Files
ui/_docs/02_document/architecture.md
T
Oleksandr Bezdieniezhnykh f7dd6c98d8
ci/woodpecker/push/build-arm Pipeline failed
[AZ-501] [AZ-502] Cycle 2 Step 14 security audit + inline fixes
Security audit (5 phases) → reports under _docs/05_security/.

AZ-501 (F-SAST-1, HIGH): Externalize hardcoded Google Geocode key
from mission-planner/src/config.ts to VITE_GOOGLE_GEOCODE_KEY via
new GeocodeService.ts; fail-soft warn when unset; STC-SEC1D static
deny-list gate; +5 unit tests in tests/mission_planner_geocode.test.ts.

AZ-502 (F-DEP-1, HIGH): Force vite>=6.4.2 and postcss>=8.5.10 via
package.json overrides in both roots; clean reinstall clears all
bun audit advisories.

Test-spec sync (Step 12) + Update Docs (Step 13) deltas: AC-43, AC-44,
NFT-SEC-09b, FT-P-61, FT-N-17, ripple log, batch_12 report.

Pending user actions: revoke Google + OWM keys (AC-6 / AZ-499 AC-7).

229 PASS / 13 SKIP / 0 FAIL on static + fast suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 05:31:11 +03:00

42 KiB
Raw Blame History

Azaion UI — Architecture

Synthesis output of /document Step 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 14) 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:24 vs. working 401-retry POST in api/client.ts:44 — Step 4 fix candidate.
  • F3 Flight selection persists as a UserSettings field on annotations/ (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 stateAuthContext + 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 57 (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 917 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 / 917.

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.

  1. 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".
  2. OpenWeatherMap key is currently hardcoded in mission-planner/src/utils/flightPlanUtils.ts:60. Step 4 (Code Testability Revision) extracts it to import.meta.env.VITE_OPENWEATHERMAP_API_KEY per principle P10.
  3. Resizable-panel widths typed in UserSettings but not persisted by useResizablePanel — Step 4 fix per principle P11.
  4. 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.
  5. AnnotationStatus / MediaStatus / Affiliation / CombatReadiness numeric drift between src/types/index.ts and the suite spec — Step 4 fix per principle P9: align values to the spec, add inline comments per numeric value.
  6. IsSeed annotation visual (legacy 8 px IndianRed border) — does the modern API still expose isSeed? Defer to a future Phase B cycle.
  7. Test framework selection — deferred to Step 5 (Decompose Tests) per Step 4.5 decision; the autodev flow chooses the runner there.
  8. Sound Detections + Drone Maintenance (legacy WPF) — dropped per Step 4.5 decision; recorded in _docs/02_document/01_legacy_coverage_gaps.md and 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 57
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-provider service 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 set VITE_OWM_API_KEY="" (or omit it) so getWeatherData returns null instead of attempting an external fetch.
  • No test framework: legacy carry-over; the WPF Azaion.Test project 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-alpine build → nginx:alpine static 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):

  1. Triggered on push to dev / stage / main.
  2. bun install --frozen-lockfile + bun run build (= tsc -b && vite build).
  3. Multi-stage Dockerfile produces nginx:alpine-based image with dist/ baked at /usr/share/nginx/html.
  4. ENV AZAION_REVISION=$CI_COMMIT_SHA is stamped into the image.
  5. Push to ${REGISTRY_HOST}/azaion/ui:${branch}-arm with 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:

  • Flight 1:N Waypoint (waypoints belong to one flight; ordering preserved by order field)
  • Flight 1:N Media (capture media is associated with the flight that produced it; Media.waypointId optionally pinpoints the capturing waypoint)
  • Media 1:N AnnotationListItem 1:N Detection
  • User N:1 Aircraft-default (default aircraft applies to a user's new flights — currently global per a finding in 08_admin/09_settings)
  • DetectionClass is referenced by Detection.classNum (not a typed FK — classNum is the raw int that includes the PhotoMode offset)

Data flow summary:

  1. Plan flightflights/ REST → flight + waypoints persist; the UI optimistically updates and listens for nothing today (no SSE on flight updates).
  2. Capture media → out-of-band: edge devices upload via the loader / annotations services; the UI surfaces them via MediaList polling.
  3. Annotate → user-edits → POST/PUT/DELETE to annotations/ REST; no streaming of other users' annotations into this UI today.
  4. AI Detect (sync image) → POST /api/detect/{mediaId} returns inline detections.
  5. 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).
  6. Curate dataset07_dataset queries annotations/ with status filters; bulk-validate transitions AnnotationStatus.{Created,Edited} → Validated.
  7. GPS-Denied Test Mode → user uploads .tlog + video → gps-denied-desktop/ service auto-syncs them via IMU analysis → SITL simulation feeds frames to gps-denied-onboard/ service → results render back through flights/ GPS-Denied tab.

5. Integration Points

Internal Communication (UI → suite)

Step 4 verification corrected this table after grepping every api.*() and createSSE() call in src/.

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), 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) + a Secure; HttpOnly; SameSite=Strict refresh-token cookie.
  • The bearer is held in AuthContext memory (not localStorage — XSS-resistant).
  • On 401, AuthContext calls GET /api/admin/auth/refresh (cookie-only credential) → new bearer + new refresh cookie. Bug: this request is missing credentials:'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.role to render or hide nav links and pages, but does NOT enforce. Two findings (/admin route lacks role-gate — security PRIORITY; /settings route 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/flights service. 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_settings use parseInt(v) || 0 — clearing a field silently writes 0 (finding B4). Step 4 fix.
  • File uploads in 06_annotations go through react-dropzone MIME 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:

  1. Next.js (SSR + RSC) — rejected because edge devices can't host a Node runtime cheaply, and the suite already has nginx for /api reverse-proxy.
  2. 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):

  1. Sound Detections feature (audio-analysis bbox button in legacy WPF) — port, drop, or new audio-pipeline service?
  2. Drone Maintenance feature (Аналіз стану БПЛА) — port, drop, or maintenance service?
  3. Class Distribution chart (Dataset's 3rd tab) — port or replace with admin-side analytics?
  4. Status-bar clock + help-text-blink — keep WPF UX or replace with toasts?
  5. IsSeed thumbnail concept — modern API still exposes it?
  6. Camera config persistence scope — per-user, per-flight, or per-detect-job?
  7. Resizable-panel persistence scope — Settings (server) or LocalStorage (client)?
  8. OpenWeatherMap routing — proxy via flights/ service (preferred for security) or move wind compute server-side entirely?
  9. 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.