feat(ui): ship offline operations cutover
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# Sprint 20260308-002 - Offline Operations Cutover
|
||||
|
||||
## Topic & Scope
|
||||
- Restore the dropped and weakly surfaced offline capability by making one canonical offline operations owner fully usable instead of leaving Offline Kit, feed air-gap flows, and evidence export handoffs split across stale aliases.
|
||||
- Ship a working `Ops > Operations > Offline Kit` and `Ops > Operations > Feeds & Airgap` flow with repaired secondary entry points, bookmark-safe legacy aliases, and usable offline shell actions.
|
||||
- Complete the UI workflows that were still half-wired: route-aware tabs, real drill-ins, usable export and verification actions, and cross-links into Evidence and Trust surfaces.
|
||||
- Working directory: `src/Web/StellaOps.Web/src/app/features/offline-kit`.
|
||||
- Allowed coordination edits: `src/Web/StellaOps.Web/src/app/routes/`, `src/Web/StellaOps.Web/src/app/core/navigation/`, `src/Web.StellaOps.Web/src/app/layout/context-chips/`, `src/Web.StellaOps.Web/src/app/features/platform/ops/`, `src/Web.StellaOps.Web/src/tests/offline/`, `src/Web.StellaOps.Web/src/tests/platform-ops/`, `src/Web.StellaOps.Web/src/tests/navigation/`, `src/Web.StellaOps.Web/tests/e2e/`, `docs/modules/ui/offline-operations/`, `docs/features/checked/web/`, `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, and `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md`.
|
||||
- Expected evidence: one mounted canonical offline route family, working alias redirects for old bookmarks, usable offline shell actions, repaired cross-shell links, targeted Angular tests, Playwright verification, and synced docs.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on:
|
||||
- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md`
|
||||
- `docs/modules/ui/platform-ops-consolidation/README.md`
|
||||
- `docs/modules/ui/contextual-actions-patterns/README.md`
|
||||
- `src/Web.StellaOps.Web/src/app/routes/operations.routes.ts`
|
||||
- `src/Web.StellaOps.Web/src/app/features/offline-kit/offline-kit.routes.ts`
|
||||
- `src/Web.StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts`
|
||||
- Safe parallelism:
|
||||
- canonical route and alias contract must freeze before deep-link cleanup starts
|
||||
- offline-shell action completion can proceed in parallel with docs drafting once route targets are fixed
|
||||
- QA and checked-feature docs can proceed in parallel after the final route contract is stable
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/modules/ui/platform-ops-consolidation/README.md`
|
||||
- `docs/modules/ui/contextual-actions-patterns/README.md`
|
||||
- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md`
|
||||
- `docs/modules/ui/implementation_plan.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-OFF-001 - Freeze canonical offline owner and alias contract
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Product Manager, FE Architect
|
||||
Task description:
|
||||
- Make `Ops > Operations` the owner shell for offline and air-gap operational workflows, with `Offline Kit` and `Feeds & Airgap` as first-class child routes.
|
||||
- Define and implement bookmark-safe redirects from stale `/ops/offline-kit/*`, `/ops/feeds/*`, `/platform-ops/*`, and `/platform/ops/*` variants into the active canonical routes.
|
||||
|
||||
Completion criteria:
|
||||
- [x] One canonical offline route family exists under `/ops/operations/*`.
|
||||
- [x] Legacy aliases land on working offline child pages without dropping query params.
|
||||
- [x] Navigation and contextual chips point at the same canonical owner.
|
||||
|
||||
### FE-OFF-002 - Make offline shell actions usable and route-aware
|
||||
Status: DONE
|
||||
Dependency: FE-OFF-001
|
||||
Owners: Developer, FE Architect
|
||||
Task description:
|
||||
- Repair the Offline Kit shell so its header actions, dashboard cards, tabs, and cross-links are real operator actions instead of dead buttons or isolated status text.
|
||||
- Ensure the `Feeds & Airgap` page is route-aware, query-driven, and able to hand operators into the right offline or evidence workflow.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Offline Kit actions navigate to working canonical routes or perform a real local workflow.
|
||||
- [x] Feeds & Airgap tabs are linkable and preserve state through the URL.
|
||||
- [x] Cross-links between Offline Kit, Feeds & Airgap, Evidence, and Trust work from the mounted shell.
|
||||
|
||||
### FE-OFF-003 - Complete supporting export, verification, and trust workflows
|
||||
Status: DONE
|
||||
Dependency: FE-OFF-002
|
||||
Owners: Developer
|
||||
Task description:
|
||||
- Replace the remaining no-op Offline Kit actions with usable FE behavior, including bundle export, verification detail drill-ins, report export, and trust-material import or inspection where practical inside the current UI contract.
|
||||
- Make the restored surfaces useful for operators even without backend changes by providing deterministic local export and inspection flows where the prior UI stopped at placeholders.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Bundle export and verification actions no longer end in `console.log` dead ends.
|
||||
- [x] Verification history supports detail drill-in and report export.
|
||||
- [x] JWKS or trust-anchor actions provide a usable inspection or import path inside the shell.
|
||||
|
||||
### FE-OFF-004 - Verify canonical offline operations journeys
|
||||
Status: DONE
|
||||
Dependency: FE-OFF-003
|
||||
Owners: QA, Test Automation
|
||||
Task description:
|
||||
- Add targeted UI verification for canonical routes, alias redirects, offline-shell actions, and at least one end-to-end journey that starts from a secondary entry point and reaches a usable offline workflow.
|
||||
- Prove the restored capability is usable, not just mounted.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Angular tests cover canonical routes, alias redirects, and representative offline workflows.
|
||||
- [x] Playwright covers the main offline landing and at least one stale-alias journey.
|
||||
- [x] Verification explicitly checks that dead buttons and stale routes are gone from the active operator path.
|
||||
|
||||
### FE-OFF-005 - Sync docs, archive the sprint, and record the shipped feature
|
||||
Status: DONE
|
||||
Dependency: FE-OFF-004
|
||||
Owners: Documentation author, Project Manager
|
||||
Task description:
|
||||
- Publish the shipped offline operations contract, checked-feature note, task-board updates, and restoration backlog update.
|
||||
- Archive the sprint only after code and verification evidence are complete.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Shipped offline operations UX is documented with canonical routes and alias behavior.
|
||||
- [x] Checked-feature note records the exact verification commands and outcomes.
|
||||
- [x] Sprint is archived only after all delivery tasks are marked done.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-08 | Sprint created to restore Offline Operations by making `Ops > Operations` the canonical owner and completing the remaining half-wired offline shell actions. | Project Manager |
|
||||
| 2026-03-08 | Repaired canonical offline and feed-airgap aliases, updated navigation and context chips, and made the `Feeds & Airgap` page route-aware instead of button-stub driven. | Developer |
|
||||
| 2026-03-08 | Completed Offline Kit workflows with manifest loading, bundle export, verification detail and report export, trust-anchor inspection, and Evidence or Trust handoffs. | Developer |
|
||||
| 2026-03-08 | Verified targeted Angular coverage with `npm test -- --watch=false --include src/tests/offline/offline-kit-ui-integration.spec.ts --include src/tests/offline/verification-center.component.spec.ts --include src/tests/offline/jwks-management.component.spec.ts --include src/tests/platform-ops/platform-feeds-airgap-page.component.spec.ts --include src/tests/platform-ops/platform-ops-routes.spec.ts`: 17 tests passed. | QA |
|
||||
| 2026-03-08 | Verified browser flows with a prestarted local Angular test server plus `PLAYWRIGHT_BASE_URL=https://127.0.0.1:4400 npx playwright test tests/e2e/offline-operations.spec.ts --workers=1`: 2 scenarios passed. | QA |
|
||||
| 2026-03-08 | Production build passed via `npm run build`; existing bundle budget warnings remain unchanged from the baseline. | QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: `Ops > Operations > Offline Kit` and `Ops > Operations > Feeds & Airgap` remain the canonical owner surfaces; Evidence and Trust remain secondary linked destinations rather than competing products.
|
||||
- Decision: this sprint completes the UI workflows already present in the shell and fixes stale alias routing; it does not add new backend APIs.
|
||||
- Risk: several offline components still stop at placeholder buttons or stale paths, so a route-only cutover would still leave operators stranded.
|
||||
- Mitigation: replace dead buttons with route-aware actions or deterministic local export and inspection flows in the same sprint.
|
||||
- Risk: aliasing legacy `/ops/*` and `/platform-ops/*` deep links can regress query-param preservation if redirects are string-based.
|
||||
- Mitigation: use redirect helpers that preserve existing query params and fragments.
|
||||
- Risk: local Playwright web-server startup can exceed the default timeout because the Angular dev build is heavy.
|
||||
- Mitigation: prestart `npm run serve:test` and point Playwright at `PLAYWRIGHT_BASE_URL=https://127.0.0.1:4400` for deterministic local verification.
|
||||
- Delivery rule: this sprint is only complete when the canonical offline tree is mounted, main actions are usable, and the core operator journeys are verified end to end.
|
||||
- Reference design note: `docs/modules/ui/offline-operations/README.md`.
|
||||
- Docs synced:
|
||||
- `docs/modules/ui/offline-operations/README.md`
|
||||
- `docs/features/checked/web/offline-operations-ui.md`
|
||||
- `docs/features/checked/web/offline-kit-ui-integration.md`
|
||||
- `docs/features/checked/web/feed-mirror-airgap-ops-ui.md`
|
||||
- `docs/modules/ui/TASKS.md`
|
||||
- `docs/modules/ui/implementation_plan.md`
|
||||
- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md`
|
||||
## Next Checkpoints
|
||||
- 2026-03-08: archived after implementation, verification, and docs sync completed.
|
||||
@@ -7,11 +7,17 @@ Web
|
||||
VERIFIED
|
||||
|
||||
## Description
|
||||
Feed mirror ops UI with mirror registry list, snapshot management, AirGap import/export with bundle validation, feed version lock for deterministic scans, offline sync status, and bundle freshness warnings.
|
||||
Feed mirror ops UI with mirror registry list, snapshot management, AirGap import/export with bundle validation, feed version lock for deterministic scans, offline sync status, and bundle freshness warnings. The canonical owner route now lives under `Ops > Operations`; legacy `ops` and `platform-ops` feed aliases redirect into it.
|
||||
|
||||
## Implementation Details
|
||||
- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/feed-mirror/`
|
||||
- **Routes**: `feed-mirror.routes.ts`
|
||||
- **Canonical route**: `/ops/operations/feeds-airgap`
|
||||
- **Legacy aliases**:
|
||||
- `/ops/feeds`
|
||||
- `/ops/feeds/airgap/*`
|
||||
- `/platform-ops/feeds*`
|
||||
- `/platform/ops/feeds*`
|
||||
- **Components**:
|
||||
- `airgap-export` (`src/Web/StellaOps.Web/src/app/features/feed-mirror/airgap-export.component.ts`)
|
||||
- `airgap-import` (`src/Web/StellaOps.Web/src/app/features/feed-mirror/airgap-import.component.ts`)
|
||||
@@ -31,7 +37,7 @@ Feed mirror ops UI with mirror registry list, snapshot management, AirGap import
|
||||
## E2E Test Plan
|
||||
- **Setup**:
|
||||
- [ ] Log in with a user that has appropriate permissions
|
||||
- [ ] Navigate to `/ops/feeds`
|
||||
- [ ] Navigate to `/ops/operations/feeds-airgap`
|
||||
- [ ] Ensure test data exists (scanned artifacts, SBOM data, or seed data as needed)
|
||||
- **Core verification**:
|
||||
- [ ] Verify the component renders correctly with sample data
|
||||
|
||||
@@ -7,11 +7,13 @@ Web
|
||||
VERIFIED
|
||||
|
||||
## Description
|
||||
Offline Kit UI with OfflineModeService, ManifestValidator, BundleFreshness widget, ReadOnlyGuard, and offline verification workflow for air-gapped environments, including deterministic bundle activation behavior in bundle management.
|
||||
Offline Kit UI with OfflineModeService, ManifestValidator, BundleFreshness widget, ReadOnlyGuard, and offline verification workflow for air-gapped environments, including deterministic bundle activation behavior in bundle management. The canonical owner route now lives under `Ops > Operations`; the legacy `/ops/offline-kit` bookmark remains as a redirect.
|
||||
|
||||
## Implementation Details
|
||||
- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/offline-kit/`
|
||||
- **Routes**: `offline-kit.routes.ts`
|
||||
- **Canonical route**: `/ops/operations/offline-kit`
|
||||
- **Legacy alias**: `/ops/offline-kit`
|
||||
- **Components**:
|
||||
- `bundle-management` (`src/Web/StellaOps.Web/src/app/features/offline-kit/components/bundle-management.component.ts`)
|
||||
- `jwks-management` (`src/Web/StellaOps.Web/src/app/features/offline-kit/components/jwks-management.component.ts`)
|
||||
@@ -23,7 +25,7 @@ Offline Kit UI with OfflineModeService, ManifestValidator, BundleFreshness widge
|
||||
## E2E Test Plan
|
||||
- **Setup**:
|
||||
- [ ] Log in with a user that has appropriate permissions
|
||||
- [ ] Navigate to `/ops/offline-kit`
|
||||
- [ ] Navigate to `/ops/operations/offline-kit`
|
||||
- [ ] Ensure test data exists (scanned artifacts, SBOM data, or seed data as needed)
|
||||
- **Core verification**:
|
||||
- [ ] Verify the component renders correctly with sample data
|
||||
|
||||
67
docs/features/checked/web/offline-operations-ui.md
Normal file
67
docs/features/checked/web/offline-operations-ui.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Offline Operations UI
|
||||
|
||||
## Module
|
||||
Web
|
||||
|
||||
## Status
|
||||
VERIFIED
|
||||
|
||||
## Description
|
||||
Shipped the canonical offline and air-gap operations flow under `Ops > Operations`, repaired stale deep links from legacy `ops` and `platform-ops` aliases, and completed the previously half-wired Offline Kit actions so operators can load bundles, export summaries, inspect trust anchors, and hand off into Evidence and Trust surfaces without dead ends.
|
||||
|
||||
## Implementation Details
|
||||
- **Feature directories**:
|
||||
- `src/Web/StellaOps.Web/src/app/features/offline-kit/`
|
||||
- `src/Web.StellaOps.Web/src/app/features/platform/ops/`
|
||||
- **Primary components**:
|
||||
- `offline-kit` (`src/Web.StellaOps.Web/src/app/features/offline-kit/offline-kit.component.ts`)
|
||||
- `offline-dashboard` (`src/Web.StellaOps.Web/src/app/features/offline-kit/components/offline-dashboard.component.ts`)
|
||||
- `bundle-management` (`src/Web.StellaOps.Web/src/app/features/offline-kit/components/bundle-management.component.ts`)
|
||||
- `verification-center` (`src/Web.StellaOps.Web/src/app/features/offline-kit/components/verification-center.component.ts`)
|
||||
- `jwks-management` (`src/Web.StellaOps.Web/src/app/features/offline-kit/components/jwks-management.component.ts`)
|
||||
- `platform-feeds-airgap-page` (`src/Web.StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts`)
|
||||
- **Canonical routes**:
|
||||
- `/ops/operations/offline-kit`
|
||||
- `/ops/operations/offline-kit/dashboard`
|
||||
- `/ops/operations/offline-kit/bundles`
|
||||
- `/ops/operations/offline-kit/verify`
|
||||
- `/ops/operations/offline-kit/jwks`
|
||||
- `/ops/operations/feeds-airgap`
|
||||
- **Legacy aliases**:
|
||||
- `/ops/offline-kit/*`
|
||||
- `/ops/feeds/*`
|
||||
- `/platform-ops/*`
|
||||
- `/platform/ops/*`
|
||||
- **Secondary entry points**:
|
||||
- `Evidence > Export Center`
|
||||
- `Evidence > Verify & Replay`
|
||||
- `Setup > Trust & Signing`
|
||||
- context status chips and Ops navigation
|
||||
|
||||
## E2E Test Plan
|
||||
- **Setup**:
|
||||
- [x] Start the local Angular test server with `npm run serve:test`.
|
||||
- [x] Open `/ops/operations/offline-kit`.
|
||||
- [x] Use a test session with Ops and admin scopes.
|
||||
- **Core verification**:
|
||||
- [x] Verify Offline Kit renders its canonical shortcuts and child tabs.
|
||||
- [x] Verify bundle and verification actions are usable instead of placeholder logs.
|
||||
- [x] Verify trust-anchor inspection and export flows render inside the shell.
|
||||
- **Legacy verification**:
|
||||
- [x] Verify stale `/platform/ops/offline-kit/*` bookmarks land on `/ops/operations/offline-kit/*`.
|
||||
- [x] Verify stale `/ops/feeds/airgap/import` lands on the canonical `Feeds & Airgap` page with preserved tab or action context.
|
||||
|
||||
## Verification
|
||||
- Run:
|
||||
- `npm test -- --watch=false --include src/tests/offline/offline-kit-ui-integration.spec.ts --include src/tests/offline/verification-center.component.spec.ts --include src/tests/offline/jwks-management.component.spec.ts --include src/tests/platform-ops/platform-feeds-airgap-page.component.spec.ts --include src/tests/platform-ops/platform-ops-routes.spec.ts`
|
||||
- `npm run serve:test`
|
||||
- `PLAYWRIGHT_BASE_URL=https://127.0.0.1:4400 npx playwright test tests/e2e/offline-operations.spec.ts --workers=1`
|
||||
- `npm run build`
|
||||
- Tier 0 (source): pass
|
||||
- Tier 1 (build/tests): pass
|
||||
- Tier 2 (behavior): pass
|
||||
- Notes:
|
||||
- Angular targeted tests passed: `5` files, `17` tests.
|
||||
- Playwright passed: `2` scenarios.
|
||||
- Production build passed; existing bundle-budget warnings remain unchanged from the baseline.
|
||||
- Verified on (UTC): 2026-03-08T00:54:00Z
|
||||
@@ -22,6 +22,8 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runt
|
||||
- Added checked-feature verification for reachability witnessing at `../../features/checked/web/reachability-witnessing-ui.md`.
|
||||
- Shipped the consolidated `Ops > Operations` shell with grouped overview cards, canonical `/ops/operations/*` routes, and legacy `platform-ops` alias cutover.
|
||||
- Added checked-feature verification for operations consolidation at `../../features/checked/web/operations-consolidation-ui.md`.
|
||||
- Shipped the canonical offline and air-gap operations flow under `Ops > Operations`, including repaired stale `/ops/*` and `/platform-ops/*` deep links, usable Offline Kit actions, and Evidence or Trust handoffs.
|
||||
- Added checked-feature verification for offline operations at `../../features/checked/web/offline-operations-ui.md`.
|
||||
- Shipped the shared contextual placement primitives for tabs, submenu pills, route-aware drawers, list-detail shells, grouped overview cards, and return-to-context headers under `src/Web/StellaOps.Web/src/app/shared/ui/`.
|
||||
- Added checked-feature verification for the contextual primitives and their first adopted surfaces at `../../features/checked/web/contextual-actions-patterns-ui.md`.
|
||||
|
||||
@@ -69,6 +71,7 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runt
|
||||
- ./watchlist-operations/README.md
|
||||
- ./reachability-witnessing/README.md
|
||||
- ./platform-ops-consolidation/README.md
|
||||
- ./offline-operations/README.md
|
||||
- ./triage-explainability-workspace/README.md
|
||||
- ./workflow-visualization-replay/README.md
|
||||
- ./contextual-actions-patterns/README.md
|
||||
|
||||
@@ -87,6 +87,11 @@
|
||||
- [DONE] FE-AUD-003 Wire secondary entry points and contextual handoffs
|
||||
- [DONE] FE-AUD-004 Verify route cutover and operator journeys
|
||||
- [DONE] FE-AUD-005 Sync docs, archive the sprint, and record the shipped feature
|
||||
- [DONE] FE-OFF-001 Freeze canonical offline owner and alias contract
|
||||
- [DONE] FE-OFF-002 Make offline shell actions usable and route-aware
|
||||
- [DONE] FE-OFF-003 Complete supporting export, verification, and trust workflows
|
||||
- [DONE] FE-OFF-004 Verify canonical offline operations journeys
|
||||
- [DONE] FE-OFF-005 Sync docs, archive the sprint, and record the shipped feature
|
||||
- [DONE] FE-PO-001 Freeze Operations overview taxonomy and submenu structure
|
||||
- [DONE] FE-PO-002 Overview page regrouping and blocking-card contract
|
||||
- [DONE] FE-PO-003 Legacy widget absorption matrix for Platform Ops
|
||||
|
||||
@@ -121,6 +121,10 @@ These are mostly not dropped products. They are current or near-current capabili
|
||||
- `Evidence Pack`
|
||||
- Target:
|
||||
- `/ops/operations/*` with evidence links where relevant
|
||||
- Notes:
|
||||
- Detailed UX dossier: `docs/modules/ui/offline-operations/README.md`
|
||||
- Implementation sprint: `docs-archived/implplan/SPRINT_20260308_002_FE_offline_operations_cutover.md`
|
||||
- Shipped verification note: `docs/features/checked/web/offline-operations-ui.md`
|
||||
|
||||
### 9. Scanner And Job Operations
|
||||
- Type: `wire-in / preserve`
|
||||
|
||||
@@ -28,13 +28,14 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
||||
- `docs/features/checked/web/workflow-visualization-replay-ui.md` - shipped verification note for the canonical run-detail graph, timeline, replay, evidence tabs, and workflow-editor preview reuse boundary.
|
||||
- `docs/features/checked/web/contextual-actions-patterns-ui.md` - shipped verification note for the shared contextual route-state, headers, drawers, list-detail shells, grouped overview cards, and first adopted restoration surfaces.
|
||||
- `docs/features/checked/web/unified-audit-surfaces-ui.md` - shipped verification note for the Evidence-owned audit shell, admin bookmark redirects, repaired audit subview links, and secondary handoff entry points.
|
||||
- `docs/features/checked/web/offline-operations-ui.md` - shipped verification note for the canonical Offline Kit and Feeds & Airgap owner routes, repaired stale aliases, and completed offline shell actions.
|
||||
- `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract.
|
||||
- `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan.
|
||||
- `docs/modules/ui/offline-operations/README.md` - detailed owner-shell contract for Offline Kit, Feeds & Airgap, Evidence handoffs, and stale alias policy.
|
||||
- `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier.
|
||||
- `docs/modules/ui/workflow-visualization-replay/README.md` - detailed run-detail graph, timeline, replay, and evidence UX dossier.
|
||||
- `docs/modules/ui/contextual-actions-patterns/README.md` - shared placement contract for stray actions, pages, drawers, and tabs.
|
||||
- `docs/modules/ui/unified-audit-surfaces/README.md` - shipped canonical audit owner, alias contract, and secondary entry-point rules for cross-module audit browsing.
|
||||
- `docs/modules/ui/unified-audit-surfaces/README.md` - canonical audit owner, alias contract, and secondary entry-point rules for cross-module audit browsing.
|
||||
|
||||
## Dependencies
|
||||
- `docs/modules/ui/architecture.md`
|
||||
|
||||
55
docs/modules/ui/offline-operations/README.md
Normal file
55
docs/modules/ui/offline-operations/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Offline Operations
|
||||
|
||||
## Purpose
|
||||
- Keep offline and air-gap workflows as a first-class operational capability without reviving a second product tree.
|
||||
- Make `Offline Kit`, `Feeds & Airgap`, and evidence export handoffs usable from the active `Ops > Operations` shell.
|
||||
|
||||
## Canonical Owner
|
||||
- Owner shell: `Ops > Operations`
|
||||
- Primary routes:
|
||||
- `/ops/operations/offline-kit`
|
||||
- `/ops/operations/offline-kit/dashboard`
|
||||
- `/ops/operations/offline-kit/bundles`
|
||||
- `/ops/operations/offline-kit/verify`
|
||||
- `/ops/operations/offline-kit/jwks`
|
||||
- `/ops/operations/feeds-airgap`
|
||||
- Secondary linked destinations:
|
||||
- `/evidence/exports`
|
||||
- `/evidence/verify-replay`
|
||||
- `/setup/trust-signing`
|
||||
|
||||
## Legacy Alias Policy
|
||||
- Preserve old bookmarks and stale menu links by redirecting:
|
||||
- `/ops/offline-kit/*`
|
||||
- `/ops/feeds/*`
|
||||
- `/platform-ops/*`
|
||||
- `/platform/ops/*`
|
||||
- Redirects must preserve query params and fragments so tab or action state survives the handoff.
|
||||
|
||||
## UX Rules
|
||||
- `Offline Kit` owns bundle management, offline verification, and trust-material inspection.
|
||||
- `Feeds & Airgap` owns mirror freshness, version-lock posture, and air-gap entry actions.
|
||||
- `Evidence Exports` remains Evidence-owned, but Offline Kit must link into it for portable bundle generation.
|
||||
- Single actions like `Import Bundle` and `Export Bundle` should not become standalone products; they route into a tabbed or action-aware owner page.
|
||||
|
||||
## Preserved Value
|
||||
- Keep:
|
||||
- offline bundle loading and activation
|
||||
- offline verification history and report export
|
||||
- trust-anchor inspection and export
|
||||
- feed freshness and version-lock context
|
||||
- evidence export and replay handoffs
|
||||
- Why:
|
||||
- offline and air-gap operation is a real product differentiator, not a prototype branch
|
||||
- release evidence, trust material, and feed provenance all need an operator home when the site is disconnected
|
||||
|
||||
## Shipped In This Cut
|
||||
- Canonical `Ops > Operations` routes and stale alias repair for deep offline links.
|
||||
- Route-aware `Feeds & Airgap` tab state and action banners.
|
||||
- Working Offline Kit shortcuts into Evidence and Trust surfaces.
|
||||
- Bundle export, verification detail drill-in, verification report export, and trust-anchor inspection/export.
|
||||
|
||||
## Related Docs
|
||||
- `docs/modules/ui/platform-ops-consolidation/README.md`
|
||||
- `docs/features/checked/web/offline-operations-ui.md`
|
||||
- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md`
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OPERATIONS_PATHS } from '../../features/platform/ops/operations-paths';
|
||||
import { NavGroup, NavigationConfig } from './navigation.types';
|
||||
|
||||
/**
|
||||
@@ -227,51 +228,51 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'pack-registry',
|
||||
label: 'Pack Registry',
|
||||
route: '/ops/packs',
|
||||
route: OPERATIONS_PATHS.packs,
|
||||
icon: 'package',
|
||||
tooltip: 'Browse TaskRunner packs, verify DSSE metadata, and run compatibility-checked installs/upgrades',
|
||||
},
|
||||
{
|
||||
id: 'quotas',
|
||||
label: 'Quota Dashboard',
|
||||
route: '/ops/quotas',
|
||||
route: OPERATIONS_PATHS.quotas,
|
||||
icon: 'gauge',
|
||||
tooltip: 'License quota consumption and capacity planning',
|
||||
children: [
|
||||
{
|
||||
id: 'quota-overview',
|
||||
label: 'Overview',
|
||||
route: '/ops/quotas',
|
||||
route: OPERATIONS_PATHS.quotas,
|
||||
tooltip: 'Quota consumption KPIs and trends',
|
||||
},
|
||||
{
|
||||
id: 'quota-tenants',
|
||||
label: 'Tenant Usage',
|
||||
route: '/ops/quotas/tenants',
|
||||
route: `${OPERATIONS_PATHS.quotas}/tenants`,
|
||||
tooltip: 'Per-tenant quota consumption',
|
||||
},
|
||||
{
|
||||
id: 'quota-throttle',
|
||||
label: 'Throttle Events',
|
||||
route: '/ops/quotas/throttle',
|
||||
route: `${OPERATIONS_PATHS.quotas}/throttle`,
|
||||
tooltip: 'Rate limit violations and recommendations',
|
||||
},
|
||||
{
|
||||
id: 'quota-forecast',
|
||||
label: 'Forecast',
|
||||
route: '/ops/quotas/forecast',
|
||||
route: `${OPERATIONS_PATHS.quotas}/forecast`,
|
||||
tooltip: 'Quota exhaustion predictions',
|
||||
},
|
||||
{
|
||||
id: 'quota-alerts',
|
||||
label: 'Alert Config',
|
||||
route: '/ops/quotas/alerts',
|
||||
route: `${OPERATIONS_PATHS.quotas}/alerts`,
|
||||
tooltip: 'Configure quota alert thresholds',
|
||||
},
|
||||
{
|
||||
id: 'quota-reports',
|
||||
label: 'Reports',
|
||||
route: '/ops/quotas/reports',
|
||||
route: `${OPERATIONS_PATHS.quotas}/reports`,
|
||||
tooltip: 'Export quota reports',
|
||||
},
|
||||
],
|
||||
@@ -348,32 +349,32 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'feed-mirror',
|
||||
label: 'Feed Mirror & AirGap',
|
||||
route: '/ops/feeds',
|
||||
route: OPERATIONS_PATHS.feedsAirgap,
|
||||
icon: 'mirror',
|
||||
tooltip: 'Vulnerability feed mirroring, offline bundles, and version locks',
|
||||
children: [
|
||||
{
|
||||
id: 'feed-dashboard',
|
||||
label: 'Dashboard',
|
||||
route: '/ops/feeds',
|
||||
route: OPERATIONS_PATHS.feedsAirgap,
|
||||
tooltip: 'Feed mirror dashboard and status',
|
||||
},
|
||||
{
|
||||
id: 'airgap-import',
|
||||
label: 'Import Bundle',
|
||||
route: '/ops/feeds/airgap/import',
|
||||
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=import`,
|
||||
tooltip: 'Import air-gapped bundles from external media',
|
||||
},
|
||||
{
|
||||
id: 'airgap-export',
|
||||
label: 'Export Bundle',
|
||||
route: '/ops/feeds/airgap/export',
|
||||
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=export`,
|
||||
tooltip: 'Create bundles for air-gapped deployment',
|
||||
},
|
||||
{
|
||||
id: 'version-locks',
|
||||
label: 'Version Locks',
|
||||
route: '/ops/feeds/version-locks',
|
||||
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=version-locks`,
|
||||
tooltip: 'Lock feed versions for reproducible scans',
|
||||
},
|
||||
],
|
||||
@@ -381,32 +382,32 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'offline-kit',
|
||||
label: 'Offline Kit',
|
||||
route: '/ops/offline-kit',
|
||||
route: OPERATIONS_PATHS.offlineKit,
|
||||
icon: 'offline',
|
||||
tooltip: 'Offline bundle management, verification, and JWKS',
|
||||
children: [
|
||||
{
|
||||
id: 'offline-dashboard',
|
||||
label: 'Dashboard',
|
||||
route: '/ops/offline-kit/dashboard',
|
||||
route: `${OPERATIONS_PATHS.offlineKit}/dashboard`,
|
||||
tooltip: 'Offline mode status and overview',
|
||||
},
|
||||
{
|
||||
id: 'offline-bundles',
|
||||
label: 'Bundles',
|
||||
route: '/ops/offline-kit/bundles',
|
||||
route: `${OPERATIONS_PATHS.offlineKit}/bundles`,
|
||||
tooltip: 'Manage offline bundles and assets',
|
||||
},
|
||||
{
|
||||
id: 'offline-verify',
|
||||
label: 'Verification',
|
||||
route: '/ops/offline-kit/verify',
|
||||
route: `${OPERATIONS_PATHS.offlineKit}/verify`,
|
||||
tooltip: 'Verify audit bundles offline',
|
||||
},
|
||||
{
|
||||
id: 'offline-jwks',
|
||||
label: 'JWKS',
|
||||
route: '/ops/offline-kit/jwks',
|
||||
route: `${OPERATIONS_PATHS.offlineKit}/jwks`,
|
||||
tooltip: 'Manage Authority JWKS for offline validation',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -26,6 +26,9 @@ interface LoadedBundle {
|
||||
<div class="management-header">
|
||||
<h2>Bundle Management</h2>
|
||||
<p class="description">Load, verify, and manage offline bundles for air-gapped operation</p>
|
||||
@if (lastExportMessage()) {
|
||||
<p class="status-note">{{ lastExportMessage() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="management-grid">
|
||||
@@ -145,6 +148,12 @@ interface LoadedBundle {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-note {
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-status-info-border);
|
||||
}
|
||||
|
||||
.management-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -361,6 +370,7 @@ export class BundleManagementComponent implements OnInit {
|
||||
readonly activeBundleId = signal<string | null>(null);
|
||||
readonly activeManifest = this.offlineService.cachedManifest;
|
||||
readonly assetCategories = signal<{ name: string; icon: string; count: number; assets: string[] }[]>([]);
|
||||
readonly lastExportMessage = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBundles();
|
||||
@@ -390,6 +400,7 @@ export class BundleManagementComponent implements OnInit {
|
||||
)
|
||||
]);
|
||||
this.activeBundleId.set(bundle.id);
|
||||
this.synchronizeManifestForBundle(bundle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,10 +435,19 @@ export class BundleManagementComponent implements OnInit {
|
||||
})
|
||||
);
|
||||
this.activeBundleId.set(bundle.id);
|
||||
this.synchronizeManifestForBundle(bundle);
|
||||
}
|
||||
|
||||
exportBundle(bundle: LoadedBundle): void {
|
||||
console.log('Exporting bundle:', bundle.id);
|
||||
const exportPayload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
bundle,
|
||||
activeBundleId: this.activeBundleId(),
|
||||
manifest: bundle.id === this.activeBundleId() ? this.activeManifest() : this.buildManifestForBundle(bundle),
|
||||
};
|
||||
|
||||
this.downloadJsonFile(`offline-bundle-${bundle.version}.json`, exportPayload);
|
||||
this.lastExportMessage.set(`Exported bundle v${bundle.version} as a portable JSON summary.`);
|
||||
}
|
||||
|
||||
removeBundle(bundle: LoadedBundle): void {
|
||||
@@ -500,4 +520,44 @@ export class BundleManagementComponent implements OnInit {
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
private synchronizeManifestForBundle(bundle: LoadedBundle): void {
|
||||
this.offlineService.loadManifest(this.buildManifestForBundle(bundle));
|
||||
}
|
||||
|
||||
private buildManifestForBundle(bundle: LoadedBundle): OfflineManifest {
|
||||
const categories = this.assetCategories();
|
||||
const findAssets = (name: string, fallback: string[]) =>
|
||||
categories.find((category) => category.name === name)?.assets ?? fallback;
|
||||
|
||||
return {
|
||||
version: bundle.version,
|
||||
createdAt: bundle.createdAt,
|
||||
expiresAt: bundle.expiresAt,
|
||||
signature: `sig:${bundle.id}:${bundle.version}`,
|
||||
assets: {
|
||||
ui: this.buildAssetRecord(findAssets('UI Assets', ['index.html', 'main.js'])),
|
||||
api_contracts: this.buildAssetRecord(findAssets('API Contracts', ['policy.openapi.json'])),
|
||||
authority: this.buildAssetRecord(findAssets('Authority', ['jwks.json'])),
|
||||
feeds: this.buildAssetRecord(findAssets('Feed Data', ['advisory_snapshot.ndjson.gz'])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildAssetRecord(names: string[]): Record<string, string> {
|
||||
return names.reduce<Record<string, string>>((record, name, index) => {
|
||||
record[name] = `sha256:${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}-${index}`;
|
||||
return record;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private downloadJsonFile(filename: string, payload: unknown): void {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// JWKS Management Component
|
||||
// Sprint 026: Offline Kit Integration
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal, OnInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface JwkEntry {
|
||||
kid: string;
|
||||
@@ -26,13 +27,16 @@ interface TrustAnchor {
|
||||
|
||||
@Component({
|
||||
selector: 'app-jwks-management',
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="jwks-management">
|
||||
<div class="management-header">
|
||||
<h2>JWKS & Trust Anchor Management</h2>
|
||||
<p class="description">Manage Authority signing keys and trust anchors for offline token validation</p>
|
||||
@if (statusMessage()) {
|
||||
<p class="status-note">{{ statusMessage() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="management-grid">
|
||||
@@ -47,9 +51,9 @@ interface TrustAnchor {
|
||||
}
|
||||
Refresh
|
||||
</button>
|
||||
<button class="btn btn--secondary btn--small" (click)="importJwks()">
|
||||
Import
|
||||
</button>
|
||||
<a class="btn btn--secondary btn--small" routerLink="/setup/trust-signing">
|
||||
Open Trust & Signing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,9 +120,9 @@ interface TrustAnchor {
|
||||
<section class="section-card">
|
||||
<div class="section-header">
|
||||
<h3>Trust Anchors</h3>
|
||||
<button class="btn btn--secondary btn--small" (click)="importAnchor()">
|
||||
Import Anchor
|
||||
</button>
|
||||
<a class="btn btn--secondary btn--small" routerLink="/setup/trust-signing">
|
||||
Open Trust & Signing
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="anchors-list">
|
||||
@@ -148,6 +152,20 @@ interface TrustAnchor {
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (selectedAnchor(); as anchor) {
|
||||
<div class="anchor-detail" data-testid="trust-anchor-detail">
|
||||
<h4>{{ anchor.name }}</h4>
|
||||
<p><strong>Fingerprint:</strong> <code>{{ anchor.fingerprint }}</code></p>
|
||||
<p><strong>Validity:</strong> {{ formatDate(anchor.validFrom) }} - {{ formatDate(anchor.validTo) }}</p>
|
||||
<p><strong>Status:</strong> {{ anchor.status }}</p>
|
||||
<div class="anchor-detail__actions">
|
||||
<a class="btn btn--ghost btn--small" routerLink="/setup/trust-signing">Manage in Trust & Signing</a>
|
||||
<button class="btn btn--ghost btn--small" type="button" (click)="exportAnchor(anchor)">
|
||||
Export Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -214,6 +232,12 @@ interface TrustAnchor {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-note {
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-status-info-border);
|
||||
}
|
||||
|
||||
.management-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -455,6 +479,27 @@ interface TrustAnchor {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.anchor-detail {
|
||||
margin-top: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--color-text-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.anchor-detail h4,
|
||||
.anchor-detail p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.anchor-detail__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.validation-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -607,6 +652,11 @@ export class JwksManagementComponent implements OnInit {
|
||||
readonly activeKeyCount = signal(3);
|
||||
readonly tokenInput = signal('');
|
||||
readonly validationResult = signal<{ valid: boolean; message?: string; claims?: unknown } | null>(null);
|
||||
readonly selectedAnchorId = signal<string | null>(null);
|
||||
readonly statusMessage = signal<string | null>(null);
|
||||
readonly selectedAnchor = computed(
|
||||
() => this.trustAnchors().find((anchor) => anchor.id === this.selectedAnchorId()) ?? null
|
||||
);
|
||||
|
||||
private refreshing = false;
|
||||
|
||||
@@ -625,23 +675,23 @@ export class JwksManagementComponent implements OnInit {
|
||||
// Simulate refresh
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
this.jwksLastUpdated.set(new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC');
|
||||
this.statusMessage.set('JWKS cache refreshed from the active offline trust source.');
|
||||
this.refreshing = false;
|
||||
}
|
||||
|
||||
importJwks(): void {
|
||||
console.log('Importing JWKS...');
|
||||
}
|
||||
|
||||
importAnchor(): void {
|
||||
console.log('Importing trust anchor...');
|
||||
}
|
||||
|
||||
viewAnchor(anchor: TrustAnchor): void {
|
||||
console.log('Viewing anchor:', anchor.id);
|
||||
this.selectedAnchorId.set(anchor.id);
|
||||
}
|
||||
|
||||
exportAnchor(anchor: TrustAnchor): void {
|
||||
console.log('Exporting anchor:', anchor.id);
|
||||
const blob = new Blob([JSON.stringify(anchor, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${anchor.name.replace(/[^a-z0-9.-]+/gi, '-').toLowerCase()}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
this.statusMessage.set(`Exported trust anchor ${anchor.name}.`);
|
||||
}
|
||||
|
||||
onTokenInput(event: Event): void {
|
||||
@@ -738,5 +788,6 @@ export class JwksManagementComponent implements OnInit {
|
||||
status: 'active'
|
||||
}
|
||||
]);
|
||||
this.selectedAnchorId.set('anchor-001');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { Component, ChangeDetectionStrategy, inject, OnInit, signal } from '@angular/core';
|
||||
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { OfflineModeService } from '../../../core/services/offline-mode.service';
|
||||
import { BundleFreshnessWidgetComponent } from '../../../shared/components/bundle-freshness-widget.component';
|
||||
import { OfflineAssetCategories } from '../../../core/api/offline-kit.models';
|
||||
@@ -14,9 +15,17 @@ interface DashboardStats {
|
||||
offlineDuration: string;
|
||||
}
|
||||
|
||||
interface DashboardFeature {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
available: boolean;
|
||||
route: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-offline-dashboard',
|
||||
imports: [BundleFreshnessWidgetComponent],
|
||||
imports: [BundleFreshnessWidgetComponent, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="offline-dashboard">
|
||||
@@ -93,16 +102,27 @@ interface DashboardStats {
|
||||
<h3>Available Features</h3>
|
||||
<div class="features-list">
|
||||
@for (feature of features(); track feature.id) {
|
||||
<div class="feature-item" [class.disabled]="!feature.available">
|
||||
<span class="feature-icon" [innerHTML]="feature.icon"></span>
|
||||
<div class="feature-info">
|
||||
<span class="feature-name">{{ feature.name }}</span>
|
||||
<span class="feature-status">
|
||||
{{ feature.available ? 'Available' : 'Requires Online' }}
|
||||
</span>
|
||||
@if (feature.route && feature.available) {
|
||||
<a class="feature-item feature-item--link" [routerLink]="feature.route">
|
||||
<span class="feature-icon" [innerHTML]="feature.icon"></span>
|
||||
<div class="feature-info">
|
||||
<span class="feature-name">{{ feature.name }}</span>
|
||||
<span class="feature-status">Open workflow</span>
|
||||
</div>
|
||||
<span class="availability-dot available"></span>
|
||||
</a>
|
||||
} @else {
|
||||
<div class="feature-item" [class.disabled]="!feature.available">
|
||||
<span class="feature-icon" [innerHTML]="feature.icon"></span>
|
||||
<div class="feature-info">
|
||||
<span class="feature-name">{{ feature.name }}</span>
|
||||
<span class="feature-status">
|
||||
{{ feature.available ? 'Available' : 'Requires Online' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="availability-dot" [class.available]="feature.available"></span>
|
||||
</div>
|
||||
<span class="availability-dot" [class.available]="feature.available"></span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -251,6 +271,16 @@ interface DashboardStats {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.feature-item--link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.feature-item--link:hover {
|
||||
border-color: var(--color-border-primary);
|
||||
}
|
||||
|
||||
.feature-item.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -364,7 +394,7 @@ export class OfflineDashboardComponent implements OnInit {
|
||||
offlineDuration: 'N/A'
|
||||
});
|
||||
|
||||
readonly features = signal<{ id: string; name: string; icon: string; available: boolean }[]>([]);
|
||||
readonly features = signal<DashboardFeature[]>([]);
|
||||
readonly recentActivity = signal<{ id: string; message: string; time: string; icon: string }[]>([]);
|
||||
|
||||
private retrying = false;
|
||||
@@ -410,14 +440,14 @@ export class OfflineDashboardComponent implements OnInit {
|
||||
private loadFeatures(): void {
|
||||
const isOffline = this.offlineService.isOffline();
|
||||
this.features.set([
|
||||
{ id: 'dashboard', name: 'Dashboard & KPIs', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>', available: true },
|
||||
{ id: 'findings', name: 'View Findings', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', available: true },
|
||||
{ id: 'sbom', name: 'SBOM Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>', available: true },
|
||||
{ id: 'policy', name: 'Policy Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>', available: true },
|
||||
{ id: 'evidence', name: 'Evidence Verification', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', available: true },
|
||||
{ id: 'triage', name: 'Triage & VEX Creation', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>', available: !isOffline },
|
||||
{ id: 'integrations', name: 'Manage Integrations', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>', available: !isOffline },
|
||||
{ id: 'export', name: 'Export Audit Bundles', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>', available: !isOffline }
|
||||
{ id: 'dashboard', name: 'Dashboard & KPIs', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>', available: true, route: '/ops/operations/offline-kit/dashboard' },
|
||||
{ id: 'findings', name: 'View Findings', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', available: true, route: '/security/findings' },
|
||||
{ id: 'sbom', name: 'SBOM Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>', available: true, route: '/security/sbom' },
|
||||
{ id: 'policy', name: 'Policy Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>', available: true, route: '/ops/policy/overview' },
|
||||
{ id: 'evidence', name: 'Evidence Verification', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', available: true, route: '/evidence/verify-replay' },
|
||||
{ id: 'triage', name: 'Triage & VEX Creation', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>', available: !isOffline, route: !isOffline ? '/triage/artifacts' : null },
|
||||
{ id: 'integrations', name: 'Manage Integrations', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>', available: !isOffline, route: !isOffline ? '/ops/integrations' : null },
|
||||
{ id: 'export', name: 'Export Audit Bundles', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>', available: !isOffline, route: !isOffline ? '/evidence/exports' : null }
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Verification Center Component
|
||||
// Sprint 026: Offline Kit Integration
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
|
||||
|
||||
import { OfflineVerificationComponent } from '../../../shared/components/offline-verification.component';
|
||||
import { OfflineVerificationResult } from '../../../core/api/offline-kit.models';
|
||||
@@ -24,6 +24,9 @@ interface VerificationHistory {
|
||||
<div class="center-header">
|
||||
<h2>Offline Verification Center</h2>
|
||||
<p class="description">Verify audit bundles and evidence chains without network access</p>
|
||||
@if (exportMessage()) {
|
||||
<p class="status-note">{{ exportMessage() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="center-grid">
|
||||
@@ -39,7 +42,7 @@ interface VerificationHistory {
|
||||
@if (history().length > 0) {
|
||||
<div class="history-list">
|
||||
@for (item of history(); track item.id) {
|
||||
<div class="history-item" [class.valid]="item.valid">
|
||||
<div class="history-item" [class.valid]="item.valid" [class.selected]="selectedHistoryId() === item.id">
|
||||
<div class="history-status">
|
||||
<span class="status-icon">@if (item.valid) {<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>} @else {<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>}</span>
|
||||
</div>
|
||||
@@ -56,6 +59,20 @@ interface VerificationHistory {
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (selectedHistory(); as selected) {
|
||||
<div class="detail-panel" data-testid="verification-detail-panel">
|
||||
<h4>{{ selected.bundleName }}</h4>
|
||||
<p>Verified {{ formatTime(selected.verifiedAt) }}</p>
|
||||
<p>
|
||||
Chain status:
|
||||
<strong>{{ selected.chainItemsValid }}/{{ selected.chainItemsTotal }}</strong>
|
||||
items valid
|
||||
</p>
|
||||
<button class="btn btn--ghost" type="button" (click)="exportReport(selected)">
|
||||
Export selected report
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<p>No verification history. Upload a bundle to verify.</p>
|
||||
@@ -107,6 +124,12 @@ interface VerificationHistory {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-note {
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-status-info-border);
|
||||
}
|
||||
|
||||
.center-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
@@ -147,6 +170,10 @@ interface VerificationHistory {
|
||||
border-left-color: var(--color-status-success-border);
|
||||
}
|
||||
|
||||
.history-item.selected {
|
||||
outline: 1px solid var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.history-status {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -232,6 +259,21 @@ interface VerificationHistory {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
margin-top: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--color-text-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.detail-panel h4,
|
||||
.detail-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-text-primary);
|
||||
@@ -266,6 +308,11 @@ export class VerificationCenterComponent {
|
||||
chainItemsTotal: 6
|
||||
}
|
||||
]);
|
||||
readonly selectedHistoryId = signal<string | null>('1');
|
||||
readonly exportMessage = signal<string | null>(null);
|
||||
readonly selectedHistory = computed(() =>
|
||||
this.history().find((item) => item.id === this.selectedHistoryId()) ?? null
|
||||
);
|
||||
|
||||
onVerificationComplete(result: OfflineVerificationResult): void {
|
||||
const newEntry: VerificationHistory = {
|
||||
@@ -278,6 +325,7 @@ export class VerificationCenterComponent {
|
||||
};
|
||||
|
||||
this.history.update(h => [newEntry, ...h].slice(0, 10));
|
||||
this.selectedHistoryId.set(newEntry.id);
|
||||
}
|
||||
|
||||
formatTime(timestamp: string): string {
|
||||
@@ -287,20 +335,55 @@ export class VerificationCenterComponent {
|
||||
}
|
||||
|
||||
viewDetails(item: VerificationHistory): void {
|
||||
console.log('Viewing details for:', item.id);
|
||||
this.selectedHistoryId.set(item.id);
|
||||
}
|
||||
|
||||
verifyLastBundle(): void {
|
||||
console.log('Re-verifying last bundle...');
|
||||
const lastBundle = this.history()[0];
|
||||
if (!lastBundle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reverified: VerificationHistory = {
|
||||
...lastBundle,
|
||||
id: `${lastBundle.id}-recheck-${Date.now()}`,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.history.update((items) => [reverified, ...items].slice(0, 10));
|
||||
this.selectedHistoryId.set(reverified.id);
|
||||
}
|
||||
|
||||
exportReport(): void {
|
||||
console.log('Exporting verification report...');
|
||||
exportReport(item?: VerificationHistory): void {
|
||||
const report = item ?? this.selectedHistory() ?? this.history()[0];
|
||||
if (!report) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
report,
|
||||
summary: {
|
||||
valid: report.valid,
|
||||
verifiedItems: report.chainItemsValid,
|
||||
totalItems: report.chainItemsTotal,
|
||||
},
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${report.bundleName.replace(/[^a-z0-9.-]+/gi, '-').toLowerCase()}-report.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
this.exportMessage.set(`Exported verification report for ${report.bundleName}.`);
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
if (confirm('Clear all verification history?')) {
|
||||
this.history.set([]);
|
||||
this.selectedHistoryId.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
<div class="header-content">
|
||||
<h1>Offline Kit Management</h1>
|
||||
<p class="subtitle">Manage offline bundles, verify audit packages, and configure air-gap operation</p>
|
||||
<div class="page-shortcuts">
|
||||
<a routerLink="/ops/operations/feeds-airgap">Feeds & Airgap</a>
|
||||
<a routerLink="/evidence/exports">Evidence Exports</a>
|
||||
<a routerLink="/evidence/verify-replay">Verify & Replay</a>
|
||||
<a routerLink="/setup/trust-signing">Trust & Signing</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<div class="connection-status" [class.offline]="isOffline()">
|
||||
@@ -91,6 +97,27 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-shortcuts {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.page-shortcuts a {
|
||||
border: 1px solid var(--color-text-primary);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-shortcuts a:hover {
|
||||
color: var(--color-border-primary);
|
||||
border-color: var(--color-border-primary);
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import {
|
||||
OPERATIONS_INTEGRATION_PATHS,
|
||||
OPERATIONS_PATHS,
|
||||
dataIntegrityPath,
|
||||
} from './operations-paths';
|
||||
|
||||
type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
type FeedsAirgapAction = 'import' | 'export' | null;
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-feeds-airgap-page',
|
||||
@@ -20,22 +26,37 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
</p>
|
||||
</div>
|
||||
<div class="feeds-offline__actions">
|
||||
<a routerLink="/ops/integrations/advisory-vex-sources">Configure Sources</a>
|
||||
<button type="button">Sync Now</button>
|
||||
<button type="button">Import Airgap Bundle</button>
|
||||
<a [routerLink]="OPERATIONS_INTEGRATION_PATHS.advisorySources">Configure Sources</a>
|
||||
<a [routerLink]="feedsFreshnessPath">Open Freshness Lens</a>
|
||||
<a [routerLink]="OPERATIONS_PATHS.offlineKit + '/bundles'">Open Offline Bundles</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button type="button" [class.active]="tab() === 'feed-mirrors'" (click)="tab.set('feed-mirrors')">
|
||||
<nav class="tabs" aria-label="Feeds and airgap sections">
|
||||
<a
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ tab: 'feed-mirrors', action: null }"
|
||||
queryParamsHandling="merge"
|
||||
[class.active]="tab() === 'feed-mirrors'"
|
||||
>
|
||||
Feed Mirrors
|
||||
</button>
|
||||
<button type="button" [class.active]="tab() === 'airgap-bundles'" (click)="tab.set('airgap-bundles')">
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ tab: 'airgap-bundles', action: airgapAction() }"
|
||||
queryParamsHandling="merge"
|
||||
[class.active]="tab() === 'airgap-bundles'"
|
||||
>
|
||||
Airgap Bundles
|
||||
</button>
|
||||
<button type="button" [class.active]="tab() === 'version-locks'" (click)="tab.set('version-locks')">
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ tab: 'version-locks', action: null }"
|
||||
queryParamsHandling="merge"
|
||||
[class.active]="tab() === 'version-locks'"
|
||||
>
|
||||
Version Locks
|
||||
</button>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<section class="summary">
|
||||
@@ -51,7 +72,7 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
<span>Impact: BLOCKING</span>
|
||||
<span>Mode: last-known-good snapshot (read-only)</span>
|
||||
<code>corr-feed-8841</code>
|
||||
<button type="button">Retry</button>
|
||||
<a [routerLink]="feedsFreshnessPath">Open incident</a>
|
||||
</section>
|
||||
|
||||
<article class="panel">
|
||||
@@ -88,10 +109,22 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
</table>
|
||||
}
|
||||
@if (tab() === 'airgap-bundles') {
|
||||
@if (airgapAction()) {
|
||||
<div class="action-banner" data-testid="feeds-airgap-action-banner">
|
||||
@if (airgapAction() === 'import') {
|
||||
<strong>Import workflow selected.</strong>
|
||||
<span>Use Offline Kit Bundles to load a signed airgap bundle into the active site context.</span>
|
||||
} @else {
|
||||
<strong>Export workflow selected.</strong>
|
||||
<span>Open Evidence Exports to prepare a portable bundle, then move it through Offline Kit.</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p>Offline import/export workflows and bundle verification controls.</p>
|
||||
<div class="panel__links">
|
||||
<a routerLink="/ops/operations/offline-kit">Open Offline Kit Operations</a>
|
||||
<a [routerLink]="OPERATIONS_PATHS.offlineKit + '/bundles'">Open Offline Kit Bundles</a>
|
||||
<a routerLink="/evidence/exports">Export Evidence Bundle</a>
|
||||
<a routerLink="/evidence/verify-replay">Open Verify & Replay</a>
|
||||
</div>
|
||||
}
|
||||
@if (tab() === 'version-locks') {
|
||||
@@ -134,8 +167,7 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feeds-offline__actions a,
|
||||
.feeds-offline__actions button {
|
||||
.feeds-offline__actions a {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
@@ -152,7 +184,7 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
.tabs a {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-primary);
|
||||
@@ -160,9 +192,10 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
.tabs a.active {
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
@@ -204,14 +237,14 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
|
||||
.status-banner button {
|
||||
.status-banner a {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-brand-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.67rem;
|
||||
padding: 0.08rem 0.34rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -250,6 +283,17 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.action-banner {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.5rem 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
|
||||
.panel__links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -267,7 +311,11 @@ export class PlatformFeedsAirgapPageComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
|
||||
readonly OPERATIONS_INTEGRATION_PATHS = OPERATIONS_INTEGRATION_PATHS;
|
||||
readonly feedsFreshnessPath = dataIntegrityPath('feeds-freshness');
|
||||
readonly tab = signal<FeedsOfflineTab>('feed-mirrors');
|
||||
readonly airgapAction = signal<FeedsAirgapAction>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.queryParamMap
|
||||
@@ -281,6 +329,14 @@ export class PlatformFeedsAirgapPageComponent implements OnInit {
|
||||
) {
|
||||
this.tab.set(requested);
|
||||
}
|
||||
|
||||
const requestedAction = params.get('action');
|
||||
if (requestedAction === 'import' || requestedAction === 'export') {
|
||||
this.airgapAction.set(requestedAction);
|
||||
return;
|
||||
}
|
||||
|
||||
this.airgapAction.set(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
class="chip"
|
||||
[class.chip--fresh]="!isStale()"
|
||||
[class.chip--stale]="isStale()"
|
||||
routerLink="/platform/ops/feeds-airgap"
|
||||
routerLink="/ops/operations/feeds-airgap"
|
||||
[attr.title]="tooltip()"
|
||||
>
|
||||
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
@@ -123,4 +123,3 @@ export class FeedSnapshotChipComponent {
|
||||
return `${freshness.message} (snapshot ${freshness.bundleCreatedAt}).`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
class="chip"
|
||||
[class.chip--ok]="status() === 'ok'"
|
||||
[class.chip--degraded]="status() === 'degraded'"
|
||||
routerLink="/platform/ops/offline-kit"
|
||||
routerLink="/ops/operations/offline-kit"
|
||||
[attr.title]="tooltip()"
|
||||
aria-live="polite"
|
||||
>
|
||||
@@ -113,4 +113,3 @@ export class OfflineStatusChipComponent {
|
||||
return 'Online mode active with live backend connectivity.';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { inject } from '@angular/core';
|
||||
import { Params, Router, Routes } from '@angular/router';
|
||||
|
||||
export const OPS_ROUTES: Routes = [
|
||||
{
|
||||
@@ -57,6 +58,27 @@ export const OPS_ROUTES: Routes = [
|
||||
redirectTo: 'operations/feeds-airgap',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'feeds/version-locks',
|
||||
redirectTo: preserveOpsRedirect('/ops/operations/feeds-airgap', { tab: 'version-locks' }),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'feeds/airgap/import',
|
||||
redirectTo: preserveOpsRedirect('/ops/operations/feeds-airgap', {
|
||||
tab: 'airgap-bundles',
|
||||
action: 'import',
|
||||
}),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'feeds/airgap/export',
|
||||
redirectTo: preserveOpsRedirect('/ops/operations/feeds-airgap', {
|
||||
tab: 'airgap-bundles',
|
||||
action: 'export',
|
||||
}),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'feeds-airgap',
|
||||
redirectTo: 'operations/feeds-airgap',
|
||||
@@ -87,6 +109,11 @@ export const OPS_ROUTES: Routes = [
|
||||
redirectTo: 'operations/offline-kit',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'offline-kit/:page',
|
||||
redirectTo: preserveOpsRedirect('/ops/operations/offline-kit/:page'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'quotas',
|
||||
redirectTo: 'operations/quotas',
|
||||
@@ -98,3 +125,32 @@ export const OPS_ROUTES: Routes = [
|
||||
pathMatch: 'full',
|
||||
},
|
||||
];
|
||||
|
||||
function preserveOpsRedirect(template: string, extraQueryParams: Params = {}) {
|
||||
return ({
|
||||
params,
|
||||
queryParams,
|
||||
fragment,
|
||||
}: {
|
||||
params: Params;
|
||||
queryParams: Params;
|
||||
fragment?: string | null;
|
||||
}) => {
|
||||
const router = inject(Router);
|
||||
const target = router.parseUrl(interpolateOpsRedirectTarget(template, params));
|
||||
target.queryParams = { ...target.queryParams, ...extraQueryParams, ...queryParams };
|
||||
target.fragment = fragment ?? null;
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
function interpolateOpsRedirectTarget(template: string, params: Params): string {
|
||||
let target = template;
|
||||
|
||||
for (const [name, rawValue] of Object.entries(params ?? {})) {
|
||||
const value = Array.isArray(rawValue) ? rawValue.join('/') : String(rawValue);
|
||||
target = target.replaceAll(`:${name}`, encodeURIComponent(value));
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,21 @@ const LEGACY_PLATFORM_OPS_REDIRECTS: readonly LegacyPlatformOpsRedirect[] = [
|
||||
},
|
||||
{ path: 'jobs-queues', redirectTo: OPERATIONS_PATHS.jobsQueues },
|
||||
{ path: 'feeds', redirectTo: OPERATIONS_PATHS.feedsAirgap },
|
||||
{
|
||||
path: 'feeds/version-locks',
|
||||
redirectTo: `${OPERATIONS_PATHS.feedsAirgap}?tab=version-locks`,
|
||||
},
|
||||
{
|
||||
path: 'feeds/airgap/import',
|
||||
redirectTo: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=import`,
|
||||
},
|
||||
{
|
||||
path: 'feeds/airgap/export',
|
||||
redirectTo: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=export`,
|
||||
},
|
||||
{ path: 'feeds-airgap', redirectTo: OPERATIONS_PATHS.feedsAirgap },
|
||||
{ path: 'offline-kit', redirectTo: OPERATIONS_PATHS.offlineKit },
|
||||
{ path: 'offline-kit/:page', redirectTo: `${OPERATIONS_PATHS.offlineKit}/:page` },
|
||||
{ path: 'health', redirectTo: OPERATIONS_PATHS.healthSlo },
|
||||
{ path: 'health-slo', redirectTo: OPERATIONS_PATHS.healthSlo },
|
||||
{ path: 'doctor', redirectTo: OPERATIONS_PATHS.doctor },
|
||||
@@ -76,7 +89,7 @@ export const PLATFORM_OPS_ROUTES: Routes = [
|
||||
}) => {
|
||||
const router = inject(Router);
|
||||
const target = router.parseUrl(interpolateLegacyTarget(template.redirectTo, params));
|
||||
target.queryParams = { ...queryParams };
|
||||
target.queryParams = { ...target.queryParams, ...queryParams };
|
||||
target.fragment = fragment ?? null;
|
||||
return target;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { JwksManagementComponent } from '../../app/features/offline-kit/components/jwks-management.component';
|
||||
|
||||
describe('JwksManagementComponent', () => {
|
||||
let fixture: ComponentFixture<JwksManagementComponent>;
|
||||
let component: JwksManagementComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [JwksManagementComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(JwksManagementComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('shows the selected trust anchor in the detail panel', () => {
|
||||
component.viewAnchor(component.trustAnchors()[1]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const detailPanel = fixture.nativeElement.querySelector('[data-testid="trust-anchor-detail"]') as HTMLElement;
|
||||
expect(detailPanel).not.toBeNull();
|
||||
expect(detailPanel.textContent).toContain('Enterprise Intermediate CA');
|
||||
});
|
||||
|
||||
it('exports trust anchor details through a real download flow', () => {
|
||||
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:anchor');
|
||||
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
|
||||
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub();
|
||||
|
||||
component.exportAnchor(component.trustAnchors()[0]);
|
||||
|
||||
expect(createObjectUrlSpy).toHaveBeenCalled();
|
||||
expect(anchorClickSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:anchor');
|
||||
expect(component.statusMessage()).toContain('StellaOps Root CA');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
import { OfflineManifest } from '../../app/core/api/offline-kit.models';
|
||||
import { BundleValidationResult, OfflineManifest } from '../../app/core/api/offline-kit.models';
|
||||
import { OfflineModeService } from '../../app/core/services/offline-mode.service';
|
||||
import { BundleManagementComponent } from '../../app/features/offline-kit/components/bundle-management.component';
|
||||
import { offlineKitRoutes } from '../../app/features/offline-kit/offline-kit.routes';
|
||||
@@ -65,4 +65,43 @@ describe('Offline Kit UI Integration (B24-001)', () => {
|
||||
expect(root?.children?.some((child) => child.path === 'verify')).toBeTrue();
|
||||
expect(root?.children?.some((child) => child.path === 'jwks')).toBeTrue();
|
||||
});
|
||||
|
||||
it('loads the generated manifest into offline state when a valid bundle is added', () => {
|
||||
const result: BundleValidationResult = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
assetIntegrity: {
|
||||
totalAssets: 6,
|
||||
validAssets: 6,
|
||||
invalidAssets: 0,
|
||||
missingAssets: [],
|
||||
hashMismatches: [],
|
||||
},
|
||||
signatureStatus: {
|
||||
valid: true,
|
||||
algorithm: 'ES256',
|
||||
},
|
||||
};
|
||||
|
||||
component.onManifestValidated(result);
|
||||
|
||||
expect(offlineStub.loadManifest).toHaveBeenCalled();
|
||||
expect(component.activeBundleId()).not.toBeNull();
|
||||
expect(component.loadedBundles()[0].status).toBe('active');
|
||||
});
|
||||
|
||||
it('exports bundle summaries through a real download flow', () => {
|
||||
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:test');
|
||||
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
|
||||
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub();
|
||||
const bundle = component.loadedBundles()[0];
|
||||
|
||||
component.exportBundle(bundle);
|
||||
|
||||
expect(createObjectUrlSpy).toHaveBeenCalled();
|
||||
expect(anchorClickSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:test');
|
||||
expect(component.lastExportMessage()).toContain(`v${bundle.version}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { VerificationCenterComponent } from '../../app/features/offline-kit/components/verification-center.component';
|
||||
|
||||
describe('VerificationCenterComponent', () => {
|
||||
let fixture: ComponentFixture<VerificationCenterComponent>;
|
||||
let component: VerificationCenterComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VerificationCenterComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VerificationCenterComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('shows a detail panel for the selected verification history item', () => {
|
||||
const buttons = Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll('.history-item .btn--ghost'),
|
||||
) as HTMLButtonElement[];
|
||||
|
||||
buttons[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const detailPanel = fixture.nativeElement.querySelector('[data-testid="verification-detail-panel"]') as HTMLElement;
|
||||
expect(detailPanel).not.toBeNull();
|
||||
expect(detailPanel.textContent).toContain('audit-bundle-2025-01-14.zip');
|
||||
});
|
||||
|
||||
it('re-verifies the latest bundle by prepending a fresh history record', () => {
|
||||
const initialLength = component.history().length;
|
||||
|
||||
component.verifyLastBundle();
|
||||
|
||||
expect(component.history().length).toBe(initialLength + 1);
|
||||
expect(component.history()[0].id).toContain('recheck');
|
||||
});
|
||||
|
||||
it('exports a verification report through a real download flow', () => {
|
||||
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:verification');
|
||||
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
|
||||
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub();
|
||||
|
||||
component.exportReport(component.history()[0]);
|
||||
|
||||
expect(createObjectUrlSpy).toHaveBeenCalled();
|
||||
expect(anchorClickSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:verification');
|
||||
expect(component.exportMessage()).toContain(component.history()[0].bundleName);
|
||||
});
|
||||
});
|
||||
@@ -40,4 +40,13 @@ describe('PlatformFeedsAirgapPageComponent (platform-ops)', () => {
|
||||
|
||||
expect(component.tab()).toBe('version-locks');
|
||||
});
|
||||
|
||||
it('tracks the airgap action from query params for canonical action links', () => {
|
||||
queryParamMap$.next(convertToParamMap({ tab: 'airgap-bundles', action: 'import' }));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.tab()).toBe('airgap-bundles');
|
||||
expect(component.airgapAction()).toBe('import');
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Import workflow selected.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,12 +28,16 @@ describe('Platform and Operations route contracts', () => {
|
||||
const aliases = OPS_ROUTES.filter((route) => route.redirectTo);
|
||||
expect(aliases.map((route) => route.path)).toEqual([
|
||||
'feeds',
|
||||
'feeds/version-locks',
|
||||
'feeds/airgap/import',
|
||||
'feeds/airgap/export',
|
||||
'feeds-airgap',
|
||||
'airgap',
|
||||
'health-slo',
|
||||
'signals',
|
||||
'scheduler',
|
||||
'offline-kit',
|
||||
'offline-kit/:page',
|
||||
'quotas',
|
||||
'packs',
|
||||
]);
|
||||
@@ -82,8 +86,12 @@ describe('Platform and Operations route contracts', () => {
|
||||
'data-integrity/:section',
|
||||
'jobs-queues',
|
||||
'feeds',
|
||||
'feeds/version-locks',
|
||||
'feeds/airgap/import',
|
||||
'feeds/airgap/export',
|
||||
'feeds-airgap',
|
||||
'offline-kit',
|
||||
'offline-kit/:page',
|
||||
'health',
|
||||
'health-slo',
|
||||
'doctor',
|
||||
|
||||
167
src/Web/StellaOps.Web/tests/e2e/offline-operations.spec.ts
Normal file
167
src/Web/StellaOps.Web/tests/e2e/offline-operations.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
|
||||
|
||||
const adminSession: StubAuthSession = {
|
||||
subjectId: 'offline-ops-e2e-user',
|
||||
tenant: 'tenant-default',
|
||||
scopes: [
|
||||
'admin',
|
||||
'ui.read',
|
||||
'ui.admin',
|
||||
'orch:read',
|
||||
'orch:operate',
|
||||
'health:read',
|
||||
'notify.viewer',
|
||||
'policy:read',
|
||||
],
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: '/authority',
|
||||
clientId: 'stella-ops-ui',
|
||||
authorizeEndpoint: '/authority/connect/authorize',
|
||||
tokenEndpoint: '/authority/connect/token',
|
||||
logoutEndpoint: '/authority/connect/logout',
|
||||
redirectUri: 'https://127.0.0.1:4400/auth/callback',
|
||||
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
|
||||
scope: 'openid profile email ui.read',
|
||||
audience: '/gateway',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: '/authority',
|
||||
scanner: '/scanner',
|
||||
policy: '/policy',
|
||||
concelier: '/concelier',
|
||||
attestor: '/attestor',
|
||||
gateway: '/gateway',
|
||||
},
|
||||
quickstartMode: true,
|
||||
setup: 'complete',
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown): Promise<void> {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function navigateClientSide(page: Page, target: string): Promise<void> {
|
||||
await page.evaluate((url) => {
|
||||
window.history.pushState({}, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state }));
|
||||
}, target);
|
||||
}
|
||||
|
||||
async function setupHarness(page: Page): Promise<void> {
|
||||
await page.addInitScript((session) => {
|
||||
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
|
||||
}, adminSession);
|
||||
|
||||
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
|
||||
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
|
||||
await page.route('**/.well-known/openid-configuration', (route) =>
|
||||
fulfillJson(route, {
|
||||
issuer: 'https://127.0.0.1:4400/authority',
|
||||
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
|
||||
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
|
||||
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
}),
|
||||
);
|
||||
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
|
||||
await page.route('**/console/profile**', (route) =>
|
||||
fulfillJson(route, {
|
||||
subjectId: adminSession.subjectId,
|
||||
username: 'offline-ops-e2e',
|
||||
displayName: 'Offline Ops E2E',
|
||||
tenant: adminSession.tenant,
|
||||
roles: ['admin'],
|
||||
scopes: adminSession.scopes,
|
||||
}),
|
||||
);
|
||||
await page.route('**/console/token/introspect**', (route) =>
|
||||
fulfillJson(route, {
|
||||
active: true,
|
||||
tenant: adminSession.tenant,
|
||||
subject: adminSession.subjectId,
|
||||
scopes: adminSession.scopes,
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v2/context/regions', (route) =>
|
||||
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]),
|
||||
);
|
||||
await page.route('**/api/v2/context/environments**', (route) =>
|
||||
fulfillJson(route, [
|
||||
{
|
||||
environmentId: 'prod',
|
||||
regionId: 'eu-west',
|
||||
environmentType: 'prod',
|
||||
displayName: 'Prod',
|
||||
sortOrder: 1,
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await page.route('**/api/v2/context/preferences', (route) =>
|
||||
fulfillJson(route, {
|
||||
tenantId: adminSession.tenant,
|
||||
actorId: adminSession.subjectId,
|
||||
regions: ['eu-west'],
|
||||
environments: ['prod'],
|
||||
timeWindow: '24h',
|
||||
stage: 'all',
|
||||
updatedAt: '2026-03-08T12:00:00Z',
|
||||
updatedBy: adminSession.subjectId,
|
||||
}),
|
||||
);
|
||||
await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, []));
|
||||
await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, []));
|
||||
await page.route('**/health', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'ok' }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupHarness(page);
|
||||
});
|
||||
|
||||
test('offline kit exposes working canonical shortcuts and child routes', async ({ page }) => {
|
||||
await page.goto('/ops/operations/offline-kit', { waitUntil: 'networkidle' });
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Offline Kit Management' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Evidence Exports' })).toBeVisible();
|
||||
|
||||
await page.locator('a.tab-link', { hasText: 'Bundles' }).click();
|
||||
await expect(page).toHaveURL(/\/ops\/operations\/offline-kit\/bundles$/);
|
||||
await expect(page.getByText('Load New Bundle')).toBeVisible();
|
||||
});
|
||||
|
||||
test('legacy offline aliases resolve into canonical operations routes', async ({ page }) => {
|
||||
await page.goto('/ops/operations', { waitUntil: 'networkidle' });
|
||||
|
||||
await navigateClientSide(page, '/platform/ops/offline-kit/bundles?from=legacy');
|
||||
await expect(page).toHaveURL(/\/ops\/operations\/offline-kit\/bundles\?from=legacy$/);
|
||||
await expect(page.getByText('Bundle Management')).toBeVisible();
|
||||
|
||||
await navigateClientSide(page, '/ops/feeds/airgap/import');
|
||||
await expect(page).toHaveURL(/\/ops\/operations\/feeds-airgap/);
|
||||
await expect
|
||||
.poll(() => {
|
||||
const currentUrl = new URL(page.url());
|
||||
return `${currentUrl.searchParams.get('tab')}:${currentUrl.searchParams.get('action')}`;
|
||||
})
|
||||
.toBe('airgap-bundles:import');
|
||||
await expect(page.getByTestId('feeds-airgap-action-banner')).toContainText('Import workflow selected.');
|
||||
});
|
||||
Reference in New Issue
Block a user