Add a Drones fleet section that fills itself in on connect

The fleet lived as a tab inside the Logbook, which buried it, and every
drone had to be typed in by hand — model, serial and firmware copied off
an airframe the app was already talking to.

Promote it to its own nav section above Logbook, and let a connecting
drone register itself. The Fly App already forwarded model, serial and
firmware upstream; the hub was keeping only the model. It now carries the
identity through to DeviceState, and the Web App offers it to a new
POST /api/drones/auto, which upserts keyed by serial. The auto path only
writes what the aircraft is authoritative about (model, both firmware
versions) and never touches what the pilot curates.

Serial and the firmware versions resolve on their own schedules after
connect — the serial in seconds, the aircraft firmware sometimes a minute
later — so nothing along the path treats an absent value as a cleared one,
and a later event filling firmware in still reaches the server. The auto
call rides every telemetry frame, so the client remembers the identity
tuple it last sent and only a change goes out; a 4xx is the server's
settled answer and is not retried, or one drone connected for an hour
would mean one request per frame for an hour.

New fields on drones: firmware, controller_firmware, and registration for
the FAA/CAA aircraft number — distinct from operator_number, which stays
the EU operator ID. Controller firmware is the remote controller's own
version, read from its component; the flight controller's version is a
different quantity and stays off this field (see 002e484). name becomes
optional and is now the pilot's custom name: auto-added drones arrive
unnamed, so the API serves a computed displayName (name, else model +
serial) for the fleet table, the flight picker and the CSV export. A
unique index on serial is what keeps the find-then-create path from
forking a drone's history across two records.

The schema is applied to the remote PocketBase; the migration is here for
fresh deployments, which the remote does not read.

Verified against a simulated device over the real socket with identity
resolving late: one record from four events, both firmware versions
filled, curated fields intact across re-registration, and a drone deleted
while connected coming back on the next frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-16 17:04:12 +02:00
co-authored by Claude Opus 4.8
parent 3e066c0a99
commit 33595c99e8
19 changed files with 931 additions and 426 deletions
+210 -49
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -33,38 +34,66 @@ func (s *Server) requireUser(next http.HandlerFunc) http.HandlerFunc {
// ---------------------------------------------------------------------------
type droneRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Model string `json:"model"`
Serial string `json:"serial"`
OperatorNumber string `json:"operator_number"`
MtomGrams float64 `json:"mtom_grams"`
IsToy bool `json:"is_toy"`
AutologsFlights bool `json:"autologs_flights"`
CClass string `json:"c_class"`
Organization string `json:"organization"`
Created string `json:"created"`
Updated string `json:"updated"`
ID string `json:"id"`
Name string `json:"name"` // pilot's custom label; blank on auto-added drones
Model string `json:"model"`
Serial string `json:"serial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controller_firmware"`
Registration string `json:"registration"`
OperatorNumber string `json:"operator_number"`
MtomGrams float64 `json:"mtom_grams"`
IsToy bool `json:"is_toy"`
AutologsFlights bool `json:"autologs_flights"`
CClass string `json:"c_class"`
Organization string `json:"organization"`
Created string `json:"created"`
Updated string `json:"updated"`
}
type droneView struct {
ID string `json:"id"`
Name string `json:"name"`
Model string `json:"model"`
Serial string `json:"serial"`
OperatorNumber string `json:"operatorNumber"`
MtomGrams float64 `json:"mtomGrams"`
IsToy bool `json:"isToy"`
AutologsFlights bool `json:"autologsFlights"`
CClass string `json:"cClass"`
Organization string `json:"organization"`
Created string `json:"created"`
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Model string `json:"model"`
Serial string `json:"serial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controllerFirmware"`
Registration string `json:"registration"`
OperatorNumber string `json:"operatorNumber"`
MtomGrams float64 `json:"mtomGrams"`
IsToy bool `json:"isToy"`
AutologsFlights bool `json:"autologsFlights"`
CClass string `json:"cClass"`
Organization string `json:"organization"`
Created string `json:"created"`
}
// displayName is what to call the drone in lists, logbook entries and the CSV
// export. The custom name wins; a drone auto-added on connection has none, so
// fall back to what the aircraft reported about itself.
func (d droneRecord) displayName() string {
if n := strings.TrimSpace(d.Name); n != "" {
return n
}
if m := strings.TrimSpace(d.Model); m != "" {
if s := strings.TrimSpace(d.Serial); s != "" {
return m + " · " + s
}
return m
}
if s := strings.TrimSpace(d.Serial); s != "" {
return s
}
return "Unnamed drone"
}
func (d droneRecord) view() droneView {
return droneView{
ID: d.ID, Name: d.Name, Model: d.Model, Serial: d.Serial,
OperatorNumber: d.OperatorNumber, MtomGrams: d.MtomGrams, IsToy: d.IsToy,
ID: d.ID, Name: d.Name, DisplayName: d.displayName(), Model: d.Model, Serial: d.Serial,
Firmware: d.Firmware, ControllerFirmware: d.ControllerFirmware,
Registration: d.Registration, OperatorNumber: d.OperatorNumber,
MtomGrams: d.MtomGrams, IsToy: d.IsToy,
AutologsFlights: d.AutologsFlights, CClass: d.CClass,
Organization: d.Organization, Created: d.Created,
}
@@ -142,7 +171,7 @@ func (f flightRecord) view(drones map[string]droneRecord) flightView {
}
name := ""
if d != nil {
name = d.Name
name = d.displayName()
}
return flightView{
ID: f.ID, OperationDate: f.OperationDate, StartTime: f.StartTime, EndTime: f.EndTime,
@@ -285,7 +314,9 @@ func (s *Server) dronesInScope(ctx context.Context, who *callerIdentity) (map[st
var list struct {
Items []droneRecord `json:"items"`
}
if _, err := s.listRecords(ctx, "drones", droneScopeFilter(who), "name", &list); err != nil {
// Sorted by creation, not name: the custom name is optional, so sorting by it
// would bunch every auto-added drone together under a blank key.
if _, err := s.listRecords(ctx, "drones", droneScopeFilter(who), "created", &list); err != nil {
return nil, err
}
m := make(map[string]droneRecord, len(list.Items))
@@ -388,15 +419,18 @@ func (s *Server) handleListDrones(w http.ResponseWriter, r *http.Request) {
}
type droneInput struct {
Name string `json:"name"`
Model string `json:"model"`
Serial string `json:"serial"`
OperatorNumber string `json:"operatorNumber"`
MtomGrams float64 `json:"mtomGrams"`
IsToy bool `json:"isToy"`
AutologsFlights bool `json:"autologsFlights"`
CClass string `json:"cClass"`
Organization *string `json:"organization"` // superadmin may target any org
Name string `json:"name"` // custom label; optional
Model string `json:"model"`
Serial string `json:"serial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controllerFirmware"`
Registration string `json:"registration"`
OperatorNumber string `json:"operatorNumber"`
MtomGrams float64 `json:"mtomGrams"`
IsToy bool `json:"isToy"`
AutologsFlights bool `json:"autologsFlights"`
CClass string `json:"cClass"`
Organization *string `json:"organization"` // superadmin may target any org
}
func (in droneInput) payload(who *callerIdentity) map[string]any {
@@ -405,18 +439,30 @@ func (in droneInput) payload(who *callerIdentity) map[string]any {
org = strings.TrimSpace(*in.Organization)
}
return map[string]any{
"name": strings.TrimSpace(in.Name),
"model": strings.TrimSpace(in.Model),
"serial": strings.TrimSpace(in.Serial),
"operator_number": strings.TrimSpace(in.OperatorNumber),
"mtom_grams": in.MtomGrams,
"is_toy": in.IsToy,
"autologs_flights": in.AutologsFlights,
"c_class": strings.TrimSpace(in.CClass),
"organization": org,
"name": strings.TrimSpace(in.Name),
"model": strings.TrimSpace(in.Model),
"serial": strings.TrimSpace(in.Serial),
"firmware": strings.TrimSpace(in.Firmware),
"controller_firmware": strings.TrimSpace(in.ControllerFirmware),
"registration": strings.TrimSpace(in.Registration),
"operator_number": strings.TrimSpace(in.OperatorNumber),
"mtom_grams": in.MtomGrams,
"is_toy": in.IsToy,
"autologs_flights": in.AutologsFlights,
"c_class": strings.TrimSpace(in.CClass),
"organization": org,
}
}
// identifiable reports whether the input says *anything* about which aircraft
// this is. The custom name is optional (auto-added drones have none), but a
// record with no name, model and serial is not a drone, it is an empty row.
func (in droneInput) identifiable() bool {
return strings.TrimSpace(in.Name) != "" ||
strings.TrimSpace(in.Model) != "" ||
strings.TrimSpace(in.Serial) != ""
}
// POST /api/drones — register a drone (assigned to the caller's org).
func (s *Server) handleCreateDrone(w http.ResponseWriter, r *http.Request) {
who := caller(r)
@@ -425,8 +471,8 @@ func (s *Server) handleCreateDrone(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "drone name is required")
if !in.identifiable() {
writeError(w, http.StatusBadRequest, "give the drone a custom name, model or serial")
return
}
data, status, err := s.admin.do(r.Context(), http.MethodPost,
@@ -444,6 +490,121 @@ func (s *Server) handleCreateDrone(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]any{"drone": d.view()})
}
// autoDroneInput is the identity a connected aircraft reports about itself.
// Everything the pilot curates by hand (custom name, registration, MTOM, class)
// is deliberately absent — the auto path never touches those.
type autoDroneInput struct {
Model string `json:"model"`
Serial string `json:"serial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controllerFirmware"`
}
// findDroneBySerial looks a drone up across *all* orgs, ignoring caller scope:
// the serial is unique per airframe, so the caller's own scope is not enough to
// know whether the record already exists.
func (s *Server) findDroneBySerial(ctx context.Context, serial string) (droneRecord, bool, error) {
var list struct {
Items []droneRecord `json:"items"`
}
filter := "serial = " + strconv.Quote(serial)
if _, err := s.listRecords(ctx, "drones", filter, "created", &list); err != nil {
return droneRecord{}, false, err
}
if len(list.Items) == 0 {
return droneRecord{}, false, nil
}
return list.Items[0], true, nil
}
// POST /api/drones/auto — upsert the drone the caller just connected, keyed by
// serial. Called by the Web App when a device reports a connected aircraft, so
// the fleet fills itself in without the pilot typing anything.
//
// Idempotent by design: it runs on every connection event, so an existing entry
// is refreshed (firmware changes as the pilot updates the aircraft) rather than
// duplicated, and the response says which happened.
func (s *Server) handleAutoDrone(w http.ResponseWriter, r *http.Request) {
who := caller(r)
var in autoDroneInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
serial := strings.TrimSpace(in.Serial)
if serial == "" {
// No serial means no stable identity to key on — auto-adding here would
// mint a fresh drone on every reconnect.
writeError(w, http.StatusBadRequest, "serial is required to auto-add a drone")
return
}
existing, found, err := s.findDroneBySerial(r.Context(), serial)
if err != nil {
gatewayError(w, err)
return
}
if found {
if !canManageDrone(who, existing) {
writeError(w, http.StatusConflict, "this drone is registered to another organisation")
return
}
// Refresh only what the aircraft is authoritative about, and only when it
// actually reported a value — a nil/absent field means "not resolved yet"
// (serial and firmware resolve on different schedules), never "cleared".
patch := map[string]any{}
if m := strings.TrimSpace(in.Model); m != "" && m != existing.Model {
patch["model"] = m
}
if f := strings.TrimSpace(in.Firmware); f != "" && f != existing.Firmware {
patch["firmware"] = f
}
if cf := strings.TrimSpace(in.ControllerFirmware); cf != "" && cf != existing.ControllerFirmware {
patch["controller_firmware"] = cf
}
if len(patch) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"drone": existing.view(), "created": false, "updated": false})
return
}
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/drones/records/"+url.PathEscape(existing.ID), patch)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var d droneRecord
_ = json.Unmarshal(data, &d)
writeJSON(w, http.StatusOK, map[string]any{"drone": d.view(), "created": false, "updated": true})
return
}
// New airframe: record what it reported and leave the curated fields blank
// for the pilot to fill in on the Drones tab.
payload := droneInput{
Model: strings.TrimSpace(in.Model),
Serial: serial,
Firmware: strings.TrimSpace(in.Firmware),
ControllerFirmware: strings.TrimSpace(in.ControllerFirmware),
}.payload(who)
data, status, err := s.admin.do(r.Context(), http.MethodPost, "/api/collections/drones/records", payload)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var d droneRecord
_ = json.Unmarshal(data, &d)
writeJSON(w, http.StatusCreated, map[string]any{"drone": d.view(), "created": true, "updated": false})
}
// PATCH /api/drones/{id} — update a drone (must be in the caller's scope).
func (s *Server) handleUpdateDrone(w http.ResponseWriter, r *http.Request) {
who := caller(r)
@@ -466,8 +627,8 @@ func (s *Server) handleUpdateDrone(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "drone name is required")
if !in.identifiable() {
writeError(w, http.StatusBadRequest, "give the drone a custom name, model or serial")
return
}
// Preserve org ownership unless a superadmin explicitly retargets it.
+5 -4
View File
@@ -41,7 +41,7 @@ func (s *Server) handleExportLogbook(w http.ResponseWriter, r *http.Request) {
_ = cw.Write([]string{
"operation_date", "start_time", "end_time",
"drone_name", "drone_model", "drone_serial", "operator_number",
"drone_name", "drone_model", "drone_serial", "drone_registration", "operator_number",
"area_or_route", "max_altitude_agl_m",
"remote_pilot", "certificate_ref",
"category", "purpose", "logging_path", "fdr_log_url", "authorisation_ref",
@@ -55,9 +55,10 @@ func (s *Server) handleExportLogbook(w http.ResponseWriter, r *http.Request) {
d = &dr
}
c := computeCompliance(f, d)
droneName, model, serial, opNo := "", "", "", ""
droneName, model, serial, reg, opNo := "", "", "", "", ""
if d != nil {
droneName, model, serial, opNo = d.Name, d.Model, d.Serial, d.OperatorNumber
droneName, model, serial = d.displayName(), d.Model, d.Serial
reg, opNo = d.Registration, d.OperatorNumber
}
alt := ""
if f.MaxAltitudeAGL > 0 {
@@ -65,7 +66,7 @@ func (s *Server) handleExportLogbook(w http.ResponseWriter, r *http.Request) {
}
_ = cw.Write([]string{
day(f.OperationDate), f.StartTime, f.EndTime,
droneName, model, serial, opNo,
droneName, model, serial, reg, opNo,
f.AreaRoute, alt,
f.PilotName, f.CertificateRef,
f.Category, f.Purpose, c.LoggingPath, f.RawFDRLogURL, f.AuthorisationRef,
+1
View File
@@ -150,6 +150,7 @@ func (s *Server) Handler() http.Handler {
// inside the handlers, so the shared requireUser gate suffices.
mux.HandleFunc("GET /api/drones", s.requireUser(s.handleListDrones))
mux.HandleFunc("POST /api/drones", s.requireUser(s.handleCreateDrone))
mux.HandleFunc("POST /api/drones/auto", s.requireUser(s.handleAutoDrone))
mux.HandleFunc("PATCH /api/drones/{id}", s.requireUser(s.handleUpdateDrone))
mux.HandleFunc("DELETE /api/drones/{id}", s.requireUser(s.handleDeleteDrone))
mux.HandleFunc("GET /api/flights", s.requireUser(s.handleListFlights))
+11
View File
@@ -182,11 +182,22 @@ func (h *Hub) Ingest(deviceID string, raw map[string]any) {
s.Connected = c
if !c {
s.Telemetry = Telemetry{} // drone unlinked: live telemetry is no longer valid
s.Serial, s.Firmware, s.ControllerFirmware = "", "", ""
}
}
if m, ok := raw["model"].(string); ok {
s.Model = m
}
if s.Connected {
applyIdentity(s, raw)
}
case "identity":
// Ignore identity that arrives after a disconnect: the Fly App forwards
// every SDK event upstream before its own connected-check, so a callback
// resolving late would otherwise repopulate what the disconnect cleared.
if s.Connected {
applyIdentity(s, raw)
}
case "battery":
if p, ok := toInt(raw["percent"]); ok {
s.Telemetry.BatteryPercent = &p
+32 -7
View File
@@ -23,13 +23,22 @@ type Telemetry struct {
// DeviceState is the server's aggregated view of one app/drone.
type DeviceState struct {
DeviceID string `json:"deviceId"`
Online bool `json:"online"` // app's websocket is connected to the server
Connected bool `json:"connected"` // a drone is connected to the app
Model string `json:"model"`
Registration string `json:"registration"`
Telemetry Telemetry `json:"telemetry"`
LastSeenMs int64 `json:"lastSeenMs"`
DeviceID string `json:"deviceId"`
Online bool `json:"online"` // app's websocket is connected to the server
Connected bool `json:"connected"` // a drone is connected to the app
Model string `json:"model"`
// Registration is the *SDK* registration state reported by the Fly App
// ("success", "failed", …) — not the aircraft's FAA/CAA registration number,
// which lives on the drone's logbook record.
Registration string `json:"registration"`
// Identity of the connected aircraft, as it reports itself. Serial and the
// two firmware versions resolve asynchronously after connect, each on its own
// schedule, so these fill in over several events rather than all at once.
Serial string `json:"serial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controllerFirmware"`
Telemetry Telemetry `json:"telemetry"`
LastSeenMs int64 `json:"lastSeenMs"`
}
// TrackPoint is one sample of the drone's GPS track (for the map trail).
@@ -84,6 +93,22 @@ func toInt(v any) (int, bool) {
return 0, false
}
// applyIdentity copies the aircraft's self-reported identity from a raw event.
// A field the event omits (or reports null) means "not resolved yet" — the SDK
// answers serial in seconds but firmware can take far longer — so an absent
// value never clears one an earlier event already delivered.
func applyIdentity(s *DeviceState, raw map[string]any) {
if v, ok := raw["serial"].(string); ok && v != "" {
s.Serial = v
}
if v, ok := raw["firmware"].(string); ok && v != "" {
s.Firmware = v
}
if v, ok := raw["controllerFirmware"].(string); ok && v != "" {
s.ControllerFirmware = v
}
}
// applyTelemetry copies any present telemetry fields from a raw event map.
func applyTelemetry(t *Telemetry, raw map[string]any) {
if v, ok := toInt(raw["satelliteCount"]); ok {
@@ -0,0 +1,58 @@
/// <reference path="../pb_data/types.d.ts" />
// Extends `drones` with the identity a connected aircraft reports over the wire
// (serial + firmware were already modelled; the firmware versions and the
// aircraft registration were not), so that connecting a drone in the Fly App can
// auto-populate its fleet entry in the Web App's Drones tab.
//
// Added fields:
// - firmware aircraft firmware package version (auto-filled)
// - controller_firmware remote-controller firmware version (auto-filled)
// - registration FAA/CAA aircraft registration number (hand-entered).
// Distinct from `operator_number`, which is the EU/
// Trafikstyrelsen *operator* ID displayed on the drone.
//
// `name` is also relaxed to optional: it is the pilot's custom label, and a
// drone auto-added on connection has no label until the pilot gives it one (the
// UI falls back to model + serial).
//
// Apply by copying into your PocketBase deployment's `pb_migrations/` directory
// and restarting. Written for PocketBase v0.22+/v0.23. Idempotent: each field is
// added only if absent, so re-running is a no-op.
//
// Depends on 1720300700_add_logbook.js (drones).
migrate(
(app) => {
const drones = app.findCollectionByNameOrId('drones')
const add = (field) => {
if (!drones.fields.find((f) => f.name === field.name)) drones.fields.add(new Field(field))
}
add({ name: 'firmware', type: 'text', max: 80 })
add({ name: 'controller_firmware', type: 'text', max: 80 })
add({ name: 'registration', type: 'text', max: 60 })
// The custom name is optional — auto-added drones arrive unnamed.
const name = drones.fields.find((f) => f.name === 'name')
if (name) name.required = false
// One fleet entry per serial: the auto-add path keys off the serial, and a
// duplicate would silently fork a drone's history across two records.
const idx = 'CREATE UNIQUE INDEX `idx_drones_serial` ON `drones` (`serial`) WHERE `serial` != \'\''
if (!drones.indexes.find((i) => i.includes('idx_drones_serial'))) drones.indexes.push(idx)
app.save(drones)
},
(app) => {
const drones = app.findCollectionByNameOrId('drones')
for (const n of ['firmware', 'controller_firmware', 'registration']) {
const f = drones.fields.find((x) => x.name === n)
if (f) drones.fields.removeById(f.id)
}
const name = drones.fields.find((f) => f.name === 'name')
if (name) name.required = true
drones.indexes = drones.indexes.filter((i) => !i.includes('idx_drones_serial'))
app.save(drones)
},
)
@@ -64,6 +64,13 @@ class DjiSdkBridge(
@Volatile private var firmwareVersion: String? = null
/**
* The *remote controller's* own firmware — a separate quantity from the
* aircraft's package version above, and legitimately read from the component
* (unlike the flight controller's version, see [fetchIdentity]).
*/
@Volatile private var controllerFirmwareVersion: String? = null
/** Invalidates in-flight identity retries when the product changes or drops. */
@Volatile private var identityGeneration = 0
@@ -162,12 +169,14 @@ class DjiSdkBridge(
}
override fun onProductConnect(product: BaseProduct?) {
resetIdentityCache()
emit(connectionMap(product))
bindComponentCallbacks(product)
startIdentityFetch()
}
override fun onProductChanged(product: BaseProduct?) {
resetIdentityCache()
emit(connectionMap(product))
bindComponentCallbacks(product)
startIdentityFetch()
@@ -205,6 +214,7 @@ class DjiSdkBridge(
"connected" to connected,
"model" to model,
"firmware" to (product?.firmwarePackageVersion ?: firmwareVersion),
"controllerFirmware" to controllerFirmwareVersion,
"serial" to serialNumber,
)
}
@@ -221,12 +231,30 @@ class DjiSdkBridge(
}
}
/**
* Forgets the identity of whatever aircraft was connected before, synchronously.
*
* Must run *before* the `connection` event is built: [connectionMap] falls back
* to these cached values (the SDK answers null for a fresh product), so on
* `onProductChanged` a serial left over from the previous airframe would be
* published as if it belonged to the new one — and the Web App would file the
* new aircraft's model and firmware against the old drone in the pilot's fleet.
* Volatile writes, so it is safe on the SDK's arbitrary callback threads; the
* worst case is dropping a just-resolved value that the retry chain re-fetches.
*/
private fun resetIdentityCache() {
serialNumber = null
firmwareVersion = null
controllerFirmwareVersion = null
}
/** Drops the cached identity and strands any retry queued for the old product. */
private fun clearIdentity() {
mainHandler.post {
identityGeneration++
serialNumber = null
firmwareVersion = null
controllerFirmwareVersion = null
}
}
@@ -278,7 +306,27 @@ class DjiSdkBridge(
// is the only source for this field — leave it blank until it is readable.
}
if ((serialNumber == null || firmwareVersion == null) && attempt + 1 < IDENTITY_MAX_ATTEMPTS) {
// The remote controller's firmware, by contrast, is exactly what its own
// component reports — a distinct field, not a stand-in for the aircraft's.
if (controllerFirmwareVersion == null) {
(product as? Aircraft)?.remoteController?.getFirmwareVersion(
object : CommonCallbacks.CompletionCallbackWith<String> {
override fun onSuccess(value: String?) {
if (value.isNullOrBlank()) return
mainHandler.post {
if (generation != identityGeneration || controllerFirmwareVersion == value) return@post
controllerFirmwareVersion = value
emitIdentity()
}
}
override fun onFailure(error: DJIError?) = Unit
},
)
}
val pending = serialNumber == null || firmwareVersion == null || controllerFirmwareVersion == null
if (pending && attempt + 1 < IDENTITY_MAX_ATTEMPTS) {
mainHandler.postDelayed({ fetchIdentity(generation, attempt + 1) }, IDENTITY_RETRY_MS)
}
}
@@ -296,6 +344,7 @@ class DjiSdkBridge(
"type" to "identity",
"serial" to serialNumber,
"firmware" to firmwareVersion,
"controllerFirmware" to controllerFirmwareVersion,
)
)
}
+2
View File
@@ -69,6 +69,8 @@ class FlightModel extends ChangeNotifier {
bool connected = false;
String? model;
String? firmwareVersion;
/// The remote controller's own firmware — distinct from the aircraft's above.
String? controllerFirmwareVersion;
String? serialNumber;
// ── Flight controller telemetry ────────────────────────────────────────────
+4
View File
@@ -153,6 +153,7 @@ class _HomePageState extends State<HomePage> {
_model.connected = event['connected'] as bool? ?? false;
_model.model = event['model'] as String?;
_model.firmwareVersion = event['firmware'] as String?;
_model.controllerFirmwareVersion = event['controllerFirmware'] as String?;
_model.serialNumber = event['serial'] as String?;
if (!_model.connected) _clearTelemetry();
_model.bump();
@@ -164,6 +165,8 @@ class _HomePageState extends State<HomePage> {
if (!_model.connected) break;
_model.serialNumber = event['serial'] as String? ?? _model.serialNumber;
_model.firmwareVersion = event['firmware'] as String? ?? _model.firmwareVersion;
_model.controllerFirmwareVersion =
event['controllerFirmware'] as String? ?? _model.controllerFirmwareVersion;
_model.bump();
break;
case 'telemetry':
@@ -268,6 +271,7 @@ class _HomePageState extends State<HomePage> {
_model.isRecording = false;
_model.recordSeconds = 0;
_model.firmwareVersion = null;
_model.controllerFirmwareVersion = null;
_model.serialNumber = null;
}
+1
View File
@@ -136,6 +136,7 @@ class _SettingsMenuPageState extends State<SettingsMenuPage> {
('Model', _m.model ?? ''),
('Serial Number', _m.serialNumber ?? ''),
('Firmware', _m.firmwareVersion ?? ''),
('Controller Firmware', _m.controllerFirmwareVersion ?? ''),
('MSDK', _m.sdkVersion),
])),
};
+9
View File
@@ -442,6 +442,15 @@ func (a *App) handleCreateDrone(w http.ResponseWriter, r *http.Request) {
a.doRelay(w, req)
}
// POST /bff/drones/auto → API Server /api/drones/auto
func (a *App) handleAutoDrone(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/drones/auto", bytes.NewReader(body))
req.Header.Set("Authorization", tokenOf(r))
req.Header.Set("Content-Type", "application/json")
a.doRelay(w, req)
}
// PATCH /bff/drones/{id} → API Server /api/drones/{id}
func (a *App) handleUpdateDrone(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -35,7 +35,7 @@
})()
</script>
<title>PilotVault — Control Panel</title>
<script type="module" crossorigin src="./assets/index-CHTNTK5I.js"></script>
<script type="module" crossorigin src="./assets/index-DrzdlcUJ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DgA6j1AP.css">
</head>
<body>
+1
View File
@@ -80,6 +80,7 @@ func main() {
// Logbook — drones, flights, and the compliance CSV export (scoping upstream)
mux.HandleFunc("GET /bff/drones", app.requireAuth(app.handleListDrones))
mux.HandleFunc("POST /bff/drones", app.requireAuth(app.handleCreateDrone))
mux.HandleFunc("POST /bff/drones/auto", app.requireAuth(app.handleAutoDrone))
mux.HandleFunc("PATCH /bff/drones/{id}", app.requireAuth(app.handleUpdateDrone))
mux.HandleFunc("DELETE /bff/drones/{id}", app.requireAuth(app.handleDeleteDrone))
mux.HandleFunc("GET /bff/flights", app.requireAuth(app.handleListFlights))
+16
View File
@@ -346,6 +346,22 @@ export async function createDrone(drone) {
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// Upsert the drone a connected device just reported, keyed by serial. Safe to
// call on every connection event: the server refreshes an existing entry rather
// than duplicating it, and answers { created, updated }.
export async function autoAddDrone(identity) {
try {
const r = await fetch('/bff/drones/auto', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(identity),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
} catch {
return { ok: false, status: 0, body: {} }
}
}
export async function updateDrone(id, drone) {
const r = await fetch(`/bff/drones/${encodeURIComponent(id)}`, {
method: 'PATCH',
+64 -1
View File
@@ -5,9 +5,10 @@ import BrandMark from './BrandMark.vue'
import Icon from './Icon.vue'
import Settings from './Settings.vue'
import Logbook from './Logbook.vue'
import Drones from './Drones.vue'
import Documents from './Documents.vue'
import Toggle from './settings/Toggle.vue'
import { getDevices, sendCommand, getOpenSkyStates, getOpenWeatherCurrent } from '../api.js'
import { getDevices, sendCommand, getOpenSkyStates, getOpenWeatherCurrent, autoAddDrone } from '../api.js'
import { formatTime, prefs } from '../prefs.js'
import { countryForPoint, bboxForCountry } from '../countries.js'
@@ -238,6 +239,7 @@ const NAV = [
['radio', 'Live flights'],
['route', 'Routes'],
['calendar', 'Schedule'],
['drone', 'Drones'],
['book', 'Logbook'],
['fileText', 'Documents'],
['server', 'Drives'],
@@ -309,6 +311,11 @@ const fleet = computed(() =>
}),
)
// Serials of aircraft connected right now — lets the Drones section flag which
// fleet entry is the drone in front of the pilot.
const connectedSerials = computed(() =>
ids.value.map((id) => (devices[id].connected ? devices[id].serial : '')).filter(Boolean),
)
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,
@@ -381,10 +388,62 @@ const orgLabel = computed(
() => props.organizationName || (props.role === 'superadmin' ? 'All organizations' : 'No organization'),
)
/* ---------- fleet auto-registration ---------- */
// Connecting a drone in the Fly App should be enough to get it into the pilot's
// fleet — nobody wants to retype a serial off an airframe. Every device update
// carrying a connected aircraft's serial is offered to the server, which upserts
// on serial (see POST /api/drones/auto).
//
// upsert() runs on every telemetry frame, so the identity tuple is remembered
// and only a *change* is sent: without that this would fire a request per frame.
// The tuple (not just the serial) is the key because serial and the two firmware
// versions resolve on their own schedules after connect — a later event filling
// firmware in has to reach the server too.
const sentIdentities = new Set()
const dronesView = ref(null)
async function autoRegister(d) {
if (!d.connected || !d.serial) return
const identity = {
serial: d.serial,
model: d.model || '',
firmware: d.firmware || '',
controllerFirmware: d.controllerFirmware || '',
}
const key = [identity.serial, identity.model, identity.firmware, identity.controllerFirmware].join('|')
if (sentIdentities.has(key)) return
sentIdentities.add(key)
const res = await autoAddDrone(identity)
if (!res.ok) {
// Only a *transient* failure earns a retry: forget the key so the next event
// tries again. A 4xx is the server's settled answer (409 = another org's
// airframe, 401 = session gone, 400 = it dislikes this payload) and will not
// change on its own — since upsert() runs on every telemetry frame, retrying
// one would mean a request per frame for as long as the drone stays connected.
const transient = res.status === 0 || res.status >= 500
if (transient) sentIdentities.delete(key)
return
}
if (res.body?.created || res.body?.updated) dronesView.value?.reload()
}
// A drone deleted from the fleet must be able to come back: without this, its
// identity tuple stays in sentIdentities and every later event short-circuits,
// so a drone deleted while connected would not reappear until a page reload.
function forgetIdentity(serial) {
if (!serial) return
for (const key of sentIdentities) {
if (key.startsWith(`${serial}|`)) sentIdentities.delete(key)
}
}
/* ---------- realtime plumbing ---------- */
function upsert(d) {
devices[d.deviceId] = d
autoRegister(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] = []
@@ -986,6 +1045,10 @@ onBeforeUnmount(() => {
<!-- ---------- Logbook ---------- -->
<Logbook v-else-if="active === 'Logbook'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
<!-- ---------- Drones ---------- -->
<Drones v-else-if="active === 'Drones'" ref="dronesView" :connected-serials="connectedSerials"
@deleted="forgetIdentity" />
<!-- ---------- Documents ---------- -->
<Documents v-else-if="active === 'Documents'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
+278
View File
@@ -0,0 +1,278 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import Icon from './Icon.vue'
import { getDrones, createDrone, updateDrone, deleteDrone } from '../api.js'
// Org/role scoping is enforced server-side, so this component needs no identity
// of its own — only which aircraft are connected right now.
const props = defineProps({
// Serials of the aircraft currently connected to a device, so the fleet can
// show which entry is the drone in front of the pilot right now.
connectedSerials: { type: Array, default: () => [] },
})
// Deleting a drone has to reach the auto-registration bookkeeping in Dashboard,
// or a drone deleted while connected would never be auto-added again.
const emit = defineEmits(['deleted'])
const badgeClass = {
success: 'bg-success-soft text-success-fg',
accent: 'bg-accent-soft text-accent-soft-fg',
neutral: 'bg-surface-2 text-ink-secondary',
}
const drones = ref([])
const loading = ref(false)
const loadErr = ref('')
async function loadAll() {
loading.value = true
loadErr.value = ''
const dr = await getDrones()
if (!dr.ok) {
loadErr.value =
dr.status === 503
? 'Drone storage is not configured on the API Server (service account missing).'
: 'Could not load your drones.'
}
drones.value = dr.drones
loading.value = false
}
onMounted(loadAll)
defineExpose({ reload: loadAll })
const connected = computed(() => new Set(props.connectedSerials.filter(Boolean)))
function isConnected(d) {
return !!d.serial && connected.value.has(d.serial)
}
/* ---------------- drone form ---------------- */
const C_CLASSES = ['', 'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6']
function blankDrone() {
return {
name: '', model: '', serial: '', firmware: '', controllerFirmware: '',
registration: '', operatorNumber: '', mtomGrams: '', isToy: false,
autologsFlights: false, cClass: '',
}
}
const showForm = ref(false)
const editingId = ref('')
const form = reactive(blankDrone())
const msg = ref('')
const saving = ref(false)
function newDrone() {
Object.assign(form, blankDrone())
editingId.value = ''
msg.value = ''
showForm.value = true
}
function editDrone(d) {
Object.assign(form, {
name: d.name || '', model: d.model || '', serial: d.serial || '',
firmware: d.firmware || '', controllerFirmware: d.controllerFirmware || '',
registration: d.registration || '', operatorNumber: d.operatorNumber || '',
mtomGrams: d.mtomGrams || '', isToy: !!d.isToy,
autologsFlights: !!d.autologsFlights, cClass: d.cClass || '',
})
editingId.value = d.id
msg.value = ''
showForm.value = true
}
function cancel() {
showForm.value = false
editingId.value = ''
}
async function save() {
msg.value = ''
if (!form.name.trim() && !form.model.trim() && !form.serial.trim()) {
msg.value = 'Give the drone a custom name, model or serial.'
return
}
saving.value = true
const payload = { ...form, mtomGrams: Number(form.mtomGrams) || 0 }
const res = editingId.value ? await updateDrone(editingId.value, payload) : await createDrone(payload)
saving.value = false
if (!res.ok) {
msg.value = res.body?.error || 'Could not save the drone.'
return
}
showForm.value = false
await loadAll()
}
const confirmId = ref('')
async function removeDrone(d) {
const res = await deleteDrone(d.id)
confirmId.value = ''
if (res.ok) {
emit('deleted', d.serial)
await loadAll()
} else msg.value = res.body?.error || 'Could not delete the drone.'
}
/* ---------------- headline stats ---------------- */
const stats = computed(() => ({
fleet: drones.value.length,
connected: drones.value.filter(isConnected).length,
unnamed: drones.value.filter((d) => !(d.name || '').trim()).length,
unregistered: drones.value.filter((d) => !(d.registration || '').trim()).length,
}))
</script>
<template>
<div class="mx-auto flex max-w-[1240px] flex-col gap-5 p-7">
<!-- header + actions -->
<div class="flex flex-wrap items-center gap-3">
<div>
<div class="eyebrow">Fleet</div>
<div class="mt-0.5 text-base font-semibold text-ink">Drones you fly</div>
</div>
<div class="ml-auto flex items-center gap-2">
<button class="btn-accent inline-flex items-center gap-2" @click="newDrone">
<Icon name="plus" :size="15" /> Add drone
</button>
</div>
</div>
<!-- stat row -->
<div class="grid grid-cols-4 gap-4 max-[900px]:grid-cols-2">
<div v-for="s in [
{ label: 'Drones in fleet', value: stats.fleet, tone: 'neutral' },
{ label: 'Connected now', value: stats.connected, tone: stats.connected ? 'success' : 'neutral' },
{ label: 'Awaiting a name', value: stats.unnamed, tone: 'neutral' },
{ label: 'No registration', value: stats.unregistered, tone: 'neutral' },
]" :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"
:class="s.tone === 'success' ? 'text-success-fg' : '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>
<!-- add / edit form -->
<div v-if="showForm" class="panel p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<div class="eyebrow">{{ editingId ? 'Edit drone' : 'New drone' }}</div>
<div class="mt-0.5 text-base font-semibold text-ink">Aircraft registry</div>
</div>
<button class="btn-icon" @click="cancel"><Icon name="x" :size="16" /></button>
</div>
<div class="grid grid-cols-3 gap-3 max-[760px]:grid-cols-1">
<label class="block"><span class="eyebrow mb-1 block">Custom name</span>
<input v-model="form.name" class="field" placeholder="Mavic-01" /></label>
<label class="block"><span class="eyebrow mb-1 block">Model</span>
<input v-model="form.model" class="field" placeholder="DJI Mavic 3 Enterprise" /></label>
<label class="block"><span class="eyebrow mb-1 block">Serial number</span>
<input v-model="form.serial" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Drone firmware</span>
<input v-model="form.firmware" class="field" placeholder="03.02.35.05" /></label>
<label class="block"><span class="eyebrow mb-1 block">Controller firmware</span>
<input v-model="form.controllerFirmware" class="field" placeholder="01.03.0800" /></label>
<label class="block"><span class="eyebrow mb-1 block">Registration (FAA/CAA)</span>
<input v-model="form.registration" class="field" placeholder="FA3X7K9PLM" /></label>
<label class="block"><span class="eyebrow mb-1 block">Operator no. (EU)</span>
<input v-model="form.operatorNumber" class="field" placeholder="DNK…" /></label>
<label class="block"><span class="eyebrow mb-1 block">MTOM (grams)</span>
<input v-model="form.mtomGrams" type="number" min="0" class="field" placeholder="920" /></label>
<label class="block"><span class="eyebrow mb-1 block">C-class</span>
<select v-model="form.cClass" class="field">
<option v-for="c in C_CLASSES" :key="c" :value="c">{{ c || '— none —' }}</option>
</select></label>
</div>
<p class="mt-3 text-xs text-ink-muted">
Model, serial and both firmware versions fill themselves in when the drone connects — anything
you type here is kept as-is.
</p>
<div class="mt-3 flex flex-wrap gap-6">
<label class="flex items-center gap-2 text-sm text-ink-secondary">
<input v-model="form.autologsFlights" type="checkbox" class="h-4 w-4 accent-[var(--accent)]" />
Auto-logs flights (onboard FDR)
</label>
<label class="flex items-center gap-2 text-sm text-ink-secondary">
<input v-model="form.isToy" type="checkbox" class="h-4 w-4 accent-[var(--accent)]" />
Toy drone (logbook-exempt)
</label>
</div>
<div class="mt-4 flex items-center gap-3">
<button class="btn-accent" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : editingId ? 'Save changes' : 'Add drone' }}
</button>
<button class="btn-ghost" @click="cancel">Cancel</button>
<span v-if="msg" class="text-sm text-danger-fg">{{ msg }}</span>
</div>
</div>
<!-- fleet table -->
<div class="panel overflow-hidden p-0">
<div v-if="loading" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
<div v-else-if="!drones.length" class="grid place-items-center px-5 py-16 text-center">
<Icon name="drone" :size="26" class="text-ink-muted" />
<div class="mt-3 text-sm font-medium text-ink-secondary">No drones yet</div>
<div class="mt-1 text-xs text-ink-muted">
Connect a drone in the Fly App and it lands here by itself — or add one by hand.
</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 ['Drone', 'Serial', 'Firmware', 'Ctrl FW', 'Registration', 'Class', '']" :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="d in drones" :key="d.id" class="border-b border-line last:border-0"
:class="editingId === d.id ? 'bg-accent-soft' : ''">
<td class="px-5 py-3">
<div class="flex items-center gap-2">
<span class="font-semibold text-ink">{{ d.displayName }}</span>
<span v-if="isConnected(d)" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold"
:class="badgeClass.success">
<Icon name="signal" :size="11" /> connected
</span>
</div>
<div v-if="d.name && d.model" class="text-xs text-ink-muted">{{ d.model }}</div>
<div v-else-if="!d.name" class="text-xs text-ink-muted">no custom name yet</div>
</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.serial || '—' }}</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.firmware || '—' }}</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.controllerFirmware || '—' }}</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ d.registration || '—' }}</td>
<td class="px-5 py-3">
<span v-if="d.cClass" class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold" :class="badgeClass.accent">{{ d.cClass }}</span>
<span v-else class="text-ink-muted">—</span>
<span v-if="d.isToy" class="ml-1 inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold" :class="badgeClass.neutral">toy</span>
</td>
<td class="whitespace-nowrap px-5 py-3 text-right">
<template v-if="confirmId === d.id">
<span class="mr-2 text-xs text-ink-muted">Delete?</span>
<button class="btn-ghost mr-1" @click="confirmId = ''">Cancel</button>
<button class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110" @click="removeDrone(d)">Delete</button>
</template>
<template v-else>
<button class="btn-ghost mr-1 inline-flex items-center gap-1" @click="editDrone(d)"><Icon name="sliders" :size="13" /> Edit</button>
<button class="btn-ghost inline-flex items-center gap-1" @click="confirmId = d.id"><Icon name="trash" :size="13" /></button>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
+168 -343
View File
@@ -1,10 +1,7 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import Icon from './Icon.vue'
import {
getDrones, createDrone, updateDrone, deleteDrone,
getFlights, createFlight, updateFlight, deleteFlight, exportLogbookUrl,
} from '../api.js'
import { getDrones, getFlights, createFlight, updateFlight, deleteFlight, exportLogbookUrl } from '../api.js'
const props = defineProps({
email: { type: String, default: '' },
@@ -13,23 +10,20 @@ const props = defineProps({
organizationName: { type: String, default: '' },
})
// Tones flightBadge() can return.
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 tab = ref('flights') // 'flights' | 'drones'
// The fleet itself is managed in the Drones section; it is loaded here only to
// resolve the flight form's drone picker and each entry's drone name.
const drones = ref([])
const flights = ref([])
const loading = ref(false)
const loadErr = ref('')
const droneById = computed(() => Object.fromEntries(drones.value.map((d) => [d.id, d])))
async function loadAll() {
loading.value = true
loadErr.value = ''
@@ -129,7 +123,7 @@ function cancelFlight() {
async function saveFlight() {
flightMsg.value = ''
if (!flightForm.drone) {
flightMsg.value = 'Select a drone first (add one on the Drones tab).'
flightMsg.value = 'Select a drone first (add one in the Drones section).'
return
}
savingFlight.value = true
@@ -153,75 +147,13 @@ async function removeFlight(f) {
if (res.ok) await loadAll()
}
/* ---------------- drone form ---------------- */
const C_CLASSES = ['', 'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6']
function blankDrone() {
return {
name: '', model: '', serial: '', operatorNumber: '',
mtomGrams: '', isToy: false, autologsFlights: false, cClass: '',
}
}
const showDroneForm = ref(false)
const editingDroneId = ref('')
const droneForm = reactive(blankDrone())
const droneMsg = ref('')
const savingDrone = ref(false)
function newDrone() {
Object.assign(droneForm, blankDrone())
editingDroneId.value = ''
droneMsg.value = ''
showDroneForm.value = true
}
function editDrone(d) {
Object.assign(droneForm, {
name: d.name || '', model: d.model || '', serial: d.serial || '',
operatorNumber: d.operatorNumber || '', mtomGrams: d.mtomGrams || '',
isToy: !!d.isToy, autologsFlights: !!d.autologsFlights, cClass: d.cClass || '',
})
editingDroneId.value = d.id
droneMsg.value = ''
showDroneForm.value = true
}
function cancelDrone() {
showDroneForm.value = false
editingDroneId.value = ''
}
async function saveDrone() {
droneMsg.value = ''
if (!droneForm.name.trim()) {
droneMsg.value = 'Give the drone a name.'
return
}
savingDrone.value = true
const payload = { ...droneForm, mtomGrams: Number(droneForm.mtomGrams) || 0 }
const res = editingDroneId.value
? await updateDrone(editingDroneId.value, payload)
: await createDrone(payload)
savingDrone.value = false
if (!res.ok) {
droneMsg.value = res.body?.error || 'Could not save the drone.'
return
}
showDroneForm.value = false
await loadAll()
}
const confirmDroneId = ref('')
async function removeDrone(d) {
const res = await deleteDrone(d.id)
confirmDroneId.value = ''
if (res.ok) await loadAll()
else droneMsg.value = res.body?.error || 'Could not delete the drone.'
}
/* ---------------- headline stats ---------------- */
const stats = computed(() => {
const total = flights.value.length
const flagged = flights.value.filter((f) => (f.compliance?.redFlags || []).length).length
const required = flights.value.filter((f) => f.compliance?.required).length
return { total, flagged, required, fleet: drones.value.length }
return { total, flagged, required }
})
</script>
@@ -229,16 +161,9 @@ const stats = computed(() => {
<div class="mx-auto flex max-w-[1240px] flex-col gap-5 p-7">
<!-- header + actions -->
<div class="flex flex-wrap items-center gap-3">
<div class="inline-flex rounded-lg border border-line bg-surface-1 p-0.5">
<button
v-for="t in [['flights', 'Flights'], ['drones', 'Drones']]"
:key="t[0]"
class="rounded-md px-3.5 py-1.5 text-sm font-semibold transition"
:class="tab === t[0] ? 'bg-accent-soft text-accent-soft-fg' : 'text-ink-secondary hover:text-ink'"
@click="tab = t[0]"
>
{{ t[1] }}
</button>
<div>
<div class="eyebrow">Logbook</div>
<div class="mt-0.5 text-base font-semibold text-ink">Flights (BEK 1649 §5)</div>
</div>
<div class="ml-auto flex items-center gap-2">
<a
@@ -248,22 +173,18 @@ const stats = computed(() => {
>
<Icon name="download" :size="15" /> Export CSV
</a>
<button v-if="tab === 'flights'" class="btn-accent inline-flex items-center gap-2" @click="newFlight">
<button class="btn-accent inline-flex items-center gap-2" @click="newFlight">
<Icon name="plus" :size="15" /> Log flight
</button>
<button v-else class="btn-accent inline-flex items-center gap-2" @click="newDrone">
<Icon name="plus" :size="15" /> Add drone
</button>
</div>
</div>
<!-- stat row -->
<div class="grid grid-cols-4 gap-4 max-[900px]:grid-cols-2">
<div class="grid grid-cols-3 gap-4 max-[900px]:grid-cols-1">
<div v-for="s in [
{ label: 'Flights logged', value: stats.total, tone: 'neutral' },
{ label: 'Require logbook', value: stats.required, tone: 'neutral' },
{ label: 'Compliance flags', value: stats.flagged, tone: stats.flagged ? 'danger' : 'success' },
{ label: 'Registered drones', value: stats.fleet, tone: 'neutral' },
]" :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"
@@ -275,277 +196,181 @@ const stats = computed(() => {
<div v-if="loadErr" class="panel border-danger/40 p-4 text-sm text-danger-fg">{{ loadErr }}</div>
<!-- ============ FLIGHTS ============ -->
<template v-if="tab === 'flights'">
<!-- add / edit form -->
<div v-if="showFlightForm" class="panel p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<div class="eyebrow">{{ editingFlightId ? 'Edit entry' : 'New entry' }}</div>
<div class="mt-0.5 text-base font-semibold text-ink">Logbook flight (BEK 1649 §5)</div>
</div>
<button class="btn-icon" @click="cancelFlight"><Icon name="x" :size="16" /></button>
<!-- add / edit form -->
<div v-if="showFlightForm" class="panel p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<div class="eyebrow">{{ editingFlightId ? 'Edit entry' : 'New entry' }}</div>
<div class="mt-0.5 text-base font-semibold text-ink">Logbook flight (BEK 1649 §5)</div>
</div>
<button class="btn-icon" @click="cancelFlight"><Icon name="x" :size="16" /></button>
</div>
<div class="grid grid-cols-3 gap-3 max-[760px]:grid-cols-1">
<label class="block">
<span class="eyebrow mb-1 block">Date</span>
<input v-model="flightForm.operationDate" type="date" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Start</span>
<input v-model="flightForm.startTime" type="time" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">End</span>
<input v-model="flightForm.endTime" type="time" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Drone</span>
<select v-model="flightForm.drone" class="field">
<option v-if="!drones.length" value="">— add a drone first —</option>
<option v-for="d in drones" :key="d.id" :value="d.id">{{ d.name }}{{ d.model ? ` · ${d.model}` : '' }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Max altitude (m AGL)</span>
<input v-model="flightForm.maxAltitudeAgl" type="number" min="0" class="field" placeholder="120" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Area / route</span>
<input v-model="flightForm.areaRoute" class="field" placeholder="Field N of Roskilde, grid survey" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Remote pilot name</span>
<input v-model="flightForm.pilotName" class="field" placeholder="Full name" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Certificate ref</span>
<input v-model="flightForm.certificateRef" class="field" placeholder="A2 / STS cert no." />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Logging path</span>
<select v-model="flightForm.loggingPath" class="field">
<option v-for="p in PATHS" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Category</span>
<select v-model="flightForm.category" class="field">
<option v-for="c in CATEGORIES" :key="c.value" :value="c.value">{{ c.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Purpose</span>
<select v-model="flightForm.purpose" class="field">
<option v-for="p in PURPOSES" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Authorisation ref</span>
<input v-model="flightForm.authorisationRef" class="field" placeholder="Specific-category ref" />
</label>
</div>
<label class="mt-3 block">
<span class="eyebrow mb-1 block">FDR log URL (automatic path)</span>
<input v-model="flightForm.rawFdrLogUrl" class="field" placeholder="Link to the stored flight-data-recorder export" />
<div class="grid grid-cols-3 gap-3 max-[760px]:grid-cols-1">
<label class="block">
<span class="eyebrow mb-1 block">Date</span>
<input v-model="flightForm.operationDate" type="date" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Start</span>
<input v-model="flightForm.startTime" type="time" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">End</span>
<input v-model="flightForm.endTime" type="time" class="field" />
</label>
<button class="mt-4 flex items-center gap-1.5 text-sm font-semibold text-accent" @click="showDetails = !showDetails">
<Icon :name="showDetails ? 'x' : 'plus'" :size="14" /> Operational details (weather, airspace, incidents)
<label class="block">
<span class="eyebrow mb-1 block">Drone</span>
<select v-model="flightForm.drone" class="field">
<option v-if="!drones.length" value="">— add a drone first —</option>
<option v-for="d in drones" :key="d.id" :value="d.id">{{ d.displayName }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Max altitude (m AGL)</span>
<input v-model="flightForm.maxAltitudeAgl" type="number" min="0" class="field" placeholder="120" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Area / route</span>
<input v-model="flightForm.areaRoute" class="field" placeholder="Field N of Roskilde, grid survey" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Remote pilot name</span>
<input v-model="flightForm.pilotName" class="field" placeholder="Full name" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Certificate ref</span>
<input v-model="flightForm.certificateRef" class="field" placeholder="A2 / STS cert no." />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Logging path</span>
<select v-model="flightForm.loggingPath" class="field">
<option v-for="p in PATHS" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Category</span>
<select v-model="flightForm.category" class="field">
<option v-for="c in CATEGORIES" :key="c.value" :value="c.value">{{ c.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Purpose</span>
<select v-model="flightForm.purpose" class="field">
<option v-for="p in PURPOSES" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Authorisation ref</span>
<input v-model="flightForm.authorisationRef" class="field" placeholder="Specific-category ref" />
</label>
</div>
<label class="mt-3 block">
<span class="eyebrow mb-1 block">FDR log URL (automatic path)</span>
<input v-model="flightForm.rawFdrLogUrl" class="field" placeholder="Link to the stored flight-data-recorder export" />
</label>
<button class="mt-4 flex items-center gap-1.5 text-sm font-semibold text-accent" @click="showDetails = !showDetails">
<Icon :name="showDetails ? 'x' : 'plus'" :size="14" /> Operational details (weather, airspace, incidents)
</button>
<div v-if="showDetails" class="mt-3 grid grid-cols-2 gap-3 max-[760px]:grid-cols-1">
<label class="block"><span class="eyebrow mb-1 block">Weather / wind</span>
<input v-model="flightForm.weather" class="field" placeholder="6 m/s NW, CAVOK" /></label>
<label class="block"><span class="eyebrow mb-1 block">Airspace / NOTAM ref</span>
<input v-model="flightForm.airspaceRef" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Observer</span>
<input v-model="flightForm.observer" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Incidents / anomalies</span>
<input v-model="flightForm.incidents" class="field" placeholder="RTH trigger, GPS dropout…" /></label>
<label class="col-span-2 block max-[760px]:col-span-1"><span class="eyebrow mb-1 block">Notes</span>
<textarea v-model="flightForm.notes" rows="2" class="field"></textarea></label>
</div>
<div class="mt-4 flex items-center gap-3">
<button class="btn-accent" :disabled="savingFlight" @click="saveFlight">
{{ savingFlight ? 'Saving…' : editingFlightId ? 'Save changes' : 'Log flight' }}
</button>
<div v-if="showDetails" class="mt-3 grid grid-cols-2 gap-3 max-[760px]:grid-cols-1">
<label class="block"><span class="eyebrow mb-1 block">Weather / wind</span>
<input v-model="flightForm.weather" class="field" placeholder="6 m/s NW, CAVOK" /></label>
<label class="block"><span class="eyebrow mb-1 block">Airspace / NOTAM ref</span>
<input v-model="flightForm.airspaceRef" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Observer</span>
<input v-model="flightForm.observer" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Incidents / anomalies</span>
<input v-model="flightForm.incidents" class="field" placeholder="RTH trigger, GPS dropout…" /></label>
<label class="col-span-2 block max-[760px]:col-span-1"><span class="eyebrow mb-1 block">Notes</span>
<textarea v-model="flightForm.notes" rows="2" class="field"></textarea></label>
</div>
<div class="mt-4 flex items-center gap-3">
<button class="btn-accent" :disabled="savingFlight" @click="saveFlight">
{{ savingFlight ? 'Saving…' : editingFlightId ? 'Save changes' : 'Log flight' }}
</button>
<button class="btn-ghost" @click="cancelFlight">Cancel</button>
<span v-if="flightMsg" class="text-sm text-danger-fg">{{ flightMsg }}</span>
</div>
<button class="btn-ghost" @click="cancelFlight">Cancel</button>
<span v-if="flightMsg" class="text-sm text-danger-fg">{{ flightMsg }}</span>
</div>
</div>
<!-- flights table -->
<div class="panel overflow-hidden p-0">
<div v-if="loading" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
<div v-else-if="!flights.length" class="grid place-items-center px-5 py-16 text-center">
<Icon name="book" :size="26" class="text-ink-muted" />
<div class="mt-3 text-sm font-medium text-ink-secondary">No flights logged yet</div>
<div class="mt-1 text-xs text-ink-muted">Log your first operation to start the 5-year retention record.</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 ['Date', 'Drone', 'Area / route', 'Alt', 'Pilot', 'Compliance', '']" :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="f in flights" :key="f.id">
<tr class="border-b border-line last:border-0" :class="editingFlightId === f.id ? 'bg-accent-soft' : ''">
<td class="whitespace-nowrap px-5 py-3 font-mono text-ink">
{{ (f.operationDate || '').slice(0, 10) }}
<span v-if="f.startTime" class="text-ink-muted">{{ f.startTime }}</span>
</td>
<td class="px-5 py-3 text-ink-secondary">{{ f.droneName || '—' }}</td>
<td class="max-w-[220px] truncate px-5 py-3 text-ink-secondary" :title="f.areaRoute">{{ f.areaRoute || '—' }}</td>
<td class="px-5 py-3 font-mono text-ink-secondary">{{ f.maxAltitudeAgl ? f.maxAltitudeAgl + ' m' : '—' }}</td>
<td class="px-5 py-3 text-ink-secondary">{{ f.pilotName || '—' }}</td>
<td class="px-5 py-3">
<button
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="badgeClass[flightBadge(f).tone]"
@click="toggleRow(f.id)"
>
<Icon v-if="flightBadge(f).tone === 'danger'" name="alertTriangle" :size="12" />
<Icon v-else-if="flightBadge(f).tone === 'success'" name="check" :size="12" />
{{ flightBadge(f).label }}
</button>
</td>
<td class="whitespace-nowrap px-5 py-3 text-right">
<template v-if="confirmFlightId === f.id">
<span class="mr-2 text-xs text-ink-muted">Delete?</span>
<button class="btn-ghost mr-1" @click="confirmFlightId = ''">Cancel</button>
<button class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110" @click="removeFlight(f)">Delete</button>
</template>
<template v-else>
<button class="btn-ghost mr-1 inline-flex items-center gap-1" @click="editFlight(f)"><Icon name="sliders" :size="13" /> Edit</button>
<button class="btn-ghost inline-flex items-center gap-1" @click="confirmFlightId = f.id"><Icon name="trash" :size="13" /></button>
</template>
</td>
</tr>
<tr v-if="openRow === f.id" class="border-b border-line bg-surface-2">
<td colspan="7" class="px-5 py-3">
<div class="flex flex-wrap gap-x-8 gap-y-1.5 text-xs">
<span class="text-ink-secondary">Logging path: <b class="text-ink">{{ f.compliance?.loggingPath || '—' }}</b></span>
<span class="text-ink-secondary">Category: <b class="text-ink">{{ f.category || '—' }}</b></span>
<span class="text-ink-secondary">Retain until: <b class="font-mono text-ink">{{ (f.retentionUntil || '').slice(0, 10) || '—' }}</b></span>
<span v-if="f.compliance?.exempt" class="text-ink-secondary">Exempt: <b class="text-ink">{{ f.compliance.exemptReason }}</b></span>
</div>
<ul v-if="(f.compliance?.redFlags || []).length" class="mt-2 space-y-1">
<li v-for="(rf, i) in f.compliance.redFlags" :key="i" class="flex items-start gap-2 text-xs text-danger-fg">
<Icon name="alertTriangle" :size="13" class="mt-px shrink-0" /> {{ rf }}
</li>
</ul>
<div v-else-if="!f.compliance?.exempt" class="mt-2 text-xs text-success-fg">No compliance gaps detected.</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- flights table -->
<div class="panel overflow-hidden p-0">
<div v-if="loading" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
<div v-else-if="!flights.length" class="grid place-items-center px-5 py-16 text-center">
<Icon name="book" :size="26" class="text-ink-muted" />
<div class="mt-3 text-sm font-medium text-ink-secondary">No flights logged yet</div>
<div class="mt-1 text-xs text-ink-muted">Log your first operation to start the 5-year retention record.</div>
</div>
</template>
<!-- ============ DRONES ============ -->
<template v-else>
<div v-if="showDroneForm" class="panel p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<div class="eyebrow">{{ editingDroneId ? 'Edit drone' : 'New drone' }}</div>
<div class="mt-0.5 text-base font-semibold text-ink">Aircraft registry</div>
</div>
<button class="btn-icon" @click="cancelDrone"><Icon name="x" :size="16" /></button>
</div>
<div class="grid grid-cols-3 gap-3 max-[760px]:grid-cols-1">
<label class="block"><span class="eyebrow mb-1 block">Name</span>
<input v-model="droneForm.name" class="field" placeholder="Mavic-01" /></label>
<label class="block"><span class="eyebrow mb-1 block">Model</span>
<input v-model="droneForm.model" class="field" placeholder="DJI Mavic 3 Enterprise" /></label>
<label class="block"><span class="eyebrow mb-1 block">Serial</span>
<input v-model="droneForm.serial" class="field" /></label>
<label class="block"><span class="eyebrow mb-1 block">Operator no.</span>
<input v-model="droneForm.operatorNumber" class="field" placeholder="DNK…" /></label>
<label class="block"><span class="eyebrow mb-1 block">MTOM (grams)</span>
<input v-model="droneForm.mtomGrams" type="number" min="0" class="field" placeholder="920" /></label>
<label class="block"><span class="eyebrow mb-1 block">C-class</span>
<select v-model="droneForm.cClass" class="field">
<option v-for="c in C_CLASSES" :key="c" :value="c">{{ c || '— none —' }}</option>
</select></label>
</div>
<div class="mt-3 flex flex-wrap gap-6">
<label class="flex items-center gap-2 text-sm text-ink-secondary">
<input v-model="droneForm.autologsFlights" type="checkbox" class="h-4 w-4 accent-[var(--accent)]" />
Auto-logs flights (onboard FDR)
</label>
<label class="flex items-center gap-2 text-sm text-ink-secondary">
<input v-model="droneForm.isToy" type="checkbox" class="h-4 w-4 accent-[var(--accent)]" />
Toy drone (logbook-exempt)
</label>
</div>
<div class="mt-4 flex items-center gap-3">
<button class="btn-accent" :disabled="savingDrone" @click="saveDrone">
{{ savingDrone ? 'Saving…' : editingDroneId ? 'Save changes' : 'Add drone' }}
</button>
<button class="btn-ghost" @click="cancelDrone">Cancel</button>
<span v-if="droneMsg" class="text-sm text-danger-fg">{{ droneMsg }}</span>
</div>
</div>
<div class="panel overflow-hidden p-0">
<div v-if="loading" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
<div v-else-if="!drones.length" class="grid place-items-center px-5 py-16 text-center">
<Icon name="drone" :size="26" class="text-ink-muted" />
<div class="mt-3 text-sm font-medium text-ink-secondary">No drones registered</div>
<div class="mt-1 text-xs text-ink-muted">Register the airframes you fly to log flights against them.</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 ['Name', 'Model', 'MTOM', 'Class', 'FDR', '']" :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="d in drones" :key="d.id" class="border-b border-line last:border-0" :class="editingDroneId === d.id ? 'bg-accent-soft' : ''">
<td class="px-5 py-3 font-semibold text-ink">{{ d.name }}</td>
<td class="px-5 py-3 text-ink-secondary">{{ d.model || '—' }}</td>
<td class="px-5 py-3 font-mono text-ink-secondary">{{ d.mtomGrams ? d.mtomGrams + ' g' : '—' }}</td>
<td class="px-5 py-3">
<span v-if="d.cClass" class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold" :class="badgeClass.accent">{{ d.cClass }}</span>
<span v-else class="text-ink-muted">—</span>
<span v-if="d.isToy" class="ml-1 inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold" :class="badgeClass.neutral">toy</span>
<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 ['Date', 'Drone', 'Area / route', 'Alt', 'Pilot', 'Compliance', '']" :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="f in flights" :key="f.id">
<tr class="border-b border-line last:border-0" :class="editingFlightId === f.id ? 'bg-accent-soft' : ''">
<td class="whitespace-nowrap px-5 py-3 font-mono text-ink">
{{ (f.operationDate || '').slice(0, 10) }}
<span v-if="f.startTime" class="text-ink-muted">{{ f.startTime }}</span>
</td>
<td class="px-5 py-3 text-ink-secondary">{{ f.droneName || '—' }}</td>
<td class="max-w-[220px] truncate px-5 py-3 text-ink-secondary" :title="f.areaRoute">{{ f.areaRoute || '—' }}</td>
<td class="px-5 py-3 font-mono text-ink-secondary">{{ f.maxAltitudeAgl ? f.maxAltitudeAgl + ' m' : '—' }}</td>
<td class="px-5 py-3 text-ink-secondary">{{ f.pilotName || '—' }}</td>
<td class="px-5 py-3">
<span class="text-xs" :class="d.autologsFlights ? 'text-success-fg' : 'text-ink-muted'">{{ d.autologsFlights ? 'yes' : 'no' }}</span>
<button
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="badgeClass[flightBadge(f).tone]"
@click="toggleRow(f.id)"
>
<Icon v-if="flightBadge(f).tone === 'danger'" name="alertTriangle" :size="12" />
<Icon v-else-if="flightBadge(f).tone === 'success'" name="check" :size="12" />
{{ flightBadge(f).label }}
</button>
</td>
<td class="whitespace-nowrap px-5 py-3 text-right">
<template v-if="confirmDroneId === d.id">
<template v-if="confirmFlightId === f.id">
<span class="mr-2 text-xs text-ink-muted">Delete?</span>
<button class="btn-ghost mr-1" @click="confirmDroneId = ''">Cancel</button>
<button class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110" @click="removeDrone(d)">Delete</button>
<button class="btn-ghost mr-1" @click="confirmFlightId = ''">Cancel</button>
<button class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110" @click="removeFlight(f)">Delete</button>
</template>
<template v-else>
<button class="btn-ghost mr-1 inline-flex items-center gap-1" @click="editDrone(d)"><Icon name="sliders" :size="13" /> Edit</button>
<button class="btn-ghost inline-flex items-center gap-1" @click="confirmDroneId = d.id"><Icon name="trash" :size="13" /></button>
<button class="btn-ghost mr-1 inline-flex items-center gap-1" @click="editFlight(f)"><Icon name="sliders" :size="13" /> Edit</button>
<button class="btn-ghost inline-flex items-center gap-1" @click="confirmFlightId = f.id"><Icon name="trash" :size="13" /></button>
</template>
</td>
</tr>
</tbody>
</table>
</div>
<tr v-if="openRow === f.id" class="border-b border-line bg-surface-2">
<td colspan="7" class="px-5 py-3">
<div class="flex flex-wrap gap-x-8 gap-y-1.5 text-xs">
<span class="text-ink-secondary">Logging path: <b class="text-ink">{{ f.compliance?.loggingPath || '—' }}</b></span>
<span class="text-ink-secondary">Category: <b class="text-ink">{{ f.category || '—' }}</b></span>
<span class="text-ink-secondary">Retain until: <b class="font-mono text-ink">{{ (f.retentionUntil || '').slice(0, 10) || '—' }}</b></span>
<span v-if="f.compliance?.exempt" class="text-ink-secondary">Exempt: <b class="text-ink">{{ f.compliance.exemptReason }}</b></span>
</div>
<ul v-if="(f.compliance?.redFlags || []).length" class="mt-2 space-y-1">
<li v-for="(rf, i) in f.compliance.redFlags" :key="i" class="flex items-start gap-2 text-xs text-danger-fg">
<Icon name="alertTriangle" :size="13" class="mt-px shrink-0" /> {{ rf }}
</li>
</ul>
<div v-else-if="!f.compliance?.exempt" class="mt-2 text-xs text-success-fg">No compliance gaps detected.</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
</div>
</template>