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.
|
||||
|
||||
Reference in New Issue
Block a user