Show current weather on the Overview

Add a weather card to the Web App Overview, wired like the OpenSky live-map
overlay and backed by the OpenWeather plugin.

- API Server: GET /api/integrations/openweather/current resolves the
  caller's cascade, gates it (master/org/personal opt-in/key), and returns
  trimmed current conditions for the configured or a supplied ?lat=&lon=
  point; off/keyless returns {unavailable, detail} so the card degrades.
- BFF relay (forwards the location override) + route; api.js client.
- Dashboard: a Weather card stacked above Schedule showing an emoji
  condition, temperature in the resolved unit, description, location, and a
  feels-like/wind/humidity/cloud grid. Location follows the same cascade as
  the map (drone -> phone -> browser -> default) without prompting for geo;
  refreshes every 10 min while on Overview.
- validLatLon test; rebuilt embedded frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-14 16:34:01 +02:00
co-authored by Claude Opus 4.8
parent 522fe450eb
commit 5960fb4806
11 changed files with 348 additions and 25 deletions
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
)
@@ -426,3 +427,126 @@ func (s *Server) handleOpenWeatherHealth(w http.ResponseWriter, r *http.Request)
}
writeJSON(w, http.StatusOK, map[string]any{"health": h})
}
// owWeather is the trimmed current-conditions shape the Overview weather card needs,
// flattened out of OpenWeather's richer /data/2.5/weather payload.
type owWeather struct {
Location string `json:"location"`
Country string `json:"country"`
Temp *float64 `json:"temp"`
FeelsLike *float64 `json:"feelsLike"`
Description string `json:"description"`
Icon string `json:"icon"` // OpenWeather icon code, e.g. "01d"
Humidity *int `json:"humidity"`
WindSpeed *float64 `json:"windSpeed"`
WindDeg *int `json:"windDeg"`
Clouds *int `json:"clouds"`
Dt int64 `json:"dt"` // observation time (unix seconds)
}
// validLatLon reports whether lat/lon are well-formed geographic coordinates.
func validLatLon(lat, lon string) bool {
la, e1 := strconv.ParseFloat(lat, 64)
lo, e2 := strconv.ParseFloat(lon, 64)
return e1 == nil && e2 == nil && la >= -90 && la <= 90 && lo >= -180 && lo <= 180
}
// GET /api/integrations/openweather/current — current conditions for the caller's
// resolved location (or a supplied ?lat=&lon= point), for the Overview weather card.
// Runs server-side against the resolved cascade config (never returns the API key).
// Gated by the same switches as the settings view: global master, org gate, and the
// caller's personal opt-in. When any gate is off (or no key resolves) it returns 200
// with {unavailable:true, detail} so the card can degrade quietly rather than error.
func (s *Server) handleOpenWeatherCurrent(w http.ResponseWriter, r *http.Request) {
who, userRaw, ok := s.integrationCaller(w, r)
if !ok {
return
}
res := s.resolveOpenWeather(r.Context(), who, userRaw)
units := res.eff.Units
if units == "" {
units = "metric" // matches the plugin's runtime fallback
}
unavailable := func(detail string) {
writeJSON(w, http.StatusOK, map[string]any{"unavailable": true, "detail": detail, "units": units})
}
switch {
case !res.available:
unavailable("OpenWeather is disabled by the administrator")
return
case !res.orgEnabled:
unavailable("OpenWeather is disabled for your organization")
return
case !res.enabled:
unavailable("Enable OpenWeather in Settings → Integrations to show weather")
return
case strings.TrimSpace(res.eff.APIKey) == "":
unavailable("No API key configured for OpenWeather")
return
}
// Optional point override (drone/device/browser location the Overview resolves).
// Malformed input is ignored so the plugin falls back to the configured default.
var payload json.RawMessage
lat := strings.TrimSpace(r.URL.Query().Get("lat"))
lon := strings.TrimSpace(r.URL.Query().Get("lon"))
if validLatLon(lat, lon) {
payload, _ = json.Marshal(map[string]string{"lat": lat, "lon": lon})
}
cfg := map[string]string{}
for _, k := range owFields {
cfg[k] = owGet(res.eff, k)
}
raw, err := s.plugins.InvokeWith(r.Context(), openWeatherPlugin, cfg, "weather.current", payload)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
// Flatten OpenWeather's /data/2.5/weather response into the card model.
var owResp struct {
Weather []struct {
Description string `json:"description"`
Icon string `json:"icon"`
} `json:"weather"`
Main struct {
Temp *float64 `json:"temp"`
FeelsLike *float64 `json:"feels_like"`
Humidity *int `json:"humidity"`
} `json:"main"`
Wind struct {
Speed *float64 `json:"speed"`
Deg *int `json:"deg"`
} `json:"wind"`
Clouds struct {
All *int `json:"all"`
} `json:"clouds"`
Dt int64 `json:"dt"`
Name string `json:"name"`
Sys struct {
Country string `json:"country"`
} `json:"sys"`
}
if err := json.Unmarshal(raw, &owResp); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "unexpected OpenWeather response"})
return
}
weather := owWeather{
Location: owResp.Name,
Country: owResp.Sys.Country,
Temp: owResp.Main.Temp,
FeelsLike: owResp.Main.FeelsLike,
Humidity: owResp.Main.Humidity,
WindSpeed: owResp.Wind.Speed,
WindDeg: owResp.Wind.Deg,
Clouds: owResp.Clouds.All,
Dt: owResp.Dt,
}
if len(owResp.Weather) > 0 {
weather.Description = owResp.Weather[0].Description
weather.Icon = owResp.Weather[0].Icon
}
writeJSON(w, http.StatusOK, map[string]any{"weather": weather, "units": units})
}
@@ -98,6 +98,21 @@ func TestOpenWeatherViewMasksKey(t *testing.T) {
}
}
func TestValidLatLon(t *testing.T) {
ok := [][2]string{{"0", "0"}, {"52.2297", "21.0122"}, {"-90", "180"}, {"90", "-180"}}
for _, c := range ok {
if !validLatLon(c[0], c[1]) {
t.Errorf("validLatLon(%q,%q) = false, want true", c[0], c[1])
}
}
bad := [][2]string{{"", ""}, {"91", "0"}, {"0", "181"}, {"-91", "0"}, {"abc", "0"}, {"0", "x"}}
for _, c := range bad {
if validLatLon(c[0], c[1]) {
t.Errorf("validLatLon(%q,%q) = true, want false", c[0], c[1])
}
}
}
// mergeOpenWeather must preserve sibling plugin keys (opensky/webdav) untouched.
func TestMergeOpenWeatherPreservesSiblings(t *testing.T) {
existing := json.RawMessage(`{"opensky":{"enabled":true},"webdav":{"config":{"baseURL":"https://x"}}}`)
+1
View File
@@ -115,6 +115,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/integrations/openweather", s.handleGetOpenWeather)
mux.HandleFunc("PUT /api/integrations/openweather", s.handlePutOpenWeather)
mux.HandleFunc("POST /api/integrations/openweather/health", s.handleOpenWeatherHealth)
mux.HandleFunc("GET /api/integrations/openweather/current", s.handleOpenWeatherCurrent)
// User-management — gated on the caller being a manager (admin or superadmin).
// Admins are scoped to their own organization inside each handler.
+12
View File
@@ -344,6 +344,18 @@ func (a *App) handleOpenWeatherHealth(w http.ResponseWriter, r *http.Request) {
a.doRelay(w, req)
}
// GET /bff/integrations/openweather/current → API Server current conditions for the
// Overview weather card. Forwards the optional ?lat=&lon= location override.
func (a *App) handleOpenWeatherCurrent(w http.ResponseWriter, r *http.Request) {
target := a.apiBaseFor(r) + "/api/integrations/openweather/current"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
req, _ := http.NewRequest(http.MethodGet, target, nil)
req.Header.Set("Authorization", tokenOf(r))
a.doRelay(w, req)
}
// GET /bff/users → API Server /api/users (admin only, enforced upstream)
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/users", nil)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -35,8 +35,8 @@
})()
</script>
<title>PilotVault — Control Panel</title>
<script type="module" crossorigin src="./assets/index-B-DwId4e.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Dr5W1RO_.css">
<script type="module" crossorigin src="./assets/index-DWe3LEIB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-buxVzOVb.css">
</head>
<body>
<div id="app"></div>
+1
View File
@@ -66,6 +66,7 @@ func main() {
mux.HandleFunc("GET /bff/integrations/openweather", app.requireAuth(app.handleGetOpenWeather))
mux.HandleFunc("PUT /bff/integrations/openweather", app.requireAuth(app.handlePutOpenWeather))
mux.HandleFunc("POST /bff/integrations/openweather/health", app.requireAuth(app.handleOpenWeatherHealth))
mux.HandleFunc("GET /bff/integrations/openweather/current", app.requireAuth(app.handleOpenWeatherCurrent))
// User-management (role + org scoping enforced by the API Server)
mux.HandleFunc("GET /bff/users", app.requireAuth(app.handleListUsers))
mux.HandleFunc("POST /bff/users", app.requireAuth(app.handleCreateUser))
+14
View File
@@ -310,6 +310,20 @@ export async function testOpenWeather() {
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// Current conditions for the Overview weather card. lat/lon optionally override the
// configured location (drone/device/browser point). Returns {weather, units} when
// available, or {unavailable:true, detail} when OpenWeather is off for the caller.
export async function getOpenWeatherCurrent(lat, lon) {
try {
const qs = lat != null && lon != null ? `?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}` : ''
const r = await fetch(`/bff/integrations/openweather/current${qs}`)
if (!r.ok) return { unavailable: true, detail: 'Weather unavailable' }
return await r.json()
} catch {
return { unavailable: true, detail: 'Weather unavailable' }
}
}
/* ---------- Logbook: drones ---------- */
export async function getDrones() {
+158 -2
View File
@@ -7,7 +7,7 @@ import Settings from './Settings.vue'
import Logbook from './Logbook.vue'
import Documents from './Documents.vue'
import Toggle from './settings/Toggle.vue'
import { getDevices, sendCommand, getOpenSkyStates } from '../api.js'
import { getDevices, sendCommand, getOpenSkyStates, getOpenWeatherCurrent } from '../api.js'
import { formatTime, prefs } from '../prefs.js'
import { countryForPoint, bboxForCountry } from '../countries.js'
@@ -144,6 +144,94 @@ function stopAirspace() {
airTimer = null
}
/* ---------- OpenWeather current conditions (Overview weather card) ---------- */
// { loaded, unavailable, detail, data: owWeather|null, units, source, updatedAt }
const weather = reactive({ loaded: false, unavailable: false, detail: '', data: null, units: 'metric', source: '', updatedAt: 0 })
let weatherTimer = null
// Weather changes slowly and OpenWeather refreshes roughly every 10 min upstream,
// so poll gently to respect the API quota.
const WEATHER_INTERVAL_MS = 10 * 60 * 1000
// Resolve the point to fetch weather for, reusing the map's location cascade but
// as a single lat/lng: drone GPS → phone GPS → already-granted browser geolocation.
// Returns null when none is known, so the server uses its configured default. Does
// not prompt for geolocation itself (only reuses a point the map already obtained).
function resolveWeatherPoint() {
const drone =
devicePoint(sel.value, 'latitude', 'longitude') ||
ids.value.map((id) => devicePoint(devices[id], 'latitude', 'longitude')).find(Boolean)
if (drone) return { lat: drone.lat, lng: drone.lng, source: 'drone' }
const phone =
devicePoint(sel.value, 'phoneLatitude', 'phoneLongitude') ||
ids.value.map((id) => devicePoint(devices[id], 'phoneLatitude', 'phoneLongitude')).find(Boolean)
if (phone) return { lat: phone.lat, lng: phone.lng, source: 'phone' }
if (browserGeo.value) return { lat: browserGeo.value.lat, lng: browserGeo.value.lng, source: 'browser' }
return null
}
async function refreshWeather() {
const p = resolveWeatherPoint()
const res = await getOpenWeatherCurrent(p ? p.lat : undefined, p ? p.lng : undefined)
weather.loaded = true
weather.units = res.units || 'metric'
if (res.unavailable || !res.weather) {
weather.unavailable = true
weather.detail = res.detail || 'Weather is unavailable.'
weather.data = null
return
}
weather.unavailable = false
weather.detail = ''
weather.data = res.weather
weather.source = p ? p.source : 'default'
weather.updatedAt = Date.now()
}
function scheduleWeather() {
if (weatherTimer) clearInterval(weatherTimer)
weatherTimer = setInterval(() => {
if (active.value === 'Overview') refreshWeather()
}, WEATHER_INTERVAL_MS)
}
function startWeather() {
refreshWeather()
scheduleWeather()
}
function stopWeather() {
if (weatherTimer) clearInterval(weatherTimer)
weatherTimer = null
}
// Temperature/wind units follow the resolved OpenWeather "units" setting.
const tempUnit = computed(() => (weather.units === 'imperial' ? '°F' : weather.units === 'standard' ? 'K' : '°C'))
const windUnit = computed(() => (weather.units === 'imperial' ? 'mph' : 'm/s'))
// Map an OpenWeather icon code (e.g. "01d", "10n") to an emoji, so the card needs
// no external image and works offline.
function wxEmoji(icon) {
const c = (icon || '').slice(0, 2)
if (c === '01') return (icon || '').endsWith('n') ? '🌙' : '☀️'
return { '02': '🌤️', '03': '⛅', '04': '☁️', '09': '🌧️', '10': '🌦️', '11': '⛈️', '13': '❄️', '50': '🌫️' }[c] || '🌡️'
}
const weatherEmoji = computed(() => wxEmoji(weather.data && weather.data.icon))
const weatherLocation = computed(() => {
const d = weather.data
if (!d) return ''
return d.country ? `${d.location}, ${d.country}` : d.location || 'Unknown location'
})
const weatherSourceLabel = computed(() => {
if (weather.source === 'drone') return 'at aircraft location'
if (weather.source === 'phone' || weather.source === 'browser') return 'at your location'
return 'default location'
})
const weatherUpdated = computed(() =>
weather.updatedAt ? new Date(weather.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '',
)
// Round a nullable numeric field for display, or "—" when absent.
function wxNum(v, digits = 0) {
return typeof v === 'number' ? v.toFixed(digits) : '—'
}
const active = ref('Overview')
const NAV = [
['grid', 'Overview'],
@@ -371,7 +459,10 @@ function track(id) {
// Re-fetch air traffic immediately when the operator returns to the Overview tab,
// so the map isn't stale for up to a poll interval.
watch(active, (v) => {
if (v === 'Overview') refreshAirspace()
if (v === 'Overview') {
refreshAirspace()
refreshWeather()
}
})
// React to the "Show live air traffic" map toggle: fetch at once when enabled,
@@ -392,12 +483,14 @@ onMounted(async () => {
;(await getDevices()).forEach(upsert)
connect()
startAirspace()
startWeather()
})
onBeforeUnmount(() => {
stopped = true
if (retry) clearTimeout(retry)
if (ws) ws.close()
stopAirspace()
stopWeather()
})
</script>
@@ -579,6 +672,68 @@ onBeforeUnmount(() => {
</p>
</div>
<div class="flex flex-col gap-5">
<!-- weather -->
<div class="panel p-5">
<div class="mb-3.5 flex items-center justify-between">
<div>
<div class="eyebrow">Conditions</div>
<div class="mt-0.5 text-base font-semibold text-ink">Weather</div>
</div>
<Icon name="sun" :size="16" class="text-ink-muted" />
</div>
<!-- current conditions -->
<template v-if="weather.data">
<div class="flex items-center gap-3">
<div class="text-5xl leading-none">{{ weatherEmoji }}</div>
<div class="min-w-0">
<div class="flex items-baseline gap-1">
<span class="text-[34px] font-bold leading-none tracking-tightest text-ink">{{ wxNum(weather.data.temp) }}</span>
<span class="text-lg font-semibold text-ink-secondary">{{ tempUnit }}</span>
</div>
<div class="mt-1 truncate text-sm capitalize text-ink-secondary">{{ weather.data.description || '—' }}</div>
</div>
</div>
<div class="mt-1.5 truncate text-xs text-ink-muted">{{ weatherLocation }} · {{ weatherSourceLabel }}</div>
<div class="mt-4 grid grid-cols-2 gap-2.5">
<div class="rounded-lg bg-surface-2 px-3 py-2">
<div class="eyebrow">Feels like</div>
<div class="mt-0.5 font-mono text-sm text-ink">{{ wxNum(weather.data.feelsLike) }}{{ tempUnit }}</div>
</div>
<div class="rounded-lg bg-surface-2 px-3 py-2">
<div class="eyebrow">Wind</div>
<div class="mt-0.5 font-mono text-sm text-ink">{{ wxNum(weather.data.windSpeed, 1) }} {{ windUnit }}</div>
</div>
<div class="rounded-lg bg-surface-2 px-3 py-2">
<div class="eyebrow">Humidity</div>
<div class="mt-0.5 font-mono text-sm text-ink">{{ wxNum(weather.data.humidity) }}<span v-if="weather.data.humidity != null">%</span></div>
</div>
<div class="rounded-lg bg-surface-2 px-3 py-2">
<div class="eyebrow">Cloud cover</div>
<div class="mt-0.5 font-mono text-sm text-ink">{{ wxNum(weather.data.clouds) }}<span v-if="weather.data.clouds != null">%</span></div>
</div>
</div>
<div v-if="weatherUpdated" class="mt-3 text-[11px] text-ink-muted">Updated {{ weatherUpdated }} · OpenWeather</div>
</template>
<!-- unavailable (off / not enabled / no key) -->
<div v-else-if="weather.loaded && weather.unavailable" class="grid place-items-center py-8 text-center">
<Icon name="sun" :size="24" class="text-ink-muted" />
<div class="mt-2 text-sm font-medium text-ink-secondary">Weather unavailable</div>
<div class="mt-0.5 text-xs text-ink-muted">{{ weather.detail }}</div>
</div>
<!-- loading -->
<div v-else class="grid place-items-center py-8 text-center text-sm text-ink-muted">
Loading weather
</div>
</div>
<!-- schedule -->
<div class="panel p-5">
<div class="mb-3.5 flex items-center justify-between">
<div>
@@ -593,6 +748,7 @@ onBeforeUnmount(() => {
<div class="mt-0.5 text-xs text-ink-muted">Scheduling is not wired to a backend yet.</div>
</div>
</div>
</div>
</div>
<!-- fleet table -->