Files
PilotVault/Web App/web/src/components/Dashboard.vue
T
tajniak81andClaude Opus 4.8 fee171a71f Add Map settings toggle to show/hide live air traffic
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>
2026-07-13 22:31:56 +02:00

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>