Make air-traffic refresh interval configurable, resolved from plan

Add a refresh-interval control to the Map settings popover: Auto (follows
the OpenSky plan) or a fixed 15/30/60/120s. The states endpoint now
resolves a recommended interval from the resolved plan (anonymous 60s,
standard 30s, contributor 15s) and returns it alongside the aircraft, so
"Auto" tracks the plan's credit budget and update rate. Polling reschedules
live when the effective interval changes; the map caption reflects it. New
airTrafficInterval preference (default 'auto', persisted + synced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-13 22:58:19 +02:00
co-authored by Claude Opus 4.8
parent 2509fb9c07
commit 125d0ccc7d
9 changed files with 116 additions and 38 deletions
+28 -2
View File
@@ -564,8 +564,15 @@ func (s *Server) handleOpenSkyStates(w http.ResponseWriter, r *http.Request) {
who := callerFromRecord(rec)
res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"])
// The plan drives the recommended refresh cadence (its daily credit budget and
// upstream update rate). Surfaced on every response so the "Auto" interval in the
// Map settings popover resolves even before the overlay is enabled.
plan := res.eff.Plan
recInterval := recommendedOpenSkyInterval(plan)
disabled := func(detail string) {
writeJSON(w, http.StatusOK, map[string]any{"states": []osAircraft{}, "unavailable": true, "detail": detail})
writeJSON(w, http.StatusOK, map[string]any{
"states": []osAircraft{}, "unavailable": true, "detail": detail,
"plan": plan, "recommendedInterval": recInterval})
}
switch {
case !res.available:
@@ -630,7 +637,26 @@ func (s *Server) handleOpenSkyStates(w http.ResponseWriter, r *http.Request) {
}
aircraft = append(aircraft, a)
}
writeJSON(w, http.StatusOK, map[string]any{"time": osResp.Time, "states": aircraft})
writeJSON(w, http.StatusOK, map[string]any{
"time": osResp.Time, "states": aircraft,
"plan": plan, "recommendedInterval": recInterval})
}
// recommendedOpenSkyInterval maps a resolved plan to a sensible default poll
// interval (seconds) for the Live map. It balances the plan's daily credit budget
// (anonymous 400 · standard 4000 · contributor 8000) against how often OpenSky
// actually refreshes state vectors (~10s anonymous, ~5s authenticated), so the map
// stays fresh without draining credits. An unset plan resolves as standard (the
// runtime default). See planDailyCredits in the opensky plugin.
func recommendedOpenSkyInterval(plan string) int {
switch plan {
case "anonymous":
return 60
case "contributor":
return 15
default: // standard or unset
return 30
}
}
// rawFloat reads element i of an OpenSky state array as a float, reporting ok=false
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -35,8 +35,8 @@
})()
</script>
<title>PilotVault — Control Panel</title>
<script type="module" crossorigin src="./assets/index-CYlCe7LJ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Cy2Ed59q.css">
<script type="module" crossorigin src="./assets/index-BdIdzNiz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CYeDXsw9.css">
</head>
<body>
<div id="app"></div>
+11 -2
View File
@@ -168,13 +168,22 @@ export async function testOpenSky() {
// 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.
// detail?, plan, recommendedInterval } — an empty list with `unavailable` when
// OpenSky is off for the caller. `recommendedInterval` (seconds) is derived from
// the resolved plan and drives the "Auto" refresh cadence.
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 || '' }
return {
states: d.states || [],
time: d.time,
unavailable: !!d.unavailable,
detail: d.detail || '',
plan: d.plan || '',
recommendedInterval: d.recommendedInterval || 0,
}
} catch {
return { states: [], unavailable: true, detail: 'OpenSky unavailable' }
}
+51 -11
View File
@@ -27,29 +27,51 @@ 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 })
// plan + recommendedInterval are resolved server-side from the OpenSky plan.
const airspace = reactive({ unavailable: false, detail: '', loaded: false, plan: '', recommendedInterval: 30 })
const airborneCount = computed(() => aircraft.value.filter((a) => !a.onGround).length)
const mapMenu = ref(false) // "Map settings" popover open state
let airTimer = null
// User-selectable cadences; "Auto" defers to the plan-recommended interval.
const INTERVAL_OPTIONS = [
{ value: 'auto', label: 'Auto' },
{ value: 15, label: '15s' },
{ value: 30, label: '30s' },
{ value: 60, label: '60s' },
{ value: 120, label: '120s' },
]
// Effective poll interval in seconds: the plan recommendation when 'auto', else the
// chosen fixed value. Falls back to 30s if the recommendation hasn't loaded yet.
const airIntervalSeconds = computed(() => {
if (prefs.airTrafficInterval === 'auto') return airspace.recommendedInterval || 30
const n = Number(prefs.airTrafficInterval)
return Number.isFinite(n) && n > 0 ? n : 30
})
async function refreshAirspace() {
if (!prefs.showAirTraffic) return
const { states, unavailable, detail } = await getOpenSkyStates()
const { states, unavailable, detail, plan, recommendedInterval } = await getOpenSkyStates()
aircraft.value = states
airspace.unavailable = unavailable
airspace.detail = detail
airspace.plan = plan || ''
if (recommendedInterval) airspace.recommendedInterval = recommendedInterval
airspace.loaded = true
}
// Poll OpenSky only while the Overview map is on screen and the overlay is
// enabled, on a credit-friendly cadence (state vectors refresh at most every
// ~10s upstream anyway).
function startAirspace() {
if (airTimer) return
refreshAirspace()
// Poll OpenSky only while the Overview map is on screen and the overlay is enabled,
// on the resolved cadence (state vectors refresh at most every ~510s upstream).
function scheduleAirspace() {
if (airTimer) clearInterval(airTimer)
airTimer = setInterval(() => {
if (active.value === 'Overview' && prefs.showAirTraffic) refreshAirspace()
}, 30000)
}, airIntervalSeconds.value * 1000)
}
function startAirspace() {
refreshAirspace()
scheduleAirspace()
}
function stopAirspace() {
if (airTimer) clearInterval(airTimer)
@@ -296,6 +318,10 @@ watch(
},
)
// Reschedule polling whenever the effective interval changes (a manual choice, or
// the plan recommendation arriving while on "Auto").
watch(airIntervalSeconds, scheduleAirspace)
onMounted(async () => {
;(await getDevices()).forEach(upsert)
connect()
@@ -450,12 +476,26 @@ onBeforeUnmount(() => {
</button>
<template v-if="mapMenu">
<div class="fixed inset-0 z-[1190]" @click="mapMenu = false"></div>
<div class="panel absolute right-0 z-[1200] mt-1.5 w-64 p-3.5 shadow-lg">
<div class="panel absolute right-0 z-[1200] mt-1.5 w-72 p-3.5 shadow-lg">
<div class="eyebrow mb-2.5">Map settings</div>
<label class="flex items-center justify-between gap-3">
<span class="text-sm text-ink-secondary">Show live air traffic</span>
<Toggle v-model="prefs.showAirTraffic" />
</label>
<div class="mt-3.5" :class="prefs.showAirTraffic ? '' : 'pointer-events-none opacity-40'">
<div class="mb-1.5 flex items-center justify-between">
<span class="text-sm text-ink-secondary">Refresh interval</span>
<span class="font-mono text-[11px] text-ink-muted">every {{ airIntervalSeconds }}s</span>
</div>
<select v-model="prefs.airTrafficInterval" class="field">
<option v-for="o in INTERVAL_OPTIONS" :key="o.value" :value="o.value">
{{ o.label }}{{ o.value === 'auto' ? ` (plan: ${airspace.recommendedInterval}s)` : '' }}
</option>
</select>
<p v-if="airspace.plan" class="mt-1.5 text-[11px] text-ink-muted">
OpenSky plan: {{ airspace.plan }}
</p>
</div>
</div>
</template>
</div>
@@ -469,7 +509,7 @@ onBeforeUnmount(() => {
{{ 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
Live air traffic from OpenSky Network · updates every {{ airIntervalSeconds }}s
</p>
</div>
+3
View File
@@ -29,6 +29,9 @@ const defaults = {
// Live map: overlay live OpenSky air traffic (client-side toggle; the OpenSky
// integration itself is still gated in Settings → Integrations).
showAirTraffic: true,
// Air-traffic refresh cadence: 'auto' follows the plan-recommended interval
// (from the server), or a fixed number of seconds (15 | 30 | 60 | 120).
airTrafficInterval: 'auto',
// Security (prototype)
twoFactor: false,
}