mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 15:31:11 +00:00
[AZ-447] autodev Steps 1-4 baseline: docs, tests, refactor specs
Captures the full output of autodev existing-code Phase A through Step 4 (Code Testability Revision) for the Azaion UI workspace: - Step 1 Document: _docs/02_document/ (FINAL_report, architecture, glossary, components/, modules/, diagrams/, system-flows, module-layout) plus _docs/00_problem/ + _docs/01_solution/ + _docs/legacy/ + _docs/how_to_test + README. - Step 2 Architecture Baseline: architecture_compliance_baseline.md. - Step 3 Test Spec: _docs/02_document/tests/ (environment, test-data, blackbox/performance/resilience/security/ resource-limit tests, traceability-matrix), enum_spec_snapshot, expected_results/results_report.md (98 rows), plus the run-tests.sh + run-performance-tests.sh runners. - Step 4 Code Testability Revision: 01-testability-refactoring/ run dir (list-of-changes C01-C07, deferred_to_refactor, analysis/research_findings + refactoring_roadmap) and the 7 child task specs AZ-448..AZ-454 under _docs/02_tasks/todo/ plus _dependencies_table.md. - _docs/_autodev_state.md pins the cursor at Step 4 / refactor Phase 4 entry so /autodev resumes cleanly. Epic AZ-447 (UI testability gates) tracks the 7 child tasks that will land in subsequent commits. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
# Module group: `mission-planner/`
|
||||
|
||||
> **Single consolidated doc** for the entire `mission-planner/` sub-project (37 modules across `services/`, `flightPlanning/`, `icons/`, `constants/`, `types/`, `utils.ts`, `config.ts`, `main.tsx`, `App.tsx`).
|
||||
>
|
||||
> **Why a single doc**: `mission-planner/` is **port-source, not deployed product** (per workspace `README.md` + `00_discovery.md §1`). The Dockerfile builds only the workspace `src/`. Documenting each of the 37 files at the same fidelity as the production SPA would burn context for code that exists only as a reference for the React-19 port. Future deletion of `mission-planner/` is a Step 8 / autodev follow-up once `src/features/flights/` reaches feature-parity.
|
||||
|
||||
## Role in the codebase
|
||||
|
||||
| Aspect | Status |
|
||||
|---|---|
|
||||
| Purpose | Reference implementation of the flight-mission planner UI being mechanically translated into `src/features/flights/`. Stand-alone CRA-flavoured Vite + React 18 + MUI 5 app. |
|
||||
| Build | Has its own `vite.config.ts`, `tsconfig`, `index.html`, `package.json`. No alias path. |
|
||||
| Deployment | None — workspace `Dockerfile` does NOT build it; `nginx.conf` does NOT serve it. |
|
||||
| Tests | `src/test/jsonImport.test.ts` uses `describe/it/expect` style; **Jest is not installed** and there is no `test` script — the test cannot run as-is. Documented gap, no fix planned (out of scope per `00_discovery.md §11.5`). |
|
||||
| Lifecycle | Will be deleted once `src/features/flights/` covers all mission-planner features. Not before. |
|
||||
|
||||
## Directory map
|
||||
|
||||
```
|
||||
mission-planner/src/
|
||||
├── main.tsx → mounts <LanguageProvider><FlightPlan /> into #root
|
||||
├── App.tsx UNUSED — empty CRA stub; main.tsx mounts FlightPlan directly
|
||||
├── config.ts COORDINATE_PRECISION, downang, upang, defaults
|
||||
├── utils.ts newGuid (Math.random v4)
|
||||
├── types/index.ts FlightPoint, CalculatedPointInfo, MapRectangle, AircraftParams,
|
||||
│ WeatherData, MovingPointInfo, ActionMode, MapType, large
|
||||
│ TranslationStrings interface tree
|
||||
├── constants/
|
||||
│ ├── actionModes.ts points / workArea / prohibitedArea
|
||||
│ ├── maptypes.ts classic / satellite
|
||||
│ ├── tileUrls.ts OSM + Esri ArcGIS tile URLs
|
||||
│ ├── translations.ts English + Ukrainian dictionaries (raw, no i18next)
|
||||
│ ├── languages.ts ISO codes + flag codes for LanguageSwitcher
|
||||
│ └── purposes.ts tank, artillery
|
||||
├── services/
|
||||
│ ├── calculateDistance.ts Haversine + plane climb/cruise/descend
|
||||
│ ├── AircraftService.ts mockGetAirplaneParams (returns hardcoded fixed-wing)
|
||||
│ ├── WeatherService.ts OpenWeatherMap fetch
|
||||
│ └── calculateBatteryUsage.ts Drag + thrust lookup; same algorithm as src/features/flights/flightPlanUtils.calculateBatteryPercentUsed
|
||||
├── icons/
|
||||
│ ├── MapIcons.tsx Leaflet icon factories
|
||||
│ ├── PointIcons.tsx Per-purpose marker icons
|
||||
│ ├── SidebarIcons.tsx MUI-styled side-panel SVGs
|
||||
│ └── PhoneIcon.tsx Rotate-phone overlay (mobile orientation hint)
|
||||
└── flightPlanning/
|
||||
├── flightPlan.tsx Top-level page (369 lines) — owns state, dialogs, JSON I/O
|
||||
├── MapView.tsx MapContainer + draw handlers + click-to-add (414 lines)
|
||||
├── MiniMap.tsx Floating thumbnail; cyclic edge with MapView
|
||||
├── MapPoint.tsx Draggable waypoint + popup
|
||||
├── DrawControl.tsx Rectangle draw tool
|
||||
├── PointsList.tsx Reorderable waypoint list
|
||||
├── LeftBoard.tsx Side panel composing list + chart + totals + lang switcher
|
||||
├── AltitudeChart.tsx Chart.js altitude visualizer
|
||||
├── AltitudeDialog.tsx Add/Edit waypoint modal
|
||||
├── JsonEditorDialog.tsx Edit-as-JSON modal
|
||||
├── TotalDistance.tsx Total distance + time + battery readout
|
||||
├── LanguageContext.tsx React Context for current locale (NOT i18next)
|
||||
├── LanguageSwitcher.tsx Locale dropdown
|
||||
├── WindEffect.tsx Wind heading / speed inputs + arrow preview
|
||||
└── Aircraft.ts Aircraft helper (filename casing odd — TypeScript file, capitalised)
|
||||
```
|
||||
|
||||
## Mapping `mission-planner/` ↔ `src/features/flights/`
|
||||
|
||||
The React 19 port translates module-for-module wherever possible. Status as of this commit:
|
||||
|
||||
| `mission-planner/src/...` | Ported to `src/features/flights/...` | Status |
|
||||
|---|---|---|
|
||||
| `flightPlanning/flightPlan.tsx` | `FlightsPage.tsx` | Ported with backend wiring (Flights API + SSE). MP-side has none. |
|
||||
| `flightPlanning/MapView.tsx` | `FlightMap.tsx` | Ported. |
|
||||
| `flightPlanning/MiniMap.tsx` | `MiniMap.tsx` | Ported. |
|
||||
| `flightPlanning/MapPoint.tsx` | `MapPoint.tsx` | Ported. |
|
||||
| `flightPlanning/DrawControl.tsx` | `DrawControl.tsx` | Ported. |
|
||||
| `flightPlanning/PointsList.tsx` | `WaypointList.tsx` | Ported (renamed for parity with backend `Waypoint` entity). |
|
||||
| `flightPlanning/LeftBoard.tsx` | `FlightParamsPanel.tsx` | Partial — MP-side hosts `LanguageSwitcher`, the SPA delegates language to global `Header` + `react-i18next`. |
|
||||
| `flightPlanning/AltitudeChart.tsx` | `AltitudeChart.tsx` | Ported. |
|
||||
| `flightPlanning/AltitudeDialog.tsx` | `AltitudeDialog.tsx` | Ported (file name kept; should be `WaypointDialog`). |
|
||||
| `flightPlanning/JsonEditorDialog.tsx` | `JsonEditorDialog.tsx` | Ported. |
|
||||
| `flightPlanning/WindEffect.tsx` | `WindEffect.tsx` | Ported. |
|
||||
| `flightPlanning/TotalDistance.tsx` | (inlined into `FlightParamsPanel`) | Ported as a `<div>` strip in the params panel; no separate module. |
|
||||
| `flightPlanning/LanguageContext.tsx` + `LanguageSwitcher.tsx` | (replaced by `react-i18next`) | Not ported as such — language is global via i18next. |
|
||||
| `flightPlanning/Aircraft.ts` | (no equivalent) | Aircraft is server-side; the SPA fetches `/api/flights/aircrafts`. |
|
||||
| `services/calculateDistance.ts` | `flightPlanUtils.calculateDistance` | Ported. |
|
||||
| `services/calculateBatteryUsage.ts` | `flightPlanUtils.calculateBatteryPercentUsed` + `calculateAllPoints` | Ported. |
|
||||
| `services/WeatherService.ts` | `flightPlanUtils.getWeatherData` | Ported (with the same hardcoded API key — Step 4 fix). |
|
||||
| `services/AircraftService.ts` | `flightPlanUtils.getMockAircraftParams` (mock only) | Real fetch is `/api/flights/aircrafts` in `FlightsPage`. |
|
||||
| `constants/translations.ts` + `LanguageContext.tsx` | `src/i18n/{en,ua}.json` + `i18n/i18n.ts` | Migrated to i18next. |
|
||||
| `constants/{actionModes,maptypes,tileUrls,purposes,languages}.ts` | `features/flights/types.ts` (`PURPOSES`, `TILE_URLS`, `ActionMode`) | Consolidated into one file. |
|
||||
| `icons/{MapIcons,PointIcons,SidebarIcons,PhoneIcon}.tsx` | `features/flights/mapIcons.ts` | Only the marker icons survived; SidebarIcons + PhoneIcon dropped (no rotate-phone overlay in the SPA today). |
|
||||
| `utils.ts` (`newGuid`) | `flightPlanUtils.newGuid` | Ported. |
|
||||
| `config.ts` | `features/flights/types.COORDINATE_PRECISION` | Single constant migrated. |
|
||||
| `App.tsx` | (none) | Was already an unused CRA stub in MP; nothing to port. |
|
||||
| `main.tsx` | (none) | Replaced by the workspace `src/main.tsx`. |
|
||||
| `setupTests.ts`, `test/jsonImport.test.ts` | (none) | Cannot run; not migrated. |
|
||||
|
||||
## Things still in MP that are NOT in the SPA port
|
||||
|
||||
- **Rotate-phone overlay** (`icons/PhoneIcon.tsx`): MP shows a rotate-phone hint when held in portrait. The SPA does not.
|
||||
- **Per-purpose marker icons** (`icons/PointIcons.tsx`): MP draws a different marker per `meta` purpose. The SPA uses three colour-coded icons (start / mid / end).
|
||||
- **`Aircraft.ts` helper class**: never used in the SPA — aircraft state is fetched and treated as a plain DTO.
|
||||
- **OpenWeather call directly from `WeatherService.ts`**: same flaw as the SPA port (hardcoded key, no proxy). Both flagged for Step 4.
|
||||
- **MUI 5**: MP uses MUI for dialogs / inputs / icons. The SPA replaced everything with hand-rolled Tailwind components matching `_docs/ui_design/README.md`. MUI is not a dep of the workspace.
|
||||
|
||||
## Findings carried into Step 4 / 6 / 8
|
||||
|
||||
1. **`mission-planner/src/App.tsx` is an unused CRA stub** — `main.tsx` mounts `FlightPlan` directly. Deletion candidate but only after the port is complete (per `00_discovery.md §11.6`). Step 8.
|
||||
2. **`mission-planner/src/test/jsonImport.test.ts` cannot run** (Jest not installed; no test script). Out of scope. Step 8 deletion or migration to the suite-level e2e harness.
|
||||
3. **`flightPlanning/MapView.tsx ↔ MiniMap.tsx`** import each other (`MiniMap` imports the *named* `UpdateMapCenter` helper from `MapView`; `MapView` imports `MiniMap` as a JSX child). Module-level execution is non-circular because each side only uses the type/handle exposed at call time. Ported to `src/features/flights/FlightMap.tsx + MiniMap.tsx` without the cycle.
|
||||
4. **MP uses raw translation tables** keyed by ISO code, not i18next. The SPA correctly migrated to `react-i18next`; the legacy Russian-language references (if any in `translations.ts`) are out of scope.
|
||||
5. **`mission-planner/.env.example`** declares `VITE_SATELLITE_TILE_URL` — same env-driven pattern that the workspace `src/` should adopt for the Esri tile URL (currently hardcoded). Carry idea to Step 4.
|
||||
6. **`mission-planner/package.json`** lockfile is uncommitted (`bun.lock` is workspace-only; MP uses npm without a committed lock). Reproducibility risk if anyone runs MP. Step 8 — when MP is deleted this becomes moot.
|
||||
7. **Dependency divergence**: MP uses `react-leaflet` 4.2 vs workspace 5.x; `@hello-pangea/dnd` 16 vs 18; `chart.js` shared. None of this affects the deployed bundle. Step 8 — delete MP.
|
||||
|
||||
## Tests
|
||||
|
||||
The single `jsonImport.test.ts` cannot run. None of the other 36 modules have tests.
|
||||
|
||||
## Cross-doc references
|
||||
|
||||
- `_docs/02_document/00_discovery.md §1, §2b, §7b, §10, §11` — discovery-level facts about MP.
|
||||
- Workspace `README.md` — declares MP as not-deployed.
|
||||
- `mission-planner/README.md` — stale CRA boilerplate, do not trust.
|
||||
- The 14 `src/features/flights/` modules — see consolidated `src__features__flights.md`.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Modules: `src/App.tsx` + `src/main.tsx`
|
||||
|
||||
> Compact combined doc — both modules are tiny, top-of-tree wiring only.
|
||||
|
||||
## `src/main.tsx` (entry)
|
||||
|
||||
Mounts the React tree:
|
||||
|
||||
- Calls `createRoot(document.getElementById('root')!)` — the non-null assertion will throw at boot if `<div id="root">` is missing from `index.html` (it is present).
|
||||
- Wraps in `<StrictMode>` (double-renders effects in dev) and `<BrowserRouter>` (HTML5 history).
|
||||
- Imports `./i18n/i18n` for **side effects only** — that file calls `i18n.init({...})` at import time. See `src__i18n__i18n.md` for the locked-language finding (lng:'en' hardcoded).
|
||||
- Imports `./index.css` — the Tailwind 4 stylesheet plus the `az-*` token definitions consumed by every component.
|
||||
|
||||
No props, no state, nothing testable.
|
||||
|
||||
## `src/App.tsx` (route tree)
|
||||
|
||||
Top-level routes:
|
||||
|
||||
| Path | Element | Notes |
|
||||
|---|---|---|
|
||||
| `/login` | `<LoginPage />` | Public; outside auth + flight providers. |
|
||||
| `/*` | `<ProtectedRoute><FlightProvider><Header />...nested Routes...</FlightProvider></ProtectedRoute>` | Auth-gated container. Mounts `Header` once across all child routes. |
|
||||
| `/flights` | `<FlightsPage />` | (default redirect target) |
|
||||
| `/annotations` | `<AnnotationsPage />` | |
|
||||
| `/dataset` | `<DatasetPage />` | |
|
||||
| `/admin` | `<AdminPage />` | (no extra role gate — see Findings) |
|
||||
| `/settings` | `<SettingsPage />` | (no extra role gate — see Findings) |
|
||||
| `*` | `<Navigate to="/flights" replace />` | catch-all under the protected branch. |
|
||||
|
||||
Outside everything: `<AuthProvider>`. So:
|
||||
- `LoginPage` can call `useAuth()`.
|
||||
- `FlightProvider` only mounts after `ProtectedRoute` has confirmed an authenticated user — `FlightContext` queries `/api/flights` only once we know we're logged in. This avoids the 401-then-401-loop on first paint.
|
||||
|
||||
Layout: `flex flex-col h-screen` — header at top, content fills the rest with `overflow-hidden`. Each page owns its own scroll/resize.
|
||||
|
||||
## Findings carried into Step 4 / 6
|
||||
|
||||
1. **`/admin` is reachable by users without ADM permission (defence-in-depth gap)**: `App.tsx:30` route has no permission check. `Header.tsx:88` filters menu visibility via `hasPermission('ADM')`, but typing `/admin` directly bypasses the menu hide. Users without ADM see a partially-working Admin page until the server returns 403 on each write. Per parent `../../../../_docs/00_roles_permissions.md` only Admin / ApiAdmin holds ADM. **PRIORITY** for Step 4. Note: `/settings` is similarly ungated, but `_docs/00_roles_permissions.md` does NOT define a `SETTINGS` permission code — settings calls land on `/api/admin/...` endpoints which are server-enforced by ADM via 403. Open question for Step 6: should `/settings` also be ADM-gated client-side, or is the per-user-settings subset (`/api/admin/users/me/settings`) intended to be reachable by non-admins?
|
||||
2. **No `<ErrorBoundary>` wrapping the protected branch**: a render error inside any page crashes the whole tree. Step 4 / Step 8.
|
||||
3. **No lazy-loading of route chunks** (`React.lazy` / `Suspense`). The whole app bundles in one chunk. For now the bundle is small enough that this is acceptable — Step 8 candidate when bundle size grows.
|
||||
4. **Default redirect target is `/flights`** even for users whose primary task is annotations or dataset. Could be a per-role default landing page. Step 6.
|
||||
|
||||
(Earlier draft of this doc claimed there was no mobile bottom-nav — that was incorrect. `Header.tsx:113-129` does render a bottom-nav at `< sm`. The whole-app `flex flex-col h-screen` layout is the same at all breakpoints by design.)
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Cross-doc references
|
||||
|
||||
- `src__main_tsx` (this doc) ← entry; depended-upon by all others transitively.
|
||||
- `src/auth/AuthContext.tsx`, `src/auth/ProtectedRoute.tsx` — already documented.
|
||||
- `src/components/FlightContext.tsx`, `src/components/Header.tsx` — already documented.
|
||||
- Parent roles spec: `../../../../_docs/00_roles_permissions.md`.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Module: `src/api/client.ts`
|
||||
|
||||
> **Source**: `src/api/client.ts` (65 lines)
|
||||
> **Topo batch**: B2 (leaf — no internal imports)
|
||||
|
||||
## Purpose
|
||||
|
||||
Minimal `fetch` wrapper that injects the JWT bearer token, normalises HTTP errors into `Error` throws, and transparently retries a single time after a 401 by attempting a refresh. Acts as the single HTTP entry point for every page; there is no per-service typed client.
|
||||
|
||||
## Public interface
|
||||
|
||||
Token plumbing:
|
||||
|
||||
```ts
|
||||
export function setToken(token: string | null): void
|
||||
export function getToken(): string | null
|
||||
```
|
||||
|
||||
HTTP API:
|
||||
|
||||
```ts
|
||||
export const api = {
|
||||
get: <T>(url) => Promise<T>
|
||||
post: <T>(url, body?) => Promise<T>
|
||||
put: <T>(url, body?) => Promise<T>
|
||||
patch: <T>(url, body?) => Promise<T>
|
||||
delete: <T>(url) => Promise<T>
|
||||
upload: <T>(url, formData: FormData) => Promise<T>
|
||||
}
|
||||
```
|
||||
|
||||
## Internal logic
|
||||
|
||||
- Module-level mutable variable `let accessToken: string | null` holds the current bearer token.
|
||||
- `request<T>(url, options)`:
|
||||
1. Build a `Headers` from `options.headers`, inject `Authorization: Bearer <token>` if present.
|
||||
2. If `options.body` is a `string`, set `Content-Type: application/json`. (Crucial: `upload()` passes a `FormData` body, which is **not** a string, so `Content-Type` is left to the browser to set with the multipart boundary.)
|
||||
3. `fetch(url, ...)`.
|
||||
4. On `401` *and* a present token: call `refreshToken()`. On success, set the new bearer and retry the same request once. On failure, clear the token and `window.location.href = '/login'`, then throw "Session expired".
|
||||
5. Hand off to `handleResponse<T>`.
|
||||
- `handleResponse<T>(res)`:
|
||||
- `204` → `undefined as T`.
|
||||
- `!res.ok` → throw `new Error(\`${status}: ${text || statusText}\`)`. Body text is read defensively (`.catch(() => '')`).
|
||||
- Otherwise → `res.json()` (no schema validation — caller types the response).
|
||||
- `refreshToken()` — `POST /api/admin/auth/refresh` with `credentials: 'include'`. On 200, expects `{ token: string }` and stores it. Returns boolean.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**: none.
|
||||
- **External**: `fetch`, `Headers`, `FormData`, `Response` (browser globals). No npm runtime dependency.
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
The module-level `api` object is imported by:
|
||||
|
||||
- `src/auth/AuthContext.tsx` (login / logout / initial refresh)
|
||||
- `src/components/FlightContext.tsx` (flights list, user settings get/put)
|
||||
- `src/components/DetectionClasses.tsx` (admin classes load)
|
||||
- `src/features/admin/AdminPage.tsx`
|
||||
- `src/features/settings/SettingsPage.tsx`
|
||||
- `src/features/dataset/DatasetPage.tsx`
|
||||
- `src/features/flights/FlightsPage.tsx`
|
||||
- `src/features/annotations/{AnnotationsPage,AnnotationsSidebar,MediaList}.tsx`
|
||||
|
||||
`setToken` is imported by `AuthContext` (login / refresh / logout).
|
||||
`getToken` is imported by `src/api/sse.ts` (to append the token to SSE URLs).
|
||||
|
||||
## Data models
|
||||
|
||||
None defined here. The generic `T` parameter is supplied by call sites.
|
||||
|
||||
## Configuration
|
||||
|
||||
URLs are **string literals** at every call site (`/api/admin/...`, `/api/flights?...`, etc.). There is no base-URL constant. The `vite.config.ts` dev proxy and `nginx.conf` production rules forward `/api/*` to per-service backends.
|
||||
|
||||
A `VITE_API_BASE_URL` env-var fix is the canonical Step 4 testability candidate (workspace `README.md` calls this out).
|
||||
|
||||
## External integrations
|
||||
|
||||
Every backend the SPA talks to flows through this module. See `nginx.conf` for the routing table:
|
||||
|
||||
| Path prefix | Backend service |
|
||||
|---|---|
|
||||
| `/api/admin/*` | `admin/` (.NET) |
|
||||
| `/api/annotations/*` | `annotations/` (.NET) |
|
||||
| `/api/flights/*` | `flights/` (.NET) |
|
||||
| `/api/resource/*` | `satellite-provider/` |
|
||||
| `/api/detect/*` | `detections/` (Cython) |
|
||||
| `/api/loader/*` | `loader/` (Cython) |
|
||||
| `/api/gps-denied-desktop/*` | `gps-denied-desktop/` |
|
||||
| `/api/gps-denied-onboard/*` | `gps-denied-onboard/` |
|
||||
| `/api/autopilot/*` | `autopilot/` |
|
||||
|
||||
## Security
|
||||
|
||||
- **Token storage**: in-memory only (`accessToken: string | null` at module scope). Survives in-tab navigations but not full reloads. The refresh path (`POST /api/admin/auth/refresh` with `credentials: 'include'`) implies the refresh token rides in an HttpOnly cookie set by the `admin/` service. The bearer access token is therefore short-lived and never persisted to `localStorage`. Acceptable XSS posture.
|
||||
- **401 handling**: redirects to `/login` via `window.location.href` (full page reload) — clears any in-memory state including the bearer.
|
||||
- **Race condition**: two parallel requests that both 401 will both call `refreshToken()` independently — one will succeed, one may receive a stale token mid-flight. Track for B3/B4 audit; minor under the current usage but should be serialised in Step 8.
|
||||
- **No CSRF token**: relies on the bearer scheme only; `credentials: 'include'` is set only on `/refresh`, so other endpoints don't carry the cookie. Verify with `admin/` service contract during Step 6 `security_approach.md`.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- The `Authorization` header is set BEFORE `refreshToken()` in the retry path, but `refreshToken()` mutates the module-level `accessToken` and the retry then `headers.set('Authorization', \`Bearer ${accessToken}\`)` reads the NEW token. Correct, but worth a comment.
|
||||
- `request` is typed `<T>` and trusts callers; a runtime schema validation layer (Zod, valibot) would be the right Step 8 hardening but is too heavy for testability scope.
|
||||
- `upload(url, formData)` does NOT set `Content-Type`, allowing the browser to compute the multipart boundary. This is intentional and correct.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Module: `src/api/sse.ts`
|
||||
|
||||
> **Source**: `src/api/sse.ts` (25 lines)
|
||||
> **Topo batch**: B3 (depends on B2 leaf: `api/client` for `getToken()` only)
|
||||
|
||||
## Purpose
|
||||
|
||||
A 25-line wrapper around the browser's native `EventSource` that (a) appends the current bearer token as an `access_token` query parameter (since `EventSource` does not let callers set headers), (b) parses each `MessageEvent` payload as JSON, and (c) returns a cleanup function. Used by features that listen for server-pushed updates (annotations queue, flight ingestion progress, etc.).
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
export function createSSE<T>(
|
||||
url: string,
|
||||
onMessage: (data: T) => void,
|
||||
onError?: (err: Event) => void,
|
||||
): () => void
|
||||
```
|
||||
|
||||
The returned function closes the underlying `EventSource`. Callers MUST call it on unmount to avoid leaking long-lived connections.
|
||||
|
||||
## Internal logic
|
||||
|
||||
1. `const token = getToken()` — read the current bearer from `api/client.ts`.
|
||||
2. Build `fullUrl`:
|
||||
- If `token` is non-null: append `access_token=<token>` using `&` if the URL already has a query string, `?` otherwise.
|
||||
- If `token` is null: use `url` as-is.
|
||||
3. `new EventSource(fullUrl)`.
|
||||
4. `source.onmessage = (e) => { try { onMessage(JSON.parse(e.data) as T) } catch { /* ignore */ } }`.
|
||||
- JSON parse errors are silently swallowed. The contract is that the server sends valid JSON; a malformed frame degrades to "skipped".
|
||||
5. `source.onerror = (e) => onError?.(e)` — forwarded straight through. The browser auto-reconnects by default; `onError` lets callers observe the disconnect.
|
||||
6. Return `() => source.close()`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**: `./client` — `getToken()`.
|
||||
- **External**: `EventSource` (browser global).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
From the §7a dependency graph:
|
||||
|
||||
- `src/features/annotations/AnnotationsSidebar.tsx` — subscribes to the annotations stream.
|
||||
- `src/features/flights/FlightsPage.tsx` — subscribes to flight ingestion / state updates.
|
||||
|
||||
## Data models
|
||||
|
||||
None defined here. The generic `T` is supplied by the caller.
|
||||
|
||||
## Configuration
|
||||
|
||||
URLs are passed in by callers (string-literal at call sites). The same testability remark as `api/client.ts` applies: a `VITE_API_BASE_URL` is the natural Step 4 fix.
|
||||
|
||||
## External integrations
|
||||
|
||||
Whichever backend exposes the SSE endpoint at the URL the caller provides. Per `nginx.conf`, the suite's `/api/*` reverse proxy forwards SSE traffic by default (no special EventSource-blocking config) — verify in Step 4.
|
||||
|
||||
## Security
|
||||
|
||||
- **Bearer in query string**: `access_token=<jwt>` ends up in browser-history URLs, server access logs, and proxy logs. This is a **known weakness of the EventSource API** — the API has no headers parameter, so cookie or query are the only options. The trade-off was made knowingly (SSE is a long-lived GET; the bearer is short-lived; nginx access logs are an internal-only artefact). Document in `security_approach.md` (Step 6) and consider rotating to a dedicated SSE-only short-lived token in Step 8.
|
||||
- **`getToken()` on connect, no refresh**: if the bearer rotates mid-session (via `client.ts`'s 401 retry path), the `EventSource` keeps using the old token and will eventually error. Callers must observe `onError` and reconnect. The current consumers (`AnnotationsSidebar`, `FlightsPage`) do NOT do this — they create the source once on mount. Flag for Step 4 / Step 8.
|
||||
- **Silent JSON parse error**: a single malformed frame is skipped without a `console.warn`. Acceptable for production noise reduction but obscures real backend bugs in dev. Defer.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- **No reconnect / backoff logic** — relies on the browser's built-in EventSource auto-reconnect. The default is "keep retrying"; on a flaky connection this may produce a tight loop with backend logs. Confirm acceptable in Step 6 against the `annotations/` and `flights/` service rate-limit posture.
|
||||
- **Cleanup on token-less call**: `getToken()` returning `null` produces an unauthenticated `EventSource`. The backend should reject with `401`, the `EventSource` then errors, and `onError?` fires. The caller is expected to interpret this as "user logged out, stop subscribing". None of the current consumers do that explicitly; instead, the `ProtectedRoute` gate prevents `useEffect` from ever running while logged out, so the path is unreachable in practice. Document but no action needed.
|
||||
- The function is fully synchronous — does not return a `Promise`. The connection is initiated on call but the first message may arrive later. Consumers must handle the "no messages yet" UI state.
|
||||
- The query-string token assembly does NOT URL-encode the token. JWTs are URL-safe Base64 by default and contain only `A–Z, a–z, 0–9, -, _, .`, so this is safe for the current token shape. If the token format ever changes, add `encodeURIComponent`. Note for Step 8.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Module: `src/auth/AuthContext.tsx`
|
||||
|
||||
> **Source**: `src/auth/AuthContext.tsx` (54 lines)
|
||||
> **Topo batch**: B3 (depends on B2 leaves: `api/client`, `types/index`)
|
||||
|
||||
## Purpose
|
||||
|
||||
The single source of truth for the SPA's authentication state. Wraps the bearer-token plumbing from `api/client.ts` in a React context, exposes `useAuth()` for any descendant component, and bootstraps the session on app start by attempting a refresh. Together with `ProtectedRoute.tsx` and `LoginPage.tsx`, this is the WPF-era `LoginWindow.xaml` + auth service replacement (`_docs/legacy/wpf-era.md` §3 / §4).
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
interface AuthState {
|
||||
user: AuthUser | null
|
||||
loading: boolean
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
hasPermission: (perm: string) => boolean
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState
|
||||
export function AuthProvider({ children }: { children: ReactNode }): JSX.Element
|
||||
```
|
||||
|
||||
`AuthContext` itself is module-private (`createContext<AuthState>(null!)`). Consumers must go through `useAuth()`.
|
||||
|
||||
## Internal logic
|
||||
|
||||
State:
|
||||
|
||||
- `user: AuthUser | null` — `null` when unauthenticated.
|
||||
- `loading: boolean` — `true` until the initial refresh attempt resolves (success or failure). Renders should gate on this.
|
||||
|
||||
**Bootstrap effect (mount-only)**:
|
||||
|
||||
```ts
|
||||
api.get<{ user: AuthUser; token: string }>('/api/admin/auth/refresh')
|
||||
.then(data => { setToken(data.token); setUser(data.user) })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
```
|
||||
|
||||
The refresh endpoint is invoked with `credentials: 'include'` only inside `client.ts`'s **internal** `refreshToken()` helper — but here we go through the public `api.get()` path, which does NOT include credentials. **This is a real divergence**: `client.ts`'s internal `refreshToken()` (used in the 401 retry) sends the cookie; the bootstrap call in `AuthContext` does not. The endpoint must therefore accept the refresh either via cookie (then bootstrap fails silently for non-cookie clients — which is everyone after a hard reload) **or** via some other mechanism (a refresh token in `localStorage`, etc.). **Flag for Step 4 verification** against the `admin/` service contract; this is likely a real bug masking by silent `.catch`.
|
||||
|
||||
**`login(email, password)`**:
|
||||
|
||||
```ts
|
||||
const data = await api.post<{ token; user }>('/api/admin/auth/login', { email, password })
|
||||
setToken(data.token); setUser(data.user)
|
||||
```
|
||||
|
||||
Throws to caller (LoginPage) on bad credentials.
|
||||
|
||||
**`logout()`**:
|
||||
|
||||
```ts
|
||||
try { await api.post('/api/admin/auth/logout') } catch {}
|
||||
setToken(null); setUser(null)
|
||||
```
|
||||
|
||||
Network failure on logout is silently swallowed because we want to clear local auth state regardless.
|
||||
|
||||
**`hasPermission(perm)`**: returns `user?.permissions.includes(perm) ?? false`. The permission strings are not constrained at the type level — any string passes. Backend-defined.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**:
|
||||
- `../api/client` — `api`, `setToken`.
|
||||
- `../types` — `AuthUser` type.
|
||||
- **External**: `react` (`createContext`, `useContext`, `useState`, `useCallback`, `useEffect`, `ReactNode`).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
From the §7a dependency graph:
|
||||
|
||||
- `src/auth/ProtectedRoute.tsx` — gates routed children on `user !== null`.
|
||||
- `src/components/Header.tsx` — shows current user, exposes Logout.
|
||||
- `src/features/login/LoginPage.tsx` — calls `login(...)`, redirects on success.
|
||||
- `src/App.tsx` — mounts `AuthProvider` near the root.
|
||||
|
||||
(Other features rely on the bearer token implicitly via `api/client.ts` — they don't import `useAuth` directly.)
|
||||
|
||||
## Data models
|
||||
|
||||
`AuthUser` (from `src/types/index.ts`) — see `_docs/02_document/modules/src__types__index.md`. Carries at minimum `id`, `email`, `permissions: string[]`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Endpoints (string-literal): `/api/admin/auth/refresh`, `/api/admin/auth/login`, `/api/admin/auth/logout`. Routed by `nginx.conf` to the `admin/` service.
|
||||
|
||||
No env vars consumed directly — token storage policy is defined in `client.ts` (in-memory; not persisted to `localStorage`).
|
||||
|
||||
## External integrations
|
||||
|
||||
`admin/` (.NET) auth service via `/api/admin/auth/{refresh,login,logout}`.
|
||||
|
||||
## Security
|
||||
|
||||
- **In-memory token only**: the bearer is held by `client.ts` at module scope. Survives intra-tab navigation, lost on hard reload — at which point the refresh path must restore it (currently broken per the bootstrap-effect note above).
|
||||
- **`hasPermission` runs client-side only**: the server is the authority; `hasPermission` is for UI affordances (hide vs. show buttons). The backend MUST re-check permissions on every endpoint. Document in `security_approach.md` (Step 6).
|
||||
- **Silent error swallowing on bootstrap and logout** is intentional but obscures real failures. A dev-only `console.error` would help during the testability pass; do not add a user-visible toast (silent recovery is the correct UX).
|
||||
- **No XSS exfiltration risk for the bearer**: in-memory only, never written to `localStorage` or a non-HttpOnly cookie. (Confirmed in `client.ts` doc.)
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- **Bootstrap-vs-refresh divergence** (above) — the highest-priority flag in this module. Either:
|
||||
1. The refresh endpoint accepts an Authorization-less, cookie-bearing call → confirm the `admin/` service sets an HttpOnly cookie on `/login` and the cookie path matches `/api/admin/auth/refresh`. The `api.get()` path in `client.ts` does NOT send `credentials: 'include'`, so this currently CANNOT work. → **likely bug**.
|
||||
2. Or the bootstrap should be calling the internal `refreshToken()` helper, which is currently not exported.
|
||||
Either way, this needs a Step 4 fix (export `refreshToken()` and call it here, or change `api.get()` to allow per-call `credentials`).
|
||||
- **`AuthContext = createContext<AuthState>(null!)`**: the non-null assertion means `useAuth()` will throw at the destructuring site if it's used outside `AuthProvider`. Acceptable given `App.tsx` mounts `AuthProvider` at the top, but a guard `if (!ctx) throw new Error(...)` would be friendlier. Defer.
|
||||
- The `loading` flag is never re-set to `true` after the initial bootstrap. `login` and `logout` complete synchronously from the React tree's perspective (the `await` is inside the callback). If a future requirement demands a "logging in…" indicator, it would need its own state. Note for Step 8.
|
||||
- `useAuth` returns the raw context value (no memoisation wrapper). React 18+ behaviour means `<AuthProvider>` re-renders all `useAuth` consumers on every state update — fine here because there's no high-frequency state.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Module: `src/auth/ProtectedRoute.tsx`
|
||||
|
||||
> **Source**: `src/auth/ProtectedRoute.tsx` (19 lines)
|
||||
> **Topo batch**: B4 (depends on B3: `auth/AuthContext`)
|
||||
|
||||
## Purpose
|
||||
|
||||
A tiny route guard that gates its children behind an authenticated session.
|
||||
While `AuthContext` is bootstrapping (`loading === true`) it shows a spinner;
|
||||
when the bootstrap finishes with no user it redirects to `/login`; otherwise
|
||||
it renders its children. Mounted exactly once in `App.tsx` between
|
||||
`AuthProvider` and `FlightProvider` so every authenticated page benefits
|
||||
from it. WPF parallel: the implicit "no LoginWindow open ⇒ MainWindow"
|
||||
gate (`_docs/legacy/wpf-era.md` §4).
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
interface Props { children: ReactNode }
|
||||
export default function ProtectedRoute(props: Props): JSX.Element
|
||||
```
|
||||
|
||||
Always returns a `JSX.Element` — never `null`. The three rendered shapes
|
||||
are:
|
||||
|
||||
1. Spinner (centered `<div>` with the orange ring) while `loading`.
|
||||
2. `<Navigate to="/login" replace />` when `loading === false && user == null`.
|
||||
3. `<>{children}</>` otherwise.
|
||||
|
||||
`replace` is intentional: it rewrites the history entry so the back
|
||||
button does not return to a protected route the user was bounced off.
|
||||
|
||||
## Internal logic
|
||||
|
||||
- Single hook call: `const { user, loading } = useAuth()`. No local state.
|
||||
- Branch order matters — `loading` is checked before `user` so a freshly
|
||||
reloaded tab never renders the login redirect during the in-flight
|
||||
refresh attempt. (See the open `AuthContext` bootstrap-vs-refresh
|
||||
divergence flagged in `src__auth__AuthContext.md`; if that bug is
|
||||
fixed the spinner duration becomes accurate.)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**: `./AuthContext` — `useAuth`.
|
||||
- **External**: `react-router-dom` (`Navigate`), `react` (`ReactNode` type only).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
From the §7a dependency graph:
|
||||
|
||||
- `src/App.tsx` — wraps the entire authenticated route tree:
|
||||
`AuthProvider → ProtectedRoute → FlightProvider → Header + nested Routes`.
|
||||
|
||||
Not used anywhere else; the SPA has a single protected zone.
|
||||
|
||||
## Data models
|
||||
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
|
||||
The `/login` redirect target is hardcoded. Matches the public route
|
||||
declared in `App.tsx`. If the public route is ever renamed, update both
|
||||
sites.
|
||||
|
||||
The spinner uses Tailwind tokens `bg-az-bg`, `border-az-orange`
|
||||
(defined in `src/index.css`).
|
||||
|
||||
## External integrations
|
||||
|
||||
None directly. Indirectly relies on whatever `AuthContext` calls during
|
||||
bootstrap — currently `GET /api/admin/auth/refresh`.
|
||||
|
||||
## Security
|
||||
|
||||
- **No token check is duplicated here** — relies entirely on `AuthContext`.
|
||||
The component cannot be confused into rendering protected children
|
||||
before the bootstrap resolves because `loading` defaults to `true` in
|
||||
`AuthProvider`.
|
||||
- Backend authority is unchanged — this is a UI affordance only. Every
|
||||
request the children make MUST also be enforced server-side.
|
||||
- The redirect uses `Navigate`, so query params on the original URL are
|
||||
lost. Acceptable today (no protected route relies on them); flag if a
|
||||
future "deep-link after login" UX appears.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- The spinner has no `role="status"` or accessible label — screen readers
|
||||
hear nothing while the bootstrap runs. Cosmetic; flag for Step 4
|
||||
verification against `_docs/ui_design/README.md` accessibility notes.
|
||||
- No timeout on the `loading` state — if `AuthContext`'s bootstrap
|
||||
somehow never resolves (e.g., the refresh endpoint hangs), the user
|
||||
sees an infinite spinner. `client.ts` does not currently set a
|
||||
request timeout either; flag jointly with Step 4.
|
||||
- The `<>{children}</>` Fragment wrap is intentional: returning `children`
|
||||
directly would make the type `ReactNode` rather than `JSX.Element` and
|
||||
would not satisfy the route element slot in `react-router-dom@7`.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Module: `src/components/ConfirmDialog.tsx`
|
||||
|
||||
> **Source**: `src/components/ConfirmDialog.tsx` (47 lines)
|
||||
> **Topo batch**: B3 (uses `react-i18next` only; no intra-repo deps)
|
||||
|
||||
## Purpose
|
||||
|
||||
A controlled, single-message confirm dialog used wherever the SPA needs an "Are you sure?" gate. Replaces the legacy WPF `MessageBox.Show(..., MessageBoxButton.YesNo, ...)` pattern (`_docs/legacy/wpf-era.md` §4 / §"What survived").
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
interface Props {
|
||||
open: boolean
|
||||
title: string
|
||||
message?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
export default function ConfirmDialog(props: Props): JSX.Element | null
|
||||
```
|
||||
|
||||
When `open === false`, returns `null`. When `open === true`, renders a centered modal over a dimmed backdrop. The component is fully controlled — it owns no `open` state itself.
|
||||
|
||||
## Internal logic
|
||||
|
||||
- **Auto-focus**: when `open` flips to `true`, the Cancel button is focused via `cancelRef.current?.focus()` (see `useEffect` keyed on `open`). This makes Cancel the default keyboard target — a common pattern for destructive confirmations.
|
||||
- **Escape-to-cancel**: a `keydown` handler is attached to `window` while `open === true`; pressing `Escape` calls `onCancel()`. The listener is removed on unmount or when `open` flips to `false`. This is the **only** dismiss path other than the explicit Cancel/Confirm buttons (no backdrop click handler).
|
||||
- **Translation**: button labels use `t('common.cancel')` and `t('common.confirm')`. Title and message are passed in by the caller — the caller is responsible for translation.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**: none.
|
||||
- **External**: `react` (`useEffect`, `useRef`), `react-i18next` (`useTranslation`).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
From the §7a dependency graph in `_docs/02_document/00_discovery.md`:
|
||||
|
||||
- `src/features/admin/AdminPage.tsx` — confirm before deleting users.
|
||||
- `src/features/annotations/MediaList.tsx` — confirm before deleting media.
|
||||
- `src/features/flights/FlightsPage.tsx` — confirm before deleting a flight.
|
||||
- `src/features/dataset/DatasetPage.tsx` — confirm before deleting dataset items.
|
||||
|
||||
(`HelpModal.tsx` is the *non-confirm* sibling; it does NOT use `ConfirmDialog` and notably does **not** have Escape-to-close — see `src__components__HelpModal.md` §Notes.)
|
||||
|
||||
## Data models
|
||||
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
|
||||
Tailwind tokens used: `bg-az-panel`, `border-az-border`, `text-az-text`, `bg-az-red`, `bg-az-bg`, `text-white`. All defined in `src/index.css` (per `_docs/02_document/00_discovery.md` §2a).
|
||||
|
||||
`z-[100]` is the chosen stacking layer. No other modal currently competes with it; if a third-party library introduces a portal at `z-[200]+`, layering will need a docs entry.
|
||||
|
||||
## External integrations
|
||||
|
||||
None.
|
||||
|
||||
## Security
|
||||
|
||||
- **No backdrop dismissal**: clicking outside the dialog does NOT cancel. Combined with Escape-to-cancel and a default-focused Cancel button, this gives a deliberate, keyboard-safe destructive-action flow.
|
||||
- **No `aria-modal` / role="dialog"**: not annotated for screen readers; flag for Step 4 verification against the `_docs/ui_design/README.md` §"Confirmation dialogs" spec (which specifies modal semantics).
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- The "destructive" colour (`bg-az-red`) is hardcoded into the Confirm button. Some callers use `ConfirmDialog` for non-destructive confirms (e.g. flight selection in some flows — verify in B7); a future variant prop (`destructive: boolean`) would be cleaner. Defer to Step 8.
|
||||
- No `width` prop — the dialog is fixed at `w-80` (320px). On the mobile breakpoint defined in `_docs/ui_design/README.md` §"Responsive Breakpoints" (640px), this fits, but very long titles or multi-line messages will overflow. Flag for Step 4 cosmetic verification.
|
||||
- Escape handler attaches to `window`, not the dialog DOM. If two `ConfirmDialog`s are mounted simultaneously (the SPA never does this today, but it's not enforced), both would call their `onCancel()` on a single Escape press. Acceptable under current usage.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Module: `src/components/DetectionClasses.tsx`
|
||||
|
||||
> **Source**: `src/components/DetectionClasses.tsx` (99 lines)
|
||||
> **Topo batch**: B3 (depends on B2 leaves: `api/client`, `features/annotations/classColors`, `types/index`)
|
||||
|
||||
## Purpose
|
||||
|
||||
A side-panel widget that lets the annotator pick a detection class (1–9 or click) and a photo mode (`Regular`, `Winter`, `Night`). Loads the class catalogue from the backend and falls back to a hardcoded list when the API returns empty / errors. Replaces the legacy WPF detection-class picker referenced in `_docs/legacy/wpf-era.md` §"What survived".
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
interface Props {
|
||||
selectedClassNum: number
|
||||
onSelect: (classNum: number) => void
|
||||
photoMode: number
|
||||
onPhotoModeChange: (mode: number) => void
|
||||
}
|
||||
export default function DetectionClasses(props: Props): JSX.Element
|
||||
```
|
||||
|
||||
Fully controlled — the parent owns `selectedClassNum` and `photoMode`. The current contract for `photoMode` values is integer offsets `0` (Regular), `20` (Winter), `40` (Night), matching the `photoMode` field of `DetectionClass` (`src/types/index.ts`) and the offsets baked into `FALLBACK_CLASSES` below.
|
||||
|
||||
## Internal logic
|
||||
|
||||
- **Class catalogue load** (mount-only `useEffect`):
|
||||
- `api.get<DetectionClass[]>('/api/annotations/classes')`.
|
||||
- On a non-empty array → `setClasses(list)`.
|
||||
- On an empty array OR a thrown error → `setClasses(FALLBACK_CLASSES)`.
|
||||
- **`FALLBACK_CLASSES`** is a module-private 3 × |`FALLBACK_CLASS_NAMES`| matrix:
|
||||
- For each mode offset in `[0, 20, 40]`, build one class entry per name in `FALLBACK_CLASS_NAMES` (imported from `features/annotations/classColors.ts`).
|
||||
- Each entry: `{ id: i + modeOffset, name, shortName: name.slice(0, 3), color: getClassColor(i), maxSizeM: 10, photoMode: modeOffset }`.
|
||||
- This means: in offline / API-down mode, the ID range is `0–N-1` (Regular), `20–20+N-1` (Winter), `40–40+N-1` (Night). The backend's class IDs MUST follow the same convention or fallback↔backend handoff yields ID collisions. Confirm in Step 4 via the `annotations/` service contract.
|
||||
- **Numeric hotkeys 1–9** (effect keyed on `[classes, photoMode, onSelect]`):
|
||||
- `keydown` on `window`. `parseInt(e.key)` → if 1–9, picks `classes[(num - 1) + photoMode]`.
|
||||
- **Bug-shaped**: `photoMode` is `0 | 20 | 40` (an *offset*), not a row count. `classes[idx + 0]` is correct for Regular; for Winter/Night the index `idx + 20` / `idx + 40` is meaningful only when `classes` is in the contiguous `[0..N-1, 20..20+N-1, 40..40+N-1]` shape that `FALLBACK_CLASSES` produces. **If the backend returns its classes in a different order**, hotkey 1–9 will pick the wrong class. Verify backend ordering in Step 4 — this is the principal correctness risk in this module. Flag.
|
||||
- **Auto-select first class on mode change** (effect keyed on `[classes, photoMode, selectedClassNum, onSelect]`):
|
||||
- Filter `classes` to the active `photoMode`.
|
||||
- If `selectedClassNum` is not within that filtered set, call `onSelect(modeClasses[0].id)`.
|
||||
- This guarantees the parent always holds a class ID consistent with the selected photo mode.
|
||||
- **Render**:
|
||||
- Class list — only entries whose `photoMode` matches the active mode.
|
||||
- Photo-mode bar — three buttons (Sunny / Snowflake / Moon icons) for Regular / Winter / Night.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**:
|
||||
- `../api/client` — `api.get<T>()`.
|
||||
- `../features/annotations/classColors` — `getClassColor(i)`, `FALLBACK_CLASS_NAMES`.
|
||||
- `../types` — `DetectionClass` type.
|
||||
- **External**: `react`, `react-i18next`, `react-icons/md`, `react-icons/fa`.
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
From the §7a dependency graph:
|
||||
|
||||
- `src/features/annotations/AnnotationsPage.tsx`
|
||||
- `src/features/dataset/DatasetPage.tsx`
|
||||
|
||||
This is the **canonical example** of the cross-layer import flagged in `_docs/02_document/00_discovery.md` §8: a `components/` (shared) module importing from `features/annotations/`. Two clean fixes are in scope for Step 4 / Step 8:
|
||||
|
||||
1. Lift `classColors.ts` into `src/components/detection/` (or `src/shared/`) and update the two consumers.
|
||||
2. Or: move `DetectionClasses` itself into `src/features/annotations/` since both consumers are in `features/`.
|
||||
|
||||
## Data models
|
||||
|
||||
`DetectionClass` (from `src/types/index.ts`) — see `_docs/02_document/modules/src__types__index.md`.
|
||||
|
||||
`FALLBACK_CLASSES` is module-private; see Internal logic above.
|
||||
|
||||
## Configuration
|
||||
|
||||
Endpoint: `/api/annotations/classes` — string-literal URL (testability fix scheduled for Step 4).
|
||||
|
||||
Photo-mode value set is `{0, 20, 40}` — hardcoded, mirrored by `FALLBACK_CLASSES`. If the backend grows a fourth mode (e.g. thermal, IR), every consumer of `photoMode` will need a coordinated change.
|
||||
|
||||
Tailwind tokens: `bg-az-orange` (Regular), `bg-az-blue` (Winter), `bg-purple-600` (Night). Defined in `src/index.css`.
|
||||
|
||||
## External integrations
|
||||
|
||||
- HTTP `GET /api/annotations/classes` → `DetectionClass[]`. Backed by the `annotations/` service (`.NET`) per `nginx.conf`.
|
||||
|
||||
## Security
|
||||
|
||||
- **Silent failure on class load**: `.catch(() => setClasses(FALLBACK_CLASSES))` swallows the error and the user sees the fallback. Acceptable for UX continuity, but the lack of any user-visible signal means a misconfigured `/api/annotations/classes` deploy could go unnoticed in prod. Flag for Step 6 `security_approach.md` / Step 8.
|
||||
- No input that flows back to the server here.
|
||||
- The `getClassColor(i)` palette is deterministic; no PII.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- **`getPhotoModeSuffix` redundancy** (carry-over from B1): `classColors.ts` exposes `getPhotoModeSuffix()` that derives the same suffix the typed `DetectionClass.photoMode` field already encodes. Once `classColors.ts` is repositioned (see Consumers), `getPhotoModeSuffix` is a deletion candidate. Defer to Step 8.
|
||||
- **`FALLBACK_CLASS_NAMES.length`** is implicitly assumed to be ≤ 9 by the hotkey code (only 1–9 are bound). If the catalogue grows to 10+ entries, hotkeys can no longer cover the tail. Acceptable for now.
|
||||
- **Mode-button colours don't use `az-` tokens for Night** (`bg-purple-600`, `text-purple-400` instead of an `az-purple` token). Cosmetic inconsistency; flag for Step 4 against `_docs/ui_design/README.md` colour palette.
|
||||
- The class list is rendered with `1.`, `2.`, … prefixes derived from `i+1` — so the hotkey number always matches the visible label inside the active mode. Good.
|
||||
- Does not handle modifier keys (`Shift`, `Ctrl`) on numeric hotkeys; pressing `Shift+5` will trigger the `5` branch. Fine for now.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Module: `src/components/FlightContext.tsx`
|
||||
|
||||
> **Source**: `src/components/FlightContext.tsx` (52 lines)
|
||||
> **Topo batch**: B3 (depends on B2 leaves: `api/client`, `types/index`)
|
||||
|
||||
## Purpose
|
||||
|
||||
A React context that holds the SPA's currently-selected flight and the cached flight list, keeps the selection in sync with the per-user backend setting, and exposes a refresh trigger. Sibling of `AuthContext.tsx` — both are mounted near the root (`App.tsx`) so any feature page can access flight state without prop-drilling. Replaces the legacy WPF "current mission" singleton (`_docs/legacy/wpf-era.md` §"What survived").
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
interface FlightState {
|
||||
flights: Flight[]
|
||||
selectedFlight: Flight | null
|
||||
selectFlight: (f: Flight | null) => void
|
||||
refreshFlights: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useFlight(): FlightState
|
||||
export function FlightProvider({ children }: { children: ReactNode }): JSX.Element
|
||||
```
|
||||
|
||||
`FlightContext` itself is module-private. Consumers must go through `useFlight()`.
|
||||
|
||||
## Internal logic
|
||||
|
||||
State:
|
||||
|
||||
- `flights: Flight[]` — most recent list returned by `GET /api/flights?pageSize=1000`.
|
||||
- `selectedFlight: Flight | null` — the active flight, or `null` if none. Survives across pages because the provider is mounted above the route tree.
|
||||
|
||||
**`refreshFlights()`** (`useCallback`, no deps):
|
||||
|
||||
```ts
|
||||
const data = await api.get<{ items: Flight[] }>('/api/flights?pageSize=1000')
|
||||
setFlights(data.items ?? [])
|
||||
```
|
||||
|
||||
Errors are silently swallowed (`try { ... } catch {}`). `pageSize=1000` is a hardcoded ceiling — see Configuration.
|
||||
|
||||
**Bootstrap effect** (`useEffect` keyed on `[refreshFlights]`, runs once because `refreshFlights` is `useCallback([])`):
|
||||
|
||||
1. `refreshFlights()` (no `await` — runs in parallel with #2).
|
||||
2. `api.get<UserSettings>('/api/annotations/settings/user')` →
|
||||
- if `settings?.selectedFlightId` is truthy: `api.get<Flight>('/api/flights/${settings.selectedFlightId}')` → `setSelectedFlight(f)`.
|
||||
- errors at every step silently swallowed.
|
||||
|
||||
The selected flight is therefore looked up by **its own GET**, not by indexing into the cached `flights` list. This is intentional — the user might have a `selectedFlightId` that fell off the first 1000 flights.
|
||||
|
||||
**`selectFlight(f)`** (`useCallback`, no deps):
|
||||
|
||||
```ts
|
||||
setSelectedFlight(f)
|
||||
api.put('/api/annotations/settings/user', { selectedFlightId: f?.id ?? null }).catch(() => {})
|
||||
```
|
||||
|
||||
Optimistic — local state updates immediately; the persisted setting is fire-and-forget. If the PUT fails, the next reload will fall back to the previously-stored ID and the user's selection silently reverts. Flag for Step 4.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**:
|
||||
- `../api/client` — `api`.
|
||||
- `../types` — `Flight`, `UserSettings` types.
|
||||
- **External**: `react` (`createContext`, `useContext`, `useState`, `useEffect`, `useCallback`, `ReactNode`).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
From the §7a dependency graph:
|
||||
|
||||
- `src/App.tsx` — mounts `FlightProvider` inside `ProtectedRoute` and above the route tree (so `selectedFlight` survives navigation between `/flights`, `/annotations`, `/dataset`).
|
||||
- `src/components/Header.tsx` — displays the currently-selected flight name.
|
||||
- `src/features/flights/FlightsPage.tsx` — the primary editor; calls `selectFlight` on row click.
|
||||
- `src/features/annotations/MediaList.tsx` — filters media by `selectedFlight.id`.
|
||||
- `src/features/dataset/DatasetPage.tsx` — same.
|
||||
|
||||
## Data models
|
||||
|
||||
- `Flight` and `UserSettings` from `src/types/index.ts` — see `_docs/02_document/modules/src__types__index.md`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Endpoints (string-literal): `/api/flights?pageSize=1000`, `/api/flights/{id}`, `/api/annotations/settings/user`. Routed by `nginx.conf` to the `flights/` and `annotations/` services.
|
||||
|
||||
`pageSize=1000` is a **hardcoded ceiling** — if the system ever has >1000 flights, the cache is silently truncated. The `flights/` service contract probably caps at 1000; verify and document in `data_parameters.md` (Step 6). Flag.
|
||||
|
||||
## External integrations
|
||||
|
||||
- `flights/` (.NET) — `GET /api/flights`, `GET /api/flights/{id}`.
|
||||
- `annotations/` (.NET) — `GET /api/annotations/settings/user`, `PUT /api/annotations/settings/user`. The user-settings store is reused for the selected-flight pointer; this is a slight abstraction leak (selected flight is logically a UI-state preference, not an annotation setting), but it works as long as `UserSettings` keeps a `selectedFlightId` field.
|
||||
|
||||
## Security
|
||||
|
||||
- **No auth checks here** — relies on `api/client.ts` to inject the bearer; backend enforces visibility.
|
||||
- **Silent failure on every async call** — same caveat as `AuthContext`. A misconfigured `/api/flights` will leave the user with an empty list and no error indication. Flag for Step 6 `security_approach.md` and Step 8 hardening.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- **Race**: bootstrap fires `refreshFlights()` AND the user-settings GET in parallel. If the user-settings GET resolves first with a `selectedFlightId` that's not in the 1000 cached flights, the per-flight GET still succeeds. If the 1000 list is somehow stale and the per-flight GET 404s, `selectedFlight` stays `null` and the user sees nothing selected. Acceptable but worth documenting.
|
||||
- **`selectFlight(null)` PUTs `{ selectedFlightId: null }`** — this clears the persisted preference. Confirm the `annotations/` service treats this as "unset" and not as an error or no-op. Flag for Step 4.
|
||||
- **No realtime invalidation**: if another tab / user creates a flight, this client won't know until the next `refreshFlights()` call. The SPA also has SSE (`api/sse.ts`) but does NOT subscribe to flight updates. Mark as a Step 8 / future-feature hook.
|
||||
- **`useCallback([])` for `refreshFlights`** is fine because `setFlights` is stable. The empty deps array means the bootstrap effect fires exactly once (as intended) — but ESLint with `react-hooks/exhaustive-deps` would still flag both `useCallback`s for missing `setFlights` (technically stable, but the linter doesn't know). Acceptable; project-wide ESLint config does not currently enforce.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Module: `src/components/Header.tsx`
|
||||
|
||||
> **Source**: `src/components/Header.tsx` (133 lines)
|
||||
> **Topo batch**: B4 (depends on B3: `auth/AuthContext`, `components/FlightContext`, `components/HelpModal`, `types/index`)
|
||||
|
||||
## Purpose
|
||||
|
||||
The persistent top navigation bar of the authenticated SPA. Combines
|
||||
five concerns into one component: brand mark, currently-selected-flight
|
||||
dropdown, top-level navigation links (permission-gated), session info
|
||||
(user email + Logout), language toggle (EN ↔ UA), and a `?` button that
|
||||
opens `HelpModal`. Also renders a duplicate bottom-nav on the mobile
|
||||
breakpoint. Replaces the legacy WPF top ribbon + window chrome
|
||||
(`_docs/legacy/wpf-era.md` §4 / §"What survived").
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
export default function Header(): JSX.Element
|
||||
```
|
||||
|
||||
No props — Header reads everything it needs from `AuthContext`,
|
||||
`FlightContext`, and `react-i18next`. Mounted exactly once in `App.tsx`
|
||||
above the protected route tree.
|
||||
|
||||
## Internal logic
|
||||
|
||||
- **Local state**:
|
||||
- `showDropdown: boolean` — flight selector open/closed.
|
||||
- `filter: string` — text filter for the flight selector list.
|
||||
- `showHelp: boolean` — controls the `HelpModal` `open` prop.
|
||||
- `dropdownRef: RefObject<HTMLDivElement>` — used to detect outside
|
||||
clicks.
|
||||
- **Outside-click effect**: registers a `mousedown` listener on `document`
|
||||
while mounted; if the event target is not inside `dropdownRef.current`,
|
||||
closes the dropdown. Listener is removed on unmount. (Always-on, even
|
||||
while the dropdown is closed — cheap, but not the most surgical
|
||||
pattern; flag to consider gating on `showDropdown` in Step 8.)
|
||||
- **Filtered flights**: `flights.filter(f => f.name.toLowerCase().includes(filter.toLowerCase()))`
|
||||
— case-insensitive substring match on the flight name. Empty filter
|
||||
shows everything.
|
||||
- **Logout**: `await logout(); navigate('/login')`. Always navigates,
|
||||
even if `logout()` throws (it never does — `AuthContext.logout` swallows
|
||||
network errors by design).
|
||||
- **Language toggle**: `i18n.changeLanguage(i18n.language === 'en' ? 'ua' : 'en')`.
|
||||
Same two-language assumption as `HelpModal` (treats every non-`'ua'`
|
||||
value as English-equivalent). The toggle button label is the
|
||||
*target* language ("UA" while EN is active, "EN" while UA is active).
|
||||
- **Permission-gated nav** (`navItems`):
|
||||
|
||||
| `to` | `label` key | `perm` |
|
||||
|---|---|---|
|
||||
| `/flights` | `nav.flights` | `FL` |
|
||||
| `/annotations` | `nav.annotations` | `ANN` |
|
||||
| `/dataset` | `nav.dataset` | `DATASET` |
|
||||
| `/admin` | `nav.admin` | `ADM` |
|
||||
|
||||
`Settings` (`/settings`) is always rendered — no permission gate.
|
||||
Filter applied via `navItems.filter(n => hasPermission(n.perm))`.
|
||||
- **Layout**: a single `<header>` containing brand → flight dropdown →
|
||||
primary nav (`hidden sm:flex`) → spacer (`flex-1`) → user email
|
||||
(`hidden sm:block`) → language toggle → `?` → `⚙` (settings link) →
|
||||
logout button. A second `<nav>` element below is `sm:hidden` and
|
||||
positions itself fixed at the bottom of the viewport — the mobile
|
||||
nav. Both navs share the same filter logic.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**:
|
||||
- `../auth/AuthContext` — `useAuth` (user, logout, hasPermission).
|
||||
- `./FlightContext` — `useFlight` (flights, selectedFlight, selectFlight).
|
||||
- `./HelpModal` — opens on `?` click.
|
||||
- `../types` — `Flight` type for the dropdown row.
|
||||
- **External**: `react-router-dom` (`NavLink`, `useNavigate`),
|
||||
`react-i18next` (`useTranslation`),
|
||||
`react` (`useState`, `useRef`, `useEffect`).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
- `src/App.tsx` — mounted inside `ProtectedRoute → FlightProvider`.
|
||||
|
||||
No other intra-repo consumer; `Header` is a top-level chrome component.
|
||||
|
||||
## Data models
|
||||
|
||||
The `navItems` array is a module-private literal of
|
||||
`{ to: string; label: string; perm: string }`. The filtered `flights`
|
||||
list is `Flight[]` (from `types/index.ts`).
|
||||
|
||||
## Configuration
|
||||
|
||||
- **i18n keys consumed**: `nav.flights`, `nav.annotations`, `nav.dataset`,
|
||||
`nav.admin`, `nav.logout`. (Verified to exist in `src/i18n/en.json`.)
|
||||
The filter placeholder ("Filter…") and the empty-state ("— Select
|
||||
Flight —", "No flights") are hardcoded strings — flag for Step 4.
|
||||
- **Tailwind tokens**: `bg-az-header`, `border-az-border`, `bg-az-panel`,
|
||||
`text-az-text`, `text-az-muted`, `text-az-orange`, `text-az-red`,
|
||||
`bg-az-bg`. Defined in `src/index.css`.
|
||||
- **Breakpoint**: `sm:` from Tailwind defaults to 640px — matches the
|
||||
responsive-breakpoint spec in `_docs/ui_design/README.md`.
|
||||
- **Permission strings**: `FL`, `ANN`, `DATASET`, `ADM`. Backend-defined
|
||||
by the `admin/` service; treated as opaque strings here.
|
||||
|
||||
## External integrations
|
||||
|
||||
None directly. Triggers `logout()` which calls `POST /api/admin/auth/logout`
|
||||
inside `AuthContext`, and `selectFlight()` which calls `PUT /api/annotations/settings/user`
|
||||
inside `FlightContext`.
|
||||
|
||||
## Security
|
||||
|
||||
- `hasPermission(perm)` is **client-side advisory** — a user with the
|
||||
`/admin` route URL can navigate there without going through the nav
|
||||
link. The backend `admin/` service is the authority. Document in
|
||||
`security_approach.md` (Step 6).
|
||||
- The flight-selector dropdown displays every flight returned by
|
||||
`GET /api/flights?pageSize=1000` — there is no permission check here.
|
||||
If `flights/` ever returns flights the user should not see, this
|
||||
component would leak them. Trust the backend filter.
|
||||
- `user?.email` is rendered raw in the header — React JSX-escapes it,
|
||||
so XSS via a malicious email is not possible at this layer, but
|
||||
validate that the `admin/` service enforces email format.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- **Outside-click listener is always attached** — slightly wasteful when
|
||||
the dropdown is closed. Gating on `showDropdown` would also remove a
|
||||
closure capture. Defer to Step 8.
|
||||
- **Dropdown is not keyboard-accessible**: no `role="combobox"`, no
|
||||
`aria-expanded`, no Esc-to-close, focus does not move into the filter
|
||||
input on open beyond `autoFocus` (which works only the first time
|
||||
the input mounts). Flag against `_docs/ui_design/README.md` keyboard
|
||||
shortcuts for Step 4.
|
||||
- **Mobile bottom nav duplicates `navItems`** twice in source — DRY win
|
||||
by extracting a `<NavSet items={...}/>` subcomponent, but a deferral
|
||||
candidate.
|
||||
- **Hardcoded English strings** ("AZAION", "— Select Flight —",
|
||||
"Filter...", "No flights"). Brand mark is intentional; the others
|
||||
are content and should move to `nav.*` keys. Step 4 candidate.
|
||||
- **`/settings` link** is unguarded by `hasPermission`. Per
|
||||
`_docs/ui_design/README.md` settings page is available to every
|
||||
authenticated user — confirmed intent.
|
||||
- **`new Date(f.createdDate).toLocaleDateString()`** uses the browser's
|
||||
default locale, not `i18n.language`. Mild inconsistency; Step 8
|
||||
cosmetic.
|
||||
- **`flights` cache truncation**: the dropdown shows at most 1000
|
||||
flights because of the hardcoded `pageSize=1000` in `FlightContext`.
|
||||
Documented as a flag there; Header inherits the limitation.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Module: `src/components/HelpModal.tsx`
|
||||
|
||||
> **Source**: `src/components/HelpModal.tsx` (62 lines)
|
||||
> **Topo batch**: B2 (uses `react-i18next` for the language flag only; no intra-repo deps)
|
||||
|
||||
## Purpose
|
||||
|
||||
A modal dialog that surfaces annotation guidelines and the keyboard-shortcut cheat-sheet. Triggered from `Header.tsx` → "Help" entry. Replaces the legacy WPF `HelpWindow.xaml` (`_docs/legacy/wpf-era.md` §4).
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
export default function HelpModal(props: Props): JSX.Element | null
|
||||
```
|
||||
|
||||
When `open === false`, returns `null`. Otherwise renders a centered `<div>` overlay.
|
||||
|
||||
## Internal logic
|
||||
|
||||
- Reads `i18n.language` from `useTranslation()`; treats anything other than `'ua'` as English (`lang = i18n.language === 'ua' ? 'ua' : 'en'`).
|
||||
- `GUIDELINES`: a hardcoded array of 6 `{ en, ua }` rules covering annotation quality. **Hardcoded — not loaded via the `i18n` resource bundle**, despite the module already importing `useTranslation`. (Inconsistency vs. the rest of the SPA — flag for Step 4.)
|
||||
- Renders the guidelines as a numbered list and a 12-row keyboard-shortcut grid (`Space`, `← →`, `Ctrl + ← →`, `Enter`, …).
|
||||
- Click on the dim backdrop closes the modal; click inside is `stopPropagation`'d.
|
||||
- `Close` button is a styled `<button>` calling `onClose()`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**: none.
|
||||
- **External**: `react-i18next` (for `useTranslation()` only — to detect the language; *not* for content lookup).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
- `src/components/Header.tsx` — owns `open` state and the trigger button.
|
||||
|
||||
## Data models
|
||||
|
||||
The `GUIDELINES` array — module-private. Each entry: `{ en: string, ua: string }`.
|
||||
|
||||
## Configuration
|
||||
|
||||
The keyboard-shortcut grid hardcodes the same shortcut taxonomy that `_docs/ui_design/README.md` §"Keyboard Shortcuts" enumerates. Verify parity during Step 4.
|
||||
|
||||
## External integrations
|
||||
|
||||
None.
|
||||
|
||||
## Security
|
||||
|
||||
None directly. Note that ESCAPE-key handling appears in the *sibling* `ConfirmDialog` but **not** here — Esc currently does NOT close `HelpModal`. Backdrop click is the only dismiss path beyond the Close button. Inconsistent UX; flag for Step 4 verification against the UI spec.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- The 6 guidelines stored as `{ en, ua }` literals duplicate the Annotation Quality Guidelines documented in `_docs/ui_design/README.md` §"Annotation Quality Guidelines" (which lists six rules with subtly different wording). Choose one source of truth in Step 5; preferred is to lift the strings into `en.json` / `ua.json` like the rest of the SPA.
|
||||
- The `<h2>` "How to Annotate" heading is hardcoded English; should also be translated.
|
||||
- The `lang === 'ua' ? 'ua' : 'en'` branch silently treats every non-Ukrainian language as English — only correct as long as exactly two locales exist. If a third locale is added, this needs to use the `i18n` lookup table.
|
||||
- Width is hardcoded `w-[500px]` and `max-h-[80vh]`. Does not adapt to mobile breakpoints documented in `_docs/ui_design/README.md` §"Responsive Breakpoints" (640px). Flag.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Module: `src/features/admin/AdminPage.tsx`
|
||||
|
||||
> **Source**: `src/features/admin/AdminPage.tsx` (209 lines)
|
||||
> **Topo batch**: B4 (depends on B3: `api/client`, `components/ConfirmDialog`, `types/index`)
|
||||
|
||||
## Purpose
|
||||
|
||||
The administrator console of the SPA. Bundles four management surfaces
|
||||
into a single page: detection-classes catalogue (CRUD-lite),
|
||||
AI-recognition tuning form, GPS-device endpoint config, user
|
||||
list (CRUD-lite), and aircraft default-selector. Replaces the WPF
|
||||
`AdminWindow.xaml` cluster (`_docs/legacy/wpf-era.md` §§4, 5).
|
||||
|
||||
Behind the `/admin` route, gated by `Header`'s `ADM` permission
|
||||
filter (a route-level permission re-check is the responsibility of the
|
||||
`admin/` service — Header gating is UI-only; see Security below).
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
export default function AdminPage(): JSX.Element
|
||||
```
|
||||
|
||||
No props. Reads everything via `api/client` and local state.
|
||||
|
||||
## Internal logic
|
||||
|
||||
- **State**:
|
||||
- `classes: DetectionClass[]` — the detection-class table.
|
||||
- `aircrafts: Aircraft[]` — the aircraft list with `isDefault` flag.
|
||||
- `users: User[]` — the user table.
|
||||
- `newClass: { name; shortName; color; maxSizeM }` — staging buffer
|
||||
for the "add detection class" form (initial: `{ name: '',
|
||||
shortName: '', color: '#FF0000', maxSizeM: 7 }`).
|
||||
- `newUser: { name; email; password; role }` — staging buffer for
|
||||
"add user" (initial: `{ name: '', email: '', password: '', role:
|
||||
'Annotator' }`).
|
||||
- `deactivateId: string | null` — drives the `ConfirmDialog`'s open
|
||||
state for user deactivation.
|
||||
- **Bootstrap effect** (`useEffect([])` — runs once at mount):
|
||||
|
||||
```ts
|
||||
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
api.get<User[]>('/api/admin/users').then(setUsers).catch(() => {})
|
||||
```
|
||||
|
||||
Three independent calls, all silently swallowed on error. No retry,
|
||||
no error UI, no loading state — empty arrays render as empty
|
||||
tables. Flag for Step 4 against the user-feedback patterns in
|
||||
`_docs/ui_design/README.md`.
|
||||
- **`handleAddClass()`**:
|
||||
1. Guard: `if (!newClass.name) return`.
|
||||
2. `await api.post('/api/admin/classes', newClass)`.
|
||||
3. Refetch via `api.get('/api/annotations/classes')` — note the
|
||||
**read** path is the public `annotations/` endpoint, while the
|
||||
**write** path is the `admin/` endpoint. Architectural caveat:
|
||||
two different services own the same logical entity. Document in
|
||||
`architecture.md` §integration-points (Step 3a).
|
||||
4. Reset `newClass` to its initial values.
|
||||
No error path — a failed POST throws (because `client.ts` throws on
|
||||
non-2xx); the throw is uncaught and reaches React's error boundary
|
||||
(none configured). Flag.
|
||||
- **`handleDeleteClass(id)`**: optimistic local update —
|
||||
`await api.delete('/api/admin/classes/${id}')` then
|
||||
`setClasses(prev => prev.filter(c => c.id !== id))`. **No
|
||||
ConfirmDialog** despite this being destructive. Inconsistent with
|
||||
the user-deactivation flow which uses ConfirmDialog. Flag for Step 4
|
||||
against `_docs/ui_design/README.md` confirmation-dialog spec.
|
||||
- **`handleAddUser()`** — analogous to `handleAddClass` against
|
||||
`POST /api/admin/users` and `GET /api/admin/users`. Guards on
|
||||
`email && password`.
|
||||
- **`handleDeactivate()`** — fired from the ConfirmDialog confirm:
|
||||
1. `PATCH /api/admin/users/${deactivateId}` with `{ isActive: false }`.
|
||||
2. Optimistic local update: marks the row inactive.
|
||||
3. Closes the dialog (`setDeactivateId(null)`).
|
||||
No "reactivate" path — once `isActive: false`, the row only renders
|
||||
the badge and no Deactivate button. Verify with `admin/` service:
|
||||
is reactivation an admin task or out of scope?
|
||||
- **`handleToggleDefault(a)`** — `PATCH /api/flights/aircrafts/${a.id}`
|
||||
with `{ isDefault: !a.isDefault }`, then optimistic local flip. Note
|
||||
this allows multiple `isDefault: true` aircraft to coexist (the
|
||||
backend should enforce exclusivity; the UI does not).
|
||||
- **Layout** (left → center → right, all in one horizontal flex):
|
||||
- **Left column** (`w-[340px]`): detection-classes table + add row.
|
||||
- **Center column** (`flex-1 max-w-md`): AI settings form, GPS
|
||||
settings form, users table + add row. The AI and GPS forms have
|
||||
`defaultValue` only — there is **no** state, no `Save` handler
|
||||
wired up. The buttons render but do nothing. Flag.
|
||||
- **Right column** (`w-[280px]`): aircraft list with star toggle.
|
||||
- `<ConfirmDialog>` mounted at the end, controlled by `deactivateId`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**:
|
||||
- `../../api/client` — `api`.
|
||||
- `../../components/ConfirmDialog` — for user deactivation.
|
||||
- `../../types` — `DetectionClass`, `Aircraft`, `User`.
|
||||
- **External**: `react` (`useState`, `useEffect`),
|
||||
`react-i18next` (`useTranslation`).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
- `src/App.tsx` — mounted at the `/admin` route inside the protected
|
||||
tree.
|
||||
|
||||
## Data models
|
||||
|
||||
Stateful local copies of `DetectionClass[]`, `Aircraft[]`, `User[]`.
|
||||
The `newClass` and `newUser` buffers are anonymous shapes —
|
||||
intentionally narrower than `DetectionClass` / `User` because the
|
||||
backend assigns `id` and other server-managed fields.
|
||||
|
||||
## Configuration
|
||||
|
||||
- **i18n keys consumed**: `admin.classes`, `admin.aiSettings`,
|
||||
`admin.gpsSettings`, `admin.users`, `admin.aircrafts`,
|
||||
`admin.deactivate`, `common.save`. (Confirmed present in
|
||||
`src/i18n/en.json` admin/common groups.) Plenty of hardcoded
|
||||
English strings — placeholders ("Name", "Email", "Password"), table
|
||||
headers (`#`, `Name`, `Color`, `Email`, `Role`, `Status`), role
|
||||
options (`Annotator`, `Admin`, `Viewer`), the GPS protocol options
|
||||
(`TCP`, `UDP`), the AI tuning labels ("Frame Period Recognition",
|
||||
etc.), and the deactivation confirmation message. Step 4 candidate.
|
||||
- **Hardcoded defaults**:
|
||||
- `maxSizeM: 7` for new detection classes.
|
||||
- `color: '#FF0000'` for new detection classes.
|
||||
- `Frame Period Recognition: 5`, `Frame Recognition Seconds: 1`,
|
||||
`Probability Threshold: 0.5` (`step: 0.05`, `min: 0`, `max: 1`).
|
||||
- `Device Address: 192.168.1.100`, `Port: 5535`, default protocol
|
||||
`TCP`. **Hardcoded internal IP** is a smell; should come from
|
||||
`system_settings` or be a placeholder. Flag for Step 4 / Step 6
|
||||
(`security_approach.md`).
|
||||
- **Tailwind tokens**: `bg-az-panel`, `bg-az-bg`, `bg-az-orange`,
|
||||
`bg-az-blue/20`, `bg-az-green/20`, `text-az-{text,muted,red,green,
|
||||
blue,orange}`, `border-az-border`. Defined in `src/index.css`.
|
||||
|
||||
## External integrations
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/annotations/classes` | List detection classes (read path uses annotations service) |
|
||||
| `POST` | `/api/admin/classes` | Create detection class (write path uses admin service) |
|
||||
| `DELETE` | `/api/admin/classes/{id}` | Delete detection class |
|
||||
| `GET` | `/api/flights/aircrafts` | List aircraft |
|
||||
| `PATCH` | `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
|
||||
| `GET` | `/api/admin/users` | List users |
|
||||
| `POST` | `/api/admin/users` | Create user |
|
||||
| `PATCH` | `/api/admin/users/{id}` | Set `isActive: false` (deactivate) |
|
||||
|
||||
Routed by `nginx.conf` to `admin/`, `annotations/`, `flights/`
|
||||
backends.
|
||||
|
||||
## Security
|
||||
|
||||
- **`/admin` is access-controlled by Header's `ADM` permission filter
|
||||
AND by every backend endpoint** (each `/api/admin/*` is the
|
||||
authority). The page itself does not call `hasPermission` — a user
|
||||
who deep-links to `/admin` would render the page but every fetch
|
||||
would 401/403. Acceptable for production; flag a UX improvement
|
||||
(server-error → friendly redirect) for Step 8.
|
||||
- **Password input has `type="password"`** — masked. The new-user
|
||||
password is held in plaintext component state until the POST
|
||||
resolves. Acceptable; cleared by `setNewUser({...})` after success.
|
||||
- **`color: '#FF0000'`** is a hex string — when posted to
|
||||
`/api/admin/classes`, the backend must validate. UI-side validation
|
||||
(regex / `<input type="color">`) is enforced because the input is
|
||||
a color-picker.
|
||||
- **No CSRF** — relies on the bearer-token scheme. Same posture as the
|
||||
rest of the SPA (see `client.ts` Security section).
|
||||
- **No row-level access checks visible to the UI**: the user list
|
||||
shows every row the backend returns; if the `admin/` service ever
|
||||
filtered by tenant, the UI would not need changes.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- **Detection-class read/write split** between `annotations/` and
|
||||
`admin/` is unusual. Verify with the suite ADRs whether
|
||||
`/api/annotations/classes` and `/api/admin/classes` are the same
|
||||
underlying entity. If yes, the split is a legacy artifact; if no,
|
||||
the UI is conflating two collections. Step 3a / Step 4 verification.
|
||||
- **AI settings & GPS settings forms are wired to nothing** — every
|
||||
`<input>` uses `defaultValue` (uncontrolled), there is no submit
|
||||
handler, and the Save button does nothing. Either:
|
||||
- The legacy WPF AI/GPS settings have not been ported yet (most
|
||||
likely — `_docs/legacy/wpf-era.md` §"What is intentionally NOT
|
||||
being ported" might apply), OR
|
||||
- The backend endpoint exists but the wiring was lost during the
|
||||
port.
|
||||
Flag prominently in Step 4 problem-extraction. Probably a missing
|
||||
feature, not a bug.
|
||||
- **`handleDeleteClass` has no confirmation** — UX inconsistency with
|
||||
user deactivation. Consider unifying via `ConfirmDialog`. Step 4.
|
||||
- **Aircraft default-toggle race**: clicking two aircraft in quick
|
||||
succession will fire two parallel `PATCH`es. The backend may end up
|
||||
with both `isDefault: true` if it does not enforce exclusivity at
|
||||
the persistence layer. Flag for Step 6 problem-extraction. Trust
|
||||
the backend; do not add UI debouncing.
|
||||
- **The "Active" / "Inactive" badge text is hardcoded English** even
|
||||
though the surrounding column header (`Status`) is too. Either
|
||||
localize all of them or accept the inconsistency. Step 4.
|
||||
- **`u.isActive` as the only mutable user field** — no rename, no
|
||||
password reset, no role change. Document the gap; may be a feature
|
||||
cycle item for Phase B.
|
||||
- **`newClass.shortName` is collected but not displayed in the table**
|
||||
— the table only shows `id, name, color, ×`. The `shortName` lives
|
||||
only in the staging buffer and is sent to the backend. Verify the
|
||||
backend stores it.
|
||||
- **Hardcoded GPS device address `192.168.1.100`** is a non-routable
|
||||
RFC1918 default that should not ship with the production bundle.
|
||||
Step 4 flag.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Module group: `src/features/annotations/`
|
||||
|
||||
> Compact doc covering all 5 annotations modules (`classColors.ts` is a shared leaf — see existing `src__features__annotations__classColors.md`). The annotations feature is the **central legacy concern** of the codebase per `_docs/legacy/wpf-era.md §4` (`Azaion.Annotator` window) — what's documented here is the React port. For the canonical product spec see `_docs/ui_design/README.md` (Annotations Tab Layout, Annotation Quality Guidelines, Affiliation Icons, Combat Readiness, Annotation Row Gradient, Keyboard Shortcuts, Video Annotation Time-Window Display) and parent suite `../../../../_docs/01_annotations.md` for the API contract.
|
||||
|
||||
## Scope
|
||||
|
||||
Owns the `/annotations` route. Lets the user:
|
||||
1. Browse media (video / image) for the currently selected flight, with a 300-ms debounced name filter, drag-and-drop / file-picker / folder-picker upload, and right-click delete.
|
||||
2. Play / pause / step a video, scrub the timeline, mute, with frame stepping at 1 / 5 / 10 / 30 / 60 frames in both directions (assumed 30 FPS — see Findings).
|
||||
3. Draw / move / resize / multi-select bounding boxes on a custom canvas overlay, click-and-drag with Ctrl-modifier behaviours, snap to image-normalized coordinates 0–1.
|
||||
4. Pick the active detection class (1–9 hotkeys) and PhotoMode (Regular / Winter / Night) via the shared `DetectionClasses` component.
|
||||
5. Save the per-frame detection set back to `POST /api/annotations/annotations`, with **fall-back to in-memory storage** when the file is a local blob: URL or the backend is unreachable.
|
||||
6. Stream the annotations sidebar from the `GET /api/annotations/annotations/events` SSE feed; show each row with the gradient defined in `_docs/ui_design/README.md`.
|
||||
7. Trigger AI detection via `POST /api/detect/{mediaId}` (modal log overlay).
|
||||
8. Download an annotation as YOLO `.txt` + a PNG of the frame with rectangles burned in.
|
||||
|
||||
## Module map
|
||||
|
||||
| Module | Layer | Responsibility |
|
||||
|---|---|---|
|
||||
| `classColors.ts` | leaf | (already documented separately) Class-number → colour + photoMode-suffix lookup. |
|
||||
| `MediaList.tsx` | sub-component | Left panel media browser. Owns `media[]` state, debounced filter, dropzone upload, blob: local-mode fallback when backend POST fails. Calls `GET /api/annotations/media`, `DELETE /api/annotations/media/{id}`, `POST /api/annotations/media/batch`. |
|
||||
| `VideoPlayer.tsx` | sub-component | Native `<video>` wrapper. `forwardRef` exposes `seek(seconds)` and `getVideoElement()`. Custom progress slider + frame-step toolbar. Global `keydown` handler for Space / ←/→ (Ctrl=±150) / M. |
|
||||
| `AnnotationsSidebar.tsx` | sub-component | Right panel: SSE-driven annotation list (`/api/annotations/annotations/events` filtered by `mediaId`), AI detect button, gradient row background built from per-detection class colour + confidence-modulated alpha, download button (delegates to page). |
|
||||
| `CanvasEditor.tsx` | sub-component | Canvas overlay used in both image-only and video-overlay modes. `forwardRef` exposes `deleteSelected / deleteAll / hasSelection`. Owns zoom (Ctrl+wheel, 0.1–10×), pan (held in `pan` state — there is no Ctrl+drag pan code, only an unused `pan` state — see Findings), 8-handle resize, multi-select, draw-on-Ctrl-mousedown. Renders detections + a "time-window" preview of *other* annotations during video playback. |
|
||||
| `AnnotationsPage.tsx` | page | Orchestrator: 3-panel layout via `useResizablePanel` (left 250 / right 200, min/max bounds). Owns `selectedMedia`, `annotations`, `detections`, `selectedClassNum`, `photoMode`, `currentTime`. Wires `MediaList` → `CanvasEditor` ↔ `VideoPlayer` ↔ `AnnotationsSidebar`. Handles the `Save` POST + local-fallback. Builds the YOLO + PNG download. |
|
||||
|
||||
## Key contracts
|
||||
|
||||
- **`Detection`** (from `src/types/index.ts`): `{ id, classNum, label, confidence ∈ [0,1], affiliation: 0|1|2, combatReadiness, centerX, centerY, width, height }` — all coords normalized 0–1. Matches `_docs/legacy/wpf-era.md §10` and parent suite `_docs/01_annotations.md` Detection DTO.
|
||||
- **`AnnotationListItem`**: `{ id, mediaId, time: 'HH:MM:SS.mmm' | null, createdDate, source: AnnotationSource, status: AnnotationStatus, isSplit, splitTile, detections[] }`. Matches `Annotations` table in parent `_docs/00_database_schema.md` modulo client-side `isSplit / splitTile`.
|
||||
- **AI detect endpoint**: `POST /api/detect/{mediaId}` — matches parent `../../../../_docs/03_detections.md §2` after nginx strips `/api/detect/`. NOTE: UI does NOT forward the `X-Refresh-Token` header that the spec requires for long-running video detection (`_docs/03_detections.md §2 request headers`). Carried as a finding.
|
||||
- **Save body**: `{ mediaId, time: 'HH:MM:SS.mmm' | null, detections: Detection[] }`. .NET `TimeSpan.Parse` accepts that format so the round-trip works for `time → VideoTime`. **Body is missing required `Source` and optional `WaypointId`** required by parent spec `CreateAnnotationRequest` — see Findings.
|
||||
|
||||
## External integrations
|
||||
|
||||
| Endpoint / origin | Where | Direction | Notes |
|
||||
|---|---|---|---|
|
||||
| `GET /api/annotations/media?flightId&name&pageSize=1000` | `MediaList.fetchMedia` | egress | Hardcoded ceiling 1000. |
|
||||
| `GET /api/annotations/media/{id}/file` | `CanvasEditor`, `VideoPlayer`, `AnnotationsPage.handleDownload` | egress | Image / video bytes. |
|
||||
| `POST /api/annotations/media/batch` | `MediaList.uploadFiles` | egress | `multipart/form-data`. |
|
||||
| `DELETE /api/annotations/media/{id}` | `MediaList.handleDelete` | egress | Skipped for local blob: entries. |
|
||||
| `GET /api/annotations/annotations?mediaId&pageSize=1000` | `MediaList.handleSelect`, `AnnotationsSidebar`, `AnnotationsPage.handleSave` | egress | Refresh after save / SSE event. |
|
||||
| `POST /api/annotations/annotations` | `AnnotationsPage.handleSave` | egress | Body: `{ mediaId, time, detections }`. Falls back to in-memory on 4xx/5xx. |
|
||||
| `GET /api/annotations/annotations/{id}/image` | `CanvasEditor.loadImage` | egress | Per-annotation hashed image; preferred over the raw media for image annotations. |
|
||||
| `GET /api/annotations/annotations/events` (SSE) | `AnnotationsSidebar` | egress | Listens for `{ annotationId, mediaId, status }` events; filters client-side by `media.id`. |
|
||||
| `POST /api/detect/{mediaId}` | `AnnotationsSidebar.handleDetect` | egress | Triggers AI inference — endpoint shape unverified vs `_docs/03_detections.md`. |
|
||||
| `URL.createObjectURL(File)` | `MediaList.uploadFiles`, `AnnotationsPage.handleDownload` | browser API | Local-mode blob URLs are revoked on delete or unmount. |
|
||||
|
||||
## Findings carried into Step 4 / 6 / 8
|
||||
|
||||
1. **`VideoPlayer.stepFrames` hardcodes `fps = 30`** — frame stepping is wrong for any other fps (most drone footage is 25 / 30 / 60). UI spec says "Frame duration = 1 / video FPS" (`_docs/ui_design/README.md`). Should read from `video.getVideoPlaybackQuality()` / metadata. Step 4.
|
||||
2. **`VideoPlayer` Ctrl+Left/Right is documented in `_docs/ui_design/README.md` as "skip 5 seconds"** but here it does `±150 frames` (`= 5s @ 30fps` only). Same fps coupling as #1.
|
||||
3. **`VideoPlayer.error` state has no setter call beyond initial `null`/onLoadedMetadata reset** — but the `setError` call on `onError` only sets a string when source fails to load; spec says status messages should appear in a status bar (`_docs/ui_design/README.md`). The status-bar pattern is not implemented.
|
||||
4. **`CanvasEditor` Ctrl+drag pan is documented (`_docs/ui_design/README.md`)** but not implemented — `pan` state exists, only `setPan(...)` calls are inside the (no-op) zoom flow. The canvas always renders at `pan = {0,0}`. Step 4 / Step 6 problem extraction.
|
||||
5. **`CanvasEditor.useEffect[draw]` has missing deps** (`isVideo`, `imgSize` dependency tracked indirectly through other deps; `currentTime`/`annotations` are listed but `getTimeWindowDetections` is recreated each render and is closed-over). Specifically: `draw`'s closure reads `getTimeWindowDetections()` and the inner `getTimeWindowDetections` reads `media`, `currentTime`, `annotations` — those *are* in the dep list, but `media` itself is missing. Step 4 a11y / correctness.
|
||||
6. **`CanvasEditor` time-window threshold is `< 2_000_000` ticks** in `getTimeWindowDetections` — at 10 000 000 ticks/second this is **±200 ms (400 ms total window)**. Spec is **asymmetric 50 ms before + 150 ms after (200 ms total)** per `../../ui_design/README.md`. Implementation window is 4× too wide and centred on the wrong offset. Step 4.
|
||||
7. **`CanvasEditor` `ctx.fillStyle = color; ctx.globalAlpha = 0.1; ctx.fillRect(...)`** — the box "fill" is class colour at 10% opacity, but the **affiliation icon** (NATO MIL-STD-2525 shape) and **combat-readiness indicator** are missing. The current code only renders a tiny green dot for `combatReadiness === 1` (Ready). UI spec demands Friendly/Hostile/Unknown/None affiliation icons and Ready/NotReady/Unknown CR. Step 4 vs `_docs/ui_design/README.md` — significant gap.
|
||||
8. **`CanvasEditor.AFFILIATION_COLORS` constant is dead code** — defined but never used. Likely the seed of the missing affiliation rendering. Step 4.
|
||||
9. **Annotation row gradient cap is wrong by ~9 percentage points**: `AnnotationsSidebar.getRowGradient` uses `Math.round(alpha * 40).toString(16)` — `40` is **decimal**, not hex, so the maximum alpha byte is `0x28` (decimal 40 ≈ **16% opacity**). Spec wireframe `../../ui_design/annotations.html` uses `rgba(...,0.25)` = `0x40` hex (25%). Almost certainly a typo: should be `* 64` (decimal) or `* 0x40` to match the wireframe. Step 4.
|
||||
10. **`AnnotationsSidebar` AI-detect modal**: `setDetectLog` writes "Detection complete" immediately after `await api.post(...)` returns — there is no progress streaming despite the spec ("scrolling log of detection progress" via `DetectionEvent` SSE per `_docs/03_detections.md`). The Detection SSE feed is not subscribed. Step 6.
|
||||
11. **`AnnotationsSidebar` SSE refresh** is fire-and-forget (`.catch(() => {})`) — silent error suppression, violates `coderule.mdc`. Step 4.
|
||||
12. **`AnnotationsPage.formatTicks(seconds)`** produces `HH:MM:SS.mmm` (string). Parent suite `_docs/01_annotations.md` carries `TimeSpan VideoTime` — .NET `TimeSpan.Parse` accepts that format, so the round-trip works, but the API contract is `TimeSpan` ticks, not a string. Verify in Step 4.
|
||||
13. **`AnnotationsPage.handleDownload` never sets `crossOrigin` on the video element** (only on the standalone image fallback) — Canvas `toBlob` will throw "tainted canvas" if the video served from `/api/annotations/media/.../file` doesn't include `Access-Control-Allow-Origin`. Same-origin via the dev proxy and nginx is fine, but cross-origin (CDN / direct) breaks download. Step 4.
|
||||
14. **`AnnotationsPage.handleSelect` annotation flow** does NOT expose `Validate` (V key per UI spec). The Annotations tab shouldn't validate (that's Dataset Explorer), but UI spec keyboard shortcuts list V on Annotations tab too — confirm scope in Step 6.
|
||||
15. **No `R` key for AI detect** in any module here despite UI spec (`R = Trigger AI detection`). The AI Detect button is only reachable by mouse. Step 4.
|
||||
16. **No PageUp / PageDown for prev/next media file** despite UI spec.
|
||||
17. **No Camera config side panel** (altitude / FocalLength / SensorWidth) per `_docs/ui_design/README.md` — completely missing. Documented in Findings of `AdminPage.tsx` too: aircraft camera defaults are global, but per-session override UI is not built. Step 6.
|
||||
18. **`MediaList` uses `alert(...)` for "Unsupported file type"** — not the project modal/toast pattern. Step 4.
|
||||
19. **`MediaList` blob: local-mode is a graceful degradation** that lets the page work without backend — useful for demos but the user has no indication that "you're working offline, save will be lost on reload". Document explicitly in Step 6.
|
||||
20. **`MediaList.fetchMedia` always merges blob: locals on top of backend results** even when filtering — local entries ignore the name filter. Step 4.
|
||||
21. **`AnnotationsSidebar` `try { ... } catch (e: any)`** — `any` cast bypasses TS strict; `e.message` may be `undefined`. Step 4.
|
||||
22. **`AnnotationsPage` left/right panels resize but widths NOT persisted** to `UserSettings` — UI spec says they should be restored per-user across sessions. The `useResizablePanel` hook only owns runtime state. Step 6 / Step 8.
|
||||
23. **`CanvasEditor.handleMouseDown` Ctrl-modifier semantics**: Ctrl+click on a box should multi-select (per spec). Code does this. Ctrl+click on empty space should NOT start a draw — but the current branch starts `dragState = 'draw'` either way, then differentiates inside `handleMouseUp` by drawRect size. Slight UX cost only. Step 4.
|
||||
24. **Tile / split-image annotation rendering**: spec mentions "Tile zoom" — auto-zoom to a tile region when opening a split-image detection. `AnnotationListItem.splitTile` exists but no consumer code reads it. Step 6 problem extraction.
|
||||
25. **`AnnotationsPage.handleSave` 4xx/5xx fallback creates an in-memory `local-${uuid}` annotation** that looks identical to a saved one. The user can't distinguish the two — risk of data loss on reload. Step 6 problem extraction.
|
||||
26. **`AnnotationsPage` does not subscribe to the inference detect SSE** — no AI progress visualization, no update on detect completion, no error if inference returns 5xx. Step 6.
|
||||
27. **[STEP 4 — UI FIX, spec is canonical, user 2026-05-10]** `AnnotationStatus` enum diverges from spec. UI declares `Created=0, Edited=1, Validated=2` (`src/types/index.ts:23`). Spec (`../../../../_docs/09_dataset_explorer.md`) is `None=0, Created=10, Edited=20, Validated=30, Deleted=40`. Action: change `src/types/index.ts` to the spec values. Cascades through every status filter, every PATCH/POST that sends a status, every render. **PRIORITY** — without this fix every dataset status filter and every PATCH `/dataset/{id}/status` from the UI sends a wrong integer.
|
||||
28. **[STEP 4 — UI FIX, spec is canonical, user 2026-05-10]** `Affiliation` enum diverges from spec. UI has 3 values `Unknown=0, Friendly=1, Hostile=2`. Spec (`../../ui_design/README.md` Affiliation Icons + parent `../../../../_docs/03_detections.md`) requires four values `None, Friendly, Hostile, Unknown`. Action: change `src/types/index.ts` to the four-value set; integer values to be confirmed once with the .NET service before patching (likely `None=0, Friendly=1, Hostile=2, Unknown=3`). Without this fix the UI cannot send/render the **None** (no-icon) case.
|
||||
29. **[STEP 4 — UI FIX, spec is canonical, user 2026-05-10]** `CombatReadiness` enum diverges from spec. UI has `NotReady=0, Ready=1`. Spec adds `Unknown` (no indicator rendered). Action: add `Unknown` to `src/types/index.ts`; confirm integer values with the .NET service.
|
||||
30. **[STEP 4 — UI FIX, spec is canonical, user 2026-05-10]** `MediaStatus` enum diverges from spec. UI has `New=0, AiProcessing=1, AiProcessed=2, ManualCreated=3`. Spec (`../../../../_docs/01_annotations.md` SSE + `_docs/03_detections.md §2`) is `None, New, AIProcessing, AIProcessed, ManualCreated, Confirmed, Error`. Action: change `src/types/index.ts` to the seven-value set; confirm integer values with the .NET service. Without this fix the UI cannot render the **Error** SSE event when inference fails.
|
||||
31. **[RESOLVED 2026-05-10 — PARENT-DOC FIX APPLIED]** `AnnotationSource` numeric serialization is canonical; UI is correct (`AI=0, Manual=1`). Parent `../../../../_docs/01_annotations.md` §5 response example and AnnotationSource enum table updated to integer values; `../../../../_docs/09_dataset_explorer.md §1`, §2, §4 examples likewise. Both files also got a "wire format is numeric" header note. No UI change needed.
|
||||
32. **[STEP 4 — UI FIX, user 2026-05-10]** `AnnotationsPage.handleSave` must add `Source` and `WaypointId` to the request body and rename `time` → `videoTime`. Required body shape per parent `../../../../_docs/01_annotations.md §1` `CreateAnnotationRequest`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mediaId": "<string>",
|
||||
"waypointId": "<guid | null>",
|
||||
"source": 1,
|
||||
"videoTime": "HH:MM:SS.mmm",
|
||||
"detections": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
Notes: (a) `userId` is supplied server-side from JWT — do not send. (b) `image` is omitted in the save flow; the server reuses the media's frame at `videoTime`. (c) `source` is `Manual` (= `1`) for hand-edited save; AI inference posts use `AI` (= `0`) and are sent server-to-server by the Detections service. (d) `waypointId` must come from `Media.waypointId` (already typed in `src/types/index.ts:16`); the UI currently does not thread waypoint association through to save. (e) Field name `time` must become `videoTime` (.NET PascalCase `VideoTime` → camelCase on the wire).
|
||||
33. **[RESOLVED 2026-05-10 — PARENT-DOC FIX APPLIED]** `DatasetItem.isSplit` is correct on the UI side; parent spec response now includes it. `../../../../_docs/09_dataset_explorer.md §1` `items[]` shape updated with `isSplit: boolean` plus a description tying it to `_docs/03_detections.md §4` Tile-Based Detection. No UI change needed.
|
||||
34. **[STEP 4 — UI FIX, user 2026-05-10]** `Detect` endpoint needs conditional `X-Refresh-Token` header. Spec `../../../../_docs/03_detections.md §2` lists it as required for long-running video detection. Access tokens expire in 3600 s per `../../../../_docs/10_auth.md §1`, so any video that takes >1 h to process loses auth on the server-to-server `POST /annotations` call. Action: when `media.mediaType === MediaType.Video`, attach `X-Refresh-Token: <localStorage refreshToken>` to the `POST /api/detect/{mediaId}` request. Caveat: `/auth/refresh` rotates the refresh token (per `_docs/10_auth.md §2`), so the value can go stale if the UI also refreshes its own token mid-flight. Step 4 must decide whether to (i) cache the refresh token at detect-start, (ii) use a long-lived service token, or (iii) accept the failure mode and surface it to the user. For images and short videos the header is unnecessary — but the UI cannot tell upfront, so default to "always send for video".
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Cross-doc references
|
||||
|
||||
- Parent suite Annotations API: `../../../../_docs/01_annotations.md`
|
||||
- Parent suite Detections (AI inference): `../../../../_docs/03_detections.md`
|
||||
- Parent suite Database schema: `../../../../_docs/00_database_schema.md`
|
||||
- Legacy WPF reference: `../../legacy/wpf-era.md` §4 (Annotator window) and §10 (what survived).
|
||||
- UI spec: `../../ui_design/README.md` (Annotations Tab Layout + keyboard shortcuts + time-window + annotation row gradient + affiliation icons + combat readiness).
|
||||
- Shared component used here: `src/components/DetectionClasses.tsx` (already documented).
|
||||
@@ -0,0 +1,73 @@
|
||||
# Module: `src/features/annotations/classColors.ts`
|
||||
|
||||
> **Source**: `src/features/annotations/classColors.ts` (24 lines)
|
||||
> **Topo batch**: B1 (leaf — no internal imports)
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure-function fallbacks for detection-class color and display name. Used when the live `DetectionClass[]` from the admin API hasn't been loaded (initial render) or doesn't include a class for a given `Detection.classNum`.
|
||||
|
||||
Also exposes the **PhotoMode-aware suffix** logic that mirrors the WPF-era `yoloId = classId + photoModeOffset` convention (see `_docs/legacy/wpf-era.md` §10): class numbers in `[0, 19]` are "Regular", `[20, 39]` are "Winter", `[40, 59]` are "Night".
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
export const FALLBACK_CLASS_NAMES: string[] // 12 generic English labels
|
||||
export function getClassColor(classNum: number): string // hex, no '#' alpha
|
||||
export function getPhotoModeSuffix(classNum: number): string // '' | ' (winter)' | ' (night)'
|
||||
export function getClassNameFallback(classNum: number): string // FALLBACK_CLASS_NAMES[base] or '#<n>'
|
||||
```
|
||||
|
||||
A 12-color palette `CLASS_COLORS` is defined module-private and referenced through `getClassColor`.
|
||||
|
||||
## Internal logic
|
||||
|
||||
```
|
||||
base = classNum % 20
|
||||
mode = floor(classNum / 20)
|
||||
color = CLASS_COLORS[base % CLASS_COLORS.length] // wraps if base >= 12
|
||||
name = FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length]
|
||||
?? `#${classNum}`
|
||||
suffix = mode === 1 ? ' (winter)' : mode === 2 ? ' (night)' : ''
|
||||
```
|
||||
|
||||
The `??` guard exists for the unreachable case `base = 12..19` against an array of length 12 — except `base % length` brings it back into range first, so `?? '#<n>'` is dead. **Flag for Step 4 verification** — either the array is wrong or the `??` is dead code.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**: none.
|
||||
- **External**: none.
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
- `src/features/annotations/CanvasEditor.tsx` — labels + crosshair color.
|
||||
- `src/features/annotations/AnnotationsPage.tsx` — `getClassColor` for selected class indicator.
|
||||
- `src/features/annotations/AnnotationsSidebar.tsx` — gradient stops in the annotation row.
|
||||
- `src/features/annotations/MediaList.tsx` — *not currently a consumer* despite being adjacent (no import seen).
|
||||
- `src/components/DetectionClasses.tsx` — fallback name + color when admin classes haven't loaded. **This is the cross-layer edge** flagged in `00_discovery.md` §8: a `components/` (shared-layer) file imports from a feature.
|
||||
|
||||
## Data models
|
||||
|
||||
`CLASS_COLORS: string[]` (12 hex strings) and `FALLBACK_CLASS_NAMES: string[]` (12 English strings). Neither is wire-coupled — pure UI defaults.
|
||||
|
||||
## Configuration
|
||||
|
||||
None.
|
||||
|
||||
## External integrations
|
||||
|
||||
None.
|
||||
|
||||
## Security
|
||||
|
||||
None.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- The "+20 winter / +40 night" PhotoMode convention is a direct port of the legacy WPF DetectionClasses contract (`_docs/legacy/wpf-era.md` §10). Verify against the current admin API DTO during component assembly (Step 2) — if the API exposes `photoMode` as a separate field on `DetectionClass` (it does, per `src/types/index.ts`), then computing the suffix from `classNum / 20` here is **redundant** and risks disagreeing with the admin-defined value. Candidate Step 4 testability fix: remove `getPhotoModeSuffix` once consumers have access to the typed `photoMode`.
|
||||
- `FALLBACK_CLASS_NAMES` lists generic English labels (Car, Person, Truck, …) that bear no relation to the actual military classes the legacy doc enumerates (Military Vehicle, Truck, Car, Artillery, Active Mine — see `_docs/ui_design/README.md` §"Detection Classes Table"). Acceptable for a *fallback* only ever shown if the admin classes failed to load; document this in Step 5 (Solution Extraction).
|
||||
- Cross-layer import surfaced in `00_discovery.md` §8 — proper home for these helpers is either `src/components/detection/` (with `DetectionClasses.tsx`) or a new `src/shared/` namespace. Decision deferred to Step 2.5 module-layout derivation.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Module: `src/features/dataset/DatasetPage.tsx`
|
||||
|
||||
> Compact module doc. Spec: `_docs/ui_design/README.md` (Dataset Explorer Layout) and parent suite `../../../../_docs/09_dataset_explorer.md` (API contract). Imports `CanvasEditor` from `../annotations/` — see cross-feature edge in `00_discovery.md §8`.
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the `/dataset` route. Read-side surface for the `Annotations` table: paginated thumbnail grid, date / class / status / objects-only / name filters, inline editing through the Annotations Tab's `CanvasEditor`, and a class-distribution chart. Mirrors the legacy `Azaion.Dataset.DatasetExplorer` window (`_docs/legacy/wpf-era.md §5`).
|
||||
|
||||
## Public interface
|
||||
|
||||
Default-exported page component, no props. Mounts under `/dataset` in `App.tsx`.
|
||||
|
||||
## Internal logic
|
||||
|
||||
- **Tabs**: `'annotations'` (grid, default) | `'editor'` (hidden until a thumbnail is double-clicked) | `'distribution'` (bar chart). State held in component.
|
||||
- **Filters**: `fromDate`, `toDate`, `statusFilter` (`AnnotationStatus`), `selectedClassNum` (from `DetectionClasses`), `objectsOnly` (boolean), `search` (400 ms debounced via `useDebounce`).
|
||||
- **Pagination**: client `page` state, server `pageSize` fixed at 20, `totalPages = ceil(totalCount / pageSize)`.
|
||||
- **Selection**: `Set<annotationId>`. Plain click replaces; Ctrl+click toggles. Validate button appears when set is non-empty.
|
||||
- **Validate**: `POST /api/annotations/dataset/bulk-status` with `{ annotationIds[], status: Validated }`.
|
||||
- **Distribution**: lazy-loaded on tab switch via `GET /api/annotations/dataset/class-distribution`.
|
||||
- **Editor tab** mounts `<CanvasEditor>` with a synthesised `Media` stub built from `editorAnnotation.mediaId` (most fields blank). Used so `CanvasEditor` can fetch the image via `/api/annotations/annotations/{id}/image`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Internal: `api/client`, `useDebounce`, `useResizablePanel` (left panel 250 / 200–400), `FlightContext`, `DetectionClasses`, `ConfirmDialog` (imported but not used here — see Findings), `features/annotations/CanvasEditor`, `types/index`.
|
||||
- External: `react`, `react-i18next`.
|
||||
|
||||
## External integrations
|
||||
|
||||
| Endpoint | Where | Notes |
|
||||
|---|---|---|
|
||||
| `GET /api/annotations/dataset?...` | `fetchItems` | Filters: `page`, `pageSize`, `fromDate`, `toDate`, `flightId`, `status`, `classNum`, `hasDetections`, `name`. |
|
||||
| `GET /api/annotations/dataset/{id}` | `handleDoubleClick` | Loads full `AnnotationListItem` for the editor tab. |
|
||||
| `POST /api/annotations/dataset/bulk-status` | `handleValidate` | Body: `{ annotationIds, status }`. |
|
||||
| `GET /api/annotations/dataset/class-distribution` | `loadDistribution` | Class histogram. |
|
||||
| `GET /api/annotations/annotations/{id}/thumbnail` | grid tile `<img src=>` | One HTTP per visible tile. |
|
||||
| `GET /api/annotations/annotations/{id}/image` | `CanvasEditor` (delegated) | When the editor is open. |
|
||||
|
||||
Spec contract is in parent suite `_docs/09_dataset_explorer.md`.
|
||||
|
||||
## Findings carried into Step 4 / 6 / 8
|
||||
|
||||
1. **No keyboard shortcuts implemented**: spec demands `1–9` (class), Enter (save), Del (delete), X (delete all), Esc (close editor), V (validate selected), Up/Down/PageUp/PageDown (navigate). Only Ctrl+click for multi-select works. The editor tab has no Save / Delete buttons either. Step 6 problem extraction.
|
||||
2. **`Refresh thumbnails` button is missing** — spec calls for a status-bar action to regenerate the thumbnail database with progress. Step 6.
|
||||
3. **No virtualisation in the grid**: spec says "virtualized grid" — current code renders all `items` returned by the page (max 20 due to `pageSize`). For larger pages performance would degrade. Today not a problem because `pageSize=20`. Step 8.
|
||||
4. **Editor tab does not Save**: opening an annotation in the editor lets the user edit `editorDetections` locally but there's no Save / Cancel control wired up. Edits are discarded when the user changes tab. Step 6.
|
||||
5. **`editorMedia` synthetic stub** — `mediaType: 1` is a magic number (`MediaType.Image = 1`); should reuse the enum import already present. Step 4.
|
||||
6. **`ConfirmDialog` imported but never used** — dead import. Step 4 cleanup.
|
||||
7. **`fetchItems` does `catch {}`** silently — same `coderule.mdc` violation as elsewhere. Step 4.
|
||||
8. **Empty-state**: when `items.length === 0` the grid renders nothing; no "No matches" message. Step 4.
|
||||
9. **`thumbnail` URL does not include a cache-buster**; if a thumbnail is regenerated server-side, the browser will keep the stale image. Step 4 / Step 8.
|
||||
10. **`isSeed` red-ring is correctly rendered**, but `isSeed` is not in `AnnotationStatus` filters — the user can't filter to seeds-only. Step 6 if intended.
|
||||
11. **Date inputs accept any `<input type="date">` string**; no validation that `fromDate ≤ toDate`. Step 4.
|
||||
12. **Status filter buttons skip status `None`** (spec lists None as one of the four buttons) — current code shows `All`, `Created`, `Edited`, `Validated`, conflating `All` with "include None". Step 4.
|
||||
13. **`selectedClassNum=0` is the "no filter" sentinel** but also a real class number (class 0 exists per `_docs/ui_design/README.md` → military vehicle). Selecting class 0 in `DetectionClasses` is indistinguishable from "no filter". Step 6.
|
||||
14. **Inherits one cross-cutting UI fix and one cross-repo parent-doc fix** (resolved with user 2026-05-10):
|
||||
- **[STEP 4 — UI FIX]** `AnnotationStatus` values must change from UI `0/1/2` to spec `0/10/20/30/40` (`src/types/index.ts:23`). Every status filter button in this page (`null`/`null`/`Created`/`Edited`/`Validated`) currently sends a wrong integer. The "All" button is value `null` (no filter) — keep, but also expose a real "None" filter using the new `AnnotationStatus.None=0`. **PRIORITY** for Step 4. See annotations doc finding #27.
|
||||
- **[RESOLVED 2026-05-10]** `DatasetItem.isSplit` is correct on UI; parent `../../../../_docs/09_dataset_explorer.md §1` response schema now includes it. See annotations doc finding #33.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Cross-doc references
|
||||
|
||||
- Parent suite Dataset Explorer API: `../../../../_docs/09_dataset_explorer.md`
|
||||
- UI spec: `../../ui_design/README.md` (Dataset Explorer Layout, Keyboard Shortcuts).
|
||||
- Imports `CanvasEditor` from `features/annotations/` — see `00_discovery.md §8` cross-feature edge.
|
||||
- Legacy WPF reference: `../../legacy/wpf-era.md §5` (Dataset Explorer window).
|
||||
@@ -0,0 +1,106 @@
|
||||
# Module group: `src/features/flights/`
|
||||
|
||||
> **Note**: this is a deliberately compact doc covering all 15 flights modules. Behaviour is mostly an in-progress port of `mission-planner/` into the React 19 SPA. For the canonical product spec see `_docs/ui_design/README.md` (Flights Page Layout) and `../../../_docs/02_flights.md` / `11_gps_denied.md` in the parent suite repo (Flights API contract + GPS-Denied semantics).
|
||||
|
||||
## Scope
|
||||
|
||||
Owns the `/flights` route. Lets the user:
|
||||
1. Browse / create / delete `Flight` rows (`POST/DELETE /api/flights/...`).
|
||||
2. Plan a mission on a Leaflet map: add waypoints, draw work-area / no-go rectangles, edit altitude + purpose per point, see live total distance, time, battery %.
|
||||
3. Toggle into GPS-Denied mode — opens an SSE stream `/api/flights/{id}/live-gps` (and the panel for orthophoto upload + correction once that backend wiring lands; today only the live-GPS readout is connected).
|
||||
4. Save waypoints back to the Flights API (`/api/flights/{id}/waypoints`).
|
||||
5. Import / export the plan as JSON.
|
||||
|
||||
Currently handles only the planning surface; the gps-denied orthophoto upload / correction inputs in `_docs/ui_design/flights.html` are not yet implemented.
|
||||
|
||||
## Module map
|
||||
|
||||
| Module | Layer | Responsibility |
|
||||
|---|---|---|
|
||||
| `types.ts` | leaf | All flight-feature-only types (`FlightPoint`, `CalculatedPointInfo`, `MapRectangle`, `WindParams`, `AircraftParams`, `MovingPointInfo`, `ActionMode`), plus tile URLs (`TILE_URLS`), `PURPOSES` (`tank` / `artillery`), and `COORDINATE_PRECISION = 8`. |
|
||||
| `mapIcons.ts` | leaf | Three coloured Leaflet `Icon` instances + the default Leaflet pin (loaded from a CDN — see Findings). |
|
||||
| `flightPlanUtils.ts` | leaf | Pure-ish helpers: `newGuid`, haversine `calculateDistance` (with plane climb/cruise/descend profile), OpenWeatherMap fetch, semi-empirical `calculateBatteryPercentUsed`, `calculateAllPoints` (sequential reduce), `parseCoordinates`, `getMockAircraftParams`. |
|
||||
| `WaypointList.tsx` | sub-component | `@hello-pangea/dnd` reorderable list, hover-only Edit/Remove buttons, shows distance / time / battery / altitude per point. |
|
||||
| `AltitudeChart.tsx` | sub-component | `react-chartjs-2` Line chart of altitude over normalized distance; pulls all controllers via `chart.js/auto`. |
|
||||
| `WindEffect.tsx` | sub-component | Two number inputs (heading 0–360°, speed 0–30 m/s) with a small SVG arrow preview. |
|
||||
| `MiniMap.tsx` | sub-component | 240×180 `react-leaflet` thumbnail anchored to a moving point; `attributionControl={false}`. |
|
||||
| `AltitudeDialog.tsx` | sub-component | Add / Edit waypoint modal: lat / lng / altitude / `meta: string[]` purpose multi-select. Fully controlled. |
|
||||
| `MapPoint.tsx` | sub-component | One waypoint marker: draggable, popup with altitude slider, purpose checkboxes, remove button. |
|
||||
| `DrawControl.tsx` | sub-component | Headless Leaflet handler that draws work-area / prohibited-area rectangles via `mousedown / mousemove / mouseup`. |
|
||||
| `FlightListSidebar.tsx` | sub-component | Left rail: flight list, "+ Create", inline-create row, telemetry date stub. |
|
||||
| `JsonEditorDialog.tsx` | sub-component | Modal `<textarea>` over the plan JSON with live `JSON.parse` validation. |
|
||||
| `FlightParamsPanel.tsx` | composite | Hosts `WaypointList` + `AltitudeChart` + `WindEffect` + all per-flight inputs (aircraft, initial altitude, FoV, comm address, action-mode buttons, totals strip, Save / Upload / EditAsJSON / Export). |
|
||||
| `FlightMap.tsx` | composite | Wraps `MapContainer`; mounts `MapPoint` × N, `DrawControl`, `MiniMap` (when a point is moving), polyline + arrow decorator, satellite/classic toggle. |
|
||||
| `FlightsPage.tsx` | page | Orchestrator: owns all state, talks to `api/client`, opens the SSE stream, mediates between sidebar / params panel / map / dialogs. |
|
||||
|
||||
## Key contracts (read by other docs)
|
||||
|
||||
- **`FlightPoint`**: `{ id: string; position: { lat; lng }; altitude: number; meta: string[] }`. `meta` ⊆ `{ 'tank', 'artillery' }`. The shape diverges from the legacy WPF `Point` (radio, single purpose) — the React SPA uses checkboxes (multi).
|
||||
- **`CalculatedPointInfo`**: `{ bat: number /* % */; time: number /* hours */ }`. Index `i` = state at point `i` after the segment from `i-1`. `lastInfo.bat` drives the Good / Caution / Low colour status (`>12 / >5 / ≤5`).
|
||||
- **`PURPOSES = [{ value: 'tank', label: 'options.tank' }, { value: 'artillery', label: 'options.artillery' }]`** — i18n keys are `flights.planner.${label}`.
|
||||
- **JSON plan shape** (`handleEditJson` / `handleExport` / `handleJsonSave`): `{ operational_height: { currentAltitude }, geofences: { polygons: [{ northWest, southEast, fence_type: 'EXCLUSION'|'INCLUSION' }] }, action_points: [{ point: { lat, lon }, height, action: 'search', action_specific: { targets: string[] } }] }`. Used for both export-to-file and the JSON editor.
|
||||
- **Tile URLs**: classic OSM and an Esri ArcGIS `World_Imagery` (in `types.ts`). Both are direct upstream — neither goes through the suite `satellite-provider/` proxy. See Findings.
|
||||
|
||||
## External integrations
|
||||
|
||||
| Endpoint / origin | Where | Direction | Notes |
|
||||
|---|---|---|---|
|
||||
| `GET /api/flights/aircrafts` | `FlightsPage` | egress | Aircraft selector population. |
|
||||
| `GET /api/flights/{id}/waypoints` | `FlightsPage` | egress | On flight select. |
|
||||
| `POST /api/flights` | `FlightsPage` | egress | Create flight from sidebar. |
|
||||
| `DELETE /api/flights/{id}` | `FlightsPage` | egress | After ConfirmDialog. |
|
||||
| `POST/DELETE /api/flights/{id}/waypoints[/{wp}]` | `FlightsPage.handleSave` | egress | Delete-all-then-recreate, sequentially. |
|
||||
| `GET /api/flights/{id}/live-gps` (SSE) | `FlightsPage` | egress | Open while in GPS mode + flight selected. |
|
||||
| `https://api.openweathermap.org/...` | `flightPlanUtils.getWeatherData` | egress | Direct browser→3rd-party. **Hardcoded API key.** See Findings. |
|
||||
| `tile.openstreetmap.org` (`TILE_URLS.classic`) | `FlightMap`, `MiniMap` | egress | Direct, no proxy. |
|
||||
| `server.arcgisonline.com/.../World_Imagery` (`TILE_URLS.satellite`) | `FlightMap`, `MiniMap` | egress | Direct, no proxy. |
|
||||
| `unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png` | `mapIcons.defaultIcon` | egress | CDN, version pinned to 1.7.1 while package is 1.9.4 (drift). |
|
||||
| `navigator.geolocation.getCurrentPosition` | `FlightsPage` mount | browser API | Fallback to hardcoded `47.242, 35.024` (Zaporizhzhia). |
|
||||
|
||||
## Findings carried into Step 4 / 6 / 8
|
||||
|
||||
These are the real findings; the per-module rationale is in git history of the deleted per-file docs. Numbered for cross-reference from `state.json.notes`.
|
||||
|
||||
1. **HARDCODED OPENWEATHER API KEY** — `flightPlanUtils.ts:60`. HIGH severity. Step 4 source-code fix; upstream rotation is a parallel user task.
|
||||
2. **`flightPlanUtils.calculateAllPoints` does N sequential `await`s** to OpenWeatherMap — N × RTT latency. Not parallelisable as-is because `info[i].bat` depends on `info[i-1]`. Step 8 refactor: fetch weather first in parallel, then reduce.
|
||||
3. **`flightPlanUtils.calculateDistance` mixes km and metres** (`R = 6371`, altitudes converted `/1000` inline, returned `time = dist / aircraft.speed` assumes km/h). No type forces correctness. Step 4.
|
||||
4. **`AircraftParams.batteryCapacity` unit is ambiguous** (Wh vs W·s). `calculateBatteryPercentUsed` divides W·s by it × 100 — only correct if W·s. Verify against `mission-planner/src/services/calculateBatteryUsage.ts`. Step 4.
|
||||
5. **`flightPlanUtils.getWeatherData` swallows errors silently** (`catch { return null }`); callers can't distinguish "no wind" from "key revoked". Step 4.
|
||||
6. **`mapIcons.defaultIcon` CDN URL is leaflet@1.7.1** while `package.json` is 1.9.4. Step 4 — switch to bundled assets or match version.
|
||||
7. **`FlightMap` and `MiniMap` bypass the suite `satellite-provider/` proxy** (Esri tiles direct from `server.arcgisonline.com`). Possible licence + rate-limit concern. Step 4 + `architecture.md`.
|
||||
8. **`MiniMap` sets `attributionControl={false}`** — drops OSM / Esri attribution. Possible licence-compliance gap. Step 4.
|
||||
9. **`MiniMap` is fixed 240×180 + zoom 18 hardcoded** — overflows below the 640px mobile breakpoint. Step 4 vs `_docs/ui_design/README.md` responsive specs.
|
||||
10. **`AltitudeDialog` lacks Esc-to-close, backdrop-click-to-cancel, `role="dialog"`, `aria-modal`** — inconsistent with `ConfirmDialog`. Same for `JsonEditorDialog`. Pick one modal convention in Step 4.
|
||||
11. **`AltitudeDialog` accepts any number for lat/lng** (no `[-90,90]` / `[-180,180]` guard). `AltitudeDialog.altitude` and `WindEffect` inputs use `Number('')` → 0 silently — same pitfall noted in `SettingsPage`. Step 4.
|
||||
12. **`AltitudeDialog` purpose multi-select** vs WPF radio (single choice). Confirm intent in Step 6: is the SPA expanding the data model on purpose, or is this a UI bug?
|
||||
13. **`WindEffect` `max=360`** allows duplicate heading at 0 vs 360. Step 4.
|
||||
14. **`WaypointList` drag handle is the entire row** (prevents text selection); Edit/Remove buttons are hover-only (unusable on touch); no a11y reorder announcements. Step 4 / Step 8 a11y.
|
||||
15. **`WaypointList.calculatedPointInfo[i]`** silently degrades to alt-only label if length mismatches; tightly coupled to `FlightsPage` keeping arrays in lockstep.
|
||||
16. **`AltitudeChart` pulls every chart.js controller** via `chart.js/auto` (bundle bloat); colours are duplicated as hex literals (drift from the `az-*` Tailwind tokens). Step 8.
|
||||
17. **`AltitudeDialog` naming**: file is "AltitudeDialog" but the dialog covers lat / lng / altitude / purpose. Port-vestige from `mission-planner/`. Rename to `WaypointDialog` in Step 8.
|
||||
18. **`flightPlanUtils.ts` is single-file** — newGuid + geo + weather + battery + parser + mock all together. Splitting into `geo.ts` / `weather.ts` / `battery.ts` / `mockAircraft.ts` is a Step 8 SRP candidate.
|
||||
19. **`FlightsPage.handleSave`** deletes all existing waypoints then recreates — N+M sequential PUT/DELETE round-trips, not transactional, no progress UI, partial failure leaves the flight half-saved.
|
||||
20. **`FlightsPage.handleSave` body shape does NOT match the Flights API spec — UI will likely 400 on a strict server**. Code POSTs `{ name, latitude, longitude, order }`. Parent `../../../../_docs/02_flights.md §3` `CreateWaypointRequest` requires `{ Geopoint: {Lat, Lon, MGRS}, Source: WaypointSource, Objective: WaypointObjective, OrderNum, Height }`. Mismatches: (a) lat/lon not nested under `Geopoint`; (b) field is `order`, spec is `OrderNum`; (c) `Source`, `Objective`, `Height` not sent at all; (d) UI sends `name` which the spec does not define on `Waypoint` (`Waypoint` interface in `src/types/index.ts:76` invents `name`). This collides with finding #19 — every save will round-trip waypoints in the wrong shape. **PRIORITY** for Step 4. Open question for Step 6: are these columns being added to the Flights API schema, or is the React UI to be aligned to the spec? Same comment for `altitude` and `meta` which have no place in the current spec.
|
||||
21. **`FlightsPage.useEffect` bootstraps `getMockAircraftParams()`** as the active `aircraft` regardless of what the backend returns — the dropdown choice is cosmetic for now. Real wiring is a Step 6 / Step 8 follow-up.
|
||||
22. **GPS-Denied panel is partial** — only the SSE live-GPS readout is wired; orthophoto upload, GPS correction (per `_docs/ui_design/README.md`) are not. Step 6 problem extraction.
|
||||
23. **`handleImport` silently drops the file picker** if the user cancels (`if (!file) return`) — fine. But `handleJsonSave`'s catch uses `alert(...)` for a UX-grade error — replace with the project's modal/toast pattern in Step 4.
|
||||
24. **`MapPoint` popup recomputes the marker DOM offset on every drag move** to choose dx/dy for the moving-point indicator. Acceptable, but the `(marker as unknown as { _icon: HTMLElement })._icon` cast leaks Leaflet internals.
|
||||
25. **`DrawControl` registers global `mousedown`/`mousemove`/`mouseup` on the map** while a draw mode is active and disables `map.dragging` for the duration — fine, but no Esc-to-cancel mid-draw.
|
||||
26. **`FlightContext` ceiling**: `FlightsPage` reads `flights` from `useFlight()` which fetches with `pageSize=1000` (already flagged in `FlightContext` doc). Won't surface here, but `selectFlight` is fire-and-forget — if the PUT to `/api/flights/select` fails the next page reload reverts the choice without notice.
|
||||
|
||||
## What's intentionally NOT here
|
||||
|
||||
- The orthophoto-upload / GPS-correction sub-panel (in `_docs/ui_design/flights.html` but not in source).
|
||||
- Any per-segment annotation-time-window logic (that's the Annotations module).
|
||||
- Any aircraft battery model that respects altitude-dependent air density (constant 1.05 kg/m³ in source).
|
||||
|
||||
## Tests
|
||||
|
||||
None — confirmed by `00_discovery.md §5`. Documented test gap is owned by Steps 3 / 5–7 of autodev (test-spec → implement → run).
|
||||
|
||||
## Cross-doc references
|
||||
|
||||
- Parent suite Flights API contract: `../../../../_docs/02_flights.md` (DTOs, endpoint shapes).
|
||||
- Parent suite GPS-Denied: `../../../../_docs/11_gps_denied.md` (SSE event shape, correction flow).
|
||||
- UI spec: `../../ui_design/README.md` (Flights Page Layout, GPS-Denied panel toggle, mobile breakpoint).
|
||||
- Port-source: `mission-planner/src/flightPlanning/*` — covered separately as one consolidated doc.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Module: `src/features/login/LoginPage.tsx`
|
||||
|
||||
> **Source**: `src/features/login/LoginPage.tsx` (95 lines)
|
||||
> **Topo batch**: B4 (depends on B3: `auth/AuthContext`)
|
||||
|
||||
## Purpose
|
||||
|
||||
The single public route of the SPA. Collects email + password, calls
|
||||
`AuthContext.login(...)`, and on success runs a four-step "unlock"
|
||||
animation (download key → decrypting → starting services → ready)
|
||||
before navigating to `/flights`. Replaces the WPF `LoginWindow.xaml`
|
||||
including its multi-step progress UI (`_docs/legacy/wpf-era.md` §3 / §4).
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
export default function LoginPage(): JSX.Element
|
||||
```
|
||||
|
||||
No props. Reads `useAuth().login` and `react-router-dom`'s `useNavigate`.
|
||||
|
||||
## Internal logic
|
||||
|
||||
- **State machine** — `step: UnlockStep` cycles through:
|
||||
`idle → authenticating → downloadingKey → decrypting → startingServices → ready`,
|
||||
with `idle` also reachable on error from `authenticating`.
|
||||
|
||||
```ts
|
||||
type UnlockStep = 'idle' | 'authenticating' | 'downloadingKey'
|
||||
| 'decrypting' | 'startingServices' | 'ready'
|
||||
```
|
||||
|
||||
- **Local state**:
|
||||
- `email: string`, `password: string` — controlled inputs.
|
||||
- `error: string` — empty unless the previous attempt failed.
|
||||
- `step: UnlockStep` — drives both the form/spinner branch and the
|
||||
spinner caption.
|
||||
- **`handleSubmit(e)`**:
|
||||
1. `preventDefault`, clear `error`, set `step = 'authenticating'`.
|
||||
2. `await login(email, password)` — this throws on bad credentials
|
||||
(`AuthContext.login` rethrows the `api.post` error).
|
||||
3. On success: call `runUnlockSequence()`.
|
||||
4. On failure: reset `step` to `'idle'`, set `error = t('login.error')`.
|
||||
- **`runUnlockSequence()`** — sequentially sets `step` to each of
|
||||
`downloadingKey`, `decrypting`, `startingServices`, `ready`, with a
|
||||
600ms `setTimeout` delay between each. After `ready`, navigates to
|
||||
`/flights`. The unlock steps are **purely presentational** — there is
|
||||
no real downloadKey/decrypt work happening; the SPA's bearer token is
|
||||
already set inside `await login(...)`. The animation reproduces the
|
||||
WPF "vault unlocking" UX for continuity, not for any cryptographic
|
||||
operation. Total minimum wait after a successful auth: 4 × 600 ms =
|
||||
2.4 s. Document explicitly to avoid future "what does this decrypt?"
|
||||
confusion.
|
||||
- **`STEP_KEYS`**: a `Record<UnlockStep, string>` mapping each step to a
|
||||
translation key. `'idle'` maps to the empty string (the spinner
|
||||
caption is not rendered when idle).
|
||||
- **Render**: when `step === 'idle'`, the form is shown (email +
|
||||
password + error + submit). Otherwise the form is hidden and a
|
||||
centered spinner with the localized step caption is shown. Note the
|
||||
spinner is rendered even during `authenticating` (real network), so
|
||||
the user sees a single uninterrupted spinner across the entire
|
||||
auth → animation flow.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**: `../../auth/AuthContext` — `useAuth().login`.
|
||||
- **External**: `react` (`useState`, `FormEvent` type),
|
||||
`react-router-dom` (`useNavigate`),
|
||||
`react-i18next` (`useTranslation`).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
- `src/App.tsx` — mounted at the public `/login` route, *outside*
|
||||
`AuthProvider`. (Verify in B8 — currently `App.tsx` puts
|
||||
`AuthProvider` inside the protected branch only, so `LoginPage`
|
||||
reaches `useAuth()` only because `App.tsx` actually wraps everything
|
||||
in `AuthProvider` at a higher level. Confirmed via the §7a graph
|
||||
edge `App → AuthProvider`.)
|
||||
|
||||
## Data models
|
||||
|
||||
`UnlockStep` and `STEP_KEYS` are module-private. No DTOs or shared
|
||||
types.
|
||||
|
||||
## Configuration
|
||||
|
||||
- **i18n keys**: `login.title`, `login.email`, `login.password`,
|
||||
`login.submit`, `login.error`, `login.authenticating`,
|
||||
`login.downloadingKey`, `login.decrypting`, `login.startingServices`,
|
||||
`login.ready`. (All present in `src/i18n/en.json` and `ua.json` —
|
||||
confirmed.)
|
||||
- **Tailwind tokens**: `bg-az-bg`, `bg-az-panel`, `border-az-border`,
|
||||
`text-az-orange`, `text-az-text`, `text-az-muted`, `text-az-red`.
|
||||
Defined in `src/index.css`.
|
||||
- **Hardcoded animation timing**: `600 ms` per step. No env override.
|
||||
|
||||
## External integrations
|
||||
|
||||
None directly. Indirect: `AuthContext.login` calls
|
||||
`POST /api/admin/auth/login` (routed by `nginx.conf` to the `admin/`
|
||||
service).
|
||||
|
||||
## Security
|
||||
|
||||
- Both inputs use the right `type` attribute (`email`, `password`),
|
||||
giving the browser the chance to mask the password and offer
|
||||
password-manager autofill.
|
||||
- The HTML form does NOT have `autoComplete="off"` — autofill is
|
||||
intentionally allowed.
|
||||
- The component does NOT log credentials anywhere. The `error` state
|
||||
carries only the localized "Invalid credentials" string, never the
|
||||
raw backend error.
|
||||
- After successful auth, the bearer is already in memory (`AuthContext`
|
||||
set it). The 2.4s "unlock" animation does NOT extend the auth window
|
||||
— if the bearer expires server-side during the animation the next
|
||||
request retries via `client.ts`'s 401 → refresh path.
|
||||
- The `setError(t('login.error'))` shows a single generic error for
|
||||
every failure mode (wrong password, account locked, server down).
|
||||
Acceptable for security (no user-enumeration leak), but logs an
|
||||
observability flag — backend should keep specific reasons in its
|
||||
audit log.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- **The unlock animation is theatrical** — it suggests cryptographic
|
||||
work that is NOT happening. If a future phase actually adds
|
||||
client-side key derivation, the animation should reflect real
|
||||
progress (`'decrypting'` would block on actual work). Keep the names
|
||||
but document the placeholder status in `solution.md` (Step 5).
|
||||
- **No "loading" while `authenticating`** beyond hiding the form —
|
||||
Submit button shows no spinner of its own; the form simply hides
|
||||
the moment `step` leaves `'idle'`. Mild UX gap (a quick auth
|
||||
failure shows the form blink). Defer to Step 8.
|
||||
- **Caps Lock indicator missing** — the WPF version had one
|
||||
(`_docs/legacy/wpf-era.md`). Not currently a goal; flag if the UX
|
||||
spec calls for it.
|
||||
- **No "Forgot password" link** — out of scope; the `admin/` service
|
||||
may or may not support that flow. Verify during Step 6 problem
|
||||
extraction.
|
||||
- **Form does not disable Submit while authenticating** — but the
|
||||
form is unmounted as soon as `step !== 'idle'`, so a double-click
|
||||
cannot fire a second `login(...)` call. Acceptable.
|
||||
- **`runUnlockSequence` resolution race**: if the user navigates
|
||||
away mid-animation (e.g. closes the tab), the pending `setTimeout`s
|
||||
still fire but harmlessly. React's StrictMode double-invokes the
|
||||
effect-mount path in dev — but `runUnlockSequence` is invoked from
|
||||
`handleSubmit`, not an effect, so no duplication.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Module: `src/features/settings/SettingsPage.tsx`
|
||||
|
||||
> **Source**: `src/features/settings/SettingsPage.tsx` (107 lines)
|
||||
> **Topo batch**: B4 (depends on B3: `api/client`, `types/index`)
|
||||
|
||||
## Purpose
|
||||
|
||||
The per-tenant configuration screen. Three side-by-side panels:
|
||||
**Tenant** (system settings: military unit, name, default camera
|
||||
width and FoV), **Directories** (server-side filesystem paths for
|
||||
videos, images, labels, results, thumbnails, GPS sat / route),
|
||||
and **Aircrafts** (read-only list with star-toggle for default
|
||||
selection). Replaces the legacy WPF `SettingsWindow.xaml` plus the
|
||||
"system" tab (`_docs/legacy/wpf-era.md` §4).
|
||||
|
||||
Available to every authenticated user — `Header` does not gate
|
||||
`/settings` behind a permission check. The backend is the authority
|
||||
for who is allowed to write.
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
export default function SettingsPage(): JSX.Element
|
||||
```
|
||||
|
||||
No props.
|
||||
|
||||
## Internal logic
|
||||
|
||||
- **State**:
|
||||
- `system: SystemSettings | null` — loaded from
|
||||
`GET /api/annotations/settings/system`. `null` until the GET
|
||||
resolves; the panel does not render until then (`{system && (...)}`).
|
||||
- `dirs: DirectorySettings | null` — analogous, from
|
||||
`GET /api/annotations/settings/directories`.
|
||||
- `aircrafts: Aircraft[]` — from `GET /api/flights/aircrafts`.
|
||||
- `saving: boolean` — disables the two Save buttons during a PUT.
|
||||
- **Bootstrap effect** (`useEffect([])`):
|
||||
|
||||
```ts
|
||||
api.get<SystemSettings>('/api/annotations/settings/system').then(setSystem).catch(() => {})
|
||||
api.get<DirectorySettings>('/api/annotations/settings/directories').then(setDirs).catch(() => {})
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
```
|
||||
|
||||
Three independent calls, all silently swallowed on error. Empty UI
|
||||
on failure (no error banner). Flag for Step 4.
|
||||
- **`saveSystem()`**:
|
||||
1. Guard: `if (!system) return`.
|
||||
2. `setSaving(true)`.
|
||||
3. `await api.put('/api/annotations/settings/system', system)`.
|
||||
4. `setSaving(false)`.
|
||||
|
||||
No optimistic update needed (the PUT body **is** the local state).
|
||||
No re-fetch — assumes the server echoes the same values. **Error
|
||||
path is missing**: a thrown PUT leaves `saving: true` permanently
|
||||
(no `try/finally`). Flag for Step 4.
|
||||
- **`saveDirs()`** — analogous against
|
||||
`PUT /api/annotations/settings/directories`. Same missing
|
||||
`try/finally` issue.
|
||||
- **`handleToggleDefault(a)`** — duplicate of the same handler in
|
||||
`AdminPage`: `PATCH /api/flights/aircrafts/${a.id}` with
|
||||
`{ isDefault: !a.isDefault }` then optimistic local flip. Two copies
|
||||
of the same logic in two pages — extract to a shared helper or to
|
||||
`FlightContext` in Step 8 (the legacy WPF had a single
|
||||
`AircraftService.SetDefault(...)`).
|
||||
- **`field(label, value, onChange, type='text')`** — local helper that
|
||||
renders a labeled `<input>`. Always controlled (`value={value ?? ''}`).
|
||||
Converts numeric inputs via `parseInt(v) || 0` /
|
||||
`parseFloat(v) || 0` at the call site. Note: `parseInt` / `parseFloat`
|
||||
on an empty string returns `NaN`, and `NaN || 0` is `0` — so
|
||||
clearing a numeric field silently writes `0`, not `null`. Flag for
|
||||
Step 4 against the `SystemSettings` type which permits `null`.
|
||||
- **Layout** — three independent flex children:
|
||||
- Tenant (`w-[300px]` shrink-0): `field()` × 4 + Save.
|
||||
- Directories (`w-[300px]` shrink-0): `field()` × 7 + Save.
|
||||
- Aircrafts (`flex-1 max-w-sm`): list with star toggle.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**:
|
||||
- `../../api/client` — `api`.
|
||||
- `../../types` — `SystemSettings`, `DirectorySettings`, `Aircraft`.
|
||||
- **External**: `react` (`useState`, `useEffect`),
|
||||
`react-i18next` (`useTranslation`).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
- `src/App.tsx` — mounted at the `/settings` route inside the
|
||||
protected tree.
|
||||
|
||||
## Data models
|
||||
|
||||
- `SystemSettings` includes `id, name, militaryUnit,
|
||||
defaultCameraWidth, defaultCameraFoV, thumbnailWidth, thumbnailHeight,
|
||||
thumbnailBorder, generateAnnotatedImage, silentDetection`. The page
|
||||
exposes only the first four — every other field is read on GET and
|
||||
echoed back on PUT untouched. Flag if a future cycle needs to expose
|
||||
thumbnails / silent-detection toggles.
|
||||
- `DirectorySettings` has all 7 directory fields exposed as text
|
||||
inputs. Path validation is server-side only.
|
||||
- `Aircraft` (`id, model, type, isDefault`) — same shape as in
|
||||
`AdminPage`.
|
||||
|
||||
## Configuration
|
||||
|
||||
- **i18n keys consumed**: `settings.tenant`, `settings.directories`,
|
||||
`settings.aircrafts`, `settings.save`. (Confirmed in
|
||||
`src/i18n/en.json`.) Hardcoded English labels for every form field
|
||||
("Military Unit", "Name", "Default Camera Width", "Default Camera
|
||||
FoV", "Videos Dir", "Images Dir", "Labels Dir", "Results Dir",
|
||||
"Thumbnails Dir", "GPS Sat Dir", "GPS Route Dir"). Flag for Step 4.
|
||||
- **Tailwind tokens**: `bg-az-panel`, `bg-az-bg`, `bg-az-orange`,
|
||||
`text-az-{text,muted,orange}`, `bg-az-blue/20`, `bg-az-green/20`,
|
||||
`text-az-{blue,green}`, `border-az-border`. Defined in
|
||||
`src/index.css`.
|
||||
|
||||
## External integrations
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/annotations/settings/system` | Load tenant config |
|
||||
| `PUT` | `/api/annotations/settings/system` | Save tenant config |
|
||||
| `GET` | `/api/annotations/settings/directories` | Load directory paths |
|
||||
| `PUT` | `/api/annotations/settings/directories` | Save directory paths |
|
||||
| `GET` | `/api/flights/aircrafts` | Load aircraft list |
|
||||
| `PATCH` | `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
|
||||
|
||||
Routed by `nginx.conf` to `annotations/` and `flights/` backends.
|
||||
|
||||
## Security
|
||||
|
||||
- **No client-side write authorization check** — the page renders the
|
||||
Save buttons for every user. The backend (`annotations/` service)
|
||||
is the authority. Document in `security_approach.md` (Step 6).
|
||||
- **Hardcoded internal directory paths** are **not** present in this
|
||||
file (unlike `AdminPage`'s hardcoded GPS device IP) — every value
|
||||
is server-supplied. Good.
|
||||
- **Path inputs are free-text** — server-side path traversal
|
||||
validation is mandatory. Verify the `annotations/` service rejects
|
||||
e.g. `../../etc/passwd`. Flag for Step 6.
|
||||
- **`saving` state can stick on PUT failure** because there is no
|
||||
`try/finally`, leaving the Save button permanently disabled. Step 4
|
||||
candidate (matches the `AdminPage` "AI/GPS save buttons do
|
||||
nothing" pattern — there is a clear lack of UX-level error
|
||||
handling across both admin/settings pages).
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- **Numeric clear-to-zero pitfall**: `parseInt('') || 0` writes `0`
|
||||
for an empty input, not `null`. This silently overwrites a
|
||||
legitimate `null` (per the `SystemSettings` type, all four numeric
|
||||
fields are nullable). Step 4 fix: detect empty input and pass
|
||||
`null`.
|
||||
- **Aircraft toggle handler is duplicated** between `AdminPage` and
|
||||
`SettingsPage` — extract to a shared helper. Step 8.
|
||||
- **No optimistic concurrency**: two admins editing system settings
|
||||
simultaneously will overwrite each other. The `id` field is sent
|
||||
back on PUT but no `version` / `etag` / `If-Match` header. Flag for
|
||||
Step 6 problem-extraction. Backend may not support optimistic
|
||||
concurrency yet.
|
||||
- **The Aircrafts panel is read-only-ish here** but allows the
|
||||
star-toggle, same as `AdminPage`. The duplication suggests an open
|
||||
question: is this intentional (settings is "personal preferences",
|
||||
admin is "global config", but default-aircraft is a *global*
|
||||
setting) or accidental? Surface to the user in Step 6 problem
|
||||
extraction.
|
||||
- **`saving` is a single global flag** even though the page has two
|
||||
independent Save buttons (system / dirs). A user who clicks
|
||||
"Save System" then quickly clicks "Save Dirs" while the first PUT
|
||||
is in flight will see the Dirs button disabled too. Acceptable
|
||||
given the latency budget; flag if both saves become slow.
|
||||
- **`thumbnailWidth/Height/Border` and `generateAnnotatedImage`,
|
||||
`silentDetection`** in `SystemSettings` are not exposed in the UI
|
||||
but are echoed back on PUT. If a future cycle adds them, ensure the
|
||||
GET → PUT round-trip preserves any concurrent change made by another
|
||||
client between the GET and the PUT.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Module: `src/hooks/useDebounce.ts`
|
||||
|
||||
> **Source**: `src/hooks/useDebounce.ts` (12 lines)
|
||||
> **Topo batch**: B1 (leaf)
|
||||
|
||||
## Purpose
|
||||
|
||||
Generic React hook that delays propagation of a rapidly-changing value to its consumers, used to throttle search inputs against the backend.
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
export function useDebounce<T>(value: T, delay: number): T
|
||||
```
|
||||
|
||||
Returns the latest `value` only after `delay` milliseconds have elapsed without any further change. Generic in `T`, so callers can debounce strings, numbers, or any reference value (referential identity matters; see notes).
|
||||
|
||||
## Internal logic
|
||||
|
||||
`useState<T>(value)` for the debounced output; `useEffect` registers a `setTimeout(setDebounced, delay)` and returns a `clearTimeout` cleanup. The effect re-runs whenever `value` or `delay` changes — each new input value cancels the pending timeout from the previous render and starts a fresh one.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**: none.
|
||||
- **External**: `react` (`useState`, `useEffect`).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
- `src/features/annotations/MediaList.tsx` — debounces the media-name search input.
|
||||
- `src/features/dataset/DatasetPage.tsx` — debounces the dataset name-search filter (specified as 400ms in `_docs/ui_design/README.md` §"Dataset Explorer", but the actual delay is set by the caller).
|
||||
|
||||
## Data models
|
||||
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
|
||||
None.
|
||||
|
||||
## External integrations
|
||||
|
||||
None.
|
||||
|
||||
## Security
|
||||
|
||||
None.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- The hook compares values by referential equality (`useEffect` deps array). Passing a fresh object literal each render — e.g. `useDebounce({ q }, 300)` — would re-trigger the timer endlessly. Current callers pass primitives only; safe.
|
||||
- No "leading edge" / "trailing edge" / "max wait" knobs. Adequate for the two current consumers; if/when richer behaviour is needed, prefer `useDebouncedCallback` from a library over extending this module.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Module: `src/hooks/useResizablePanel.ts`
|
||||
|
||||
> **Source**: `src/hooks/useResizablePanel.ts` (33 lines)
|
||||
> **Topo batch**: B1 (leaf)
|
||||
|
||||
## Purpose
|
||||
|
||||
React hook that backs a draggable splitter between two horizontally-arranged panels. Owns the panel width as state and exposes a mouse handler the host attaches to the splitter element.
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
export function useResizablePanel(
|
||||
initialWidth: number,
|
||||
min?: number, // default 100
|
||||
max?: number, // default 600
|
||||
): {
|
||||
width: number
|
||||
onMouseDown: (e: React.MouseEvent) => void
|
||||
setWidth: React.Dispatch<React.SetStateAction<number>>
|
||||
}
|
||||
```
|
||||
|
||||
`width` is the current panel width (px). `onMouseDown` should be wired to the splitter element. `setWidth` is exposed for programmatic resets — currently unused by callers but kept for parity with the persistence story (see Notes).
|
||||
|
||||
## Internal logic
|
||||
|
||||
1. `useState(initialWidth)` for `width`.
|
||||
2. Drag bookkeeping is held in three `useRef` cells (`dragging`, `startX`, `startWidth`) — kept out of state so the move/up handlers never re-render the host.
|
||||
3. `onMouseDown` (memoized via `useCallback([width])`) snapshots `clientX` and `width`, marks `dragging = true`, and calls `e.preventDefault()` to avoid triggering text selection.
|
||||
4. A `useEffect` registers global `mousemove` / `mouseup` listeners on `window`. While `dragging.current === true`, `mousemove` updates `width` to `clamp(startWidth + (e.clientX - startX), min, max)`. `mouseup` flips `dragging` back to `false`.
|
||||
5. The effect cleans up on unmount.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**: none.
|
||||
- **External**: `react` (`useState`, `useCallback`, `useRef`, `useEffect`).
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
- `src/features/annotations/AnnotationsPage.tsx` — left + right panel widths.
|
||||
- `src/features/dataset/DatasetPage.tsx` — left + right panel widths.
|
||||
|
||||
Both pages persist their layout server-side via `UserSettings.{annotationsLeftPanelWidth, annotationsRightPanelWidth, datasetLeftPanelWidth, datasetRightPanelWidth}` (see `src/types/index.ts`). The persistence read/write happens in the page, not in this hook.
|
||||
|
||||
## Data models
|
||||
|
||||
None.
|
||||
|
||||
## Configuration
|
||||
|
||||
None.
|
||||
|
||||
## External integrations
|
||||
|
||||
DOM only — `window.addEventListener('mousemove' / 'mouseup')`.
|
||||
|
||||
## Security
|
||||
|
||||
None.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- The hook is keyboard- and touch-blind: only mouse drag is supported, so accessibility for non-pointer input is missing. Track for the broader a11y pass; out of scope for `/document`.
|
||||
- `setWidth` is exposed but no current caller hydrates `initialWidth` from `UserSettings` via it — they pass the persisted width directly to `useResizablePanel(persistedWidth)` on mount. If the persisted width arrives **after** mount (asynchronous load), the panel jumps. Flag for the consumers' module docs (B8).
|
||||
- Default bounds (100 / 600) are arbitrary; consumer pages may want different ceilings (e.g., the annotations sidebar). Currently both consumers accept the defaults.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Module: `src/i18n/i18n.ts`
|
||||
|
||||
> **Source**: `src/i18n/i18n.ts` (13 lines)
|
||||
> **Topo batch**: B2 (depends only on JSON data files)
|
||||
|
||||
## Purpose
|
||||
|
||||
Module-load-time initialisation of `i18next` with the `react-i18next` adapter. Imports the English and Ukrainian translation tables and registers them as the available `resources`. Exporting the configured singleton lets call sites use `useTranslation()` directly.
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts
|
||||
export default i18n // configured i18next instance
|
||||
```
|
||||
|
||||
## Internal logic
|
||||
|
||||
```ts
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: { en: { translation: en }, ua: { translation: ua } },
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
interpolation: { escapeValue: false },
|
||||
})
|
||||
```
|
||||
|
||||
- `lng: 'en'` is hardcoded at boot. There is **no** runtime mechanism to detect the user's locale or to read a saved preference. Switching language at runtime works (via `i18n.changeLanguage(...)`) but the page reload always lands on English first.
|
||||
- `escapeValue: false` is the documented default for React (which already escapes JSX text).
|
||||
- Module side-effect at top level: `init()` runs as soon as `src/main.tsx` imports this file.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**: `./en.json`, `./ua.json` (JSON imports — Vite handles).
|
||||
- **External**: `i18next`, `react-i18next`.
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
- `src/main.tsx` — side-effect import (`import './i18n/i18n'`).
|
||||
- Indirectly: every component that calls `useTranslation()` from `react-i18next` (e.g. `ConfirmDialog`, `HelpModal`, `JsonEditorDialog`, …).
|
||||
|
||||
## Data models
|
||||
|
||||
The two JSON resource files (`en.json`, `ua.json`) define the key namespace. Per `_docs/ui_design/README.md` §"Localization" and `_docs/legacy/wpf-era.md` §10, the SPA must support EN + UA at parity. Inspection of the JSON files (deferred to Step 4 verification) will confirm whether parity is held.
|
||||
|
||||
## Configuration
|
||||
|
||||
`lng: 'en'` and `fallbackLng: 'en'` are inlined. `localStorage` persistence (e.g., via `i18next-browser-languagedetector`) would be the natural Step 8 enhancement for "remember user choice"; deferred.
|
||||
|
||||
## External integrations
|
||||
|
||||
None.
|
||||
|
||||
## Security
|
||||
|
||||
`escapeValue: false` is correct in React but would be unsafe if any consumer rendered translation values via `dangerouslySetInnerHTML`. None do (verify in Step 4).
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- The `mission-planner/` sub-project does NOT use `react-i18next` — it has its own `LanguageContext` + raw `translations` table (see `00_discovery.md` §11 finding 8). The port to `src/features/flights/` should consume this module instead. Track for MP-B3.
|
||||
- Adding a third language is a one-line edit (`resources: { en, ua, <new> }`) plus a JSON file. Document the procedure in the final `solution.md`.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Module: `src/types/index.ts`
|
||||
|
||||
> **Source**: `src/types/index.ts` (161 lines)
|
||||
> **Topo batch**: B1 (leaf — no internal imports)
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared TypeScript contract surface for the SPA: pure type aliases, interfaces, and numeric enums describing the DTOs that travel between the React UI and the suite backends (`admin/`, `annotations/`, `flights/`, `loader/`, etc.).
|
||||
|
||||
## Public interface
|
||||
|
||||
Generic shape:
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `PaginatedResponse<T>` | `interface` | `{ items, totalCount, page, pageSize }`. Returned by paged list endpoints (e.g., `GET /api/flights?pageSize=...`). |
|
||||
|
||||
Domain entities (mirroring backend DTOs):
|
||||
|
||||
| Symbol | Kind | Backing concept |
|
||||
|---|---|---|
|
||||
| `Media` | `interface` | A media file (image / video) attached to a flight. Fields: `id, name, path, mediaType, mediaStatus, duration, annotationCount, waypointId, userId`. |
|
||||
| `Detection` | `interface` | A bounding box. Fields: `id, classNum, label, confidence, affiliation, combatReadiness, centerX, centerY, width, height` (normalized 0–1 coords, see `_docs/legacy/wpf-era.md` §10). |
|
||||
| `AnnotationListItem` | `interface` | A frame's annotation set. `time` is `string \| null` (ISO duration), `splitTile` carries tile zoom info for split-image detections. Embeds `Detection[]`. |
|
||||
| `DetectionClass` | `interface` | Admin-managed detection class metadata (`id, name, shortName, color, maxSizeM, photoMode`). Drives the Detection Classes panel + GSD validation. |
|
||||
| `Flight`, `Aircraft`, `Waypoint` | `interface` | Flight feature entities. `Aircraft.type` is union `'Plane' \| 'Copter'`. |
|
||||
| `SystemSettings`, `DirectorySettings`, `CameraSettings`, `UserSettings` | `interface` | Settings domain. `UserSettings` carries the global `selectedFlightId` plus per-page panel widths persisted server-side via the annotations API (see `src/components/FlightContext.tsx`). |
|
||||
| `DatasetItem` | `interface` | Row in the Dataset Explorer grid (`annotationId, imageName, thumbnailPath, status, createdDate, createdEmail, flightName, source, isSeed, isSplit`). |
|
||||
| `ClassDistributionItem` | `interface` | Aggregate row for the Class Distribution chart. |
|
||||
| `User` | `interface` | Admin user list row. |
|
||||
| `AuthUser` | `interface` | Authenticated session user (`id, email, name, role, permissions[]`); used by `AuthContext`. |
|
||||
|
||||
Numeric enums (must match backend wire values bit-for-bit):
|
||||
|
||||
| Enum | Members | Used by |
|
||||
|---|---|---|
|
||||
| `MediaType` | `None=0, Image=1, Video=2` | media list, video player branching |
|
||||
| `MediaStatus` | `New=0, AiProcessing=1, AiProcessed=2, ManualCreated=3` | media list state |
|
||||
| `AnnotationSource` | `AI=0, Manual=1` | annotation provenance |
|
||||
| `AnnotationStatus` | `Created=0, Edited=1, Validated=2` | dataset filter buttons |
|
||||
| `Affiliation` | `Unknown=0, Friendly=1, Hostile=2` | bounding-box icon (see `_docs/legacy/wpf-era.md` §10) |
|
||||
| `CombatReadiness` | `NotReady=0, Ready=1` | bounding-box readiness dot |
|
||||
|
||||
## Internal logic
|
||||
|
||||
None. Declarations file only.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Internal**: none (true leaf).
|
||||
- **External**: none.
|
||||
|
||||
## Consumers (intra-repo)
|
||||
|
||||
`src/auth/AuthContext.tsx`, `src/components/FlightContext.tsx`, `src/components/Header.tsx`, `src/components/DetectionClasses.tsx`, `src/api/*` (none directly — API calls are typed at call sites), every page under `src/features/*` except `src/features/login/LoginPage.tsx`. Roughly 15 importing files.
|
||||
|
||||
## Data models
|
||||
|
||||
The whole module **is** the data-model surface for the SPA. Two implicit constraints:
|
||||
|
||||
1. **Numeric enum values are wire-coupled** — changing them silently breaks every backend that returns the same number. Treated as a public contract; downstream changes must coordinate with the corresponding `.NET` / Cython service.
|
||||
2. **`*.id` is `string`** for every entity (suite uses GUIDs), but `Detection.classNum` and the four enums are `number`. Mixed-key shapes are intentional.
|
||||
|
||||
## Configuration
|
||||
|
||||
None.
|
||||
|
||||
## External integrations
|
||||
|
||||
None directly. The types are the *contract* surface for HTTP responses from `/api/admin/*`, `/api/annotations/*`, `/api/flights/*`. Ground truth for shapes lives in the respective backend submodule (`suite/admin/`, `suite/annotations/`, `suite/flights/`).
|
||||
|
||||
## Security
|
||||
|
||||
`AuthUser.permissions: string[]` is the only security-relevant field — checked by `AuthContext.hasPermission(perm)`. The permission strings are not enumerated here; they are defined by the `admin/` service.
|
||||
|
||||
## Tests
|
||||
|
||||
None.
|
||||
|
||||
## Notes / open questions
|
||||
|
||||
- `Media.duration` is `string | null` rather than `number | null` — this matches a `TimeSpan`-style ISO duration the backend returns. Document the format precisely once the consumer (`VideoPlayer`) is reached in B7.
|
||||
- `UserSettings` mixes "selected flight" with "per-page panel widths" — possibly a candidate for splitting along ownership lines, but the API endpoint (`/api/annotations/settings/user`) treats them as one document. Out of scope for `/document`.
|
||||
Reference in New Issue
Block a user