diff --git a/README.md b/README.md index 6c587a4..f89eb50 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ As of May 2025, FSD is still used to facilitate over 140,000 active members conn cmd/openfsd/ # Binary entrypoint (FSD + web; image CMD is /openfsd) pkg/protocol/ # Pure wire format (parse/marshal; no I/O) pkg/fsdclient/ # Mock/real FSD client for e2e and tools +pkg/twrfiles/ # Pure .apt/.air parse + format (sweatbox + editor) internal/server/ # TCP accept, login, handlers, service HTTP internal/session/ # Per-connection state + outbound send worker internal/postoffice/ # Callsign registry + geospatial index @@ -48,6 +49,19 @@ go run ./cmd/aptdat2apt -download -out generated-apt -quiet Sources, licensing (Gateway / Global Airports GPLv2), and packaging rules: [docs/xplane-airport-data.md](docs/xplane-airport-data.md). +### Airport editor (web) + +Administrators can author paired **`.apt`** (geometry) and **`.air`** (scenario +aircraft) files in the browser at **`/airport-editor`**: + +- Map-first editing (Leaflet; JS required for the map; text paste + echo-download works without JS) +- **No server persistence** of APT/AIR — open/download only (Blob download with JS; form echo-download without) +- Validate tab: live client parse + soft cross-file warnings; optional **Confirm with server** + (`POST /api/v1/editor/validate-apt` and `validate-air`) +- Workflow: download files → load them on **`/sweatbox`** (editor never pushes into a live session) + +Design notes: [docs/design/apt-air-editor.md](docs/design/apt-air-editor.md). + ## Build and run ```bash @@ -130,6 +144,7 @@ docker compose down ```bash go test -race ./... bash scripts/check-coverage.sh 80 # overall ≥80%; pure-pkg floors (see AGENTS.md) +bash scripts/check-webjs.sh # Node ≥20 unit tests for airport-editor JS modules go test -bench=. -benchmem ./internal/postoffice/ ./pkg/protocol/ go test -tags=stress -count=1 -timeout=120s ./internal/server/ -run TestStress -v ``` diff --git a/internal/web/README.md b/internal/web/README.md index d00a574..32e2a5c 100644 --- a/internal/web/README.md +++ b/internal/web/README.md @@ -18,10 +18,17 @@ JSON under `/api/v1` for external tools and map polling. First-party UI is a pro JSON under `/api/v1` remains for external consumers and map polling. Admin mutations work with **cookie + CSRF only** (no `Authorization` header required). +### Airport editor validation + +- **Live client:** JS `parseAPT` / `parseAIR` + soft cross-file warnings (dep ICAO, aircraft far from field) on the Validate tab. +- **Confirm with server (optional):** `POST /api/v1/editor/validate-apt` and `POST /api/v1/editor/validate-air` with JSON `{"text":"…"}` (Admin, dual-accept Bearer | cookie; CSRF when cookie). Response is standard `APIV1Response` with `data.errors`, plus `icao` / `surface_count` or `aircraft_count`. Transient request body only — never written to disk/DB. +- **Handoff:** download `.apt`/`.air`, then load on `/sweatbox` (no automatic push from editor → live session). +- Design: `docs/design/apt-air-editor.md`. JS unit tests: `webjs/` + `bash scripts/check-webjs.sh`. + ### JS budget / map exception First-party openfsd modules stay small and vanilla (no jQuery). The **dashboard route** may load **Leaflet** (vendor) + `dashboard.js` as a documented exception to the 30–50 KB compressed first-party budget. Failure mode: map is absent; connection summary HTML still works. -The **airport editor** (`/airport-editor`) is a second complexity-gate exception for map geometry authoring (Leaflet + first-party modules, landed in later PRs). Essential data path without JS: paste `apt_text` / `air_text` + CSRF form echo-download. Map region is inert when JS is off. Download handlers never write APT/AIR to disk or DB. +The **airport editor** (`/airport-editor`) is a second complexity-gate exception for map geometry authoring (Leaflet + first-party modules). Essential data path without JS: paste `apt_text` / `air_text` + CSRF form echo-download. Map region is inert when JS is off. Download handlers never write APT/AIR to disk or DB. --- diff --git a/internal/web/pages_airport_editor_test.go b/internal/web/pages_airport_editor_test.go index dfd96a9..c1fff4a 100644 --- a/internal/web/pages_airport_editor_test.go +++ b/internal/web/pages_airport_editor_test.go @@ -95,6 +95,16 @@ func TestAirportEditorAdminShell(t *testing.T) { `data-mode="select"`, `data-mode="park"`, `data-mode="aircraft"`, + // PR8: validation UX hooks + sweatbox handoff + `data-validate-apt="/api/v1/editor/validate-apt"`, + `data-validate-air="/api/v1/editor/validate-air"`, + `data-js="chip-issues"`, + `data-action="confirm-server"`, + `data-js="confirm-server"`, + `data-js="panel-validate"`, + `data-js="sweatbox-handoff"`, + `href="/sweatbox"`, + `Confirm with server`, } { if !strings.Contains(body, want) { t.Fatalf("expected body to contain %q, body=%s", want, clip(body, 800)) diff --git a/internal/web/static/css/openfsd/airport-editor.css b/internal/web/static/css/openfsd/airport-editor.css index 984f3c7..54b6c16 100644 --- a/internal/web/static/css/openfsd/airport-editor.css +++ b/internal/web/static/css/openfsd/airport-editor.css @@ -104,6 +104,58 @@ color: #664d03; } +.apted-chip-issues.is-ok { + border-color: #a3cfbb; + background: #d1e7dd; + color: var(--apted-ok); +} + +.apted-chip-issues.is-err { + border-color: #f1aeb5; + background: #f8d7da; + color: var(--apted-danger); +} + +.apted-chip-issues.is-warn { + border-color: #c9a227; + background: var(--apted-warn-bg); + color: #664d03; +} + +.apted-chip-issues.is-empty { + color: var(--apted-muted); + font-weight: 500; +} + +.apted-tab-badge { + display: inline-block; + margin-left: 0.25rem; + padding: 0 0.3rem; + border-radius: 2px; + font-family: var(--apted-mono); + font-size: 0.65rem; + font-weight: 700; + line-height: 1.35; + vertical-align: middle; + background: #e9ecef; + color: var(--apted-muted); +} + +.apted-tab-badge.is-ok { + background: #d1e7dd; + color: var(--apted-ok); +} + +.apted-tab-badge.is-err { + background: #f8d7da; + color: var(--apted-danger); +} + +.apted-tab-badge.is-warn { + background: var(--apted-warn-bg); + color: #664d03; +} + .apted-flash { margin: 0; padding: 0.35rem 0.6rem; @@ -525,6 +577,57 @@ .apted-err-list-warn { color: #664d03; + background: var(--apted-warn-bg); + border: 1px solid #e6d28a; + border-radius: 2px; + padding: 0.35rem 0.5rem 0.35rem 1.25rem; +} + +.apted-soft-hd { + color: #664d03; +} + +.apted-soft-note { + margin-bottom: 0.25rem; +} + +.apted-validate-summary { + margin: 0 0 0.5rem; + font-weight: 600; + font-size: var(--apted-fs-sm); +} + +.apted-server-validate { + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid var(--apted-border); +} + +.apted-server-side { + margin-top: 0.4rem; +} + +.apted-server-ok { + color: var(--apted-ok); + font-weight: 600; +} + +.apted-server-stale { + color: #664d03; + background: var(--apted-warn-bg); + border: 1px solid #e6d28a; + border-radius: 2px; + padding: 0.25rem 0.4rem; +} + +.apted-handoff { + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px dashed var(--apted-border); +} + +.apted-handoff a { + font-weight: 600; } .apted-mini-table { diff --git a/internal/web/static/js/openfsd/airport-editor/main.js b/internal/web/static/js/openfsd/airport-editor/main.js index 2177ba9..7c9ee8a 100644 --- a/internal/web/static/js/openfsd/airport-editor/main.js +++ b/internal/web/static/js/openfsd/airport-editor/main.js @@ -24,6 +24,7 @@ import { updateAircraft, updateAirportHeaders, snapAircraftToParking, + markServerValidationStale, MODE_SELECT, MODE_PARK, MODE_TAXI, @@ -40,6 +41,7 @@ import { parseAIR } from './parse-air.js'; import { formatAPT } from './format-apt.js'; import { formatAIR } from './format-air.js'; import { validateDocument } from './validate.js'; +import { postServerValidate } from './server-validate.js'; import { createMap, OverlayController, @@ -273,6 +275,9 @@ function main() { ); } }, + onConfirmServer() { + void confirmWithServer(); + }, }); const toolbar = mountToolbar(root, { @@ -291,6 +296,7 @@ function main() { markDirty: false, }); doc.lastAptDownloadHash = null; + doc.serverValidation = null; syncFallbackTextareas(); validateDocument(doc); doc.selection = null; @@ -321,6 +327,7 @@ function main() { markDirty: false, }); doc.lastAirDownloadHash = null; + doc.serverValidation = null; syncFallbackTextareas(); validateDocument(doc); doc.selection = null; @@ -386,6 +393,7 @@ function main() { if (!window.confirm('Discard unsaved APT/AIR changes and start new?')) return; } resetDocument(doc); + doc.serverValidation = null; cancelDraw(draw); overlays.clearDrawPreview(); toolbar.setMode(MODE_SELECT); @@ -591,6 +599,7 @@ function main() { */ function afterAptMutation(opts = {}) { validateDocument(doc); + markServerValidationStale(doc); syncFallbackTextareas(); refresh(opts); } @@ -600,10 +609,109 @@ function main() { */ function afterAirMutation(opts = {}) { validateDocument(doc); + markServerValidationStale(doc); syncFallbackTextareas(); refresh(opts); } + /** + * POST formatted document text to Admin validate-apt / validate-air APIs. + */ + async function confirmWithServer() { + if (doc.serverValidation?.loading) return; + + const aptURL = + root.getAttribute('data-validate-apt') || '/api/v1/editor/validate-apt'; + const airURL = + root.getAttribute('data-validate-air') || '/api/v1/editor/validate-air'; + + const hasApt = !!doc.airport; + const hasAir = Array.isArray(doc.aircraft) && doc.aircraft.length > 0; + if (!hasApt && !hasAir) { + doc.serverValidation = { + loading: false, + stale: false, + message: 'Nothing to confirm — load or draw APT/AIR first.', + }; + refresh(); + showStatus('Nothing to confirm with server.', true); + return; + } + + doc.serverValidation = { + loading: true, + stale: false, + apt: null, + air: null, + }; + refresh(); + showStatus('Confirming with server…', false); + + /** @type {import('./model.js').ServerValidationSide|null} */ + let aptResult = null; + /** @type {import('./model.js').ServerValidationSide|null} */ + let airResult = null; + let transportErr = null; + + try { + if (hasApt) { + const text = formatAPT(doc.airport); + const r = await postServerValidate(aptURL, text, { side: 'apt' }); + aptResult = { + ok: r.ok && r.httpOk, + error: r.httpOk ? r.error : r.error || `HTTP ${r.status}`, + errors: r.errors || [], + summary: r.summary || {}, + }; + } + if (hasAir) { + const text = formatAIR(doc.aircraft); + const r = await postServerValidate(airURL, text, { side: 'air' }); + airResult = { + ok: r.ok && r.httpOk, + error: r.httpOk ? r.error : r.error || `HTTP ${r.status}`, + errors: r.errors || [], + summary: r.summary || {}, + }; + } + } catch (err) { + transportErr = errMessage(err); + } + + doc.serverValidation = { + loading: false, + stale: false, + apt: aptResult, + air: airResult, + message: transportErr || undefined, + }; + refresh(); + + if (transportErr) { + showStatus(`Server confirm failed: ${transportErr}`, true); + return; + } + const aptN = aptResult?.errors?.length || 0; + const airN = airResult?.errors?.length || 0; + const aptFail = aptResult && !aptResult.ok; + const airFail = airResult && !airResult.ok; + if (aptFail || airFail) { + showStatus( + `Server confirm request error${aptFail && aptResult?.error ? `: ${aptResult.error}` : airFail && airResult?.error ? `: ${airResult.error}` : ''}.`, + true, + ); + return; + } + const total = aptN + airN; + showStatus( + total + ? `Server confirm: ${total} error(s) (APT ${aptN}, AIR ${airN}).` + : 'Server confirm: OK.', + total > 0, + ); + rail.setTab('validate'); + } + /** * @param {{ fit?: boolean }} [opts] */ diff --git a/internal/web/static/js/openfsd/airport-editor/map-layers.js b/internal/web/static/js/openfsd/airport-editor/map-layers.js index ec3bdae..0dbe301 100644 --- a/internal/web/static/js/openfsd/airport-editor/map-layers.js +++ b/internal/web/static/js/openfsd/airport-editor/map-layers.js @@ -131,7 +131,7 @@ export function countSurfacesByKind(surfaces) { /** * Titlebar chip strings from document (pure). * @param {import('./model.js').EditorDocument} doc - * @returns {{ icao: string, apt: string, air: string, counts: string }} + * @returns {{ icao: string, apt: string, air: string, counts: string, issues: string, issuesTone: 'ok'|'err'|'warn'|'empty' }} */ export function titlebarChips(doc) { const icao = doc.airport?.icao ? String(doc.airport.icao).toUpperCase() : '—'; @@ -145,7 +145,30 @@ export function titlebarChips(doc) { if (sc.taxi) parts.push(`${sc.taxi} taxi`); if (sc.hold) parts.push(`${sc.hold} hold`); if (acN) parts.push(`${acN} ac`); - return { icao, apt, air, counts: parts.join(' · ') }; + + const aptN = Array.isArray(doc.aptErrors) ? doc.aptErrors.length : 0; + const airN = Array.isArray(doc.airErrors) ? doc.airErrors.length : 0; + const softN = Array.isArray(doc.softWarnings) ? doc.softWarnings.length : 0; + const total = aptN + airN + softN; + const hasContent = !!doc.airport || acN > 0; + /** @type {'ok'|'err'|'warn'|'empty'} */ + let issuesTone = 'empty'; + let issues = '—'; + if (!hasContent && total === 0) { + issues = '—'; + issuesTone = 'empty'; + } else if (total === 0) { + issues = 'OK'; + issuesTone = 'ok'; + } else if (aptN + airN > 0) { + issues = `${total} issue${total === 1 ? '' : 's'}`; + issuesTone = 'err'; + } else { + issues = `${softN} warn${softN === 1 ? '' : 's'}`; + issuesTone = 'warn'; + } + + return { icao, apt, air, counts: parts.join(' · '), issues, issuesTone }; } /** diff --git a/internal/web/static/js/openfsd/airport-editor/model.js b/internal/web/static/js/openfsd/airport-editor/model.js index 0ed5381..eb63482 100644 --- a/internal/web/static/js/openfsd/airport-editor/model.js +++ b/internal/web/static/js/openfsd/airport-editor/model.js @@ -72,6 +72,24 @@ * @property {string|null} lastAptDownloadHash * @property {string|null} lastAirDownloadHash * @property {number} _nextSurfaceId + * @property {ServerValidationState|null} [serverValidation] - last server confirm result (UI) + */ + +/** + * @typedef {Object} ServerValidationSide + * @property {boolean} ok + * @property {string|null} error + * @property {string[]} errors + * @property {{ icao?: string, surface_count?: number, aircraft_count?: number }} summary + */ + +/** + * @typedef {Object} ServerValidationState + * @property {boolean} loading + * @property {boolean} stale - true after local edits since last confirm + * @property {ServerValidationSide|null} [apt] + * @property {ServerValidationSide|null} [air] + * @property {string} [message] - top-level status (e.g. nothing to send) */ // Surface kind identifiers (TWRTrainer section types). @@ -178,9 +196,20 @@ export function createEmptyDocument() { lastAptDownloadHash: null, lastAirDownloadHash: null, _nextSurfaceId: 1, + serverValidation: null, }; } +/** + * Mark last server confirm result as stale after local edits (if any). + * @param {EditorDocument} doc + */ +export function markServerValidationStale(doc) { + if (doc?.serverValidation && !doc.serverValidation.loading) { + doc.serverValidation = { ...doc.serverValidation, stale: true }; + } +} + /** * Stable string hash for dirty tracking (FNV-1a 32-bit hex). * Pure — Node-testable. diff --git a/internal/web/static/js/openfsd/airport-editor/server-validate.js b/internal/web/static/js/openfsd/airport-editor/server-validate.js new file mode 100644 index 0000000..065eb6e --- /dev/null +++ b/internal/web/static/js/openfsd/airport-editor/server-validate.js @@ -0,0 +1,249 @@ +/** + * Server validate helpers for the airport editor. + * POST /api/v1/editor/validate-apt|validate-air with JSON {"text":"…"}. + * Pure parsing + CSRF-aware fetch (same-origin cookie session). + */ + +/** + * @param {string|null|undefined} text + * @returns {{ text: string }} + */ +export function buildValidateRequest(text) { + return { text: text == null ? '' : String(text) }; +} + +/** + * Read CSRF token from meta tag or openfsd_csrf cookie (double-submit). + * @param {Document|null|undefined} [doc] + * @returns {string} + */ +export function getCSRFToken(doc) { + const d = doc ?? (typeof document !== 'undefined' ? document : null); + if (!d) return ''; + const meta = d.querySelector?.('meta[name="csrf-token"]'); + if (meta && 'content' in meta && meta.content) { + return String(meta.content); + } + const cookie = typeof d.cookie === 'string' ? d.cookie : ''; + const match = cookie.match(/(?:^|;\s*)openfsd_csrf=([^;]+)/); + return match ? decodeURIComponent(match[1]) : ''; +} + +/** + * Normalize APIV1 validate-apt/air response body. + * Soft parse issues live in data.errors (HTTP 200); envelope err is for failures. + * + * @param {*} body + * @param {'apt'|'air'} [side='apt'] + * @returns {{ + * ok: boolean, + * error: string|null, + * errors: string[], + * summary: { icao?: string, surface_count?: number, aircraft_count?: number } + * }} + */ +export function parseValidateAPIResponse(body, side = 'apt') { + if (!body || typeof body !== 'object') { + return { ok: false, error: 'invalid response', errors: [], summary: {} }; + } + if (body.err != null && body.err !== '') { + return { ok: false, error: String(body.err), errors: [], summary: {} }; + } + const data = body.data && typeof body.data === 'object' ? body.data : {}; + const rawErrs = Array.isArray(data.errors) ? data.errors : []; + const errors = rawErrs.map((e) => String(e)); + /** @type {{ icao?: string, surface_count?: number, aircraft_count?: number }} */ + const summary = {}; + if (side === 'air') { + summary.aircraft_count = Number.isFinite(Number(data.aircraft_count)) + ? Number(data.aircraft_count) + : 0; + } else { + summary.icao = data.icao != null ? String(data.icao) : ''; + summary.surface_count = Number.isFinite(Number(data.surface_count)) + ? Number(data.surface_count) + : 0; + } + return { ok: true, error: null, errors, summary }; +} + +/** + * POST text to a validate endpoint. credentials: 'same-origin'; CSRF on mutation. + * + * @param {string} url + * @param {string} text + * @param {{ + * fetchImpl?: typeof fetch, + * csrfToken?: string, + * doc?: Document|null, + * side?: 'apt'|'air' + * }} [opts] + * @returns {Promise<{ + * httpOk: boolean, + * status: number, + * ok: boolean, + * error: string|null, + * errors: string[], + * summary: { icao?: string, surface_count?: number, aircraft_count?: number } + * }>} + */ +export async function postServerValidate(url, text, opts = {}) { + const fetchImpl = opts.fetchImpl ?? (typeof fetch !== 'undefined' ? fetch : null); + if (!fetchImpl) { + return { + httpOk: false, + status: 0, + ok: false, + error: 'fetch unavailable', + errors: [], + summary: {}, + }; + } + const side = opts.side === 'air' ? 'air' : 'apt'; + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + const csrf = opts.csrfToken != null ? opts.csrfToken : getCSRFToken(opts.doc); + if (csrf) { + headers['X-CSRF-Token'] = csrf; + } + + let res; + try { + res = await fetchImpl(url, { + method: 'POST', + headers, + credentials: 'same-origin', + body: JSON.stringify(buildValidateRequest(text)), + }); + } catch (err) { + const msg = err && typeof err === 'object' && 'message' in err + ? String(/** @type {{ message: unknown }} */ (err).message) + : 'network error'; + return { + httpOk: false, + status: 0, + ok: false, + error: msg, + errors: [], + summary: {}, + }; + } + + let body = null; + const ct = res.headers?.get?.('content-type') || ''; + if (ct.includes('application/json')) { + try { + body = await res.json(); + } catch { + body = null; + } + } + + const parsed = parseValidateAPIResponse(body, side); + if (!res.ok) { + const errMsg = + parsed.error || + (body && body.err) || + res.statusText || + `HTTP ${res.status}`; + return { + httpOk: false, + status: res.status, + ok: false, + error: String(errMsg), + errors: [], + summary: {}, + }; + } + return { + httpOk: true, + status: res.status, + ok: parsed.ok, + error: parsed.error, + errors: parsed.errors, + summary: parsed.summary, + }; +} + +/** + * Aggregate client-side issue counts for badge / summary. + * @param {{ aptErrors?: string[], airErrors?: string[], softWarnings?: string[], airport?: *, aircraft?: *[] }|null|undefined} doc + * @returns {{ apt: number, air: number, soft: number, total: number, hasContent: boolean }} + */ +export function countClientIssues(doc) { + const apt = Array.isArray(doc?.aptErrors) ? doc.aptErrors.length : 0; + const air = Array.isArray(doc?.airErrors) ? doc.airErrors.length : 0; + const soft = Array.isArray(doc?.softWarnings) ? doc.softWarnings.length : 0; + const hasAirport = !!doc?.airport; + const hasAircraft = Array.isArray(doc?.aircraft) && doc.aircraft.length > 0; + return { + apt, + air, + soft, + total: apt + air + soft, + hasContent: hasAirport || hasAircraft, + }; +} + +/** + * Titlebar / tab badge text for live client issues. + * @param {{ total: number, hasContent: boolean, soft?: number }} counts + * @returns {string} + */ +export function issueBadgeText(counts) { + if (!counts) return '—'; + if (!counts.hasContent && counts.total === 0) return '—'; + if (counts.total === 0) return 'OK'; + const n = counts.total; + const soft = counts.soft || 0; + if (soft === n) { + return `${n} warn${n === 1 ? '' : 's'}`; + } + return `${n} issue${n === 1 ? '' : 's'}`; +} + +/** + * Human summary line for the Validate tab header. + * @param {{ apt: number, air: number, soft: number, total: number, hasContent: boolean }} counts + * @returns {string} + */ +export function clientSummaryLine(counts) { + if (!counts.hasContent && counts.total === 0) { + return 'No document loaded'; + } + if (counts.total === 0) { + return 'Client: no parse errors or soft warnings'; + } + const parts = []; + if (counts.apt) parts.push(`${counts.apt} APT`); + if (counts.air) parts.push(`${counts.air} AIR`); + if (counts.soft) parts.push(`${counts.soft} soft`); + return `Client: ${parts.join(' · ')} (${counts.total} total)`; +} + +/** + * Format a one-line server result summary. + * @param {'apt'|'air'} side + * @param {{ ok: boolean, error: string|null, errors: string[], summary: object }|null|undefined} result + * @returns {string} + */ +export function serverResultSummary(side, result) { + if (!result) return ''; + if (!result.ok || result.error) { + return `Server ${side.toUpperCase()}: ${result.error || 'failed'}`; + } + const n = result.errors?.length || 0; + if (side === 'air') { + const ac = result.summary?.aircraft_count ?? 0; + return n + ? `Server AIR: ${n} error(s), ${ac} aircraft` + : `Server AIR: OK (${ac} aircraft)`; + } + const icao = result.summary?.icao || '—'; + const sc = result.summary?.surface_count ?? 0; + return n + ? `Server APT: ${n} error(s), ICAO ${icao}, ${sc} surfaces` + : `Server APT: OK (ICAO ${icao}, ${sc} surfaces)`; +} diff --git a/internal/web/static/js/openfsd/airport-editor/ui-rail.js b/internal/web/static/js/openfsd/airport-editor/ui-rail.js index 8d70d91..c939024 100644 --- a/internal/web/static/js/openfsd/airport-editor/ui-rail.js +++ b/internal/web/static/js/openfsd/airport-editor/ui-rail.js @@ -39,6 +39,7 @@ export const RAIL_TABS = ['airport', 'surfaces', 'aircraft', 'validate', 'raw']; * @property {(index: number) => void} [onDeleteAircraft] * @property {(acIndex: number, parkingSurfaceIndex: number) => void} [onSnap] * @property {(side: 'apt'|'air', text: string) => void} [onApplyRaw] + * @property {() => void} [onConfirmServer] - POST current text to validate-apt/air */ /** @@ -194,6 +195,10 @@ export function mountRail(root, handlers) { } return; } + if (action === 'confirm-server' && handlers.onConfirmServer) { + handlers.onConfirmServer(); + return; + } } /** @@ -482,17 +487,43 @@ function renderValidatePanel(root, doc) { const el = root.querySelector('[data-js="panel-validate"]'); if (!el) return; clearEl(el); + const aptErrs = doc.aptErrors || []; const airErrs = doc.airErrors || []; const soft = doc.softWarnings || []; - if (!aptErrs.length && !airErrs.length && !soft.length) { - if (!doc.airport && !(doc.aircraft && doc.aircraft.length)) { - el.appendChild(hint('Load or draw geometry / aircraft to see issues.')); - } else { - el.appendChild(hint('No parse errors or soft warnings.')); - } - return; + const hasAirport = !!doc.airport; + const hasAircraft = Array.isArray(doc.aircraft) && doc.aircraft.length > 0; + const empty = !hasAirport && !hasAircraft; + const clientTotal = aptErrs.length + airErrs.length + soft.length; + const loading = !!doc.serverValidation?.loading; + + // Live summary + const summary = document.createElement('p'); + summary.className = 'apted-validate-summary'; + summary.setAttribute('data-js', 'validate-summary'); + if (empty) { + summary.textContent = 'No APT/AIR loaded.'; + } else if (clientTotal === 0) { + summary.textContent = 'Client: no parse errors or soft warnings.'; + } else { + const parts = []; + if (aptErrs.length) parts.push(`${aptErrs.length} APT error${aptErrs.length === 1 ? '' : 's'}`); + if (airErrs.length) parts.push(`${airErrs.length} AIR error${airErrs.length === 1 ? '' : 's'}`); + if (soft.length) parts.push(`${soft.length} soft warning${soft.length === 1 ? '' : 's'}`); + summary.textContent = `Client: ${parts.join(' · ')}.`; } + el.appendChild(summary); + + if (empty) { + el.appendChild( + hint( + 'No airport geometry or aircraft loaded. Open a .apt / .air file, paste via Raw Apply, or draw on the map (Park / Taxi / Rwy / Hold / Aircraft modes). Live parse issues and soft cross-file warnings appear here.', + ), + ); + } else if (clientTotal === 0) { + el.appendChild(hint('Live client parse matches the in-memory document. Soft cross-file checks (dep ICAO, aircraft distance) also look clean.')); + } + if (aptErrs.length) { el.appendChild(sectionTitle(`APT errors (${aptErrs.length})`)); el.appendChild(errorList(aptErrs)); @@ -502,9 +533,118 @@ function renderValidatePanel(root, doc) { el.appendChild(errorList(airErrs)); } if (soft.length) { - el.appendChild(sectionTitle(`Soft warnings (${soft.length})`)); + const softHd = sectionTitle(`Soft warnings (${soft.length})`); + softHd.classList.add('apted-soft-hd'); + el.appendChild(softHd); + const softNote = document.createElement('p'); + softNote.className = 'apted-hint apted-soft-note'; + softNote.textContent = + 'Not parse failures — dep ICAO ≠ airport ICAO, or aircraft farther than ~50 NM from the field.'; + el.appendChild(softNote); el.appendChild(errorList(soft, 'warn')); } + + // Server confirm + const serverBox = document.createElement('div'); + serverBox.className = 'apted-server-validate'; + serverBox.setAttribute('data-js', 'server-validate'); + + const serverHd = sectionTitle('Server confirm'); + serverBox.appendChild(serverHd); + + const serverHint = document.createElement('p'); + serverHint.className = 'apted-hint'; + serverHint.textContent = + 'Optional: re-check formatted text with the Go ParseAPT / ParseAIR path (Admin API). Does not save files.'; + serverBox.appendChild(serverHint); + + const btnRow = document.createElement('div'); + btnRow.className = 'apted-btn-row'; + const confirmBtn = document.createElement('button'); + confirmBtn.type = 'button'; + confirmBtn.className = 'btn btn-sm btn-outline-primary'; + confirmBtn.setAttribute('data-action', 'confirm-server'); + confirmBtn.setAttribute('data-js', 'confirm-server'); + confirmBtn.textContent = loading ? 'Confirming…' : 'Confirm with server'; + confirmBtn.disabled = loading || empty; + if (empty) confirmBtn.title = 'Load or draw APT/AIR first'; + btnRow.appendChild(confirmBtn); + serverBox.appendChild(btnRow); + + const sv = doc.serverValidation; + if (sv?.message && !sv.apt && !sv.air) { + serverBox.appendChild(hint(sv.message)); + } + if (sv?.stale && (sv.apt || sv.air) && !loading) { + const stale = document.createElement('p'); + stale.className = 'apted-hint apted-server-stale'; + stale.setAttribute('data-js', 'server-stale'); + stale.textContent = 'Document changed since last server confirm — results may be stale.'; + serverBox.appendChild(stale); + } + if (sv?.apt) { + serverBox.appendChild(serverSideBlock('APT', sv.apt)); + } + if (sv?.air) { + serverBox.appendChild(serverSideBlock('AIR', sv.air)); + } + el.appendChild(serverBox); + + // Sweatbox handoff + const handoff = document.createElement('div'); + handoff.className = 'apted-handoff'; + handoff.setAttribute('data-js', 'sweatbox-handoff'); + const handoffP = document.createElement('p'); + handoffP.className = 'apted-hint'; + handoffP.appendChild(document.createTextNode('When ready: Download files, then load in ')); + const link = document.createElement('a'); + link.href = '/sweatbox'; + link.textContent = 'Sweatbox'; + link.setAttribute('data-js', 'sweatbox-link'); + handoffP.appendChild(link); + handoffP.appendChild(document.createTextNode('. The editor never pushes into a live session.')); + handoff.appendChild(handoffP); + el.appendChild(handoff); +} + +/** + * @param {'APT'|'AIR'} label + * @param {import('./model.js').ServerValidationSide} side + */ +function serverSideBlock(label, side) { + const wrap = document.createElement('div'); + wrap.className = 'apted-server-side'; + wrap.setAttribute('data-js', `server-${label.toLowerCase()}`); + + if (!side.ok || side.error) { + wrap.appendChild(sectionTitle(`Server ${label} — failed`)); + wrap.appendChild(hint(side.error || 'Request failed')); + return wrap; + } + + const n = side.errors?.length || 0; + let meta = ''; + if (label === 'APT') { + meta = `ICAO ${side.summary?.icao || '—'} · ${side.summary?.surface_count ?? 0} surfaces`; + } else { + meta = `${side.summary?.aircraft_count ?? 0} aircraft`; + } + + if (n === 0) { + const ok = document.createElement('p'); + ok.className = 'apted-hint apted-server-ok'; + ok.textContent = `Server ${label}: OK — ${meta}`; + wrap.appendChild(ok); + return wrap; + } + + wrap.appendChild(sectionTitle(`Server ${label} errors (${n})`)); + const metaP = document.createElement('p'); + metaP.className = 'apted-hint'; + metaP.textContent = meta; + wrap.appendChild(metaP); + wrap.appendChild(errorList(side.errors)); + return wrap; } /** @@ -696,13 +836,33 @@ function renderInspector(root, doc) { /** * @param {HTMLElement} root - * @param {{ icao: string, apt: string, air: string, counts: string }} chips + * @param {{ icao: string, apt: string, air: string, counts: string, issues?: string, issuesTone?: 'ok'|'err'|'warn'|'empty' }} chips */ export function applyTitlebarChips(root, chips) { setText(root.querySelector('[data-js="chip-icao"]'), chips.icao); setText(root.querySelector('[data-js="chip-apt"]'), chips.apt); setText(root.querySelector('[data-js="chip-air"]'), chips.air); setText(root.querySelector('[data-js="chip-counts"]'), chips.counts); + const issuesEl = root.querySelector('[data-js="chip-issues"]'); + if (issuesEl) { + setText(issuesEl, chips.issues != null ? chips.issues : '—'); + issuesEl.classList.remove('is-ok', 'is-err', 'is-warn', 'is-empty'); + const tone = chips.issuesTone || 'empty'; + if (tone === 'ok') issuesEl.classList.add('is-ok'); + else if (tone === 'err') issuesEl.classList.add('is-err'); + else if (tone === 'warn') issuesEl.classList.add('is-warn'); + else issuesEl.classList.add('is-empty'); + issuesEl.setAttribute( + 'title', + tone === 'ok' + ? 'No client parse errors or soft warnings' + : tone === 'err' + ? 'Client parse errors and/or soft warnings — see Validate tab' + : tone === 'warn' + ? 'Soft cross-file warnings only — see Validate tab' + : 'Load APT/AIR to validate', + ); + } const aptChip = root.querySelector('[data-js="chip-apt"]'); const airChip = root.querySelector('[data-js="chip-air"]'); if (aptChip) { @@ -723,6 +883,29 @@ export function applyTitlebarChips(root, chips) { : 'Scenario aircraft', ); } + // Validate tab badge (issue count on the tab button) + const valTab = root.querySelector('[data-tab="validate"]'); + if (valTab && chips.issues != null) { + let badge = valTab.querySelector('[data-js="tab-issues-badge"]'); + if (!badge) { + badge = document.createElement('span'); + badge.className = 'apted-tab-badge'; + badge.setAttribute('data-js', 'tab-issues-badge'); + valTab.appendChild(badge); + } + const tone = chips.issuesTone || 'empty'; + if (tone === 'empty' || chips.issues === '—' || chips.issues === 'OK') { + badge.hidden = tone === 'empty' || chips.issues === '—'; + badge.textContent = chips.issues === 'OK' ? 'OK' : ''; + if (chips.issues === 'OK') badge.hidden = false; + } else { + badge.hidden = false; + badge.textContent = chips.issues; + } + badge.classList.toggle('is-ok', tone === 'ok'); + badge.classList.toggle('is-err', tone === 'err'); + badge.classList.toggle('is-warn', tone === 'warn'); + } } // --- DOM helpers (textContent only for user-derived values) --- diff --git a/internal/web/templates/airport_editor.html b/internal/web/templates/airport_editor.html index 3da1f4c..87a1b13 100644 --- a/internal/web/templates/airport_editor.html +++ b/internal/web/templates/airport_editor.html @@ -16,7 +16,9 @@
+ data-js="airport-editor" + data-validate-apt="/api/v1/editor/validate-apt" + data-validate-air="/api/v1/editor/validate-air">

Airport Editor

@@ -24,6 +26,8 @@ APT AIR +

@@ -141,10 +145,33 @@ role="tabpanel" aria-labelledby="apted-tab-validate">

Validate

- Parse errors + Client + server
-

Load .apt / .air to see parse issues.

+

+ No APT/AIR loaded. Open files or draw on the map to see live parse + errors and soft cross-file warnings. +

+
+

Server confirm

+

+ Optional Go ParseAPT / ParseAIR check via Admin API (does not save). +

+
+ +
+
+
+

+ When ready: Download files, then load in + Sweatbox. +

+
diff --git a/webjs/airport-editor/map-layers.test.js b/webjs/airport-editor/map-layers.test.js index 8e9ae79..10d12ec 100644 --- a/webjs/airport-editor/map-layers.test.js +++ b/webjs/airport-editor/map-layers.test.js @@ -95,6 +95,8 @@ test('titlebarChips', () => { assert.equal(chips.apt, 'APT'); assert.equal(chips.air, 'AIR'); assert.equal(chips.counts, ''); + assert.equal(chips.issues, '—'); + assert.equal(chips.issuesTone, 'empty'); doc.airport = { icao: 'kbtv', @@ -112,6 +114,19 @@ test('titlebarChips', () => { assert.match(chips.counts, /1 park/); assert.match(chips.counts, /1 rwy/); assert.match(chips.counts, /2 ac/); + assert.equal(chips.issues, 'OK'); + assert.equal(chips.issuesTone, 'ok'); + + doc.aptErrors = ['bad header']; + doc.softWarnings = ['dep mismatch']; + chips = titlebarChips(doc); + assert.equal(chips.issuesTone, 'err'); + assert.match(chips.issues, /2 issue/); + + doc.aptErrors = []; + chips = titlebarChips(doc); + assert.equal(chips.issuesTone, 'warn'); + assert.match(chips.issues, /1 warn/); }); test('normalizeSelection', () => { diff --git a/webjs/airport-editor/server-validate.test.js b/webjs/airport-editor/server-validate.test.js new file mode 100644 index 0000000..655c5a8 --- /dev/null +++ b/webjs/airport-editor/server-validate.test.js @@ -0,0 +1,238 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildValidateRequest, + parseValidateAPIResponse, + getCSRFToken, + postServerValidate, + countClientIssues, + issueBadgeText, + clientSummaryLine, + serverResultSummary, +} from '../../internal/web/static/js/openfsd/airport-editor/server-validate.js'; +import { createEmptyDocument } from '../../internal/web/static/js/openfsd/airport-editor/model.js'; +import { markServerValidationStale } from '../../internal/web/static/js/openfsd/airport-editor/model.js'; + +test('buildValidateRequest', () => { + assert.deepEqual(buildValidateRequest('icao=KBTV\n'), { text: 'icao=KBTV\n' }); + assert.deepEqual(buildValidateRequest(null), { text: '' }); + assert.deepEqual(buildValidateRequest(undefined), { text: '' }); +}); + +test('parseValidateAPIResponse apt success with errors', () => { + const r = parseValidateAPIResponse( + { + version: 'v1', + err: null, + data: { + errors: ['Parking area G1 has no waypoint defined.'], + icao: 'KBTV', + surface_count: 12, + }, + }, + 'apt', + ); + assert.equal(r.ok, true); + assert.equal(r.error, null); + assert.equal(r.errors.length, 1); + assert.match(r.errors[0], /G1/); + assert.equal(r.summary.icao, 'KBTV'); + assert.equal(r.summary.surface_count, 12); +}); + +test('parseValidateAPIResponse air success empty errors', () => { + const r = parseValidateAPIResponse( + { + version: 'v1', + err: null, + data: { errors: [], aircraft_count: 3 }, + }, + 'air', + ); + assert.equal(r.ok, true); + assert.deepEqual(r.errors, []); + assert.equal(r.summary.aircraft_count, 3); +}); + +test('parseValidateAPIResponse envelope error', () => { + const r = parseValidateAPIResponse({ version: 'v1', err: 'forbidden', data: null }, 'apt'); + assert.equal(r.ok, false); + assert.equal(r.error, 'forbidden'); + assert.deepEqual(r.errors, []); +}); + +test('parseValidateAPIResponse invalid body', () => { + const r = parseValidateAPIResponse(null, 'apt'); + assert.equal(r.ok, false); + assert.match(r.error || '', /invalid/); +}); + +test('getCSRFToken from meta and cookie', () => { + const metaDoc = { + querySelector(sel) { + if (sel === 'meta[name="csrf-token"]') return { content: 'meta-tok' }; + return null; + }, + cookie: '', + }; + assert.equal(getCSRFToken(metaDoc), 'meta-tok'); + + const cookieDoc = { + querySelector() { + return null; + }, + cookie: 'openfsd_session=abc; openfsd_csrf=cookie%2Dtok; other=1', + }; + assert.equal(getCSRFToken(cookieDoc), 'cookie-tok'); + + assert.equal(getCSRFToken(null), ''); +}); + +test('postServerValidate sends CSRF and same-origin credentials', async () => { + /** @type {RequestInit|undefined} */ + let seen; + const fetchImpl = async (url, opts) => { + seen = opts; + assert.equal(url, '/api/v1/editor/validate-apt'); + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => 'application/json' }, + json: async () => ({ + version: 'v1', + err: null, + data: { errors: [], icao: 'KBTV', surface_count: 1 }, + }), + }; + }; + const r = await postServerValidate('/api/v1/editor/validate-apt', 'icao=KBTV\n', { + fetchImpl, + csrfToken: 'tok123', + side: 'apt', + }); + assert.equal(r.httpOk, true); + assert.equal(r.ok, true); + assert.equal(r.summary.icao, 'KBTV'); + assert.equal(seen?.credentials, 'same-origin'); + assert.equal(seen?.method, 'POST'); + assert.equal(/** @type {Record} */ (seen?.headers)['X-CSRF-Token'], 'tok123'); + assert.equal( + /** @type {Record} */ (seen?.headers)['Content-Type'], + 'application/json', + ); + assert.equal(seen?.body, JSON.stringify({ text: 'icao=KBTV\n' })); +}); + +test('postServerValidate HTTP error surfaces body.err', async () => { + const fetchImpl = async () => ({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: { get: () => 'application/json' }, + json: async () => ({ version: 'v1', err: 'forbidden', data: null }), + }); + const r = await postServerValidate('/api/v1/editor/validate-air', 'x', { + fetchImpl, + csrfToken: '', + side: 'air', + }); + assert.equal(r.httpOk, false); + assert.equal(r.status, 403); + assert.equal(r.ok, false); + assert.equal(r.error, 'forbidden'); +}); + +test('postServerValidate network failure', async () => { + const fetchImpl = async () => { + throw new Error('offline'); + }; + const r = await postServerValidate('/x', 't', { fetchImpl, side: 'apt' }); + assert.equal(r.httpOk, false); + assert.equal(r.error, 'offline'); +}); + +test('countClientIssues and issueBadgeText', () => { + const empty = countClientIssues(createEmptyDocument()); + assert.equal(empty.total, 0); + assert.equal(empty.hasContent, false); + assert.equal(issueBadgeText(empty), '—'); + + const ok = countClientIssues({ + airport: { icao: 'KBTV' }, + aircraft: [], + aptErrors: [], + airErrors: [], + softWarnings: [], + }); + assert.equal(ok.hasContent, true); + assert.equal(issueBadgeText(ok), 'OK'); + + const softOnly = countClientIssues({ + airport: { icao: 'KBTV' }, + aircraft: [{}], + aptErrors: [], + airErrors: [], + softWarnings: ['dep mismatch'], + }); + assert.equal(softOnly.total, 1); + assert.equal(issueBadgeText(softOnly), '1 warn'); + + const mixed = countClientIssues({ + airport: { icao: 'KBTV' }, + aptErrors: ['a', 'b'], + airErrors: ['c'], + softWarnings: ['d'], + }); + assert.equal(mixed.total, 4); + assert.equal(issueBadgeText(mixed), '4 issues'); + assert.match(clientSummaryLine(mixed), /2 APT/); + assert.match(clientSummaryLine(mixed), /1 AIR/); + assert.match(clientSummaryLine(mixed), /1 soft/); +}); + +test('serverResultSummary', () => { + assert.equal(serverResultSummary('apt', null), ''); + assert.match( + serverResultSummary('apt', { + ok: true, + error: null, + errors: [], + summary: { icao: 'KBTV', surface_count: 2 }, + }), + /OK.*KBTV/, + ); + assert.match( + serverResultSummary('air', { + ok: true, + error: null, + errors: ['bad row'], + summary: { aircraft_count: 1 }, + }), + /1 error/, + ); + assert.match( + serverResultSummary('apt', { ok: false, error: 'forbidden', errors: [], summary: {} }), + /forbidden/, + ); +}); + +test('markServerValidationStale', () => { + const doc = createEmptyDocument(); + markServerValidationStale(doc); + assert.equal(doc.serverValidation, null); + + doc.serverValidation = { + loading: false, + stale: false, + apt: { ok: true, error: null, errors: [], summary: {} }, + }; + markServerValidationStale(doc); + assert.equal(doc.serverValidation.stale, true); + + doc.serverValidation = { loading: true, stale: false }; + markServerValidationStale(doc); + assert.equal(doc.serverValidation.stale, false); + assert.equal(doc.serverValidation.loading, true); +}); diff --git a/wiki/Home.md b/wiki/Home.md index 43ef8e2..b9730b0 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -13,6 +13,13 @@ Operator documentation for [openfsd](https://github.com/renorris/openfsd). | [Client Connection](Client-Connection.md) | VRC, Euroscope, Swift, vPilot, xPilot | | [Migrating from PostgreSQL](Migrating-from-PostgreSQL.md) | Convert an existing Postgres DB to SQLite | +## Admin web tools (in the product UI) + +| URL | Who | Notes | +|-----|-----|--------| +| `/sweatbox` | Administrator | Live ground/taxi simulator control (needs FSD + sweatbox enabled) | +| `/airport-editor` | Administrator | Map-first `.apt` / `.air` authoring; **download only** (no server save). Validate tab has live client checks + optional server confirm. Download files, then load them on `/sweatbox`. Design: repo `docs/design/apt-air-editor.md` | + ## Quick links - Images: `ghcr.io/renorris/openfsd` (`:latest`, `:dev`, `sha-*`)