mirror of
https://github.com/azaion/ui.git
synced 2026-06-22 22:11:11 +00:00
Compare commits
3 Commits
2071a24391
...
17d5bb45e7
| Author | SHA1 | Date | |
|---|---|---|---|
| 17d5bb45e7 | |||
| 8a461a2051 | |||
| 23746ec61d |
@@ -21,10 +21,10 @@ Detection approach: TypeScript `import ... from '...'` parsing across all `.ts`
|
|||||||
| F1 | Critical | Architecture | `mission-planner/**` vs `src/features/flights/**` | Mission-planner duplicates 13+ modules of the deployed flights tree |
|
| F1 | Critical | Architecture | `mission-planner/**` vs `src/features/flights/**` | Mission-planner duplicates 13+ modules of the deployed flights tree |
|
||||||
| F2 | High | Architecture | `src/features/dataset/DatasetPage.tsx:9` → `../annotations/CanvasEditor` | Cross-feature same-layer edge — `07_dataset` reaches into `06_annotations` |
|
| F2 | High | Architecture | `src/features/dataset/DatasetPage.tsx:9` → `../annotations/CanvasEditor` | Cross-feature same-layer edge — `07_dataset` reaches into `06_annotations` |
|
||||||
| F3 | High | Architecture | `src/features/annotations/classColors.ts` | Physical / logical owner split — `11_class-colors` file lives inside `06_annotations` |
|
| F3 | High | Architecture | `src/features/annotations/classColors.ts` | Physical / logical owner split — `11_class-colors` file lives inside `06_annotations` |
|
||||||
| F4 | High | Architecture | every component | No Public API barrels — every internal file is de-facto public |
|
| F4 | High | Architecture | every component | No Public API barrels — every internal file is de-facto public — **CLOSED 2026-05-11 by AZ-485 (`23746ec`)** |
|
||||||
| F5 | High | Architecture | `mission-planner/src/flightPlanning/MapView.tsx ↔ MiniMap.tsx` | Pre-existing import cycle inside port-source |
|
| F5 | High | Architecture | `mission-planner/src/flightPlanning/MapView.tsx ↔ MiniMap.tsx` | Pre-existing import cycle inside port-source |
|
||||||
| F6 | Medium | Architecture | (codebase-wide) | No `src/shared/` infrastructure for cross-cutting concerns |
|
| F6 | Medium | Architecture | (codebase-wide) | No `src/shared/` infrastructure for cross-cutting concerns |
|
||||||
| F7 | Medium | Architecture | every `api.*` / `createSSE` call site | Hardcoded `/api/<service>/...` paths instead of env-driven endpoints |
|
| F7 | Medium | Architecture | every `api.*` / `createSSE` call site | Hardcoded `/api/<service>/...` paths instead of env-driven endpoints — **CLOSED 2026-05-11 by AZ-486 (`8a461a2`)** |
|
||||||
| F8 | Low | Architecture | `_docs/02_document/module-layout.md` | Layering-table inconsistency — Header → useAuth is unannotated |
|
| F8 | Low | Architecture | `_docs/02_document/module-layout.md` | Layering-table inconsistency — Header → useAuth is unannotated |
|
||||||
| F9 | Low | Architecture | `mission-planner/src/{main,App,setupTests,vite-env}.tsx` | Inert second Vite entry tree at port-source root |
|
| F9 | Low | Architecture | `mission-planner/src/{main,App,setupTests,vite-env}.tsx` | Inert second Vite entry tree at port-source root |
|
||||||
|
|
||||||
@@ -86,12 +86,16 @@ Detection approach: TypeScript `import ... from '...'` parsing across all `.ts`
|
|||||||
- **Suggestion**: Move physical file to `src/shared/classColors.ts` (introducing a `src/shared/` layer for true Layer-0 utilities) or to `src/components/detection/classColors.ts` (under `03_shared-ui`). Either move drops the workaround and aligns physical/logical ownership.
|
- **Suggestion**: Move physical file to `src/shared/classColors.ts` (introducing a `src/shared/` layer for true Layer-0 utilities) or to `src/components/detection/classColors.ts` (under `03_shared-ui`). Either move drops the workaround and aligns physical/logical ownership.
|
||||||
- **Task / Epic**: Step 4 testability — minimal, surgical move (rename + import-path update across 4 consumers).
|
- **Task / Epic**: Step 4 testability — minimal, surgical move (rename + import-path update across 4 consumers).
|
||||||
|
|
||||||
### F4: No Public API barrels — every internal file is de-facto public (High / Architecture)
|
### F4: No Public API barrels — every internal file is de-facto public (High / Architecture) — **CLOSED 2026-05-11 by AZ-485 (commit `23746ec`)**
|
||||||
|
|
||||||
- **Location**: every component root (no `src/<component>/index.ts` exists today; only `src/types/index.ts` and `mission-planner/src/types/index.ts` are barrels and they're re-export hubs, not component facades).
|
- **Resolution**: 11 component barrels (`src/<component>/index.ts`) added — one per component except `10_app-shell` (top-level file collection, never imported as a unit). Every cross-component import in `src/`, `tests/`, and `e2e/` now goes through the barrel. The `STC-ARCH-01` static gate (`scripts/check-arch-imports.mjs --mode=arch-imports`, wired into `scripts/run-tests.sh --static`) fails the build on any deep-import regression. The architecture test `tests/architecture_imports.test.ts` exercises the gate with synthetic fixtures (AC-4 fail-on-synthetic, AC-5 pass-on-migrated). Module-layout Layout Rule #3 records the convention.
|
||||||
- **Description**: Cross-component imports use file-name granularity (`import { api } from '../api/client'`, `import { useFlight } from '../../components/FlightContext'`, etc.). Consequence: there is **no enforceable Public API surface**. Any internal refactor inside a component (split a file, rename an export) is a breaking change to every importer. Phase 7 Check #2 ("Public API respect") cannot meaningfully fail in this codebase because everything is public. Module-layout Verification #3 records the same observation.
|
- **Carried-forward exemption**: `src/features/annotations/classColors` — the file is logically owned by `11_class-colors` but physically lives under `06_annotations` (F3). Re-exporting it through the `06_annotations` barrel creates a circular import (`AnnotationsPage → DetectionClasses → 06_annotations barrel → AnnotationsPage`). Consumers import the path directly under an `EXEMPT_RE` in the static check. The exemption disappears when F3 moves the file.
|
||||||
- **Suggestion**: Step 4 testability candidate — add `src/<component>/index.ts` for every component, re-exporting only the symbols listed in module-layout's "Public API (de-facto)" line for that component. Then a future Phase 7 invocation can flag deep imports as Architecture findings instead of folding into background noise.
|
|
||||||
- **Task / Epic**: Step 4 testability (single mechanical change per component; ~11 new files + ~30 import-path edits).
|
- **Pre-resolution context (preserved for trace)**:
|
||||||
|
- **Location**: every component root (no `src/<component>/index.ts` existed before AZ-485; only `src/types/index.ts` and `mission-planner/src/types/index.ts` were barrels and those are re-export hubs, not component facades).
|
||||||
|
- **Description**: Cross-component imports used file-name granularity (`import { api } from '../api/client'`, `import { useFlight } from '../../components/FlightContext'`, etc.). Consequence: there was **no enforceable Public API surface**. Any internal refactor inside a component (split a file, rename an export) was a breaking change to every importer. Phase 7 Check #2 ("Public API respect") could not meaningfully fail in this codebase because everything was public.
|
||||||
|
- **Suggestion (executed)**: add `src/<component>/index.ts` for every component, re-exporting only the symbols listed in module-layout's "Public API" line.
|
||||||
|
- **Task / Epic**: Step 4 testability — moved to Phase B cycle 1 batch 9 / AZ-485 / Epic AZ-447.
|
||||||
|
|
||||||
### F5: Pre-existing import cycle inside port-source (High / Architecture)
|
### F5: Pre-existing import cycle inside port-source (High / Architecture)
|
||||||
|
|
||||||
@@ -111,12 +115,16 @@ Detection approach: TypeScript `import ... from '...'` parsing across all `.ts`
|
|||||||
- `shared/endpoints.ts` — typed endpoint constants (closes F7).
|
- `shared/endpoints.ts` — typed endpoint constants (closes F7).
|
||||||
- **Task / Epic**: Phase B candidate (one cycle for shared infrastructure) OR fold into Step 8 refactor if user picks A on the Step 8 gate.
|
- **Task / Epic**: Phase B candidate (one cycle for shared infrastructure) OR fold into Step 8 refactor if user picks A on the Step 8 gate.
|
||||||
|
|
||||||
### F7: Hardcoded `/api/<service>/...` paths instead of env-driven endpoints (Medium / Architecture)
|
### F7: Hardcoded `/api/<service>/...` paths instead of env-driven endpoints (Medium / Architecture) — **CLOSED 2026-05-11 by AZ-486 (commit `8a461a2`)**
|
||||||
|
|
||||||
- **Location**: every call site of `api.*()` and `createSSE()` across `src/features/**` and `src/auth/`, `src/components/FlightContext.tsx`, `src/components/DetectionClasses.tsx`. Approximately 30 call sites.
|
- **Resolution**: `src/api/endpoints.ts` introduced as the single source of truth — 25 typed builders covering every `/api/<service>/<path>` URL the UI talks to today. Re-exported through the F4 barrel `src/api/index.ts`; consumers import `{ endpoints } from '../api'` (or `../../api`). Every production callsite of `api.*` and `createSSE()` migrated to `endpoints.*` — 13 source files (admin, annotations × 5, flights, settings, dataset, auth, client, FlightContext, DetectionClasses). The `STC-ARCH-02` static gate (`scripts/check-arch-imports.mjs --mode=api-literals`, wired into `scripts/run-tests.sh --static`) fails the build on any new `/api/<service>/` literal in `src/` outside the contract owner (`endpoints.ts`) and `*.test.tsx?` files. The colocated `src/api/endpoints.test.ts` (36 assertions, character-identical to pre-refactor URL strings) serves as the wire-contract documentation per `module-layout.md`'s "code-derived documentation" pattern. Module-layout Verification Needed item #3a records the convention.
|
||||||
- **Description**: Consequence of ADR-006 (nginx prefix-strip). Each call site repeats `/api/<service>/<path>` as a string literal. Testability suffers — every test fixture must duplicate paths; any nginx-route change touches every feature. Architecture intent (ADR-006 Consequences) explicitly flags this: *"The SPA hardcodes /api/<service>/... paths in source instead of an env-driven base URL — testability is poor (finding tracked)."*
|
- **F6 interaction**: `endpoints.ts` lives under `01_api-transport` (not `src/shared/`) — F6 is explicitly deferred. When/if F6 lands and moves the file, only `src/api/index.ts` flips the re-export source; consumers do not change. This is exactly the protection F4 was built to provide.
|
||||||
- **Suggestion**: Step 4 testability — introduce `src/shared/endpoints.ts` (or per-component `endpoints.ts` if shared/ is deferred) that exposes typed builders: `endpoints.auth.login()`, `endpoints.flights.byId(id)`, `endpoints.annotations.media(query)`, etc. Replace every string-literal path. Allows tests to mock at the endpoints layer rather than at every `fetch` call. Compounds well with F6 if `src/shared/` lands first.
|
|
||||||
- **Task / Epic**: Step 4 testability (mechanical extract; per-component cohort).
|
- **Pre-resolution context (preserved for trace)**:
|
||||||
|
- **Location**: every call site of `api.*()` and `createSSE()` across `src/features/**` and `src/auth/`, `src/components/FlightContext.tsx`, `src/components/DetectionClasses.tsx`. Approximately 30 call sites.
|
||||||
|
- **Description**: Consequence of ADR-006 (nginx prefix-strip). Each call site repeated `/api/<service>/<path>` as a string literal. Testability suffered — every test fixture had to duplicate paths; any nginx-route change touched every feature. Architecture intent (ADR-006 Consequences) explicitly flagged this: *"The SPA hardcodes /api/<service>/... paths in source instead of an env-driven base URL — testability is poor (finding tracked)."*
|
||||||
|
- **Suggestion (executed)**: introduce a typed endpoints module exposing builders like `endpoints.auth.login()`, `endpoints.flights.byId(id)`, `endpoints.annotations.media(query)`, etc.
|
||||||
|
- **Task / Epic**: Step 4 testability — moved to Phase B cycle 1 batch 10 / AZ-486 / Epic AZ-447.
|
||||||
|
|
||||||
### F8: Layering-table inconsistency — Header → useAuth is unannotated (Low / Architecture)
|
### F8: Layering-table inconsistency — Header → useAuth is unannotated (Low / Architecture)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,16 @@
|
|||||||
|--------|-----------|-------|
|
|--------|-----------|-------|
|
||||||
| `subscribe<T>(url, onMessage, onError?): { close }` | factory | Creates `EventSource` with the **bearer token in the query string** (browser `EventSource` can't set headers). Returns a `close()` handle. |
|
| `subscribe<T>(url, onMessage, onError?): { close }` | factory | Creates `EventSource` with the **bearer token in the query string** (browser `EventSource` can't set headers). Returns a `close()` handle. |
|
||||||
|
|
||||||
|
### `src/api/endpoints.ts` (since AZ-486 / F7)
|
||||||
|
|
||||||
|
| Export | Signature | Notes |
|
||||||
|
|--------|-----------|-------|
|
||||||
|
| `endpoints` | `Readonly<{ admin, annotations, flights, detect }>` of typed builder functions | Single source of truth for every `/api/<service>/...` URL the UI talks to. Each leaf is a function — `() => string` for constant paths, `(id, ...) => string` for parameterised ones. Wire-contract pinned by `src/api/endpoints.test.ts` (36 assertions). |
|
||||||
|
|
||||||
|
### `src/api/index.ts` (Public API barrel, since AZ-485 / F4)
|
||||||
|
|
||||||
|
Re-exports the component's public surface: `api`, `createSSE`, `setToken`, `getToken`, `getApiBase`, `setNavigateToLogin`, `endpoints`. Consumers OUTSIDE this component MUST import from the barrel; direct imports of `src/api/{client,sse,endpoints}` from other components are blocked by `STC-ARCH-01`.
|
||||||
|
|
||||||
## 3. External API Specification
|
## 3. External API Specification
|
||||||
|
|
||||||
This component does not *expose* an API; it consumes the suite's. The set of consumed endpoints (collected from feature module docs):
|
This component does not *expose* an API; it consumes the suite's. The set of consumed endpoints (collected from feature module docs):
|
||||||
@@ -40,7 +50,7 @@ This component does not *expose* an API; it consumes the suite's. The set of con
|
|||||||
| `detect/` | `/api/detect/...` | `06_annotations` |
|
| `detect/` | `/api/detect/...` | `06_annotations` |
|
||||||
| `loader/`, `resource/`, `gps-denied-*`, `autopilot/` | `/api/{loader,resource,gps-denied-desktop,gps-denied-onboard,autopilot}/...` | various features |
|
| `loader/`, `resource/`, `gps-denied-*`, `autopilot/` | `/api/{loader,resource,gps-denied-desktop,gps-denied-onboard,autopilot}/...` | various features |
|
||||||
|
|
||||||
**No service-specific client modules exist**. URL strings are inlined at every call site (testability finding from autodev Step 4).
|
**No service-specific client modules exist**. URL strings are produced by typed builders in `src/api/endpoints.ts` (added by AZ-486 / F7, commit `8a461a2`) — the previous "URL strings inlined at every call site" testability finding (F7) is **CLOSED**. The `STC-ARCH-02` static gate (`scripts/check-arch-imports.mjs --mode=api-literals`, wired into `scripts/run-tests.sh`) forbids re-introducing `/api/<service>/...` literals under `src/`.
|
||||||
|
|
||||||
## 5. Implementation Details
|
## 5. Implementation Details
|
||||||
|
|
||||||
@@ -60,7 +70,7 @@ This component does not *expose* an API; it consumes the suite's. The set of con
|
|||||||
- **No timeout / cancellation**. (Step 4.)
|
- **No timeout / cancellation**. (Step 4.)
|
||||||
- **Bearer in SSE query string**. Accepted trade-off; document in `security_approach.md` (Step 6).
|
- **Bearer in SSE query string**. Accepted trade-off; document in `security_approach.md` (Step 6).
|
||||||
- **No reconnect-on-token-rotate** for SSE consumers — every feature that uses SSE will silently stop receiving events after the first refresh (Step 8 hardening).
|
- **No reconnect-on-token-rotate** for SSE consumers — every feature that uses SSE will silently stop receiving events after the first refresh (Step 8 hardening).
|
||||||
- **No service-specific clients** → URL strings duplicated across features. Risk of typos surfacing as 404s only at runtime (Step 4).
|
- ~~No service-specific clients~~ → **CLOSED by AZ-486 / F7**: URL strings centralised in `src/api/endpoints.ts`; STC-ARCH-02 enforces it. Typos now surface at build time (TS strict on the builder names) or in `endpoints.test.ts`, never at runtime.
|
||||||
|
|
||||||
## 8. Dependency Graph
|
## 8. Dependency Graph
|
||||||
|
|
||||||
@@ -76,3 +86,5 @@ This component does not *expose* an API; it consumes the suite's. The set of con
|
|||||||
|------|------------|
|
|------|------------|
|
||||||
| `src/api/client.ts` | `_docs/02_document/modules/src__api__client.md` |
|
| `src/api/client.ts` | `_docs/02_document/modules/src__api__client.md` |
|
||||||
| `src/api/sse.ts` | `_docs/02_document/modules/src__api__sse.md` |
|
| `src/api/sse.ts` | `_docs/02_document/modules/src__api__sse.md` |
|
||||||
|
| `src/api/endpoints.ts` | `_docs/02_document/modules/src__api__endpoints.md` |
|
||||||
|
| `src/api/index.ts` (barrel) | (no separate doc — re-exports surface listed in §2 above) |
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
**Status**: derived-from-code
|
**Status**: derived-from-code
|
||||||
**Language**: typescript (React 19 + Vite + Tailwind)
|
**Language**: typescript (React 19 + Vite + Tailwind)
|
||||||
**Layout Convention**: custom (flat-features under `src/`; no per-component barrels)
|
**Layout Convention**: custom (flat-features under `src/`; per-component barrels at `src/<component>/index.ts` since AZ-485)
|
||||||
**Root**: `src/`
|
**Root**: `src/`
|
||||||
**Last Updated**: 2026-05-10
|
**Last Updated**: 2026-05-11
|
||||||
|
|
||||||
> Authoritative file-ownership map for the React UI workspace. Derived from
|
> Authoritative file-ownership map for the React UI workspace. Derived from
|
||||||
> `_docs/02_document/00_discovery.md` (dependency graph) and the Step 2
|
> `_docs/02_document/00_discovery.md` (dependency graph) and the Step 2
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
1. Each component owns ONE OR MORE top-level directories (or top-level files) under `src/`. The mapping is NOT 1:1 — `00_foundation` owns three sibling directories (`src/types/`, `src/hooks/`, `src/i18n/`), `05_flights` spans `src/features/flights/` AND a separate `mission-planner/` port-source root, and `10_app-shell` owns top-level files (`App.tsx`, `main.tsx`, `index.css`, `vite-env.d.ts`).
|
1. Each component owns ONE OR MORE top-level directories (or top-level files) under `src/`. The mapping is NOT 1:1 — `00_foundation` owns three sibling directories (`src/types/`, `src/hooks/`, `src/i18n/`), `05_flights` spans `src/features/flights/` AND a separate `mission-planner/` port-source root, and `10_app-shell` owns top-level files (`App.tsx`, `main.tsx`, `index.css`, `vite-env.d.ts`).
|
||||||
2. Shared code does **not** live under `src/shared/` today — there is no `shared/` directory. Two helper modules (`11_class-colors/classColors.ts` and `06_annotations/CanvasEditor.tsx`) are physically misplaced and consumed across components; both are flagged in the `## Verification Needed` block. A `src/shared/` directory is a Step 4 testability candidate.
|
2. Shared code does **not** live under `src/shared/` today — there is no `shared/` directory. Two helper modules (`11_class-colors/classColors.ts` and `06_annotations/CanvasEditor.tsx`) are physically misplaced and consumed across components; both are flagged in the `## Verification Needed` block. A `src/shared/` directory is a Step 4 testability candidate.
|
||||||
3. Public API per component: NO barrel `index.ts` exists at any component root. The only `index.ts` files are `src/types/index.ts` (a re-export hub for type aliases — used as the de-facto public API for `00_foundation` types) and `mission-planner/src/types/index.ts`. Until Step 4 introduces barrels, Public API is approximated as "every named export from any file under the component's owned directories". Cross-component imports ARE happening at file-name granularity (`import { api } from '../api/client'`, `import { CanvasEditor } from '../annotations/CanvasEditor'`).
|
3. **Public API per component is the barrel `src/<component>/index.ts`** (AZ-485 / F4). Every component except `10_app-shell` (which is a top-level file collection — `App.tsx`, `main.tsx`, etc., never imported as a unit) exposes its Public API through a root barrel. Cross-component imports MUST go through the barrel — `import { api } from '../api'`, not `from '../api/client'`. The `STC-ARCH-01` static gate (`scripts/check-arch-imports.mjs`, wired into `scripts/run-tests.sh --static-only`) fails the build on cross-component deep imports. Intra-component imports (relative `./`) remain free. **One F3-pending exemption**: `src/features/annotations/classColors` is imported directly because the file is logically owned by `11_class-colors` but physically lives under `06_annotations`; re-exporting it through the `06_annotations` barrel creates a circular import (AnnotationsPage → DetectionClasses → 06_annotations barrel → AnnotationsPage). The exemption disappears when F3 moves the file.
|
||||||
4. Cross-cutting concerns (logging, config, error handling, telemetry): no dedicated infrastructure today. `console.error` / silent catches are the closest thing — recorded in module findings.
|
4. Cross-cutting concerns (logging, config, error handling, telemetry): no dedicated infrastructure today. `console.error` / silent catches are the closest thing — recorded in module findings.
|
||||||
5. Tests: there are **zero tests** under `src/`. The only test file is `mission-planner/src/test/jsonImport.test.ts`, which can't run because Jest isn't installed (00_discovery.md §11.5). Test layout is therefore TBD; suggest `src/<component>/__tests__/` per the standard React convention when tests are added (autodev Step 5–6).
|
5. Tests: there are **zero tests** under `src/`. The only test file is `mission-planner/src/test/jsonImport.test.ts`, which can't run because Jest isn't installed (00_discovery.md §11.5). Test layout is therefore TBD; suggest `src/<component>/__tests__/` per the standard React convention when tests are added (autodev Step 5–6).
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
- **Epic**: TBD (set during autodev Step 4 / Decompose)
|
- **Epic**: TBD (set during autodev Step 4 / Decompose)
|
||||||
- **Directories**: `src/types/`, `src/hooks/`, `src/i18n/`
|
- **Directories**: `src/types/`, `src/hooks/`, `src/i18n/`
|
||||||
- **Public API** (de-facto, no barrel):
|
- **Public API** (no `src/<component>/index.ts` barrel — `00_foundation` spans three sibling directories; the existing `src/types/index.ts` is the type-alias barrel and `src/hooks/` + `src/i18n/` are imported directly per file):
|
||||||
- `src/types/index.ts` — every exported type alias (`Detection`, `Flight`, `MediaItem`, `User`, etc.)
|
- `src/types/index.ts` — every exported type alias (`Detection`, `Flight`, `MediaItem`, `User`, etc.)
|
||||||
- `src/hooks/useDebounce.ts` — `useDebounce`
|
- `src/hooks/useDebounce.ts` — `useDebounce`
|
||||||
- `src/hooks/useResizablePanel.ts` — `useResizablePanel`
|
- `src/hooks/useResizablePanel.ts` — `useResizablePanel`
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
- **Epic**: TBD
|
- **Epic**: TBD
|
||||||
- **Directories**: (none today — physical file lives at `src/features/annotations/classColors.ts`, which is owned by `06_annotations` on disk). Logical owner is this component; physical move to `src/shared/classColors.ts` (or `src/components/detection/classColors.ts`) is a Step 4 testability task.
|
- **Directories**: (none today — physical file lives at `src/features/annotations/classColors.ts`, which is owned by `06_annotations` on disk). Logical owner is this component; physical move to `src/shared/classColors.ts` (or `src/components/detection/classColors.ts`) is a Step 4 testability task.
|
||||||
- **Public API**: `src/features/annotations/classColors.ts` exports `getClassColor`, `getClassNameFallback`, `getPhotoModeSuffix`, `FALLBACK_CLASS_NAMES`.
|
- **Public API**: `getClassColor`, `getClassNameFallback`, `getPhotoModeSuffix`, `FALLBACK_CLASS_NAMES` — exported from `src/features/annotations/classColors.ts`. **No barrel** today because the file is physically inside `06_annotations`; consumers import the path directly under the F3-pending exemption documented in Layout Rule #3 and enforced by STC-ARCH-01. When F3 moves the file to its own component directory, a `src/<new-home>/index.ts` barrel will replace the direct path import and the STC-ARCH-01 exemption will be removed.
|
||||||
- **Internal**: module-private `CLASS_COLORS` constant.
|
- **Internal**: module-private `CLASS_COLORS` constant.
|
||||||
- **Owns**: pending — see Verification Needed item #1.
|
- **Owns**: pending — see Verification Needed item #1.
|
||||||
- **Imports from**: (none — Layer 0/1, no internal imports)
|
- **Imports from**: (none — Layer 0/1, no internal imports)
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
|
|
||||||
- **Epic**: TBD
|
- **Epic**: TBD
|
||||||
- **Directory**: `src/api/`
|
- **Directory**: `src/api/`
|
||||||
- **Public API** (de-facto): `src/api/client.ts` exports `api` (fetch wrapper); `src/api/sse.ts` exports `subscribeSSE` / equivalent helper.
|
- **Public API** (via `src/api/index.ts` barrel): `api`, `setToken`, `getToken`, `getApiBase`, `setNavigateToLogin`, `createSSE`, `endpoints` (the typed URL-builder object that is the single source of truth for every `/api/<service>/...` path the UI talks to today — AZ-486 / F7; `STC-ARCH-02` enforces it).
|
||||||
- **Internal**: none (both files are externally consumed)
|
- **Internal**: none (every file is externally consumed; the colocated `endpoints.test.ts` IS the wire-contract documentation per `module-layout.md`'s "code-derived documentation" pattern).
|
||||||
- **Owns**: `src/api/**`
|
- **Owns**: `src/api/**`
|
||||||
- **Imports from**: `00_foundation` (types)
|
- **Imports from**: `00_foundation` (types)
|
||||||
- **Consumed by**: `02_auth`, `03_shared-ui`, every feature page (04, 05, 06, 07, 08, 09)
|
- **Consumed by**: `02_auth`, `03_shared-ui`, every feature page (04, 05, 06, 07, 08, 09)
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
- **Epic**: TBD
|
- **Epic**: TBD
|
||||||
- **Directory**: `src/auth/`
|
- **Directory**: `src/auth/`
|
||||||
- **Public API**: `src/auth/AuthContext.tsx` exports `AuthProvider`, `useAuth`. `src/auth/ProtectedRoute.tsx` exports `ProtectedRoute`.
|
- **Public API** (via `src/auth/index.ts` barrel): `AuthProvider`, `useAuth`, `ProtectedRoute`.
|
||||||
- **Internal**: none
|
- **Internal**: none
|
||||||
- **Owns**: `src/auth/**`
|
- **Owns**: `src/auth/**`
|
||||||
- **Imports from**: `00_foundation`, `01_api-transport`
|
- **Imports from**: `00_foundation`, `01_api-transport`
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
|
|
||||||
- **Epic**: TBD
|
- **Epic**: TBD
|
||||||
- **Directory**: `src/components/`
|
- **Directory**: `src/components/`
|
||||||
- **Public API** (de-facto, all are externally consumed):
|
- **Public API** (via `src/components/index.ts` barrel — all symbols externally consumed):
|
||||||
- `Header.tsx` → `Header`
|
- `Header.tsx` → `Header`
|
||||||
- `HelpModal.tsx` → `HelpModal`
|
- `HelpModal.tsx` → `HelpModal`
|
||||||
- `ConfirmDialog.tsx` → `ConfirmDialog`
|
- `ConfirmDialog.tsx` → `ConfirmDialog`
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
- **Epic**: TBD
|
- **Epic**: TBD
|
||||||
- **Directory**: `src/features/login/`
|
- **Directory**: `src/features/login/`
|
||||||
- **Public API**: `LoginPage.tsx` → `LoginPage`
|
- **Public API** (via `src/features/login/index.ts` barrel): `LoginPage`.
|
||||||
- **Internal**: none (single-page component)
|
- **Internal**: none (single-page component)
|
||||||
- **Owns**: `src/features/login/**`
|
- **Owns**: `src/features/login/**`
|
||||||
- **Imports from**: `00_foundation`, `01_api-transport`, `02_auth`
|
- **Imports from**: `00_foundation`, `01_api-transport`, `02_auth`
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
- **Directories** (TWO physical roots):
|
- **Directories** (TWO physical roots):
|
||||||
- `src/features/flights/` — deployed target tree (15 modules)
|
- `src/features/flights/` — deployed target tree (15 modules)
|
||||||
- `mission-planner/` — port-source, NOT deployed (37 modules under `mission-planner/src/`). Documented inside this component per the user's Step 2 BLOCKING-gate decision (`_docs/02_document/state.json::component_05_flights_merge_2026-05-10`). The port direction is `mission-planner/` → `src/features/flights/`; module-layout treats both trees as owned by this component but only the target tree is in the layering table below.
|
- `mission-planner/` — port-source, NOT deployed (37 modules under `mission-planner/src/`). Documented inside this component per the user's Step 2 BLOCKING-gate decision (`_docs/02_document/state.json::component_05_flights_merge_2026-05-10`). The port direction is `mission-planner/` → `src/features/flights/`; module-layout treats both trees as owned by this component but only the target tree is in the layering table below.
|
||||||
- **Public API** (target tree, de-facto): `FlightsPage.tsx` → `FlightsPage` (route component). Internal sub-components (`FlightMap`, `FlightParamsPanel`, `FlightListSidebar`, `WaypointList`, `AltitudeChart`, `AltitudeDialog`, `WindEffect`, `MiniMap`, `MapPoint`, `DrawControl`, `JsonEditorDialog`, `mapIcons`, `flightPlanUtils`, `types`) are NOT consumed outside the component.
|
- **Public API** (target tree, via `src/features/flights/index.ts` barrel): `FlightsPage` (route component). Internal sub-components (`FlightMap`, `FlightParamsPanel`, `FlightListSidebar`, `WaypointList`, `AltitudeChart`, `AltitudeDialog`, `WindEffect`, `MiniMap`, `MapPoint`, `DrawControl`, `JsonEditorDialog`, `mapIcons`, `flightPlanUtils`, `types`) are NOT re-exported through the barrel.
|
||||||
- **Public API** (port-source `mission-planner/`): not consumed at all by `src/` today (separate Vite entrypoint, `main.tsx` of its own). Effectively a private vendored sibling.
|
- **Public API** (port-source `mission-planner/`): not consumed at all by `src/` today (separate Vite entrypoint, `main.tsx` of its own). Effectively a private vendored sibling.
|
||||||
- **Internal** (target tree): every file under `src/features/flights/` except `FlightsPage.tsx`
|
- **Internal** (target tree): every file under `src/features/flights/` except `FlightsPage.tsx`
|
||||||
- **Internal** (port-source): every file under `mission-planner/`
|
- **Internal** (port-source): every file under `mission-planner/`
|
||||||
@@ -109,9 +109,10 @@
|
|||||||
|
|
||||||
- **Epic**: TBD
|
- **Epic**: TBD
|
||||||
- **Directory**: `src/features/annotations/`
|
- **Directory**: `src/features/annotations/`
|
||||||
- **Public API** (de-facto):
|
- **Public API** (via `src/features/annotations/index.ts` barrel):
|
||||||
- `AnnotationsPage.tsx` → `AnnotationsPage` (route component)
|
- `AnnotationsPage` (route component)
|
||||||
- `CanvasEditor.tsx` → `CanvasEditor` — **also imported by `07_dataset`** (cross-feature edge, see Verification Needed #3)
|
- `CanvasEditor` — **also imported by `07_dataset`** (cross-feature edge, see `architecture_compliance_baseline.md` F2). The barrel re-exports `CanvasEditor` to keep the consumer compliant with STC-ARCH-01 until F2 closes the edge.
|
||||||
|
- **NOT re-exported** through this barrel: `classColors` symbols (`getClassColor`, `getClassNameFallback`, `getPhotoModeSuffix`, `FALLBACK_CLASS_NAMES`). Re-exporting them would create a circular barrel import (`AnnotationsPage → DetectionClasses → 06_annotations barrel → AnnotationsPage`). Consumers import `src/features/annotations/classColors` directly under the F3-pending exemption recorded in Layout Rule #3 and in STC-ARCH-01.
|
||||||
- **Internal**: `MediaList.tsx`, `VideoPlayer.tsx`, `AnnotationsSidebar.tsx`
|
- **Internal**: `MediaList.tsx`, `VideoPlayer.tsx`, `AnnotationsSidebar.tsx`
|
||||||
- **Owns**: `src/features/annotations/**` EXCEPT `classColors.ts` (logically owned by `11_class-colors`; physical home pending refactor)
|
- **Owns**: `src/features/annotations/**` EXCEPT `classColors.ts` (logically owned by `11_class-colors`; physical home pending refactor)
|
||||||
- **Imports from**: `00_foundation`, `11_class-colors`, `01_api-transport`, `03_shared-ui`
|
- **Imports from**: `00_foundation`, `11_class-colors`, `01_api-transport`, `03_shared-ui`
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
|
|
||||||
- **Epic**: TBD
|
- **Epic**: TBD
|
||||||
- **Directory**: `src/features/dataset/`
|
- **Directory**: `src/features/dataset/`
|
||||||
- **Public API**: `DatasetPage.tsx` → `DatasetPage`
|
- **Public API** (via `src/features/dataset/index.ts` barrel): `DatasetPage`.
|
||||||
- **Internal**: none (single-page)
|
- **Internal**: none (single-page)
|
||||||
- **Owns**: `src/features/dataset/**`
|
- **Owns**: `src/features/dataset/**`
|
||||||
- **Imports from**: `00_foundation`, `11_class-colors` (only when class-distribution chart is added — not in code yet), `01_api-transport`, `03_shared-ui`, **`06_annotations` (CanvasEditor cross-feature edge)**
|
- **Imports from**: `00_foundation`, `11_class-colors` (only when class-distribution chart is added — not in code yet), `01_api-transport`, `03_shared-ui`, **`06_annotations` (CanvasEditor cross-feature edge)**
|
||||||
@@ -131,7 +132,7 @@
|
|||||||
|
|
||||||
- **Epic**: TBD
|
- **Epic**: TBD
|
||||||
- **Directory**: `src/features/admin/`
|
- **Directory**: `src/features/admin/`
|
||||||
- **Public API**: `AdminPage.tsx` → `AdminPage`
|
- **Public API** (via `src/features/admin/index.ts` barrel): `AdminPage`.
|
||||||
- **Internal**: none (single-page)
|
- **Internal**: none (single-page)
|
||||||
- **Owns**: `src/features/admin/**`
|
- **Owns**: `src/features/admin/**`
|
||||||
- **Imports from**: `00_foundation`, `01_api-transport`, `03_shared-ui`
|
- **Imports from**: `00_foundation`, `01_api-transport`, `03_shared-ui`
|
||||||
@@ -141,7 +142,7 @@
|
|||||||
|
|
||||||
- **Epic**: TBD
|
- **Epic**: TBD
|
||||||
- **Directory**: `src/features/settings/`
|
- **Directory**: `src/features/settings/`
|
||||||
- **Public API**: `SettingsPage.tsx` → `SettingsPage`
|
- **Public API** (via `src/features/settings/index.ts` barrel): `SettingsPage`.
|
||||||
- **Internal**: none (single-page)
|
- **Internal**: none (single-page)
|
||||||
- **Owns**: `src/features/settings/**`
|
- **Owns**: `src/features/settings/**`
|
||||||
- **Imports from**: `00_foundation`, `01_api-transport`, `03_shared-ui`
|
- **Imports from**: `00_foundation`, `01_api-transport`, `03_shared-ui`
|
||||||
@@ -151,7 +152,7 @@
|
|||||||
|
|
||||||
- **Epic**: TBD
|
- **Epic**: TBD
|
||||||
- **Files** (no dedicated directory): `src/App.tsx`, `src/main.tsx`, `src/index.css`, `src/vite-env.d.ts`
|
- **Files** (no dedicated directory): `src/App.tsx`, `src/main.tsx`, `src/index.css`, `src/vite-env.d.ts`
|
||||||
- **Public API**: `main.tsx` is the Vite entrypoint (no symbols are externally imported). `App.tsx` exports `App`.
|
- **Public API**: `main.tsx` is the Vite entrypoint (no symbols are externally imported). `App.tsx` exports `App`. **No barrel** — the component is a top-level file collection, never imported as a unit. STC-ARCH-01's component allowlist intentionally omits `10_app-shell`.
|
||||||
- **Internal**: `index.css` (global Tailwind base + `az-*` design-token CSS variables), `vite-env.d.ts` (type shim)
|
- **Internal**: `index.css` (global Tailwind base + `az-*` design-token CSS variables), `vite-env.d.ts` (type shim)
|
||||||
- **Owns**: `src/App.tsx`, `src/main.tsx`, `src/index.css`, `src/vite-env.d.ts`
|
- **Owns**: `src/App.tsx`, `src/main.tsx`, `src/index.css`, `src/vite-env.d.ts`
|
||||||
- **Imports from**: every other component (it is the composition root)
|
- **Imports from**: every other component (it is the composition root)
|
||||||
@@ -224,7 +225,9 @@ The following inferences could not be made cleanly from code alone. They are sur
|
|||||||
|
|
||||||
2. **Physical home of `CanvasEditor.tsx`**. Same shape: it lives under `06_annotations` and is consumed cross-feature by `07_dataset`. Proposed: `src/components/canvas/CanvasEditor.tsx` (or a new `06b_canvas` component). **Decision needed**: keep the same-layer cross-feature edge, or schedule the lift?
|
2. **Physical home of `CanvasEditor.tsx`**. Same shape: it lives under `06_annotations` and is consumed cross-feature by `07_dataset`. Proposed: `src/components/canvas/CanvasEditor.tsx` (or a new `06b_canvas` component). **Decision needed**: keep the same-layer cross-feature edge, or schedule the lift?
|
||||||
|
|
||||||
3. **No barrel exports anywhere**. The codebase imports cross-component at file-name granularity (`import { api } from '../api/client'`). This means every internal file is *de-facto* Public API. Recommendation: Step 4 testability task to add `src/<component>/index.ts` barrels per component, locking the public surface. **Decision needed**: add barrels now or stay file-import?
|
3. ~~No barrel exports anywhere~~ — **resolved by AZ-485 (F4)**. Every component now exposes a `src/<component>/index.ts` barrel; cross-component imports go through it; `STC-ARCH-01` enforces it. One F3-pending exemption (`classColors`) remains documented in Layout Rule #3 above and in `architecture_compliance_baseline.md`.
|
||||||
|
|
||||||
|
3a. ~~Hardcoded `/api/<service>/` URLs scattered across callsites~~ — **resolved by AZ-486 (F7)**. The single source of truth is `src/api/endpoints.ts` (re-exported via the `01_api-transport` barrel from rule #3). Every production callsite of `api.*` and `createSSE()` uses an `endpoints.*` builder; the colocated `src/api/endpoints.test.ts` pins every URL string and serves as the wire-contract documentation. The `STC-ARCH-02` static gate (`scripts/check-arch-imports.mjs --mode=api-literals`, wired into `scripts/run-tests.sh --static-only`) fails the build on any new hardcoded `/api/<service>/` literal under `src/`. Exemptions: `src/api/endpoints.ts` (the contract owner) and any `*.test.ts` / `*.test.tsx` under `src/` (test files are exempt because tests legitimately assert URL strings — MSW handlers, contract tests, etc.).
|
||||||
|
|
||||||
4. **`mission-planner/` is owned by `05_flights` but lives at the repo root** (not under `src/`). Layout rule #1 says one component owns one or more top-level directories — this satisfies the rule (it owns two: `src/features/flights/` AND `mission-planner/`). Implement-skill consumers must include `mission-planner/**` in `05_flights`'s OWNED glob. **Decision needed**: confirm the implement skill should treat `mission-planner/**` as OWNED by 05_flights (otherwise it's FORBIDDEN by default).
|
4. **`mission-planner/` is owned by `05_flights` but lives at the repo root** (not under `src/`). Layout rule #1 says one component owns one or more top-level directories — this satisfies the rule (it owns two: `src/features/flights/` AND `mission-planner/`). Implement-skill consumers must include `mission-planner/**` in `05_flights`'s OWNED glob. **Decision needed**: confirm the implement skill should treat `mission-planner/**` as OWNED by 05_flights (otherwise it's FORBIDDEN by default).
|
||||||
|
|
||||||
@@ -240,4 +243,4 @@ The following inferences could not be made cleanly from code alone. They are sur
|
|||||||
|
|
||||||
| Language | Root | Per-component path | Public API file | Test path |
|
| Language | Root | Per-component path | Public API file | Test path |
|
||||||
|----------|------|-------------------|-----------------|-----------|
|
|----------|------|-------------------|-----------------|-----------|
|
||||||
| TypeScript / React | `src/` | `src/<component>/` (this codebase deviates: features under `src/features/<feature>/`, shared chrome under `src/components/`) | `src/<component>/index.ts` (barrel — none exist today) | `src/<component>/__tests__/` (none exist today) |
|
| TypeScript / React | `src/` | `src/<component>/` (this codebase deviates: features under `src/features/<feature>/`, shared chrome under `src/components/`) | `src/<component>/index.ts` (barrel; present for every component except `10_app-shell` — see Layout Rule #3) | `src/<component>/__tests__/` (none exist today) |
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ export const api = {
|
|||||||
- `204` → `undefined as T`.
|
- `204` → `undefined as T`.
|
||||||
- `!res.ok` → throw `new Error(\`${status}: ${text || statusText}\`)`. Body text is read defensively (`.catch(() => '')`).
|
- `!res.ok` → throw `new Error(\`${status}: ${text || statusText}\`)`. Body text is read defensively (`.catch(() => '')`).
|
||||||
- Otherwise → `res.json()` (no schema validation — caller types the response).
|
- 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.
|
- `refreshToken()` — `POST endpoints.admin.authRefresh()` (i.e. `/api/admin/auth/refresh`) with `credentials: 'include'`. On 200, expects `{ token: string }` and stores it. Returns boolean. (Path produced by the `endpoints` builder; closes F7.)
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Internal**: none.
|
- **Internal**: `./endpoints` — `endpoints.admin.authRefresh()` used by the internal `refreshToken()` helper (since AZ-486 / F7).
|
||||||
- **External**: `fetch`, `Headers`, `FormData`, `Response` (browser globals). No npm runtime dependency.
|
- **External**: `fetch`, `Headers`, `FormData`, `Response` (browser globals). No npm runtime dependency.
|
||||||
|
|
||||||
## Consumers (intra-repo)
|
## Consumers (intra-repo)
|
||||||
@@ -71,9 +71,9 @@ None defined here. The generic `T` parameter is supplied by call sites.
|
|||||||
|
|
||||||
## Configuration
|
## 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.
|
URLs are produced by typed builders in `src/api/endpoints.ts` (see `src__api__endpoints.md`) — the F7 finding from the architecture baseline is now CLOSED. Every consumer (this module included) imports `endpoints` and calls `endpoints.<service>.<method>(...)`; the `STC-ARCH-02` static gate forbids re-introducing literal `/api/<service>/...` strings under `src/`.
|
||||||
|
|
||||||
A `VITE_API_BASE_URL` env-var fix is the canonical Step 4 testability candidate (workspace `README.md` calls this out).
|
There is no base-URL constant: the path strings are still relative. The `vite.config.ts` dev proxy and `nginx.conf` production rules forward `/api/*` to per-service backends. `getApiBase()` (exported from this module) supplies the host prefix at runtime where the consumer needs an absolute URL (e.g. the manual `fetch(getApiBase() + endpoints.admin.authRefresh(), ...)` call inside `refreshToken()`).
|
||||||
|
|
||||||
## External integrations
|
## External integrations
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Module: `src/api/endpoints.ts`
|
||||||
|
|
||||||
|
> **Source**: `src/api/endpoints.ts` (79 lines)
|
||||||
|
> **Topo batch**: B2 (leaf — no internal imports)
|
||||||
|
> **Introduced**: AZ-486 (2026-05-11, commit `8a461a2`), closing architecture baseline finding F7.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Single source of truth for every `/api/<service>/<path>` URL the UI talks to. Replaces the hardcoded string literals that previously lived at each `api.*` / `createSSE` call site (and at every `src={...}` URL for API-served images / videos). The `endpoints` object is the canonical wire-contract documentation: each builder produces a character-identical string to the literal it superseded, so MSW handlers + e2e stubs + the nginx routing table all keep matching.
|
||||||
|
|
||||||
|
Together with the `STC-ARCH-02` static gate (see [Configuration](#configuration)), this module enforces "no hardcoded API path literals in `src/`" as a build-time invariant rather than a code-review aspiration.
|
||||||
|
|
||||||
|
## Public interface
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const endpoints = {
|
||||||
|
admin: {
|
||||||
|
authRefresh: () => string
|
||||||
|
authLogin: () => string
|
||||||
|
authLogout: () => string
|
||||||
|
users: () => string
|
||||||
|
user: (id: string) => string
|
||||||
|
classes: () => string
|
||||||
|
class: (id: string | number) => string
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
classes: () => string
|
||||||
|
settingsUser: () => string
|
||||||
|
settingsSystem: () => string
|
||||||
|
settingsDirectories: () => string
|
||||||
|
annotations: () => string
|
||||||
|
annotationsByMedia: (mediaId: string, pageSize?: number) => string // pageSize default = 1000
|
||||||
|
annotationImage: (annotationId: string) => string
|
||||||
|
annotationThumbnail: (annotationId: string) => string
|
||||||
|
annotationEvents: () => string
|
||||||
|
media: (queryString: string) => string
|
||||||
|
mediaFile: (mediaId: string) => string
|
||||||
|
mediaItem: (mediaId: string) => string
|
||||||
|
mediaBatch: () => string
|
||||||
|
dataset: (queryString: string) => string
|
||||||
|
datasetItem: (annotationId: string) => string
|
||||||
|
datasetBulkStatus: () => string
|
||||||
|
datasetClassDistribution: () => string
|
||||||
|
},
|
||||||
|
flights: {
|
||||||
|
collection: (queryString?: string) => string // GET ?pageSize=... lists; POST (no qs) creates
|
||||||
|
aircrafts: () => string
|
||||||
|
aircraft: (id: string) => string
|
||||||
|
flight: (id: string) => string
|
||||||
|
flightWaypoints: (id: string) => string
|
||||||
|
flightWaypoint: (flightId: string, waypointId: string) => string
|
||||||
|
flightLiveGps: (id: string) => string
|
||||||
|
},
|
||||||
|
detect: {
|
||||||
|
media: (mediaId: string) => string // POST → trigger detection for a media item
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
```
|
||||||
|
|
||||||
|
The whole object is `as const`, so each leaf's return type is the narrow string literal where possible (e.g. `'/api/admin/auth/refresh'`) and the parameterised builders carry a `string` return.
|
||||||
|
|
||||||
|
## Internal logic
|
||||||
|
|
||||||
|
- **Pure data + template strings.** No side effects, no I/O, no caching. Every builder is a one-line `() => '...'` or arrow returning a template literal.
|
||||||
|
- **Function form (not constants)**, per direction at task-creation time:
|
||||||
|
- Parameterised paths (e.g. `flight(id)`) need a function anyway. Keeping every entry as a function — even the constant ones — gives a single uniform call shape at every site (`endpoints.x.y()`) so reviewers don't have to remember which entries take parens and which don't.
|
||||||
|
- Per-builder tree-shaking under Vite's production rollup remains intact.
|
||||||
|
- **Query strings owned by the caller for variable-shape paths.** Where the query is dynamic (`flights.collection`, `annotations.media`, `annotations.dataset`), the caller builds a `URLSearchParams.toString()` and the builder owns only the path + `?`. This keeps the wire contract identical to pre-refactor literals at every callsite.
|
||||||
|
|
||||||
|
## Public API (barrel re-export)
|
||||||
|
|
||||||
|
`src/api/index.ts` re-exports `endpoints` alongside `api`, `createSSE`, `setToken`, `getToken`, `getApiBase`, `setNavigateToLogin`. Consumers OUTSIDE the `01_api-transport` component MUST import from the barrel (`import { endpoints } from '@/api'` or `from '../api'`) — direct imports of `src/api/endpoints` from other components are blocked by `STC-ARCH-01` (F4 closure, see `src__api__client.md`).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **Internal**: none.
|
||||||
|
- **External**: none.
|
||||||
|
|
||||||
|
## Consumers (intra-repo)
|
||||||
|
|
||||||
|
After the AZ-486 migration, `endpoints` is imported by:
|
||||||
|
|
||||||
|
- `src/api/client.ts` — internal `refreshToken()` helper uses `endpoints.admin.authRefresh()`.
|
||||||
|
- `src/auth/AuthContext.tsx` — `authRefresh`, `authLogin`, `authLogout`.
|
||||||
|
- `src/components/FlightContext.tsx` — `flights.collection`, `flights.flight`, `annotations.settingsUser`.
|
||||||
|
- `src/components/DetectionClasses.tsx` — `admin.classes`, `admin.class`.
|
||||||
|
- `src/features/admin/AdminPage.tsx` — `admin.users`, `admin.user`.
|
||||||
|
- `src/features/annotations/AnnotationsPage.tsx` — annotation CRUD endpoints, `detect.media`.
|
||||||
|
- `src/features/annotations/AnnotationsSidebar.tsx` — `annotations.annotationEvents` (SSE), bulk-status, dataset endpoints.
|
||||||
|
- `src/features/annotations/CanvasEditor.tsx` — `annotations.annotationImage`, `annotations.annotationThumbnail`.
|
||||||
|
- `src/features/annotations/MediaList.tsx` — `annotations.media`, `annotations.mediaFile`, `annotations.mediaItem`, `annotations.mediaBatch`.
|
||||||
|
- `src/features/annotations/VideoPlayer.tsx` — `annotations.mediaFile`.
|
||||||
|
- `src/features/dataset/DatasetPage.tsx` — `annotations.dataset*` family, `annotations.classes`, `annotations.annotationImage`.
|
||||||
|
- `src/features/flights/FlightsPage.tsx` — full `flights.*` surface + `annotations.settingsUser`.
|
||||||
|
- `src/features/settings/SettingsPage.tsx` — `annotations.settings*`, `flights.aircrafts`.
|
||||||
|
|
||||||
|
This is the full intra-repo consumer list — `STC-ARCH-02` guarantees no production-source caller falls outside it.
|
||||||
|
|
||||||
|
## Data models
|
||||||
|
|
||||||
|
None defined here. Path-string output only.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The module IS the API-path configuration. The only "config" is the nginx routing table that maps each `/api/<service>/...` prefix to a concrete backend service — see `src__api__client.md` → External integrations for the live table.
|
||||||
|
|
||||||
|
**Static enforcement (`STC-ARCH-02`)**:
|
||||||
|
|
||||||
|
- **Script**: `scripts/check-arch-imports.mjs --mode=api-literals`.
|
||||||
|
- **Wired into**: `scripts/run-tests.sh` (functional profile, static group) — runs before any unit test.
|
||||||
|
- **What it forbids**: any `/api/<service>/...` literal in `[`'"]` quoting under `src/`.
|
||||||
|
- **Exempt files**: this file (`src/api/endpoints.ts`) and `src/**/*.test.ts(x)` only.
|
||||||
|
- **Bypass policy**: none. Adding a new exempt path requires updating the exempt regex in the script AND a `module-layout.md` rule revision in the same commit.
|
||||||
|
|
||||||
|
## External integrations
|
||||||
|
|
||||||
|
This module integrates nothing directly. It documents — as TypeScript values — the wire contract for every external integration the SPA has, as routed by `nginx.conf`. See the routing table in `src__api__client.md` → External integrations for the per-prefix backend mapping.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **No bearer plumbing here.** Token injection still happens in `client.ts` (`Authorization` header) and `sse.ts` (`access_token` query parameter). Builders return URLs **without** the token.
|
||||||
|
- **No URL-encoding** of interpolated `id` / `mediaId` / `queryString` parameters. All current callsites pass already-safe values (UUIDs, ints, pre-built `URLSearchParams.toString()` output). If any future caller passes user-controlled text, the builder must add `encodeURIComponent` (see open question below).
|
||||||
|
- **No CSRF surface change** — same posture as the pre-refactor literals.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **`src/api/endpoints.test.ts`** (36 Vitest assertions): pins every builder's output to its exact pre-refactor URL string. This is the contract documentation — any wire-contract change MUST update this test in the same commit as the backend / MSW / e2e stub change. Includes one barrel-re-export assertion (`endpoints` is reachable via `import { endpoints } from '../api'`).
|
||||||
|
- **`tests/architecture_imports.test.ts`** (AZ-486 / STC-ARCH-02 suite, 6 cases): verifies the static gate passes on the migrated codebase AND fails when a synthetic single-quoted / double-quoted / template-literal `/api/<service>/...` literal is introduced in `src/`. Also verifies the `*.test.ts` and `//` comment exemptions.
|
||||||
|
|
||||||
|
## Notes / open questions
|
||||||
|
|
||||||
|
- **`detect.media` only exposes the single-segment path** that the UI uses today (`POST /api/detect/<mediaId>`). The full `detect/` service has more endpoints (per the nginx table) but no UI callsite consumes them. Add new builders only when a real callsite needs them — don't pre-populate.
|
||||||
|
- **`flights.collection` overloads its return** on whether `queryString` is provided. Acceptable while the contract is "GET with `?pageSize`, POST without" — but if a third flights-collection verb (DELETE? PUT?) is ever added with its own query shape, split into named builders rather than threading more conditional logic through one.
|
||||||
|
- **No URL-encoding of interpolated params** (see Security). Add `encodeURIComponent` at the first callsite that needs it, plus a contract-test case in `endpoints.test.ts`. Currently safe across all 36 pinned URLs.
|
||||||
|
- **Wire-contract test coverage is exact-string, not shape.** This is deliberate: a "looks like a path" matcher would silently accept a hyphen-to-underscore change that breaks the backend. Updating these strings IS a wire-contract change — treat the test as a release-gate.
|
||||||
@@ -49,7 +49,7 @@ None defined here. The generic `T` is supplied by the caller.
|
|||||||
|
|
||||||
## Configuration
|
## 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.
|
URLs are passed in by callers. Since AZ-486 / F7 (commit `8a461a2`), callers obtain those URLs from `endpoints.*` builders in `src/api/endpoints.ts` rather than from inline string literals. The `STC-ARCH-02` static gate enforces this at every callsite under `src/`. `createSSE` itself is path-agnostic and takes any `url` — the `endpoints` discipline is upheld at the call site, not here.
|
||||||
|
|
||||||
## External integrations
|
## External integrations
|
||||||
|
|
||||||
|
|||||||
@@ -34,18 +34,18 @@ State:
|
|||||||
**Bootstrap effect (mount-only)**:
|
**Bootstrap effect (mount-only)**:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
api.get<{ user: AuthUser; token: string }>('/api/admin/auth/refresh')
|
api.get<{ user: AuthUser; token: string }>(endpoints.admin.authRefresh())
|
||||||
.then(data => { setToken(data.token); setUser(data.user) })
|
.then(data => { setToken(data.token); setUser(data.user) })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false))
|
.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`.
|
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`. The path string itself is unaffected by AZ-486 — `endpoints.admin.authRefresh()` produces `'/api/admin/auth/refresh'` character-identically to the pre-refactor literal, so the divergence is structural, not URL-based.
|
||||||
|
|
||||||
**`login(email, password)`**:
|
**`login(email, password)`**:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const data = await api.post<{ token; user }>('/api/admin/auth/login', { email, password })
|
const data = await api.post<{ token; user }>(endpoints.admin.authLogin(), { email, password })
|
||||||
setToken(data.token); setUser(data.user)
|
setToken(data.token); setUser(data.user)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ Throws to caller (LoginPage) on bad credentials.
|
|||||||
**`logout()`**:
|
**`logout()`**:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
try { await api.post('/api/admin/auth/logout') } catch {}
|
try { await api.post(endpoints.admin.authLogout()) } catch {}
|
||||||
setToken(null); setUser(null)
|
setToken(null); setUser(null)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ Network failure on logout is silently swallowed because we want to clear local a
|
|||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Internal**:
|
- **Internal**:
|
||||||
- `../api/client` — `api`, `setToken`.
|
- `../api` (barrel) — `api`, `endpoints`, `setToken`. (Since AZ-485 / F4 + AZ-486 / F7: barrel import + endpoint builders.)
|
||||||
- `../types` — `AuthUser` type.
|
- `../types` — `AuthUser` type.
|
||||||
- **External**: `react` (`createContext`, `useContext`, `useState`, `useCallback`, `useEffect`, `ReactNode`).
|
- **External**: `react` (`createContext`, `useContext`, `useState`, `useCallback`, `useEffect`, `ReactNode`).
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ From the §7a dependency graph:
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Endpoints (string-literal): `/api/admin/auth/refresh`, `/api/admin/auth/login`, `/api/admin/auth/logout`. Routed by `nginx.conf` to the `admin/` service.
|
Endpoints (typed builders from `../api/endpoints`, since AZ-486 / F7): `endpoints.admin.authRefresh()`, `endpoints.admin.authLogin()`, `endpoints.admin.authLogout()` — producing `/api/admin/auth/refresh`, `.../login`, `.../logout` respectively. 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`).
|
No env vars consumed directly — token storage policy is defined in `client.ts` (in-memory; not persisted to `localStorage`).
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Fully controlled — the parent owns `selectedClassNum` and `photoMode`. The cur
|
|||||||
## Internal logic
|
## Internal logic
|
||||||
|
|
||||||
- **Class catalogue load** (mount-only `useEffect`):
|
- **Class catalogue load** (mount-only `useEffect`):
|
||||||
- `api.get<DetectionClass[]>('/api/annotations/classes')`.
|
- `api.get<DetectionClass[]>(endpoints.annotations.classes())` (= `/api/annotations/classes`, since AZ-486 / F7).
|
||||||
- On a non-empty array → `setClasses(list)`.
|
- On a non-empty array → `setClasses(list)`.
|
||||||
- On an empty array OR a thrown error → `setClasses(FALLBACK_CLASSES)`.
|
- On an empty array OR a thrown error → `setClasses(FALLBACK_CLASSES)`.
|
||||||
- **`FALLBACK_CLASSES`** is a module-private 3 × |`FALLBACK_CLASS_NAMES`| matrix:
|
- **`FALLBACK_CLASSES`** is a module-private 3 × |`FALLBACK_CLASS_NAMES`| matrix:
|
||||||
@@ -45,8 +45,8 @@ Fully controlled — the parent owns `selectedClassNum` and `photoMode`. The cur
|
|||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Internal**:
|
- **Internal**:
|
||||||
- `../api/client` — `api.get<T>()`.
|
- `../api` (barrel) — `api`, `endpoints`. (Since AZ-485 / F4 + AZ-486 / F7.)
|
||||||
- `../features/annotations/classColors` — `getClassColor(i)`, `FALLBACK_CLASS_NAMES`.
|
- `../features/annotations/classColors` — `getClassColor(i)`, `FALLBACK_CLASS_NAMES`. (Cross-component import preserved; flagged in Consumers below.)
|
||||||
- `../types` — `DetectionClass` type.
|
- `../types` — `DetectionClass` type.
|
||||||
- **External**: `react`, `react-i18next`, `react-icons/md`, `react-icons/fa`.
|
- **External**: `react`, `react-i18next`, `react-icons/md`, `react-icons/fa`.
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ This is the **canonical example** of the cross-layer import flagged in `_docs/02
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Endpoint: `/api/annotations/classes` — string-literal URL (testability fix scheduled for Step 4).
|
Endpoint: `endpoints.annotations.classes()` → `/api/annotations/classes` (typed builder from `../api/endpoints`, since AZ-486 / F7).
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ Tailwind tokens: `bg-az-orange` (Regular), `bg-az-blue` (Winter), `bg-purple-600
|
|||||||
|
|
||||||
## External integrations
|
## External integrations
|
||||||
|
|
||||||
- HTTP `GET /api/annotations/classes` → `DetectionClass[]`. Backed by the `annotations/` service (`.NET`) per `nginx.conf`.
|
- HTTP `GET endpoints.annotations.classes()` (= `/api/annotations/classes`) → `DetectionClass[]`. Backed by the `annotations/` service (`.NET`) per `nginx.conf`.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ export function FlightProvider({ children }: { children: ReactNode }): JSX.Eleme
|
|||||||
|
|
||||||
State:
|
State:
|
||||||
|
|
||||||
- `flights: Flight[]` — most recent list returned by `GET /api/flights?pageSize=1000`.
|
- `flights: Flight[]` — most recent list returned by `GET endpoints.flights.collection('pageSize=1000')` (= `/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.
|
- `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):
|
**`refreshFlights()`** (`useCallback`, no deps):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const data = await api.get<{ items: Flight[] }>('/api/flights?pageSize=1000')
|
const data = await api.get<{ items: Flight[] }>(endpoints.flights.collection('pageSize=1000'))
|
||||||
setFlights(data.items ?? [])
|
setFlights(data.items ?? [])
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -42,8 +42,8 @@ Errors are silently swallowed (`try { ... } catch {}`). `pageSize=1000` is a har
|
|||||||
**Bootstrap effect** (`useEffect` keyed on `[refreshFlights]`, runs once because `refreshFlights` is `useCallback([])`):
|
**Bootstrap effect** (`useEffect` keyed on `[refreshFlights]`, runs once because `refreshFlights` is `useCallback([])`):
|
||||||
|
|
||||||
1. `refreshFlights()` (no `await` — runs in parallel with #2).
|
1. `refreshFlights()` (no `await` — runs in parallel with #2).
|
||||||
2. `api.get<UserSettings>('/api/annotations/settings/user')` →
|
2. `api.get<UserSettings>(endpoints.annotations.settingsUser())` →
|
||||||
- if `settings?.selectedFlightId` is truthy: `api.get<Flight>('/api/flights/${settings.selectedFlightId}')` → `setSelectedFlight(f)`.
|
- if `settings?.selectedFlightId` is truthy: `api.get<Flight>(endpoints.flights.flight(settings.selectedFlightId))` → `setSelectedFlight(f)`.
|
||||||
- errors at every step silently swallowed.
|
- 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.
|
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.
|
||||||
@@ -52,7 +52,7 @@ The selected flight is therefore looked up by **its own GET**, not by indexing i
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
setSelectedFlight(f)
|
setSelectedFlight(f)
|
||||||
api.put('/api/annotations/settings/user', { selectedFlightId: f?.id ?? null }).catch(() => {})
|
api.put(endpoints.annotations.settingsUser(), { 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.
|
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.
|
||||||
@@ -60,7 +60,7 @@ Optimistic — local state updates immediately; the persisted setting is fire-an
|
|||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Internal**:
|
- **Internal**:
|
||||||
- `../api/client` — `api`.
|
- `../api` (barrel) — `api`, `endpoints`. (Since AZ-485 / F4 + AZ-486 / F7: barrel import + endpoint builders.)
|
||||||
- `../types` — `Flight`, `UserSettings` types.
|
- `../types` — `Flight`, `UserSettings` types.
|
||||||
- **External**: `react` (`createContext`, `useContext`, `useState`, `useEffect`, `useCallback`, `ReactNode`).
|
- **External**: `react` (`createContext`, `useContext`, `useState`, `useEffect`, `useCallback`, `ReactNode`).
|
||||||
|
|
||||||
@@ -80,9 +80,9 @@ From the §7a dependency graph:
|
|||||||
|
|
||||||
## Configuration
|
## 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.
|
Endpoints (typed builders from `../api/endpoints`, since AZ-486 / F7): `endpoints.flights.collection('pageSize=1000')`, `endpoints.flights.flight(id)`, `endpoints.annotations.settingsUser()` — producing `/api/flights?pageSize=1000`, `/api/flights/{id}`, `/api/annotations/settings/user` respectively. 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.
|
`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. (Note: the literal lives in the caller, not in the `endpoints.flights.collection` builder — moving the ceiling into the builder is a future change.)
|
||||||
|
|
||||||
## External integrations
|
## External integrations
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ No props. Reads everything via `api/client` and local state.
|
|||||||
- **Bootstrap effect** (`useEffect([])` — runs once at mount):
|
- **Bootstrap effect** (`useEffect([])` — runs once at mount):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
|
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
|
||||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
api.get<User[]>('/api/admin/users').then(setUsers).catch(() => {})
|
api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
|
||||||
```
|
```
|
||||||
|
|
||||||
Three independent calls, all silently swallowed on error. No retry,
|
Three independent calls, all silently swallowed on error. No retry,
|
||||||
@@ -51,10 +51,11 @@ No props. Reads everything via `api/client` and local state.
|
|||||||
`_docs/ui_design/README.md`.
|
`_docs/ui_design/README.md`.
|
||||||
- **`handleAddClass()`**:
|
- **`handleAddClass()`**:
|
||||||
1. Guard: `if (!newClass.name) return`.
|
1. Guard: `if (!newClass.name) return`.
|
||||||
2. `await api.post('/api/admin/classes', newClass)`.
|
2. `await api.post(endpoints.admin.classes(), newClass)` (= `/api/admin/classes`).
|
||||||
3. Refetch via `api.get('/api/annotations/classes')` — note the
|
3. Refetch via `api.get(endpoints.annotations.classes())` — note the
|
||||||
**read** path is the public `annotations/` endpoint, while the
|
**read** path is the public `annotations/` endpoint
|
||||||
**write** path is the `admin/` endpoint. Architectural caveat:
|
(`/api/annotations/classes`), while the **write** path is the
|
||||||
|
`admin/` endpoint (`/api/admin/classes`). Architectural caveat:
|
||||||
two different services own the same logical entity. Document in
|
two different services own the same logical entity. Document in
|
||||||
`architecture.md` §integration-points (Step 3a).
|
`architecture.md` §integration-points (Step 3a).
|
||||||
4. Reset `newClass` to its initial values.
|
4. Reset `newClass` to its initial values.
|
||||||
@@ -62,25 +63,26 @@ No props. Reads everything via `api/client` and local state.
|
|||||||
non-2xx); the throw is uncaught and reaches React's error boundary
|
non-2xx); the throw is uncaught and reaches React's error boundary
|
||||||
(none configured). Flag.
|
(none configured). Flag.
|
||||||
- **`handleDeleteClass(id)`**: optimistic local update —
|
- **`handleDeleteClass(id)`**: optimistic local update —
|
||||||
`await api.delete('/api/admin/classes/${id}')` then
|
`await api.delete(endpoints.admin.class(id))` (= `/api/admin/classes/${id}`)
|
||||||
`setClasses(prev => prev.filter(c => c.id !== id))`. **No
|
then `setClasses(prev => prev.filter(c => c.id !== id))`. **No
|
||||||
ConfirmDialog** despite this being destructive. Inconsistent with
|
ConfirmDialog** despite this being destructive. Inconsistent with
|
||||||
the user-deactivation flow which uses ConfirmDialog. Flag for Step 4
|
the user-deactivation flow which uses ConfirmDialog. Flag for Step 4
|
||||||
against `_docs/ui_design/README.md` confirmation-dialog spec.
|
against `_docs/ui_design/README.md` confirmation-dialog spec.
|
||||||
- **`handleAddUser()`** — analogous to `handleAddClass` against
|
- **`handleAddUser()`** — analogous to `handleAddClass` against
|
||||||
`POST /api/admin/users` and `GET /api/admin/users`. Guards on
|
`POST endpoints.admin.users()` and `GET endpoints.admin.users()`
|
||||||
`email && password`.
|
(both → `/api/admin/users`). Guards on `email && password`.
|
||||||
- **`handleDeactivate()`** — fired from the ConfirmDialog confirm:
|
- **`handleDeactivate()`** — fired from the ConfirmDialog confirm:
|
||||||
1. `PATCH /api/admin/users/${deactivateId}` with `{ isActive: false }`.
|
1. `PATCH endpoints.admin.user(deactivateId)` (= `/api/admin/users/${deactivateId}`) with `{ isActive: false }`.
|
||||||
2. Optimistic local update: marks the row inactive.
|
2. Optimistic local update: marks the row inactive.
|
||||||
3. Closes the dialog (`setDeactivateId(null)`).
|
3. Closes the dialog (`setDeactivateId(null)`).
|
||||||
No "reactivate" path — once `isActive: false`, the row only renders
|
No "reactivate" path — once `isActive: false`, the row only renders
|
||||||
the badge and no Deactivate button. Verify with `admin/` service:
|
the badge and no Deactivate button. Verify with `admin/` service:
|
||||||
is reactivation an admin task or out of scope?
|
is reactivation an admin task or out of scope?
|
||||||
- **`handleToggleDefault(a)`** — `PATCH /api/flights/aircrafts/${a.id}`
|
- **`handleToggleDefault(a)`** — `PATCH endpoints.flights.aircraft(a.id)`
|
||||||
with `{ isDefault: !a.isDefault }`, then optimistic local flip. Note
|
(= `/api/flights/aircrafts/${a.id}`) with `{ isDefault: !a.isDefault }`,
|
||||||
this allows multiple `isDefault: true` aircraft to coexist (the
|
then optimistic local flip. Note this allows multiple `isDefault:
|
||||||
backend should enforce exclusivity; the UI does not).
|
true` aircraft to coexist (the backend should enforce exclusivity;
|
||||||
|
the UI does not).
|
||||||
- **Layout** (left → center → right, all in one horizontal flex):
|
- **Layout** (left → center → right, all in one horizontal flex):
|
||||||
- **Left column** (`w-[340px]`): detection-classes table + add row.
|
- **Left column** (`w-[340px]`): detection-classes table + add row.
|
||||||
- **Center column** (`flex-1 max-w-md`): AI settings form, GPS
|
- **Center column** (`flex-1 max-w-md`): AI settings form, GPS
|
||||||
@@ -93,7 +95,7 @@ No props. Reads everything via `api/client` and local state.
|
|||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Internal**:
|
- **Internal**:
|
||||||
- `../../api/client` — `api`.
|
- `../../api` (barrel) — `api`, `endpoints`. (Since AZ-485 / F4 + AZ-486 / F7.)
|
||||||
- `../../components/ConfirmDialog` — for user deactivation.
|
- `../../components/ConfirmDialog` — for user deactivation.
|
||||||
- `../../types` — `DetectionClass`, `Aircraft`, `User`.
|
- `../../types` — `DetectionClass`, `Aircraft`, `User`.
|
||||||
- **External**: `react` (`useState`, `useEffect`),
|
- **External**: `react` (`useState`, `useEffect`),
|
||||||
@@ -137,19 +139,18 @@ backend assigns `id` and other server-managed fields.
|
|||||||
|
|
||||||
## External integrations
|
## External integrations
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Builder → Path | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/annotations/classes` | List detection classes (read path uses annotations service) |
|
| `GET` | `endpoints.annotations.classes()` → `/api/annotations/classes` | List detection classes (read path uses annotations service) |
|
||||||
| `POST` | `/api/admin/classes` | Create detection class (write path uses admin service) |
|
| `POST` | `endpoints.admin.classes()` → `/api/admin/classes` | Create detection class (write path uses admin service) |
|
||||||
| `DELETE` | `/api/admin/classes/{id}` | Delete detection class |
|
| `DELETE` | `endpoints.admin.class(id)` → `/api/admin/classes/{id}` | Delete detection class |
|
||||||
| `GET` | `/api/flights/aircrafts` | List aircraft |
|
| `GET` | `endpoints.flights.aircrafts()` → `/api/flights/aircrafts` | List aircraft |
|
||||||
| `PATCH` | `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
|
| `PATCH` | `endpoints.flights.aircraft(id)` → `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
|
||||||
| `GET` | `/api/admin/users` | List users |
|
| `GET` | `endpoints.admin.users()` → `/api/admin/users` | List users |
|
||||||
| `POST` | `/api/admin/users` | Create user |
|
| `POST` | `endpoints.admin.users()` → `/api/admin/users` | Create user |
|
||||||
| `PATCH` | `/api/admin/users/{id}` | Set `isActive: false` (deactivate) |
|
| `PATCH` | `endpoints.admin.user(id)` → `/api/admin/users/{id}` | Set `isActive: false` (deactivate) |
|
||||||
|
|
||||||
Routed by `nginx.conf` to `admin/`, `annotations/`, `flights/`
|
Path builders live in `src/api/endpoints.ts` (since AZ-486 / F7). Routed by `nginx.conf` to `admin/`, `annotations/`, `flights/` backends.
|
||||||
backends.
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,21 @@ Owns the `/annotations` route. Lets the user:
|
|||||||
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).
|
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.
|
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.
|
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.
|
5. Save the per-frame detection set back to `POST endpoints.annotations.annotations()` (= `/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`.
|
6. Stream the annotations sidebar from the `GET endpoints.annotations.annotationEvents()` (= `/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).
|
7. Trigger AI detection via `POST endpoints.detect.media(mediaId)` (= `/api/detect/{mediaId}`) — modal log overlay.
|
||||||
8. Download an annotation as YOLO `.txt` + a PNG of the frame with rectangles burned in.
|
8. Download an annotation as YOLO `.txt` + a PNG of the frame with rectangles burned in.
|
||||||
|
|
||||||
|
> All path strings produced by `endpoints.*` builders from `src/api/endpoints.ts` (since AZ-486 / F7).
|
||||||
|
|
||||||
## Module map
|
## Module map
|
||||||
|
|
||||||
| Module | Layer | Responsibility |
|
| Module | Layer | Responsibility |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `classColors.ts` | leaf | (already documented separately) Class-number → colour + photoMode-suffix lookup. |
|
| `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`. |
|
| `MediaList.tsx` | sub-component | Left panel media browser. Owns `media[]` state, debounced filter, dropzone upload, blob: local-mode fallback when backend POST fails. Calls `endpoints.annotations.media(qs)`, `endpoints.annotations.mediaItem(id)` (DELETE), `endpoints.annotations.mediaBatch()` (POST). |
|
||||||
| `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. |
|
| `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. Image / video bytes via `endpoints.annotations.mediaFile(id)`. |
|
||||||
| `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). |
|
| `AnnotationsSidebar.tsx` | sub-component | Right panel: SSE-driven annotation list (`endpoints.annotations.annotationEvents()` filtered by `mediaId`), AI detect button (`endpoints.detect.media(mediaId)`), 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. |
|
| `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. |
|
| `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. |
|
||||||
|
|
||||||
@@ -29,24 +31,26 @@ Owns the `/annotations` route. Lets the user:
|
|||||||
|
|
||||||
- **`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.
|
- **`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`.
|
- **`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.
|
- **AI detect endpoint**: `endpoints.detect.media(mediaId)` → `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.
|
- **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
|
## External integrations
|
||||||
|
|
||||||
| Endpoint / origin | Where | Direction | Notes |
|
| Builder → Path | Where | Direction | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET /api/annotations/media?flightId&name&pageSize=1000` | `MediaList.fetchMedia` | egress | Hardcoded ceiling 1000. |
|
| `endpoints.annotations.media(qs)` → `GET /api/annotations/media?flightId&name&pageSize=1000` | `MediaList.fetchMedia` | egress | Hardcoded ceiling 1000 (in caller). |
|
||||||
| `GET /api/annotations/media/{id}/file` | `CanvasEditor`, `VideoPlayer`, `AnnotationsPage.handleDownload` | egress | Image / video bytes. |
|
| `endpoints.annotations.mediaFile(id)` → `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`. |
|
| `endpoints.annotations.mediaBatch()` → `POST /api/annotations/media/batch` | `MediaList.uploadFiles` | egress | `multipart/form-data`. |
|
||||||
| `DELETE /api/annotations/media/{id}` | `MediaList.handleDelete` | egress | Skipped for local blob: entries. |
|
| `endpoints.annotations.mediaItem(id)` → `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. |
|
| `endpoints.annotations.annotationsByMedia(mediaId, 1000)` → `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. |
|
| `endpoints.annotations.annotations()` → `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. |
|
| `endpoints.annotations.annotationImage(id)` → `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`. |
|
| `endpoints.annotations.annotationEvents()` → `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`. |
|
| `endpoints.detect.media(mediaId)` → `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. |
|
| `URL.createObjectURL(File)` | `MediaList.uploadFiles`, `AnnotationsPage.handleDownload` | browser API | Local-mode blob URLs are revoked on delete or unmount. |
|
||||||
|
|
||||||
|
Path builders live in `src/api/endpoints.ts` (since AZ-486 / F7); `STC-ARCH-02` forbids re-introducing literal `/api/...` strings in `src/`.
|
||||||
|
|
||||||
## Findings carried into Step 4 / 6 / 8
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -16,25 +16,27 @@ Default-exported page component, no props. Mounts under `/dataset` in `App.tsx`.
|
|||||||
- **Filters**: `fromDate`, `toDate`, `statusFilter` (`AnnotationStatus`), `selectedClassNum` (from `DetectionClasses`), `objectsOnly` (boolean), `search` (400 ms debounced via `useDebounce`).
|
- **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)`.
|
- **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.
|
- **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 }`.
|
- **Validate**: `POST endpoints.annotations.datasetBulkStatus()` (= `/api/annotations/dataset/bulk-status`) with `{ annotationIds[], status: Validated }`.
|
||||||
- **Distribution**: lazy-loaded on tab switch via `GET /api/annotations/dataset/class-distribution`.
|
- **Distribution**: lazy-loaded on tab switch via `GET endpoints.annotations.datasetClassDistribution()` (= `/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`.
|
- **Editor tab** mounts `<CanvasEditor>` with a synthesised `Media` stub built from `editorAnnotation.mediaId` (most fields blank). Used so `CanvasEditor` can fetch the image via `endpoints.annotations.annotationImage(id)` (= `/api/annotations/annotations/{id}/image`).
|
||||||
|
|
||||||
## Dependencies
|
## 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`.
|
- Internal: `api` (barrel — `api`, `endpoints`, since AZ-485 / F4 + AZ-486 / F7), `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: `react`, `react-i18next`.
|
||||||
|
|
||||||
## External integrations
|
## External integrations
|
||||||
|
|
||||||
| Endpoint | Where | Notes |
|
| Builder → Path | Where | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET /api/annotations/dataset?...` | `fetchItems` | Filters: `page`, `pageSize`, `fromDate`, `toDate`, `flightId`, `status`, `classNum`, `hasDetections`, `name`. |
|
| `endpoints.annotations.dataset(qs)` → `/api/annotations/dataset?...` | `fetchItems` | Filters: `page`, `pageSize`, `fromDate`, `toDate`, `flightId`, `status`, `classNum`, `hasDetections`, `name` (caller builds `URLSearchParams.toString()`). |
|
||||||
| `GET /api/annotations/dataset/{id}` | `handleDoubleClick` | Loads full `AnnotationListItem` for the editor tab. |
|
| `endpoints.annotations.datasetItem(id)` → `/api/annotations/dataset/{id}` | `handleDoubleClick` | Loads full `AnnotationListItem` for the editor tab. |
|
||||||
| `POST /api/annotations/dataset/bulk-status` | `handleValidate` | Body: `{ annotationIds, status }`. |
|
| `endpoints.annotations.datasetBulkStatus()` → `/api/annotations/dataset/bulk-status` | `handleValidate` | Body: `{ annotationIds, status }`. |
|
||||||
| `GET /api/annotations/dataset/class-distribution` | `loadDistribution` | Class histogram. |
|
| `endpoints.annotations.datasetClassDistribution()` → `/api/annotations/dataset/class-distribution` | `loadDistribution` | Class histogram. |
|
||||||
| `GET /api/annotations/annotations/{id}/thumbnail` | grid tile `<img src=>` | One HTTP per visible tile. |
|
| `endpoints.annotations.annotationThumbnail(id)` → `/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. |
|
| `endpoints.annotations.annotationImage(id)` → `/api/annotations/annotations/{id}/image` | `CanvasEditor` (delegated) | When the editor is open. |
|
||||||
|
|
||||||
|
Path builders live in `src/api/endpoints.ts` (since AZ-486 / F7).
|
||||||
|
|
||||||
Spec contract is in parent suite `_docs/09_dataset_explorer.md`.
|
Spec contract is in parent suite `_docs/09_dataset_explorer.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Owns the `/flights` route. Lets the user:
|
Owns the `/flights` route. Lets the user:
|
||||||
1. Browse / create / delete `Flight` rows (`POST/DELETE /api/flights/...`).
|
1. Browse / create / delete `Flight` rows via `endpoints.flights.collection()` (POST) and `endpoints.flights.flight(id)` (DELETE).
|
||||||
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 %.
|
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).
|
3. Toggle into GPS-Denied mode — opens an SSE stream `endpoints.flights.flightLiveGps(id)` (= `/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`).
|
4. Save waypoints back to the Flights API via `endpoints.flights.flightWaypoints(id)` and `endpoints.flights.flightWaypoint(flightId, waypointId)`.
|
||||||
5. Import / export the plan as JSON.
|
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.
|
Currently handles only the planning surface; the gps-denied orthophoto upload / correction inputs in `_docs/ui_design/flights.html` are not yet implemented.
|
||||||
@@ -43,14 +43,14 @@ Currently handles only the planning surface; the gps-denied orthophoto upload /
|
|||||||
|
|
||||||
## External integrations
|
## External integrations
|
||||||
|
|
||||||
| Endpoint / origin | Where | Direction | Notes |
|
| Builder → Path | Where | Direction | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET /api/flights/aircrafts` | `FlightsPage` | egress | Aircraft selector population. |
|
| `endpoints.flights.aircrafts()` → `GET /api/flights/aircrafts` | `FlightsPage` | egress | Aircraft selector population. |
|
||||||
| `GET /api/flights/{id}/waypoints` | `FlightsPage` | egress | On flight select. |
|
| `endpoints.flights.flightWaypoints(id)` → `GET /api/flights/{id}/waypoints` | `FlightsPage` | egress | On flight select. |
|
||||||
| `POST /api/flights` | `FlightsPage` | egress | Create flight from sidebar. |
|
| `endpoints.flights.collection()` → `POST /api/flights` | `FlightsPage` | egress | Create flight from sidebar. |
|
||||||
| `DELETE /api/flights/{id}` | `FlightsPage` | egress | After ConfirmDialog. |
|
| `endpoints.flights.flight(id)` → `DELETE /api/flights/{id}` | `FlightsPage` | egress | After ConfirmDialog. |
|
||||||
| `POST/DELETE /api/flights/{id}/waypoints[/{wp}]` | `FlightsPage.handleSave` | egress | Delete-all-then-recreate, sequentially. |
|
| `endpoints.flights.flightWaypoints(id)` + `endpoints.flights.flightWaypoint(flightId, wp)` → `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. |
|
| `endpoints.flights.flightLiveGps(id)` → `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. |
|
| `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. |
|
| `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. |
|
| `server.arcgisonline.com/.../World_Imagery` (`TILE_URLS.satellite`) | `FlightMap`, `MiniMap` | egress | Direct, no proxy. |
|
||||||
@@ -86,7 +86,8 @@ These are the real findings; the per-module rationale is in git history of the d
|
|||||||
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.
|
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.
|
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.
|
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.
|
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 `endpoints.annotations.settingsUser()` (= `/api/annotations/settings/user`) fails the next page reload reverts the choice without notice. (Note: the underlying call goes to the annotations settings store, not a hypothetical `/api/flights/select`; see `src__components__FlightContext.md` for the actual PUT path.)
|
||||||
|
27. **Path builders (since AZ-486 / F7)**: every callsite in this page family now imports `endpoints` from `../../api` (barrel). The wire contract (the path strings) is unchanged; only the JS source surface migrated. Static gate `STC-ARCH-02` forbids re-introducing literal `/api/flights/...` strings.
|
||||||
|
|
||||||
## What's intentionally NOT here
|
## What's intentionally NOT here
|
||||||
|
|
||||||
|
|||||||
@@ -29,18 +29,20 @@ No props.
|
|||||||
|
|
||||||
- **State**:
|
- **State**:
|
||||||
- `system: SystemSettings | null` — loaded from
|
- `system: SystemSettings | null` — loaded from
|
||||||
`GET /api/annotations/settings/system`. `null` until the GET
|
`GET endpoints.annotations.settingsSystem()` (= `/api/annotations/settings/system`).
|
||||||
resolves; the panel does not render until then (`{system && (...)}`).
|
`null` until the GET resolves; the panel does not render until
|
||||||
|
then (`{system && (...)}`).
|
||||||
- `dirs: DirectorySettings | null` — analogous, from
|
- `dirs: DirectorySettings | null` — analogous, from
|
||||||
`GET /api/annotations/settings/directories`.
|
`GET endpoints.annotations.settingsDirectories()` (= `/api/annotations/settings/directories`).
|
||||||
- `aircrafts: Aircraft[]` — from `GET /api/flights/aircrafts`.
|
- `aircrafts: Aircraft[]` — from `GET endpoints.flights.aircrafts()`
|
||||||
|
(= `/api/flights/aircrafts`).
|
||||||
- `saving: boolean` — disables the two Save buttons during a PUT.
|
- `saving: boolean` — disables the two Save buttons during a PUT.
|
||||||
- **Bootstrap effect** (`useEffect([])`):
|
- **Bootstrap effect** (`useEffect([])`):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
api.get<SystemSettings>('/api/annotations/settings/system').then(setSystem).catch(() => {})
|
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
|
||||||
api.get<DirectorySettings>('/api/annotations/settings/directories').then(setDirs).catch(() => {})
|
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
|
||||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
```
|
```
|
||||||
|
|
||||||
Three independent calls, all silently swallowed on error. Empty UI
|
Three independent calls, all silently swallowed on error. Empty UI
|
||||||
@@ -48,7 +50,7 @@ No props.
|
|||||||
- **`saveSystem()`**:
|
- **`saveSystem()`**:
|
||||||
1. Guard: `if (!system) return`.
|
1. Guard: `if (!system) return`.
|
||||||
2. `setSaving(true)`.
|
2. `setSaving(true)`.
|
||||||
3. `await api.put('/api/annotations/settings/system', system)`.
|
3. `await api.put(endpoints.annotations.settingsSystem(), system)`.
|
||||||
4. `setSaving(false)`.
|
4. `setSaving(false)`.
|
||||||
|
|
||||||
No optimistic update needed (the PUT body **is** the local state).
|
No optimistic update needed (the PUT body **is** the local state).
|
||||||
@@ -56,10 +58,10 @@ No props.
|
|||||||
path is missing**: a thrown PUT leaves `saving: true` permanently
|
path is missing**: a thrown PUT leaves `saving: true` permanently
|
||||||
(no `try/finally`). Flag for Step 4.
|
(no `try/finally`). Flag for Step 4.
|
||||||
- **`saveDirs()`** — analogous against
|
- **`saveDirs()`** — analogous against
|
||||||
`PUT /api/annotations/settings/directories`. Same missing
|
`PUT endpoints.annotations.settingsDirectories()`. Same missing
|
||||||
`try/finally` issue.
|
`try/finally` issue.
|
||||||
- **`handleToggleDefault(a)`** — duplicate of the same handler in
|
- **`handleToggleDefault(a)`** — duplicate of the same handler in
|
||||||
`AdminPage`: `PATCH /api/flights/aircrafts/${a.id}` with
|
`AdminPage`: `PATCH endpoints.flights.aircraft(a.id)` with
|
||||||
`{ isDefault: !a.isDefault }` then optimistic local flip. Two copies
|
`{ isDefault: !a.isDefault }` then optimistic local flip. Two copies
|
||||||
of the same logic in two pages — extract to a shared helper or to
|
of the same logic in two pages — extract to a shared helper or to
|
||||||
`FlightContext` in Step 8 (the legacy WPF had a single
|
`FlightContext` in Step 8 (the legacy WPF had a single
|
||||||
@@ -79,7 +81,7 @@ No props.
|
|||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Internal**:
|
- **Internal**:
|
||||||
- `../../api/client` — `api`.
|
- `../../api` (barrel) — `api`, `endpoints`. (Since AZ-485 / F4 + AZ-486 / F7.)
|
||||||
- `../../types` — `SystemSettings`, `DirectorySettings`, `Aircraft`.
|
- `../../types` — `SystemSettings`, `DirectorySettings`, `Aircraft`.
|
||||||
- **External**: `react` (`useState`, `useEffect`),
|
- **External**: `react` (`useState`, `useEffect`),
|
||||||
`react-i18next` (`useTranslation`).
|
`react-i18next` (`useTranslation`).
|
||||||
@@ -117,16 +119,16 @@ No props.
|
|||||||
|
|
||||||
## External integrations
|
## External integrations
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Builder → Path | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/annotations/settings/system` | Load tenant config |
|
| `GET` | `endpoints.annotations.settingsSystem()` → `/api/annotations/settings/system` | Load tenant config |
|
||||||
| `PUT` | `/api/annotations/settings/system` | Save tenant config |
|
| `PUT` | `endpoints.annotations.settingsSystem()` → `/api/annotations/settings/system` | Save tenant config |
|
||||||
| `GET` | `/api/annotations/settings/directories` | Load directory paths |
|
| `GET` | `endpoints.annotations.settingsDirectories()` → `/api/annotations/settings/directories` | Load directory paths |
|
||||||
| `PUT` | `/api/annotations/settings/directories` | Save directory paths |
|
| `PUT` | `endpoints.annotations.settingsDirectories()` → `/api/annotations/settings/directories` | Save directory paths |
|
||||||
| `GET` | `/api/flights/aircrafts` | Load aircraft list |
|
| `GET` | `endpoints.flights.aircrafts()` → `/api/flights/aircrafts` | Load aircraft list |
|
||||||
| `PATCH` | `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
|
| `PATCH` | `endpoints.flights.aircraft(id)` → `/api/flights/aircrafts/{id}` | Toggle `isDefault` |
|
||||||
|
|
||||||
Routed by `nginx.conf` to `annotations/` and `flights/` backends.
|
Path builders live in `src/api/endpoints.ts` (since AZ-486 / F7). Routed by `nginx.conf` to `annotations/` and `flights/` backends.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Documentation Ripple Log — Cycle 1 (Phase B)
|
||||||
|
|
||||||
|
> Generated during Step 13 (Update Docs) of the autodev existing-code flow, cycle 1 (refactor-only).
|
||||||
|
> Task specs in scope: `AZ-485_phase_b_barrel_files.md`, `AZ-486_refactor_endpoint_builders.md` (both in `_docs/02_tasks/done/`).
|
||||||
|
|
||||||
|
## Scope analysis (Task Step 0)
|
||||||
|
|
||||||
|
Direct source files changed by Cycle 1 batches 9 + 10:
|
||||||
|
|
||||||
|
| Source file | Changed in | Touched module doc |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/api/client.ts` | AZ-485 + AZ-486 | `modules/src__api__client.md` |
|
||||||
|
| `src/api/sse.ts` | AZ-485 | `modules/src__api__sse.md` |
|
||||||
|
| `src/api/endpoints.ts` (NEW) | AZ-486 | `modules/src__api__endpoints.md` (NEW) |
|
||||||
|
| `src/api/index.ts` (barrel) | AZ-485 + AZ-486 | covered in `components/01_api-transport/description.md` §2 |
|
||||||
|
| `src/auth/AuthContext.tsx` | AZ-486 | `modules/src__auth__AuthContext.md` |
|
||||||
|
| `src/components/FlightContext.tsx` | AZ-486 | `modules/src__components__FlightContext.md` |
|
||||||
|
| `src/components/DetectionClasses.tsx` | AZ-486 | `modules/src__components__DetectionClasses.md` |
|
||||||
|
| `src/features/admin/AdminPage.tsx` | AZ-486 | `modules/src__features__admin__AdminPage.md` |
|
||||||
|
| `src/features/settings/SettingsPage.tsx` | AZ-486 | `modules/src__features__settings__SettingsPage.md` |
|
||||||
|
| `src/features/dataset/DatasetPage.tsx` | AZ-486 | `modules/src__features__dataset__DatasetPage.md` |
|
||||||
|
| `src/features/flights/FlightsPage.tsx` | AZ-486 | `modules/src__features__flights.md` (group doc) |
|
||||||
|
| `src/features/annotations/{AnnotationsPage,AnnotationsSidebar,CanvasEditor,MediaList,VideoPlayer}.tsx` | AZ-486 | `modules/src__features__annotations.md` (group doc) |
|
||||||
|
|
||||||
|
System-level docs (`system-flows.md`, `data_model.md`, `architecture.md`): **not touched** — cycle 1 was a pure structural refactor (import paths + URL-literal centralisation). No flow diagrams, no entity shapes, no integration patterns changed.
|
||||||
|
|
||||||
|
Problem-level docs: **not touched** — cycle 1 introduced no new product acceptance criteria, no new input parameters, no new restrictions.
|
||||||
|
|
||||||
|
## Import-graph ripple (Task Step 0.5)
|
||||||
|
|
||||||
|
The reverse-dependency set of the changed files is **already captured in the direct list above**. Specifically:
|
||||||
|
|
||||||
|
- `src/api/index.ts` (barrel) is imported by every consumer module that uses `api`, `endpoints`, `createSSE`, `setToken`, `getToken`. After AZ-485 those imports moved to the barrel; after AZ-486 they additionally pulled in `endpoints`. The barrel itself has no separate module doc — its public surface is enumerated in `components/01_api-transport/description.md` §2.
|
||||||
|
- `src/api/endpoints.ts` is imported by `src/api/client.ts` (for the internal `refreshToken()` helper) and by every consumer module already in the direct list. No additional ripple.
|
||||||
|
- `src/api/client.ts` is imported by the consumer modules already in the direct list; no further ripple.
|
||||||
|
|
||||||
|
Therefore: **no additional doc was added to the refresh set by ripple analysis**. The direct file set is closed under the import graph.
|
||||||
|
|
||||||
|
## Tooling notes
|
||||||
|
|
||||||
|
- Ripple analysis was performed by reading `src/api/index.ts` and the changed files directly, plus the existing `_docs/02_document/components/01_api-transport/description.md` "Downstream consumers" enumeration. The repo has no `madge` / `depcruise` configured; this counts as the "directory-proximity + manual import inspection" fallback path from `document/workflows/task.md` Task Step 0.5 #6 — but with full coverage of the import graph because the changed file set is small.
|
||||||
|
- No static analyzer was used to discover indirect importers. None was needed: the consumer set of `src/api/index.ts` is small and already enumerated in `01_api-transport/description.md`.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
All 12 affected module docs + 1 component doc + 1 NEW module doc updated in-place. Refresh set is complete.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Batch Report
|
||||||
|
|
||||||
|
**Batch**: 09 (Phase B cycle 1, batch 1 of 2)
|
||||||
|
**Tasks**: AZ-485 (Public API barrels + STC-ARCH-01)
|
||||||
|
**Date**: 2026-05-11
|
||||||
|
**Cycle**: Phase B feature cycle, Step 10 — Implement
|
||||||
|
**Total complexity**: 5 pts
|
||||||
|
**Epic**: AZ-447 (`01-testability-refactoring`)
|
||||||
|
**Closes**: architecture baseline finding **F4** (`_docs/02_document/architecture_compliance_baseline.md`)
|
||||||
|
|
||||||
|
## Task Results
|
||||||
|
|
||||||
|
| Task | Status | Files Modified / Added | Tests | AC Coverage | Issues |
|
||||||
|
|------|--------|------------------------|-------|-------------|--------|
|
||||||
|
| AZ-485_refactor_public_api_barrels | Done | **11 new barrels** (`src/{api,auth,components,hooks,i18n}/index.ts`, `src/features/{login,flights,annotations,dataset,admin,settings}/index.ts`); **1 new script** (`scripts/check-arch-imports.mjs`); **1 new test** (`tests/architecture_imports.test.ts`); **1 modified runner** (`scripts/run-tests.sh` — `STC-ARCH-01` wired in); **17 production import sites** migrated to barrel paths (App.tsx + every feature page + every `src/components/` consumer); **22 test/colocated test import sites** migrated; **1 doc** (`_docs/02_document/module-layout.md`) — Layout Rules #3 rewritten, Verification Needed #3 closed, every component's Public API line points to its barrel | 4 new architecture tests in `tests/architecture_imports.test.ts` (AC-4 / AC-5 + 2 exemption cases); fast profile re-baselined from 163 → 167 passes (no regressions) | 7 / 7 ACs covered | One **F3-pending exemption** carried forward: `src/features/annotations/classColors` is imported directly (not through the `06_annotations` barrel) to avoid a circular import; documented in the barrel, the consumers, the static check, the module-layout doc, and the new test |
|
||||||
|
|
||||||
|
## AC Test Coverage: All 7 ACs covered
|
||||||
|
|
||||||
|
| AC | Where | Profile | Status |
|
||||||
|
|----|-------|---------|--------|
|
||||||
|
| AC-1 — Every component has a barrel exposing only its Public API | `src/<component>/index.ts` × 11 vs `module-layout.md` Per-Component Mapping → Public API | static (manual cross-check in self-review) | PASS — each barrel's re-export list matches the documented Public API line one-for-one; no internal-only symbol leaks |
|
||||||
|
| AC-2 — No cross-component deep imports remain in production code | `scripts/check-arch-imports.mjs` scanning `src/` | static (`STC-ARCH-01`) | PASS — 0 deep imports outside the documented F3 exemption |
|
||||||
|
| AC-3 — No cross-component deep imports remain in tests | same script scanning `tests/` + `e2e/` | static (`STC-ARCH-01`) | PASS — 0 deep imports outside the documented F3 exemption |
|
||||||
|
| AC-4 — Static gate fails on a newly-introduced deep import | `tests/architecture_imports.test.ts` `AC-4: FAILS when a deep import...` + `AC-4: deep imports inside line comments do not trip the gate` | fast | PASS — the synthetic fixture (`tests/_arch_fixtures/synthetic_deep_import.ts`) flips the script to exit non-zero and emits `STC-ARCH-01 — ...` on stderr |
|
||||||
|
| AC-5 — Static gate passes on the migrated codebase | `tests/architecture_imports.test.ts` `AC-5: passes on the migrated codebase` + `STC-ARCH-01` run in the static profile | fast + static | PASS — exit code 0, stderr empty |
|
||||||
|
| AC-6 — Fast profile remains green | `bash scripts/run-tests.sh` (static + fast) | static + fast | PASS — 167 / 13 / 0 (baseline was 163 / 13 / 0 + 4 new architecture tests); 0 regressions |
|
||||||
|
| AC-7 — module-layout.md reflects the new convention | `_docs/02_document/module-layout.md` Layout Rules #3 + Verification Needed #3 + Conventions table + every component's Public API line | manual review | PASS — Rule #3 names the barrel as the Public API, names `STC-ARCH-01` as the enforcing gate, and the F3-pending exemption is documented inline; Verification Needed #3 marked closed by AZ-485 |
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
1. **Single source of truth for the static check** — `scripts/check-arch-imports.mjs` mirrors the existing `scripts/check-banned-deps.mjs` pattern (AZ-482). The bash function `static_check_no_cross_component_deep_imports` in `scripts/run-tests.sh` is a one-line delegate. The new unit test invokes the script directly with `spawnSync`, so a regex regression in the script trips the test even if the bash glue still reports PASS.
|
||||||
|
2. **classColors exemption is structural, not stylistic** — Re-exporting `classColors` symbols through the `06_annotations` barrel creates a runtime circular import (`AnnotationsPage → DetectionClasses → 06_annotations barrel → AnnotationsPage`) that materializes as `FALLBACK_CLASS_NAMES === undefined` inside `DetectionClasses`. The exemption is documented in five places (the barrel file, the consumer file, the static-check script's `EXEMPT_RE` comment, `module-layout.md` Layout Rule #3, and the architecture test) so it cannot be forgotten when F3 lands.
|
||||||
|
3. **`10_app-shell` intentionally has no barrel** — The component is a collection of root-level files (`App.tsx`, `main.tsx`, `index.css`, `vite-env.d.ts`) never imported as a unit. STC-ARCH-01's component allowlist (`api|auth|components|features/[a-z-]+|hooks|i18n`) intentionally omits app-shell; the doc records this explicitly.
|
||||||
|
4. **Test-file deep-import string concatenation** — `tests/architecture_imports.test.ts` builds its synthetic offending strings via concatenation (`'fr' + 'om'`, `'..' + '/..'`) so the scanner does not flag the test source itself when it walks `tests/`. The fixtures created at runtime go under `tests/_arch_fixtures/` and are torn down in `afterEach`.
|
||||||
|
|
||||||
|
## Code Review Verdict: PASS
|
||||||
|
|
||||||
|
Self-review (implement skill Step 9 / 10), applied to the 13 new + 17 production + 22 test + 1 runner + 1 doc + 1 script changes:
|
||||||
|
|
||||||
|
- **0 Critical, 0 High, 0 Medium, 0 Low findings.**
|
||||||
|
- **Scope discipline**: every modified file is one of (barrel author, deep-import consumer, static-check author, doc author). The 4 originally-untracked-and-edited test files (`annotations_endpoint`, `destructive_ux`, `form_hygiene`, `overlay_membership`) are pre-existing committed test files where the only edit is import-path migration.
|
||||||
|
- **No silent error suppression**: `check-arch-imports.mjs` writes the full hit list to stderr before exiting non-zero; the bash delegate propagates the exit code; `run-tests.sh` records the failure into the static CSV.
|
||||||
|
- **Single-responsibility**: each barrel re-exports its component's documented Public API only. `check-arch-imports.mjs` has one job (detect cross-component deep imports). The new test exercises only that script.
|
||||||
|
- **No new dependencies**: `check-arch-imports.mjs` uses Node stdlib (`fs`, `path`, `url`) only. The architecture test uses Vitest + Node stdlib.
|
||||||
|
- **Architecture compliance (Phase 7)**: no layer-direction violations introduced; the only cross-feature edge (`07_dataset → 06_annotations` for `CanvasEditor`, F2) is grandfathered exactly as before — `CanvasEditor` is intentionally re-exported through the `06_annotations` barrel so the consumer is barrel-compliant. STC-ARCH-01 confirms no new cyclic dependencies.
|
||||||
|
|
||||||
|
## Auto-Fix Attempts: 1
|
||||||
|
|
||||||
|
One auto-fix loop entered during Phase 3 (test import migration):
|
||||||
|
|
||||||
|
- **Symptom**: `tests/detection_classes.test.tsx` failed with `TypeError: Cannot read properties of undefined (reading 'map')` after `FALLBACK_CLASS_NAMES` was migrated to import through the `06_annotations` barrel.
|
||||||
|
- **Diagnosis**: barrel-induced circular import — `AnnotationsPage → DetectionClasses → 06_annotations barrel → AnnotationsPage`. The barrel module evaluated before `classColors` exports were bound, so the symbol resolved to `undefined`.
|
||||||
|
- **Fix**: remove `classColors` re-exports from the `06_annotations` barrel, document the F3-pending exemption in five places (see Design Decision #2), point the consumer + the test back at the direct path `src/features/annotations/classColors`.
|
||||||
|
- **Validation**: fast profile back to green; STC-ARCH-01 unit test added an exemption case (`AC-4: still PASSES when only the classColors F3-pending exemption is used`) so the carve-out is regression-tested.
|
||||||
|
|
||||||
|
## Stuck Agents: None
|
||||||
|
|
||||||
|
No multi-pass investigations beyond the auto-fix above.
|
||||||
|
|
||||||
|
## Test Run Summary
|
||||||
|
|
||||||
|
- `bun run test:fast` (via `bash scripts/run-tests.sh`) — 27 files / 167 passed / 13 skipped / 21.11 s wall (+4 new tests vs Phase A close at 163; 0 regressions).
|
||||||
|
- `bash scripts/run-tests.sh --static-only` — 30 / 30 static checks PASS (added `STC-ARCH-01`; no regressions in the existing 29).
|
||||||
|
- `node scripts/check-arch-imports.mjs` (direct invocation) — exit 0, stderr empty on the migrated codebase; exit 1 on every synthetic fixture in the architecture test.
|
||||||
|
- `ReadLints` — clean on all 13 new files.
|
||||||
|
- `git diff --stat` — 41 modified + 13 new files; +113 / -99 net lines; mostly mechanical one-line import path edits.
|
||||||
|
|
||||||
|
## Documented Drifts (cumulative across batch)
|
||||||
|
|
||||||
|
| Drift | Where | Spec/AC affected | Resolves when |
|
||||||
|
|-------|-------|------------------|---------------|
|
||||||
|
| `classColors` symbols cannot flow through the `06_annotations` barrel due to a circular import | `src/features/annotations/index.ts` (export omitted by design); 5 cross-doc mentions | F3 (Medium / Architecture) — `architecture_compliance_baseline.md` | F3 moves `classColors.ts` out of `06_annotations` into its own component directory (`src/shared/classColors.ts` or a dedicated `11_class-colors` directory); F3 closes by adding a `src/<new-home>/index.ts` barrel and removing the STC-ARCH-01 exemption |
|
||||||
|
|
||||||
|
(No other drifts surfaced.)
|
||||||
|
|
||||||
|
## Phase B Cycle 1 Status
|
||||||
|
|
||||||
|
This is **batch 1 of 2** in Phase B cycle 1 (the cycle covers baseline findings F4 + F7 under epic AZ-447). Batch 2 will implement **AZ-486** — endpoint builders in `src/api/endpoints.ts` + `STC-ARCH-02` for hardcoded `/api/<service>/…` paths — which depends on this batch landing first (`endpoints` ships through the new `src/api` barrel; Jira "Blocks" link AZ-485 → AZ-486).
|
||||||
|
|
||||||
|
## Next Batch
|
||||||
|
|
||||||
|
**AZ-486** (5 pts) — endpoint builders + STC-ARCH-02. Spec already in `_docs/02_tasks/todo/AZ-486_refactor_endpoint_builders.md`.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Batch Report
|
||||||
|
|
||||||
|
**Batch**: 10 (Phase B cycle 1, batch 2 of 2 — cycle close)
|
||||||
|
**Tasks**: AZ-486 (Endpoint builders + STC-ARCH-02)
|
||||||
|
**Date**: 2026-05-11
|
||||||
|
**Cycle**: Phase B feature cycle, Step 10 — Implement
|
||||||
|
**Total complexity**: 5 pts
|
||||||
|
**Epic**: AZ-447 (`01-testability-refactoring`)
|
||||||
|
**Closes**: architecture baseline finding **F7** (`_docs/02_document/architecture_compliance_baseline.md`)
|
||||||
|
**Depends on**: AZ-485 (F4 barrels) — committed in `23746ec`, AC-6 verified barrel re-export
|
||||||
|
|
||||||
|
## Task Results
|
||||||
|
|
||||||
|
| Task | Status | Files Modified / Added | Tests | AC Coverage | Issues |
|
||||||
|
|------|--------|------------------------|-------|-------------|--------|
|
||||||
|
| AZ-486_refactor_endpoint_builders | Done | **2 new files** (`src/api/endpoints.ts` — 25 builders; `src/api/endpoints.test.ts` — 36 contract assertions); **1 barrel update** (`src/api/index.ts` re-exports `endpoints`); **13 production files migrated** (`src/api/client.ts`, `src/auth/AuthContext.tsx`, `src/components/{FlightContext,DetectionClasses}.tsx`, `src/features/{admin/AdminPage,annotations/{AnnotationsPage,AnnotationsSidebar,CanvasEditor,MediaList,VideoPlayer},dataset/DatasetPage,flights/FlightsPage,settings/SettingsPage}.tsx`); **1 static-check script extended** (`scripts/check-arch-imports.mjs` — added `--mode=api-literals` for STC-ARCH-02 alongside existing `--mode=arch-imports` for STC-ARCH-01); **1 runner wired** (`scripts/run-tests.sh` — STC-ARCH-02 row added; STC-ARCH-01 invocation made explicit with `--mode=arch-imports`); **1 test file extended** (`tests/architecture_imports.test.ts` — 6 new STC-ARCH-02 cases covering single-/double-/template-literal fail paths, *.test.* exemption, line-comment skip, and migrated-codebase pass); **1 doc updated** (`_docs/02_document/module-layout.md` — `01_api-transport` Public API now lists `endpoints`; Verification Needed item #3a records F7 resolution + STC-ARCH-02 inventory + exemptions) | 36 new contract tests in `src/api/endpoints.test.ts` + 6 new architecture tests in `tests/architecture_imports.test.ts` = **+42 fast tests**; fast profile re-baselined from 167 → 209 passes (0 regressions); 31/31 static checks PASS including new `STC-ARCH-02` | 7 / 7 ACs covered | None |
|
||||||
|
|
||||||
|
## AC Test Coverage: All 7 ACs covered
|
||||||
|
|
||||||
|
| AC | Where | Profile | Status |
|
||||||
|
|----|-------|---------|--------|
|
||||||
|
| AC-1 — Every current path has a builder; URL strings character-identical | `src/api/endpoints.test.ts` (36 `expect(...).toBe('...')` cases — one per builder × every realistic input shape) | fast | PASS — every URL literal that lived in source before this refactor is reproduced exactly. Parameter interpolation (id strings, query strings, two-segment composites like `flightWaypoint(flightId, waypointId)`) covered explicitly. The test file IS the wire contract per `module-layout.md`'s "code-derived documentation" pattern. |
|
||||||
|
| AC-2 — No `/api/<service>/` literals remain in production | `node scripts/check-arch-imports.mjs --mode=api-literals` (exit 0) over `src/` excluding `endpoints.ts` and `*.test.tsx?`; cross-checked with workspace-wide grep showing only `endpoints.ts` retains the literals | static (`STC-ARCH-02`) | PASS — 0 hits outside the documented exemptions |
|
||||||
|
| AC-3 — Static gate fails on a newly-introduced literal | `tests/architecture_imports.test.ts` — 3 fail-on-synthetic-fixture cases (single-quoted, double-quoted, template-literal) all assert `status != 0` and `stderr` mentions `STC-ARCH-02` + fixture filename | fast | PASS — every quote style trips the gate. Single quote and template literal cases shown in the run log; double-quote case implicitly verified (same regex branch) |
|
||||||
|
| AC-4 — Static gate passes on the migrated codebase | `tests/architecture_imports.test.ts` `AC-4: passes on the migrated codebase` + `STC-ARCH-02` row in the static profile | fast + static | PASS — exit code 0, stderr empty |
|
||||||
|
| AC-5 — Fast profile remains green | `bash scripts/run-tests.sh` (static + fast); fast went 167 / 13 / 0 → 209 / 13 / 0 (+36 endpoint contract + 6 architecture STC-ARCH-02 = +42 new passes) | static + fast | PASS — 0 regressions; only adds new tests |
|
||||||
|
| AC-6 — `endpoints` is re-exported from `src/api/index.ts` (the F4 barrel) | `src/api/endpoints.test.ts` `AC-6: barrel re-export` (`endpoints === endpointsViaBarrel`); 13 production consumers import `endpoints` via `'../api'` or `'../../api'` — verified by STC-ARCH-01 still PASS | fast + static | PASS — same object identity; no deep imports reintroduced |
|
||||||
|
| AC-7 — MSW handlers and e2e stubs continue to match | Pre-existing MSW handlers across the fast suite still intercept correctly (no NEW "intercepted unhandled" errors introduced by the refactor); URL strings character-identical per AC-1; e2e profile not run in this batch (per project's batch-level testing strategy — handed off to Step 11 / Step 16 full run) | fast | PASS — observed MSW unhandled-warning lines under `ConfirmDialog.test.tsx` are pre-existing noise (AuthProvider boot triggers `/api/admin/auth/refresh` which that test file deliberately leaves unhandled; the auth-refresh URL is character-identical to pre-refactor); no new failure modes |
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
1. **Single shared static-check script with `--mode` flag, not a second `check-api-literals.mjs`.** Mirrors AZ-485's "single source of truth" decision (batch 9 / Design Decision #1). Both gates walk the same codebase, use the same `IGNORED_DIRS` / `SOURCE_EXT` / `walkSourceFiles` machinery, and skip the same single-line comments. Forking the script would have duplicated the walker and the comment-skip rule in two places, which is exactly the kind of drift STC-ARCH-* gates exist to prevent. The `--mode` flag is a one-line dispatch in `main()`.
|
||||||
|
|
||||||
|
2. **STC-ARCH-02 regex matches all three quote styles** (`'`, `"`, `` ` ``), not just single quotes as the task spec's illustrative `ripgrep "'/api/[a-z-]+/"` suggested. Quote style is not a meaningful difference for "no hardcoded URLs in production" — a developer could regress the gate by switching quote styles otherwise. The regex `[\`'"]/api/[a-z][a-z-]*/` requires the path to be preceded by a string-opener, which avoids false positives on comment text that mentions `/api/<service>/` as documentation. Three fail-on-synthetic test cases (one per quote style) lock this behavior in.
|
||||||
|
|
||||||
|
3. **`*.test.{ts,tsx}` files under `src/` are exempted from STC-ARCH-02.** Tests legitimately assert URL strings — MSW handlers, the `endpoints.test.ts` contract itself, and existing colocated tests (`src/api/client.test.ts`, `src/auth/{AuthContext,ProtectedRoute}.test.tsx`, `src/components/Header.test.tsx`) all reference `/api/...` literals. The exemption is documented in five places: the static-check script's `API_LITERAL_EXEMPT_FILES_RE` comment, the bash wrapper in `run-tests.sh`, `module-layout.md` Verification Needed item #3a, the architecture test (`AC-3: still PASSES when an offending literal lives in a *.test.ts file under src/`), and the in-code header comment at the top of `endpoints.ts`. Same exemption discipline as the AZ-485 F3-pending exemption (5 places).
|
||||||
|
|
||||||
|
4. **Function-form builders everywhere (not constants).** Pinned by the task spec's "Why function form" comment in `endpoints.ts`. Allows parameter interpolation without callsites re-introducing template literals (and re-introducing STC-ARCH-02 violations), keeps tree-shaking per-builder under Vite's production rollup, and lets builders that take a query string own the `?` boundary so the wire contract stays identical (e.g., `endpoints.annotations.dataset(queryString)` returns `` `/api/annotations/dataset?${queryString}` `` — caller passes `params.toString()`, not a literal `?`-prefixed string).
|
||||||
|
|
||||||
|
5. **`endpoints.flights.collection(queryString?)` accepts an optional query string.** Today's callsites are split: `FlightContext` reads `'/api/flights?pageSize=1000'` (GET with query), `FlightsPage` writes `'/api/flights'` (POST without query). One builder with an optional arg keeps the wire-contract surface coherent; passing `undefined` returns the bare path. Validated by two test cases (`flights.collection() without query` and `flights.collection(queryString) appends ?queryString`). Same shape used for `annotations.dataset(queryString)` and `annotations.media(queryString)`.
|
||||||
|
|
||||||
|
6. **`endpoints.annotations.annotationsByMedia(mediaId, pageSize?=1000)` exposes the page-size constant.** Every current callsite uses `pageSize=1000`; the optional arg lets future tuning be a single-file change (per task spec NFR / Maintainability). Two test cases pin both the default and the override path.
|
||||||
|
|
||||||
|
7. **`endpoints.admin.class(id: string | number)` widens the ID type.** `DetectionClass.id` is `number` in the type system today (per `AdminPage` line 51 cast), but the rest of the admin builders take `string` because user/aircraft IDs are GUIDs. Widening to `string | number` at the builder accepts today's number-typed call from `AdminPage.handleDeleteClass` without an explicit cast and stays forward-compatible if the backend later switches `DetectionClass.id` to UUID. Two test cases (`'cls-7'` and `42`) pin both arms.
|
||||||
|
|
||||||
|
8. **`endpoints.detect.media(mediaId)` is the only entry under a non-annotations namespace.** The `/api/detect/<mediaId>` path is a single-segment service path (no further segments today) consumed only by `AnnotationsSidebar`. Keeping it under its own `detect` namespace mirrors the URL's first segment and leaves room for future detect-service endpoints without renaming.
|
||||||
|
|
||||||
|
9. **`src/api/endpoints.ts` lives under `01_api-transport` — F6 explicitly out of scope.** Per the AZ-486 spec's `Excluded` section, the future `src/shared/` move (F6) is deferred. The barrel-re-export pattern means consumers import `{ endpoints } from '../api'` — when F6 lands and moves the file, only `src/api/index.ts` needs to flip the re-export source; consumers do not change. This is exactly the protection F4 / AZ-485 was built to provide.
|
||||||
|
|
||||||
|
## Code Review Verdict: PASS
|
||||||
|
|
||||||
|
Self-review (implement skill Step 9 / 10) applied to the 2 new + 13 modified + 1 script-extended + 1 runner-wired + 1 doc-updated + 1 test-extended files:
|
||||||
|
|
||||||
|
- **0 Critical, 0 High, 0 Medium, 0 Low findings.**
|
||||||
|
- **Scope discipline**: every modified file is one of (contract author, deep-literal consumer, static-check author, runner wirer, contract documentor, gate test author). The spec's listed files are all migrated; two additional files outside the spec's explicit list (`CanvasEditor.tsx`, `VideoPlayer.tsx`) were also migrated because they contain `/api/<service>/` literals as `<img src>` / `<video src>` URLs — including them is required for AC-2 / STC-ARCH-02 to pass, and the spec's "every component that calls `api.*` or `createSSE`" intent reads "every UI callsite of a wire-contract URL", which these are.
|
||||||
|
- **No silent error suppression**: `check-arch-imports.mjs` writes the full hit list to stderr (with `STC-ARCH-02` named in the header) before exiting non-zero; the bash delegate (`static_check_no_api_literals`) propagates the exit code; `run-tests.sh` records the result into the static CSV. No new `try { } catch { }` blocks in production code; no new `2>/dev/null` redirects.
|
||||||
|
- **Single-responsibility**: `endpoints.ts` exports one shape (the typed URL-builder object) with one job (produce wire-contract URLs). `endpoints.test.ts` has one job (pin every URL string). `check-arch-imports.mjs` now has two modes but each scanner function (`scanArchImports`, `scanApiLiterals`) has one job. The `main()` dispatch is a 12-line config-and-call.
|
||||||
|
- **No new dependencies**: `endpoints.ts` is plain TypeScript with `as const`. `endpoints.test.ts` uses Vitest + the barrel import. The script extension uses Node stdlib (`fs`, `path`, `url`) only — same as before.
|
||||||
|
- **Architecture compliance (Phase 7)**: STC-ARCH-01 still PASS (no new cross-component deep imports); the new `endpoints` import in `client.ts` is intra-component (`./endpoints`). The 12 modified consumer files all import `endpoints` via the `01_api-transport` barrel. Layer direction unchanged.
|
||||||
|
- **Cross-task consistency (Phase 6)**: builds on AZ-485 cleanly — uses the same barrel pattern (`module-layout.md` Rule #3) and the same static-check delivery mechanism (`scripts/check-arch-imports.mjs`). The shared script now has symmetric STC-ARCH-01 + STC-ARCH-02 modes. AZ-447 epic now has both F4 and F7 closed.
|
||||||
|
- **Performance**: `STC-PERF01` (initial JS bundle ≤ 2 MB gzipped) still PASS after the refactor. The `endpoints` object is small and tree-shakeable per builder per the Vite production rollup; observed bundle size unchanged within measurement noise.
|
||||||
|
|
||||||
|
## Auto-Fix Attempts: 0
|
||||||
|
|
||||||
|
The session resumed an in-progress AZ-486 batch (the user-recommended "option A" path from `/autodev` re-entry). No auto-fix loop was needed — the missing pieces (DatasetPage + DetectionClasses migrations, STC-ARCH-02 wiring, architecture test extension, doc update) were straightforward additions to a coherent partial implementation. The first `bash scripts/run-tests.sh` run went green: 31/31 static + 209/13/0 fast.
|
||||||
|
|
||||||
|
## Stuck Agents: None
|
||||||
|
|
||||||
|
No multi-pass investigations. The resume was a continuation, not a debug loop.
|
||||||
|
|
||||||
|
## Test Run Summary
|
||||||
|
|
||||||
|
- `bash scripts/run-tests.sh` (static + fast) — exit 0
|
||||||
|
- **Static profile**: 31 / 31 PASS, including `STC-ARCH-01` (166 ms) and the new `STC-ARCH-02` (179 ms). `STC-T1` (tsc) 3.8 s, `STC-B1` (vite build) 7.4 s.
|
||||||
|
- **Fast profile**: `bun run test:fast` — 28 files / **209 passed** / 13 skipped / 22.58 s wall. +42 vs end-of-batch-9 (167 + 36 endpoint contract + 6 architecture STC-ARCH-02 = 209). 0 regressions.
|
||||||
|
- `node scripts/check-arch-imports.mjs --mode=api-literals` (direct invocation) — exit 0, stderr empty.
|
||||||
|
- `node scripts/check-arch-imports.mjs --mode=arch-imports` (direct invocation) — exit 0, stderr empty.
|
||||||
|
- `ReadLints` — clean on all modified files.
|
||||||
|
- Pre-existing MSW "intercepted unhandled" stderr lines under `ConfirmDialog.test.tsx` are NOT new and NOT caused by this batch: the failing URL `/api/admin/auth/refresh` is character-identical pre- and post-refactor (AC-1 verifies); the warning has been latent in the suite and is not a blocker.
|
||||||
|
|
||||||
|
## Documented Drifts (cumulative across batch)
|
||||||
|
|
||||||
|
None new. The F3-pending exemption (`classColors`) carried forward from batch 9 is unchanged. STC-ARCH-02 has no F3 interaction.
|
||||||
|
|
||||||
|
## Phase B Cycle 1 Status
|
||||||
|
|
||||||
|
This is **batch 2 of 2** in Phase B cycle 1 (the cycle covered baseline findings **F4** + **F7** under epic AZ-447). Both batches are now complete:
|
||||||
|
|
||||||
|
- Batch 9 / AZ-485 — F4 (Public API barrels + STC-ARCH-01) — committed `23746ec`
|
||||||
|
- Batch 10 / AZ-486 — F7 (Endpoint builders + STC-ARCH-02) — this batch, uncommitted pending user approval
|
||||||
|
|
||||||
|
**Cycle 1 complete** once batch 10 is committed. Per the autodev existing-code flow, Step 10 (Implement) then auto-chains to Step 11 (Run Tests) → Step 12 (Test-Spec Sync) → Step 13 (Update Docs) → Step 14 (Security Audit, optional) → Step 15 (Performance Test, optional) → Step 16 (Deploy) → Step 17 (Retrospective) → loop back to Step 9 with `cycle: 2` incremented.
|
||||||
|
|
||||||
|
## Cumulative Code Review Trigger
|
||||||
|
|
||||||
|
Per the implement skill Step 14.5, cumulative code review fires every `K=3` batches. Phase B cycle 1 had only 2 batches (AZ-485, AZ-486); no cumulative review is triggered at this cycle close. The existing `cumulative_review_batches_07-08_cycle1_report.md` was the Phase A wrap. The next cumulative review will be after batches 11 + 12 + 13 of cycle 2 (or whenever the next 3-batch window closes).
|
||||||
|
|
||||||
|
## Next Batch
|
||||||
|
|
||||||
|
**All Phase B cycle 1 tasks complete.** Final implementation report for cycle 1 will be `_docs/03_implementation/implementation_report_phase_b_cycle1.md` (written at the close of Step 10 once user approves commit of batch 10). The autodev orchestrator will auto-chain to Step 11 (Run Tests, full suite, owned by `test-run/SKILL.md`) after commit.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Implementation Report — Phase B Cycle 1 (Refactoring)
|
||||||
|
|
||||||
|
**Cycle**: Phase B, cycle 1 (`state.cycle = 1`)
|
||||||
|
**Date close**: 2026-05-11
|
||||||
|
**Epic**: AZ-447 (`01-testability-refactoring`)
|
||||||
|
**Findings closed**: F4 (Public API barrels) + F7 (Endpoint builders) — both from `_docs/02_document/architecture_compliance_baseline.md`
|
||||||
|
**Total complexity**: 10 pts (5 + 5)
|
||||||
|
**Verdict**: PASS
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
| Task | Spec | Batch | Commit | Verdict | AC Coverage |
|
||||||
|
|------|------|-------|--------|---------|-------------|
|
||||||
|
| AZ-485 — Public API barrels + STC-ARCH-01 | `_docs/02_tasks/done/AZ-485_refactor_public_api_barrels.md` | batch 9 | `23746ec` | PASS | 7 / 7 |
|
||||||
|
| AZ-486 — Endpoint builders + STC-ARCH-02 | `_docs/02_tasks/done/AZ-486_refactor_endpoint_builders.md` | batch 10 | `8a461a2` | PASS | 7 / 7 |
|
||||||
|
|
||||||
|
Batch reports: `_docs/03_implementation/batch_09_report.md`, `_docs/03_implementation/batch_10_report.md` (canonical per-batch source of truth — design decisions, modified-files inventory, AC test mapping).
|
||||||
|
|
||||||
|
## Architecture Outcome
|
||||||
|
|
||||||
|
After cycle 1, the `src/` codebase has two coupled static gates that lock in the architecture vision:
|
||||||
|
|
||||||
|
1. **`STC-ARCH-01`** (`scripts/check-arch-imports.mjs --mode=arch-imports`) — every cross-component import MUST go through the component's barrel (`src/<component>/index.ts`). Closes F4. One F3-pending exemption (`features/annotations/classColors`) documented in 5 places.
|
||||||
|
|
||||||
|
2. **`STC-ARCH-02`** (`scripts/check-arch-imports.mjs --mode=api-literals`) — no hardcoded `/api/<service>/<...>` literals in production source. The single source of truth is `src/api/endpoints.ts`, re-exported via the `01_api-transport` barrel. Closes F7. Exemptions: the contract owner (`endpoints.ts`) and `*.test.tsx?` files under `src/`.
|
||||||
|
|
||||||
|
The two gates are symmetric (single shared script, side-by-side `--mode` flags, identical fixture-driven test harness in `tests/architecture_imports.test.ts`). Adding a future STC-ARCH-03 / -04 follows the same pattern.
|
||||||
|
|
||||||
|
## Test Suite Delta
|
||||||
|
|
||||||
|
| Metric | End of Phase A (Step 7) | End of cycle 1 (Step 11) | Delta |
|
||||||
|
|--------|-------------------------|---------------------------|-------|
|
||||||
|
| Fast profile PASS | 163 | **209** | +46 |
|
||||||
|
| Fast profile SKIP | 13 | 13 | 0 |
|
||||||
|
| Fast profile FAIL | 0 | 0 | 0 |
|
||||||
|
| Static profile gates | 29 / 29 PASS | **31 / 31 PASS** | +2 (STC-ARCH-01, STC-ARCH-02) |
|
||||||
|
|
||||||
|
No regressions. All 46 new fast tests are additive — 4 new STC-ARCH-01 architecture cases (AZ-485), 6 new STC-ARCH-02 architecture cases (AZ-486), 36 new endpoint contract assertions (AZ-486).
|
||||||
|
|
||||||
|
## Code Review Trace
|
||||||
|
|
||||||
|
- Per-batch self-review: PASS (0 Critical / 0 High / 0 Medium / 0 Low on both batches).
|
||||||
|
- Cumulative review (K=3 trigger): not fired — cycle 1 had only 2 batches. Next cumulative review at the next 3-batch window close.
|
||||||
|
|
||||||
|
## Productivity Notes (Retro Input)
|
||||||
|
|
||||||
|
- **Single script, two modes** (Design Decision #1 in batch 10 report) replaced the obvious-but-wrong choice of forking `check-arch-imports.mjs` into a second script. Saved ~150 LOC of duplicated walker/comment-skip machinery and eliminated a drift surface.
|
||||||
|
- **All-quote-style regex** (`[`'"]/api/<service>/`) caught a class of regressions the spec's illustrative single-quote ripgrep would have missed. Locked in with 3 quote-style-specific test cases.
|
||||||
|
- **Resume of in-progress AZ-486 work** at the start of this session: the user's prior session left the working tree with most of AZ-486 done but unrecorded. The autodev orchestrator detected the state/working-tree disagreement and surfaced it as a Choose block before continuing — this is exactly what the state-reconciliation rule in `state.md` is for.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Auto-chain → Step 12 (Test-Spec Sync, `test-spec/SKILL.md` cycle-update mode).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Test Run Report — Phase B Cycle 1 (Step 11)
|
||||||
|
|
||||||
|
**Date**: 2026-05-11
|
||||||
|
**Mode**: functional
|
||||||
|
**Runner**: `scripts/run-tests.sh` (default profiles: static + fast; e2e env-blocked, see Step 7 report)
|
||||||
|
**Verdict**: PASS_WITH_DOCUMENTED_GATE
|
||||||
|
**Handoff**: re-uses the suite run performed under Step 10 batch 10 (implement skill Step 16: avoid duplicate full runs when next flow step is Run Tests). Source state at run-time === source state at commit `8a461a2`.
|
||||||
|
|
||||||
|
## Profile Outcomes
|
||||||
|
|
||||||
|
| Profile | Status | Counts | Wall-clock | Report file |
|
||||||
|
|---------|--------|--------|------------|-------------|
|
||||||
|
| static | PASS | **31 / 31** including new `STC-ARCH-02` | ~14 s | `test-output/summary.csv` |
|
||||||
|
| fast | PASS | 28 files / **209 PASS / 13 SKIP / 0 FAIL** | ~22.6 s | `test-output/fast-report.xml` |
|
||||||
|
| e2e | env-blocked (deferred — same registry-access block as Step 7) | n/a | n/a | n/a |
|
||||||
|
|
||||||
|
## Delta vs Step 7 baseline
|
||||||
|
|
||||||
|
- Fast: 163 / 13 → 209 / 13 (+46 over Phase A close, +42 over end-of-batch-9):
|
||||||
|
- +4 STC-ARCH-01 architecture tests (added in batch 9 / AZ-485)
|
||||||
|
- +36 STC-ARCH-02 contract assertions in `src/api/endpoints.test.ts` (this cycle / AZ-486)
|
||||||
|
- +6 STC-ARCH-02 architecture tests in `tests/architecture_imports.test.ts` (this cycle / AZ-486)
|
||||||
|
- Static: 29 / 29 → 31 / 31 (+2 new gates: `STC-ARCH-01` AZ-485, `STC-ARCH-02` AZ-486)
|
||||||
|
- Skip count unchanged at 13 — no new skips introduced this cycle.
|
||||||
|
|
||||||
|
## System-Under-Test Reality Gate
|
||||||
|
|
||||||
|
PASS (same shape as Step 7):
|
||||||
|
- `_docs/00_problem/input_data/expected_results/results_report.md` still exists; `_docs/02_document/tests/traceability-matrix.md` still maps every AC. No internal product module was faked, monkeypatched, or replaced with a deterministic fallback by this cycle's batches — verified by self-review for batch 9 and batch 10.
|
||||||
|
- The refactor surface this cycle (`endpoints.*` + STC-ARCH-02) is pure rewrite-of-string-literals through a typed accessor object; no behavior change, no external system replaced.
|
||||||
|
- CSV report inspected — all 31 static rows PASS; fast profile rolled-up PASS row points at the JUnit XML.
|
||||||
|
|
||||||
|
## Skipped Tests — Same 13 As Step 7, Still Legitimate
|
||||||
|
|
||||||
|
The 13 skips are byte-for-byte the same set documented in `test_run_report.md`'s "Skipped Tests — All 13 Accepted as Legitimate" section. None of this cycle's two tasks (AZ-485, AZ-486) touched any of the skip conditions: F4 (barrels) and F7 (URL builders) are pure mechanical refactors of the import path and string-literal layer; they do not change which features ship to production. The user-approved acceptance from Step 7 still applies.
|
||||||
|
|
||||||
|
## Environment Block — e2e Profile
|
||||||
|
|
||||||
|
Same as Step 7: registry-access block on `azaion/{admin,flights,annotations,detect,loader,resource}:test` images. F4 / F7 changes do not affect Docker images or compose configuration (no Dockerfile or compose edits this cycle), so re-running e2e would not produce different results. Defer to the merge-lane CI per Step 7's user-approved option A.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Step 11 **passes**. Auto-chain to Step 12 (Test-Spec Sync).
|
||||||
|
|
||||||
|
## Open Items
|
||||||
|
|
||||||
|
Unchanged from Step 7:
|
||||||
|
- F-CUM-5 production-drift backlog — Phase B / Step 9 work continues into cycle 2.
|
||||||
|
- F-CUM-4 long-running-soak Playwright config tag — recommended fold-in to merge-lane config.
|
||||||
+16
-23
@@ -2,35 +2,28 @@
|
|||||||
|
|
||||||
## Current Step
|
## Current Step
|
||||||
flow: existing-code
|
flow: existing-code
|
||||||
step: 10
|
step: 16
|
||||||
name: Implement
|
name: Deploy
|
||||||
status: not_started
|
status: not_started
|
||||||
sub_step:
|
sub_step:
|
||||||
phase: 0
|
phase: 0
|
||||||
name: awaiting-invocation
|
name: awaiting-invocation
|
||||||
detail: "Step 9 closed; AZ-485 (F4 barrels) + AZ-486 (F7 endpoints) ready in todo/; AZ-485 first (AZ-486 depends on barrel)"
|
detail: ""
|
||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 1
|
cycle: 1
|
||||||
tracker: jira
|
tracker: jira
|
||||||
step_4_5_glossary_vision: confirmed
|
|
||||||
step_2_baseline_routing: per-finding-recommended (option A)
|
|
||||||
step_3_results_report_authoring: agent (option A)
|
|
||||||
step_3_ac_gap_handling: rollback-to-6c (option A)
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Phase A baseline cycle. Step 1 (Document) complete; see
|
- Cycle 1 (Phase B) refactor cycle: F4 (AZ-485, `23746ec`) and F7
|
||||||
`_docs/02_document/state.json`, `FINAL_report.md`, `architecture.md`,
|
(AZ-486, `8a461a2`) closed.
|
||||||
`glossary.md`, plus `_docs/01_solution/solution.md` and
|
- Step 13 (Update Docs) DONE in working tree, NOT YET COMMITTED. User
|
||||||
`_docs/00_problem/{problem,acceptance_criteria,restrictions,security_approach}.md`.
|
skipped the commit gate; 12 module docs + new `src__api__endpoints.md`
|
||||||
- Implement-skill batch reports at
|
+ `01_api-transport/description.md` + ripple log + 2 cycle reports
|
||||||
`_docs/03_implementation/batch_0{1..8}_report.md`.
|
pending review. Commit subject for when ready:
|
||||||
- Cumulative reviews PASS_WITH_WARNINGS at
|
`[AZ-485] [AZ-486] Cycle 1 docs refresh (Step 13)`.
|
||||||
`_docs/03_implementation/cumulative_review_batches_01-03_report.md`,
|
- Step 14 (Security Audit) SKIPPED — cycle 1 was structural refactor
|
||||||
`_docs/03_implementation/cumulative_review_batches_04-06_cycle1_report.md`,
|
only; URL strings character-identical to pre-refactor (pinned by
|
||||||
`_docs/03_implementation/cumulative_review_batches_07-08_cycle1_report.md`
|
`endpoints.test.ts`), no new auth / wire / permission surface.
|
||||||
(cycle close — Phase A wrap, no batch 9).
|
- Step 15 (Performance Test) SKIPPED — same rationale; no
|
||||||
- Phase B started. Step 9 (New Task) cycle 1 closed:
|
latency-/throughput-relevant code paths changed.
|
||||||
- AZ-485 (F4 — Public API barrels + STC-ARCH-01, 5 pts, no deps)
|
- Next: Step 16 (Deploy) — destructive; awaiting user decision.
|
||||||
- AZ-486 (F7 — Endpoint builders + STC-ARCH-02, 5 pts, blocked by AZ-485)
|
|
||||||
- F1 (mission-planner convergence) deliberately not created — needs `/decompose` for 7+ port-group cycles in its own Epic.
|
|
||||||
- Step 10 (Implement) starts with AZ-485 — `Blackbox Tests` owns the static-check addition and the `tests/**` import-path migration; production component teams own their barrel files per `module-layout.md`.
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// AZ-485 / F4 (STC-ARCH-01) + AZ-486 / F7 (STC-ARCH-02).
|
||||||
|
//
|
||||||
|
// STC-ARCH-01 (mode=arch-imports, default) — no cross-component deep imports.
|
||||||
|
// Every component under src/<component>/ exposes its Public API via a barrel
|
||||||
|
// (src/<component>/index.ts). Cross-component imports MUST go through the
|
||||||
|
// barrel; reaching into another component's internal files is a layering
|
||||||
|
// violation.
|
||||||
|
//
|
||||||
|
// STC-ARCH-02 (mode=api-literals) — no hardcoded `/api/<service>/...` literals
|
||||||
|
// in production source. The single source of truth for those URLs is
|
||||||
|
// src/api/endpoints.ts (AZ-486 / F7). Any production callsite of api.* or
|
||||||
|
// createSSE() that needs an API path must use an `endpoints.*` builder.
|
||||||
|
//
|
||||||
|
// Single source of truth — scripts/run-tests.sh delegates here, and the
|
||||||
|
// architecture unit test (tests/architecture_imports.test.ts) calls this
|
||||||
|
// script with synthetic fixtures to verify each gate fails on a regression
|
||||||
|
// and passes on the migrated codebase. Mirrors the scripts/check-banned-deps.mjs
|
||||||
|
// pattern (AZ-482).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node scripts/check-arch-imports.mjs [--mode=arch-imports|api-literals] [--root=<repo-root>]
|
||||||
|
//
|
||||||
|
// Exit code 0 on PASS; non-zero on FAIL with the hit list on stderr.
|
||||||
|
|
||||||
|
import { readFileSync, statSync, readdirSync } from 'node:fs'
|
||||||
|
import { join, dirname, resolve, relative } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
|
const MODES = new Set(['arch-imports', 'api-literals'])
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = { root: resolve(__dirname, '..'), mode: 'arch-imports' }
|
||||||
|
for (const a of argv.slice(2)) {
|
||||||
|
if (a.startsWith('--root=')) out.root = resolve(a.slice('--root='.length))
|
||||||
|
else if (a.startsWith('--mode=')) out.mode = a.slice('--mode='.length)
|
||||||
|
else if (a === '-h' || a === '--help') {
|
||||||
|
process.stderr.write(
|
||||||
|
'Usage: check-arch-imports.mjs [--mode=arch-imports|api-literals] [--root=<repo-root>]\n',
|
||||||
|
)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!MODES.has(out.mode)) {
|
||||||
|
process.stderr.write(`unknown --mode=${out.mode}; expected one of: ${[...MODES].join(', ')}\n`)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const IGNORED_DIRS = new Set([
|
||||||
|
'node_modules', 'dist', 'build', 'test-output', 'test-results',
|
||||||
|
'coverage', '.git', '.cache', 'playwright-report', 'blob-report',
|
||||||
|
])
|
||||||
|
const SOURCE_EXT = new Set(['.ts', '.tsx'])
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// STC-ARCH-01 — cross-component deep imports (mode=arch-imports)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Cross-component deep-import pattern: `from '<up>/<src>?/<component>/<File>'`
|
||||||
|
// - one or more `..` segments
|
||||||
|
// - optional `src/` prefix (used by tests/ and e2e/ imports)
|
||||||
|
// - a known component directory
|
||||||
|
// - a path segment starting with a letter (i.e. NOT the barrel `index`)
|
||||||
|
//
|
||||||
|
// Allowed by construction:
|
||||||
|
// - barrel: from '../api' (no further /<File>)
|
||||||
|
// - intra-component: from './sse' (starts with ./, not ../)
|
||||||
|
const COMPONENT_DIRS = 'api|auth|components|features/[a-z-]+|hooks|i18n'
|
||||||
|
const DEEP_IMPORT_RE = new RegExp(
|
||||||
|
String.raw`from\s+['"](?:\.\./)+(?:src/)?(?:${COMPONENT_DIRS})/[A-Za-z]`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// F3-pending exemptions for STC-ARCH-01:
|
||||||
|
// - `features/annotations/classColors` — classColors is logically owned by
|
||||||
|
// 11_class-colors but physically lives under 06_annotations. Re-exporting
|
||||||
|
// it through the 06_annotations barrel creates a circular import:
|
||||||
|
// AnnotationsPage -> DetectionClasses -> 06_annotations barrel
|
||||||
|
// -> AnnotationsPage
|
||||||
|
// so consumers (DetectionClasses, tests/detection_classes.test.tsx)
|
||||||
|
// import the file directly. F3 will move the file and remove this
|
||||||
|
// exemption.
|
||||||
|
const ARCH_IMPORTS_EXEMPT_RE = /features\/annotations\/classColors/
|
||||||
|
|
||||||
|
const ARCH_IMPORTS_SCAN_ROOTS = ['src', 'tests', 'e2e']
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// STC-ARCH-02 — hardcoded /api/<service>/ literals (mode=api-literals)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Hardcoded API path pattern: a quote/backtick immediately followed by
|
||||||
|
// `/api/<lowercase-hyphen-service>/`. Catches single-quoted ('...'),
|
||||||
|
// double-quoted ("..."), and template-literal (`...`) styles equally — quote
|
||||||
|
// style is not a meaningful difference for "no hardcoded URLs in production".
|
||||||
|
//
|
||||||
|
// Examples that MATCH:
|
||||||
|
// '/api/admin/users'
|
||||||
|
// "/api/admin/auth/refresh"
|
||||||
|
// `/api/annotations/dataset?${params}`
|
||||||
|
// `/api/flights/${id}/waypoints`
|
||||||
|
//
|
||||||
|
// Examples that DO NOT match (intentional):
|
||||||
|
// /api/admin (bare prefix, no trailing /) — kept out so getApiBase()
|
||||||
|
// prefixes the base path
|
||||||
|
// unobstructed.
|
||||||
|
// /api/users (no service segment, hypothetical) — pattern requires the
|
||||||
|
// SECOND segment.
|
||||||
|
const API_LITERAL_RE = /[`'"]\/api\/[a-z][a-z-]*\//
|
||||||
|
|
||||||
|
// STC-ARCH-02 exemptions:
|
||||||
|
// - the contract owner itself (src/api/endpoints.ts)
|
||||||
|
// - test files (*.test.ts, *.test.tsx) — tests legitimately assert URL
|
||||||
|
// strings, and the contract is precisely "the test file IS the contract"
|
||||||
|
const API_LITERAL_EXEMPT_FILES_RE = /(?:^|\/)(?:api\/endpoints\.ts|.+\.test\.tsx?)$/
|
||||||
|
|
||||||
|
const API_LITERALS_SCAN_ROOTS = ['src']
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Walk helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function* walkSourceFiles(rootDir) {
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = readdirSync(rootDir, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = join(rootDir, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (IGNORED_DIRS.has(entry.name)) continue
|
||||||
|
yield* walkSourceFiles(full)
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
const dot = entry.name.lastIndexOf('.')
|
||||||
|
if (dot < 0) continue
|
||||||
|
const ext = entry.name.slice(dot)
|
||||||
|
if (!SOURCE_EXT.has(ext)) continue
|
||||||
|
yield full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLines(file) {
|
||||||
|
try {
|
||||||
|
return readFileSync(file, 'utf8').split('\n')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Scanners — one per mode. Both share the walker and line-comment skip.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function scanArchImports(file, root) {
|
||||||
|
const hits = []
|
||||||
|
const lines = readLines(file)
|
||||||
|
if (lines === null) return hits
|
||||||
|
const rel = relative(root, file).replaceAll('\\', '/')
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
if (/^\s*\/\//.test(line)) continue
|
||||||
|
if (!DEEP_IMPORT_RE.test(line)) continue
|
||||||
|
if (ARCH_IMPORTS_EXEMPT_RE.test(line)) continue
|
||||||
|
hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
return hits
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanApiLiterals(file, root) {
|
||||||
|
const hits = []
|
||||||
|
const rel = relative(root, file).replaceAll('\\', '/')
|
||||||
|
if (API_LITERAL_EXEMPT_FILES_RE.test(rel)) return hits
|
||||||
|
const lines = readLines(file)
|
||||||
|
if (lines === null) return hits
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
if (/^\s*\/\//.test(line)) continue
|
||||||
|
if (!API_LITERAL_RE.test(line)) continue
|
||||||
|
hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
return hits
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const { root, mode } = parseArgs(process.argv)
|
||||||
|
const config = mode === 'api-literals'
|
||||||
|
? {
|
||||||
|
roots: API_LITERALS_SCAN_ROOTS,
|
||||||
|
scan: scanApiLiterals,
|
||||||
|
failHeader:
|
||||||
|
'STC-ARCH-02 — hardcoded /api/<service>/ literals detected in ' +
|
||||||
|
'production source (must go through endpoints.* builders, see ' +
|
||||||
|
'src/api/endpoints.ts):\n',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
roots: ARCH_IMPORTS_SCAN_ROOTS,
|
||||||
|
scan: scanArchImports,
|
||||||
|
failHeader:
|
||||||
|
'STC-ARCH-01 — cross-component deep imports detected ' +
|
||||||
|
'(must go through component barrel, see module-layout.md):\n',
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = []
|
||||||
|
for (const sub of config.roots) {
|
||||||
|
const full = join(root, sub)
|
||||||
|
try { statSync(full) } catch { continue }
|
||||||
|
for (const file of walkSourceFiles(full)) {
|
||||||
|
hits.push(...config.scan(file, root))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hits.length) {
|
||||||
|
process.stderr.write(config.failHeader)
|
||||||
|
for (const h of hits) process.stderr.write(` ${h}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -470,6 +470,38 @@ if [ "$RUN_STATIC" = "true" ]; then
|
|||||||
'
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# AZ-485 F4 — STC-ARCH-01: no cross-component deep imports. After F4 every
|
||||||
|
# component exposes its Public API via `src/<component>/index.ts`; cross-
|
||||||
|
# component imports MUST go through the barrel, not reach into another
|
||||||
|
# component's internal files. Flags imports of the form
|
||||||
|
# from '../api/client'
|
||||||
|
# from '../../components/ConfirmDialog'
|
||||||
|
# from '../src/features/annotations/AnnotationsPage' (test files)
|
||||||
|
# Allowed:
|
||||||
|
# - barrel imports: from '../api', from '../../components'
|
||||||
|
# - intra-component: from './sse', from './MediaList' (./ not ..)
|
||||||
|
# - F3-pending edge: from '../features/annotations/classColors'
|
||||||
|
# (classColors lives under 06_annotations until F3 moves it; importing
|
||||||
|
# through the 06_annotations barrel would create a circular import
|
||||||
|
# AnnotationsPage → DetectionClasses → barrel → AnnotationsPage.)
|
||||||
|
static_check_no_cross_component_deep_imports() {
|
||||||
|
node "$PROJECT_ROOT/scripts/check-arch-imports.mjs" --mode=arch-imports
|
||||||
|
}
|
||||||
|
|
||||||
|
# AZ-486 F7 — STC-ARCH-02: no hardcoded `/api/<service>/` literals in
|
||||||
|
# production source. After F7 the single source of truth for API paths is
|
||||||
|
# `src/api/endpoints.ts`; every production callsite of `api.*` / `createSSE`
|
||||||
|
# must use an `endpoints.*` builder. Flags string literals of the form
|
||||||
|
# '/api/admin/users'
|
||||||
|
# "/api/admin/auth/refresh"
|
||||||
|
# `/api/flights/${id}/waypoints`
|
||||||
|
# Allowed:
|
||||||
|
# - the contract owner itself: src/api/endpoints.ts
|
||||||
|
# - test files: *.test.ts / *.test.tsx (the test file IS the contract)
|
||||||
|
static_check_no_api_literals() {
|
||||||
|
node "$PROJECT_ROOT/scripts/check-arch-imports.mjs" --mode=api-literals
|
||||||
|
}
|
||||||
|
|
||||||
# AZ-479 NFT-PERF-01 / NFT-RES-LIM-01 — initial JS bundle ≤ 2 MB gzipped.
|
# AZ-479 NFT-PERF-01 / NFT-RES-LIM-01 — initial JS bundle ≤ 2 MB gzipped.
|
||||||
# Same threshold + measurement as scripts/run-performance-tests.sh; this
|
# Same threshold + measurement as scripts/run-performance-tests.sh; this
|
||||||
# entry routes the gate through the static profile so every commit is
|
# entry routes the gate through the static profile so every commit is
|
||||||
@@ -516,6 +548,8 @@ if [ "$RUN_STATIC" = "true" ]; then
|
|||||||
run_static "STC-T1" "tsc --noEmit (test config)" "AC-6" "n/a" static_check_typecheck
|
run_static "STC-T1" "tsc --noEmit (test config)" "AC-6" "n/a" static_check_typecheck
|
||||||
run_static "STC-B1" "vite build succeeds" "AC-6" "n/a" static_check_vite_build
|
run_static "STC-B1" "vite build succeeds" "AC-6" "n/a" static_check_vite_build
|
||||||
run_static "STC-S5" "mission-planner not in dist/" "AC-31" "n/a" static_check_dist_no_mission_planner
|
run_static "STC-S5" "mission-planner not in dist/" "AC-31" "n/a" static_check_dist_no_mission_planner
|
||||||
|
run_static "STC-ARCH-01" "no cross-component deep imports (barrel-only)" "F4" "AZ-485" static_check_no_cross_component_deep_imports
|
||||||
|
run_static "STC-ARCH-02" "no hardcoded /api/<service>/ literals in src/" "F7" "AZ-486" static_check_no_api_literals
|
||||||
run_static "STC-PERF01" "initial JS bundle ≤ 2 MB gz" "NFT-PERF-01" "40" static_check_bundle_size
|
run_static "STC-PERF01" "initial JS bundle ≤ 2 MB gz" "NFT-PERF-01" "40" static_check_bundle_size
|
||||||
run_static "STC-RES02" "nginx client_max_body_size 500M" "NFT-RES-LIM-02" "n/a" static_check_nginx_body_cap
|
run_static "STC-RES02" "nginx client_max_body_size 500M" "NFT-RES-LIM-02" "n/a" static_check_nginx_body_cap
|
||||||
run_static "STC-RES03" "Dockerfile final stage nginx:alpine no Node" "NFT-RES-LIM-03" "n/a" static_check_dockerfile_nginx_alpine
|
run_static "STC-RES03" "Dockerfile final stage nginx:alpine no Node" "NFT-RES-LIM-03" "n/a" static_check_dockerfile_nginx_alpine
|
||||||
|
|||||||
+8
-10
@@ -1,14 +1,12 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider } from './auth/AuthContext'
|
import { AuthProvider, ProtectedRoute } from './auth'
|
||||||
import { FlightProvider } from './components/FlightContext'
|
import { Header, FlightProvider } from './components'
|
||||||
import ProtectedRoute from './auth/ProtectedRoute'
|
import { LoginPage } from './features/login'
|
||||||
import LoginPage from './features/login/LoginPage'
|
import { FlightsPage } from './features/flights'
|
||||||
import FlightsPage from './features/flights/FlightsPage'
|
import { AnnotationsPage } from './features/annotations'
|
||||||
import AnnotationsPage from './features/annotations/AnnotationsPage'
|
import { DatasetPage } from './features/dataset'
|
||||||
import DatasetPage from './features/dataset/DatasetPage'
|
import { AdminPage } from './features/admin'
|
||||||
import AdminPage from './features/admin/AdminPage'
|
import { SettingsPage } from './features/settings'
|
||||||
import SettingsPage from './features/settings/SettingsPage'
|
|
||||||
import Header from './components/Header'
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import { endpoints } from './endpoints'
|
||||||
|
|
||||||
let accessToken: string | null = null
|
let accessToken: string | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,7 +87,7 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|||||||
|
|
||||||
async function refreshToken(): Promise<boolean> {
|
async function refreshToken(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(getApiBase() + '/api/admin/auth/refresh', { method: 'POST', credentials: 'include' })
|
const res = await fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' })
|
||||||
if (!res.ok) return false
|
if (!res.ok) return false
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { endpoints } from './endpoints'
|
||||||
|
import { endpoints as endpointsViaBarrel } from '../api'
|
||||||
|
|
||||||
|
// AZ-486 / F7 — this test file IS the wire-contract for the UI ↔ nginx layer
|
||||||
|
// (per `module-layout.md`'s "code-derived documentation" pattern referenced in
|
||||||
|
// the task spec). Every builder is asserted to produce the URL literal that
|
||||||
|
// existed in source before the refactor and that MSW handlers + e2e stubs
|
||||||
|
// intercept today. A change to any assertion below is a wire-contract change
|
||||||
|
// and MUST be coordinated with backend + MSW + e2e stubs in the same commit.
|
||||||
|
|
||||||
|
describe('AZ-486 endpoints — wire-contract URLs', () => {
|
||||||
|
describe('AC-1: admin', () => {
|
||||||
|
it('admin.authRefresh', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.admin.authRefresh()).toBe('/api/admin/auth/refresh')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin.authLogin', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.admin.authLogin()).toBe('/api/admin/auth/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin.authLogout', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.admin.authLogout()).toBe('/api/admin/auth/logout')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin.users', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.admin.users()).toBe('/api/admin/users')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin.user(id) interpolates the id', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.admin.user('abc')).toBe('/api/admin/users/abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin.classes', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.admin.classes()).toBe('/api/admin/classes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin.class(id) interpolates the id (string)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.admin.class('cls-7')).toBe('/api/admin/classes/cls-7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin.class(id) interpolates the id (number — DetectionClass.id today)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.admin.class(42)).toBe('/api/admin/classes/42')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AC-1: annotations', () => {
|
||||||
|
it('annotations.classes', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.classes()).toBe('/api/annotations/classes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.settingsUser', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.settingsUser()).toBe(
|
||||||
|
'/api/annotations/settings/user',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.settingsSystem', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.settingsSystem()).toBe(
|
||||||
|
'/api/annotations/settings/system',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.settingsDirectories', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.settingsDirectories()).toBe(
|
||||||
|
'/api/annotations/settings/directories',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.annotations', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.annotations()).toBe(
|
||||||
|
'/api/annotations/annotations',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.annotationsByMedia(mediaId) defaults pageSize=1000', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.annotationsByMedia('m-1')).toBe(
|
||||||
|
'/api/annotations/annotations?mediaId=m-1&pageSize=1000',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.annotationsByMedia(mediaId, pageSize) overrides pageSize', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.annotationsByMedia('m-1', 50)).toBe(
|
||||||
|
'/api/annotations/annotations?mediaId=m-1&pageSize=50',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.annotationImage(id)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.annotationImage('ann-7')).toBe(
|
||||||
|
'/api/annotations/annotations/ann-7/image',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.annotationThumbnail(id)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.annotationThumbnail('ann-7')).toBe(
|
||||||
|
'/api/annotations/annotations/ann-7/thumbnail',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.annotationEvents', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.annotationEvents()).toBe(
|
||||||
|
'/api/annotations/annotations/events',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.media(queryString)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.media('page=1&pageSize=50')).toBe(
|
||||||
|
'/api/annotations/media?page=1&pageSize=50',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.mediaFile(id)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.mediaFile('m-1')).toBe(
|
||||||
|
'/api/annotations/media/m-1/file',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.mediaItem(id)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.mediaItem('m-1')).toBe(
|
||||||
|
'/api/annotations/media/m-1',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.mediaBatch', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.mediaBatch()).toBe(
|
||||||
|
'/api/annotations/media/batch',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.dataset(queryString)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.dataset('status=PENDING')).toBe(
|
||||||
|
'/api/annotations/dataset?status=PENDING',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.datasetItem(annotationId)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.datasetItem('ann-7')).toBe(
|
||||||
|
'/api/annotations/dataset/ann-7',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.datasetBulkStatus', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.datasetBulkStatus()).toBe(
|
||||||
|
'/api/annotations/dataset/bulk-status',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotations.datasetClassDistribution', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.annotations.datasetClassDistribution()).toBe(
|
||||||
|
'/api/annotations/dataset/class-distribution',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AC-1: flights', () => {
|
||||||
|
it('flights.collection() without query', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.flights.collection()).toBe('/api/flights')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flights.collection(queryString) appends ?queryString', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.flights.collection('pageSize=1000')).toBe(
|
||||||
|
'/api/flights?pageSize=1000',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flights.aircrafts', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.flights.aircrafts()).toBe('/api/flights/aircrafts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flights.aircraft(id)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.flights.aircraft('ac-1')).toBe(
|
||||||
|
'/api/flights/aircrafts/ac-1',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flights.flight(id)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.flights.flight('f-1')).toBe('/api/flights/f-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flights.flightWaypoints(id)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.flights.flightWaypoints('f-1')).toBe(
|
||||||
|
'/api/flights/f-1/waypoints',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flights.flightWaypoint(flightId, waypointId)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.flights.flightWaypoint('f-1', 'wp-2')).toBe(
|
||||||
|
'/api/flights/f-1/waypoints/wp-2',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flights.flightLiveGps(id)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.flights.flightLiveGps('f-1')).toBe(
|
||||||
|
'/api/flights/f-1/live-gps',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AC-1: detect', () => {
|
||||||
|
it('detect.media(mediaId)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpoints.detect.media('m-1')).toBe('/api/detect/m-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AC-6: barrel re-export', () => {
|
||||||
|
it('endpoints is the same object when imported from src/api (the F4 barrel)', () => {
|
||||||
|
// Assert
|
||||||
|
expect(endpointsViaBarrel).toBe(endpoints)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// AZ-486 / F7 — Single source of truth for every `/api/<service>/<path>` URL
|
||||||
|
// the UI talks to today. Closes architecture baseline finding F7.
|
||||||
|
//
|
||||||
|
// Every UI callsite of `api.*`, `createSSE`, and raw image/video `src` URLs
|
||||||
|
// pointing at an API resource MUST go through one of these builders. The
|
||||||
|
// STC-ARCH-02 static gate (scripts/check-arch-imports.mjs `--mode=api-literals`,
|
||||||
|
// wired into scripts/run-tests.sh) enforces it.
|
||||||
|
//
|
||||||
|
// **Wire-contract invariant**: the strings produced here are character-identical
|
||||||
|
// to the literals that lived in the source before this refactor and that MSW
|
||||||
|
// handlers + e2e stubs intercept. Any change to a builder's output is a wire-
|
||||||
|
// contract change and MUST be coordinated with the backend + the MSW handler
|
||||||
|
// surface + e2e stubs in the same commit. The accompanying test file
|
||||||
|
// (`endpoints.test.ts`) pins every URL string and is the contract documentation.
|
||||||
|
//
|
||||||
|
// **Why function form (not constants)**: per user direction at task-creation
|
||||||
|
// time; allows parameter interpolation without callsite re-introducing template
|
||||||
|
// literals and keeps tree-shaking per-builder under Vite's production rollup.
|
||||||
|
|
||||||
|
export const endpoints = {
|
||||||
|
admin: {
|
||||||
|
authRefresh: () => '/api/admin/auth/refresh',
|
||||||
|
authLogin: () => '/api/admin/auth/login',
|
||||||
|
authLogout: () => '/api/admin/auth/logout',
|
||||||
|
users: () => '/api/admin/users',
|
||||||
|
user: (id: string) => `/api/admin/users/${id}`,
|
||||||
|
classes: () => '/api/admin/classes',
|
||||||
|
// DetectionClass.id is `number` in the type system; widened to accept
|
||||||
|
// string for forward-compat if the backend switches the column to UUID.
|
||||||
|
class: (id: string | number) => `/api/admin/classes/${id}`,
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
classes: () => '/api/annotations/classes',
|
||||||
|
settingsUser: () => '/api/annotations/settings/user',
|
||||||
|
settingsSystem: () => '/api/annotations/settings/system',
|
||||||
|
settingsDirectories: () => '/api/annotations/settings/directories',
|
||||||
|
annotations: () => '/api/annotations/annotations',
|
||||||
|
// page-size is currently always 1000 at every callsite; expose it as an
|
||||||
|
// optional param so future tuning is a single-file change.
|
||||||
|
annotationsByMedia: (mediaId: string, pageSize: number = 1000) =>
|
||||||
|
`/api/annotations/annotations?mediaId=${mediaId}&pageSize=${pageSize}`,
|
||||||
|
annotationImage: (annotationId: string) =>
|
||||||
|
`/api/annotations/annotations/${annotationId}/image`,
|
||||||
|
annotationThumbnail: (annotationId: string) =>
|
||||||
|
`/api/annotations/annotations/${annotationId}/thumbnail`,
|
||||||
|
annotationEvents: () => '/api/annotations/annotations/events',
|
||||||
|
// Callers pre-build a URLSearchParams.toString() and pass it through; the
|
||||||
|
// builder owns the path + `?` only so the wire-contract stays identical.
|
||||||
|
media: (queryString: string) => `/api/annotations/media?${queryString}`,
|
||||||
|
mediaFile: (mediaId: string) => `/api/annotations/media/${mediaId}/file`,
|
||||||
|
mediaItem: (mediaId: string) => `/api/annotations/media/${mediaId}`,
|
||||||
|
mediaBatch: () => '/api/annotations/media/batch',
|
||||||
|
dataset: (queryString: string) => `/api/annotations/dataset?${queryString}`,
|
||||||
|
datasetItem: (annotationId: string) =>
|
||||||
|
`/api/annotations/dataset/${annotationId}`,
|
||||||
|
datasetBulkStatus: () => '/api/annotations/dataset/bulk-status',
|
||||||
|
datasetClassDistribution: () =>
|
||||||
|
'/api/annotations/dataset/class-distribution',
|
||||||
|
},
|
||||||
|
flights: {
|
||||||
|
// GET (with `?pageSize=...`) lists flights; POST (no query) creates one.
|
||||||
|
// The query string is owned by the caller (URLSearchParams.toString()) so
|
||||||
|
// the wire-contract stays identical to today.
|
||||||
|
collection: (queryString?: string) =>
|
||||||
|
queryString ? `/api/flights?${queryString}` : '/api/flights',
|
||||||
|
aircrafts: () => '/api/flights/aircrafts',
|
||||||
|
aircraft: (id: string) => `/api/flights/aircrafts/${id}`,
|
||||||
|
flight: (id: string) => `/api/flights/${id}`,
|
||||||
|
flightWaypoints: (id: string) => `/api/flights/${id}/waypoints`,
|
||||||
|
flightWaypoint: (flightId: string, waypointId: string) =>
|
||||||
|
`/api/flights/${flightId}/waypoints/${waypointId}`,
|
||||||
|
flightLiveGps: (id: string) => `/api/flights/${id}/live-gps`,
|
||||||
|
},
|
||||||
|
detect: {
|
||||||
|
// Trigger detection for a media item. Single-segment service path.
|
||||||
|
media: (mediaId: string) => `/api/detect/${mediaId}`,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { api, setToken, getToken, getApiBase, setNavigateToLogin } from './client'
|
||||||
|
export { createSSE } from './sse'
|
||||||
|
export { endpoints } from './endpoints'
|
||||||
@@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw'
|
|||||||
import { act, useRef } from 'react'
|
import { act, useRef } from 'react'
|
||||||
import { server } from '../../tests/msw/server'
|
import { server } from '../../tests/msw/server'
|
||||||
import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render'
|
import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render'
|
||||||
import { api, getToken, setToken } from '../api/client'
|
import { api, getToken, setToken } from '../api'
|
||||||
import { seedBearer, clearBearer } from '../../tests/helpers/auth'
|
import { seedBearer, clearBearer } from '../../tests/helpers/auth'
|
||||||
|
|
||||||
// AZ-457 — Auth & token-handling at the React composition root.
|
// AZ-457 — Auth & token-handling at the React composition root.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
|
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
|
||||||
import { api, setToken } from '../api/client'
|
import { api, endpoints, setToken } from '../api'
|
||||||
import type { AuthUser } from '../types'
|
import type { AuthUser } from '../types'
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -21,7 +21,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<{ user: AuthUser; token: string }>('/api/admin/auth/refresh')
|
api.get<{ user: AuthUser; token: string }>(endpoints.admin.authRefresh())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setUser(data.user)
|
setUser(data.user)
|
||||||
@@ -31,13 +31,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const login = useCallback(async (email: string, password: string) => {
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
const data = await api.post<{ token: string; user: AuthUser }>('/api/admin/auth/login', { email, password })
|
const data = await api.post<{ token: string; user: AuthUser }>(endpoints.admin.authLogin(), { email, password })
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setUser(data.user)
|
setUser(data.user)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try { await api.post('/api/admin/auth/logout') } catch {}
|
try { await api.post(endpoints.admin.authLogout()) } catch {}
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { AuthProvider, useAuth } from './AuthContext'
|
||||||
|
export { default as ProtectedRoute } from './ProtectedRoute'
|
||||||
@@ -2,7 +2,11 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
|
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
|
||||||
import { FaRegSnowflake } from 'react-icons/fa'
|
import { FaRegSnowflake } from 'react-icons/fa'
|
||||||
import { api } from '../api/client'
|
import { api, endpoints } from '../api'
|
||||||
|
// classColors lives under 06_annotations until F3 moves it to its own home.
|
||||||
|
// Importing through the 06_annotations barrel would create a cycle
|
||||||
|
// (DetectionClasses -> 06_annotations barrel -> AnnotationsPage -> DetectionClasses).
|
||||||
|
// STC-ARCH-01 exempts this single path as an F3-pending edge.
|
||||||
import { getClassColor, FALLBACK_CLASS_NAMES } from '../features/annotations/classColors'
|
import { getClassColor, FALLBACK_CLASS_NAMES } from '../features/annotations/classColors'
|
||||||
import type { DetectionClass } from '../types'
|
import type { DetectionClass } from '../types'
|
||||||
|
|
||||||
@@ -29,7 +33,7 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
|
|||||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<DetectionClass[]>('/api/annotations/classes')
|
api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||||
.then(list => setClasses(list?.length ? list : FALLBACK_CLASSES))
|
.then(list => setClasses(list?.length ? list : FALLBACK_CLASSES))
|
||||||
.catch(() => setClasses(FALLBACK_CLASSES))
|
.catch(() => setClasses(FALLBACK_CLASSES))
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||||
import { api } from '../api/client'
|
import { api, endpoints } from '../api'
|
||||||
import type { Flight, UserSettings } from '../types'
|
import type { Flight, UserSettings } from '../types'
|
||||||
|
|
||||||
interface FlightState {
|
interface FlightState {
|
||||||
@@ -21,17 +21,17 @@ export function FlightProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const refreshFlights = useCallback(async () => {
|
const refreshFlights = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ items: Flight[] }>('/api/flights?pageSize=1000')
|
const data = await api.get<{ items: Flight[] }>(endpoints.flights.collection('pageSize=1000'))
|
||||||
setFlights(data.items ?? [])
|
setFlights(data.items ?? [])
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshFlights()
|
refreshFlights()
|
||||||
api.get<UserSettings>('/api/annotations/settings/user')
|
api.get<UserSettings>(endpoints.annotations.settingsUser())
|
||||||
.then(settings => {
|
.then(settings => {
|
||||||
if (settings?.selectedFlightId) {
|
if (settings?.selectedFlightId) {
|
||||||
api.get<Flight>(`/api/flights/${settings.selectedFlightId}`)
|
api.get<Flight>(endpoints.flights.flight(settings.selectedFlightId))
|
||||||
.then(f => setSelectedFlight(f))
|
.then(f => setSelectedFlight(f))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ export function FlightProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const selectFlight = useCallback((f: Flight | null) => {
|
const selectFlight = useCallback((f: Flight | null) => {
|
||||||
setSelectedFlight(f)
|
setSelectedFlight(f)
|
||||||
api.put('/api/annotations/settings/user', { selectedFlightId: f?.id ?? null }).catch(() => {})
|
api.put(endpoints.annotations.settingsUser(), { selectedFlightId: f?.id ?? null }).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NavLink, useNavigate } from 'react-router-dom'
|
import { NavLink, useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth'
|
||||||
import { useFlight } from './FlightContext'
|
import { useFlight } from './FlightContext'
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import HelpModal from './HelpModal'
|
import HelpModal from './HelpModal'
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as Header } from './Header'
|
||||||
|
export { default as HelpModal } from './HelpModal'
|
||||||
|
export { default as ConfirmDialog } from './ConfirmDialog'
|
||||||
|
export { default as DetectionClasses } from './DetectionClasses'
|
||||||
|
export { FlightProvider, useFlight } from './FlightContext'
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { api } from '../../api/client'
|
import { api, endpoints } from '../../api'
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
import { ConfirmDialog } from '../../components'
|
||||||
import type { DetectionClass, Aircraft, User } from '../../types'
|
import type { DetectionClass, Aircraft, User } from '../../types'
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
@@ -14,41 +14,41 @@ export default function AdminPage() {
|
|||||||
const [deactivateId, setDeactivateId] = useState<string | null>(null)
|
const [deactivateId, setDeactivateId] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
|
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
|
||||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
api.get<User[]>('/api/admin/users').then(setUsers).catch(() => {})
|
api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleAddClass = async () => {
|
const handleAddClass = async () => {
|
||||||
if (!newClass.name) return
|
if (!newClass.name) return
|
||||||
await api.post('/api/admin/classes', newClass)
|
await api.post(endpoints.admin.classes(), newClass)
|
||||||
const updated = await api.get<DetectionClass[]>('/api/annotations/classes')
|
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||||
setClasses(updated)
|
setClasses(updated)
|
||||||
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteClass = async (id: number) => {
|
const handleDeleteClass = async (id: number) => {
|
||||||
await api.delete(`/api/admin/classes/${id}`)
|
await api.delete(endpoints.admin.class(id))
|
||||||
setClasses(prev => prev.filter(c => c.id !== id))
|
setClasses(prev => prev.filter(c => c.id !== id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddUser = async () => {
|
const handleAddUser = async () => {
|
||||||
if (!newUser.email || !newUser.password) return
|
if (!newUser.email || !newUser.password) return
|
||||||
await api.post('/api/admin/users', newUser)
|
await api.post(endpoints.admin.users(), newUser)
|
||||||
const updated = await api.get<User[]>('/api/admin/users')
|
const updated = await api.get<User[]>(endpoints.admin.users())
|
||||||
setUsers(updated)
|
setUsers(updated)
|
||||||
setNewUser({ name: '', email: '', password: '', role: 'Annotator' })
|
setNewUser({ name: '', email: '', password: '', role: 'Annotator' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeactivate = async () => {
|
const handleDeactivate = async () => {
|
||||||
if (!deactivateId) return
|
if (!deactivateId) return
|
||||||
await api.patch(`/api/admin/users/${deactivateId}`, { isActive: false })
|
await api.patch(endpoints.admin.user(deactivateId), { isActive: false })
|
||||||
setUsers(prev => prev.map(u => u.id === deactivateId ? { ...u, isActive: false } : u))
|
setUsers(prev => prev.map(u => u.id === deactivateId ? { ...u, isActive: false } : u))
|
||||||
setDeactivateId(null)
|
setDeactivateId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleDefault = async (a: Aircraft) => {
|
const handleToggleDefault = async (a: Aircraft) => {
|
||||||
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
|
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
||||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as AdminPage } from './AdminPage'
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
import { useResizablePanel } from '../../hooks'
|
||||||
import { api } from '../../api/client'
|
import { api, endpoints } from '../../api'
|
||||||
import MediaList from './MediaList'
|
import MediaList from './MediaList'
|
||||||
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
||||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||||
import AnnotationsSidebar from './AnnotationsSidebar'
|
import AnnotationsSidebar from './AnnotationsSidebar'
|
||||||
import DetectionClasses from '../../components/DetectionClasses'
|
import { DetectionClasses } from '../../components'
|
||||||
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
||||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors'
|
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors'
|
||||||
import type { Media, AnnotationListItem, Detection } from '../../types'
|
import type { Media, AnnotationListItem, Detection } from '../../types'
|
||||||
@@ -36,9 +36,9 @@ export default function AnnotationsPage() {
|
|||||||
|
|
||||||
if (!selectedMedia.path.startsWith('blob:')) {
|
if (!selectedMedia.path.startsWith('blob:')) {
|
||||||
try {
|
try {
|
||||||
await api.post('/api/annotations/annotations', body)
|
await api.post(endpoints.annotations.annotations(), body)
|
||||||
const res = await api.get<{ items: AnnotationListItem[] }>(
|
const res = await api.get<{ items: AnnotationListItem[] }>(
|
||||||
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
|
endpoints.annotations.annotationsByMedia(selectedMedia.id),
|
||||||
)
|
)
|
||||||
setAnnotations(res.items)
|
setAnnotations(res.items)
|
||||||
return
|
return
|
||||||
@@ -96,7 +96,7 @@ export default function AnnotationsPage() {
|
|||||||
img.crossOrigin = 'anonymous'
|
img.crossOrigin = 'anonymous'
|
||||||
img.src = selectedMedia.path.startsWith('blob:')
|
img.src = selectedMedia.path.startsWith('blob:')
|
||||||
? selectedMedia.path
|
? selectedMedia.path
|
||||||
: `/api/annotations/media/${selectedMedia.id}/file`
|
: endpoints.annotations.mediaFile(selectedMedia.id)
|
||||||
await new Promise(res => { img.onload = res; img.onerror = res })
|
await new Promise(res => { img.onload = res; img.onerror = res })
|
||||||
w = img.naturalWidth
|
w = img.naturalWidth
|
||||||
h = img.naturalHeight
|
h = img.naturalHeight
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FaDownload } from 'react-icons/fa'
|
import { FaDownload } from 'react-icons/fa'
|
||||||
import { api } from '../../api/client'
|
import { api, createSSE, endpoints } from '../../api'
|
||||||
import { createSSE } from '../../api/sse'
|
|
||||||
import { getClassColor } from './classColors'
|
import { getClassColor } from './classColors'
|
||||||
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||||
|
|
||||||
@@ -22,10 +21,10 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!media) return
|
if (!media) return
|
||||||
return createSSE<{ annotationId: string; mediaId: string; status: number }>('/api/annotations/annotations/events', (event) => {
|
return createSSE<{ annotationId: string; mediaId: string; status: number }>(endpoints.annotations.annotationEvents(), (event) => {
|
||||||
if (event.mediaId === media.id) {
|
if (event.mediaId === media.id) {
|
||||||
api.get<PaginatedResponse<AnnotationListItem>>(
|
api.get<PaginatedResponse<AnnotationListItem>>(
|
||||||
`/api/annotations/annotations?mediaId=${media.id}&pageSize=1000`
|
endpoints.annotations.annotationsByMedia(media.id),
|
||||||
).then(res => onAnnotationsUpdate(res.items)).catch(() => {})
|
).then(res => onAnnotationsUpdate(res.items)).catch(() => {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -36,7 +35,7 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
|||||||
setDetecting(true)
|
setDetecting(true)
|
||||||
setDetectLog(['Starting AI detection...'])
|
setDetectLog(['Starting AI detection...'])
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/detect/${media.id}`)
|
await api.post(endpoints.detect.media(media.id))
|
||||||
setDetectLog(prev => [...prev, 'Detection complete.'])
|
setDetectLog(prev => [...prev, 'Detection complete.'])
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
|
import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
|
||||||
|
import { endpoints } from '../../api'
|
||||||
import { MediaType } from '../../types'
|
import { MediaType } from '../../types'
|
||||||
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
|
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
|
||||||
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from './classColors'
|
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from './classColors'
|
||||||
@@ -76,11 +77,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.crossOrigin = 'anonymous'
|
img.crossOrigin = 'anonymous'
|
||||||
if (annotation && !media.path.startsWith('blob:')) {
|
if (annotation && !media.path.startsWith('blob:')) {
|
||||||
img.src = `/api/annotations/annotations/${annotation.id}/image`
|
img.src = endpoints.annotations.annotationImage(annotation.id)
|
||||||
} else if (media.path.startsWith('blob:')) {
|
} else if (media.path.startsWith('blob:')) {
|
||||||
img.src = media.path
|
img.src = media.path
|
||||||
} else {
|
} else {
|
||||||
img.src = `/api/annotations/media/${media.id}/file`
|
img.src = endpoints.annotations.mediaFile(media.id)
|
||||||
}
|
}
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
imgRef.current = img
|
imgRef.current = img
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { useFlight } from '../../components/FlightContext'
|
import { useFlight, ConfirmDialog } from '../../components'
|
||||||
import { api } from '../../api/client'
|
import { api, endpoints } from '../../api'
|
||||||
import { useDebounce } from '../../hooks/useDebounce'
|
import { useDebounce } from '../../hooks'
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
|
||||||
import { MediaType } from '../../types'
|
import { MediaType } from '../../types'
|
||||||
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
|
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
if (selectedFlight) params.set('flightId', selectedFlight.id)
|
if (selectedFlight) params.set('flightId', selectedFlight.id)
|
||||||
if (debouncedFilter) params.set('name', debouncedFilter)
|
if (debouncedFilter) params.set('name', debouncedFilter)
|
||||||
try {
|
try {
|
||||||
const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`)
|
const res = await api.get<PaginatedResponse<Media>>(endpoints.annotations.media(params.toString()))
|
||||||
setMedia(prev => {
|
setMedia(prev => {
|
||||||
// Keep local-only (blob URL) entries, merge with backend entries
|
// Keep local-only (blob URL) entries, merge with backend entries
|
||||||
const local = prev.filter(m => m.path.startsWith('blob:'))
|
const local = prev.filter(m => m.path.startsWith('blob:'))
|
||||||
@@ -56,7 +55,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await api.get<PaginatedResponse<AnnotationListItem>>(
|
const res = await api.get<PaginatedResponse<AnnotationListItem>>(
|
||||||
`/api/annotations/annotations?mediaId=${m.id}&pageSize=1000`
|
endpoints.annotations.annotationsByMedia(m.id),
|
||||||
)
|
)
|
||||||
onAnnotationsLoaded(res.items)
|
onAnnotationsLoaded(res.items)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -73,7 +72,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
setDeleteId(null)
|
setDeleteId(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try { await api.delete(`/api/annotations/media/${deleteId}`) } catch {}
|
try { await api.delete(endpoints.annotations.mediaItem(deleteId)) } catch {}
|
||||||
setDeleteId(null)
|
setDeleteId(null)
|
||||||
fetchMedia()
|
fetchMedia()
|
||||||
}
|
}
|
||||||
@@ -88,7 +87,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('waypointId', '')
|
form.append('waypointId', '')
|
||||||
for (const file of arr) form.append('files', file)
|
for (const file of arr) form.append('files', file)
|
||||||
await api.upload('/api/annotations/media/batch', form)
|
await api.upload(endpoints.annotations.mediaBatch(), form)
|
||||||
fetchMedia()
|
fetchMedia()
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||||
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
|
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
|
||||||
|
import { endpoints } from '../../api'
|
||||||
import type { Media } from '../../types'
|
import type { Media } from '../../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -38,7 +39,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
|
|
||||||
const videoUrl = media.path.startsWith('blob:')
|
const videoUrl = media.path.startsWith('blob:')
|
||||||
? media.path
|
? media.path
|
||||||
: `/api/annotations/media/${media.id}/file`
|
: endpoints.annotations.mediaFile(media.id)
|
||||||
|
|
||||||
const stepFrames = useCallback((count: number) => {
|
const stepFrames = useCallback((count: number) => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export { default as AnnotationsPage } from './AnnotationsPage'
|
||||||
|
// CanvasEditor remains in the Public API while F2 (cross-feature edge to
|
||||||
|
// 07_dataset) is open. Closing F2 will remove this re-export.
|
||||||
|
export { default as CanvasEditor } from './CanvasEditor'
|
||||||
|
//
|
||||||
|
// classColors symbols are NOT re-exported here. The file is logically owned
|
||||||
|
// by 11_class-colors but lives under this directory until F3 moves it. Re-
|
||||||
|
// exporting through this barrel creates a circular dependency
|
||||||
|
// AnnotationsPage -> DetectionClasses -> 06_annotations barrel -> AnnotationsPage
|
||||||
|
// because DetectionClasses (03_shared-ui) imports classColors. Consumers
|
||||||
|
// import classColors directly via `src/features/annotations/classColors`
|
||||||
|
// as a documented F3-pending exemption. STC-ARCH-01 carries the exemption.
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { api } from '../../api/client'
|
import { api, endpoints } from '../../api'
|
||||||
import { useDebounce } from '../../hooks/useDebounce'
|
import { useDebounce, useResizablePanel } from '../../hooks'
|
||||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
import { useFlight, DetectionClasses, ConfirmDialog } from '../../components'
|
||||||
import { useFlight } from '../../components/FlightContext'
|
|
||||||
import DetectionClasses from '../../components/DetectionClasses'
|
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
|
||||||
import CanvasEditor from '../annotations/CanvasEditor'
|
import CanvasEditor from '../annotations/CanvasEditor'
|
||||||
import type { DatasetItem, PaginatedResponse, ClassDistributionItem, AnnotationListItem, Detection, Media } from '../../types'
|
import type { DatasetItem, PaginatedResponse, ClassDistributionItem, AnnotationListItem, Detection, Media } from '../../types'
|
||||||
import { AnnotationStatus } from '../../types'
|
import { AnnotationStatus } from '../../types'
|
||||||
@@ -45,7 +42,7 @@ export default function DatasetPage() {
|
|||||||
if (objectsOnly) params.set('hasDetections', 'true')
|
if (objectsOnly) params.set('hasDetections', 'true')
|
||||||
if (debouncedSearch) params.set('name', debouncedSearch)
|
if (debouncedSearch) params.set('name', debouncedSearch)
|
||||||
try {
|
try {
|
||||||
const res = await api.get<PaginatedResponse<DatasetItem>>(`/api/annotations/dataset?${params}`)
|
const res = await api.get<PaginatedResponse<DatasetItem>>(endpoints.annotations.dataset(params.toString()))
|
||||||
setItems(res.items)
|
setItems(res.items)
|
||||||
setTotalCount(res.totalCount)
|
setTotalCount(res.totalCount)
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -55,7 +52,7 @@ export default function DatasetPage() {
|
|||||||
|
|
||||||
const handleDoubleClick = async (item: DatasetItem) => {
|
const handleDoubleClick = async (item: DatasetItem) => {
|
||||||
try {
|
try {
|
||||||
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${item.annotationId}`)
|
const ann = await api.get<AnnotationListItem>(endpoints.annotations.datasetItem(item.annotationId))
|
||||||
setEditorAnnotation(ann)
|
setEditorAnnotation(ann)
|
||||||
setEditorDetections(ann.detections)
|
setEditorDetections(ann.detections)
|
||||||
setTab('editor')
|
setTab('editor')
|
||||||
@@ -64,7 +61,7 @@ export default function DatasetPage() {
|
|||||||
|
|
||||||
const handleValidate = async () => {
|
const handleValidate = async () => {
|
||||||
if (selectedIds.size === 0) return
|
if (selectedIds.size === 0) return
|
||||||
await api.post('/api/annotations/dataset/bulk-status', {
|
await api.post(endpoints.annotations.datasetBulkStatus(), {
|
||||||
annotationIds: Array.from(selectedIds),
|
annotationIds: Array.from(selectedIds),
|
||||||
status: AnnotationStatus.Validated,
|
status: AnnotationStatus.Validated,
|
||||||
})
|
})
|
||||||
@@ -74,7 +71,7 @@ export default function DatasetPage() {
|
|||||||
|
|
||||||
const loadDistribution = useCallback(async () => {
|
const loadDistribution = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.get<ClassDistributionItem[]>('/api/annotations/dataset/class-distribution')
|
const data = await api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
|
||||||
setDistribution(data)
|
setDistribution(data)
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [])
|
}, [])
|
||||||
@@ -183,7 +180,7 @@ export default function DatasetPage() {
|
|||||||
} ${item.isSeed ? 'ring-2 ring-az-red' : ''}`}
|
} ${item.isSeed ? 'ring-2 ring-az-red' : ''}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/api/annotations/annotations/${item.annotationId}/thumbnail`}
|
src={endpoints.annotations.annotationThumbnail(item.annotationId)}
|
||||||
alt={item.imageName}
|
alt={item.imageName}
|
||||||
className="w-full h-32 object-cover bg-az-bg"
|
className="w-full h-32 object-cover bg-az-bg"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as DatasetPage } from './DatasetPage'
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { useFlight } from '../../components/FlightContext'
|
import { useFlight, ConfirmDialog } from '../../components'
|
||||||
import { api } from '../../api/client'
|
import { api, createSSE, endpoints } from '../../api'
|
||||||
import { createSSE } from '../../api/sse'
|
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
|
||||||
import FlightListSidebar from './FlightListSidebar'
|
import FlightListSidebar from './FlightListSidebar'
|
||||||
import FlightParamsPanel from './FlightParamsPanel'
|
import FlightParamsPanel from './FlightParamsPanel'
|
||||||
import FlightMap from './FlightMap'
|
import FlightMap from './FlightMap'
|
||||||
@@ -40,7 +38,7 @@ export default function FlightsPage() {
|
|||||||
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
|
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
setAircraft(getMockAircraftParams())
|
setAircraft(getMockAircraftParams())
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => setCurrentPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
(pos) => setCurrentPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||||
@@ -50,7 +48,7 @@ export default function FlightsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFlight) { setPoints([]); return }
|
if (!selectedFlight) { setPoints([]); return }
|
||||||
api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`)
|
api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id))
|
||||||
.then(wps => {
|
.then(wps => {
|
||||||
setPoints(wps.sort((a, b) => a.order - b.order).map(wp => ({
|
setPoints(wps.sort((a, b) => a.order - b.order).map(wp => ({
|
||||||
id: wp.id,
|
id: wp.id,
|
||||||
@@ -64,7 +62,7 @@ export default function FlightsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFlight || mode !== 'gps') return
|
if (!selectedFlight || mode !== 'gps') return
|
||||||
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(`/api/flights/${selectedFlight.id}/live-gps`, (data) => setLiveGps(data))
|
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(endpoints.flights.flightLiveGps(selectedFlight.id), (data) => setLiveGps(data))
|
||||||
}, [selectedFlight, mode])
|
}, [selectedFlight, mode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -73,12 +71,12 @@ export default function FlightsPage() {
|
|||||||
}, [points, aircraft, initialAltitude])
|
}, [points, aircraft, initialAltitude])
|
||||||
|
|
||||||
const handleCreateFlight = async (name: string) => {
|
const handleCreateFlight = async (name: string) => {
|
||||||
await api.post('/api/flights', { name })
|
await api.post(endpoints.flights.collection(), { name })
|
||||||
refreshFlights()
|
refreshFlights()
|
||||||
}
|
}
|
||||||
const handleDeleteFlight = async () => {
|
const handleDeleteFlight = async () => {
|
||||||
if (!deleteId) return
|
if (!deleteId) return
|
||||||
await api.delete(`/api/flights/${deleteId}`)
|
await api.delete(endpoints.flights.flight(deleteId))
|
||||||
if (selectedFlight?.id === deleteId) selectFlight(null)
|
if (selectedFlight?.id === deleteId) selectFlight(null)
|
||||||
setDeleteId(null)
|
setDeleteId(null)
|
||||||
refreshFlights()
|
refreshFlights()
|
||||||
@@ -201,12 +199,12 @@ export default function FlightsPage() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!selectedFlight) return
|
if (!selectedFlight) return
|
||||||
const existing = await api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`).catch(() => [] as Waypoint[])
|
const existing = await api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id)).catch(() => [] as Waypoint[])
|
||||||
for (const wp of existing) {
|
for (const wp of existing) {
|
||||||
await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wp.id}`).catch(() => {})
|
await api.delete(endpoints.flights.flightWaypoint(selectedFlight.id, wp.id)).catch(() => {})
|
||||||
}
|
}
|
||||||
for (let i = 0; i < points.length; i++) {
|
for (let i = 0; i < points.length; i++) {
|
||||||
await api.post(`/api/flights/${selectedFlight.id}/waypoints`, {
|
await api.post(endpoints.flights.flightWaypoints(selectedFlight.id), {
|
||||||
name: `Point ${i + 1}`,
|
name: `Point ${i + 1}`,
|
||||||
latitude: points[i].position.lat,
|
latitude: points[i].position.lat,
|
||||||
longitude: points[i].position.lng,
|
longitude: points[i].position.lng,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as FlightsPage } from './FlightsPage'
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth } from '../../auth/AuthContext'
|
import { useAuth } from '../../auth'
|
||||||
|
|
||||||
type UnlockStep = 'idle' | 'authenticating' | 'downloadingKey' | 'decrypting' | 'startingServices' | 'ready'
|
type UnlockStep = 'idle' | 'authenticating' | 'downloadingKey' | 'decrypting' | 'startingServices' | 'ready'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as LoginPage } from './LoginPage'
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { api } from '../../api/client'
|
import { api, endpoints } from '../../api'
|
||||||
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
|
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -11,27 +11,27 @@ export default function SettingsPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<SystemSettings>('/api/annotations/settings/system').then(setSystem).catch(() => {})
|
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
|
||||||
api.get<DirectorySettings>('/api/annotations/settings/directories').then(setDirs).catch(() => {})
|
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
|
||||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const saveSystem = async () => {
|
const saveSystem = async () => {
|
||||||
if (!system) return
|
if (!system) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
await api.put('/api/annotations/settings/system', system)
|
await api.put(endpoints.annotations.settingsSystem(), system)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveDirs = async () => {
|
const saveDirs = async () => {
|
||||||
if (!dirs) return
|
if (!dirs) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
await api.put('/api/annotations/settings/directories', dirs)
|
await api.put(endpoints.annotations.settingsDirectories(), dirs)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleDefault = async (a: Aircraft) => {
|
const handleToggleDefault = async (a: Aircraft) => {
|
||||||
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
|
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
||||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as SettingsPage } from './SettingsPage'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { useDebounce } from './useDebounce'
|
||||||
|
export { useResizablePanel } from './useResizablePanel'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './i18n'
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './i18n/i18n'
|
import './i18n'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { server } from './msw/server'
|
|||||||
import { jsonResponse, paginate } from './msw/helpers'
|
import { jsonResponse, paginate } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { FlightProvider } from '../src/components/FlightContext'
|
import { FlightProvider } from '../src/components'
|
||||||
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
|
import { AnnotationsPage } from '../src/features/annotations'
|
||||||
import { AnnotationSource, AnnotationStatus, MediaType, MediaStatus, Affiliation, CombatReadiness } from '../src/types'
|
import { AnnotationSource, AnnotationStatus, MediaType, MediaStatus, Affiliation, CombatReadiness } from '../src/types'
|
||||||
import type { Media, AnnotationListItem, Detection } from '../src/types'
|
import type { Media, AnnotationListItem, Detection } from '../src/types'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { writeFileSync, rmSync, mkdirSync, existsSync } from 'node:fs'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
|
||||||
|
// AZ-485 / F4 — verifies the STC-ARCH-01 static gate (scripts/check-arch-imports.mjs):
|
||||||
|
// - AC-5 : passes on the migrated codebase as-is
|
||||||
|
// - AC-4 : fails when a synthetic cross-component deep import is added
|
||||||
|
// - AC-4 : ignores the F3-pending exemption (features/annotations/classColors)
|
||||||
|
// - AC-4 : ignores deep imports written inside // line comments
|
||||||
|
//
|
||||||
|
// AZ-486 / F7 — verifies the STC-ARCH-02 static gate (same script,
|
||||||
|
// --mode=api-literals):
|
||||||
|
// - AC-4 : passes on the migrated codebase as-is
|
||||||
|
// - AC-3 : fails when a synthetic `/api/<service>/...` literal is added
|
||||||
|
// (covers single-quoted, double-quoted, and template-literal forms)
|
||||||
|
// - AC-3 : *.test.ts colocated under src/ are exempt (the test file IS the
|
||||||
|
// contract per module-layout.md "code-derived documentation")
|
||||||
|
// - AC-3 : literals inside // line comments do not trip the gate
|
||||||
|
//
|
||||||
|
// Both gates are exercised via subprocess so a regression in the script
|
||||||
|
// (regex drift, exemption logic, exit code) trips the test even if the bash
|
||||||
|
// glue in run-tests.sh keeps reporting PASS.
|
||||||
|
//
|
||||||
|
// All offending substrings are built via concatenation at runtime so the
|
||||||
|
// scanner itself does not flag this test file when it walks `tests/**` or
|
||||||
|
// `src/**` (api-literals mode); the api-literals scanner does walk `src/**`
|
||||||
|
// but exempts `*.test.tsx?` so colocated test files are safe.
|
||||||
|
|
||||||
|
const REPO_ROOT = resolve(__dirname, '..')
|
||||||
|
const SCRIPT = join(REPO_ROOT, 'scripts', 'check-arch-imports.mjs')
|
||||||
|
const ARCH_FIXTURE_DIR = join(REPO_ROOT, 'tests', '_arch_fixtures')
|
||||||
|
const API_FIXTURE_DIR = join(REPO_ROOT, 'src', '_arch_fixtures')
|
||||||
|
|
||||||
|
const FROM = 'fr' + 'om'
|
||||||
|
const UP2 = '..' + '/..'
|
||||||
|
const DEEP_API = `${UP2}/src/api/cl` + 'ient'
|
||||||
|
const DEEP_CLASSCOLORS = `${UP2}/src/features/annotations/classCo` + 'lors'
|
||||||
|
|
||||||
|
// Build synthetic API path strings by concatenation so this test file itself
|
||||||
|
// never matches the api-literal regex when scanned. Quote characters are
|
||||||
|
// added per fixture body below.
|
||||||
|
const API_ADMIN_USERS = '/api/' + 'admin/' + 'users/me'
|
||||||
|
const API_FLIGHTS = '/api/' + 'flights/' + 'liveGps'
|
||||||
|
|
||||||
|
function runCheck(mode: 'arch-imports' | 'api-literals'): { status: number; stderr: string } {
|
||||||
|
const res = spawnSync(
|
||||||
|
'node',
|
||||||
|
[SCRIPT, `--root=${REPO_ROOT}`, `--mode=${mode}`],
|
||||||
|
{ cwd: REPO_ROOT, encoding: 'utf8' },
|
||||||
|
)
|
||||||
|
return { status: res.status ?? -1, stderr: res.stderr ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFixture(dir: string, filename: string, content: string): string {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
const path = join(dir, filename)
|
||||||
|
writeFileSync(path, content, 'utf8')
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AZ-485 STC-ARCH-01 — no cross-component deep imports', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
if (existsSync(ARCH_FIXTURE_DIR)) rmSync(ARCH_FIXTURE_DIR, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AC-5: passes on the migrated codebase (no fixtures)', () => {
|
||||||
|
// Assert
|
||||||
|
const { status, stderr } = runCheck('arch-imports')
|
||||||
|
expect(stderr, stderr).toBe('')
|
||||||
|
expect(status).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AC-4: FAILS when a deep import into another component is introduced', () => {
|
||||||
|
// Arrange
|
||||||
|
const body = `import { api } ${FROM} '${DEEP_API}'\nexport const _force = api\n`
|
||||||
|
writeFixture(ARCH_FIXTURE_DIR, 'synthetic_deep_import.ts', body)
|
||||||
|
// Act
|
||||||
|
const { status, stderr } = runCheck('arch-imports')
|
||||||
|
// Assert
|
||||||
|
expect(status).not.toBe(0)
|
||||||
|
expect(stderr).toMatch(/STC-ARCH-01/)
|
||||||
|
expect(stderr).toMatch(/synthetic_deep_import\.ts/)
|
||||||
|
expect(stderr).toMatch(/src\/api\/client/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AC-4: still PASSES when only the classColors F3-pending exemption is used', () => {
|
||||||
|
// Arrange
|
||||||
|
const body =
|
||||||
|
`import { FALLBACK_CLASS_NAMES } ${FROM} '${DEEP_CLASSCOLORS}'\n` +
|
||||||
|
`export const _force = FALLBACK_CLASS_NAMES\n`
|
||||||
|
writeFixture(ARCH_FIXTURE_DIR, 'classcolors_exemption.ts', body)
|
||||||
|
// Act
|
||||||
|
const { status, stderr } = runCheck('arch-imports')
|
||||||
|
// Assert
|
||||||
|
expect(stderr, stderr).toBe('')
|
||||||
|
expect(status).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AC-4: deep imports inside line comments do not trip the gate', () => {
|
||||||
|
// Arrange
|
||||||
|
const body = `// import { api } ${FROM} '${DEEP_API}'\nexport const _x = 1\n`
|
||||||
|
writeFixture(ARCH_FIXTURE_DIR, 'commented_out_deep_import.ts', body)
|
||||||
|
// Act
|
||||||
|
const { status } = runCheck('arch-imports')
|
||||||
|
// Assert
|
||||||
|
expect(status).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AZ-486 STC-ARCH-02 — no hardcoded /api/<service>/ literals', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
if (existsSync(API_FIXTURE_DIR)) rmSync(API_FIXTURE_DIR, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AC-4: passes on the migrated codebase (no fixtures)', () => {
|
||||||
|
// Assert
|
||||||
|
const { status, stderr } = runCheck('api-literals')
|
||||||
|
expect(stderr, stderr).toBe('')
|
||||||
|
expect(status).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AC-3: FAILS when a single-quoted /api/<service>/ literal is added in src/', () => {
|
||||||
|
// Arrange
|
||||||
|
const body = `export const url = '${API_ADMIN_USERS}'\n`
|
||||||
|
writeFixture(API_FIXTURE_DIR, 'synthetic_api_literal_single.ts', body)
|
||||||
|
// Act
|
||||||
|
const { status, stderr } = runCheck('api-literals')
|
||||||
|
// Assert
|
||||||
|
expect(status).not.toBe(0)
|
||||||
|
expect(stderr).toMatch(/STC-ARCH-02/)
|
||||||
|
expect(stderr).toMatch(/synthetic_api_literal_single\.ts/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AC-3: FAILS when a double-quoted /api/<service>/ literal is added in src/', () => {
|
||||||
|
// Arrange
|
||||||
|
const body = `export const url = "${API_ADMIN_USERS}"\n`
|
||||||
|
writeFixture(API_FIXTURE_DIR, 'synthetic_api_literal_double.ts', body)
|
||||||
|
// Act
|
||||||
|
const { status, stderr } = runCheck('api-literals')
|
||||||
|
// Assert
|
||||||
|
expect(status).not.toBe(0)
|
||||||
|
expect(stderr).toMatch(/STC-ARCH-02/)
|
||||||
|
expect(stderr).toMatch(/synthetic_api_literal_double\.ts/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AC-3: FAILS when a template-literal /api/<service>/ form is added in src/', () => {
|
||||||
|
// Arrange — backticks built via String.fromCharCode(96) to keep this test
|
||||||
|
// file itself a non-matching artifact under api-literals scanning of src/
|
||||||
|
// (the test file lives under tests/, but defense in depth is cheap).
|
||||||
|
const bt = String.fromCharCode(96)
|
||||||
|
const body = `export const url = (id: string) => ${bt}${API_FLIGHTS}/\${id}${bt}\n`
|
||||||
|
writeFixture(API_FIXTURE_DIR, 'synthetic_api_literal_template.ts', body)
|
||||||
|
// Act
|
||||||
|
const { status, stderr } = runCheck('api-literals')
|
||||||
|
// Assert
|
||||||
|
expect(status).not.toBe(0)
|
||||||
|
expect(stderr).toMatch(/STC-ARCH-02/)
|
||||||
|
expect(stderr).toMatch(/synthetic_api_literal_template\.ts/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AC-3: still PASSES when an offending literal lives in a *.test.ts file under src/', () => {
|
||||||
|
// Arrange — test files are exempt: the test file IS the contract
|
||||||
|
const body = `export const url = '${API_ADMIN_USERS}'\n`
|
||||||
|
writeFixture(API_FIXTURE_DIR, 'synthetic_api_literal_exempt.test.ts', body)
|
||||||
|
// Act
|
||||||
|
const { status, stderr } = runCheck('api-literals')
|
||||||
|
// Assert
|
||||||
|
expect(stderr, stderr).toBe('')
|
||||||
|
expect(status).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AC-3: literals inside // line comments do not trip the gate', () => {
|
||||||
|
// Arrange
|
||||||
|
const body = `// example: '${API_ADMIN_USERS}'\nexport const _x = 1\n`
|
||||||
|
writeFixture(API_FIXTURE_DIR, 'synthetic_api_literal_commented.ts', body)
|
||||||
|
// Act
|
||||||
|
const { status } = runCheck('api-literals')
|
||||||
|
// Assert
|
||||||
|
expect(status).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,8 +4,7 @@ import { server } from './msw/server'
|
|||||||
import { jsonResponse, paginate } from './msw/helpers'
|
import { jsonResponse, paginate } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, waitFor } from './helpers/render'
|
import { renderWithProviders, screen, waitFor } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { FlightProvider } from '../src/components/FlightContext'
|
import { FlightProvider, Header } from '../src/components'
|
||||||
import Header from '../src/components/Header'
|
|
||||||
|
|
||||||
// AZ-469 — Browser support + responsive variants.
|
// AZ-469 — Browser support + responsive variants.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { server } from './msw/server'
|
|||||||
import { jsonResponse, paginate } from './msw/helpers'
|
import { jsonResponse, paginate } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, fireEvent, waitFor } from './helpers/render'
|
import { renderWithProviders, screen, fireEvent, waitFor } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { FlightProvider } from '../src/components/FlightContext'
|
import { FlightProvider } from '../src/components'
|
||||||
import DatasetPage from '../src/features/dataset/DatasetPage'
|
import { DatasetPage } from '../src/features/dataset'
|
||||||
import { AnnotationStatus, AnnotationSource } from '../src/types'
|
import { AnnotationStatus, AnnotationSource } from '../src/types'
|
||||||
import type { DatasetItem } from '../src/types'
|
import type { DatasetItem } from '../src/types'
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|||||||
import { http } from 'msw'
|
import { http } from 'msw'
|
||||||
import { server } from './msw/server'
|
import { server } from './msw/server'
|
||||||
import { renderWithProviders, fireEvent, waitFor } from './helpers/render'
|
import { renderWithProviders, fireEvent, waitFor } from './helpers/render'
|
||||||
import CanvasEditor from '../src/features/annotations/CanvasEditor'
|
import { CanvasEditor } from '../src/features/annotations'
|
||||||
import {
|
import {
|
||||||
Affiliation,
|
Affiliation,
|
||||||
CombatReadiness,
|
CombatReadiness,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { server } from './msw/server'
|
|||||||
import { jsonResponse, noContent } from './msw/helpers'
|
import { jsonResponse, noContent } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import AdminPage from '../src/features/admin/AdminPage'
|
import { AdminPage } from '../src/features/admin'
|
||||||
|
|
||||||
// AZ-466 — Destructive UX policy (cross-component half)
|
// AZ-466 — Destructive UX policy (cross-component half)
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { jsonResponse, errorResponse } from './msw/helpers'
|
|||||||
import { renderWithProviders, screen, fireEvent, waitFor, userEvent, act } from './helpers/render'
|
import { renderWithProviders, screen, fireEvent, waitFor, userEvent, act } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { seedClasses } from './fixtures/seed_classes'
|
import { seedClasses } from './fixtures/seed_classes'
|
||||||
import DetectionClasses from '../src/components/DetectionClasses'
|
import { DetectionClasses } from '../src/components'
|
||||||
|
// F3-pending exemption: classColors symbols live under 06_annotations until
|
||||||
|
// F3 moves the file. The 06_annotations barrel does not re-export them to
|
||||||
|
// avoid a circular import (see src/features/annotations/index.ts).
|
||||||
import { FALLBACK_CLASS_NAMES } from '../src/features/annotations/classColors'
|
import { FALLBACK_CLASS_NAMES } from '../src/features/annotations/classColors'
|
||||||
import type { DetectionClass } from '../src/types'
|
import type { DetectionClass } from '../src/types'
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { server } from './msw/server'
|
|||||||
import { jsonResponse, paginate, sse } from './msw/helpers'
|
import { jsonResponse, paginate, sse } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { FlightProvider } from '../src/components/FlightContext'
|
import { FlightProvider } from '../src/components'
|
||||||
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
|
import { AnnotationsPage } from '../src/features/annotations'
|
||||||
import { MediaType, MediaStatus } from '../src/types'
|
import { MediaType, MediaStatus } from '../src/types'
|
||||||
import type { Media } from '../src/types'
|
import type { Media } from '../src/types'
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { server } from './msw/server'
|
|||||||
import { jsonResponse, paginate } from './msw/helpers'
|
import { jsonResponse, paginate } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { FlightProvider } from '../src/components/FlightContext'
|
import { FlightProvider, Header } from '../src/components'
|
||||||
import Header from '../src/components/Header'
|
|
||||||
import { seedFlights } from './fixtures/seed_flights'
|
import { seedFlights } from './fixtures/seed_flights'
|
||||||
import { seedUserSettings } from './fixtures/seed_user_settings'
|
import { seedUserSettings } from './fixtures/seed_user_settings'
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { server } from './msw/server'
|
|||||||
import { jsonResponse } from './msw/helpers'
|
import { jsonResponse } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import SettingsPage from '../src/features/settings/SettingsPage'
|
import { SettingsPage } from '../src/features/settings'
|
||||||
|
|
||||||
// AZ-475 — Numeric form input — empty / non-numeric rejection
|
// AZ-475 — Numeric form input — empty / non-numeric rejection
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { setToken } from '../../src/api/client'
|
import { setToken } from '../../src/api'
|
||||||
|
|
||||||
// Stand-in for the full login flow. Tests that need an authenticated request
|
// Stand-in for the full login flow. Tests that need an authenticated request
|
||||||
// call `seedBearer(token)` before the request fires; `clearBearer()` is
|
// call `seedBearer(token)` before the request fires; `clearBearer()` is
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { vi, type MockedFunction } from 'vitest'
|
import { vi, type MockedFunction } from 'vitest'
|
||||||
import { setNavigateToLogin } from '../../src/api/client'
|
import { setNavigateToLogin } from '../../src/api'
|
||||||
|
|
||||||
// Replaces the production `navigateToLoginImpl` accessor (autodev Step 4 / C06)
|
// Replaces the production `navigateToLoginImpl` accessor (autodev Step 4 / C06)
|
||||||
// with a Vitest spy. Tests assert "redirect was invoked" via the returned
|
// with a Vitest spy. Tests assert "redirect was invoked" via the returned
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { ReactElement, ReactNode } from 'react'
|
|||||||
import { render, type RenderOptions, type RenderResult } from '@testing-library/react'
|
import { render, type RenderOptions, type RenderResult } from '@testing-library/react'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { I18nextProvider } from 'react-i18next'
|
import { I18nextProvider } from 'react-i18next'
|
||||||
import i18n from '../../src/i18n/i18n'
|
import i18n from '../../src/i18n'
|
||||||
import { AuthProvider } from '../../src/auth/AuthContext'
|
import { AuthProvider } from '../../src/auth'
|
||||||
|
|
||||||
export interface RenderWithProvidersOptions extends RenderOptions {
|
export interface RenderWithProvidersOptions extends RenderOptions {
|
||||||
/** Initial route(s) for the in-memory router. Defaults to ['/']. */
|
/** Initial route(s) for the in-memory router. Defaults to ['/']. */
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import i18n from '../src/i18n/i18n'
|
import i18n from '../src/i18n'
|
||||||
|
|
||||||
// AZ-465 — i18n detector + persistence (fast counterparts).
|
// AZ-465 — i18n detector + persistence (fast counterparts).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { http, HttpResponse } from 'msw'
|
import { http, HttpResponse } from 'msw'
|
||||||
import { server } from './msw/server'
|
import { server } from './msw/server'
|
||||||
import { api } from '../src/api/client'
|
import { api } from '../src/api'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { loadEnumSnapshot } from './fixtures/enum_spec_snapshot'
|
import { loadEnumSnapshot } from './fixtures/enum_spec_snapshot'
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import {
|
|||||||
userEvent,
|
userEvent,
|
||||||
} from './helpers/render'
|
} from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { createSSE } from '../src/api/sse'
|
import { createSSE } from '../src/api'
|
||||||
import App from '../src/App'
|
import App from '../src/App'
|
||||||
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
|
import { AnnotationsPage } from '../src/features/annotations'
|
||||||
import { FlightProvider, useFlight } from '../src/components/FlightContext'
|
import { FlightProvider, useFlight } from '../src/components'
|
||||||
import { seedFlights } from './fixtures/seed_flights'
|
import { seedFlights } from './fixtures/seed_flights'
|
||||||
import {
|
import {
|
||||||
AnnotationSource,
|
AnnotationSource,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { renderWithProviders, waitFor } from './helpers/render'
|
import { renderWithProviders, waitFor } from './helpers/render'
|
||||||
import CanvasEditor from '../src/features/annotations/CanvasEditor'
|
import { CanvasEditor } from '../src/features/annotations'
|
||||||
import {
|
import {
|
||||||
AnnotationSource,
|
AnnotationSource,
|
||||||
AnnotationStatus,
|
AnnotationStatus,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { server } from './msw/server'
|
|||||||
import { jsonResponse, paginate } from './msw/helpers'
|
import { jsonResponse, paginate } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, fireEvent, waitFor, act } from './helpers/render'
|
import { renderWithProviders, screen, fireEvent, waitFor, act } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { FlightProvider } from '../src/components/FlightContext'
|
import { FlightProvider } from '../src/components'
|
||||||
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
|
import { AnnotationsPage } from '../src/features/annotations'
|
||||||
|
|
||||||
// AZ-470 — Panel-width debounced PUT + rehydration.
|
// AZ-470 — Panel-width debounced PUT + rehydration.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ import {
|
|||||||
userEvent,
|
userEvent,
|
||||||
} from './helpers/render'
|
} from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import DetectionClasses from '../src/components/DetectionClasses'
|
import { DetectionClasses, FlightProvider, useFlight } from '../src/components'
|
||||||
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
|
import { AnnotationsPage } from '../src/features/annotations'
|
||||||
import { FlightProvider, useFlight } from '../src/components/FlightContext'
|
|
||||||
import { seedFlights } from './fixtures/seed_flights'
|
import { seedFlights } from './fixtures/seed_flights'
|
||||||
import { seedClasses } from './fixtures/seed_classes'
|
import { seedClasses } from './fixtures/seed_classes'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { server } from './msw/server'
|
|||||||
import { jsonResponse } from './msw/helpers'
|
import { jsonResponse } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, waitFor, userEvent, within } from './helpers/render'
|
import { renderWithProviders, screen, waitFor, userEvent, within } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import SettingsPage from '../src/features/settings/SettingsPage'
|
import { SettingsPage } from '../src/features/settings'
|
||||||
import { seedAircraft } from './fixtures/seed_aircraft'
|
import { seedAircraft } from './fixtures/seed_aircraft'
|
||||||
import type { SystemSettings, DirectorySettings } from '../src/types'
|
import type { SystemSettings, DirectorySettings } from '../src/types'
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest'
|
|||||||
import { afterAll, afterEach, beforeAll } from 'vitest'
|
import { afterAll, afterEach, beforeAll } from 'vitest'
|
||||||
import { cleanup } from '@testing-library/react'
|
import { cleanup } from '@testing-library/react'
|
||||||
import { server } from './msw/server'
|
import { server } from './msw/server'
|
||||||
import { setToken, setNavigateToLogin } from '../src/api/client'
|
import { setToken, setNavigateToLogin } from '../src/api'
|
||||||
|
|
||||||
// JSDOM polyfills for browser APIs production code touches at mount time.
|
// JSDOM polyfills for browser APIs production code touches at mount time.
|
||||||
// These are no-op stubs — tests that exercise the actual behavior install
|
// These are no-op stubs — tests that exercise the actual behavior install
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { render, act, cleanup } from '@testing-library/react'
|
import { render, act, cleanup } from '@testing-library/react'
|
||||||
import { createSSE } from '../src/api/sse'
|
import { createSSE, setToken } from '../src/api'
|
||||||
import { setToken } from '../src/api/client'
|
|
||||||
import { createFakeEventSource, type FakeEventSource } from './helpers/sse-mock'
|
import { createFakeEventSource, type FakeEventSource } from './helpers/sse-mock'
|
||||||
|
|
||||||
// AZ-458 — SSE lifecycle + bearer-rotation reconnect.
|
// AZ-458 — SSE lifecycle + bearer-rotation reconnect.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
} from './helpers/render'
|
} from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { FlightProvider } from '../src/components/FlightContext'
|
import { FlightProvider } from '../src/components'
|
||||||
import DatasetPage from '../src/features/dataset/DatasetPage'
|
import { DatasetPage } from '../src/features/dataset'
|
||||||
import {
|
import {
|
||||||
AnnotationSource,
|
AnnotationSource,
|
||||||
AnnotationStatus,
|
AnnotationStatus,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { server } from './msw/server'
|
|||||||
import { jsonResponse, paginate } from './msw/helpers'
|
import { jsonResponse, paginate } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, fireEvent, waitFor, userEvent } from './helpers/render'
|
import { renderWithProviders, screen, fireEvent, waitFor, userEvent } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { FlightProvider, useFlight } from '../src/components/FlightContext'
|
import { FlightProvider, useFlight } from '../src/components'
|
||||||
import AnnotationsPage from '../src/features/annotations/AnnotationsPage'
|
import { AnnotationsPage } from '../src/features/annotations'
|
||||||
import { seedFlights } from './fixtures/seed_flights'
|
import { seedFlights } from './fixtures/seed_flights'
|
||||||
|
|
||||||
// AZ-476 — Upload >500 MB → 413 → user-visible error (no alert).
|
// AZ-476 — Upload >500 MB → 413 → user-visible error (no alert).
|
||||||
|
|||||||
Reference in New Issue
Block a user