Show live OpenSky air traffic on the Overview Live map

Add GET /api/integrations/opensky/states: resolves the caller's OpenSky
cascade and returns trimmed aircraft state vectors (icao24, callsign,
country, lat/lng, heading, velocity, altitude, onGround) for their bbox.
Reuses the settings gates (global master / org / personal opt-in) and is
backed by a new Manager.InvokeWith that runs the plugin's states.bbox
action on a transient instance. Proxied through the Web App BFF.

DeviceMap now overlays these as rotatable plane markers (accent when
airborne, grey on ground) with tooltips; Dashboard polls every 30s while
Overview is visible and shows an aircraft count badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-13 22:15:18 +02:00
co-authored by Claude Opus 4.8
parent 97c19083cf
commit a578555b43
11 changed files with 377 additions and 46 deletions
+158 -14
View File
@@ -70,16 +70,16 @@ type osFieldView struct {
// admin can manage each independently — their own settings as a user, and the
// organization-wide settings that override every user's.
type osResolution struct {
eff osConfig // effective (unmasked) — used only server-side (probes)
userOwn osConfig // caller's personal (L3) values (unmasked)
orgOwn osConfig // organization (L2) values (unmasked)
source map[string]string // field -> layer name (global|org|user|unset)
isSuper bool // superadmin: manages the global layer in the panel
canOrg bool // caller may edit the organization layer (org admin)
available bool // global master switch
orgEnabled bool // org master switch (default true; gates the org's users)
allowAnon bool // global anonymous policy
enabled bool // caller's personal enable flag
eff osConfig // effective (unmasked) — used only server-side (probes)
userOwn osConfig // caller's personal (L3) values (unmasked)
orgOwn osConfig // organization (L2) values (unmasked)
source map[string]string // field -> layer name (global|org|user|unset)
isSuper bool // superadmin: manages the global layer in the panel
canOrg bool // caller may edit the organization layer (org admin)
available bool // global master switch
orgEnabled bool // org master switch (default true; gates the org's users)
allowAnon bool // global anonymous policy
enabled bool // caller's personal enable flag
}
// resolveOpenSky computes the cascade for a caller. userRaw is the caller's
@@ -104,10 +104,10 @@ func (s *Server) resolveOpenSky(ctx context.Context, who *callerIdentity, userRa
uc := uStored.Config
res := osResolution{
source: map[string]string{},
userOwn: uc,
orgOwn: oc,
isSuper: who.isSuperadmin(),
source: map[string]string{},
userOwn: uc,
orgOwn: oc,
isSuper: who.isSuperadmin(),
// An org admin may edit the organization layer in addition to their own
// personal layer. Requires the service account (org writes go through it);
// without it the org layer is invisible to the cascade anyway.
@@ -526,6 +526,150 @@ func (s *Server) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"health": h})
}
// osAircraft is one trimmed aircraft state vector for the Live map. It flattens the
// positional fields the UI actually plots out of OpenSky's raw index-addressed array.
type osAircraft struct {
Icao24 string `json:"icao24"`
Callsign string `json:"callsign"`
Country string `json:"country"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Heading *float64 `json:"heading,omitempty"` // true track, degrees
Velocity *float64 `json:"velocity,omitempty"` // m/s over ground
Altitude *float64 `json:"altitude,omitempty"` // barometric, metres
OnGround bool `json:"onGround"`
}
// GET /api/integrations/opensky/states — live aircraft positions for the caller's
// resolved bounding box, for plotting on the Web App Live map. Runs server-side
// against the resolved cascade config (never returns credentials). 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 it returns 200 with an empty list plus a
// reason, so the map can degrade quietly rather than error.
func (s *Server) handleOpenSkyStates(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
rec, status, err := s.pbAuthRefresh(r.Context(), token)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
}
if status != http.StatusOK || rec == nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
who := callerFromRecord(rec)
res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"])
disabled := func(detail string) {
writeJSON(w, http.StatusOK, map[string]any{"states": []osAircraft{}, "unavailable": true, "detail": detail})
}
switch {
case !res.available:
disabled("OpenSky is disabled by the administrator")
return
case !res.orgEnabled:
disabled("OpenSky is disabled for your organization")
return
case !res.enabled:
disabled("Enable OpenSky in Settings → Integrations to show live air traffic")
return
}
cfg := map[string]string{
"clientId": res.eff.ClientID,
"clientSecret": res.eff.ClientSecret,
"plan": res.eff.Plan,
"bbox": res.eff.Bbox,
"allowAnonymous": boolStr(res.allowAnon),
}
raw, err := s.plugins.InvokeWith(r.Context(), openSkyPlugin, cfg, "states.bbox", nil)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
// OpenSky /states/all shape: {time, states: [[icao24, callsign, country,
// time_position, last_contact, lon, lat, baro_altitude, on_ground, velocity,
// true_track, ...], ...]}. states may be null when nothing is in the box.
var osResp struct {
Time int64 `json:"time"`
States [][]json.RawMessage `json:"states"`
}
if err := json.Unmarshal(raw, &osResp); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "unexpected OpenSky response"})
return
}
aircraft := make([]osAircraft, 0, len(osResp.States))
for _, st := range osResp.States {
lng, okLng := rawFloat(st, 5)
lat, okLat := rawFloat(st, 6)
if !okLat || !okLng {
continue // no position fix — nothing to plot
}
a := osAircraft{
Icao24: strings.TrimSpace(rawString(st, 0)),
Callsign: strings.TrimSpace(rawString(st, 1)),
Country: strings.TrimSpace(rawString(st, 2)),
Lat: lat,
Lng: lng,
OnGround: rawBool(st, 8),
}
if v, ok := rawFloat(st, 7); ok {
a.Altitude = &v
}
if v, ok := rawFloat(st, 9); ok {
a.Velocity = &v
}
if v, ok := rawFloat(st, 10); ok {
a.Heading = &v
}
aircraft = append(aircraft, a)
}
writeJSON(w, http.StatusOK, map[string]any{"time": osResp.Time, "states": aircraft})
}
// rawFloat reads element i of an OpenSky state array as a float, reporting ok=false
// for a missing index or a JSON null (OpenSky uses null for unknown fields).
func rawFloat(st []json.RawMessage, i int) (float64, bool) {
if i >= len(st) {
return 0, false
}
var f float64
if err := json.Unmarshal(st[i], &f); err != nil {
return 0, false
}
return f, true
}
// rawString reads element i as a string ("" for missing/null/non-string).
func rawString(st []json.RawMessage, i int) string {
if i >= len(st) {
return ""
}
var s string
if err := json.Unmarshal(st[i], &s); err != nil {
return ""
}
return s
}
// rawBool reads element i as a bool (false for missing/null/non-bool).
func rawBool(st []json.RawMessage, i int) bool {
if i >= len(st) {
return false
}
var b bool
if err := json.Unmarshal(st[i], &b); err != nil {
return false
}
return b
}
func boolStr(b bool) string {
if b {
return "true"
+1
View File
@@ -102,6 +102,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/integrations/opensky", s.handleGetOpenSky)
mux.HandleFunc("PUT /api/integrations/opensky", s.handlePutOpenSky)
mux.HandleFunc("POST /api/integrations/opensky/health", s.handleOpenSkyHealth)
mux.HandleFunc("GET /api/integrations/opensky/states", s.handleOpenSkyStates)
mux.HandleFunc("GET /api/integrations/filetransfer", s.handleGetFileTransfer)
mux.HandleFunc("PUT /api/integrations/filetransfer", s.handlePutFileTransfer)
mux.HandleFunc("POST /api/integrations/filetransfer/health", s.handleFileTransferHealth)