web: airport-editor validation UX and docs polish

Live client + server confirm validation panel, empty states, sweatbox
handoff copy, and residual docs.
This commit is contained in:
Reese Norris
2026-07-23 20:37:49 -04:00
parent 1726fbe751
commit 6f80c534d9
13 changed files with 1029 additions and 15 deletions

View File

@@ -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
```

View File

@@ -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 3050 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.
---

View File

@@ -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))

View File

@@ -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 {

View File

@@ -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]
*/

View File

@@ -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 };
}
/**

View File

@@ -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.

View File

@@ -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)`;
}

View File

@@ -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) ---

View File

@@ -16,7 +16,9 @@
<link href="/static/css/openfsd/airport-editor.css" rel="stylesheet">
<main id="apted-root" class="apted container-fluid"
data-js="airport-editor">
data-js="airport-editor"
data-validate-apt="/api/v1/editor/validate-apt"
data-validate-air="/api/v1/editor/validate-air">
<header class="apted-titlebar">
<h1>Airport Editor</h1>
@@ -24,6 +26,8 @@
<span class="apted-chip apted-chip-icao" data-js="chip-icao"></span>
<span class="apted-chip" data-js="chip-apt" title="Airport geometry">APT</span>
<span class="apted-chip" data-js="chip-air" title="Scenario aircraft">AIR</span>
<span class="apted-chip apted-chip-issues is-empty" data-js="chip-issues"
title="Load APT/AIR to validate"></span>
<span class="apted-hint" data-js="chip-counts"></span>
</div>
<p class="apted-hint apted-title-hint">
@@ -141,10 +145,33 @@
role="tabpanel" aria-labelledby="apted-tab-validate">
<div class="apted-panel-hd">
<h2>Validate</h2>
<span class="apted-hint">Parse errors</span>
<span class="apted-hint">Client + server</span>
</div>
<div class="apted-panel-bd" data-js="panel-validate">
<p class="apted-hint">Load .apt / .air to see parse issues.</p>
<p class="apted-hint apted-validate-summary" data-js="validate-summary">
No APT/AIR loaded. Open files or draw on the map to see live parse
errors and soft cross-file warnings.
</p>
<div class="apted-server-validate" data-js="server-validate">
<h3 class="apted-subhd">Server confirm</h3>
<p class="apted-hint">
Optional Go ParseAPT / ParseAIR check via Admin API (does not save).
</p>
<div class="apted-btn-row">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="confirm-server" data-js="confirm-server"
disabled
title="Requires JavaScript and a loaded document">
Confirm with server
</button>
</div>
</div>
<div class="apted-handoff" data-js="sweatbox-handoff">
<p class="apted-hint">
When ready: Download files, then load in
<a href="/sweatbox" data-js="sweatbox-link">Sweatbox</a>.
</p>
</div>
</div>
</section>

View File

@@ -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', () => {

View File

@@ -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<string,string>} */ (seen?.headers)['X-CSRF-Token'], 'tok123');
assert.equal(
/** @type {Record<string,string>} */ (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);
});

View File

@@ -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-*`)