Steps 12-15 closure for cycle 4 (AZ-512 admin class inline edit):
- Step 12 (Test-Spec Sync): traceability O9 -> Covered; new FT-P-62
+ FT-N-18 in blackbox-tests.md.
- Step 13 (Update Docs): AdminPage module doc gains the inline-edit
state slots, four new handlers, PATCH integrations row, expanded
i18n key list, tests section. architecture.md row 272 now lists
PATCH /api/admin/classes/{id} with AZ-513 deploy-gate caveat.
- Step 14 (Security Audit): cycle-4 delta report records one new
LOW finding (F-SAST-CY4-1 lost-update / mid-air-collision on
PATCH, by design per spec); verdict carries PASS_WITH_WARNINGS;
bun audit re-run clean.
- Step 15 (Performance Test): NFT-PERF-01 bundle = 291 332 B
(+757 B / +0.26% vs cycle 3; ~13.89% of 2 MB budget); PASS.
Tests 243 passed / 13 skipped / 0 failed (+12 AZ-512 cases).
Co-authored-by: Cursor <cursoragent@cursor.com>
42 KiB
Azaion UI — Architecture
Synthesis output of
/documentStep 3a. Derived from Step 0 (00_discovery.md), Step 1 (modules/*.md), Step 2 (components/*/description.md), Step 2.5 (module-layout.md), and the legacy reference_docs/legacy/wpf-era.md. No new code is read in this step; if anything is missing, the gap belongs upstream.
Architecture Vision
Status: confirmed-by-user (Step 4.5 — 2026-05-10). Source of truth for the system's structural intent and non-negotiables. Reconciles the verified code-grounded view (Steps 1–4) with operator/product intent the code alone cannot convey. Glossary at
_docs/02_document/glossary.md.
What this system is
The Azaion UI is a React 19 single-page application, statically built and served by nginx inside an ARM64 container, that operates the browser-facing half of the Azaion UAV operations suite. It is the in-progress rewrite of the legacy WPF stack (Azaion.Annotator + Azaion.Dataset + a MapMatcher mission planner) and communicates with the parent suite's microservices over REST + SSE only — no SSR, no GraphQL, no in-browser persistence beyond a single bearer token in memory and a Secure HttpOnly refresh cookie.
The dominant pattern is "thin client over a typed REST contract": React Context for two cross-cutting concerns (auth, selected flight), no global state library, feature pages that fetch + cache locally. A second React 18 + MUI 5 tree (mission-planner/) lives in the repo as the port source for the flights component — it is NOT deployed; its features migrate into src/features/flights/ across Phase B feature cycles per the convergence plan below.
Components / responsibilities
| ID | Component | One-line responsibility |
|---|---|---|
| 00 | 00_foundation |
Types, hooks (useDebounce, useResizablePanel), i18n (en + ua) — the data-vocabulary of the SPA |
| 01 | 01_api-transport |
Native fetch wrapper (api/client.ts) + EventSource wrapper (api/sse.ts); 401-retry refresh lives here |
| 02 | 02_auth |
AuthContext, login / logout, bootstrap refresh (broken — finding B3) + 401-retry refresh (works) |
| 03 | 03_shared-ui |
Header (top nav + flight dropdown), FlightContext, ConfirmDialog, HelpModal, DetectionClasses strip |
| 04 | 04_login |
Public /login route |
| 05 | 05_flights |
Flight CRUD + waypoints + GPS-Denied + planned Test Mode; mission-planner/ is the port source |
| 06 | 06_annotations |
Bounding-box editor, AI Detect (sync today; async not wired), MediaList browser scoped to the selected flight |
| 07 | 07_dataset |
Dataset Explorer with three tabs (annotations / editor / class distribution); bulk-validate works |
| 08 | 08_admin |
Class CRUD (add + delete + edit, to be re-introduced), user management, AI/GPS settings forms (broken save) |
| 09 | 09_settings |
System / Directory / Camera / User settings |
| 10 | 10_app-shell |
App.tsx + main.tsx + routing tree |
| 11 | 11_class-colors |
Class → color + text mapping (lifted out of 06_annotations in Step 2 — its own component) |
Major data flows
- F1 Login → bearer in memory + refresh cookie set.
- F2 Refresh has TWO paths in code: broken bootstrap GET in
AuthContext.tsx:24vs. working 401-retry POST inapi/client.ts:44— Step 4 fix candidate. - F3 Flight selection persists as a
UserSettingsfield onannotations/(NOT/api/flights/select). - F5 Annotation save POSTs the doubly-prefixed
/api/annotations/annotations. - F6 AI Detect (sync) hits
/api/detect/${id}for both images and videos today (silent UX hazard for long videos — bridge until F7 ships). - F7 Async video detect is target-only; not wired today. Comes via Phase B cycles.
- F9 Bulk-validate works via the Validate button (only
[V]shortcut missing). - F12 GPS-Denied Test Mode is target-only; planned per
_docs/how_to_test.md. - F13 Live-GPS SSE stream per selected flight.
- F14 Annotation-status SSE (admin-wide, client-side filtered).
Full sequence diagrams + error scenarios: _docs/02_document/system-flows.md.
Architectural principles / non-negotiables
These are inferred from code or user-confirmed at Step 4.5. Downstream skills (refactor, decompose, new-task) treat them as binding constraints.
| # | Principle | Status | Inferred-from / source |
|---|---|---|---|
| P1 | REST + SSE only — no WebSocket, no GraphQL | inferred-from-code | src/api/client.ts, src/api/sse.ts |
| P2 | Static bundle + nginx reverse-proxy — zero UI runtime, no SSR, no React Server Components | inferred-from-code | Dockerfile, nginx.conf |
| P3 | Bearer in memory; refresh in HttpOnly cookie — tokens are never written to localStorage / sessionStorage | inferred-from-code | src/auth/AuthContext.tsx (no storage.* calls) |
| P4 | Two-context state — AuthContext + FlightContext are the only cross-cutting state stores; everything else is local |
inferred-from-code | src/auth/AuthContext.tsx, src/components/FlightContext.tsx |
| P5 | ARM-first edge deployment — Woodpecker builds ARM64 only | inferred-from-code | .woodpecker/build-arm.yml |
| P6 | Bilingual UI (en + ua) is mandatory — English-only UX is a regression | inferred-from-code + WPF era | src/i18n/i18n.ts, _docs/legacy/wpf-era.md |
| P7 | Lift cross-cutting concerns to their own component as soon as 2+ features touch them — class colors did this at Step 2 | inferred-from-code | components/11_class-colors/description.md emergence |
| P8 | WPF parity is a goal, not a constraint — features that don't make sense in a browser (DI host, LibVLC, ZeroMQ, binary-split key fragments, Cython sidecars) are intentionally NOT ported | inferred-from-WPF-source | _docs/legacy/wpf-era.md §11 |
| P9 | Spec is the source of truth for numeric enums (AnnotationStatus, MediaStatus, Affiliation, CombatReadiness) — UI types file matches the spec verbatim and adds inline comments per numeric value |
confirmed-by-user (Step 4.5) | src/types/index.ts; 04_verification_log.md enum drift |
| P10 | No hardcoded credentials in source — third-party keys (e.g., OpenWeatherMap) live in .env and are read via import.meta.env.* |
confirmed-by-user (Step 4.5) | mission-planner/src/utils/flightPlanUtils.ts:60 (current violation, Step 4 fix) |
| P11 | Persist what you type — fields declared in UserSettings (incl. resizable-panel widths) are actually persisted; the typed shape is the contract |
confirmed-by-user (Step 4.5) | src/hooks/useResizablePanel.ts (current violation, Step 4 fix); src/types/index.ts UserSettings |
| P12 | Admin can edit existing detection classes — add + edit + delete is the full CRUD surface; current code has add + delete only | confirmed-by-user (Step 4.5) | 04_verification_log.md F10; new PATCH /api/admin/classes/{id} to introduce |
Mission-planner convergence plan
mission-planner/ (37 modules, React 18 + MUI 5) is the port source for src/features/flights/ (15 modules, React 19 + Tailwind 4). They are one component, two trees — convergence is the active migration. Per Step 4.5 decision the timing across the autodev existing-code flow is:
| Autodev step | Role in convergence |
|---|---|
| Step 1 (Document) — done | Both trees documented as one component (05_flights) |
| Step 2 (Architecture Baseline Scan) | Flag the convergence as a Critical Architecture finding; produces the work-list of port targets |
| Step 3 (Test Spec) | Write ACs for the converged target — incl. mission-planner-only behaviors (camera-config side panel, mission JSON I/O, satellite tile provider, richer waypoint-altitude UX) |
| Steps 5–7 (Decompose Tests / Implement Tests / Run Tests) | Build the test safety net for the current src/features/flights/ |
| Step 8 (Refactor — optional) | Reserved for mechanical / no-behavior consolidation only (shared leaflet helpers, type aliases). Skip if convergence is all behavior-bearing. |
| Phase B feature cycles (Steps 9–17 looping) | Primary home for convergence. One Phase-B cycle per ported feature group: New Task → Implement → Run Tests → Test-Spec Sync → Update Docs → Security → Deploy → Retrospective. |
| Phase B final cycle | Once all port targets land, a final cycle deletes mission-planner/ (its only consumer became zero). |
Rationale: 37 modules with new behaviors is too big for a single Step 8 refactor. Phase B cycles give a per-feature test safety net, per-feature doc updates, and per-feature shippable deploys. source: flows/existing-code.md Steps 2 / 3 / 8 / 9–17.
Open questions / drift signals (deferred — for downstream skills)
These are surfaces the verified code can't answer alone; they will be picked up by Step 2 (Architecture Baseline) or by individual Phase B cycles. They are not blocking Step 4.5 — they are recorded here so downstream skills don't re-discover them.
- Sync
/api/detect/${id}used for video is the deliberate bridge until F7 (async video detect) ships. Plan: keep the bridge, time-bound it to "before the first Phase B cycle that ports the async-detect flow". - OpenWeatherMap key is currently hardcoded in
mission-planner/src/utils/flightPlanUtils.ts:60. Step 4 (Code Testability Revision) extracts it toimport.meta.env.VITE_OPENWEATHERMAP_API_KEYper principle P10. - Resizable-panel widths typed in
UserSettingsbut not persisted byuseResizablePanel— Step 4 fix per principle P11. - Admin can no longer edit detection classes — Step 4 fix candidate (or Phase B task) re-introduces
PATCH /api/admin/classes/{id}plus the in-place edit form per principle P12. - AnnotationStatus / MediaStatus / Affiliation / CombatReadiness numeric drift between
src/types/index.tsand the suite spec — Step 4 fix per principle P9: align values to the spec, add inline comments per numeric value. IsSeedannotation visual (legacy 8 px IndianRed border) — does the modern API still exposeisSeed? Defer to a future Phase B cycle.- Test framework selection — deferred to Step 5 (Decompose Tests) per Step 4.5 decision; the autodev flow chooses the runner there.
- Sound Detections + Drone Maintenance (legacy WPF) — dropped per Step 4.5 decision; recorded in
_docs/02_document/01_legacy_coverage_gaps.mdand not ported.
1. System Context
Problem being solved: Azaion is a UAV / aerial-imagery operations suite for military and
defense use cases. The piece of the system documented here is the operator-facing browser
UI — a single-page React 19 application that lets an operator plan flights, browse and
annotate captured media, run AI object detection (synchronous on images, asynchronous via
SSE on video), curate datasets, manage detection classes / users / aircraft, and operate a
GPS-Denied positioning workflow including a Test Mode that drives SITL simulation from
a pre-recorded .tlog + video pair.
This UI is the React rewrite of the front-end half of the legacy WPF stack
(_docs/legacy/wpf-era.md). The Cython sidecars, the SQLite outbox, the LibVLC
playback, the per-app DI host, and the binary-split key-fragment loader handoff
all moved server-side into the parent suite (suite/) as separate .NET / Python /
Cython submodules. The UI's job is now narrowed to "render the suite's REST + SSE
contract beautifully and accessibly".
System boundaries:
| Inside this workspace | Outside this workspace |
|---|---|
React 19 SPA (src/) |
Suite backend services (annotations/, flights/, admin/, detect/, loader/, gps-denied-{desktop,onboard}/, autopilot/, resource/) |
mission-planner/ port-source (NOT deployed) |
Database (PostgreSQL — managed by individual suite services, not the UI) |
| Static-bundle Dockerfile + nginx config | OpenWeatherMap public API (consumed by the main SPA via env-resolved key per AZ-448 / AZ-449; consumed by mission-planner/ per AZ-499 — env-resolved key, fail-soft on unset, manual revocation of the previously-committed key tracked under AC-42) |
Woodpecker CI pipeline (.woodpecker/build-arm.yml) |
Suite-internal satellite-provider service for map tiles (same-origin via nginx in production; env-resolved URL VITE_SATELLITE_TILE_URL per AZ-498). The legacy OpenStreetMap / Esri tile providers are NO LONGER consumed by the main SPA as of cycle 2 / 2026-05-12. |
| Identity provider (suite-internal — Admin API) |
External systems:
| System | Integration Type | Direction | Purpose |
|---|---|---|---|
annotations/ service |
REST + SSE (via /api/annotations/* and /api/detect/*) |
Outbound | CRUD annotations, AI detection results, detection classes |
flights/ service |
REST (via /api/flights/*) |
Outbound | CRUD flights + waypoints; selected-flight persistence |
admin/ service |
REST (via /api/admin/*) |
Outbound | Users, roles, aircraft, AI / GPS settings, auth (login + refresh) |
detect/ service |
REST + SSE (via /api/detect/*) |
Outbound | Sync image inference; async video inference w/ SSE progress |
loader/ service |
REST (via /api/loader/*) |
Outbound | (Currently nominal — UI does not call directly today) |
gps-denied-desktop/, gps-denied-onboard/ |
REST (via /api/gps-denied-*/*) |
Outbound | GPS-Denied operations + Test Mode (SITL feed) |
autopilot/ |
REST (via /api/autopilot/*) |
Outbound | Aircraft autopilot configuration (admin-side) |
resource/ |
REST (via /api/resource/*) |
Outbound | Static resource fetch (icons, configs not bundled in the SPA) |
Suite-internal satellite-provider service for satellite tiles |
HTTPS (Leaflet TileLayer with env-configured URL VITE_SATELLITE_TILE_URL); same-origin in production via nginx; cookie auth (crossOrigin="use-credentials") |
Outbound (intra-suite) | Satellite map raster tiles. Replaces the previously-used OpenStreetMap and Esri ArcGIS World Imagery tile servers as of cycle 2 / 2026-05-12 (AZ-498) — air-gap restriction E1 satisfied without a stub. |
| OpenWeatherMap (main SPA) | HTTPS (api.openweathermap.org/data/2.5/onecall) |
Outbound | Wind data for flight planning. Env-resolved key + base URL via VITE_OWM_API_KEY / VITE_OWM_BASE_URL since AZ-448 / AZ-449. |
| OpenWeatherMap (mission-planner) | HTTPS (api.openweathermap.org/data/2.5/weather) |
Outbound | Wind data for the mission-planner port. Env-resolved key + base URL via VITE_OWM_API_KEY / VITE_OWM_BASE_URL since AZ-499; getWeatherData(lat, lon) returns null and issues NO fetch when the key is unset (fail-soft contract). The previously-committed literal 335799082893fad97fa36118b131f919 is defended against re-introduction by STC-SEC1C and tracked for manual OWM-dashboard revocation under AC-42. |
2. Technology Stack
| Layer | Technology | Version | Rationale |
|---|---|---|---|
| Language | TypeScript | 5.7 (strict: true) |
Type-checked interfaces against the suite's REST contract |
| UI framework | React | 19 | Latest stable; React Server Components NOT used (this is a static-bundle SPA) |
| Bundler | Vite | 6 | Fast dev iteration; static-bundle output for nginx |
| Pkg manager | Bun | 1.3.11 (declared via packageManager) |
CI image (oven/bun:1.3.11-alpine) matches |
| Styling | Tailwind CSS 4 + custom az-* design tokens (src/index.css) |
4 | Direct port of legacy WPF dark-navy / orange-accent / red-danger palette |
| Routing | react-router-dom |
7 | /login public; everything else under AuthProvider → ProtectedRoute → FlightProvider → Header + nested Routes |
| i18n | i18next + react-i18next |
latest | English + Ukrainian. Note: lng:'en' hardcoded today — language detector / persistence is a Step 4 fix (finding) |
| Map | leaflet 1.9 + react-leaflet 5 + leaflet-draw + leaflet-polylinedecorator |
leaflet 1.9.4 | Replaces WPF MapMatcher. Note: mapIcons.ts pins leaflet@1.7.1 CDN URL (drift) — Step 4 fix |
| Charts | chart.js 4 + react-chartjs-2 |
4 | Altitude charts in flights; class-distribution chart NOT YET built (gap) |
| HTTP transport | native fetch (custom thin wrapper at src/api/client.ts) |
— | No axios / TanStack Query — explicitly minimal |
| Realtime | native EventSource (SSE) at src/api/sse.ts |
— | Backend exposes SSE for long-running detect; no WebSocket |
| State management | React Context only (AuthContext, FlightContext) |
— | Explicitly NO Redux / Zustand / TanStack Query. Caching is in component state. |
| File upload | react-dropzone |
latest | Drag-drop in MediaList |
| DnD | @hello-pangea/dnd |
18 | Waypoint reorder |
| Tests | (none configured) | — | Zero test coverage in src/. Test plan owned by autodev Steps 5–7 |
| Build target | static bundle → nginx (multi-stage Dockerfile) | nginx:alpine | All routes proxied via nginx.conf |
| Runtime | nginx in container, ARM64 image | — | Edge-device deployment (operator laptops, OrangePi, Jetson per legacy doc §1) |
| CI | Woodpecker | — | .woodpecker/build-arm.yml builds + pushes ${REGISTRY_HOST}/azaion/ui:${branch}-arm. No test step today. |
Key constraints driving the stack:
- Static bundle only: the UI ships zero server-side runtime. nginx serves
dist/and reverse-proxies/api/<service>/to the matching suite service. - ARM-first: production target is ARM-class edge devices; CI builds ARM64 only today (no AMD64 image in the pipeline).
- Air-gapped friendly: the SPA is bundled fully. As of cycle 2 / 2026-05-12 (AZ-498), map tiles are served by the suite-internal
satellite-providerservice on the same origin via nginx — restriction E1 is satisfied for tiles without a stub. The only remaining direct-from-browser external dependency is OpenWeatherMap (env-resolved per AC-42; fail-soft when the key is unset). Field deployments that go fully air-gapped MUST setVITE_OWM_API_KEY=""(or omit it) sogetWeatherDatareturnsnullinstead of attempting an external fetch. - No test framework: legacy carry-over; the WPF
Azaion.Testproject tested utilities only; full test infrastructure is being built fresh under autodev. - Bilingual UI required: Ukrainian + English are mandatory per the legacy WPF UX. English-only SaaS-style copy is a regression — finding tracked.
3. Deployment Model
Environments: Development (local Vite dev server + suite docker-compose), Stage, Production (per the Woodpecker dev/stage/main branch triggers).
Infrastructure:
- Container orchestration: containerized — Dockerfile is multi-stage
oven/bun:1.3.11-alpinebuild →nginx:alpinestatic serve. Orchestrator (Kubernetes / Docker Compose / Nomad) is the parent suite's concern, not this repo's. - Scaling strategy: stateless; horizontal scaling is trivial (each pod is identical).
- Edge target: ARM64 image; deployed onto operator laptops / OrangePi / Jetson alongside the suite services.
Environment-specific configuration:
| Config | Development | Production |
|---|---|---|
| API base URL | Vite dev proxy (/api → http://localhost:8080, configured in vite.config.ts) |
nginx reverse-proxy per /api/<service>/ route → service hostname inside the docker network (configured in nginx.conf) |
| Secrets | .env.example absent (Step 4 testability fix) |
Secrets are server-side; the SPA carries no API keys EXCEPT the hardcoded OpenWeatherMap key (security finding) |
| Logging | browser console | browser console (no centralized client telemetry today — Step 6 surface) |
| Auth cookie | local Vite dev proxy passes through; cookies are Secure; HttpOnly; SameSite=Strict set by Admin API |
Same — cookies are set server-side by Admin API on POST /api/admin/auth/login |
| Refresh-token rotation | Same code path (handled inside Admin API; UI just retries on 401) | Same |
| OpenWeatherMap | Direct HTTPS from the browser (CORS-enabled OWM endpoint) | Same — but should be proxied via suite to remove the hardcoded key from the bundle. Step 4 / Step 6. |
Build pipeline (.woodpecker/build-arm.yml):
- Triggered on push to
dev/stage/main. bun install --frozen-lockfile+bun run build(=tsc -b && vite build).- Multi-stage Dockerfile produces
nginx:alpine-based image withdist/baked at/usr/share/nginx/html. ENV AZAION_REVISION=$CI_COMMIT_SHAis stamped into the image.- Push to
${REGISTRY_HOST}/azaion/ui:${branch}-armwith OCI labels (org.opencontainers.image.{revision,created,source}).
Missing from the pipeline today:
- No test step (no test infrastructure exists).
- No vulnerability scan / SBOM emission.
- No image signing.
- AMD64 build (only ARM64 today).
4. Data Model Overview
Detailed per-component data models live in component specs and
data_model.md(Step 3c). Below is the high-level entity map.
Core entities (defined in src/types/index.ts, mirroring the suite's REST contract):
| Entity | Description | Owned By Component | Backing service |
|---|---|---|---|
AuthUser |
Logged-in user with role + permissions | 02_auth |
admin/ |
User |
User CRUD entity (admin) | 08_admin |
admin/ |
Aircraft |
Plane / Copter with default flag | 08_admin (or 09_settings) |
admin/ |
Flight |
Flight session entity | 05_flights |
flights/ |
Waypoint |
Lat/lng/order point on a flight | 05_flights |
flights/ |
Media |
One captured image or video file | 06_annotations |
annotations/ |
MediaType enum |
None=0 / Image=1 / Video=2 | shared (00_foundation) |
(matches suite spec) |
MediaStatus enum |
New=0 / AiProcessing=1 / AiProcessed=2 / ManualCreated=3 — drift, missing None/Confirmed/Error | shared (00_foundation) |
(matches suite spec — mismatch flagged) |
AnnotationListItem |
Annotation row with detections | 06_annotations |
annotations/ |
AnnotationStatus enum |
Created=0 / Edited=1 / Validated=2 — drift, spec is 0/10/20/30 | shared (00_foundation) |
(mismatch flagged) |
AnnotationSource enum |
AI=0 / Manual=1 | shared | (matches suite spec) |
Detection |
Bounding box (x/y/w/h normalized) + class + affiliation + combatReadiness | 06_annotations (display) |
annotations/ (storage) + detect/ (production) |
Affiliation enum |
Unknown=0 / Friendly=1 / Hostile=2 — drift, spec also has 'None' | shared | (mismatch flagged) |
CombatReadiness enum |
NotReady=0 / Ready=1 — drift, spec also has 'Unknown' | shared | (mismatch flagged) |
DetectionClass |
Admin-managed class with name / color / size / photoMode | 08_admin (write) |
admin/ (write) + annotations/ (read) |
DatasetItem |
Thumbnail + status row | 07_dataset |
annotations/ (queries) |
ClassDistributionItem |
classNum / label / color / count | (currently unused in UI — backs missing chart) | annotations/ |
SystemSettings / DirectorySettings / CameraSettings |
Server-side configs | 09_settings + 08_admin |
admin/ |
UserSettings |
Per-user preferences (incl. left/right panel widths — type exists, not wired) | 09_settings |
admin/ |
PaginatedResponse<T> |
{ items, totalCount, page, pageSize } envelope |
shared | (used everywhere) |
Key relationships:
Flight1:NWaypoint(waypoints belong to one flight; ordering preserved byorderfield)Flight1:NMedia(capture media is associated with the flight that produced it;Media.waypointIdoptionally pinpoints the capturing waypoint)Media1:NAnnotationListItem1:NDetectionUserN:1Aircraft-default (default aircraft applies to a user's new flights — currently global per a finding in08_admin/09_settings)DetectionClassis referenced byDetection.classNum(not a typed FK —classNumis the raw int that includes the PhotoMode offset)
Data flow summary:
- Plan flight →
flights/REST → flight + waypoints persist; the UI optimistically updates and listens for nothing today (no SSE on flight updates). - Capture media → out-of-band: edge devices upload via the loader / annotations services; the UI surfaces them via
MediaListpolling. - Annotate → user-edits → POST/PUT/DELETE to
annotations/REST; no streaming of other users' annotations into this UI today. - AI Detect (sync image) →
POST /api/detect/{mediaId}returns inline detections. - AI Detect (async video) →
POST /api/detect/video/{mediaId}returns a job ID → SSE on/api/detect/stream/{jobId}streams progress + finalized detections. The UI does not subscribe today (finding #10). - Curate dataset →
07_datasetqueriesannotations/with status filters; bulk-validate transitionsAnnotationStatus.{Created,Edited} → Validated. - GPS-Denied Test Mode → user uploads
.tlog+ video →gps-denied-desktop/service auto-syncs them via IMU analysis → SITL simulation feeds frames togps-denied-onboard/service → results render back throughflights/GPS-Denied tab.
5. Integration Points
Internal Communication (UI → suite)
Step 4 verification corrected this table after grepping every
api.*()andcreateSSE()call insrc/.
| From (component) | To (suite service) | Protocol | Pattern | Endpoints actually called | Notes |
|---|---|---|---|---|---|
02_auth/AuthContext |
admin/ |
REST | Request-Response | POST /api/admin/auth/login, POST /api/admin/auth/logout, GET /api/admin/auth/refresh (bootstrap, no credentials:'include'), POST /api/admin/auth/refresh (401-retry inside api/client.ts:44, has credentials:'include') |
Two refresh paths exist — bootstrap GET vs. 401-retry POST. Only the POST path correctly sends the cookie. The GET bootstrap will fail → Step 4 fix. |
01_api-transport/sse.ts |
every service | SSE (HTTP) | Server-pushed events | Helper is named createSSE (NOT subscribeSSE). Bearer in query string (?token=...) — accepted trade-off. EventSource holds the token captured at create time; refresh-rotation breaks long subscriptions (Step 8 hardening). |
|
03_shared-ui/FlightContext |
flights/ + annotations/ |
REST | Request-Response | GET /api/flights?pageSize=1000 (hardcoded ceiling, finding B3). GET /api/annotations/settings/user to load selectedFlightId. GET /api/flights/${id} to hydrate selected flight. PUT /api/annotations/settings/user to save selection (NOT /api/flights/select as initially drafted). Fire-and-forget — finding B3. |
|
03_shared-ui/DetectionClasses |
annotations/ |
REST | Request-Response | GET /api/annotations/classes |
|
06_annotations/MediaList |
annotations/ |
REST | Request-Response + upload | GET /api/annotations/media?..., GET /api/annotations/annotations?mediaId=...&pageSize=1000, DELETE /api/annotations/media/${id}, api.upload('/api/annotations/media/batch', formData) (multipart). |
|
06_annotations/AnnotationsPage |
annotations/ |
REST + SSE | Request-Response + Event | GET /api/annotations/media/${id}/file, POST /api/annotations/annotations (NOT /api/annotations — the path is doubly-prefixed). Annotation save body shape: finding #32 (Step 4 fix). |
|
06_annotations/AnnotationsSidebar |
annotations/, detect/ |
REST + SSE | Request-Response + Event | POST /api/detect/${mediaId} (sync detect — used for BOTH images and videos today); createSSE('/api/annotations/annotations/events', ...) for annotation-status SSE (NOT detect progress). No /api/detect/video/${id} and no /api/detect/stream/${jobId} are wired today — finding #10 / #21 confirmed. |
|
06_annotations/CanvasEditor |
annotations/ |
static asset GET | — | GET /api/annotations/annotations/${id}/image (annotation thumbnail), GET /api/annotations/media/${id}/file (raw media). |
|
07_dataset/DatasetPage |
annotations/ |
REST | Request-Response | GET /api/annotations/dataset?..., GET /api/annotations/dataset/${annotationId}, POST /api/annotations/dataset/bulk-status, GET /api/annotations/dataset/class-distribution (the endpoint already exists; the chart UI is what's missing — see 01_legacy_coverage_gaps.md), <img src="/api/annotations/annotations/${id}/thumbnail">. Editor tab does not save — finding #4. |
|
08_admin/AdminPage |
annotations/ + admin/ + flights/ |
REST | Request-Response | GET /api/annotations/classes (read), POST /api/admin/classes (create), PATCH /api/admin/classes/${id} (update — AZ-512 inline edit; full body always sent per Risk-2 mitigation; live deploy gates on admin/ AZ-513), DELETE /api/admin/classes/${id} (delete — no ConfirmDialog, finding B4), POST /api/admin/users, PATCH /api/admin/users/${id} (deactivate), GET /api/flights/aircrafts, PATCH /api/flights/aircrafts/${id}. Cross-service reads — admin page reads aircraft from flights/ and classes from annotations/. |
|
09_settings/SettingsPage |
annotations/ + flights/ |
REST | Request-Response | GET/PUT /api/annotations/settings/system, GET/PUT /api/annotations/settings/directories, GET /api/flights/aircrafts, PATCH /api/flights/aircrafts/${id}. Settings endpoints route to annotations/, NOT admin/ as initially drafted. |
|
05_flights/FlightsPage |
flights/ |
REST + SSE | Request-Response + Event | GET /api/flights/aircrafts, GET /api/flights/${id}/waypoints, createSSE('/api/flights/${id}/live-gps', ...) — live-GPS SSE for aircraft telemetry, POST /api/flights, DELETE /api/flights/${id}, DELETE /api/flights/${id}/waypoints/${wpId} (loop), POST /api/flights/${id}/waypoints (loop, lossy shape — finding #20). |
|
05_flights/flightPlanUtils |
OpenWeatherMap (external) | REST | Request-Response | GET https://api.openweathermap.org/data/2.5/onecall?... with env-resolved key + base URL since AZ-448 / AZ-449 (closes the original security finding; see AC-20 + AC-42). |
External Integrations
| External System | Protocol | Auth | Rate Limits | Failure Mode |
|---|---|---|---|---|
Suite-internal satellite-provider for satellite tiles |
HTTPS (Leaflet TileLayer); same-origin via nginx in production; cookie auth (crossOrigin="use-credentials") |
HttpOnly same-origin cookie set by admin/ |
Bounded by suite ops (no external usage policy) | 401 / 503 on a tile request renders a broken-tile placeholder for the failing cell; rest of the SPA stays interactive (per NFT-RES-11). Cycle 2 / 2026-05-12 — AZ-498. |
| OpenWeatherMap | HTTPS | Env-resolved key (VITE_OWM_API_KEY); never hardcoded since AZ-448 / AZ-499 |
Free-tier 60 calls/min | Errors silently swallowed in main SPA's flightPlanUtils.ts (existing finding); mission-planner WeatherService.getWeatherData now returns null and issues NO outbound fetch when the key is unset (AZ-499 fail-soft contract — AC-42). |
| Suite identity provider (admin/) | REST + HttpOnly refresh cookie | JWT bearer + refresh-token rotation | server-enforced | 401 → ProtectedRoute redirects to /login; refresh-token rotation handled inside AuthContext (mostly) |
6. Non-Functional Requirements
Targets that are explicitly enforceable in code or config. Anything else is aspirational and noted as such.
| Requirement | Target | Measurement | Priority | Source |
|---|---|---|---|---|
| Bundle size (initial JS) | ≤ ~2 MB gzipped | vite build artifact inspection |
High | Bundle bloat is a finding (AltitudeChart lazy-load opportunity in flights) |
| Initial paint | < 2 s on operator laptops over LAN | Manual inspection | Medium | No metric collection today |
| Long-running detect (video) | UI stays responsive; progress visible to user | Should be SSE-streamed | High | Currently NOT met — finding #21 (no progress UI), #10 (no SSE subscription) |
| Auth refresh | Transparent — no user-visible flicker | One refresh = one network round trip; no UI re-render past <ProtectedRoute> |
High | refresh missing credentials:'include' is a real bug — Step 4 fix |
| Upload size | ≤ 500 MB single file | nginx.conf client_max_body_size 500M |
Medium | Hard cap from server config |
| Browser support | Chromium-based + Firefox latest 2 versions | Manual | Medium | No browser-list config; ES module output assumes evergreen |
| Mobile responsiveness | Header has bottom-nav variant; main pages render at 768 px+ | Header.tsx:113-129 |
Low | Mobile is a P2 use-case |
| Accessibility | Confirm dialogs / dropdowns must be keyboard-navigable | ConfirmDialog lacks aria-modal/role=dialog; Header dropdown lacks role=combobox/Esc |
Medium | Multiple findings flagged for Step 4 / Step 8 |
| i18n coverage | English + Ukrainian for all user-visible strings | Manual | High | Many strings hardcoded English today (admin, annotations help) — Step 4 |
| Test coverage | Aspirational target TBD by autodev Step 3 | (none yet) | High | Zero coverage today |
7. Security Architecture
Authentication:
- Operator logs in via
POST /api/admin/auth/login→ Admin service issues a JWT bearer (response body) + aSecure; HttpOnly; SameSite=Strictrefresh-token cookie. - The bearer is held in
AuthContextmemory (not localStorage — XSS-resistant). - On 401,
AuthContextcallsGET /api/admin/auth/refresh(cookie-only credential) → new bearer + new refresh cookie. Bug: this request is missingcredentials:'include'— likely fails on cross-origin path. Step 4 fix. - WPF-era encrypted-creds command-line handoff is intentionally not ported (
_docs/legacy/wpf-era.md §11).
Authorization:
- Admin service enforces RBAC server-side via
User.role+permissions[]. - UI inspects
AuthUser.roleto render or hide nav links and pages, but does NOT enforce. Two findings (/adminroute lacks role-gate — security PRIORITY;/settingsroute is more nuanced). - The browser is treated as untrusted; every action confirms with the server.
Data protection:
- At rest: not in scope for the UI.
- In transit: TLS terminated server-side at the suite ingress (nginx ingress → service mesh / docker network).
- Secrets management: secrets are NEVER in the SPA bundle, EXCEPT the hardcoded OpenWeatherMap API key in
src/features/flights/flightPlanUtils.ts:60. Must rotate at OpenWeatherMap, remove from source, and proxy via suite/flightsservice. Step 4 / Step 6. - Bearer in SSE query string: documented trade-off; the bearer is short-lived and the URL is HTTPS-encrypted on the wire. Risk: server-side access logs may capture the URL with the token. Cross-link to
security_approach.md(Step 6). - CORS: handled server-side;
credentials:'include'is required on every authenticated fetch. The bug above is the primary CORS-related defect.
Input validation:
- Numeric inputs in
09_settingsuseparseInt(v) || 0— clearing a field silently writes 0 (finding B4). Step 4 fix. - File uploads in
06_annotationsgo throughreact-dropzoneMIME filter; no client-side virus scan; server is authoritative.
Audit logging: server-side concern; the SPA does not emit audit events directly.
Cross-site / clickjack: no Content-Security-Policy headers configured in nginx.conf today — Step 6 surface.
8. Key Architectural Decisions
ADRs inferred from the code + legacy doc. The decisions are recorded retrospectively here — the original decision-making lives in commit history and the WPF-era doc.
ADR-001: Pure SPA over server-rendered React
Context: The legacy WPF stack ran on the operator's machine. The new UI must deploy alongside the suite services as a peer container.
Decision: Static-bundle SPA served by nginx; no Node.js runtime in the production image; no SSR.
Alternatives considered:
- Next.js (SSR + RSC) — rejected because edge devices can't host a Node runtime cheaply, and the suite already has nginx for
/apireverse-proxy. - Plain HTML + jQuery — rejected because the WPF-era complexity (canvas editor, video sync, leaflet, charts) doesn't degrade well to non-React.
Consequences: Initial paint waits for the bundle. SEO and deep-linking are not concerns (the UI is operator-only). Bundle bloat is the main risk → flagged for Step 4.
ADR-002: REST + SSE only; no WebSockets
Context: The suite already exposes REST + SSE on every service. WebSockets would require a duplex protocol the backend doesn't speak.
Decision: src/api/client.ts (fetch wrapper) + src/api/sse.ts (EventSource wrapper) cover all transport.
Alternatives considered: WebSockets, GraphQL Subscriptions, gRPC-Web — all rejected as needing backend changes that aren't on the roadmap.
Consequences: Long-running operations (video AI detect) require an SSE consumer; one is missing today (finding #10). Bidirectional updates (e.g., live "another user is editing") are not possible — accepted.
ADR-003: HTML5 <video> instead of LibVLC
Context: Legacy used LibVLCSharp for frame-accurate playback. The browser cannot host LibVLC.
Decision: HTML5 <video> with a frame-accurate seeking shim.
Alternatives considered: WebAssembly LibVLC — rejected as too heavy in the bundle for marginal accuracy gains; HLS/DASH chunked playback — rejected as overkill for short capture videos.
Consequences: Per-frame stepping requires manual currentTime ± (1/fps) math; FPS is detected at metadata-load time. Today fps is hardcoded 30 in VideoPlayer.tsx — finding to fix in Step 4.
ADR-004: React Context only; no Redux / TanStack Query
Context: Two pieces of cross-component state — current user (AuthContext) and selected flight (FlightContext) — and the rest is page-local.
Decision: Two Context providers; everything else is useState.
Alternatives considered: Redux — rejected as overkill for two stores; Zustand — rejected as adding a dependency; TanStack Query — rejected as we don't currently need cache invalidation across components.
Consequences: Pages re-fetch on mount; no shared cache. Add TanStack Query in a future iteration if cross-page caching becomes a real need (Step 5 solution surface).
ADR-005: Tailwind 4 + custom az-* design tokens
Context: Legacy WPF had a fixed dark-navy/orange/red palette (_docs/legacy/wpf-era.md §10).
Decision: Tailwind 4 with az-bg, az-text, az-orange, az-success, az-danger, az-primary CSS variables defined in src/index.css.
Consequences: Design tokens are the single source of truth — but index.html body class hardcodes hex literals (bg-[#1e1e1e] text-[#adb5bd]) instead of using tokens (cosmetic finding, 00_discovery #11.10).
ADR-006: nginx reverse-proxy strips /api/<service>/ prefix per service
Context: The suite has 9 distinct backend services; the SPA must reach all of them via the same origin (cookie scope + CORS-free).
Decision: nginx.conf enumerates 9 routes and strips the prefix before proxying.
Alternatives considered: API gateway (e.g., Traefik / Kong) — rejected as adding ops complexity; service mesh — same.
Consequences: A new suite service requires a nginx.conf edit. The SPA hardcodes /api/<service>/... paths in source instead of an env-driven base URL — testability is poor (finding tracked).
ADR-007: Bilingual (English + Ukrainian)
Context: Legacy WPF was Ukrainian-only with a tiny translations.json for English. Operators are Ukrainian-speaking; some allies are English-speaking.
Decision: react-i18next with en.json + ua.json. Operator can switch in the Header.
Consequences: Every user-visible string MUST be in both bundles. Many strings are hardcoded English today (admin page especially) — high-priority Step 4 fix.
ADR-008: Bearer-token-in-query-string for SSE
Context: EventSource cannot send arbitrary headers; the suite SSE endpoints require a bearer.
Decision: Pass bearer in ?token=.... Document the trade-off in security_approach.md.
Consequences: Bearer may appear in nginx access logs. Mitigation: short-lived bearers + log-redaction at the nginx layer (Step 6 surface). Also: refresh-rotation breaks live SSE subscriptions; reconnect logic is missing today (Step 8 hardening).
ADR-009: mission-planner/ is a vendored port-source, NOT a deployed component
Context: The mission-planner React 18 + MUI app is the upstream design source for src/features/flights/. Documenting it as a separate top-level component would imply parallel deployment; it isn't deployed.
Decision: Per the user's Step 2 BLOCKING-gate decision (2026-05-10), mission-planner/ is documented INSIDE component 05_flights as the port-source. The Vite build does NOT compile it. After the port reaches parity, the directory is a deletion candidate.
Consequences: Two physical trees under one logical component. The implement skill must include mission-planner/** in 05_flights's OWNED glob (called out in module-layout.md Verification Needed #4).
ADR-010: GPS-Denied Test Mode is a planned sub-feature of 05_flights
Context: Per _docs/how_to_test.md, the Test Mode lets an operator upload a .tlog + video pair, auto-sync them via IMU analysis, and feed SITL frames into the onboard GPS-Denied service.
Decision: Test Mode is a tab inside the 05_flights GPS-Denied sub-feature. NOT a separate component.
Consequences: One component spans flight-planning + flight operations + GPS-Denied + Test Mode. Acceptable today; a future split is feasible if the component grows further.
Open architecture questions
The following remain undecided and surface at Step 4.5 (Glossary & Architecture Vision):
- Sound Detections feature (audio-analysis bbox button in legacy WPF) — port, drop, or new audio-pipeline service?
- Drone Maintenance feature (
Аналіз стану БПЛА) — port, drop, or maintenance service? - Class Distribution chart (Dataset's 3rd tab) — port or replace with admin-side analytics?
- Status-bar clock + help-text-blink — keep WPF UX or replace with toasts?
IsSeedthumbnail concept — modern API still exposes it?- Camera config persistence scope — per-user, per-flight, or per-detect-job?
- Resizable-panel persistence scope — Settings (server) or LocalStorage (client)?
- OpenWeatherMap routing — proxy via
flights/service (preferred for security) or move wind compute server-side entirely? mission-planner/end-state — delete after parity port (preferred) or keep as a continuously-vendored reference?
These are the inputs for Step 4.5 user confirmation; this architecture doc proceeds without resolving them.