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>
788 lines
33 KiB
Vue
788 lines
33 KiB
Vue
<script setup>
|
||
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 Toggle from './settings/Toggle.vue'
|
||
import { getDevices, sendCommand, getOpenSkyStates } from '../api.js'
|
||
import { formatTime, prefs } from '../prefs.js'
|
||
|
||
const props = defineProps({
|
||
email: { type: String, default: '' },
|
||
role: { type: String, default: 'user' },
|
||
organization: { type: String, default: '' },
|
||
organizationName: { type: String, default: '' },
|
||
})
|
||
const emit = defineEmits(['logout'])
|
||
|
||
const devices = reactive({}) // deviceId -> DeviceState
|
||
const trails = reactive({}) // deviceId -> [[lat,lng], ...]
|
||
const selectedId = ref(null)
|
||
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 }]
|
||
// 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, 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 the resolved cadence (state vectors refresh at most every ~5–10s upstream).
|
||
function scheduleAirspace() {
|
||
if (airTimer) clearInterval(airTimer)
|
||
airTimer = setInterval(() => {
|
||
if (active.value === 'Overview' && prefs.showAirTraffic) refreshAirspace()
|
||
}, airIntervalSeconds.value * 1000)
|
||
}
|
||
function startAirspace() {
|
||
refreshAirspace()
|
||
scheduleAirspace()
|
||
}
|
||
function stopAirspace() {
|
||
if (airTimer) clearInterval(airTimer)
|
||
airTimer = null
|
||
}
|
||
|
||
const active = ref('Overview')
|
||
const NAV = [
|
||
['grid', 'Overview'],
|
||
['radio', 'Live flights'],
|
||
['route', 'Routes'],
|
||
['calendar', 'Schedule'],
|
||
['book', 'Logbook'],
|
||
['fileText', 'Documents'],
|
||
['server', 'Drives'],
|
||
['settings', 'Settings'],
|
||
]
|
||
// Icon for the currently active section (drives the shared placeholder header).
|
||
const activeIcon = computed(() => (NAV.find(([, l]) => l === active.value) || ['grid'])[0])
|
||
|
||
const cmdName = ref('')
|
||
const cmdPayload = ref('')
|
||
const cmdResult = ref('')
|
||
|
||
let ws = null
|
||
let retry = null
|
||
let stopped = false
|
||
|
||
const ids = computed(() =>
|
||
Object.keys(devices).sort(
|
||
(a, b) => (devices[b].online ? 1 : 0) - (devices[a].online ? 1 : 0) || a.localeCompare(b),
|
||
),
|
||
)
|
||
const sel = computed(() => (selectedId.value ? devices[selectedId.value] : null))
|
||
const tel = computed(() => (sel.value && sel.value.telemetry) || {})
|
||
const online = computed(() => !!(sel.value && sel.value.online))
|
||
const position = computed(() => {
|
||
const t = tel.value
|
||
return typeof t.latitude === 'number' && typeof t.longitude === 'number' && (t.latitude || t.longitude)
|
||
? { lat: t.latitude, lng: t.longitude }
|
||
: null
|
||
})
|
||
const trail = computed(() => (selectedId.value && trails[selectedId.value]) || [])
|
||
|
||
const hSpeed = computed(() => {
|
||
const t = tel.value
|
||
return typeof t.velocityX === 'number' && typeof t.velocityY === 'number'
|
||
? Math.hypot(t.velocityX, t.velocityY)
|
||
: null
|
||
})
|
||
|
||
/* ---------- fleet-level derived state (drives Overview) ---------- */
|
||
|
||
// Per-device presentation status → [label, tone].
|
||
function statusOf(d) {
|
||
if (!d.online) return ['Offline', 'neutral']
|
||
if (d.connected) return ['In flight', 'success']
|
||
return ['Standby', 'accent']
|
||
}
|
||
function speedOf(d) {
|
||
const t = (d && d.telemetry) || {}
|
||
return typeof t.velocityX === 'number' && typeof t.velocityY === 'number'
|
||
? Math.hypot(t.velocityX, t.velocityY)
|
||
: null
|
||
}
|
||
|
||
const fleet = computed(() =>
|
||
ids.value.map((id) => {
|
||
const d = devices[id]
|
||
const t = d.telemetry || {}
|
||
const [status, tone] = statusOf(d)
|
||
return {
|
||
id,
|
||
mission: d.model || (d.connected ? 'Drone linked' : d.online ? 'App online' : 'No signal'),
|
||
status,
|
||
tone,
|
||
alt: typeof t.altitude === 'number' ? t.altitude.toFixed(0) + ' m' : '—',
|
||
battery: typeof t.batteryPercent === 'number' ? t.batteryPercent : null,
|
||
speed: speedOf(d),
|
||
}
|
||
}),
|
||
)
|
||
|
||
const onlineCount = computed(() => ids.value.filter((id) => devices[id].online).length)
|
||
const flyingCount = computed(() =>
|
||
ids.value.filter((id) => devices[id].online && devices[id].connected).length,
|
||
)
|
||
const offlineCount = computed(() => ids.value.filter((id) => !devices[id].online).length)
|
||
const avgBattery = computed(() => {
|
||
const vals = ids.value
|
||
.map((id) => devices[id].telemetry?.batteryPercent)
|
||
.filter((v) => typeof v === 'number')
|
||
if (!vals.length) return null
|
||
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)
|
||
})
|
||
|
||
const stats = computed(() => [
|
||
{
|
||
label: 'Active flights',
|
||
value: String(flyingCount.value),
|
||
delta: `${onlineCount.value} online`,
|
||
tone: 'success',
|
||
icon: 'radio',
|
||
},
|
||
{
|
||
label: 'Avg battery',
|
||
value: avgBattery.value == null ? '—' : avgBattery.value + '%',
|
||
delta: avgBattery.value == null ? 'no telemetry' : avgBattery.value < 40 ? 'low — watch' : 'nominal',
|
||
tone: avgBattery.value != null && avgBattery.value < 40 ? 'danger' : 'neutral',
|
||
icon: 'battery',
|
||
},
|
||
{
|
||
label: 'Fleet size',
|
||
value: String(ids.value.length),
|
||
delta: `${flyingCount.value} in flight`,
|
||
tone: 'neutral',
|
||
icon: 'grid',
|
||
},
|
||
{
|
||
label: 'Offline',
|
||
value: String(offlineCount.value),
|
||
delta: offlineCount.value ? 'needs attention' : 'all reachable',
|
||
tone: offlineCount.value ? 'warning' : 'success',
|
||
icon: 'signal',
|
||
},
|
||
])
|
||
|
||
const badgeClass = {
|
||
success: 'bg-success-soft text-success-fg',
|
||
warning: 'bg-amber-soft text-amber-fg',
|
||
danger: 'bg-danger-soft text-danger-fg',
|
||
accent: 'bg-accent-soft text-accent-soft-fg',
|
||
neutral: 'bg-surface-2 text-ink-secondary',
|
||
}
|
||
const deltaClass = {
|
||
success: 'text-success-fg',
|
||
danger: 'text-danger-fg',
|
||
warning: 'text-amber-fg',
|
||
neutral: 'text-ink-muted',
|
||
accent: 'text-accent-soft-fg',
|
||
}
|
||
|
||
const initials = computed(() => {
|
||
const base = (props.email || 'PV').split('@')[0]
|
||
const parts = base.split(/[.\-_ ]+/).filter(Boolean)
|
||
return ((parts[0]?.[0] || 'P') + (parts[1]?.[0] || parts[0]?.[1] || 'V')).toUpperCase()
|
||
})
|
||
|
||
/* current user's role + organization, shown in the sidebar identity card */
|
||
const ROLE_LABEL = { superadmin: 'Superadmin', admin: 'Admin', user: 'Operator' }
|
||
const roleLabel = computed(() => ROLE_LABEL[props.role] || 'Operator')
|
||
const orgLabel = computed(
|
||
() => props.organizationName || (props.role === 'superadmin' ? 'All organizations' : 'No organization'),
|
||
)
|
||
|
||
/* ---------- realtime plumbing ---------- */
|
||
|
||
function upsert(d) {
|
||
devices[d.deviceId] = d
|
||
const t = d.telemetry || {}
|
||
if (typeof t.latitude === 'number' && typeof t.longitude === 'number' && (t.latitude || t.longitude)) {
|
||
if (!trails[d.deviceId]) trails[d.deviceId] = []
|
||
trails[d.deviceId].push([t.latitude, t.longitude])
|
||
if (trails[d.deviceId].length > 1000) trails[d.deviceId].shift()
|
||
}
|
||
if (!selectedId.value || (d.online && !devices[selectedId.value]?.online)) selectedId.value = d.deviceId
|
||
}
|
||
function remove(id) {
|
||
delete devices[id]
|
||
delete trails[id]
|
||
if (selectedId.value === id) selectedId.value = ids.value[0] || null
|
||
}
|
||
function pushLog(ev) {
|
||
log.unshift({ t: formatTime(Date.now()), tag: ev.type || '?', text: JSON.stringify(strip(ev)) })
|
||
if (log.length > 200) log.pop()
|
||
}
|
||
function strip(ev) {
|
||
const c = { ...ev }
|
||
delete c.type
|
||
return c
|
||
}
|
||
|
||
function connect() {
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||
ws = new WebSocket(`${proto}://${location.host}/bff/ws`)
|
||
ws.onopen = () => (live.value = true)
|
||
ws.onclose = () => {
|
||
live.value = false
|
||
if (!stopped) retry = setTimeout(connect, 1500)
|
||
}
|
||
ws.onerror = () => ws && ws.close()
|
||
ws.onmessage = (e) => {
|
||
let m
|
||
try {
|
||
m = JSON.parse(e.data)
|
||
} catch {
|
||
return
|
||
}
|
||
if (m.type === 'snapshot') (m.devices || []).forEach(upsert)
|
||
else if (m.type === 'update' && m.device) {
|
||
upsert(m.device)
|
||
if (m.event && m.device.deviceId === selectedId.value) pushLog(m.event)
|
||
} else if (m.type === 'removed' && m.deviceId) remove(m.deviceId)
|
||
}
|
||
}
|
||
|
||
async function doCommand() {
|
||
if (!selectedId.value) return (cmdResult.value = 'No device selected.')
|
||
if (!cmdName.value.trim()) return (cmdResult.value = 'Enter a command name.')
|
||
let payload
|
||
if (cmdPayload.value.trim()) {
|
||
try {
|
||
payload = JSON.parse(cmdPayload.value)
|
||
} catch {
|
||
return (cmdResult.value = 'Payload is not valid JSON.')
|
||
}
|
||
}
|
||
const { ok, body } = await sendCommand(selectedId.value, cmdName.value.trim(), payload)
|
||
cmdResult.value = ok ? `Sent "${cmdName.value.trim()}".` : `Error: ${body.error || 'failed'}`
|
||
}
|
||
|
||
function fmt(v, d, s = '') {
|
||
return typeof v === 'number' ? v.toFixed(d) + s : '—'
|
||
}
|
||
|
||
function track(id) {
|
||
selectedId.value = 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()
|
||
})
|
||
|
||
// React to the "Show live air traffic" map toggle: fetch at once when enabled,
|
||
// clear the overlay (and pause polling) when disabled.
|
||
watch(
|
||
() => prefs.showAirTraffic,
|
||
(on) => {
|
||
if (on) refreshAirspace()
|
||
else aircraft.value = []
|
||
},
|
||
)
|
||
|
||
// 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()
|
||
startAirspace()
|
||
})
|
||
onBeforeUnmount(() => {
|
||
stopped = true
|
||
if (retry) clearTimeout(retry)
|
||
if (ws) ws.close()
|
||
stopAirspace()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="grid h-full grid-cols-[248px_1fr] max-[900px]:grid-cols-1">
|
||
<!-- ============ Sidebar ============ -->
|
||
<aside class="flex flex-col border-r border-line bg-surface-1 px-4 py-5 max-[900px]:hidden">
|
||
<div class="flex items-center gap-2.5 px-2 pb-5">
|
||
<BrandMark :size="26" />
|
||
<span class="text-[19px] tracking-tightest"
|
||
><span class="font-medium text-ink-secondary">Pilot</span><span class="font-bold text-ink">Vault</span></span
|
||
>
|
||
</div>
|
||
|
||
<nav class="flex flex-col gap-0.5">
|
||
<button
|
||
v-for="[ic, label] in NAV"
|
||
:key="label"
|
||
class="flex items-center gap-3 rounded px-3 py-2.5 text-left text-sm transition"
|
||
:class="active === label
|
||
? 'bg-accent-soft font-semibold text-accent-soft-fg'
|
||
: 'font-medium text-ink-secondary hover:bg-surface-2'"
|
||
@click="active = label"
|
||
>
|
||
<Icon :name="ic" :size="18" :stroke="active === label ? 2.2 : 1.8" />
|
||
{{ label }}
|
||
</button>
|
||
</nav>
|
||
|
||
<div class="mt-auto flex flex-col gap-2.5">
|
||
<div class="rounded-lg bg-surface-2 p-3">
|
||
<div class="flex items-center gap-2">
|
||
<span class="h-2 w-2 rounded-full" :class="live ? 'bg-ready' : 'bg-caution'"></span>
|
||
<span class="text-xs font-semibold text-ink">{{ live ? 'Link healthy' : 'Reconnecting…' }}</span>
|
||
</div>
|
||
<span class="mt-1.5 block font-mono text-[10.5px] text-ink-muted"
|
||
>API gateway · {{ live ? 'streaming' : 'retrying' }}</span
|
||
>
|
||
</div>
|
||
<div class="flex items-center gap-2.5 px-2 py-1">
|
||
<div
|
||
class="grid h-8 w-8 place-items-center rounded-full bg-[var(--navy-800)] text-xs font-bold text-white"
|
||
>
|
||
{{ initials }}
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="truncate text-[13px] font-semibold text-ink">{{ email || 'Operator' }}</div>
|
||
<div class="flex items-center gap-1.5 text-[11px] text-ink-muted">
|
||
<Icon name="grid" :size="11" class="shrink-0" />
|
||
<span class="truncate" :title="`${roleLabel} · ${orgLabel}`">{{ roleLabel }} · {{ orgLabel }}</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
class="text-ink-muted transition hover:text-ink"
|
||
title="Log out"
|
||
aria-label="Log out"
|
||
@click="emit('logout')"
|
||
>
|
||
<Icon name="logout" :size="16" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ============ Main ============ -->
|
||
<main class="overflow-y-auto">
|
||
<!-- topbar -->
|
||
<header
|
||
class="sticky top-0 z-10 flex items-center gap-4 border-b border-line px-7 py-3.5"
|
||
style="background: color-mix(in srgb, var(--bg-app) 82%, transparent); backdrop-filter: blur(10px)"
|
||
>
|
||
<div>
|
||
<div class="eyebrow">Live operations</div>
|
||
<h1 class="mt-0.5 text-[22px] font-bold tracking-tightest text-ink">{{ active }}</h1>
|
||
</div>
|
||
<div class="ml-auto flex items-center gap-3">
|
||
<div
|
||
class="flex h-10 w-60 items-center gap-2 rounded border border-line-strong bg-surface-1 px-3 max-[1100px]:hidden"
|
||
>
|
||
<Icon name="search" :size="16" class="text-ink-muted" />
|
||
<input
|
||
v-model="search"
|
||
placeholder="Search drones, routes…"
|
||
class="w-full border-none bg-transparent text-sm text-ink outline-none placeholder:text-ink-muted"
|
||
/>
|
||
</div>
|
||
<button class="btn-accent flex items-center gap-2" @click="active = 'Live flights'">
|
||
<Icon name="radio" :size="16" /> Live flights
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ---------- Overview ---------- -->
|
||
<div v-if="active === 'Overview'" class="mx-auto flex max-w-[1240px] flex-col gap-5 p-7">
|
||
<!-- stat row -->
|
||
<div class="grid grid-cols-4 gap-4 max-[1100px]:grid-cols-2 max-[560px]:grid-cols-1">
|
||
<div v-for="s in stats" :key="s.label" class="panel p-5">
|
||
<div class="flex items-center justify-between">
|
||
<span class="eyebrow">{{ s.label }}</span>
|
||
<Icon :name="s.icon" :size="16" class="text-ink-muted" />
|
||
</div>
|
||
<div class="mt-2.5 text-[34px] font-bold leading-none tracking-tightest text-ink">{{ s.value }}</div>
|
||
<span class="mt-2 block font-mono text-[11px]" :class="deltaClass[s.tone]">{{ s.delta }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- map + schedule -->
|
||
<div class="grid grid-cols-[1.6fr_1fr] gap-5 max-[1100px]:grid-cols-1">
|
||
<div class="panel p-5">
|
||
<div class="mb-3.5 flex items-center justify-between">
|
||
<div>
|
||
<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="prefs.showAirTraffic && 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 }} drones
|
||
</span>
|
||
<!-- Map settings -->
|
||
<div class="relative z-[1200]">
|
||
<button
|
||
type="button"
|
||
class="grid h-7 w-7 place-items-center rounded-md text-ink-muted transition hover:bg-surface-2 hover:text-ink"
|
||
:class="mapMenu ? 'bg-surface-2 text-ink' : ''"
|
||
title="Map settings"
|
||
aria-label="Map settings"
|
||
@click="mapMenu = !mapMenu"
|
||
>
|
||
<Icon name="settings" :size="16" />
|
||
</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-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>
|
||
</div>
|
||
</div>
|
||
<DeviceMap :position="position" :trail="trail" :aircraft="prefs.showAirTraffic ? aircraft : []" />
|
||
<p v-if="!prefs.showAirTraffic" class="mt-2.5 text-xs text-ink-muted">
|
||
Live air traffic hidden · enable it in Map settings
|
||
</p>
|
||
<p v-else-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 {{ airIntervalSeconds }}s
|
||
</p>
|
||
</div>
|
||
|
||
<div class="panel p-5">
|
||
<div class="mb-3.5 flex items-center justify-between">
|
||
<div>
|
||
<div class="eyebrow">Today</div>
|
||
<div class="mt-0.5 text-base font-semibold text-ink">Schedule</div>
|
||
</div>
|
||
<Icon name="clock" :size="16" class="text-ink-muted" />
|
||
</div>
|
||
<div class="grid place-items-center py-10 text-center">
|
||
<Icon name="calendar" :size="24" class="text-ink-muted" />
|
||
<div class="mt-2 text-sm font-medium text-ink-secondary">No missions scheduled</div>
|
||
<div class="mt-0.5 text-xs text-ink-muted">Scheduling is not wired to a backend yet.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- fleet table -->
|
||
<div class="panel overflow-hidden p-0">
|
||
<div class="flex items-center justify-between px-5 py-4">
|
||
<div>
|
||
<div class="eyebrow">Fleet</div>
|
||
<div class="mt-0.5 text-base font-semibold text-ink">Aircraft status</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<span
|
||
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 }} in flight
|
||
</span>
|
||
<span
|
||
v-if="offlineCount"
|
||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||
:class="badgeClass.warning"
|
||
>
|
||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ offlineCount }} offline
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!fleet.length" class="px-5 py-12 text-center text-sm text-ink-muted">
|
||
No aircraft connected yet. Devices appear here as they come online.
|
||
</div>
|
||
<div v-else class="overflow-x-auto">
|
||
<table class="w-full border-collapse text-sm">
|
||
<thead>
|
||
<tr class="text-left">
|
||
<th
|
||
v-for="h in ['Aircraft', 'Mission', 'Status', 'Alt', 'Battery', 'Speed', '']"
|
||
:key="h"
|
||
class="border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted"
|
||
>
|
||
{{ h }}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="(row, i) in fleet"
|
||
:key="row.id"
|
||
class="cursor-pointer transition hover:bg-surface-2"
|
||
:class="i < fleet.length - 1 ? 'border-b border-line' : ''"
|
||
@click="track(row.id)"
|
||
>
|
||
<td class="px-5 py-3 font-mono font-bold text-ink">{{ row.id }}</td>
|
||
<td class="px-5 py-3 text-ink-secondary">{{ row.mission }}</td>
|
||
<td class="px-5 py-3">
|
||
<span
|
||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||
:class="badgeClass[row.tone]"
|
||
>
|
||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ row.status }}
|
||
</span>
|
||
</td>
|
||
<td class="px-5 py-3 font-mono text-ink-secondary">{{ row.alt }}</td>
|
||
<td class="px-5 py-3">
|
||
<div v-if="row.battery != null" class="flex items-center gap-2">
|
||
<div class="h-1.5 w-12 overflow-hidden rounded bg-surface-2">
|
||
<div
|
||
class="h-full"
|
||
:class="row.battery < 40 ? 'bg-caution' : 'bg-ready'"
|
||
:style="{ width: row.battery + '%' }"
|
||
></div>
|
||
</div>
|
||
<span class="font-mono text-xs text-ink-secondary">{{ row.battery }}%</span>
|
||
</div>
|
||
<span v-else class="font-mono text-xs text-ink-muted">—</span>
|
||
</td>
|
||
<td class="px-5 py-3 font-mono text-ink-secondary">
|
||
{{ row.speed == null ? '—' : row.speed.toFixed(1) }}
|
||
<span class="text-ink-muted">m/s</span>
|
||
</td>
|
||
<td class="px-5 py-3 text-right">
|
||
<button
|
||
class="btn-ghost inline-flex items-center gap-1.5 whitespace-nowrap"
|
||
@click.stop="track(row.id)"
|
||
>
|
||
<Icon name="play" :size="14" /> Track
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ---------- Live flights (detailed HUD) ---------- -->
|
||
<div v-else-if="active === 'Live flights'" class="p-7">
|
||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||
<span class="font-mono text-mode font-bold text-ink">{{ selectedId || 'No device selected' }}</span>
|
||
<span
|
||
v-if="sel && !online"
|
||
class="rounded-full bg-danger-soft px-2.5 py-0.5 font-mono text-[10px] font-bold uppercase tracking-caps text-danger-fg"
|
||
>Offline</span
|
||
>
|
||
<!-- device switcher -->
|
||
<div v-if="ids.length" class="ml-auto flex flex-wrap gap-1.5">
|
||
<button
|
||
v-for="id in ids"
|
||
:key="id"
|
||
class="flex items-center gap-2 rounded border px-3 py-1.5 font-mono text-xs font-bold transition"
|
||
:class="id === selectedId
|
||
? 'border-accent bg-accent-soft text-accent-soft-fg'
|
||
: 'border-line bg-surface-1 text-ink-secondary hover:border-line-strong'"
|
||
@click="selectedId = id"
|
||
>
|
||
<span class="h-2 w-2 rounded-full" :class="devices[id].online ? 'bg-ready' : 'bg-ink-muted'"></span>
|
||
{{ id }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!ids.length" class="panel grid place-items-center p-16 text-center">
|
||
<Icon name="radio" :size="28" class="text-ink-muted" />
|
||
<div class="mt-3 text-sm font-medium text-ink-secondary">No aircraft online</div>
|
||
<div class="mt-1 text-xs text-ink-muted">Live telemetry appears here once a drone connects.</div>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div
|
||
class="mb-4 grid gap-3"
|
||
:class="!online && sel ? 'opacity-60' : ''"
|
||
style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr))"
|
||
>
|
||
<div class="pill">
|
||
<div class="eyebrow">Registration</div>
|
||
<div
|
||
class="mt-1 text-sm font-semibold"
|
||
:class="online ? (sel?.registration === 'success' ? 'text-success-fg' : 'text-danger-fg') : 'text-ink'"
|
||
>
|
||
{{ online && sel?.registration ? sel.registration : '—' }}
|
||
</div>
|
||
</div>
|
||
<div class="pill">
|
||
<div class="eyebrow">Drone link</div>
|
||
<div
|
||
class="mt-1 text-sm font-semibold"
|
||
:class="!online ? 'text-ink' : sel?.connected ? 'text-success-fg' : 'text-danger-fg'"
|
||
>
|
||
{{ !sel ? '—' : !online ? 'app offline' : sel.connected ? 'connected' : 'no drone' }}
|
||
</div>
|
||
</div>
|
||
<div class="pill">
|
||
<div class="eyebrow">Model</div>
|
||
<div class="mt-1 text-sm font-semibold text-ink">{{ sel?.model || '—' }}</div>
|
||
</div>
|
||
<div class="pill">
|
||
<div class="eyebrow">Last update</div>
|
||
<div class="mt-1 font-mono text-sm font-bold tabular text-ink">
|
||
{{ sel?.lastSeenMs ? formatTime(sel.lastSeenMs) : '—' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4 max-[820px]:grid-cols-1">
|
||
<div class="panel p-4">
|
||
<div class="mb-3 eyebrow">Battery</div>
|
||
<div class="flex items-center gap-4">
|
||
<div class="h-9 flex-1 overflow-hidden rounded border border-line bg-surface-2">
|
||
<div
|
||
class="h-full transition-all"
|
||
:style="{ width: (typeof tel.batteryPercent === 'number' ? tel.batteryPercent : 0) + '%' }"
|
||
:class="typeof tel.batteryPercent === 'number'
|
||
? tel.batteryPercent < 20 ? 'bg-warning' : tel.batteryPercent < 40 ? 'bg-caution' : 'bg-ready'
|
||
: ''"
|
||
></div>
|
||
</div>
|
||
<div class="readout">
|
||
{{ typeof tel.batteryPercent === 'number' ? tel.batteryPercent : '—'
|
||
}}<span class="text-sm text-ink-secondary">%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel p-4">
|
||
<div class="mb-3 eyebrow">Altitude</div>
|
||
<div class="readout">{{ fmt(tel.altitude, 1) }}<span class="text-sm text-ink-secondary"> m</span></div>
|
||
</div>
|
||
|
||
<div class="panel p-4">
|
||
<div class="mb-3 eyebrow">Flight</div>
|
||
<div class="space-y-1.5 text-sm">
|
||
<div class="flex justify-between"><span class="text-ink-secondary">Mode</span><b class="text-ink">{{ tel.flightMode || '—' }}</b></div>
|
||
<div class="flex justify-between"><span class="text-ink-secondary">Flying</span><b class="text-ink">{{ tel.isFlying == null ? '—' : tel.isFlying ? 'yes' : 'no' }}</b></div>
|
||
<div class="flex justify-between"><span class="text-ink-secondary">GPS sats</span><b class="font-mono tabular text-ink">{{ tel.satelliteCount == null ? '—' : tel.satelliteCount }}</b></div>
|
||
<div class="flex justify-between"><span class="text-ink-secondary">Speed (H)</span><b class="font-mono tabular text-ink">{{ hSpeed == null ? '—' : fmt(hSpeed, 2, ' m/s') }}</b></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel p-4">
|
||
<div class="mb-3 eyebrow">Position</div>
|
||
<div class="space-y-1.5 text-sm">
|
||
<div class="flex justify-between"><span class="text-ink-secondary">Latitude</span><b class="font-mono tabular text-ink">{{ fmt(tel.latitude, 6) }}</b></div>
|
||
<div class="flex justify-between"><span class="text-ink-secondary">Longitude</span><b class="font-mono tabular text-ink">{{ fmt(tel.longitude, 6) }}</b></div>
|
||
<div class="flex justify-between"><span class="text-ink-secondary">Vert. speed</span><b class="font-mono tabular text-ink">{{ fmt(typeof tel.velocityZ === 'number' ? -tel.velocityZ : undefined, 2, ' m/s') }}</b></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel col-span-2 p-4 max-[820px]:col-span-1">
|
||
<div class="mb-3 eyebrow">Track</div>
|
||
<DeviceMap :position="position" :trail="trail" />
|
||
</div>
|
||
|
||
<div class="panel p-4">
|
||
<div class="mb-3 eyebrow">Send command</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<input v-model="cmdName" class="field flex-1" placeholder="command (e.g. startConnection)" />
|
||
<input v-model="cmdPayload" class="field flex-1" placeholder="payload JSON (optional)" />
|
||
<button class="btn-accent" @click="doCommand">Send</button>
|
||
</div>
|
||
<div class="mt-2 min-h-[16px] text-xs text-ink-muted">{{ cmdResult }}</div>
|
||
</div>
|
||
|
||
<div class="panel p-4">
|
||
<div class="mb-3 eyebrow">Event log</div>
|
||
<div class="h-[180px] overflow-y-auto font-mono text-xs">
|
||
<div v-for="(e, i) in log" :key="i" class="border-b border-line py-1">
|
||
<span class="text-ink-muted">{{ e.t }}</span>
|
||
<span class="font-semibold text-accent"> {{ e.tag }} </span>
|
||
<span class="break-all text-ink">{{ e.text }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- ---------- Logbook ---------- -->
|
||
<Logbook v-else-if="active === 'Logbook'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
|
||
|
||
<!-- ---------- Documents ---------- -->
|
||
<Documents v-else-if="active === 'Documents'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
|
||
|
||
<!-- ---------- Settings ---------- -->
|
||
<Settings v-else-if="active === 'Settings'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" @logout="emit('logout')" />
|
||
|
||
<!-- ---------- Placeholder views (Routes / Schedule / Drives) ---------- -->
|
||
<div v-else class="p-7">
|
||
<div class="panel grid place-items-center p-16 text-center">
|
||
<Icon :name="activeIcon" :size="28" class="text-ink-muted" />
|
||
<div class="mt-3 text-sm font-medium text-ink-secondary">{{ active }}</div>
|
||
<div v-if="active === 'Drives'" class="mt-1 text-xs text-ink-muted">
|
||
Browse and transfer files here once a drive is connected. Configure drives in
|
||
<button class="font-semibold text-accent hover:underline" @click="active = 'Settings'">Settings → Integrations</button>.
|
||
</div>
|
||
<div v-else class="mt-1 text-xs text-ink-muted">This section is part of the console shell and has no backend yet.</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</template>
|