Add drone-pilot logbook system (BEK 1649 §5)

Model Denmark's Dronebekendtgørelsen § 5 (on top of EU 2019/947) across all
three tiers: schema, API Server, and Web App.

Schema (migration 1720300700_add_logbook.js): two collections — `drones`
(classification inputs: mtom, is_toy, autologs, c_class, operator no.) and
`flights` (§5 minimum content + category/purpose/logging-path + operational
maturity + a retention_until computed as operation_date + 5y). Locked API
rules; access flows through the service account like users/orgs.

API Server (logbook.go, logbook_export.go): /api/drones and /api/flights CRUD
with per-role scoping in Go (user→own, admin→org, superadmin→all), plus
GET /api/logbook/export (CSV — the "readable electronic format" for
Trafikstyrelsen / pending police disclosure). Compliance is computed
server-side per flight: exemption (toy / club-area / <250 g hobby), effective
logging path, and red flags (autologs-without-FDR, specific-category-without-
authorisation, missing §5 fields, past retention). Manual-path saves missing a
§5 field are blocked (422).

Web App: BFF proxies (export preserves the CSV Content-Type/Disposition),
api.js client fns, and a Logbook.vue view (Flights/Drones tabs, inline forms,
compliance badges + expandable detail, Export CSV) wired into Dashboard.vue,
replacing the placeholder. Includes the rebuilt embedded dist bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-13 13:11:39 +02:00
co-authored by Claude Opus 4.8
parent 407e34bf0d
commit e9b27530ec
14 changed files with 1843 additions and 23 deletions
+817
View File
@@ -0,0 +1,817 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
)
// The logbook models Denmark's BEK nr. 1649 af 12/12/2023 ("Dronebekendtgørelsen")
// § 5 on top of the EU 2019/947 framework. Two collections back it: `drones`
// (airframes + the classification inputs that drive exemption / logging-path
// logic) and `flights` (the log entries). Like user/org management, all access
// flows through the superuser service account; per-role scoping is enforced here
// in Go, and the collections' own API rules stay locked.
//
// Scoping:
// - user → only their own flights; drones in their org (or unowned).
// - admin → all flights + drones in their organization.
// - superadmin → everything.
// requireUser gates a handler on any authenticated caller (and, like the other
// managed collections, on the service account being configured). The caller is
// stashed on the request context for the handler to read via caller(r).
func (s *Server) requireUser(next http.HandlerFunc) http.HandlerFunc {
return s.requireRole(next, func(c *callerIdentity) bool { return true }, "authentication required")
}
// ---------------------------------------------------------------------------
// PocketBase record shapes (snake_case, as stored) + client-facing views.
// ---------------------------------------------------------------------------
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"`
}
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"`
}
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,
AutologsFlights: d.AutologsFlights, CClass: d.CClass,
Organization: d.Organization, Created: d.Created,
}
}
type flightRecord struct {
ID string `json:"id"`
OperationDate string `json:"operation_date"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Drone string `json:"drone"`
AreaRoute string `json:"area_route"`
RouteGeoJSON json.RawMessage `json:"route_geojson"`
MaxAltitudeAGL float64 `json:"max_altitude_agl"`
RemotePilot string `json:"remote_pilot"`
PilotName string `json:"pilot_name"`
CertificateRef string `json:"certificate_ref"`
Category string `json:"category"`
Purpose string `json:"purpose"`
LoggingPath string `json:"logging_path"`
RawFDRLogURL string `json:"raw_fdr_log_url"`
AuthorisationRef string `json:"authorisation_ref"`
Weather string `json:"weather"`
AirspaceRef string `json:"airspace_ref"`
Observer string `json:"observer"`
Incidents string `json:"incidents"`
Notes string `json:"notes"`
Organization string `json:"organization"`
RetentionUntil string `json:"retention_until"`
Created string `json:"created"`
}
type flightView struct {
ID string `json:"id"`
OperationDate string `json:"operationDate"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Drone string `json:"drone"`
DroneName string `json:"droneName"`
AreaRoute string `json:"areaRoute"`
RouteGeoJSON json.RawMessage `json:"routeGeojson,omitempty"`
MaxAltitudeAGL float64 `json:"maxAltitudeAgl"`
RemotePilot string `json:"remotePilot"`
PilotName string `json:"pilotName"`
CertificateRef string `json:"certificateRef"`
Category string `json:"category"`
Purpose string `json:"purpose"`
LoggingPath string `json:"loggingPath"`
RawFDRLogURL string `json:"rawFdrLogUrl"`
AuthorisationRef string `json:"authorisationRef"`
Weather string `json:"weather"`
AirspaceRef string `json:"airspaceRef"`
Observer string `json:"observer"`
Incidents string `json:"incidents"`
Notes string `json:"notes"`
Organization string `json:"organization"`
RetentionUntil string `json:"retentionUntil"`
Created string `json:"created"`
Compliance compliance `json:"compliance"`
}
// compliance is the server-computed regulatory assessment for a single flight.
type compliance struct {
Required bool `json:"required"` // does § 5 require a logbook entry?
Exempt bool `json:"exempt"` // exempt from the logbook obligation
ExemptReason string `json:"exemptReason"` // why, when exempt
LoggingPath string `json:"loggingPath"` // automatic | manual
RedFlags []string `json:"redFlags"` // compliance gaps to surface
}
func (f flightRecord) view(drones map[string]droneRecord) flightView {
var d *droneRecord
if dr, ok := drones[f.Drone]; ok {
d = &dr
}
name := ""
if d != nil {
name = d.Name
}
return flightView{
ID: f.ID, OperationDate: f.OperationDate, StartTime: f.StartTime, EndTime: f.EndTime,
Drone: f.Drone, DroneName: name, AreaRoute: f.AreaRoute, RouteGeoJSON: f.RouteGeoJSON,
MaxAltitudeAGL: f.MaxAltitudeAGL, RemotePilot: f.RemotePilot, PilotName: f.PilotName,
CertificateRef: f.CertificateRef, Category: f.Category, Purpose: f.Purpose,
LoggingPath: f.LoggingPath, RawFDRLogURL: f.RawFDRLogURL, AuthorisationRef: f.AuthorisationRef,
Weather: f.Weather, AirspaceRef: f.AirspaceRef, Observer: f.Observer,
Incidents: f.Incidents, Notes: f.Notes, Organization: f.Organization,
RetentionUntil: f.RetentionUntil, Created: f.Created,
Compliance: computeCompliance(f, d),
}
}
// ---------------------------------------------------------------------------
// Compliance logic (BEK 1649 § 5 + the checklist's red flags).
// ---------------------------------------------------------------------------
// effectiveLoggingPath is the stored path, or — when blank — derived from the
// drone's capability (autologging → automatic, else manual).
func effectiveLoggingPath(f flightRecord, d *droneRecord) string {
if p := strings.TrimSpace(f.LoggingPath); p != "" {
return p
}
if d != nil && d.AutologsFlights {
return "automatic"
}
return "manual"
}
// manualRequired are the § 5 minimum fields a manual-path entry must carry.
// Returns the human labels of any that are missing.
func missingManualFields(f flightRecord) []string {
var missing []string
if strings.TrimSpace(f.OperationDate) == "" {
missing = append(missing, "operation date")
}
if strings.TrimSpace(f.StartTime) == "" {
missing = append(missing, "start time")
}
if strings.TrimSpace(f.EndTime) == "" {
missing = append(missing, "end time")
}
if strings.TrimSpace(f.Drone) == "" {
missing = append(missing, "drone")
}
if strings.TrimSpace(f.AreaRoute) == "" {
missing = append(missing, "area or route flown")
}
if f.MaxAltitudeAGL <= 0 {
missing = append(missing, "maximum altitude (AGL)")
}
if strings.TrimSpace(f.PilotName) == "" {
missing = append(missing, "remote pilot name")
}
return missing
}
func computeCompliance(f flightRecord, d *droneRecord) compliance {
c := compliance{RedFlags: []string{}}
// 1. Exemption (BEK 1649 § 5 scope).
switch {
case d != nil && d.IsToy:
c.Exempt, c.ExemptReason = true, "toy drone"
case f.Purpose == "club_area":
c.Exempt, c.ExemptReason = true, "flown within a model-flying club's designated area"
case d != nil && d.MtomGrams > 0 && d.MtomGrams < 250 && f.Purpose == "hobby":
c.Exempt, c.ExemptReason = true, "private hobby flight under 250 g"
}
c.Required = !c.Exempt
// 2. Logging path.
c.LoggingPath = effectiveLoggingPath(f, d)
// 3. Red flags (compliance gaps, not just missing data).
if d != nil && d.AutologsFlights && c.LoggingPath == "automatic" && strings.TrimSpace(f.RawFDRLogURL) == "" {
c.RedFlags = append(c.RedFlags, "Automatic-logging drone but no FDR log stored for this operation")
}
if f.Category == "specific" && strings.TrimSpace(f.AuthorisationRef) == "" {
c.RedFlags = append(c.RedFlags, "Specific-category flight with no linked authorisation reference")
}
if c.Required && c.LoggingPath == "manual" {
for _, m := range missingManualFields(f) {
c.RedFlags = append(c.RedFlags, "Missing § 5 field: "+m)
}
}
if until := parseDay(f.RetentionUntil); !until.IsZero() && time.Now().After(until) {
c.RedFlags = append(c.RedFlags, "Past the 5-year retention window — archive before any cleanup")
}
return c
}
// parseDay parses the leading YYYY-MM-DD of a PocketBase date string.
func parseDay(s string) time.Time {
if len(s) >= 10 {
if t, err := time.Parse("2006-01-02", s[:10]); err == nil {
return t
}
}
return time.Time{}
}
// addFiveYears returns operation_date + 5 years as YYYY-MM-DD (the § 5 retention
// boundary, counted from the operation date). "" if the date can't be parsed.
func addFiveYears(dateStr string) string {
t := parseDay(dateStr)
if t.IsZero() {
return ""
}
return t.AddDate(5, 0, 0).Format("2006-01-02")
}
// ---------------------------------------------------------------------------
// PocketBase helpers.
// ---------------------------------------------------------------------------
// listRecords fetches a collection's records (up to 500) with an optional filter
// and sort, decoding items into out (a *struct{ Items []T }).
func (s *Server) listRecords(ctx context.Context, collection, filter, sort string, out any) (int, error) {
path := "/api/collections/" + collection + "/records?perPage=500"
if sort != "" {
path += "&sort=" + url.QueryEscape(sort)
}
if filter != "" {
path += "&filter=" + url.QueryEscape(filter)
}
data, status, err := s.admin.do(ctx, http.MethodGet, path, nil)
if err != nil {
return 0, err
}
if status != http.StatusOK {
return status, nil
}
return status, json.Unmarshal(data, out)
}
// dronesInScope returns an id→record map of the drones the caller may see.
func (s *Server) dronesInScope(ctx context.Context, who *callerIdentity) (map[string]droneRecord, error) {
var list struct {
Items []droneRecord `json:"items"`
}
if _, err := s.listRecords(ctx, "drones", droneScopeFilter(who), "name", &list); err != nil {
return nil, err
}
m := make(map[string]droneRecord, len(list.Items))
for _, d := range list.Items {
m[d.ID] = d
}
return m, nil
}
func droneScopeFilter(who *callerIdentity) string {
if who.isSuperadmin() {
return ""
}
if who.OrgID != "" {
return "organization = \"" + who.OrgID + "\" || organization = \"\""
}
return "organization = \"\""
}
func flightScopeFilter(who *callerIdentity) string {
if who.isSuperadmin() {
return ""
}
if who.isManager() && who.OrgID != "" {
return "organization = \"" + who.OrgID + "\""
}
return "remote_pilot = \"" + who.ID + "\""
}
func canManageDrone(who *callerIdentity, d droneRecord) bool {
if who.isSuperadmin() {
return true
}
if who.OrgID != "" {
return d.Organization == who.OrgID
}
return d.Organization == ""
}
func canManageFlight(who *callerIdentity, f flightRecord) bool {
if who.isSuperadmin() {
return true
}
if who.isManager() && who.OrgID != "" && f.Organization == who.OrgID {
return true
}
return f.RemotePilot == who.ID
}
// getDrone fetches one drone record by id.
func (s *Server) getDrone(ctx context.Context, id string) (droneRecord, int, error) {
var d droneRecord
data, status, err := s.admin.do(ctx, http.MethodGet,
"/api/collections/drones/records/"+url.PathEscape(id), nil)
if err != nil {
return d, 0, err
}
if status == http.StatusOK {
_ = json.Unmarshal(data, &d)
}
return d, status, nil
}
// getFlight fetches one flight record by id.
func (s *Server) getFlight(ctx context.Context, id string) (flightRecord, int, error) {
var f flightRecord
data, status, err := s.admin.do(ctx, http.MethodGet,
"/api/collections/flights/records/"+url.PathEscape(id), nil)
if err != nil {
return f, 0, err
}
if status == http.StatusOK {
_ = json.Unmarshal(data, &f)
}
return f, status, nil
}
// gatewayError relays a PocketBase transport failure.
func gatewayError(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
}
// ---------------------------------------------------------------------------
// Drones CRUD.
// ---------------------------------------------------------------------------
// GET /api/drones — list drones in the caller's scope.
func (s *Server) handleListDrones(w http.ResponseWriter, r *http.Request) {
who := caller(r)
m, err := s.dronesInScope(r.Context(), who)
if err != nil {
gatewayError(w, err)
return
}
out := make([]droneView, 0, len(m))
for _, d := range m {
out = append(out, d.view())
}
writeJSON(w, http.StatusOK, map[string]any{"drones": out})
}
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
}
func (in droneInput) payload(who *callerIdentity) map[string]any {
org := who.OrgID
if who.isSuperadmin() && in.Organization != nil {
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,
}
}
// 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)
var in droneInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "drone name is required")
return
}
data, status, err := s.admin.do(r.Context(), http.MethodPost,
"/api/collections/drones/records", in.payload(who))
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()})
}
// 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)
id := r.PathValue("id")
existing, status, err := s.getDrone(r.Context(), id)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusNotFound, "drone not found")
return
}
if !canManageDrone(who, existing) {
writeError(w, http.StatusForbidden, "you cannot modify this drone")
return
}
var in droneInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "drone name is required")
return
}
// Preserve org ownership unless a superadmin explicitly retargets it.
payload := in.payload(who)
if !who.isSuperadmin() {
payload["organization"] = existing.Organization
}
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/drones/records/"+url.PathEscape(id), 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.StatusOK, map[string]any{"drone": d.view()})
}
// DELETE /api/drones/{id} — delete a drone. Refused while flights reference it.
func (s *Server) handleDeleteDrone(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
existing, status, err := s.getDrone(r.Context(), id)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusNotFound, "drone not found")
return
}
if !canManageDrone(who, existing) {
writeError(w, http.StatusForbidden, "you cannot delete this drone")
return
}
// Guard: don't orphan logbook entries.
var refs struct {
TotalItems int `json:"totalItems"`
}
data, st, err := s.admin.do(r.Context(), http.MethodGet,
"/api/collections/flights/records?perPage=1&fields=id&filter="+
url.QueryEscape("drone = \""+id+"\""), nil)
if err != nil {
gatewayError(w, err)
return
}
if st == http.StatusOK {
_ = json.Unmarshal(data, &refs)
if refs.TotalItems > 0 {
writeError(w, http.StatusConflict, "drone still has logbook entries; delete or reassign them first")
return
}
}
_, st, err = s.admin.do(r.Context(), http.MethodDelete,
"/api/collections/drones/records/"+url.PathEscape(id), nil)
if err != nil {
gatewayError(w, err)
return
}
if st != http.StatusOK && st != http.StatusNoContent {
writeError(w, http.StatusBadGateway, "could not delete drone")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// ---------------------------------------------------------------------------
// Flights CRUD.
// ---------------------------------------------------------------------------
// GET /api/flights — list the caller's in-scope flights (newest first), each
// with its computed compliance assessment.
func (s *Server) handleListFlights(w http.ResponseWriter, r *http.Request) {
who := caller(r)
drones, err := s.dronesInScope(r.Context(), who)
if err != nil {
gatewayError(w, err)
return
}
var list struct {
Items []flightRecord `json:"items"`
}
if _, err := s.listRecords(r.Context(), "flights", flightScopeFilter(who),
"-operation_date,-start_time", &list); err != nil {
gatewayError(w, err)
return
}
out := make([]flightView, 0, len(list.Items))
for _, f := range list.Items {
out = append(out, f.view(drones))
}
writeJSON(w, http.StatusOK, map[string]any{"flights": out})
}
type flightInput struct {
OperationDate string `json:"operationDate"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Drone string `json:"drone"`
AreaRoute string `json:"areaRoute"`
RouteGeoJSON json.RawMessage `json:"routeGeojson"`
MaxAltitudeAGL float64 `json:"maxAltitudeAgl"`
RemotePilot string `json:"remotePilot"` // managers may log for another pilot
PilotName string `json:"pilotName"`
CertificateRef string `json:"certificateRef"`
Category string `json:"category"`
Purpose string `json:"purpose"`
LoggingPath string `json:"loggingPath"`
RawFDRLogURL string `json:"rawFdrLogUrl"`
AuthorisationRef string `json:"authorisationRef"`
Weather string `json:"weather"`
AirspaceRef string `json:"airspaceRef"`
Observer string `json:"observer"`
Incidents string `json:"incidents"`
Notes string `json:"notes"`
}
// asRecord projects the input onto a flightRecord (used for validation before
// persisting). Pilot/org resolution happens in the handler.
func (in flightInput) asRecord() flightRecord {
return flightRecord{
OperationDate: strings.TrimSpace(in.OperationDate), StartTime: strings.TrimSpace(in.StartTime),
EndTime: strings.TrimSpace(in.EndTime), Drone: strings.TrimSpace(in.Drone),
AreaRoute: strings.TrimSpace(in.AreaRoute), MaxAltitudeAGL: in.MaxAltitudeAGL,
PilotName: strings.TrimSpace(in.PilotName), CertificateRef: strings.TrimSpace(in.CertificateRef),
Category: strings.TrimSpace(in.Category), Purpose: strings.TrimSpace(in.Purpose),
LoggingPath: strings.TrimSpace(in.LoggingPath), RawFDRLogURL: strings.TrimSpace(in.RawFDRLogURL),
AuthorisationRef: strings.TrimSpace(in.AuthorisationRef),
}
}
func (in flightInput) payload(remotePilot, org, retentionUntil string) map[string]any {
p := map[string]any{
"operation_date": strings.TrimSpace(in.OperationDate),
"start_time": strings.TrimSpace(in.StartTime),
"end_time": strings.TrimSpace(in.EndTime),
"drone": strings.TrimSpace(in.Drone),
"area_route": strings.TrimSpace(in.AreaRoute),
"max_altitude_agl": in.MaxAltitudeAGL,
"remote_pilot": remotePilot,
"pilot_name": strings.TrimSpace(in.PilotName),
"certificate_ref": strings.TrimSpace(in.CertificateRef),
"category": strings.TrimSpace(in.Category),
"purpose": strings.TrimSpace(in.Purpose),
"logging_path": strings.TrimSpace(in.LoggingPath),
"raw_fdr_log_url": strings.TrimSpace(in.RawFDRLogURL),
"authorisation_ref": strings.TrimSpace(in.AuthorisationRef),
"weather": strings.TrimSpace(in.Weather),
"airspace_ref": strings.TrimSpace(in.AirspaceRef),
"observer": strings.TrimSpace(in.Observer),
"incidents": strings.TrimSpace(in.Incidents),
"notes": strings.TrimSpace(in.Notes),
"organization": org,
"retention_until": retentionUntil,
}
if len(in.RouteGeoJSON) > 0 {
p["route_geojson"] = in.RouteGeoJSON
}
return p
}
// validateFlight enforces the § 5 minimum for the effective logging path. It
// returns an error message (and false) when a manual-path entry is incomplete —
// callers must block the save rather than store a silent partial record.
func validateFlight(rec flightRecord, d *droneRecord) (string, bool) {
if strings.TrimSpace(rec.OperationDate) == "" {
return "operation date is required", false
}
if strings.TrimSpace(rec.Drone) == "" {
return "a drone must be selected", false
}
if effectiveLoggingPath(rec, d) == "manual" {
if missing := missingManualFields(rec); len(missing) > 0 {
return "manual logbook entry is missing required § 5 field(s): " + strings.Join(missing, ", "), false
}
}
return "", true
}
// POST /api/flights — create a logbook entry.
func (s *Server) handleCreateFlight(w http.ResponseWriter, r *http.Request) {
who := caller(r)
var in flightInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
// Resolve + authorise the drone.
drone, status, err := s.getDrone(r.Context(), strings.TrimSpace(in.Drone))
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusBadRequest, "selected drone does not exist")
return
}
if !droneVisibleTo(who, drone) {
writeError(w, http.StatusForbidden, "selected drone is not in your scope")
return
}
// Pilot: default to the caller; a manager may log on behalf of another pilot.
pilot := who.ID
if who.isManager() && strings.TrimSpace(in.RemotePilot) != "" {
pilot = strings.TrimSpace(in.RemotePilot)
}
if strings.TrimSpace(in.PilotName) == "" {
in.PilotName = who.Email
}
rec := in.asRecord()
if msg, ok := validateFlight(rec, &drone); !ok {
writeError(w, http.StatusUnprocessableEntity, msg)
return
}
payload := in.payload(pilot, who.OrgID, addFiveYears(in.OperationDate))
data, status, err := s.admin.do(r.Context(), http.MethodPost,
"/api/collections/flights/records", payload)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var f flightRecord
_ = json.Unmarshal(data, &f)
writeJSON(w, http.StatusCreated, map[string]any{"flight": f.view(map[string]droneRecord{drone.ID: drone})})
}
// PATCH /api/flights/{id} — update a logbook entry.
func (s *Server) handleUpdateFlight(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
existing, status, err := s.getFlight(r.Context(), id)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusNotFound, "flight not found")
return
}
if !canManageFlight(who, existing) {
writeError(w, http.StatusForbidden, "you cannot modify this flight")
return
}
var in flightInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
drone, status, err := s.getDrone(r.Context(), strings.TrimSpace(in.Drone))
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusBadRequest, "selected drone does not exist")
return
}
if !droneVisibleTo(who, drone) {
writeError(w, http.StatusForbidden, "selected drone is not in your scope")
return
}
if strings.TrimSpace(in.PilotName) == "" {
in.PilotName = existing.PilotName
}
rec := in.asRecord()
if msg, ok := validateFlight(rec, &drone); !ok {
writeError(w, http.StatusUnprocessableEntity, msg)
return
}
// Preserve the original pilot + org; recompute retention from the new date.
payload := in.payload(existing.RemotePilot, existing.Organization, addFiveYears(in.OperationDate))
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/flights/records/"+url.PathEscape(id), payload)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var f flightRecord
_ = json.Unmarshal(data, &f)
writeJSON(w, http.StatusOK, map[string]any{"flight": f.view(map[string]droneRecord{drone.ID: drone})})
}
// DELETE /api/flights/{id} — delete a logbook entry.
func (s *Server) handleDeleteFlight(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
existing, status, err := s.getFlight(r.Context(), id)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusNotFound, "flight not found")
return
}
if !canManageFlight(who, existing) {
writeError(w, http.StatusForbidden, "you cannot delete this flight")
return
}
_, st, err := s.admin.do(r.Context(), http.MethodDelete,
"/api/collections/flights/records/"+url.PathEscape(id), nil)
if err != nil {
gatewayError(w, err)
return
}
if st != http.StatusOK && st != http.StatusNoContent {
writeError(w, http.StatusBadGateway, "could not delete flight")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// droneVisibleTo reports whether the caller may reference this drone on a flight
// (same rule as list scope: in the caller's org, or unowned; superadmin: any).
func droneVisibleTo(who *callerIdentity, d droneRecord) bool {
if who.isSuperadmin() {
return true
}
if who.OrgID != "" {
return d.Organization == who.OrgID || d.Organization == ""
}
return d.Organization == ""
}
// relayRaw relays a raw upstream body + status (used to surface PocketBase's own
// validation errors verbatim).
func relayRaw(w http.ResponseWriter, status int, data []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(data)
}
+91
View File
@@ -0,0 +1,91 @@
package api
import (
"encoding/csv"
"net/http"
"strconv"
"strings"
"time"
)
// GET /api/logbook/export — exports the caller's in-scope logbook as CSV.
//
// This is the "readable electronic format" disclosure path required by BEK 1649
// § 5: retained 5 years and producible on request from Trafikstyrelsen (and,
// under the 2026 hearing draft, the police). CSV is an open format, so it holds
// regardless of whether the source records came from a manual entry or an
// automatic FDR export.
func (s *Server) handleExportLogbook(w http.ResponseWriter, r *http.Request) {
who := caller(r)
drones, err := s.dronesInScope(r.Context(), who)
if err != nil {
gatewayError(w, err)
return
}
var list struct {
Items []flightRecord `json:"items"`
}
if _, err := s.listRecords(r.Context(), "flights", flightScopeFilter(who),
"operation_date,start_time", &list); err != nil {
gatewayError(w, err)
return
}
filename := "pilotvault-logbook-" + time.Now().Format("2006-01-02") + ".csv"
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
w.WriteHeader(http.StatusOK)
cw := csv.NewWriter(w)
defer cw.Flush()
_ = cw.Write([]string{
"operation_date", "start_time", "end_time",
"drone_name", "drone_model", "drone_serial", "operator_number",
"area_or_route", "max_altitude_agl_m",
"remote_pilot", "certificate_ref",
"category", "purpose", "logging_path", "fdr_log_url", "authorisation_ref",
"weather", "airspace_ref", "observer", "incidents", "notes",
"retention_until", "logbook_required", "compliance_flags",
})
for _, f := range list.Items {
var d *droneRecord
if dr, ok := drones[f.Drone]; ok {
d = &dr
}
c := computeCompliance(f, d)
droneName, model, serial, opNo := "", "", "", ""
if d != nil {
droneName, model, serial, opNo = d.Name, d.Model, d.Serial, d.OperatorNumber
}
alt := ""
if f.MaxAltitudeAGL > 0 {
alt = strconv.FormatFloat(f.MaxAltitudeAGL, 'f', -1, 64)
}
_ = cw.Write([]string{
day(f.OperationDate), f.StartTime, f.EndTime,
droneName, model, serial, opNo,
f.AreaRoute, alt,
f.PilotName, f.CertificateRef,
f.Category, f.Purpose, c.LoggingPath, f.RawFDRLogURL, f.AuthorisationRef,
f.Weather, f.AirspaceRef, f.Observer, f.Incidents, f.Notes,
day(f.RetentionUntil), boolText(c.Required), strings.Join(c.RedFlags, "; "),
})
}
}
// day trims a PocketBase datetime string to its YYYY-MM-DD date.
func day(s string) string {
if len(s) >= 10 {
return s[:10]
}
return s
}
func boolText(b bool) string {
if b {
return "yes"
}
return "no"
}
+13
View File
@@ -140,6 +140,19 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin)) mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin))
mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth)) mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth))
// Logbook — drones + flights (BEK 1649 §5). Available to any authenticated
// user; per-role scoping (user→own, admin→org, superadmin→all) is enforced
// 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("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))
mux.HandleFunc("POST /api/flights", s.requireUser(s.handleCreateFlight))
mux.HandleFunc("PATCH /api/flights/{id}", s.requireUser(s.handleUpdateFlight))
mux.HandleFunc("DELETE /api/flights/{id}", s.requireUser(s.handleDeleteFlight))
mux.HandleFunc("GET /api/logbook/export", s.requireUser(s.handleExportLogbook))
// Device / dashboard API. // Device / dashboard API.
mux.HandleFunc("GET /api/devices", s.handleListDevices) mux.HandleFunc("GET /api/devices", s.handleListDevices)
mux.HandleFunc("GET /api/devices/{id}/track", s.handleTrack) mux.HandleFunc("GET /api/devices/{id}/track", s.handleTrack)
@@ -0,0 +1,163 @@
/// <reference path="../pb_data/types.d.ts" />
// Creates the drone-pilot logbook collections: `drones` (the airframes an
// operator flies, carrying the classification inputs that drive exemption /
// logging-path logic) and `flights` (the logbook entries themselves, modelled on
// Denmark's BEK nr. 1649 af 12/12/2023 "Dronebekendtgørelsen" § 5).
//
// Both collections are reached only through the API Server's superuser service
// account (like `organizations` + user management), so their API rules are left
// locked (superusers only); the API Server enforces per-role scoping in Go.
//
// Apply by copying into your PocketBase deployment's `pb_migrations/` directory
// and restarting. Written for PocketBase v0.22+/v0.23. Idempotent: each
// collection is created only if absent, so re-running is a no-op.
//
// Depends on 1720300200_add_organizations.js (organizations) and the `users`
// auth collection.
migrate(
(app) => {
const orgs = app.findCollectionByNameOrId('organizations')
const users = app.findCollectionByNameOrId('users')
// ---- drones -----------------------------------------------------------
let drones
try {
drones = app.findCollectionByNameOrId('drones')
} catch (_) {
drones = new Collection({
type: 'base',
name: 'drones',
fields: [
{ name: 'name', type: 'text', required: true, max: 120, presentable: true },
{ name: 'model', type: 'text', max: 120 },
{ name: 'serial', type: 'text', max: 120 },
// Trafikstyrelsen operator number displayed on the drone.
{ name: 'operator_number', type: 'text', max: 60 },
// Max take-off mass in grams — drives the < 250 g exemption.
{ name: 'mtom_grams', type: 'number', min: 0 },
{ name: 'is_toy', type: 'bool' },
// Has an onboard flight-data recorder (automatic-logging path).
{ name: 'autologs_flights', type: 'bool' },
// C-class marking: C0..C6 (or blank for legacy/unmarked).
{ name: 'c_class', type: 'select', maxSelect: 1, values: ['C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6'] },
{
name: 'organization',
type: 'relation',
required: false,
collectionId: orgs.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
presentable: false,
},
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
],
indexes: [
'CREATE INDEX `idx_drones_org` ON `drones` (`organization`)',
],
})
app.save(drones)
drones = app.findCollectionByNameOrId('drones')
}
// ---- flights ----------------------------------------------------------
try {
app.findCollectionByNameOrId('flights')
return // already present
} catch (_) {
// create below
}
const flights = new Collection({
type: 'base',
name: 'flights',
fields: [
// -- BEK 1649 § 5 minimum content --
{ name: 'operation_date', type: 'date', required: true },
{ name: 'start_time', type: 'text', max: 5 }, // "HH:MM"
{ name: 'end_time', type: 'text', max: 5 }, // "HH:MM"
{
name: 'drone',
type: 'relation',
required: true,
collectionId: drones.id,
cascadeDelete: false,
minSelect: 1,
maxSelect: 1,
presentable: true,
},
// Area flown or route taken (free text; optional GeoJSON alongside).
{ name: 'area_route', type: 'text', max: 500 },
{ name: 'route_geojson', type: 'json', maxSize: 200000 },
// Maximum altitude relative to terrain, in metres AGL.
{ name: 'max_altitude_agl', type: 'number', min: 0 },
{
name: 'remote_pilot',
type: 'relation',
required: true,
collectionId: users.id,
cascadeDelete: false,
minSelect: 1,
maxSelect: 1,
presentable: false,
},
// Denormalised pilot name — § 5 requires the remote pilot's *name*, which
// the users relation alone may not carry.
{ name: 'pilot_name', type: 'text', max: 160 },
{ name: 'certificate_ref', type: 'text', max: 120 },
// -- category / logging path --
{ name: 'category', type: 'select', maxSelect: 1, values: ['open', 'specific', 'certified'] },
// Declared flight purpose — drives the exemption computation.
{ name: 'purpose', type: 'select', maxSelect: 1, values: ['hobby', 'commercial', 'research', 'public', 'club_area'] },
{ name: 'logging_path', type: 'select', maxSelect: 1, values: ['automatic', 'manual'] },
// Link to the stored FDR export (automatic path).
{ name: 'raw_fdr_log_url', type: 'text', max: 500 },
// Authorisation reference for Specific-category ops (STS/PDRA/SORA).
{ name: 'authorisation_ref', type: 'text', max: 200 },
// -- operational maturity (beyond the legal minimum) --
{ name: 'weather', type: 'text', max: 300 },
{ name: 'airspace_ref', type: 'text', max: 200 },
{ name: 'observer', type: 'text', max: 160 },
{ name: 'incidents', type: 'text', max: 1000 },
{ name: 'notes', type: 'text', max: 1000 },
// -- ownership + retention --
{
name: 'organization',
type: 'relation',
required: false,
collectionId: orgs.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
presentable: false,
},
// 5-year retention boundary — computed as operation_date + 5y at create.
{ name: 'retention_until', type: 'date' },
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
],
indexes: [
'CREATE INDEX `idx_flights_pilot` ON `flights` (`remote_pilot`)',
'CREATE INDEX `idx_flights_org` ON `flights` (`organization`)',
'CREATE INDEX `idx_flights_date` ON `flights` (`operation_date`)',
],
})
app.save(flights)
},
(app) => {
// Down: remove flights first (it references drones), then drones.
for (const name of ['flights', 'drones']) {
try {
app.delete(app.findCollectionByNameOrId(name))
} catch (_) {
// already gone
}
}
},
)
+94
View File
@@ -371,6 +371,100 @@ func (a *App) handleDeleteOrg(w http.ResponseWriter, r *http.Request) {
a.doRelay(w, req) a.doRelay(w, req)
} }
/* ---------- Logbook: drones ---------- */
// GET /bff/drones → API Server /api/drones
func (a *App) handleListDrones(w http.ResponseWriter, r *http.Request) {
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/drones", nil)
req.Header.Set("Authorization", tokenOf(r))
a.doRelay(w, req)
}
// POST /bff/drones → API Server /api/drones
func (a *App) handleCreateDrone(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/drones", 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")
body, _ := io.ReadAll(r.Body)
req, _ := http.NewRequest(http.MethodPatch, a.apiBaseFor(r)+"/api/drones/"+url.PathEscape(id), bytes.NewReader(body))
req.Header.Set("Authorization", tokenOf(r))
req.Header.Set("Content-Type", "application/json")
a.doRelay(w, req)
}
// DELETE /bff/drones/{id} → API Server /api/drones/{id}
func (a *App) handleDeleteDrone(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
req, _ := http.NewRequest(http.MethodDelete, a.apiBaseFor(r)+"/api/drones/"+url.PathEscape(id), nil)
req.Header.Set("Authorization", tokenOf(r))
a.doRelay(w, req)
}
/* ---------- Logbook: flights ---------- */
// GET /bff/flights → API Server /api/flights
func (a *App) handleListFlights(w http.ResponseWriter, r *http.Request) {
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/flights", nil)
req.Header.Set("Authorization", tokenOf(r))
a.doRelay(w, req)
}
// POST /bff/flights → API Server /api/flights
func (a *App) handleCreateFlight(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/flights", bytes.NewReader(body))
req.Header.Set("Authorization", tokenOf(r))
req.Header.Set("Content-Type", "application/json")
a.doRelay(w, req)
}
// PATCH /bff/flights/{id} → API Server /api/flights/{id}
func (a *App) handleUpdateFlight(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
body, _ := io.ReadAll(r.Body)
req, _ := http.NewRequest(http.MethodPatch, a.apiBaseFor(r)+"/api/flights/"+url.PathEscape(id), bytes.NewReader(body))
req.Header.Set("Authorization", tokenOf(r))
req.Header.Set("Content-Type", "application/json")
a.doRelay(w, req)
}
// DELETE /bff/flights/{id} → API Server /api/flights/{id}
func (a *App) handleDeleteFlight(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
req, _ := http.NewRequest(http.MethodDelete, a.apiBaseFor(r)+"/api/flights/"+url.PathEscape(id), nil)
req.Header.Set("Authorization", tokenOf(r))
a.doRelay(w, req)
}
// GET /bff/logbook/export → API Server /api/logbook/export. Unlike the JSON
// endpoints this streams a CSV download, so it preserves the upstream
// Content-Type + Content-Disposition instead of forcing application/json.
func (a *App) handleExportLogbook(w http.ResponseWriter, r *http.Request) {
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/logbook/export", nil)
req.Header.Set("Authorization", tokenOf(r))
resp, err := client.Do(req)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"})
return
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
w.Header().Set("Content-Disposition", cd)
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
// doRelay executes an outbound request and relays the response verbatim. // doRelay executes an outbound request and relays the response verbatim.
func (a *App) doRelay(w http.ResponseWriter, req *http.Request) { func (a *App) doRelay(w http.ResponseWriter, req *http.Request) {
resp, err := client.Do(req) resp, err := client.Do(req)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -35,8 +35,8 @@
})() })()
</script> </script>
<title>PilotVault — Control Panel</title> <title>PilotVault — Control Panel</title>
<script type="module" crossorigin src="./assets/index-BldP9Pra.js"></script> <script type="module" crossorigin src="./assets/index-DLbqB6QP.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DBe0h801.css"> <link rel="stylesheet" crossorigin href="./assets/index-Co-T5CTN.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+10
View File
@@ -72,6 +72,16 @@ func main() {
mux.HandleFunc("POST /bff/orgs", app.requireAuth(app.handleCreateOrg)) mux.HandleFunc("POST /bff/orgs", app.requireAuth(app.handleCreateOrg))
mux.HandleFunc("PATCH /bff/orgs/{id}", app.requireAuth(app.handleUpdateOrg)) mux.HandleFunc("PATCH /bff/orgs/{id}", app.requireAuth(app.handleUpdateOrg))
mux.HandleFunc("DELETE /bff/orgs/{id}", app.requireAuth(app.handleDeleteOrg)) mux.HandleFunc("DELETE /bff/orgs/{id}", app.requireAuth(app.handleDeleteOrg))
// 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("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))
mux.HandleFunc("POST /bff/flights", app.requireAuth(app.handleCreateFlight))
mux.HandleFunc("PATCH /bff/flights/{id}", app.requireAuth(app.handleUpdateFlight))
mux.HandleFunc("DELETE /bff/flights/{id}", app.requireAuth(app.handleDeleteFlight))
mux.HandleFunc("GET /bff/logbook/export", app.requireAuth(app.handleExportLogbook))
mux.HandleFunc("GET /bff/ws", app.handleWS) mux.HandleFunc("GET /bff/ws", app.handleWS)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "api": app.apiBase}) writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "api": app.apiBase})
+77
View File
@@ -253,6 +253,83 @@ export async function testWebDav() {
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
} }
/* ---------- Logbook: drones ---------- */
export async function getDrones() {
try {
const r = await fetch('/bff/drones')
if (!r.ok) return { ok: false, status: r.status, drones: [] }
const d = await r.json()
return { ok: true, status: 200, drones: d.drones || [] }
} catch {
return { ok: false, status: 0, drones: [] }
}
}
export async function createDrone(drone) {
const r = await fetch('/bff/drones', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(drone),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function updateDrone(id, drone) {
const r = await fetch(`/bff/drones/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(drone),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function deleteDrone(id) {
const r = await fetch(`/bff/drones/${encodeURIComponent(id)}`, { method: 'DELETE' })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
/* ---------- Logbook: flights ---------- */
export async function getFlights() {
try {
const r = await fetch('/bff/flights')
if (!r.ok) return { ok: false, status: r.status, flights: [] }
const d = await r.json()
return { ok: true, status: 200, flights: d.flights || [] }
} catch {
return { ok: false, status: 0, flights: [] }
}
}
export async function createFlight(flight) {
const r = await fetch('/bff/flights', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flight),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function updateFlight(id, flight) {
const r = await fetch(`/bff/flights/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flight),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function deleteFlight(id) {
const r = await fetch(`/bff/flights/${encodeURIComponent(id)}`, { method: 'DELETE' })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// Trigger a browser download of the compliance CSV export.
export function exportLogbookUrl() {
return '/bff/logbook/export'
}
export async function sendCommand(id, command, payload) { export async function sendCommand(id, command, payload) {
const r = await fetch(`/bff/devices/${encodeURIComponent(id)}/command`, { const r = await fetch(`/bff/devices/${encodeURIComponent(id)}/command`, {
method: 'POST', method: 'POST',
+4
View File
@@ -4,6 +4,7 @@ import DeviceMap from './DeviceMap.vue'
import BrandMark from './BrandMark.vue' import BrandMark from './BrandMark.vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import Settings from './Settings.vue' import Settings from './Settings.vue'
import Logbook from './Logbook.vue'
import { getDevices, sendCommand } from '../api.js' import { getDevices, sendCommand } from '../api.js'
import { formatTime } from '../prefs.js' import { formatTime } from '../prefs.js'
@@ -627,6 +628,9 @@ onBeforeUnmount(() => {
</template> </template>
</div> </div>
<!-- ---------- Logbook ---------- -->
<Logbook v-else-if="active === 'Logbook'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
<!-- ---------- Settings ---------- --> <!-- ---------- Settings ---------- -->
<Settings v-else-if="active === 'Settings'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" @logout="emit('logout')" /> <Settings v-else-if="active === 'Settings'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" @logout="emit('logout')" />
+551
View File
@@ -0,0 +1,551 @@
<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'
const props = defineProps({
email: { type: String, default: '' },
role: { type: String, default: 'user' },
organization: { type: String, default: '' },
organizationName: { type: String, default: '' },
})
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'
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 = ''
const [dr, fl] = await Promise.all([getDrones(), getFlights()])
if (!dr.ok || !fl.ok) {
loadErr.value =
dr.status === 503 || fl.status === 503
? 'Logbook storage is not configured on the API Server (service account missing).'
: 'Could not load the logbook.'
}
drones.value = dr.drones
flights.value = fl.flights
loading.value = false
}
onMounted(loadAll)
/* ---------------- compliance presentation ---------------- */
function flightBadge(f) {
const c = f.compliance || {}
if (c.exempt) return { tone: 'neutral', label: 'Exempt' }
if ((c.redFlags || []).length) return { tone: 'danger', label: `${c.redFlags.length} issue${c.redFlags.length > 1 ? 's' : ''}` }
return { tone: 'success', label: 'Compliant' }
}
const openRow = ref('') // flight id whose compliance detail is expanded
function toggleRow(id) {
openRow.value = openRow.value === id ? '' : id
}
/* ---------------- flight form ---------------- */
const CATEGORIES = [
{ value: 'open', label: 'Open' },
{ value: 'specific', label: 'Specific' },
{ value: 'certified', label: 'Certified' },
]
const PURPOSES = [
{ value: 'commercial', label: 'Commercial' },
{ value: 'research', label: 'Research' },
{ value: 'public', label: 'Public-benefit' },
{ value: 'hobby', label: 'Private hobby' },
{ value: 'club_area', label: 'Model-club area' },
]
const PATHS = [
{ value: '', label: 'Auto (from drone)' },
{ value: 'manual', label: 'Manual' },
{ value: 'automatic', label: 'Automatic (FDR)' },
]
function blankFlight() {
return {
operationDate: new Date().toISOString().slice(0, 10),
startTime: '', endTime: '', drone: drones.value[0]?.id || '',
areaRoute: '', maxAltitudeAgl: '', pilotName: props.email, certificateRef: '',
category: 'open', purpose: 'commercial', loggingPath: '',
rawFdrLogUrl: '', authorisationRef: '',
weather: '', airspaceRef: '', observer: '', incidents: '', notes: '',
}
}
const showFlightForm = ref(false)
const editingFlightId = ref('')
const flightForm = reactive(blankFlight())
const flightMsg = ref('')
const savingFlight = ref(false)
const showDetails = ref(false)
function newFlight() {
Object.assign(flightForm, blankFlight())
editingFlightId.value = ''
flightMsg.value = ''
showDetails.value = false
showFlightForm.value = true
}
function editFlight(f) {
Object.assign(flightForm, {
operationDate: (f.operationDate || '').slice(0, 10),
startTime: f.startTime || '', endTime: f.endTime || '', drone: f.drone || '',
areaRoute: f.areaRoute || '', maxAltitudeAgl: f.maxAltitudeAgl || '',
pilotName: f.pilotName || '', certificateRef: f.certificateRef || '',
category: f.category || 'open', purpose: f.purpose || 'commercial',
loggingPath: f.loggingPath || '', rawFdrLogUrl: f.rawFdrLogUrl || '',
authorisationRef: f.authorisationRef || '', weather: f.weather || '',
airspaceRef: f.airspaceRef || '', observer: f.observer || '',
incidents: f.incidents || '', notes: f.notes || '',
})
editingFlightId.value = f.id
flightMsg.value = ''
showDetails.value = !!(f.weather || f.airspaceRef || f.observer || f.incidents || f.notes)
showFlightForm.value = true
}
function cancelFlight() {
showFlightForm.value = false
editingFlightId.value = ''
}
async function saveFlight() {
flightMsg.value = ''
if (!flightForm.drone) {
flightMsg.value = 'Select a drone first (add one on the Drones tab).'
return
}
savingFlight.value = true
const payload = { ...flightForm, maxAltitudeAgl: Number(flightForm.maxAltitudeAgl) || 0 }
const res = editingFlightId.value
? await updateFlight(editingFlightId.value, payload)
: await createFlight(payload)
savingFlight.value = false
if (!res.ok) {
flightMsg.value = res.body?.error || 'Could not save the flight.'
return
}
showFlightForm.value = false
await loadAll()
}
const confirmFlightId = ref('')
async function removeFlight(f) {
const res = await deleteFlight(f.id)
confirmFlightId.value = ''
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 }
})
</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 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="ml-auto flex items-center gap-2">
<a
:href="exportLogbookUrl()"
class="btn-ghost inline-flex items-center gap-2"
title="Download a compliance CSV (Trafikstyrelsen / police disclosure)"
>
<Icon name="download" :size="15" /> Export CSV
</a>
<button v-if="tab === 'flights'" 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 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"
:class="s.tone === 'danger' ? 'text-danger-fg' : 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>
<!-- ============ 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>
</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" />
</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>
<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>
</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>
</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>
</td>
<td class="whitespace-nowrap px-5 py-3 text-right">
<template v-if="confirmDroneId === d.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>
</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>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>