# 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 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: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 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.

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 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-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 flight** → `flights/` 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 dataset** → `07_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), **`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) + 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.
