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
@@ -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
|
||||
|
||||
@@ -234,6 +234,14 @@ func (a *App) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) {
|
||||
a.doRelay(w, req)
|
||||
}
|
||||
|
||||
// GET /bff/integrations/opensky/states → API Server /api/integrations/opensky/states.
|
||||
// Live aircraft positions for the Live map.
|
||||
func (a *App) handleOpenSkyStates(w http.ResponseWriter, r *http.Request) {
|
||||
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/opensky/states", nil)
|
||||
req.Header.Set("Authorization", tokenOf(r))
|
||||
a.doRelay(w, req)
|
||||
}
|
||||
|
||||
// GET /bff/integrations/filetransfer → API Server /api/integrations/filetransfer
|
||||
func (a *App) handleGetFileTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/filetransfer", 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
@@ -35,7 +35,7 @@
|
||||
})()
|
||||
</script>
|
||||
<title>PilotVault — Control Panel</title>
|
||||
<script type="module" crossorigin src="./assets/index-By3vEu-b.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-uk1cBykG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CoIRbg0g.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -50,6 +50,7 @@ func main() {
|
||||
mux.HandleFunc("GET /bff/integrations/opensky", app.requireAuth(app.handleGetOpenSky))
|
||||
mux.HandleFunc("PUT /bff/integrations/opensky", app.requireAuth(app.handlePutOpenSky))
|
||||
mux.HandleFunc("POST /bff/integrations/opensky/health", app.requireAuth(app.handleOpenSkyHealth))
|
||||
mux.HandleFunc("GET /bff/integrations/opensky/states", app.requireAuth(app.handleOpenSkyStates))
|
||||
// Plugin integrations (File transfer: FTP/SFTP) — per-user/per-org settings
|
||||
mux.HandleFunc("GET /bff/integrations/filetransfer", app.requireAuth(app.handleGetFileTransfer))
|
||||
mux.HandleFunc("PUT /bff/integrations/filetransfer", app.requireAuth(app.handlePutFileTransfer))
|
||||
|
||||
@@ -166,6 +166,20 @@ export async function testOpenSky() {
|
||||
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
|
||||
}
|
||||
|
||||
// Live aircraft positions (OpenSky state vectors) within the caller's resolved
|
||||
// bounding box, for plotting on the Live map. Returns { states, unavailable?,
|
||||
// detail? } — an empty list with `unavailable` when OpenSky is off for the caller.
|
||||
export async function getOpenSkyStates() {
|
||||
try {
|
||||
const r = await fetch('/bff/integrations/opensky/states')
|
||||
if (!r.ok) return { states: [], unavailable: true, detail: 'OpenSky unavailable' }
|
||||
const d = await r.json()
|
||||
return { states: d.states || [], time: d.time, unavailable: !!d.unavailable, detail: d.detail || '' }
|
||||
} catch {
|
||||
return { states: [], unavailable: true, detail: 'OpenSky unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Plugin integrations: File transfer (FTP/SFTP) ---------- */
|
||||
|
||||
// Resolved file-transfer settings for the current user (cascade + masked secrets).
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import DeviceMap from './DeviceMap.vue'
|
||||
import BrandMark from './BrandMark.vue'
|
||||
import Icon from './Icon.vue'
|
||||
import Settings from './Settings.vue'
|
||||
import Logbook from './Logbook.vue'
|
||||
import Documents from './Documents.vue'
|
||||
import { getDevices, sendCommand } from '../api.js'
|
||||
import { getDevices, sendCommand, getOpenSkyStates } from '../api.js'
|
||||
import { formatTime } from '../prefs.js'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -24,6 +24,34 @@ const live = ref(false)
|
||||
const log = reactive([])
|
||||
const search = ref('')
|
||||
|
||||
/* ---------- OpenSky live air traffic (Overview map overlay) ---------- */
|
||||
const aircraft = ref([]) // [{ icao24, callsign, lat, lng, heading, velocity, altitude, onGround }]
|
||||
const airspace = reactive({ unavailable: false, detail: '', loaded: false })
|
||||
const airborneCount = computed(() => aircraft.value.filter((a) => !a.onGround).length)
|
||||
let airTimer = null
|
||||
|
||||
async function refreshAirspace() {
|
||||
const { states, unavailable, detail } = await getOpenSkyStates()
|
||||
aircraft.value = states
|
||||
airspace.unavailable = unavailable
|
||||
airspace.detail = detail
|
||||
airspace.loaded = true
|
||||
}
|
||||
|
||||
// Poll OpenSky only while the Overview map is on screen, on a credit-friendly
|
||||
// cadence (state vectors refresh at most every ~10s upstream anyway).
|
||||
function startAirspace() {
|
||||
if (airTimer) return
|
||||
refreshAirspace()
|
||||
airTimer = setInterval(() => {
|
||||
if (active.value === 'Overview') refreshAirspace()
|
||||
}, 30000)
|
||||
}
|
||||
function stopAirspace() {
|
||||
if (airTimer) clearInterval(airTimer)
|
||||
airTimer = null
|
||||
}
|
||||
|
||||
const active = ref('Overview')
|
||||
const NAV = [
|
||||
['grid', 'Overview'],
|
||||
@@ -248,14 +276,22 @@ function track(id) {
|
||||
active.value = 'Live flights'
|
||||
}
|
||||
|
||||
// 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()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
;(await getDevices()).forEach(upsert)
|
||||
connect()
|
||||
startAirspace()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
stopped = true
|
||||
if (retry) clearTimeout(retry)
|
||||
if (ws) ws.close()
|
||||
stopAirspace()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -370,15 +406,31 @@ onBeforeUnmount(() => {
|
||||
<div class="eyebrow">Airspace</div>
|
||||
<div class="mt-0.5 text-base font-semibold text-ink">Live map</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="airborneCount"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||||
:class="badgeClass.accent"
|
||||
title="Live aircraft from OpenSky Network"
|
||||
>
|
||||
<Icon name="radio" :size="12" />{{ airborneCount }} aircraft
|
||||
</span>
|
||||
<span
|
||||
v-if="flyingCount"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||||
:class="badgeClass.success"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ flyingCount }} airborne
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ flyingCount }} drones
|
||||
</span>
|
||||
</div>
|
||||
<DeviceMap :position="position" :trail="trail" />
|
||||
</div>
|
||||
<DeviceMap :position="position" :trail="trail" :aircraft="aircraft" />
|
||||
<p v-if="airspace.loaded && airspace.unavailable" class="mt-2.5 text-xs text-ink-muted">
|
||||
{{ airspace.detail || 'Live air traffic is unavailable.' }}
|
||||
</p>
|
||||
<p v-else class="mt-2.5 text-xs text-ink-muted">
|
||||
Live air traffic from OpenSky Network · updates every 30s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel p-5">
|
||||
|
||||
@@ -1,14 +1,76 @@
|
||||
<script setup>
|
||||
import { onMounted, watch, ref } from 'vue'
|
||||
import { onMounted, onBeforeUnmount, watch, ref } from 'vue'
|
||||
import L from 'leaflet'
|
||||
|
||||
const props = defineProps({
|
||||
position: { type: Object, default: null }, // { lat, lng }
|
||||
trail: { type: Array, default: () => [] },
|
||||
// Live OpenSky aircraft to overlay: [{ icao24, callsign, lat, lng, heading,
|
||||
// velocity, altitude, onGround, country }, ...].
|
||||
aircraft: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const el = ref(null)
|
||||
let map, marker, line
|
||||
let planeLayer // L.LayerGroup holding all aircraft markers
|
||||
const planes = new Map() // icao24 -> L.Marker (reused across refreshes)
|
||||
|
||||
// A small rotatable plane glyph rendered as a divIcon so it can be coloured via
|
||||
// the app accent and pointed along each aircraft's true track.
|
||||
function planeIcon(heading, onGround) {
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#3D7BF0'
|
||||
const color = onGround ? '#8a94a6' : accent
|
||||
const rot = typeof heading === 'number' ? heading : 0
|
||||
return L.divIcon({
|
||||
className: 'plane-marker',
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11],
|
||||
html:
|
||||
`<svg viewBox="0 0 24 24" width="22" height="22" style="transform:rotate(${rot}deg)">` +
|
||||
`<path fill="${color}" stroke="rgba(0,0,0,.35)" stroke-width="0.5" ` +
|
||||
`d="M12 2l1.4 6.9 7.6 4.3-.1 1.6-7.2-1.9-.4 4.8 2.1 1.5-.1 1.3L12 21l-3.3-.6-.1-1.3 2.1-1.5-.4-4.8-7.2 1.9-.1-1.6 7.6-4.3z"/>` +
|
||||
`</svg>`,
|
||||
})
|
||||
}
|
||||
|
||||
function aircraftLabel(a) {
|
||||
const name = a.callsign || a.icao24 || 'aircraft'
|
||||
const bits = [`<strong>${name}</strong>`]
|
||||
if (a.country) bits.push(a.country)
|
||||
if (typeof a.altitude === 'number') bits.push(`${Math.round(a.altitude)} m`)
|
||||
if (typeof a.velocity === 'number') bits.push(`${Math.round(a.velocity * 3.6)} km/h`)
|
||||
if (a.onGround) bits.push('on ground')
|
||||
return bits.join(' · ')
|
||||
}
|
||||
|
||||
function drawAircraft() {
|
||||
if (!map) return
|
||||
if (!planeLayer) planeLayer = L.layerGroup().addTo(map)
|
||||
const seen = new Set()
|
||||
for (const a of props.aircraft) {
|
||||
if (typeof a.lat !== 'number' || typeof a.lng !== 'number') continue
|
||||
seen.add(a.icao24)
|
||||
const pos = [a.lat, a.lng]
|
||||
let m = planes.get(a.icao24)
|
||||
if (!m) {
|
||||
m = L.marker(pos, { icon: planeIcon(a.heading, a.onGround) }).bindTooltip(aircraftLabel(a))
|
||||
m.addTo(planeLayer)
|
||||
planes.set(a.icao24, m)
|
||||
} else {
|
||||
m.setLatLng(pos)
|
||||
m.setIcon(planeIcon(a.heading, a.onGround))
|
||||
m.setTooltipContent(aircraftLabel(a))
|
||||
}
|
||||
}
|
||||
// Drop aircraft that have left the box since the last refresh.
|
||||
for (const [id, m] of planes) {
|
||||
if (!seen.has(id)) {
|
||||
planeLayer.removeLayer(m)
|
||||
planes.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!map) return
|
||||
@@ -38,10 +100,41 @@ onMounted(() => {
|
||||
}).addTo(map)
|
||||
setTimeout(() => map.invalidateSize(), 60)
|
||||
draw()
|
||||
drawAircraft()
|
||||
// With no tracked device to zoom to, frame the live-traffic box on first data.
|
||||
if ((!props.position || (!props.position.lat && !props.position.lng)) && props.aircraft.length) {
|
||||
fitAircraft()
|
||||
}
|
||||
})
|
||||
|
||||
// Fit the view to the current aircraft cloud (used when there is no device fix).
|
||||
let didFit = false
|
||||
function fitAircraft() {
|
||||
if (didFit || !map || !props.aircraft.length) return
|
||||
const pts = props.aircraft
|
||||
.filter((a) => typeof a.lat === 'number' && typeof a.lng === 'number')
|
||||
.map((a) => [a.lat, a.lng])
|
||||
if (pts.length) {
|
||||
map.fitBounds(L.latLngBounds(pts).pad(0.2))
|
||||
didFit = true
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (map) map.remove()
|
||||
map = null
|
||||
})
|
||||
|
||||
watch(() => props.position, draw, { deep: true })
|
||||
watch(() => props.trail, draw, { deep: true })
|
||||
watch(
|
||||
() => props.aircraft,
|
||||
() => {
|
||||
drawAircraft()
|
||||
if (!props.position || (!props.position.lat && !props.position.lng)) fitAircraft()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user