Add a "Map settings" popover (gear button) on the Overview Live map with a "Show live air traffic" switch, backed by a new client-side showAirTraffic preference (default on, persisted in localStorage and synced like other prefs). When off, the aircraft overlay clears and polling pauses to save OpenSky credits; the count badge and caption reflect the hidden state. Toggling on re-fetches immediately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
748 lines
31 KiB
Vue
748 lines
31 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 }]
|
|
const airspace = reactive({ unavailable: false, detail: '', loaded: false })
|
|
const airborneCount = computed(() => aircraft.value.filter((a) => !a.onGround).length)
|
|
const mapMenu = ref(false) // "Map settings" popover open state
|
|
let airTimer = null
|
|
|
|
async function refreshAirspace() {
|
|
if (!prefs.showAirTraffic) return
|
|
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 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()
|
|
airTimer = setInterval(() => {
|
|
if (active.value === 'Overview' && prefs.showAirTraffic) refreshAirspace()
|
|
}, 30000)
|
|
}
|
|
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 = []
|
|
},
|
|
)
|
|
|
|
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">
|
|
<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-10" @click="mapMenu = false"></div>
|
|
<div class="panel absolute right-0 z-20 mt-1.5 w-64 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>
|
|
</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 30s
|
|
</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>
|