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:
co-authored by
Claude Opus 4.8
parent
522fe450eb
commit
5960fb4806
@@ -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"}}}`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
-20
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user