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:
co-authored by
Claude Opus 4.8
parent
97c19083cf
commit
a578555b43
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -339,6 +339,24 @@ func (m *Manager) HealthCheckWith(ctx context.Context, name string, cfg map[stri
|
||||
return p.HealthCheck(ctx), nil
|
||||
}
|
||||
|
||||
// InvokeWith calls a plugin action using a caller-supplied config instead of the
|
||||
// stored record. Like HealthCheckWith it always builds a transient instance, so it
|
||||
// never disturbs the live instance. Used by per-user integration flows that resolve
|
||||
// their own effective config (e.g. the OpenSky live-map states query).
|
||||
func (m *Manager) InvokeWith(ctx context.Context, name string, cfg map[string]string, action string, payload json.RawMessage) (json.RawMessage, error) {
|
||||
m.mu.Lock()
|
||||
rec := m.records[name]
|
||||
p := construct(name, m.factories[name], rec)
|
||||
m.mu.Unlock()
|
||||
|
||||
if p == nil {
|
||||
return nil, errUnknown
|
||||
}
|
||||
_ = p.Init(ctx, cfg)
|
||||
defer func() { _ = p.Shutdown(context.Background()) }()
|
||||
return p.Invoke(ctx, action, payload)
|
||||
}
|
||||
|
||||
// RawConfig returns a plugin's stored config UNMASKED, together with its enabled
|
||||
// flag and whether the plugin is known. Server-side callers use it to resolve a
|
||||
// layered effective config (which needs the real secret values); it must never be
|
||||
|
||||
Reference in New Issue
Block a user