Sync the Fly App's waypoint routes through the API Server
Routes saved in the Fly App's mission library now sync to a new per-user routes collection on the API Server, in the WaypointMissionSpec JSON format. The library pulls on load and pushes on every save and delete, merges last-writer-wins by updatedAt, keeps unacknowledged deletes as tombstones, and keeps working offline. Each signed-in account has its own copy on the device; routes saved while signed out go to the first account that signs in. The API Server gets list / get / upsert / delete route endpoints with a PocketBase migration, and the Web App a read-only Routes view on the dashboard (rebuilt dist). Tested against a fake PocketBase and a fake server only; not yet tried with a real sign-in on the tablet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e49f318a0e
commit
93f4a580e9
18 files changed
+1525
-43
No files matched your search
@@ -418,6 +418,21 @@ export function exportLogbookUrl() {
|
||||
return '/bff/logbook/export'
|
||||
}
|
||||
|
||||
/* ---------- Routes ---------- */
|
||||
|
||||
// List the saved waypoint routes in scope (own; an admin's whole org). Each
|
||||
// carries the Fly App's route JSON in `spec`.
|
||||
export async function getRoutes() {
|
||||
try {
|
||||
const r = await fetch('/bff/routes')
|
||||
if (!r.ok) return { ok: false, status: r.status, routes: [] }
|
||||
const d = await r.json()
|
||||
return { ok: true, status: 200, routes: d.routes || [] }
|
||||
} catch {
|
||||
return { ok: false, status: 0, routes: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Documents ---------- */
|
||||
|
||||
// List in-scope documents. Pass `expiring` (days) to narrow to documents that
|
||||
|
||||
@@ -7,6 +7,7 @@ import Settings from './Settings.vue'
|
||||
import Logbook from './Logbook.vue'
|
||||
import Drones from './Drones.vue'
|
||||
import Documents from './Documents.vue'
|
||||
import RoutesView from './Routes.vue'
|
||||
import Toggle from './settings/Toggle.vue'
|
||||
import { getDevices, sendCommand, getOpenSkyStates, getOpenWeatherCurrent, autoAddDrone } from '../api.js'
|
||||
import { formatTime, prefs } from '../prefs.js'
|
||||
@@ -1060,13 +1061,16 @@ onBeforeUnmount(() => {
|
||||
<Drones v-else-if="active === 'Drones'" ref="dronesView" :connected-fc-serials="connectedFcSerials"
|
||||
@deleted="forgetIdentity" />
|
||||
|
||||
<!-- ---------- Routes ---------- -->
|
||||
<RoutesView v-else-if="active === 'Routes'" />
|
||||
|
||||
<!-- ---------- 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) ---------- -->
|
||||
<!-- ---------- Placeholder views (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" />
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import { getRoutes } from '../api.js'
|
||||
|
||||
// Read-only view of the waypoint routes pilots save in the Fly App's mission
|
||||
// library. The tablet is where routes are drawn and edited; this lists what has
|
||||
// synced. Scoping (own routes; an admin sees the org's) is server-side.
|
||||
|
||||
const routes = ref([])
|
||||
const loading = ref(false)
|
||||
const loadErr = ref('')
|
||||
const openId = ref('')
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
loadErr.value = ''
|
||||
const res = await getRoutes()
|
||||
if (!res.ok) {
|
||||
loadErr.value =
|
||||
res.status === 503
|
||||
? 'Route storage is not configured on the API Server (service account missing).'
|
||||
: 'Could not load your routes.'
|
||||
}
|
||||
routes.value = res.routes
|
||||
loading.value = false
|
||||
}
|
||||
onMounted(loadAll)
|
||||
|
||||
// Great-circle metres between two {lat, lon} points.
|
||||
function metres(a, b) {
|
||||
const R = 6371000
|
||||
const rad = (d) => (d * Math.PI) / 180
|
||||
const dLat = rad(b.lat - a.lat)
|
||||
const dLon = rad(b.lon - a.lon)
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(h))
|
||||
}
|
||||
function points(r) {
|
||||
return Array.isArray(r.spec?.points) ? r.spec.points : []
|
||||
}
|
||||
function length(r) {
|
||||
const p = points(r)
|
||||
let m = 0
|
||||
for (let i = 1; i < p.length; i++) m += metres(p[i - 1], p[i])
|
||||
return m
|
||||
}
|
||||
function fmtLength(m) {
|
||||
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
|
||||
}
|
||||
// Flight time at cruise speed (ignores hovers and turns): a rough guide only.
|
||||
function fmtDuration(r) {
|
||||
const v = Number(r.spec?.cruiseSpeed) || 0
|
||||
if (!v) return '—'
|
||||
const s = Math.round(length(r) / v)
|
||||
return s >= 60 ? `${Math.floor(s / 60)} min ${s % 60} s` : `${s} s`
|
||||
}
|
||||
function fmtWhen(iso) {
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString()
|
||||
}
|
||||
const FINISH = { goHome: 'Return to home', hover: 'Hover', land: 'Land', firstWaypoint: 'Back to first WP' }
|
||||
const ACTION = { none: '—', photo: 'Photo', startRecord: 'Start recording', stopRecord: 'Stop recording' }
|
||||
|
||||
const stats = computed(() => {
|
||||
const all = routes.value
|
||||
return {
|
||||
count: all.length,
|
||||
mine: all.filter((r) => r.mine).length,
|
||||
waypoints: all.reduce((n, r) => n + points(r).length, 0),
|
||||
distance: all.reduce((m, r) => m + length(r), 0),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex max-w-[1240px] flex-col gap-5 p-7">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div>
|
||||
<div class="eyebrow">Mission library</div>
|
||||
<div class="mt-0.5 text-base font-semibold text-ink">Waypoint routes</div>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button class="btn-ghost inline-flex items-center gap-2" :disabled="loading" @click="loadAll">
|
||||
<Icon name="download" :size="14" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-4 max-[900px]:grid-cols-2">
|
||||
<div v-for="s in [
|
||||
{ label: 'Routes', value: stats.count },
|
||||
{ label: 'Yours', value: stats.mine },
|
||||
{ label: 'Waypoints', value: stats.waypoints },
|
||||
{ label: 'Total distance', value: fmtLength(stats.distance) },
|
||||
]" :key="s.label" class="panel p-5">
|
||||
<div class="eyebrow">{{ s.label }}</div>
|
||||
<div class="mt-2 text-[30px] font-bold leading-none tracking-tightest text-ink">{{ s.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadErr" class="panel border-danger/40 p-4 text-sm text-danger-fg">{{ loadErr }}</div>
|
||||
|
||||
<div class="panel overflow-hidden p-0">
|
||||
<div v-if="loading && !routes.length" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
|
||||
<div v-else-if="!routes.length" class="grid place-items-center px-5 py-16 text-center">
|
||||
<Icon name="route" :size="26" class="text-ink-muted" />
|
||||
<div class="mt-3 text-sm font-medium text-ink-secondary">No routes yet</div>
|
||||
<div class="mt-1 text-xs text-ink-muted">
|
||||
Save a route in the Fly App's Waypoints mission library while signed in and it shows up here.
|
||||
</div>
|
||||
</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 ['Route', 'Waypoints', 'Length', 'Est. time', 'Speed', 'On finish', 'Updated']" :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>
|
||||
<template v-for="r in routes" :key="r.id">
|
||||
<tr class="cursor-pointer border-b border-line last:border-0 hover:bg-surface-2"
|
||||
:class="openId === r.id ? 'bg-accent-soft' : ''" @click="openId = openId === r.id ? '' : r.id">
|
||||
<td class="px-5 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="chevronRight" :size="13" class="text-ink-muted transition"
|
||||
:class="openId === r.id ? 'rotate-90' : ''" />
|
||||
<span class="font-semibold text-ink">{{ r.name || 'Route' }}</span>
|
||||
<span v-if="!r.mine" class="inline-flex rounded-full bg-surface-2 px-2 py-0.5 text-[11px] font-semibold text-ink-secondary">
|
||||
another pilot
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ points(r).length }}</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ fmtLength(length(r)) }}</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ fmtDuration(r) }}</td>
|
||||
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ r.spec?.cruiseSpeed ?? '—' }} m/s</td>
|
||||
<td class="px-5 py-3 text-xs text-ink-secondary">{{ FINISH[r.spec?.finishAction] || r.spec?.finishAction || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-5 py-3 text-xs text-ink-muted">{{ fmtWhen(r.updatedAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="openId === r.id" class="border-b border-line bg-surface-2">
|
||||
<td colspan="7" class="px-5 py-3">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-left text-ink-muted">
|
||||
<th v-for="h in ['#', 'Latitude', 'Longitude', 'Altitude', 'Speed', 'Gimbal', 'Action']" :key="h"
|
||||
class="py-1 pr-4 font-mono text-[10.5px] font-normal uppercase tracking-caps">{{ h }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="font-mono text-ink-secondary">
|
||||
<tr v-for="(p, i) in points(r)" :key="i">
|
||||
<td class="py-1 pr-4">{{ i + 1 }}</td>
|
||||
<td class="py-1 pr-4">{{ Number(p.lat).toFixed(6) }}</td>
|
||||
<td class="py-1 pr-4">{{ Number(p.lon).toFixed(6) }}</td>
|
||||
<td class="py-1 pr-4">{{ p.altitude }} m</td>
|
||||
<td class="py-1 pr-4">{{ p.speed != null ? `${p.speed} m/s` : 'cruise' }}</td>
|
||||
<td class="py-1 pr-4">{{ p.gimbalPitch != null ? `${p.gimbalPitch}°` : '—' }}</td>
|
||||
<td class="py-1 pr-4">
|
||||
{{ ACTION[p.action] || p.action || '—' }}{{ p.hoverSeconds ? ` · hover ${p.hoverSeconds} s` : '' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-2 text-xs text-ink-muted">Edit this route in the Fly App; changes sync here.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user