doctor and setup fixes

This commit is contained in:
master
2026-02-21 09:45:32 +02:00
parent 1ec797d5e8
commit 7e36c1f151
82 changed files with 5336 additions and 761 deletions

View File

@@ -473,7 +473,7 @@ services:
aliases: aliases:
- attestor-tileproxy.stella-ops.local - attestor-tileproxy.stella-ops.local
healthcheck: healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"] test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/8080'"]
<<: *healthcheck-tcp <<: *healthcheck-tcp
labels: *release-labels labels: *release-labels
@@ -1086,6 +1086,15 @@ services:
ConnectionStrings__Default: *postgres-connection ConnectionStrings__Default: *postgres-connection
ConnectionStrings__Redis: "cache.stella-ops.local:6379" ConnectionStrings__Redis: "cache.stella-ops.local:6379"
Scheduler__Authority__Enabled: "false" Scheduler__Authority__Enabled: "false"
# Worker options are validated even in web mode
scheduler__queue__Kind: "Redis"
scheduler__queue__Redis__ConnectionString: "cache.stella-ops.local:6379"
Scheduler__Storage__Postgres__Scheduler__ConnectionString: *postgres-connection
Scheduler__Storage__Postgres__Scheduler__SchemaName: "scheduler"
Scheduler__Worker__Runner__Scanner__BaseAddress: "http://scanner.stella-ops.local"
Scheduler__Worker__Graph__Cartographer__BaseAddress: "http://cartographer.stella-ops.local"
Scheduler__Worker__Graph__SchedulerApi__BaseAddress: "http://scheduler.stella-ops.local"
Scheduler__Worker__Policy__Api__BaseAddress: "http://policy.stella-ops.local"
volumes: volumes:
- *cert-volume - *cert-volume
tmpfs: tmpfs:
@@ -1528,7 +1537,7 @@ services:
- smremote.stella-ops.local - smremote.stella-ops.local
frontdoor: {} frontdoor: {}
healthcheck: healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"] test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/8080'"]
<<: *healthcheck-tcp <<: *healthcheck-tcp
labels: *release-labels labels: *release-labels

View File

@@ -1,4 +1,4 @@
#!/bin/sh #!/bin/bash
set -eu set -eu
HOST="${HEALTH_HOST:-127.0.0.1}" HOST="${HEALTH_HOST:-127.0.0.1}"
PORT="${HEALTH_PORT:-8080}" PORT="${HEALTH_PORT:-8080}"

View File

@@ -0,0 +1,100 @@
# Sprint 034 — Unified System Health View
## Topic & Scope
- Merge Platform Health + Doctor diagnostics into a single tabbed "System Health" page.
- Extract reusable sub-components (KPI strip, service grid) from the 620-line Platform Health monolith.
- Compose extracted sub-components with Doctor inline checks in a new SystemHealthPageComponent.
- Working directory: `src/Web/StellaOps.Web/`
- Expected evidence: component renders with tabs, existing routes still functional, new tests pass.
## Dependencies & Concurrency
- Depends on Sprint 035 (Feature #4 — DoctorChecksInlineComponent).
- Safe to parallelize with Sprints 036039 after Sprint 035 completes.
## Documentation Prerequisites
- `docs/modules/platform/architecture-overview.md`
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
## Delivery Tracker
### 034-T1 - Extract KpiStripComponent from Platform Health
Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
- Create `features/platform-health/components/kpi-strip.component.ts`.
- Extract the KPI strip section (service counts by status, avg latency, error rate).
- Input: `@Input({ required: true }) summary!: PlatformHealthSummary`.
Completion criteria:
- [x] Component renders KPI strip identically to current dashboard
- [x] Platform Health dashboard refactored to use the extracted component
### 034-T2 - Extract ServiceHealthGridComponent from Platform Health
Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
- Create `features/platform-health/components/service-health-grid.component.ts`.
- Extract service health grid (cards grouped by state).
- Inputs: `services`, `compact` (boolean for reduced layout in unified view).
Completion criteria:
- [x] Component renders service grid identically to current dashboard
- [x] Compact mode reduces card height/spacing for unified view
### 034-T3 - Create SystemHealthPageComponent
Status: DONE
Dependency: 034-T1, 034-T2, Sprint 035
Owners: Developer (FE)
Task description:
- Create `features/system-health/system-health-page.component.ts`.
- Tabbed layout: Overview, Services, Diagnostics, Incidents.
- Overview tab: KPI strip + compact service grid + top 5 Doctor failures (via DoctorChecksInlineComponent).
- Services tab: Full service health grid.
- Diagnostics tab: Doctor results with filters (reuses store filtering).
- Incidents tab: Incident timeline from Platform Health.
- Header: auto-refresh indicator, manual refresh, Quick Diagnostics button.
Completion criteria:
- [x] All four tabs render correct content
- [x] Doctor inline checks appear in Overview tab
- [x] Navigation between tabs preserves state
### 034-T4 - Route and sidebar wiring
Status: DONE
Dependency: 034-T3
Owners: Developer (FE)
Task description:
- Add `system-health` route to `routes/operations.routes.ts` before `health-slo`.
- Replace two sidebar nav entries (plat-health + plat-diagnostics) with single `plat-system-health` pointing to `/platform/ops/system-health`.
Completion criteria:
- [x] `/platform/ops/system-health` loads SystemHealthPageComponent
- [x] Sidebar shows single "System Health" entry
- [x] Old routes still function (no breakage)
### 034-T5 - Tests for SystemHealthPageComponent
Status: DONE
Dependency: 034-T3
Owners: Developer (FE)
Task description:
- Create `tests/system-health/system-health-page.spec.ts`.
- Test tab navigation, KPI strip rendering, Doctor inline integration.
Completion criteria:
- [x] Tests pass with `npx ng test --watch=false`
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created. | Planning |
| 2026-02-21 | All tasks implemented and verified. Build passes, tests pass. | Developer (FE) |
## Decisions & Risks
- Existing Platform Health and Doctor routes must remain functional for backward compatibility.
- Sidebar entry consolidation may affect user muscle memory; mitigated by keeping old routes as redirects.
## Next Checkpoints
- Feature #4 (Sprint 035) must complete before 034-T3 can start.
- Visual review after 034-T3 implementation.

View File

@@ -0,0 +1,105 @@
# Sprint 035 — Contextual Doctor Inline Checks
## Topic & Scope
- Add `resultsByCategory()` and `summaryByCategory()` methods to DoctorStore.
- Create DoctorChecksInlineComponent for embedding Doctor check summaries on module pages.
- Add category query-param support to Doctor dashboard.
- Place inline checks on Security Risk, Integration Hub, and Platform Ops pages.
- Working directory: `src/Web/StellaOps.Web/`
- Expected evidence: inline strips render on target pages, expand/collapse works, link to dashboard with category filter.
## Dependencies & Concurrency
- No upstream sprint dependencies (foundation feature).
- Blocks Sprint 034 (Unified System Health View depends on inline component).
## Documentation Prerequisites
- Doctor module models: `features/doctor/models/doctor.models.ts`
## Delivery Tracker
### 035-T1 - Add category methods to DoctorStore
Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
- Add `resultsByCategory(category: string): CheckResult[]` method to `doctor.store.ts`.
- Add `summaryByCategory(category: string): { pass: number; warn: number; fail: number; total: number }` method.
- These are regular methods (not signals) because they take a parameter; callers wrap in `computed()`.
Completion criteria:
- [x] Methods filter current report results by category
- [x] Return empty results/zeroes when no report loaded
### 035-T2 - Create DoctorChecksInlineComponent
Status: DONE
Dependency: 035-T1
Owners: Developer (FE)
Task description:
- Create `features/doctor/components/doctor-checks-inline/doctor-checks-inline.component.ts`.
- Inputs: `category` (required), `heading?`, `autoRun = false`, `maxResults = 5`.
- Compact summary strip: "3 pass / 1 warn / 0 fail" with expand toggle.
- Expanded view: individual check-result items + "Run Quick Check" button + "Open Full Diagnostics" link.
- Link to Doctor dashboard uses `[queryParams]="{ category: category }"`.
Completion criteria:
- [x] Summary strip shows correct counts for given category
- [x] Expand/collapse toggles individual results
- [x] "Open Full Diagnostics" navigates to `/platform/ops/doctor?category=<cat>`
### 035-T3 - Doctor dashboard category query-param support
Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
- Modify `doctor-dashboard.component.ts` to inject `ActivatedRoute`.
- In `ngOnInit()`, read `category` query param and call `this.store.setCategoryFilter()`.
Completion criteria:
- [x] Navigating to `/platform/ops/doctor?category=security` pre-filters results
### 035-T4 - Barrel export update
Status: DONE
Dependency: 035-T2
Owners: Developer (FE)
Task description:
- Add export for `DoctorChecksInlineComponent` to `features/doctor/index.ts`.
Completion criteria:
- [x] Component importable via barrel
### 035-T5 - Place inline checks on module pages
Status: DONE
Dependency: 035-T2
Owners: Developer (FE)
Task description:
- Security Risk Overview (`security-risk-overview.component.ts`): category `'security'`.
- Integration Hub List (`integration-list.component.ts`): category `'integration'`.
- Platform Ops Overview (`platform-ops-overview-page.component.ts`): category `'core'`.
Completion criteria:
- [x] Inline check strip visible on all three pages
- [x] Each shows correct category filter
### 035-T6 - Tests for DoctorChecksInlineComponent
Status: DONE
Dependency: 035-T2
Owners: Developer (FE)
Task description:
- Create `tests/doctor/doctor-checks-inline.component.spec.ts`.
- Test summary rendering, expand/collapse, category filtering, navigation link.
Completion criteria:
- [x] Tests pass with `npx ng test --watch=false`
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created. | Planning |
| 2026-02-21 | All tasks implemented and verified. Build passes, tests pass. | Developer (FE) |
## Decisions & Risks
- `resultsByCategory` and `summaryByCategory` are regular methods (not signals) because they accept parameters. Callers must wrap in `computed()` for reactivity.
- Inline component auto-run disabled by default to avoid unnecessary API calls on page load.
## Next Checkpoints
- Sprint 034 (Unified Health) unblocked once 035-T2 is DONE.

View File

@@ -0,0 +1,124 @@
# Sprint 036 — Sidebar Trend Sparklines
## Topic & Scope
- Add health trend sparklines next to Security and Platform sidebar nav sections.
- Create DoctorTrendService for periodic trend data fetching.
- Create SidebarSparklineComponent rendering 40x16px SVG polylines.
- Working directory: `src/Web/StellaOps.Web/`
- Expected evidence: sparklines visible in sidebar, auto-refresh on navigation, graceful degradation.
## Dependencies & Concurrency
- No upstream sprint dependencies.
- Safe to parallelize with Sprints 034, 035, 037039.
- Shares `app.config.ts` with Sprint 037 (app init registration).
## Documentation Prerequisites
- Sidebar component: `layout/app-sidebar/app-sidebar.component.ts`
- Doctor API client: `features/doctor/services/doctor.client.ts`
## Delivery Tracker
### 036-T1 - Create DoctorTrend models
Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
- Create `core/doctor/doctor-trend.models.ts`.
- Types: `DoctorTrendPoint { timestamp: string; score: number }`, `DoctorTrendResponse { category: string; points: DoctorTrendPoint[] }`.
Completion criteria:
- [x] Models exported and importable
### 036-T2 - Add getTrends to Doctor client
Status: DONE
Dependency: 036-T1
Owners: Developer (FE)
Task description:
- Add `getTrends(categories?: string[], limit?: number): Observable<DoctorTrendResponse[]>` to `DoctorApi` interface.
- Implement in `HttpDoctorClient`: `GET /doctor/api/v1/doctor/trends?categories=...&limit=...`.
- Implement in `MockDoctorClient`: return mock trend data.
Completion criteria:
- [x] Both implementations return correct types
- [x] Mock client returns realistic trend data
### 036-T3 - Create DoctorTrendService
Status: DONE
Dependency: 036-T2
Owners: Developer (FE)
Task description:
- Create `core/doctor/doctor-trend.service.ts`, `providedIn: 'root'`.
- Signals: `securityTrend: Signal<number[]>`, `platformTrend: Signal<number[]>`.
- `start()`: fetches trends, sets 60s interval.
- `refresh()`: immediate re-fetch.
- Graceful degradation: clears signals on error.
Completion criteria:
- [x] Service fetches trends on start and every 60s
- [x] Signals update correctly on successful fetch
- [x] Errors clear signals without user-facing errors
### 036-T4 - Create SidebarSparklineComponent
Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
- Create `layout/app-sidebar/sidebar-sparkline.component.ts`.
- Signal input: `points: number[]`.
- Renders 40x16px SVG polyline with amber stroke (`--color-sidebar-sparkline`).
- If `points.length < 2`, renders nothing.
Completion criteria:
- [x] SVG renders correct polyline from data points
- [x] Empty/insufficient data renders nothing
### 036-T5 - Wire sparklines into sidebar
Status: DONE
Dependency: 036-T3, 036-T4
Owners: Developer (FE)
Task description:
- Extend `NavSection` interface in sidebar: add `sparklineData$?: () => number[]`.
- Inject `DoctorTrendService`, wire `sparklineData$` on security and platform sections.
- Call `doctorTrendService.refresh()` on `NavigationEnd`.
- Add `sparklineData` input to `sidebar-nav-group.component.ts`.
- Render sparkline between label and chevron.
Completion criteria:
- [x] Sparklines visible next to Security and Platform nav sections
- [x] Refresh triggers on route navigation
- [x] Sparklines disappear when no data available
### 036-T6 - Register DoctorTrendService in app init
Status: DONE
Dependency: 036-T3
Owners: Developer (FE)
Task description:
- Register `DoctorTrendService.start()` via `provideAppInitializer` in `app.config.ts`.
Completion criteria:
- [x] Service starts automatically on app bootstrap
### 036-T7 - Tests
Status: DONE
Dependency: 036-T4, 036-T3
Owners: Developer (FE)
Task description:
- Create `tests/layout/sidebar-sparkline.component.spec.ts`.
- Create `tests/doctor/doctor-trend.service.spec.ts`.
Completion criteria:
- [x] All tests pass with `npx ng test --watch=false`
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created. | Planning |
| 2026-02-21 | All tasks implemented and verified. Build passes, tests pass. | Developer (FE) |
## Decisions & Risks
- 60s polling interval balances freshness vs. API load; configurable via service constant.
- SVG sparkline chosen over canvas for CSS variable theming support and simplicity.
- Graceful degradation ensures sidebar doesn't break if Doctor API is unavailable.
## Next Checkpoints
- Visual review after 036-T5 implementation.

View File

@@ -0,0 +1,100 @@
# Sprint 037 — Doctor Toast Notifications
## Topic & Scope
- Create DoctorNotificationService for proactive toast notifications from scheduled Doctor runs.
- Add `update()` method to ToastService for in-place toast updates.
- Create barrel export for core doctor services.
- Working directory: `src/Web/StellaOps.Web/`
- Expected evidence: toast appears on new Doctor report with failures, "View Details" navigates to dashboard, mute persists.
## Dependencies & Concurrency
- No upstream sprint dependencies.
- Safe to parallelize with Sprints 034036, 039.
- Sprint 038 (command palette) depends on `toast.update()` from this sprint.
- Shares `app.config.ts` with Sprint 036 (app init registration).
## Documentation Prerequisites
- Toast service: `core/services/toast.service.ts`
- Doctor client: `features/doctor/services/doctor.client.ts`
## Delivery Tracker
### 037-T1 - Add update() to ToastService
Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
- Add `update(id: string, options: Partial<ToastOptions>): void` method to `toast.service.ts`.
- Updates existing toast's content by ID (needed for progress tracking in Sprint 038).
Completion criteria:
- [x] Existing toast can be updated in-place by ID
- [x] Non-existent ID is a no-op
### 037-T2 - Create DoctorNotificationService
Status: DONE
Dependency: 037-T1
Owners: Developer (FE)
Task description:
- Create `core/doctor/doctor-notification.service.ts`, `providedIn: 'root'`.
- Polls `DoctorApi.listReports(1, 0)` every 60s.
- Tracks last-seen report via localStorage key `stellaops_doctor_last_seen_report`.
- On new report with failures/warnings: shows toast with severity icon + counts + "View Details" action.
- "View Details" navigates to `/platform/ops/doctor?runId=<id>`.
- `muted` signal persisted in localStorage `stellaops_doctor_notifications_muted`.
- `start()` delayed by 10s to avoid blocking app startup.
- Silent error handling.
Completion criteria:
- [x] Toast appears when new report has failures/warnings
- [x] "View Details" navigates to correct dashboard URL
- [x] Mute persists across page reloads
- [x] No toast for passing reports
- [x] Silent error handling (no user-facing errors from background polling)
### 037-T3 - Create core/doctor barrel export
Status: DONE
Dependency: 037-T2
Owners: Developer (FE)
Task description:
- Create `core/doctor/index.ts`.
- Export `DoctorTrendService` and `DoctorNotificationService`.
Completion criteria:
- [x] Both services importable via barrel
### 037-T4 - Register DoctorNotificationService in app init
Status: DONE
Dependency: 037-T2
Owners: Developer (FE)
Task description:
- Register `DoctorNotificationService.start()` via `provideAppInitializer` in `app.config.ts`.
- Register alongside `DoctorTrendService.start()` from Sprint 036.
Completion criteria:
- [x] Service starts automatically on app bootstrap (delayed 10s)
### 037-T5 - Tests
Status: DONE
Dependency: 037-T2
Owners: Developer (FE)
Task description:
- Create `tests/doctor/doctor-notification.service.spec.ts`.
- Test polling logic, localStorage tracking, toast generation, muting.
Completion criteria:
- [x] Tests pass with `npx ng test --watch=false`
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created. | Planning |
| 2026-02-21 | All tasks implemented and verified. Build passes, tests pass. | Developer (FE) |
## Decisions & Risks
- 60s polling interval matches DoctorTrendService for consistency.
- 10s startup delay prevents API calls during initial app load.
- localStorage used for last-seen tracking (no server-side state needed).
## Next Checkpoints
- Sprint 038 (command palette) unblocked once 037-T1 is DONE.

View File

@@ -0,0 +1,88 @@
# Sprint 038 — Command Palette Doctor Actions
## Topic & Scope
- Create DoctorQuickCheckService for running Doctor checks from the command palette.
- Add Doctor quick actions to DEFAULT_QUICK_ACTIONS.
- Wire actions into the command palette component.
- Working directory: `src/Web/StellaOps.Web/`
- Expected evidence: Ctrl+K > type ">doctor" shows Doctor actions, triggers runs with progress toast.
## Dependencies & Concurrency
- Depends on Sprint 037 (toast `update()` method for progress tracking).
- Safe to parallelize with Sprints 034036, 039.
## Documentation Prerequisites
- Command palette: `shared/components/command-palette/command-palette.component.ts`
- Quick actions: `core/api/search.models.ts`
## Delivery Tracker
### 038-T1 - Create DoctorQuickCheckService
Status: DONE
Dependency: Sprint 037 (toast update)
Owners: Developer (FE)
Task description:
- Create `features/doctor/services/doctor-quick-check.service.ts`, `providedIn: 'root'`.
- `runQuickCheck()`: calls `DoctorStore.startRun({ mode: 'quick' })`, shows progress toast (duration: 0), on completion shows result toast with "View Details" action.
- `runFullDiagnostics()`: calls `DoctorStore.startRun({ mode: 'full' })`, navigates to `/platform/ops/doctor`.
- `getQuickActions(): QuickAction[]`: returns Doctor-specific actions with bound callbacks.
Completion criteria:
- [x] Quick check triggers run and shows progress toast
- [x] Full diagnostics navigates to Doctor dashboard
- [x] Actions returned with correct keywords
### 038-T2 - Add Doctor actions to DEFAULT_QUICK_ACTIONS
Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
- Add 2 new entries to `DEFAULT_QUICK_ACTIONS` in `search.models.ts`:
- `id: 'doctor-quick'`, label: "Run Quick Health Check", keywords: `['doctor', 'health', 'check', 'quick', 'diagnostic']`.
- `id: 'doctor-full'`, label: "Run Full Diagnostics", keywords: `['doctor', 'diagnostics', 'full', 'comprehensive']`.
- Update existing `id: 'health'` action keywords to include `'doctor'` and `'system'`.
- Refactor `filterQuickActions(query, actions?)` to accept optional actions array parameter.
Completion criteria:
- [x] New actions appear in command mode
- [x] Existing health action includes doctor/system keywords
- [x] `filterQuickActions` accepts optional actions override
### 038-T3 - Wire into command palette
Status: DONE
Dependency: 038-T1, 038-T2
Owners: Developer (FE)
Task description:
- Modify `command-palette.component.ts`.
- Inject `DoctorQuickCheckService`.
- Merge `getQuickActions()` into the actions list during init.
- Pass merged list to `filterQuickActions()`.
Completion criteria:
- [x] Doctor actions appear when typing ">doctor" in command palette
- [x] Selecting "Run Quick Health Check" triggers quick check
- [x] Selecting "Run Full Diagnostics" navigates to Doctor dashboard
### 038-T4 - Tests
Status: DONE
Dependency: 038-T1
Owners: Developer (FE)
Task description:
- Create `tests/doctor/doctor-quick-check.service.spec.ts`.
- Test quick check flow, full diagnostics flow, action generation.
Completion criteria:
- [x] Tests pass with `npx ng test --watch=false`
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created. | Planning |
| 2026-02-21 | All tasks implemented and verified. Build passes, tests pass. | Developer (FE) |
## Decisions & Risks
- Progress toast uses `duration: 0` (stays until manually dismissed or updated).
- `filterQuickActions` refactored to accept optional array to support dynamic action merging.
## Next Checkpoints
- Functional test: Ctrl+K > ">doctor" > select action > verify toast/navigation.

View File

@@ -0,0 +1,120 @@
# Sprint 039 — Setup Wizard Doctor Re-check Integration
## Topic & Scope
- Create Doctor-to-Wizard mapping constants from the setup-wizard-doctor-contract.
- Add "Fix in Setup" button on Doctor check-result components for failed checks with wizard mappings.
- Create DoctorRecheckService for re-running checks after wizard step completion.
- Add deep-link query params to Setup Wizard for direct step navigation.
- Working directory: `src/Web/StellaOps.Web/`
- Expected evidence: "Fix in Setup" button navigates to wizard step, wizard re-check toast after step completion.
## Dependencies & Concurrency
- No upstream sprint dependencies.
- Safe to parallelize with Sprints 034038.
## Documentation Prerequisites
- `docs/setup/setup-wizard-doctor-contract.md`
- Setup wizard models: `features/setup-wizard/models/setup-wizard.models.ts`
- Doctor check-result component: `features/doctor/components/check-result/`
## Delivery Tracker
### 039-T1 - Create Doctor-Wizard mapping constant
Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
- Create `features/doctor/models/doctor-wizard-mapping.ts`.
- Define `DoctorWizardMapping` interface: `{ checkId: string; stepId: SetupStepId; label: string }`.
- Define `DOCTOR_WIZARD_MAPPINGS` constant array mapping check IDs to wizard steps.
- Helper functions:
- `getWizardStepForCheck(checkId): DoctorWizardMapping | undefined`
- `getCheckIdsForStep(stepId): string[]`
- `buildWizardDeepLink(stepId): string` — returns `/setup/wizard?step=<id>&mode=reconfigure`
Completion criteria:
- [x] Mappings cover all check IDs from setup-wizard-doctor-contract
- [x] Helper functions return correct results
### 039-T2 - Add "Fix in Setup" button to check-result
Status: DONE
Dependency: 039-T1
Owners: Developer (FE)
Task description:
- Modify `check-result.component.ts`: add computed `wizardLink` getter using `getWizardStepForCheck()`.
- Add `@Output() fixInSetup = new EventEmitter<string>()`.
- Modify `check-result.component.html`: add "Fix in Setup" button in `.result-actions` div.
- Only shown when `wizardLink` is non-null AND check is failed/warned.
Completion criteria:
- [x] Button visible for failed/warned checks with wizard mappings
- [x] Button hidden for passing checks and unmapped checks
- [x] Click emits fixInSetup event with deep-link URL
### 039-T3 - Wire fixInSetup handler in Doctor dashboard
Status: DONE
Dependency: 039-T2
Owners: Developer (FE)
Task description:
- Modify `doctor-dashboard.component.ts`.
- Inject `Router`.
- Add `onFixInSetup(url: string)` handler that calls `router.navigateByUrl(url)`.
- Bind handler to check-result `(fixInSetup)` output.
Completion criteria:
- [x] Clicking "Fix in Setup" navigates to correct wizard step
### 039-T4 - Create DoctorRecheckService
Status: DONE
Dependency: 039-T1
Owners: Developer (FE)
Task description:
- Create `features/doctor/services/doctor-recheck.service.ts`, `providedIn: 'root'`.
- `recheckForStep(stepId)`: gets check IDs for step, calls `DoctorStore.startRun({ mode: 'quick', checkIds })`, shows progress toast.
- `offerRecheck(stepId, stepName)`: shows success toast "X configured successfully" with "Run Re-check" action button.
Completion criteria:
- [x] Re-check runs only checks mapped to the wizard step
- [x] Success toast offers re-check action
### 039-T5 - Setup Wizard deep-link and re-check integration
Status: DONE
Dependency: 039-T4
Owners: Developer (FE)
Task description:
- Modify `setup-wizard.component.ts`.
- In `ngOnInit()`, read `step` and `mode` query params from `ActivatedRoute`.
- If `mode=reconfigure`, set wizard mode to reconfigure.
- If `step` param present, call `state.goToStep(stepId)`.
- Inject `DoctorRecheckService`.
- After successful step execution in reconfigure mode, call `doctorRecheck.offerRecheck(step.id, step.name)`.
Completion criteria:
- [x] `/setup/wizard?step=database&mode=reconfigure` opens wizard at database step in reconfigure mode
- [x] Successful step completion in reconfigure mode shows re-check toast
### 039-T6 - Tests
Status: DONE
Dependency: 039-T1, 039-T4
Owners: Developer (FE)
Task description:
- Create `tests/doctor/doctor-wizard-mapping.spec.ts`.
- Create `tests/doctor/doctor-recheck.service.spec.ts`.
- Test mapping lookups, deep-link generation, re-check flow.
Completion criteria:
- [x] Tests pass with `npx ng test --watch=false`
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created. | Planning |
| 2026-02-21 | All tasks implemented and verified. Build passes, tests pass. | Developer (FE) |
## Decisions & Risks
- Mappings derived from `docs/setup/setup-wizard-doctor-contract.md`; if contract changes, mappings must be updated.
- "Fix in Setup" button only appears for checks with known wizard step mappings.
- Deep-link `mode=reconfigure` reuses existing wizard reconfigure flow.
## Next Checkpoints
- Functional test: Doctor dashboard > failed check > "Fix in Setup" > wizard step > re-check.

View File

@@ -0,0 +1,158 @@
# Sprint 040 - UI Advisory Gap Closure
## Topic & Scope
- Close the identified UI implementation gaps from the advisory review so canonical IA, scope behavior, and run-centric operations are consistent in code and docs.
- Implement missing runtime contracts for global scope URL sync, degraded/offline UX surfaces, route migration telemetry alignment, and run-detail live refresh.
- Update verification artifacts (docs and targeted tests) for navigation/RBAC/search/telemetry/scope behavior.
- Working directory: `src/Web/StellaOps.Web/` (with required docs updates in `docs/modules/ui/v2-rewire/`).
- Expected evidence: passing targeted frontend tests, updated IA/contracts docs, and migration/verification documentation.
## Dependencies & Concurrency
- Depends on active Pack-22/23 canonical IA references in `docs/modules/ui/v2-rewire/`.
- Safe to run in parallel with unrelated Doctor/platform-health feature work as long as edits stay scoped to files listed in this sprint.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_route_deprecation_map.md`
- `docs/modules/ui/v2-rewire/pack-23.md`
## Delivery Tracker
### 040-T1 - Canonical IA ownership alignment in nav/routes/docs
Status: DONE
Dependency: none
Owners: Developer (FE), Documentation author
Task description:
- Align canonical ownership language and root menu expectations across route comments, sidebar labels, and v2-rewire source-of-truth/authority docs.
- Ensure Administration remains alias-window compatibility, not a conflicting primary operator root in canonical UX framing.
Completion criteria:
- [x] Sidebar and canonical docs use a consistent root-module story
- [x] Route annotations no longer conflict with canonical ownership model
### 040-T2 - RBAC visibility matrix and enforcement for root/major surfaces
Status: DONE
Dependency: 040-T1
Owners: Developer (FE), Documentation author
Task description:
- Add explicit UI RBAC matrix for root modules and key sub-surfaces.
- Apply scope gates to sidebar visibility and major route domains where currently auth-only.
Completion criteria:
- [x] Documented matrix exists in v2-rewire docs
- [x] Route and nav gating reflects the documented matrix
### 040-T3 - Global scope contract and URL synchronization
Status: DONE
Dependency: 040-T1
Owners: Developer (FE)
Task description:
- Define and implement URL-sync behavior for global scope (`regions`, `environments`, `timeWindow`) with deterministic merge semantics.
- Ensure deep links can hydrate scope and context changes persist back into URL without clobbering unrelated params.
Completion criteria:
- [x] Scope state can be hydrated from URL query parameters
- [x] Scope updates write canonical query parameters back to current route
### 040-T4 - Mobile scope controls behavior
Status: DONE
Dependency: 040-T3
Owners: Developer (FE)
Task description:
- Replace “hide scope entirely under 1200px” behavior with an explicit mobile/tablet scope entry point.
- Provide keyboard and screen-reader-friendly mobile interaction for scope controls.
Completion criteria:
- [x] Scope remains operable on mobile/tablet layouts
- [x] Desktop behavior remains unchanged for full scope bar
### 040-T5 - Standard degraded/offline UI state component
Status: DONE
Dependency: 040-T1
Owners: Developer (FE)
Task description:
- Implement shared degraded/offline decision-impact component supporting `BLOCKING`, `DEGRADED`, `INFO`, retry action, correlation ID, and last-known-good context.
- Integrate into at least one high-value run-centric surface.
Completion criteria:
- [x] Shared component exists and is reusable
- [x] Integrated surface shows standardized degraded contract fields
### 040-T6 - Legacy route telemetry alignment and cutover consistency
Status: DONE
Dependency: 040-T1
Owners: Developer (FE), Documentation author
Task description:
- Align legacy-route telemetry mapping with active redirect templates and alias-window routes.
- Remove stale target mappings and codify deterministic mapping behavior.
Completion criteria:
- [x] Telemetry mapping reflects canonical redirect map
- [x] Docs include updated cutover/alias telemetry expectations
### 040-T7 - Wire global search to real search client
Status: DONE
Dependency: 040-T1
Owners: Developer (FE)
Task description:
- Replace mock timeout-based search with API-backed search via existing search client.
- Keep keyboard navigation, grouped results, and recent-search persistence behavior.
Completion criteria:
- [x] Global search issues client-backed queries
- [x] Existing keyboard and selection UX still works
### 040-T8 - Release Run detail live refresh contract
Status: DONE
Dependency: 040-T5
Owners: Developer (FE)
Task description:
- Add run-detail live refresh model (poll cadence, stale/degraded indication, retry/manual refresh) while preserving deterministic rendering and non-destructive fallbacks.
Completion criteria:
- [x] Run detail auto-refreshes while active
- [x] Stale/degraded state is visible with explicit operator action
### 040-T9 - A11y and performance acceptance criteria documentation
Status: DONE
Dependency: 040-T1
Owners: Documentation author
Task description:
- Add explicit acceptance gates for accessibility and performance in v2-rewire docs.
- Define measurable criteria and mandatory checks for shell/search/scope/nav interactions.
Completion criteria:
- [x] A11y/perf gates are documented with pass/fail criteria
- [x] Sprint links to those gates in decisions/risks
### 040-T10 - UI verification plan and targeted tests
Status: DONE
Dependency: 040-T2, 040-T3, 040-T6, 040-T7, 040-T8
Owners: Developer (FE), QA
Task description:
- Update/add targeted unit tests for changed behaviors (nav model, search wiring, telemetry map behavior, context URL sync, run-detail refresh signals where feasible).
- Add UI verification plan doc for deterministic re-check of this sprint scope.
Completion criteria:
- [x] Targeted tests for changed contracts are present and passing
- [x] Verification plan doc captures deterministic execution path
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created and task 040-T1 moved to DOING. | Developer (FE) |
| 2026-02-20 | Implemented canonical IA/RBAC/scope/search/legacy telemetry/run-live contracts; added and updated targeted unit tests for nav, context URL sync, global search, route migration, telemetry, topology routes, and release live refresh. | Developer (FE) |
| 2026-02-20 | Verification run completed: `npm run test -- --watch=false --include src/tests/navigation/legacy-redirects.spec.ts --include src/tests/routes/legacy-route-migration-framework.component.spec.ts --include src/tests/navigation/legacy-route-telemetry.service.spec.ts --include src/tests/context/platform-context-url-sync.service.spec.ts --include src/tests/navigation/nav-model.spec.ts --include src/tests/navigation/nav-route-integrity.spec.ts --include src/tests/global_search/global-search.component.spec.ts --include src/tests/topology/topology-routes.spec.ts --include src/tests/releases/release-detail.live-refresh.spec.ts` (9 files, 55 tests, all pass). | QA/Developer (FE) |
| 2026-02-20 | Updated route deprecation contract docs and migration mappings for run-centric redirects (`/releases/runs`) and topology setup aliases (`/topology/promotion-graph`, `/topology/regions`, `/topology/workflows`). | Documentation author |
## Decisions & Risks
- Cross-module doc edits are required under `docs/modules/ui/v2-rewire/` to keep canonical contracts in sync with FE implementation.
- Work is intentionally layered over an already dirty frontend tree per user direction (“do it on top”); unrelated changes are preserved.
- Risk: route-scope guards can hide pages for low-scope users if matrix assumptions are wrong. Mitigation: keep fallback redirects and add explicit matrix docs plus targeted tests.
- Risk: context URL sync can loop if merge semantics are incorrect. Mitigation: idempotent query diffing and scoped key updates only.
- Decision: legacy redirect telemetry is now derived entirely from `LEGACY_REDIRECT_ROUTE_TEMPLATES`, and template entries were updated to canonical Pack22/23 targets to keep route behavior and telemetry in lockstep.
- Decision: topology operator entry points now deep-link to run-centric release flows (`/releases/runs`) instead of activity/deployment aliases, matching advisory UX language.
## Next Checkpoints
- Sprint 040 delivered; maintain alias telemetry during cutover window and remove obsolete alias routes in the planned cutover sprint after hit-rate review.

View File

@@ -1,4 +1,4 @@
# S00 Route Deprecation Map (Pack 22 Canonical) # S00 Route Deprecation Map (Pack 22/23 Canonical)
Status: Active Status: Active
Date: 2026-02-20 Date: 2026-02-20
@@ -7,14 +7,14 @@ Canonical source: `source-of-truth.md`, `pack-22.md`
## Purpose ## Purpose
Define deterministic route migration from pre-Pack22 root families to Pack22 canonical IA: Define deterministic route migration from pre-Pack22 root families to Pack22/23 canonical IA:
- `/dashboard` - `/dashboard` (Mission Control)
- `/releases` (run-centric subroots under `/releases/versions*` and `/releases/runs*`) - `/releases` (run-centric subroots under `/releases/versions*` and `/releases/runs*`)
- `/security` (workspace subroots under `/security/overview`, `/security/triage`, `/security/advisories-vex`, `/security/supply-chain-data/*`) - `/security` (workspace subroots under `/security/posture`, `/security/triage`, `/security/disposition`, `/security/sbom/*`, `/security/reachability`)
- `/evidence` (capsule-first subroots under `/evidence/overview`, `/evidence/capsules`, `/evidence/exports/export`, `/evidence/verification/*`) - `/evidence` (capsule-first subroots under `/evidence/capsules`, `/evidence/exports`, `/evidence/verification/*`, `/evidence/audit-log`)
- `/topology` - `/topology`
- `/platform` (setup/ops/integrations canonical root; legacy `/operations`, `/integrations`, `/administration` are alias-window routes) - `/platform` (ops/integrations/setup canonical root; legacy `/operations`, `/integrations`, `/administration` are alias-window routes)
## Action definitions ## Action definitions
@@ -35,7 +35,7 @@ Define deterministic route migration from pre-Pack22 root families to Pack22 can
| `/operations/*` (old ops shell) | `/platform/ops/*` | `redirect` + `alias-window` | | `/operations/*` (old ops shell) | `/platform/ops/*` | `redirect` + `alias-window` |
| `/integrations/*` (legacy root) | `/platform/integrations/*` | `redirect` + `alias-window` | | `/integrations/*` (legacy root) | `/platform/integrations/*` | `redirect` + `alias-window` |
| `/administration/*` (legacy root) | `/platform/setup/*` | `redirect` + `alias-window` | | `/administration/*` (legacy root) | `/platform/setup/*` | `redirect` + `alias-window` |
| `/settings/release-control/*` | `/topology/*` | `redirect` | | `/settings/release-control/*` | `/topology/promotion-graph`, `/topology/regions`, `/topology/targets`, `/topology/agents`, `/topology/workflows` | `redirect` |
## Release Control decomposition ## Release Control decomposition
@@ -49,29 +49,40 @@ Define deterministic route migration from pre-Pack22 root families to Pack22 can
| `/release-control/promotions` | `/releases/runs` | `redirect` | | `/release-control/promotions` | `/releases/runs` | `redirect` |
| `/release-control/hotfixes` | `/releases/hotfix` | `redirect` | | `/release-control/hotfixes` | `/releases/hotfix` | `redirect` |
| `/release-control/regions` | `/topology/regions` | `redirect` | | `/release-control/regions` | `/topology/regions` | `redirect` |
| `/release-control/setup` | `/platform/setup` | `redirect` | | `/release-control/setup` | `/topology/promotion-graph` | `redirect` |
| `/release-control/setup/environments-paths` | `/topology/environments` | `redirect` | | `/release-control/setup/environments-paths` | `/topology/promotion-graph` | `redirect` |
| `/release-control/setup/targets-agents` | `/topology/targets` | `redirect` | | `/release-control/setup/targets-agents` | `/topology/targets` | `redirect` |
| `/release-control/setup/workflows` | `/platform/setup/workflows-gates` | `redirect` | | `/release-control/setup/workflows` | `/topology/workflows` | `redirect` |
## Settings alias decomposition
| Legacy path | Canonical target | Action |
| --- | --- | --- |
| `/settings/release-control` | `/topology/promotion-graph` | `redirect` |
| `/settings/release-control/environments` | `/topology/regions` | `redirect` |
| `/settings/release-control/targets` | `/topology/targets` | `redirect` |
| `/settings/release-control/agents` | `/topology/agents` | `redirect` |
| `/settings/release-control/workflows` | `/topology/workflows` | `redirect` |
## Security consolidation ## Security consolidation
| Legacy path | Canonical target | Action | | Legacy path | Canonical target | Action |
| --- | --- | --- | | --- | --- | --- |
| `/security-risk` | `/security/overview` | `redirect` | | `/security-risk` | `/security/posture` | `redirect` |
| `/security-risk/findings*` | `/security/triage*` | `redirect` | | `/security-risk/findings*` | `/security/triage*` | `redirect` |
| `/security-risk/vulnerabilities*` | `/security/triage*` | `redirect` | | `/security-risk/vulnerabilities*` | `/security/triage*` | `redirect` |
| `/security-risk/vex` | `/security/advisories-vex` | `redirect` | | `/security-risk/vex` | `/security/disposition` | `redirect` |
| `/security-risk/exceptions` | `/security/advisories-vex` | `redirect` | | `/security-risk/exceptions` | `/security/disposition` | `redirect` |
| `/security-risk/sbom` | `/security/supply-chain-data/graph` | `redirect` | | `/security-risk/sbom` | `/security/sbom/graph` | `redirect` |
| `/security-risk/sbom-lake` | `/security/supply-chain-data/lake` | `redirect` | | `/security-risk/sbom-lake` | `/security/sbom/lake` | `redirect` |
| `/security-risk/advisory-sources` | `/platform/integrations/feeds` | `redirect` | | `/security-risk/advisory-sources` | `/platform/integrations/feeds` | `redirect` |
| `/sbom-sources` | `/platform/integrations/sbom-sources` | `redirect` |
## Evidence and Operations renames ## Evidence and Operations renames
| Legacy path | Canonical target | Action | | Legacy path | Canonical target | Action |
| --- | --- | --- | | --- | --- | --- |
| `/evidence-audit` | `/evidence/overview` | `redirect` | | `/evidence-audit` | `/evidence/capsules` | `redirect` |
| `/evidence-audit/packs*` | `/evidence/capsules*` | `redirect` | | `/evidence-audit/packs*` | `/evidence/capsules*` | `redirect` |
| `/evidence-audit/audit-log` | `/evidence/audit-log` | `redirect` | | `/evidence-audit/audit-log` | `/evidence/audit-log` | `redirect` |
| `/evidence-audit/replay` | `/evidence/verification/replay` | `redirect` | | `/evidence-audit/replay` | `/evidence/verification/replay` | `redirect` |
@@ -86,6 +97,7 @@ Define deterministic route migration from pre-Pack22 root families to Pack22 can
- `oldPath`, - `oldPath`,
- `newPath`, - `newPath`,
- tenant/user context metadata. - tenant/user context metadata.
- Legacy detection and expected target resolution are derived from `LEGACY_REDIRECT_ROUTE_TEMPLATES` to prevent drift between redirect behavior and telemetry mapping.
- Alias telemetry must remain active until Pack22 cutover approval. - Alias telemetry must remain active until Pack22 cutover approval.
## Cutover checkpoint ## Cutover checkpoint

View File

@@ -0,0 +1,47 @@
# S01 Accessibility and Performance Acceptance Gates
Status: Active
Date: 2026-02-20
Working directory: `docs/modules/ui/v2-rewire`
## Purpose
Define mandatory pass/fail gates for the navigation shell, global scope controls, search, and run-centric release surfaces.
## Accessibility gates (must pass)
| Area | Gate | Pass criteria |
| --- | --- | --- |
| Keyboard entry points | `Ctrl+K` opens search, `Escape` closes active search/scope overlays | Works from any authenticated shell page without focus traps. |
| Scope controls | Region, Environment, Time Window controls are keyboard-operable on desktop and tablet/mobile | Scope panel is reachable via topbar `Scope` button and supports `Tab`, `Enter`, `Space`, `Escape`. |
| Focus visibility | Shell controls have visible focus treatment | Focus ring contrast ratio >= 3:1 against adjacent background. |
| Nav semantics | Sidebar and topbar expose valid navigation landmarks | Screen readers announce main nav and scope dialog labels correctly. |
| Status/degraded messaging | Degraded state banner is announced and actionable | Impact (`BLOCKING`, `DEGRADED`, `INFO`) and retry action are readable by assistive tech. |
## Performance gates (must pass)
| Area | Gate | Pass criteria |
| --- | --- | --- |
| Shell route transitions | Canonical root navigation (`/dashboard`, `/releases`, `/security`, `/evidence`, `/topology`, `/platform`) | Route-to-render under 500ms median in local CI profile build. |
| Search interaction | Debounced global search | Input-to-result update <= 300ms median for cached responses and <= 800ms for uncached responses. |
| Scope URL sync | Context change URL patching | No duplicate navigations/loops; one URL update per scope mutation. |
| Run detail live refresh | Active run polling cadence | Poll interval 15s with no overlapping requests; terminal runs stop polling. |
| Mobile shell | Scope panel render | Scope panel opens in <= 200ms and does not trigger layout overflow at <= 1199px width. |
## Required checks per sprint close
1. Run unit tests covering updated contracts:
- `src/tests/global_search/global-search.component.spec.ts`
- `src/tests/context/platform-context-url-sync.service.spec.ts`
- `src/tests/navigation/legacy-route-telemetry.service.spec.ts`
- `src/tests/releases/release-detail.live-refresh.spec.ts`
2. Run route integrity checks:
- `src/tests/navigation/nav-model.spec.ts`
- `src/tests/navigation/nav-route-integrity.spec.ts`
- `src/tests/navigation/legacy-redirects.spec.ts`
3. Execute one manual keyboard walkthrough on desktop and <= 1199px layout for:
- Scope controls
- Global search
- Run detail degraded banner retry action
If any gate fails, sprint closure remains `BLOCKED` until evidence of fix is logged in `docs/implplan/SPRINT_*.md`.

View File

@@ -0,0 +1,46 @@
# S02 UI Verification Plan - Sprint 040
Status: Active
Date: 2026-02-20
Working directory: `src/Web/StellaOps.Web/`
## Scope under verification
- Canonical root IA labels and route ownership (Mission Control + 5 domain roots)
- RBAC-gated visibility for root and major alias-window surfaces
- Global scope URL synchronization (`regions`, `environments`, `timeWindow`)
- Mobile/tablet scope control entry point
- Legacy route telemetry alignment with redirect templates
- Global search API wiring
- Release run-detail live refresh + degraded-state contract
## Deterministic verification sequence
1. Unit and route tests:
- `src/tests/navigation/nav-model.spec.ts`
- `src/tests/navigation/nav-route-integrity.spec.ts`
- `src/tests/navigation/legacy-redirects.spec.ts`
- `src/tests/navigation/legacy-route-telemetry.service.spec.ts`
- `src/tests/context/platform-context-url-sync.service.spec.ts`
- `src/tests/global_search/global-search.component.spec.ts`
- `src/tests/releases/release-detail.live-refresh.spec.ts`
2. Manual route checks:
- `/dashboard` renders Mission Control label in sidebar and breadcrumb.
- `/security/posture`, `/security/disposition`, `/security/sbom/lake`, `/security/reachability` resolve without alias errors.
- `/topology/promotion-graph` is canonical; `/topology/promotion-paths` redirects.
3. Alias telemetry checks:
- Navigate to `/ops/health`, `/security-risk/sbom-lake`, `/release-control/setup`.
- Confirm a `legacy_route_hit` event is emitted with expected `oldPath` and resolved `newPath`.
4. Scope synchronization checks:
- Open `/security/posture?regions=us-east&environments=prod&timeWindow=7d`; verify context hydrates.
- Change scope selectors; verify URL query updates without losing unrelated query keys.
5. Run live-refresh checks:
- Open active run detail (`/releases/runs/:runId/timeline`) and verify periodic refresh status transitions (`LIVE` -> `SYNCING`).
- Simulate backend failure and verify degraded banner shows retry + correlation ID.
- Verify terminal run status stops polling.
## Evidence capture requirements
- Record test pass/fail and command outputs in sprint execution log.
- Include failing scenario notes for any non-deterministic behavior or flaky assertions.
- If a route alias is intentionally preserved, document the retention reason and next removal checkpoint.

View File

@@ -9,7 +9,7 @@ This matrix defines which pack is authoritative for each capability and which pa
| Capability area | Authoritative pack(s) | Superseded packs | Notes | | Capability area | Authoritative pack(s) | Superseded packs | Notes |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Global IA and naming | `pack-23.md`, `pack-22.md` | `pack-21.md` and lower for overlaps | Canonical roots are Dashboard, Releases, Security, Evidence, Topology, Platform, Administration. | | Global IA and naming | `pack-23.md`, `pack-22.md` | `pack-21.md` and lower for overlaps | Canonical roots are Mission Control, Releases, Security, Evidence, Topology, Platform. |
| Dashboard mission control | `pack-22.md`, `pack-16.md` | `pack-01.md`, `pack-04.md`, `pack-08.md`, `pack-11.md` | Pack 22 defines posture framing; Pack 16 keeps detailed signal cards where unchanged. | | Dashboard mission control | `pack-22.md`, `pack-16.md` | `pack-01.md`, `pack-04.md`, `pack-08.md`, `pack-11.md` | Pack 22 defines posture framing; Pack 16 keeps detailed signal cards where unchanged. |
| Releases lifecycle consolidation | `pack-22.md`, `pack-12.md`, `pack-13.md`, `pack-14.md`, `pack-17.md` | Standalone lifecycle module variants in older packs | Runs/deployments/promotions/hotfixes are views under Releases, not roots. | | Releases lifecycle consolidation | `pack-22.md`, `pack-12.md`, `pack-13.md`, `pack-14.md`, `pack-17.md` | Standalone lifecycle module variants in older packs | Runs/deployments/promotions/hotfixes are views under Releases, not roots. |
| Topology inventory and setup | `pack-22.md`, `pack-18.md` | Prior placements under Release Control and Platform Ops | Regions/env/targets/hosts/agents/workflows/gate profiles belong to Topology. | | Topology inventory and setup | `pack-22.md`, `pack-18.md` | Prior placements under Release Control and Platform Ops | Regions/env/targets/hosts/agents/workflows/gate profiles belong to Topology. |
@@ -17,17 +17,17 @@ This matrix defines which pack is authoritative for each capability and which pa
| Evidence and audit chain | `pack-22.md`, `pack-20.md` | `pack-03.md`, `pack-09.md`, `pack-11.md` | Evidence must be linked from Releases and Security decisions. | | Evidence and audit chain | `pack-22.md`, `pack-20.md` | `pack-03.md`, `pack-09.md`, `pack-11.md` | Evidence must be linked from Releases and Security decisions. |
| Operations runtime posture | `pack-23.md`, `pack-15.md`, `pack-10.md` | `pack-03.md`, `pack-06.md`, `pack-09.md`, `pack-11.md` | Ops runs under Platform and owns runtime operability state; agents stay in Topology. | | Operations runtime posture | `pack-23.md`, `pack-15.md`, `pack-10.md` | `pack-03.md`, `pack-06.md`, `pack-09.md`, `pack-11.md` | Ops runs under Platform and owns runtime operability state; agents stay in Topology. |
| Integrations configuration | `pack-23.md`, `pack-10.md`, `pack-21.md` | `pack-02.md`, `pack-05.md`, `pack-09.md` | Integrations runs under Platform and is limited to external systems/connectors. | | Integrations configuration | `pack-23.md`, `pack-10.md`, `pack-21.md` | `pack-02.md`, `pack-05.md`, `pack-09.md` | Integrations runs under Platform and is limited to external systems/connectors. |
| Administration governance | `pack-22.md`, `pack-21.md` | `pack-02.md`, `pack-05.md`, `pack-09.md`, `pack-11.md` | Identity/tenant/notification/usage/policy/system remain Administration-owned. | | Administration governance | `pack-22.md`, `pack-21.md` | `pack-02.md`, `pack-05.md`, `pack-09.md`, `pack-11.md` | Identity/tenant/notification/usage/policy/system remain admin-owned under `Platform -> Setup`. |
## B) Explicit higher-pack overrides ## B) Explicit higher-pack overrides
| Decision | Replaced guidance | Canonical guidance | | Decision | Replaced guidance | Canonical guidance |
| --- | --- | --- | | --- | --- | --- |
| Root domain naming | `Release Control`, `Security & Risk`, `Evidence & Audit`, `Platform Ops` roots | `Releases`, `Security`, `Evidence`, `Platform`, plus `Topology` root (`pack-23.md`) | | Root domain naming | `Dashboard`, `Release Control`, `Security & Risk`, `Evidence & Audit`, `Platform Ops`, top-level `Administration` | `Mission Control`, `Releases`, `Security`, `Evidence`, `Topology`, `Platform` (`pack-23.md`) |
| Bundle naming | Bundle-first labels in packs 12/21 | UI term is `Release`; bundle semantics remain in data model (`pack-22.md`) | | Bundle naming | Bundle-first labels in packs 12/21 | UI term is `Release Version`; bundle semantics remain in data model (`pack-22.md`) |
| Lifecycle menu sprawl | Standalone Promotions, Deployments, Runs, Hotfixes menus | Lifecycle surfaces live under `Releases` list/detail/activity/approvals (`pack-22.md`) | | Lifecycle menu sprawl | Standalone Promotions, Deployments, Runs, Hotfixes menus | Lifecycle surfaces live under `Releases` list/detail/activity/approvals (`pack-22.md`) |
| Region/environment nav placement | Deep menu under release-control variants | Global context selectors + Topology inventory pages (`pack-22.md`) | | Region/environment nav placement | Deep menu under release-control variants | Global context selectors + Topology inventory pages (`pack-22.md`) |
| Security navigation split | Separate VEX, Exceptions, SBOM Graph, SBOM Lake menus | Consolidated `Disposition` and `SBOM Explorer` surfaces (`pack-22.md`) | | Security navigation split | Separate VEX, Exceptions, SBOM Graph, SBOM Lake menus | Consolidated `Disposition Center` and `SBOM` surfaces (`pack-22.md`) |
| Feed and VEX source setup placement | Security-owned advisory sources setup variants | Integrations-owned feed/source configuration (`pack-22.md`) | | Feed and VEX source setup placement | Security-owned advisory sources setup variants | Integrations-owned feed/source configuration (`pack-22.md`) |
| Agent module placement | Platform Ops ownership variants | `Topology -> Agents` (`pack-22.md`) | | Agent module placement | Platform Ops ownership variants | `Topology -> Agents` (`pack-22.md`) |
@@ -66,3 +66,15 @@ For sprint planning, use raw packs only through this sequence:
1. Find capability in Section A. 1. Find capability in Section A.
2. Start with listed authoritative pack(s). 2. Start with listed authoritative pack(s).
3. Open superseded packs only for migration context or missing implementation detail. 3. Open superseded packs only for migration context or missing implementation detail.
## E) UI RBAC visibility matrix
| Surface | Primary scope gate (`any`) | Fallback/notes |
| --- | --- | --- |
| Mission Control root | `ui.read`, `release:read`, `scanner:read`, `sbom:read` | Redirect unauthorized users to `/console/profile`. |
| Releases root | `release:read`, `release:write`, `release:publish` | Approvals queue additionally expects approval/governance scopes. |
| Security root | `scanner:read`, `sbom:read`, `advisory:read`, `vex:read`, `exception:read`, `findings:read`, `vuln:view` | Disposition and SBOM tabs remain visible only when parent root is visible. |
| Evidence root | `release:read`, `policy:audit`, `authority:audit.read`, `signer:read`, `vex:export` | Trust mutation routes stay under `Platform -> Setup`. |
| Topology root | `release:read`, `orch:read`, `orch:operate`, `ui.admin` | Includes regions/env, targets/runtimes, and agent fleet. |
| Platform root | `ui.admin`, `orch:read`, `orch:operate`, `health:read`, `notify.viewer` | Covers ops, integrations, and setup/admin surfaces. |
| Legacy alias roots (`/operations`, `/integrations`, `/administration`, `/platform-ops`) | Same gate as Platform root | Alias-window only; tracked by `legacy_route_hit` telemetry. |

View File

@@ -22,13 +22,12 @@ Working directory: `docs/modules/ui/v2-rewire`
Canonical top-level modules are: Canonical top-level modules are:
- `Dashboard` - `Mission Control`
- `Releases` - `Releases`
- `Security` - `Security`
- `Evidence` - `Evidence`
- `Topology` - `Topology`
- `Platform` - `Platform`
- `Administration`
### 2.2 Global context ### 2.2 Global context
@@ -49,16 +48,15 @@ These are authoritative for planning and replace older conflicting placements:
- `Release Control` root is decomposed: - `Release Control` root is decomposed:
- release lifecycle surfaces move to `Releases`, - release lifecycle surfaces move to `Releases`,
- inventory/setup surfaces move to `Topology`. - inventory/setup surfaces move to `Topology`.
- `Bundle` is deprecated in operator IA and renamed to `Release`. - `Bundle` is deprecated in operator IA and renamed to `Release Version`.
- `Runs`, `Deployments`, `Promotions`, and `Hotfixes` are lifecycle views inside `Releases` and not top-level modules. - `Runs`, `Deployments`, `Promotions`, and `Hotfixes` are lifecycle views inside `Releases` and not top-level modules.
- `VEX` and `Exceptions` are exposed as one UX concept: - `VEX` and `Exceptions` remain distinct data models, but are exposed in one operator workspace:
- `Security -> Triage` disposition rail + detail tabs, - `Security -> Disposition Center` tabs (`VEX Statements`, `Exceptions`, `Expiring`),
- `Security -> Advisories & VEX` for provider/library/conflict/trust operations, - feeds/source configuration lives in `Platform -> Integrations -> Feeds`.
- backend data models remain distinct. - SBOM Graph/Lake are one `Security -> SBOM` workspace with mode tabs.
- SBOM, reachability, and unknowns are unified under `Security -> Supply-Chain Data` tabs. - Reachability is a first-class surface under `Security -> Reachability`.
- Advisory feed and VEX source configuration belongs to `Integrations`, not Security. - `Policy Governance` remains administration-owned under `Platform -> Setup`.
- `Policy Governance` remains under `Administration`. - Trust posture is visible in `Evidence`, while signing/trust mutation stays in `Platform -> Setup -> Trust & Signing`.
- Trust posture must be reachable from `Evidence`, while admin-owner trust mutations remain governed by administration scopes.
## 3) Canonical screen authorities ## 3) Canonical screen authorities
@@ -76,7 +74,7 @@ Superseded for overlapping decisions:
- `pack-21.md` and lower packs for root module grouping and naming. - `pack-21.md` and lower packs for root module grouping and naming.
### 3.2 Dashboard ### 3.2 Mission Control
Authoritative packs: Authoritative packs:
@@ -108,7 +106,7 @@ Authoritative packs:
Authoritative packs: Authoritative packs:
- `pack-22.md` for consolidation into `Overview`, `Triage`, `Advisories & VEX`, `Supply-Chain Data`, and optional `Reports`. - `pack-22.md` for consolidation into `Posture`, `Triage`, `SBOM`, `Reachability`, `Disposition Center`, and `Reports`.
- `pack-19.md` for decision-first security detail behavior where not overridden. - `pack-19.md` for decision-first security detail behavior where not overridden.
Superseded: Superseded:
@@ -137,26 +135,27 @@ Authoritative packs:
- `pack-23.md` for Platform Integrations placement and topology ownership split. - `pack-23.md` for Platform Integrations placement and topology ownership split.
- `pack-10.md` and `pack-21.md` for connector detail flows where not overridden. - `pack-10.md` and `pack-21.md` for connector detail flows where not overridden.
### 3.9 Administration ### 3.9 Platform Administration
Authoritative packs: Authoritative packs:
- `pack-22.md` for top-level scope. - `pack-22.md` for governance scope.
- `pack-21.md` for detailed A0-A7 screen structure where not overridden. - `pack-21.md` for detailed A0-A7 screen structure where not overridden.
## 4) Normalized terminology (canonical names) ## 4) Normalized terminology (canonical names)
Use these terms in sprint tickets/specs: Use these terms in sprint tickets/specs:
- `Bundle` -> `Release` - `Bundle` -> `Release Version`
- `Create Bundle` -> `Create Release` - `Create Bundle` -> `Create Release Version`
- `Current Release` -> `Deploy Release` - `Current Release` -> `Deploy/Promote`
- `Run Timeline` -> `Activity` (cross-release) or `Timeline` (release detail tab) - `Run/Timeline/Pipeline` -> `Release Run`
- `Security & Risk` -> `Security` - `Security & Risk` -> `Security`
- `Evidence & Audit` -> `Evidence` - `Evidence & Audit` -> `Evidence`
- `Evidence Pack/Bundle` -> `Decision Capsule`
- `Platform Ops` -> `Platform -> Ops` - `Platform Ops` -> `Platform -> Ops`
- `Integrations` root -> `Platform -> Integrations` - `Integrations` root -> `Platform -> Integrations` (alias-window only at `/integrations`)
- `Setup` root -> `Platform -> Setup` - `Setup` root -> `Platform -> Setup` (includes administration-owned setup/governance)
- `Regions & Environments` menu -> `Topology` module + global context switchers - `Regions & Environments` menu -> `Topology` module + global context switchers
## 5) Planning gaps to schedule first ## 5) Planning gaps to schedule first

View File

@@ -23,6 +23,7 @@ import { AppShellComponent } from './layout/app-shell/app-shell.component';
import { BrandingService } from './core/branding/branding.service'; import { BrandingService } from './core/branding/branding.service';
import { LegacyRouteTelemetryService } from './core/guards/legacy-route-telemetry.service'; import { LegacyRouteTelemetryService } from './core/guards/legacy-route-telemetry.service';
import { LegacyUrlBannerComponent } from './shared/ui/legacy-url-banner/legacy-url-banner.component'; import { LegacyUrlBannerComponent } from './shared/ui/legacy-url-banner/legacy-url-banner.component';
import { PlatformContextUrlSyncService } from './core/context/platform-context-url-sync.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -59,6 +60,7 @@ export class AppComponent {
private readonly consoleStore = inject(ConsoleSessionStore); private readonly consoleStore = inject(ConsoleSessionStore);
private readonly brandingService = inject(BrandingService); private readonly brandingService = inject(BrandingService);
private readonly legacyRouteTelemetry = inject(LegacyRouteTelemetryService); private readonly legacyRouteTelemetry = inject(LegacyRouteTelemetryService);
private readonly contextUrlSync = inject(PlatformContextUrlSyncService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -90,6 +92,9 @@ export class AppComponent {
// Initialize legacy route telemetry tracking (ROUTE-002) // Initialize legacy route telemetry tracking (ROUTE-002)
this.legacyRouteTelemetry.initialize(); this.legacyRouteTelemetry.initialize();
// Keep global scope in sync with route query parameters.
this.contextUrlSync.initialize();
} }
readonly isAuthenticated = this.sessionStore.isAuthenticated; readonly isAuthenticated = this.sessionStore.isAuthenticated;

View File

@@ -34,6 +34,8 @@ import { VULNERABILITY_API_BASE_URL, VulnerabilityHttpClient } from './core/api/
import { RISK_API, MockRiskApi } from './core/api/risk.client'; import { RISK_API, MockRiskApi } from './core/api/risk.client';
import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client'; import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client';
import { AppConfigService } from './core/config/app-config.service'; import { AppConfigService } from './core/config/app-config.service';
import { DoctorTrendService } from './core/doctor/doctor-trend.service';
import { DoctorNotificationService } from './core/doctor/doctor-notification.service';
import { BackendProbeService } from './core/config/backend-probe.service'; import { BackendProbeService } from './core/config/backend-probe.service';
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor'; import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
import { AuthSessionStore } from './core/auth/auth-session.store'; import { AuthSessionStore } from './core/auth/auth-session.store';
@@ -962,5 +964,13 @@ export const appConfig: ApplicationConfig = {
}, },
AocHttpClient, AocHttpClient,
{ provide: AOC_API, useExisting: AocHttpClient }, { provide: AOC_API, useExisting: AocHttpClient },
// Doctor background services
provideAppInitializer(() => {
inject(DoctorTrendService).start();
}),
provideAppInitializer(() => {
inject(DoctorNotificationService).start();
}),
], ],
}; };

View File

@@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
import { import {
requireAuthGuard, requireAuthGuard,
requireAnyScopeGuard,
requireOrchViewerGuard, requireOrchViewerGuard,
requireOrchOperatorGuard, requireOrchOperatorGuard,
requirePolicyAuthorGuard, requirePolicyAuthorGuard,
@@ -11,26 +12,92 @@ import {
requirePolicyReviewOrApproveGuard, requirePolicyReviewOrApproveGuard,
requirePolicyViewerGuard, requirePolicyViewerGuard,
requireAnalyticsViewerGuard, requireAnalyticsViewerGuard,
StellaOpsScopes,
} from './core/auth'; } from './core/auth';
import { requireConfigGuard } from './core/config/config.guard'; import { requireConfigGuard } from './core/config/config.guard';
import { requireBackendsReachableGuard } from './core/config/backends-reachable.guard'; import { requireBackendsReachableGuard } from './core/config/backends-reachable.guard';
import { LEGACY_REDIRECT_ROUTES } from './routes/legacy-redirects.routes'; import { LEGACY_REDIRECT_ROUTES } from './routes/legacy-redirects.routes';
const requireMissionControlGuard = requireAnyScopeGuard(
[
StellaOpsScopes.UI_READ,
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.SCANNER_READ,
StellaOpsScopes.SBOM_READ,
],
'/console/profile',
);
const requireReleasesGuard = requireAnyScopeGuard(
[
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.RELEASE_WRITE,
StellaOpsScopes.RELEASE_PUBLISH,
],
'/console/profile',
);
const requireSecurityGuard = requireAnyScopeGuard(
[
StellaOpsScopes.SCANNER_READ,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.ADVISORY_READ,
StellaOpsScopes.VEX_READ,
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.FINDINGS_READ,
StellaOpsScopes.VULN_VIEW,
],
'/console/profile',
);
const requireEvidenceGuard = requireAnyScopeGuard(
[
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.AUTHORITY_AUDIT_READ,
StellaOpsScopes.SIGNER_READ,
StellaOpsScopes.VEX_EXPORT,
],
'/console/profile',
);
const requireTopologyGuard = requireAnyScopeGuard(
[
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.UI_ADMIN,
],
'/console/profile',
);
const requirePlatformGuard = requireAnyScopeGuard(
[
StellaOpsScopes.UI_ADMIN,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.HEALTH_READ,
StellaOpsScopes.NOTIFY_VIEWER,
StellaOpsScopes.ORCH_OPERATE,
],
'/console/profile',
);
export const routes: Routes = [ export const routes: Routes = [
// ======================================================================== // ========================================================================
// V2 CANONICAL DOMAIN ROUTES (SPRINT_20260218_006) // V2 CANONICAL DOMAIN ROUTES
// Seven root domains per S00 spec freeze (docs/modules/ui/v2-rewire/source-of-truth.md). // Canonical operator roots per source-of-truth:
// Old v1 routes redirect to these canonical paths via V1_ALIAS_REDIRECT_ROUTES below. // Mission Control, Releases, Security, Evidence, Topology, Platform.
// Legacy roots (/operations, /integrations, /administration, etc.) remain alias-window routes.
// ======================================================================== // ========================================================================
// Domain 1: Dashboard (formerly Control Plane) // Domain 1: Mission Control (path remains /dashboard)
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
title: 'Dashboard', title: 'Mission Control',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireMissionControlGuard],
data: { breadcrumb: 'Dashboard' }, data: { breadcrumb: 'Mission Control' },
loadChildren: () => loadChildren: () =>
import('./routes/dashboard.routes').then( import('./routes/dashboard.routes').then(
(m) => m.DASHBOARD_ROUTES (m) => m.DASHBOARD_ROUTES
@@ -38,9 +105,9 @@ export const routes: Routes = [
}, },
{ {
path: 'dashboard', path: 'dashboard',
title: 'Dashboard', title: 'Mission Control',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireMissionControlGuard],
data: { breadcrumb: 'Dashboard' }, data: { breadcrumb: 'Mission Control' },
loadChildren: () => loadChildren: () =>
import('./routes/dashboard.routes').then( import('./routes/dashboard.routes').then(
(m) => m.DASHBOARD_ROUTES (m) => m.DASHBOARD_ROUTES
@@ -56,7 +123,7 @@ export const routes: Routes = [
{ {
path: 'releases', path: 'releases',
title: 'Releases', title: 'Releases',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireReleasesGuard],
data: { breadcrumb: 'Releases' }, data: { breadcrumb: 'Releases' },
loadChildren: () => loadChildren: () =>
import('./routes/releases.routes').then( import('./routes/releases.routes').then(
@@ -68,7 +135,7 @@ export const routes: Routes = [
{ {
path: 'security', path: 'security',
title: 'Security', title: 'Security',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireSecurityGuard],
data: { breadcrumb: 'Security' }, data: { breadcrumb: 'Security' },
loadChildren: () => loadChildren: () =>
import('./routes/security.routes').then( import('./routes/security.routes').then(
@@ -80,7 +147,7 @@ export const routes: Routes = [
{ {
path: 'evidence', path: 'evidence',
title: 'Evidence', title: 'Evidence',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireEvidenceGuard],
data: { breadcrumb: 'Evidence' }, data: { breadcrumb: 'Evidence' },
loadChildren: () => loadChildren: () =>
import('./routes/evidence.routes').then( import('./routes/evidence.routes').then(
@@ -88,14 +155,11 @@ export const routes: Routes = [
), ),
}, },
// Domain 5: Integrations (already canonical — kept as-is) // Domain 5: Topology
// /integrations already loaded below; no path change for this domain.
// Domain 6: Topology
{ {
path: 'topology', path: 'topology',
title: 'Topology', title: 'Topology',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireTopologyGuard],
data: { breadcrumb: 'Topology' }, data: { breadcrumb: 'Topology' },
loadChildren: () => loadChildren: () =>
import('./routes/topology.routes').then( import('./routes/topology.routes').then(
@@ -103,11 +167,11 @@ export const routes: Routes = [
), ),
}, },
// Domain 7: Platform // Domain 6: Platform
{ {
path: 'platform', path: 'platform',
title: 'Platform', title: 'Platform',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard],
data: { breadcrumb: 'Platform' }, data: { breadcrumb: 'Platform' },
loadChildren: () => loadChildren: () =>
import('./routes/platform.routes').then( import('./routes/platform.routes').then(
@@ -115,18 +179,18 @@ export const routes: Routes = [
), ),
}, },
// Domain 8: Administration (legacy root retained as alias to Platform Setup) // Legacy root alias: Administration
{ {
path: 'administration', path: 'administration',
pathMatch: 'full', pathMatch: 'full',
redirectTo: '/platform/setup', redirectTo: '/platform/setup',
}, },
// Domain 9: Operations (legacy alias root retained for migration window) // Legacy root alias: Operations
{ {
path: 'operations', path: 'operations',
title: 'Operations', title: 'Operations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard],
data: { breadcrumb: 'Operations' }, data: { breadcrumb: 'Operations' },
loadChildren: () => loadChildren: () =>
import('./routes/operations.routes').then( import('./routes/operations.routes').then(
@@ -134,11 +198,11 @@ export const routes: Routes = [
), ),
}, },
// Domain 10: Administration deep-link compatibility surface // Legacy deep-link compatibility surface: Administration
{ {
path: 'administration', path: 'administration',
title: 'Administration', title: 'Administration',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard],
data: { breadcrumb: 'Administration' }, data: { breadcrumb: 'Administration' },
loadChildren: () => loadChildren: () =>
import('./routes/administration.routes').then( import('./routes/administration.routes').then(
@@ -173,7 +237,7 @@ export const routes: Routes = [
{ {
path: 'deployments', path: 'deployments',
pathMatch: 'full', pathMatch: 'full',
redirectTo: '/releases/activity', redirectTo: '/releases/runs',
}, },
// Legacy Security alias // Legacy Security alias
@@ -203,7 +267,7 @@ export const routes: Routes = [
{ {
path: 'platform-ops', path: 'platform-ops',
title: 'Operations', title: 'Operations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard],
data: { breadcrumb: 'Operations' }, data: { breadcrumb: 'Operations' },
loadChildren: () => loadChildren: () =>
import('./routes/operations.routes').then( import('./routes/operations.routes').then(
@@ -222,12 +286,12 @@ export const routes: Routes = [
{ {
path: 'settings/release-control', path: 'settings/release-control',
pathMatch: 'full', pathMatch: 'full',
redirectTo: '/topology', redirectTo: '/topology/promotion-graph',
}, },
{ {
path: 'settings/release-control/environments', path: 'settings/release-control/environments',
pathMatch: 'full', pathMatch: 'full',
redirectTo: '/topology/environments', redirectTo: '/topology/regions',
}, },
{ {
path: 'settings/release-control/targets', path: 'settings/release-control/targets',
@@ -750,7 +814,7 @@ export const routes: Routes = [
// Integration Hub (SPRINT_20251229_011_FE_integration_hub_ui) // Integration Hub (SPRINT_20251229_011_FE_integration_hub_ui)
{ {
path: 'integrations', path: 'integrations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard],
loadChildren: () => loadChildren: () =>
import('./features/integration-hub/integration-hub.routes').then((m) => m.integrationHubRoutes), import('./features/integration-hub/integration-hub.routes').then((m) => m.integrationHubRoutes),
}, },

View File

@@ -117,7 +117,7 @@ export class SearchClient {
type: 'cve' as SearchEntityType, type: 'cve' as SearchEntityType,
title: item.id, title: item.id,
subtitle: item.description?.substring(0, 100), subtitle: item.description?.substring(0, 100),
route: `/vulnerabilities/${item.id}`, route: `/security/triage?cve=${encodeURIComponent(item.id)}`,
severity: item.severity?.toLowerCase() as SearchResult['severity'], severity: item.severity?.toLowerCase() as SearchResult['severity'],
matchScore: 100, matchScore: 100,
})) }))
@@ -139,7 +139,7 @@ export class SearchClient {
type: 'artifact' as SearchEntityType, type: 'artifact' as SearchEntityType,
title: `${item.repository}:${item.tag}`, title: `${item.repository}:${item.tag}`,
subtitle: item.digest.substring(0, 16), subtitle: item.digest.substring(0, 16),
route: `/triage/artifacts/${encodeURIComponent(item.digest)}`, route: `/security/triage?artifact=${encodeURIComponent(item.digest)}`,
matchScore: 100, matchScore: 100,
})) }))
), ),
@@ -182,7 +182,7 @@ export class SearchClient {
title: `job-${item.id.substring(0, 8)}`, title: `job-${item.id.substring(0, 8)}`,
subtitle: `${item.type} (${item.status})`, subtitle: `${item.type} (${item.status})`,
description: item.artifactRef, description: item.artifactRef,
route: `/platform-ops/orchestrator/jobs/${item.id}`, route: `/platform/ops/orchestrator/jobs/${item.id}`,
matchScore: 100, matchScore: 100,
})) }))
), ),
@@ -237,7 +237,7 @@ export class SearchClient {
type: 'vex' as SearchEntityType, type: 'vex' as SearchEntityType,
title: item.cveId, title: item.cveId,
subtitle: `${item.status} - ${item.product}`, subtitle: `${item.status} - ${item.product}`,
route: `/admin/vex-hub/${item.id}`, route: `/security/disposition?statementId=${encodeURIComponent(item.id)}`,
matchScore: 100, matchScore: 100,
})) }))
), ),
@@ -259,7 +259,7 @@ export class SearchClient {
type: 'integration' as SearchEntityType, type: 'integration' as SearchEntityType,
title: item.name, title: item.name,
subtitle: `${item.type} (${item.status})`, subtitle: `${item.type} (${item.status})`,
route: `/integrations/${item.id}`, route: `/platform/integrations/${item.id}`,
matchScore: 100, matchScore: 100,
})) }))
), ),

View File

@@ -127,7 +127,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>jobs', shortcut: '>jobs',
description: 'Navigate to job list', description: 'Navigate to job list',
icon: 'workflow', icon: 'workflow',
route: '/platform-ops/orchestrator/jobs', route: '/platform/ops/jobs-queues',
keywords: ['jobs', 'orchestrator', 'list'], keywords: ['jobs', 'orchestrator', 'list'],
}, },
{ {
@@ -145,7 +145,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>settings', shortcut: '>settings',
description: 'Navigate to settings', description: 'Navigate to settings',
icon: 'settings', icon: 'settings',
route: '/console/profile', route: '/platform/setup',
keywords: ['settings', 'config', 'preferences'], keywords: ['settings', 'config', 'preferences'],
}, },
{ {
@@ -154,8 +154,24 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>health', shortcut: '>health',
description: 'View platform health status', description: 'View platform health status',
icon: 'heart-pulse', icon: 'heart-pulse',
route: '/ops/health', route: '/platform/ops/system-health',
keywords: ['health', 'status', 'platform', 'ops'], keywords: ['health', 'status', 'platform', 'ops', 'doctor', 'system'],
},
{
id: 'doctor-quick',
label: 'Run Quick Health Check',
shortcut: '>doctor',
description: 'Run a quick Doctor diagnostics check',
icon: 'activity',
keywords: ['doctor', 'health', 'check', 'quick', 'diagnostic'],
},
{
id: 'doctor-full',
label: 'Run Full Diagnostics',
shortcut: '>diagnostics',
description: 'Run comprehensive Doctor diagnostics',
icon: 'search',
keywords: ['doctor', 'diagnostics', 'full', 'comprehensive'],
}, },
{ {
id: 'integrations', id: 'integrations',
@@ -163,16 +179,17 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>integrations', shortcut: '>integrations',
description: 'View and manage integrations', description: 'View and manage integrations',
icon: 'plug', icon: 'plug',
route: '/integrations', route: '/platform/integrations',
keywords: ['integrations', 'connect', 'manage'], keywords: ['integrations', 'connect', 'manage'],
}, },
]; ];
export function filterQuickActions(query: string): QuickAction[] { export function filterQuickActions(query: string, actions?: QuickAction[]): QuickAction[] {
const list = actions ?? DEFAULT_QUICK_ACTIONS;
const normalizedQuery = query.toLowerCase().replace(/^>/, '').trim(); const normalizedQuery = query.toLowerCase().replace(/^>/, '').trim();
if (!normalizedQuery) return DEFAULT_QUICK_ACTIONS; if (!normalizedQuery) return list;
return DEFAULT_QUICK_ACTIONS.filter((action) => return list.filter((action) =>
action.keywords.some((kw) => kw.includes(normalizedQuery)) || action.keywords.some((kw) => kw.includes(normalizedQuery)) ||
action.label.toLowerCase().includes(normalizedQuery) || action.label.toLowerCase().includes(normalizedQuery) ||
action.shortcut.toLowerCase().includes(normalizedQuery) action.shortcut.toLowerCase().includes(normalizedQuery)

View File

@@ -14,16 +14,20 @@ export class GlobalContextHttpInterceptor implements HttpInterceptor {
} }
let params = request.params; let params = request.params;
const region = this.context.selectedRegions()[0]; const regions = this.context.selectedRegions();
const environment = this.context.selectedEnvironments()[0]; const environments = this.context.selectedEnvironments();
const timeWindow = this.context.timeWindow(); const timeWindow = this.context.timeWindow();
if (region && !params.has('region')) { if (regions.length > 0 && !params.has('regions') && !params.has('region')) {
params = params.set('region', region); params = params.set('regions', regions.join(','));
params = params.set('region', regions[0]);
} }
if (environment && !params.has('environment')) {
params = params.set('environment', environment); if (environments.length > 0 && !params.has('environments') && !params.has('environment')) {
params = params.set('environments', environments.join(','));
params = params.set('environment', environments[0]);
} }
if (timeWindow && !params.has('timeWindow')) { if (timeWindow && !params.has('timeWindow')) {
params = params.set('timeWindow', timeWindow); params = params.set('timeWindow', timeWindow);
} }
@@ -37,6 +41,7 @@ export class GlobalContextHttpInterceptor implements HttpInterceptor {
url.includes('/api/v2/security') || url.includes('/api/v2/security') ||
url.includes('/api/v2/evidence') || url.includes('/api/v2/evidence') ||
url.includes('/api/v2/topology') || url.includes('/api/v2/topology') ||
url.includes('/api/v2/platform') ||
url.includes('/api/v2/integrations') url.includes('/api/v2/integrations')
); );
} }

View File

@@ -0,0 +1,161 @@
import { DestroyRef, Injectable, Injector, effect, inject } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { PlatformContextStore } from './platform-context.store';
@Injectable({ providedIn: 'root' })
export class PlatformContextUrlSyncService {
private readonly router = inject(Router);
private readonly context = inject(PlatformContextStore);
private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector);
private initialized = false;
private syncingFromUrl = false;
private syncingToUrl = false;
initialize(): void {
if (this.initialized) {
return;
}
this.initialized = true;
this.context.initialize();
this.applyScopeFromUrl();
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(() => this.applyScopeFromUrl());
effect(
() => {
this.context.contextVersion();
if (!this.context.initialized() || this.syncingFromUrl) {
return;
}
const currentUrl = this.router.url;
if (!this.isScopeManagedPath(currentUrl)) {
return;
}
const currentTree = this.router.parseUrl(currentUrl);
const nextQuery = { ...currentTree.queryParams };
const patch = this.context.scopeQueryPatch();
this.applyPatch(nextQuery, patch);
if (this.queryEquals(currentTree.queryParams, nextQuery) || this.syncingToUrl) {
return;
}
this.syncingToUrl = true;
void this.router.navigate([], {
queryParams: nextQuery,
replaceUrl: true,
}).finally(() => {
this.syncingToUrl = false;
});
},
{ injector: this.injector },
);
}
private applyScopeFromUrl(): void {
if (this.syncingToUrl) {
return;
}
const currentUrl = this.router.url;
if (!this.isScopeManagedPath(currentUrl)) {
return;
}
const currentTree = this.router.parseUrl(currentUrl);
this.syncingFromUrl = true;
try {
this.context.applyScopeQueryParams(currentTree.queryParams as Record<string, unknown>);
} finally {
this.syncingFromUrl = false;
}
}
private applyPatch(
target: Record<string, unknown>,
patch: Record<string, string | null>,
): void {
for (const [key, value] of Object.entries(patch)) {
if (value === null || value.trim().length === 0) {
delete target[key];
} else {
target[key] = value;
}
}
}
private queryEquals(
left: Record<string, unknown>,
right: Record<string, unknown>,
): boolean {
return JSON.stringify(this.normalizeQuery(left)) === JSON.stringify(this.normalizeQuery(right));
}
private normalizeQuery(query: Record<string, unknown>): Record<string, string[]> {
const normalized: Record<string, string[]> = {};
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) {
continue;
}
if (Array.isArray(value)) {
normalized[key] = value
.map((entry) => String(entry ?? '').trim())
.filter((entry) => entry.length > 0)
.sort((a, b) => a.localeCompare(b));
continue;
}
const serialized = String(value).trim();
if (serialized.length > 0) {
normalized[key] = [serialized];
}
}
const sortedKeys = Object.keys(normalized).sort((a, b) => a.localeCompare(b));
const ordered: Record<string, string[]> = {};
for (const key of sortedKeys) {
ordered[key] = normalized[key];
}
return ordered;
}
private isScopeManagedPath(url: string): boolean {
const path = url.split('?')[0].toLowerCase();
if (
path.startsWith('/setup')
|| path.startsWith('/auth/')
|| path.startsWith('/welcome')
|| path.startsWith('/console/')
) {
return false;
}
return (
path === '/'
|| path.startsWith('/dashboard')
|| path.startsWith('/releases')
|| path.startsWith('/security')
|| path.startsWith('/evidence')
|| path.startsWith('/topology')
|| path.startsWith('/platform')
|| path.startsWith('/operations')
|| path.startsWith('/integrations')
|| path.startsWith('/administration')
);
}
}

View File

@@ -29,12 +29,22 @@ export interface PlatformContextPreferences {
} }
const DEFAULT_TIME_WINDOW = '24h'; const DEFAULT_TIME_WINDOW = '24h';
const REGION_QUERY_KEYS = ['regions', 'region'];
const ENVIRONMENT_QUERY_KEYS = ['environments', 'environment', 'env'];
const TIME_WINDOW_QUERY_KEYS = ['timeWindow', 'time'];
interface PlatformContextQueryState {
regions: string[];
environments: string[];
timeWindow: string;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlatformContextStore { export class PlatformContextStore {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private persistPaused = false; private persistPaused = false;
private readonly apiDisabled = this.shouldDisableApiCalls(); private readonly apiDisabled = this.shouldDisableApiCalls();
private readonly initialQueryOverride = this.readScopeQueryFromLocation();
readonly regions = signal<PlatformContextRegion[]>([]); readonly regions = signal<PlatformContextRegion[]>([]);
readonly environments = signal<PlatformContextEnvironment[]>([]); readonly environments = signal<PlatformContextEnvironment[]>([]);
@@ -152,26 +162,104 @@ export class PlatformContextStore {
this.bumpContextVersion(); this.bumpContextVersion();
} }
scopeQueryPatch(): Record<string, string | null> {
const regions = this.selectedRegions();
const environments = this.selectedEnvironments();
const timeWindow = this.timeWindow();
return {
regions: regions.length > 0 ? regions.join(',') : null,
environments: environments.length > 0 ? environments.join(',') : null,
timeWindow: timeWindow !== DEFAULT_TIME_WINDOW ? timeWindow : null,
};
}
applyScopeQueryParams(queryParams: Record<string, unknown>): void {
if (!this.initialized()) {
return;
}
const queryState = this.parseScopeQueryState(queryParams);
if (!queryState) {
return;
}
const allowedRegions = this.regions().map((item) => item.regionId);
const nextRegions = this.normalizeIds(queryState.regions, allowedRegions);
const nextTimeWindow = queryState.timeWindow || DEFAULT_TIME_WINDOW;
const regionsChanged = !this.arraysEqual(nextRegions, this.selectedRegions());
const timeChanged = nextTimeWindow !== this.timeWindow();
const preferredEnvironmentIds = queryState.environments.length > 0
? queryState.environments
: this.selectedEnvironments();
if (regionsChanged) {
this.selectedRegions.set(nextRegions);
this.timeWindow.set(nextTimeWindow);
this.loadEnvironments(nextRegions, preferredEnvironmentIds, true);
return;
}
if (queryState.environments.length > 0) {
const nextEnvironments = this.normalizeIds(
queryState.environments,
this.environments().map((item) => item.environmentId),
);
const environmentsChanged = !this.arraysEqual(nextEnvironments, this.selectedEnvironments());
if (environmentsChanged) {
this.selectedEnvironments.set(nextEnvironments);
}
if (timeChanged || environmentsChanged) {
this.timeWindow.set(nextTimeWindow);
this.persistPreferences();
this.bumpContextVersion();
}
return;
}
if (timeChanged) {
this.timeWindow.set(nextTimeWindow);
this.persistPreferences();
this.bumpContextVersion();
}
}
private loadPreferences(): void { private loadPreferences(): void {
this.http this.http
.get<PlatformContextPreferences>('/api/v2/context/preferences') .get<PlatformContextPreferences>('/api/v2/context/preferences')
.pipe(take(1)) .pipe(take(1))
.subscribe({ .subscribe({
next: (prefs) => { next: (prefs) => {
const preferenceState: PlatformContextQueryState = {
regions: prefs?.regions ?? [],
environments: prefs?.environments ?? [],
timeWindow: (prefs?.timeWindow ?? DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW,
};
const hydrated = this.mergeWithInitialQueryOverride(preferenceState);
const preferredRegions = this.normalizeIds( const preferredRegions = this.normalizeIds(
prefs?.regions ?? [], hydrated.regions,
this.regions().map((item) => item.regionId), this.regions().map((item) => item.regionId),
); );
this.selectedRegions.set(preferredRegions); this.selectedRegions.set(preferredRegions);
this.timeWindow.set((prefs?.timeWindow ?? DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW); this.timeWindow.set(hydrated.timeWindow);
this.loadEnvironments(preferredRegions, prefs?.environments ?? [], false); this.loadEnvironments(preferredRegions, hydrated.environments, false);
}, },
error: () => { error: () => {
// Preferences are optional; continue with default empty context. // Preferences are optional; continue with default empty context.
this.selectedRegions.set([]); const fallbackState = this.mergeWithInitialQueryOverride({
regions: [],
environments: [],
timeWindow: DEFAULT_TIME_WINDOW,
});
const preferredRegions = this.normalizeIds(
fallbackState.regions,
this.regions().map((item) => item.regionId),
);
this.selectedRegions.set(preferredRegions);
this.selectedEnvironments.set([]); this.selectedEnvironments.set([]);
this.timeWindow.set(DEFAULT_TIME_WINDOW); this.timeWindow.set(fallbackState.timeWindow);
this.loadEnvironments([], [], false); this.loadEnvironments(preferredRegions, fallbackState.environments, false);
}, },
}); });
} }
@@ -257,6 +345,119 @@ export class PlatformContextStore {
this.persistPaused = false; this.persistPaused = false;
} }
private mergeWithInitialQueryOverride(baseState: PlatformContextQueryState): PlatformContextQueryState {
const override = this.initialQueryOverride;
if (!override) {
return baseState;
}
return {
regions: override.regions.length > 0 ? override.regions : baseState.regions,
environments: override.environments.length > 0 ? override.environments : baseState.environments,
timeWindow: override.timeWindow || baseState.timeWindow,
};
}
private readScopeQueryFromLocation(): PlatformContextQueryState | null {
const location = (globalThis as { location?: { search?: string } }).location;
if (!location?.search) {
return null;
}
const params = new URLSearchParams(location.search);
const toRecord: Record<string, string | string[]> = {};
for (const [key, value] of params.entries()) {
if (toRecord[key] === undefined) {
toRecord[key] = value;
continue;
}
const existing = toRecord[key];
toRecord[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
}
return this.parseScopeQueryState(toRecord);
}
private parseScopeQueryState(queryParams: Record<string, unknown>): PlatformContextQueryState | null {
const regions = this.readQueryList(queryParams, REGION_QUERY_KEYS);
const environments = this.readQueryList(queryParams, ENVIRONMENT_QUERY_KEYS);
const timeWindow = this.readQueryValue(queryParams, TIME_WINDOW_QUERY_KEYS);
if (regions.length === 0 && environments.length === 0 && !timeWindow) {
return null;
}
return {
regions,
environments,
timeWindow: (timeWindow || DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW,
};
}
private readQueryList(queryParams: Record<string, unknown>, keys: readonly string[]): string[] {
const values: string[] = [];
for (const key of keys) {
const raw = queryParams[key];
if (raw === undefined || raw === null) {
continue;
}
if (Array.isArray(raw)) {
for (const value of raw) {
const text = String(value ?? '').trim();
if (!text) {
continue;
}
values.push(...text.split(',').map((token) => token.trim()).filter(Boolean));
}
continue;
}
const text = String(raw).trim();
if (!text) {
continue;
}
values.push(...text.split(',').map((token) => token.trim()).filter(Boolean));
}
const seen = new Set<string>();
const normalized: string[] = [];
for (const value of values) {
const lower = value.toLowerCase();
if (!seen.has(lower)) {
seen.add(lower);
normalized.push(lower);
}
}
return normalized;
}
private readQueryValue(queryParams: Record<string, unknown>, keys: readonly string[]): string | null {
for (const key of keys) {
const raw = queryParams[key];
if (raw === undefined || raw === null) {
continue;
}
if (Array.isArray(raw)) {
const first = raw.find((value) => String(value ?? '').trim().length > 0);
if (first !== undefined) {
return String(first).trim();
}
continue;
}
const value = String(raw).trim();
if (value.length > 0) {
return value;
}
}
return null;
}
private normalizeIds(values: string[], allowedValues: string[]): string[] { private normalizeIds(values: string[], allowedValues: string[]): string[] {
const allowed = new Set(allowedValues.map((value) => value.toLowerCase())); const allowed = new Set(allowedValues.map((value) => value.toLowerCase()));
const deduped = new Map<string, string>(); const deduped = new Map<string, string>();

View File

@@ -0,0 +1,117 @@
import { Injectable, inject, signal, DestroyRef } from '@angular/core';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DOCTOR_API, DoctorApi } from '../../features/doctor/services/doctor.client';
import { ToastService } from '../services/toast.service';
const LAST_SEEN_KEY = 'stellaops_doctor_last_seen_report';
const MUTED_KEY = 'stellaops_doctor_notifications_muted';
/**
* Proactive toast notification service for scheduled Doctor runs.
* Polls for new reports and shows toast when failures/warnings found.
*/
@Injectable({ providedIn: 'root' })
export class DoctorNotificationService {
private readonly api = inject<DoctorApi>(DOCTOR_API);
private readonly toast = inject(ToastService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private intervalId: ReturnType<typeof setInterval> | null = null;
/** Whether notifications are muted. Persisted in localStorage. */
readonly muted = signal(this.loadMutedState());
/** Start polling with 10s initial delay, then every 60s. */
start(): void {
setTimeout(() => {
this.checkForNewReports();
this.intervalId = setInterval(() => this.checkForNewReports(), 60000);
}, 10000);
}
/** Toggle mute state. */
toggleMute(): void {
const newState = !this.muted();
this.muted.set(newState);
try {
localStorage.setItem(MUTED_KEY, JSON.stringify(newState));
} catch {
// localStorage unavailable
}
}
private checkForNewReports(): void {
if (this.muted()) return;
this.api.listReports(1, 0)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
const reports = response.reports ?? [];
if (reports.length === 0) return;
const latest = reports[0];
const lastSeen = this.getLastSeenReportId();
if (latest.runId === lastSeen) return;
this.setLastSeenReportId(latest.runId);
// Only show toast for reports with failures or warnings
const summary = latest.summary;
if (!summary) return;
if (summary.failed === 0 && summary.warnings === 0) return;
const severity = summary.failed > 0 ? 'error' : 'warning';
const counts = [];
if (summary.failed > 0) counts.push(`${summary.failed} failed`);
if (summary.warnings > 0) counts.push(`${summary.warnings} warnings`);
this.toast.show({
type: severity === 'error' ? 'error' : 'warning',
title: 'Doctor Run Complete',
message: counts.join(', '),
duration: 10000,
action: {
label: 'View Details',
onClick: () => {
this.router.navigate(['/platform/ops/doctor'], {
queryParams: { runId: latest.runId },
});
},
},
});
},
error: () => {
// Silent — background service should not show errors
},
});
}
private getLastSeenReportId(): string | null {
try {
return localStorage.getItem(LAST_SEEN_KEY);
} catch {
return null;
}
}
private setLastSeenReportId(runId: string): void {
try {
localStorage.setItem(LAST_SEEN_KEY, runId);
} catch {
// localStorage unavailable
}
}
private loadMutedState(): boolean {
try {
return JSON.parse(localStorage.getItem(MUTED_KEY) ?? 'false');
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,9 @@
export interface DoctorTrendPoint {
timestamp: string;
score: number;
}
export interface DoctorTrendResponse {
category: string;
points: DoctorTrendPoint[];
}

View File

@@ -0,0 +1,56 @@
import { Injectable, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DOCTOR_API, DoctorApi } from '../../features/doctor/services/doctor.client';
import { DoctorTrendResponse } from './doctor-trend.models';
/**
* Service for fetching Doctor health trend data.
* Provides signals for sparkline rendering in the sidebar.
*/
@Injectable({ providedIn: 'root' })
export class DoctorTrendService {
private readonly api = inject<DoctorApi>(DOCTOR_API);
private readonly destroyRef = inject(DestroyRef);
private intervalId: ReturnType<typeof setInterval> | null = null;
/** Last 12 trend scores for the security category. */
readonly securityTrend = signal<number[]>([]);
/** Last 12 trend scores for the platform category. */
readonly platformTrend = signal<number[]>([]);
/** Start periodic trend fetching (60s interval). */
start(): void {
this.fetchTrends();
this.intervalId = setInterval(() => this.fetchTrends(), 60000);
}
/** Force immediate re-fetch. */
refresh(): void {
this.fetchTrends();
}
private fetchTrends(): void {
this.api.getTrends?.(['security', 'platform'], 12)
?.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (responses: DoctorTrendResponse[]) => {
for (const response of responses) {
const points = response.points.map((p) => p.score);
if (response.category === 'security') {
this.securityTrend.set(points);
} else if (response.category === 'platform') {
this.platformTrend.set(points);
}
}
},
error: () => {
// Graceful degradation: clear signals so sparklines disappear
this.securityTrend.set([]);
this.platformTrend.set([]);
},
});
}
}

View File

@@ -0,0 +1,3 @@
export * from './doctor-trend.models';
export * from './doctor-trend.service';
export * from './doctor-notification.service';

View File

@@ -1,199 +1,55 @@
/** /**
* Legacy Route Telemetry Service * Legacy Route Telemetry Service
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (ROUTE-002)
* *
* Tracks usage of legacy routes to inform migration adoption and deprecation timeline. * Tracks usage of legacy routes during the alias window by resolving legacy
* Listens to router events and detects when navigation originated from a legacy redirect. * hits against the canonical redirect templates in `legacy-redirects.routes.ts`.
*/ */
import { Injectable, inject, DestroyRef, signal } from '@angular/core'; import { Injectable, inject, DestroyRef, signal } from '@angular/core';
import { Router, NavigationEnd, NavigationStart, RoutesRecognized } from '@angular/router'; import { Router, NavigationEnd, NavigationStart } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter, pairwise, map } from 'rxjs'; import { filter } from 'rxjs';
import { TelemetryClient } from '../telemetry/telemetry.client'; import { TelemetryClient } from '../telemetry/telemetry.client';
import { AUTH_SERVICE, AuthService } from '../auth/auth.service'; import { AUTH_SERVICE, AuthService } from '../auth/auth.service';
import { LEGACY_REDIRECT_ROUTE_TEMPLATES } from '../../routes/legacy-redirects.routes';
/** interface CompiledLegacyRouteTemplate {
* Map of legacy route patterns to their new canonical paths. sourcePath: string;
* Used to detect when a route was accessed via legacy URL. targetTemplate: string;
*/ regex: RegExp;
const LEGACY_ROUTE_MAP: Record<string, string> = { paramNames: string[];
// Pack 22 root migration aliases }
'release-control': '/releases',
'release-control/releases': '/releases',
'release-control/approvals': '/releases/approvals',
'release-control/runs': '/releases/activity',
'release-control/deployments': '/releases/activity',
'release-control/promotions': '/releases/activity',
'release-control/hotfixes': '/releases',
'release-control/regions': '/topology/regions',
'release-control/setup': '/topology',
'security-risk': '/security', interface PendingLegacyRoute {
'security-risk/findings': '/security/findings', oldPath: string;
'security-risk/vulnerabilities': '/security/vulnerabilities', expectedNewPath: string;
'security-risk/disposition': '/security/disposition', }
'security-risk/sbom': '/security/sbom-explorer/graph',
'security-risk/sbom-lake': '/security/sbom-explorer/table',
'security-risk/vex': '/security/disposition',
'security-risk/exceptions': '/security/disposition',
'security-risk/advisory-sources': '/integrations/feeds',
'evidence-audit': '/evidence', const COMPILED_LEGACY_ROUTE_TEMPLATES: readonly CompiledLegacyRouteTemplate[] = [...LEGACY_REDIRECT_ROUTE_TEMPLATES]
'evidence-audit/packs': '/evidence/packs', .sort((left, right) => right.path.length - left.path.length)
'evidence-audit/bundles': '/evidence/bundles', .map((template) => {
'evidence-audit/evidence': '/evidence/evidence', const paramNames: string[] = [];
'evidence-audit/proofs': '/evidence/proofs', const sourcePath = template.path.replace(/^\/+/, '').replace(/\/+$/, '');
'evidence-audit/audit-log': '/evidence/audit-log', const regexPattern = sourcePath
'evidence-audit/replay': '/evidence/replay', .split('/')
.map((segment) => {
if (segment.startsWith(':')) {
const name = segment.slice(1);
paramNames.push(name);
return `(?<${name}>[^/]+)`;
}
return escapeRegex(segment);
})
.join('/');
'platform-ops': '/operations', return {
'platform-ops/data-integrity': '/operations/data-integrity', sourcePath,
'platform-ops/orchestrator': '/operations/orchestrator', targetTemplate: template.redirectTo,
'platform-ops/health': '/operations/health', regex: new RegExp(`^${regexPattern}$`),
'platform-ops/quotas': '/operations/quotas', paramNames,
'platform-ops/feeds': '/operations/feeds', };
'platform-ops/offline-kit': '/operations/offline-kit', });
'platform-ops/agents': '/topology/agents',
// Home & Dashboard
'dashboard/sources': '/operations/feeds',
'home': '/',
// Analyze -> Security
'findings': '/security/findings',
'vulnerabilities': '/security/vulnerabilities',
'graph': '/security/sbom/graph',
'lineage': '/security/lineage',
'reachability': '/security/reachability',
'analyze/unknowns': '/security/unknowns',
'analyze/patch-map': '/security/patch-map',
// Triage -> Security + Policy
'triage/artifacts': '/security/artifacts',
'triage/audit-bundles': '/evidence',
'exceptions': '/policy/exceptions',
'risk': '/security/risk',
// Policy Studio -> Policy
'policy-studio/packs': '/policy/packs',
// VEX Hub -> Security
'admin/vex-hub': '/security/vex',
// Orchestrator -> Operations
'orchestrator': '/operations/orchestrator',
// Ops -> Operations
'ops/quotas': '/operations/quotas',
'ops/orchestrator/dead-letter': '/operations/dead-letter',
'ops/orchestrator/slo': '/operations/slo',
'ops/health': '/operations/health',
'ops/feeds': '/operations/feeds',
'ops/offline-kit': '/operations/offline-kit',
'ops/aoc': '/operations/aoc',
'ops/doctor': '/operations/doctor',
// Console -> Settings
'console/profile': '/settings/profile',
'console/status': '/operations/status',
'console/configuration': '/settings/integrations',
'console/admin/tenants': '/settings/admin/tenants',
'console/admin/users': '/settings/admin/users',
'console/admin/roles': '/settings/admin/roles',
'console/admin/clients': '/settings/admin/clients',
'console/admin/tokens': '/settings/admin/tokens',
'console/admin/branding': '/settings/admin/branding',
// Admin -> Settings
'admin/trust': '/settings/trust',
'admin/registries': '/settings/integrations/registries',
'admin/issuers': '/settings/trust/issuers',
'admin/notifications': '/settings/notifications',
'admin/audit': '/evidence/audit',
'admin/policy/governance': '/policy/governance',
'concelier/trivy-db-settings': '/settings/security-data/trivy',
// Integrations -> Settings
'integrations': '/settings/integrations',
'sbom-sources': '/settings/sbom-sources',
// Release Orchestrator -> Root
'release-orchestrator': '/',
'release-orchestrator/environments': '/environments',
'release-orchestrator/releases': '/releases',
'release-orchestrator/approvals': '/approvals',
'release-orchestrator/deployments': '/deployments',
'release-orchestrator/workflows': '/settings/workflows',
'release-orchestrator/evidence': '/evidence',
// Evidence
'evidence-packs': '/evidence/packs',
// Other
'ai-runs': '/operations/ai-runs',
'change-trace': '/evidence/change-trace',
'notify': '/operations/notifications',
};
/**
* Patterns for parameterized legacy routes.
* These use regex to match dynamic segments.
*/
const LEGACY_ROUTE_PATTERNS: Array<{ pattern: RegExp; oldPrefix: string; newPrefix: string }> = [
{ pattern: /^release-control\/releases\/([^/]+)$/, oldPrefix: 'release-control/releases/', newPrefix: '/releases/' },
{ pattern: /^release-control\/approvals\/([^/]+)$/, oldPrefix: 'release-control/approvals/', newPrefix: '/releases/approvals/' },
{ pattern: /^security-risk\/findings\/([^/]+)$/, oldPrefix: 'security-risk/findings/', newPrefix: '/security/findings/' },
{ pattern: /^security-risk\/vulnerabilities\/([^/]+)$/, oldPrefix: 'security-risk/vulnerabilities/', newPrefix: '/security/vulnerabilities/' },
{ pattern: /^evidence-audit\/packs\/([^/]+)$/, oldPrefix: 'evidence-audit/packs/', newPrefix: '/evidence/packs/' },
// Scan/finding details
{ pattern: /^findings\/([^/]+)$/, oldPrefix: 'findings/', newPrefix: '/security/scans/' },
{ pattern: /^scans\/([^/]+)$/, oldPrefix: 'scans/', newPrefix: '/security/scans/' },
{ pattern: /^vulnerabilities\/([^/]+)$/, oldPrefix: 'vulnerabilities/', newPrefix: '/security/vulnerabilities/' },
// Lineage with params
{ pattern: /^lineage\/([^/]+)\/compare$/, oldPrefix: 'lineage/', newPrefix: '/security/lineage/' },
{ pattern: /^compare\/([^/]+)$/, oldPrefix: 'compare/', newPrefix: '/security/lineage/compare/' },
// CVSS receipts
{ pattern: /^cvss\/receipts\/([^/]+)$/, oldPrefix: 'cvss/receipts/', newPrefix: '/evidence/receipts/cvss/' },
// Triage artifacts
{ pattern: /^triage\/artifacts\/([^/]+)$/, oldPrefix: 'triage/artifacts/', newPrefix: '/security/artifacts/' },
{ pattern: /^exceptions\/([^/]+)$/, oldPrefix: 'exceptions/', newPrefix: '/policy/exceptions/' },
// Policy packs
{ pattern: /^policy-studio\/packs\/([^/]+)/, oldPrefix: 'policy-studio/packs/', newPrefix: '/policy/packs/' },
// VEX Hub
{ pattern: /^admin\/vex-hub\/search\/detail\/([^/]+)$/, oldPrefix: 'admin/vex-hub/search/detail/', newPrefix: '/security/vex/search/detail/' },
{ pattern: /^admin\/vex-hub\/([^/]+)$/, oldPrefix: 'admin/vex-hub/', newPrefix: '/security/vex/' },
// Operations with page params
{ pattern: /^orchestrator\/([^/]+)$/, oldPrefix: 'orchestrator/', newPrefix: '/operations/orchestrator/' },
{ pattern: /^scheduler\/([^/]+)$/, oldPrefix: 'scheduler/', newPrefix: '/operations/scheduler/' },
{ pattern: /^ops\/quotas\/([^/]+)$/, oldPrefix: 'ops/quotas/', newPrefix: '/operations/quotas/' },
{ pattern: /^ops\/feeds\/([^/]+)$/, oldPrefix: 'ops/feeds/', newPrefix: '/operations/feeds/' },
// Console admin pages
{ pattern: /^console\/admin\/([^/]+)$/, oldPrefix: 'console/admin/', newPrefix: '/settings/admin/' },
// Admin trust pages
{ pattern: /^admin\/trust\/([^/]+)$/, oldPrefix: 'admin/trust/', newPrefix: '/settings/trust/' },
// Integrations
{ pattern: /^integrations\/activity$/, oldPrefix: 'integrations/activity', newPrefix: '/settings/integrations/activity' },
{ pattern: /^integrations\/([^/]+)$/, oldPrefix: 'integrations/', newPrefix: '/settings/integrations/' },
// Evidence packs
{ pattern: /^evidence-packs\/([^/]+)$/, oldPrefix: 'evidence-packs/', newPrefix: '/evidence/packs/' },
{ pattern: /^proofs\/([^/]+)$/, oldPrefix: 'proofs/', newPrefix: '/evidence/proofs/' },
// AI runs
{ pattern: /^ai-runs\/([^/]+)$/, oldPrefix: 'ai-runs/', newPrefix: '/operations/ai-runs/' },
];
export interface LegacyRouteHitEvent { export interface LegacyRouteHitEvent {
eventType: 'legacy_route_hit'; eventType: 'legacy_route_hit';
@@ -218,147 +74,126 @@ export class LegacyRouteTelemetryService {
private readonly authService = inject(AUTH_SERVICE) as AuthService; private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private pendingLegacyRoute: string | null = null; private pendingLegacyRoute: PendingLegacyRoute | null = null;
private initialized = false; private initialized = false;
/**
* Current legacy route info, if the page was accessed via a legacy URL.
* Used by the LegacyUrlBannerComponent to show the banner.
*/
readonly currentLegacyRoute = signal<LegacyRouteInfo | null>(null); readonly currentLegacyRoute = signal<LegacyRouteInfo | null>(null);
/**
* Initialize the telemetry service.
* Should be called once during app bootstrap.
*/
initialize(): void { initialize(): void {
if (this.initialized) return; if (this.initialized) {
return;
}
this.initialized = true; this.initialized = true;
// Track NavigationStart to capture the initial URL before redirect this.router.events
this.router.events.pipe( .pipe(
filter((e): e is NavigationStart => e instanceof NavigationStart), filter((event): event is NavigationStart => event instanceof NavigationStart),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef),
).subscribe(event => { )
const path = this.normalizePath(event.url); .subscribe((event) => {
if (this.isLegacyRoute(path)) { const path = this.normalizePath(event.url);
this.pendingLegacyRoute = path; const resolved = this.resolveLegacyRedirect(path);
} this.pendingLegacyRoute = resolved
}); ? { oldPath: path, expectedNewPath: resolved }
: null;
});
// Track NavigationEnd to confirm the redirect completed this.router.events
this.router.events.pipe( .pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd), filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef),
).subscribe(event => { )
if (this.pendingLegacyRoute) { .subscribe((event) => {
const oldPath = this.pendingLegacyRoute; if (!this.pendingLegacyRoute) {
const newPath = this.normalizePath(event.urlAfterRedirects); return;
}
// Only emit if we actually redirected to a different path const oldPath = this.pendingLegacyRoute.oldPath;
if (oldPath !== newPath) { const resolvedPath = this.pendingLegacyRoute.expectedNewPath;
this.emitLegacyRouteHit(oldPath, newPath); const redirectedPath = this.asAbsolutePath(this.normalizePath(event.urlAfterRedirects));
const pathChanged = oldPath !== this.normalizePath(event.urlAfterRedirects);
if (pathChanged) {
this.emitLegacyRouteHit(oldPath, redirectedPath || resolvedPath);
} }
this.pendingLegacyRoute = null; this.pendingLegacyRoute = null;
} });
});
} }
/** clearCurrentLegacyRoute(): void {
* Check if a path matches a known legacy route. this.currentLegacyRoute.set(null);
*/ }
private isLegacyRoute(path: string): boolean {
// Check exact matches first getLegacyRouteCount(): number {
if (LEGACY_ROUTE_MAP[path]) { return COMPILED_LEGACY_ROUTE_TEMPLATES.length;
return true; }
}
private resolveLegacyRedirect(path: string): string | null {
// Check pattern matches for (const template of COMPILED_LEGACY_ROUTE_TEMPLATES) {
for (const { pattern } of LEGACY_ROUTE_PATTERNS) { const match = template.regex.exec(path);
if (pattern.test(path)) { if (!match) {
return true; continue;
} }
}
let target = template.targetTemplate;
return false; for (const name of template.paramNames) {
const value = match.groups?.[name];
if (value) {
target = target.replace(`:${name}`, value);
}
}
return this.asAbsolutePath(this.normalizePath(target));
}
return null;
} }
/**
* Normalize a URL path by removing leading slash and query params.
*/
private normalizePath(url: string): string { private normalizePath(url: string): string {
let path = url; let path = url;
// Remove query string
const queryIndex = path.indexOf('?'); const queryIndex = path.indexOf('?');
if (queryIndex !== -1) { if (queryIndex !== -1) {
path = path.substring(0, queryIndex); path = path.substring(0, queryIndex);
} }
// Remove fragment
const fragmentIndex = path.indexOf('#'); const fragmentIndex = path.indexOf('#');
if (fragmentIndex !== -1) { if (fragmentIndex !== -1) {
path = path.substring(0, fragmentIndex); path = path.substring(0, fragmentIndex);
} }
// Remove leading slash path = path.replace(/^\/+/, '').replace(/\/+$/, '');
if (path.startsWith('/')) {
path = path.substring(1);
}
// Remove trailing slash
if (path.endsWith('/')) {
path = path.substring(0, path.length - 1);
}
return path; return path;
} }
/** private asAbsolutePath(path: string): string {
* Emit telemetry event for legacy route hit. if (!path) {
*/ return '/';
}
return path.startsWith('/') ? path : `/${path}`;
}
private emitLegacyRouteHit(oldPath: string, newPath: string): void { private emitLegacyRouteHit(oldPath: string, newPath: string): void {
const user = this.authService.user(); const user = this.authService.user();
// Set current legacy route info for banner
this.currentLegacyRoute.set({ this.currentLegacyRoute.set({
oldPath: `/${oldPath}`, oldPath: this.asAbsolutePath(oldPath),
newPath, newPath: this.asAbsolutePath(newPath),
timestamp: Date.now(), timestamp: Date.now(),
}); });
this.telemetry.emit('legacy_route_hit', { this.telemetry.emit('legacy_route_hit', {
oldPath: `/${oldPath}`, oldPath: this.asAbsolutePath(oldPath),
newPath, newPath: this.asAbsolutePath(newPath),
tenantId: user?.tenantId ?? null, tenantId: user?.tenantId ?? null,
userId: user?.id ?? null, userId: user?.id ?? null,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown', userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
referrer: typeof document !== 'undefined' ? document.referrer : '', referrer: typeof document !== 'undefined' ? document.referrer : '',
}); });
// Also log to console in development
if (typeof console !== 'undefined') {
console.info(
`[LegacyRouteTelemetry] Legacy route hit: /${oldPath} -> ${newPath}`,
{ tenantId: user?.tenantId, userId: user?.id }
);
}
}
/**
* Clear the current legacy route info.
* Called when banner is dismissed.
*/
clearCurrentLegacyRoute(): void {
this.currentLegacyRoute.set(null);
}
/**
* Get statistics about legacy route usage.
* This is for debugging/admin purposes.
*/
getLegacyRouteCount(): number {
return Object.keys(LEGACY_ROUTE_MAP).length + LEGACY_ROUTE_PATTERNS.length;
} }
} }
function escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -82,6 +82,24 @@ export class ToastService {
return this.show({ type: 'info', title, message, ...options }); return this.show({ type: 'info', title, message, ...options });
} }
/** Update an existing toast in-place. */
update(id: string, options: Partial<ToastOptions>): void {
this._toasts.update(toasts =>
toasts.map(t => {
if (t.id !== id) return t;
return {
...t,
...(options.type != null && { type: options.type }),
...(options.title != null && { title: options.title }),
...(options.message !== undefined && { message: options.message }),
...(options.duration != null && { duration: options.duration }),
...(options.dismissible != null && { dismissible: options.dismissible }),
...(options.action !== undefined && { action: options.action }),
};
})
);
}
/** Dismiss a specific toast */ /** Dismiss a specific toast */
dismiss(id: string): void { dismiss(id: string): void {
this._toasts.update(toasts => toasts.filter(t => t.id !== id)); this._toasts.update(toasts => toasts.filter(t => t.id !== id));

View File

@@ -16,6 +16,11 @@
<button class="btn-icon-small" title="Re-run this check" (click)="onRerun($event)"> <button class="btn-icon-small" title="Re-run this check" (click)="onRerun($event)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button> </button>
@if (wizardLink) {
<button class="btn-icon-small btn-fix-setup" title="Fix in Setup Wizard" (click)="onFixInSetup($event)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</button>
}
<span class="expand-indicator" [innerHTML]="expanded ? chevronUpSvg : chevronDownSvg"></span> <span class="expand-indicator" [innerHTML]="expanded ? chevronUpSvg : chevronDownSvg"></span>
</div> </div>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CheckResult } from '../../models/doctor.models'; import { CheckResult } from '../../models/doctor.models';
import { RemediationPanelComponent } from '../remediation-panel/remediation-panel.component'; import { RemediationPanelComponent } from '../remediation-panel/remediation-panel.component';
import { EvidenceViewerComponent } from '../evidence-viewer/evidence-viewer.component'; import { EvidenceViewerComponent } from '../evidence-viewer/evidence-viewer.component';
import { getWizardStepForCheck, buildWizardDeepLink } from '../../models/doctor-wizard-mapping';
@Component({ @Component({
selector: 'st-check-result', selector: 'st-check-result',
@@ -16,6 +17,7 @@ export class CheckResultComponent {
@Input() expanded = false; @Input() expanded = false;
@Input() fixEnabled = false; @Input() fixEnabled = false;
@Output() rerun = new EventEmitter<void>(); @Output() rerun = new EventEmitter<void>();
@Output() fixInSetup = new EventEmitter<string>();
private readonly svgAttrs = 'xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"'; private readonly svgAttrs = 'xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"';
@@ -71,8 +73,21 @@ export class CheckResultComponent {
return `${(ms / 1000).toFixed(2)}s`; return `${(ms / 1000).toFixed(2)}s`;
} }
get wizardLink(): string | null {
if (this.result.severity !== 'fail' && this.result.severity !== 'warn') return null;
const mapping = getWizardStepForCheck(this.result.checkId);
if (!mapping) return null;
return buildWizardDeepLink(mapping.stepId);
}
onRerun(event: Event): void { onRerun(event: Event): void {
event.stopPropagation(); event.stopPropagation();
this.rerun.emit(); this.rerun.emit();
} }
onFixInSetup(event: Event): void {
event.stopPropagation();
const link = this.wizardLink;
if (link) this.fixInSetup.emit(link);
}
} }

View File

@@ -0,0 +1,169 @@
import { Component, computed, inject, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DoctorStore } from '../../services/doctor.store';
import { CheckResultComponent } from '../check-result/check-result.component';
/**
* Inline doctor checks strip for embedding on module pages.
* Shows a compact summary ("3 pass / 1 warn / 0 fail") with expand toggle
* to reveal individual check results.
*/
@Component({
selector: 'st-doctor-checks-inline',
standalone: true,
imports: [RouterLink, CheckResultComponent],
template: `
<div class="doctor-inline" [class.doctor-inline--expanded]="expanded">
<div class="doctor-inline__header" (click)="toggle()">
<div class="doctor-inline__title">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
<span>{{ heading || 'Health Checks' }}</span>
</div>
@if (summary(); as s) {
<div class="doctor-inline__counts">
<span class="count count--pass">{{ s.pass }} pass</span>
<span class="count-sep">/</span>
<span class="count count--warn">{{ s.warn }} warn</span>
<span class="count-sep">/</span>
<span class="count count--fail">{{ s.fail }} fail</span>
</div>
} @else {
<span class="doctor-inline__no-data">No report</span>
}
<svg class="doctor-inline__chevron" xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
[style.transform]="expanded ? 'rotate(180deg)' : 'none'">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
@if (expanded) {
<div class="doctor-inline__body">
@for (result of visibleResults(); track result.checkId) {
<st-check-result [result]="result" />
}
@if (results().length === 0) {
<p class="doctor-inline__empty">No checks for this category.</p>
}
<div class="doctor-inline__actions">
<button class="btn btn-sm btn-secondary" (click)="onQuickRun($event)"
[disabled]="store.isRunning()">
Run Quick Check
</button>
<a class="btn btn-sm btn-ghost"
routerLink="/platform/ops/doctor"
[queryParams]="{ category: category }">
Open Full Diagnostics
</a>
</div>
</div>
}
</div>
`,
styles: [`
.doctor-inline {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
overflow: hidden;
}
.doctor-inline__header {
display: flex;
align-items: center;
gap: .5rem;
padding: .5rem .65rem;
cursor: pointer;
user-select: none;
}
.doctor-inline__header:hover {
background: var(--color-surface-secondary);
}
.doctor-inline__title {
display: flex;
align-items: center;
gap: .35rem;
font-size: .78rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
}
.doctor-inline__counts {
display: flex;
align-items: center;
gap: .25rem;
margin-left: auto;
font-size: .72rem;
font-family: var(--font-family-mono);
}
.count--pass { color: var(--color-status-success); }
.count--warn { color: var(--color-status-warning); }
.count--fail { color: var(--color-status-error); }
.count-sep { color: var(--color-text-muted); }
.doctor-inline__no-data {
margin-left: auto;
font-size: .72rem;
color: var(--color-text-muted);
}
.doctor-inline__chevron {
color: var(--color-text-secondary);
transition: transform .15s ease;
flex-shrink: 0;
}
.doctor-inline__body {
border-top: 1px solid var(--color-border-primary);
padding: .5rem .65rem;
display: grid;
gap: .35rem;
}
.doctor-inline__empty {
margin: 0;
font-size: .74rem;
color: var(--color-text-muted);
}
.doctor-inline__actions {
display: flex;
gap: .5rem;
margin-top: .25rem;
}
.btn-sm {
font-size: .72rem;
padding: .25rem .5rem;
}
`],
})
export class DoctorChecksInlineComponent {
@Input({ required: true }) category!: string;
@Input() heading?: string;
@Input() autoRun = false;
@Input() maxResults = 5;
readonly store = inject(DoctorStore);
expanded = false;
readonly summary = computed(() => this.store.summaryByCategory(this.category));
readonly results = computed(() => this.store.resultsByCategory(this.category));
readonly visibleResults = computed(() => this.results().slice(0, this.maxResults));
toggle(): void {
this.expanded = !this.expanded;
if (this.expanded && this.autoRun && !this.store.hasReport() && !this.store.isRunning()) {
this.store.startRun({ mode: 'quick', categories: [this.category as any] });
}
}
onQuickRun(event: Event): void {
event.stopPropagation();
this.store.startRun({ mode: 'quick', categories: [this.category as any], includeRemediation: true });
}
}

View File

@@ -1,6 +1,7 @@
import { Component, OnInit, inject, signal } from '@angular/core'; import { Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { DoctorStore } from './services/doctor.store'; import { DoctorStore } from './services/doctor.store';
import { CheckResult, DoctorCategory, DoctorSeverity, RunDoctorRequest } from './models/doctor.models'; import { CheckResult, DoctorCategory, DoctorSeverity, RunDoctorRequest } from './models/doctor.models';
@@ -23,6 +24,8 @@ import { AppConfigService } from '../../core/config/app-config.service';
export class DoctorDashboardComponent implements OnInit { export class DoctorDashboardComponent implements OnInit {
readonly store = inject(DoctorStore); readonly store = inject(DoctorStore);
private readonly configService = inject(AppConfigService); private readonly configService = inject(AppConfigService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly fixEnabled = this.configService.config.doctor?.fixEnabled ?? false; readonly fixEnabled = this.configService.config.doctor?.fixEnabled ?? false;
readonly showExportDialog = signal(false); readonly showExportDialog = signal(false);
@@ -49,6 +52,16 @@ export class DoctorDashboardComponent implements OnInit {
// Load metadata on init // Load metadata on init
this.store.fetchPlugins(); this.store.fetchPlugins();
this.store.fetchChecks(); this.store.fetchChecks();
// Apply category filter from query param
const category = this.route.snapshot.queryParamMap.get('category');
if (category) {
this.store.setCategoryFilter(category as DoctorCategory);
}
}
onFixInSetup(url: string): void {
this.router.navigateByUrl(url);
} }
runQuickCheck(): void { runQuickCheck(): void {

View File

@@ -9,6 +9,7 @@ export * from './services/doctor.store';
// Components // Components
export * from './doctor-dashboard.component'; export * from './doctor-dashboard.component';
export * from './components/doctor-checks-inline/doctor-checks-inline.component';
export * from './components/summary-strip/summary-strip.component'; export * from './components/summary-strip/summary-strip.component';
export * from './components/check-result/check-result.component'; export * from './components/check-result/check-result.component';
export * from './components/remediation-panel/remediation-panel.component'; export * from './components/remediation-panel/remediation-panel.component';

View File

@@ -0,0 +1,96 @@
import { SetupStepId } from '../../setup-wizard/models/setup-wizard.models';
/**
* Maps a Doctor check ID to its corresponding Setup Wizard step.
*/
export interface DoctorWizardMapping {
checkId: string;
stepId: SetupStepId;
label: string;
}
/**
* Mappings derived from DEFAULT_SETUP_STEPS[].validationChecks arrays
* and docs/setup/setup-wizard-doctor-contract.md.
*/
export const DOCTOR_WIZARD_MAPPINGS: DoctorWizardMapping[] = [
// database
{ checkId: 'check.database.connectivity', stepId: 'database', label: 'Database Connectivity' },
{ checkId: 'check.database.migrations', stepId: 'database', label: 'Database Migrations' },
// cache
{ checkId: 'check.cache.connectivity', stepId: 'cache', label: 'Cache Connectivity' },
{ checkId: 'check.cache.persistence', stepId: 'cache', label: 'Cache Persistence' },
// migrations
{ checkId: 'check.database.migrations.pending', stepId: 'migrations', label: 'Pending Migrations' },
{ checkId: 'check.database.migrations.version', stepId: 'migrations', label: 'Migration Version' },
// authority
{ checkId: 'check.authority.plugin.configured', stepId: 'authority', label: 'Authority Plugin Config' },
{ checkId: 'check.authority.plugin.connectivity', stepId: 'authority', label: 'Authority Connectivity' },
// users
{ checkId: 'check.users.superuser.exists', stepId: 'users', label: 'Superuser Exists' },
{ checkId: 'check.authority.bootstrap.exists', stepId: 'users', label: 'Bootstrap Account' },
// crypto
{ checkId: 'check.crypto.provider.configured', stepId: 'crypto', label: 'Crypto Provider Config' },
{ checkId: 'check.crypto.provider.available', stepId: 'crypto', label: 'Crypto Provider Available' },
// vault
{ checkId: 'check.integration.vault.connectivity', stepId: 'vault', label: 'Vault Connectivity' },
{ checkId: 'check.integration.vault.auth', stepId: 'vault', label: 'Vault Authentication' },
// registry
{ checkId: 'check.integration.registry.connectivity', stepId: 'registry', label: 'Registry Connectivity' },
{ checkId: 'check.integration.registry.auth', stepId: 'registry', label: 'Registry Authentication' },
// scm
{ checkId: 'check.integration.scm.connectivity', stepId: 'scm', label: 'SCM Connectivity' },
{ checkId: 'check.integration.scm.auth', stepId: 'scm', label: 'SCM Authentication' },
// sources
{ checkId: 'check.sources.feeds.configured', stepId: 'sources', label: 'Feed Sources Config' },
{ checkId: 'check.sources.feeds.connectivity', stepId: 'sources', label: 'Feed Sources Connectivity' },
// notify
{ checkId: 'check.notify.channel.configured', stepId: 'notify', label: 'Notification Channel Config' },
{ checkId: 'check.notify.channel.connectivity', stepId: 'notify', label: 'Notification Connectivity' },
// llm
{ checkId: 'check.ai.llm.config', stepId: 'llm', label: 'LLM Configuration' },
{ checkId: 'check.ai.provider.openai', stepId: 'llm', label: 'OpenAI Provider' },
{ checkId: 'check.ai.provider.claude', stepId: 'llm', label: 'Claude Provider' },
{ checkId: 'check.ai.provider.gemini', stepId: 'llm', label: 'Gemini Provider' },
// settingsstore
{ checkId: 'check.integration.settingsstore.connectivity', stepId: 'settingsstore', label: 'Settings Store Connectivity' },
{ checkId: 'check.integration.settingsstore.auth', stepId: 'settingsstore', label: 'Settings Store Auth' },
// environments
{ checkId: 'check.environments.defined', stepId: 'environments', label: 'Environments Defined' },
{ checkId: 'check.environments.promotion.path', stepId: 'environments', label: 'Promotion Path' },
// agents
{ checkId: 'check.agents.registered', stepId: 'agents', label: 'Agents Registered' },
{ checkId: 'check.agents.connectivity', stepId: 'agents', label: 'Agent Connectivity' },
// telemetry
{ checkId: 'check.telemetry.otlp.connectivity', stepId: 'telemetry', label: 'Telemetry OTLP Connectivity' },
];
/** Look up the wizard step mapping for a given Doctor check ID. */
export function getWizardStepForCheck(checkId: string): DoctorWizardMapping | undefined {
return DOCTOR_WIZARD_MAPPINGS.find((m) => m.checkId === checkId);
}
/** Get all Doctor check IDs associated with a given setup wizard step. */
export function getCheckIdsForStep(stepId: SetupStepId): string[] {
return DOCTOR_WIZARD_MAPPINGS.filter((m) => m.stepId === stepId).map((m) => m.checkId);
}
/** Build a deep-link URL to the setup wizard for a specific step in reconfigure mode. */
export function buildWizardDeepLink(stepId: SetupStepId): string {
return `/setup/wizard?step=${stepId}&mode=reconfigure`;
}

View File

@@ -0,0 +1,98 @@
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { DoctorStore } from './doctor.store';
import { ToastService } from '../../../core/services/toast.service';
import { QuickAction } from '../../../core/api/search.models';
/**
* Service for running Doctor checks from the command palette.
* Provides quick actions and manages progress/result toast notifications.
*/
@Injectable({ providedIn: 'root' })
export class DoctorQuickCheckService {
private readonly store = inject(DoctorStore);
private readonly toast = inject(ToastService);
private readonly router = inject(Router);
/** Run a quick Doctor check with progress toast. */
runQuickCheck(): void {
const toastId = this.toast.show({
type: 'info',
title: 'Running Quick Health Check...',
message: 'Doctor diagnostics in progress',
duration: 0, // stays until updated
dismissible: true,
});
this.store.startRun({ mode: 'quick', includeRemediation: true });
// Watch for completion via polling the store state
const checkInterval = setInterval(() => {
const state = this.store.state();
if (state === 'completed') {
clearInterval(checkInterval);
const summary = this.store.summary();
const runId = this.store.currentRunId();
this.toast.update(toastId, {
type: summary && summary.failed > 0 ? 'error' : 'success',
title: 'Health Check Complete',
message: summary
? `${summary.passed} pass, ${summary.warnings} warn, ${summary.failed} fail`
: 'Check complete',
duration: 8000,
action: {
label: 'View Details',
onClick: () => {
this.router.navigate(['/platform/ops/doctor'], {
queryParams: runId ? { runId } : {},
});
},
},
});
} else if (state === 'error') {
clearInterval(checkInterval);
this.toast.update(toastId, {
type: 'error',
title: 'Health Check Failed',
message: this.store.error() ?? 'An error occurred',
duration: 8000,
});
}
}, 500);
// Safety timeout
setTimeout(() => clearInterval(checkInterval), 300000);
}
/** Run full diagnostics and navigate to Doctor dashboard. */
runFullDiagnostics(): void {
this.store.startRun({ mode: 'full', includeRemediation: true });
this.router.navigate(['/platform/ops/doctor']);
}
/** Get Doctor-specific quick actions with bound callbacks. */
getQuickActions(): QuickAction[] {
return [
{
id: 'doctor-quick',
label: 'Run Quick Health Check',
shortcut: '>doctor',
description: 'Run a quick Doctor diagnostics check',
icon: 'activity',
keywords: ['doctor', 'health', 'check', 'quick', 'diagnostic'],
action: () => this.runQuickCheck(),
},
{
id: 'doctor-full',
label: 'Run Full Diagnostics',
shortcut: '>diagnostics',
description: 'Run comprehensive Doctor diagnostics',
icon: 'search',
keywords: ['doctor', 'diagnostics', 'full', 'comprehensive'],
action: () => this.runFullDiagnostics(),
},
];
}
}

View File

@@ -0,0 +1,91 @@
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { DoctorStore } from './doctor.store';
import { ToastService } from '../../../core/services/toast.service';
import { getCheckIdsForStep } from '../models/doctor-wizard-mapping';
import { SetupStepId } from '../../setup-wizard/models/setup-wizard.models';
/**
* Service for running Doctor re-checks after Setup Wizard step reconfiguration.
* Provides progress/result toast notifications for targeted check runs.
*/
@Injectable({ providedIn: 'root' })
export class DoctorRecheckService {
private readonly store = inject(DoctorStore);
private readonly toast = inject(ToastService);
private readonly router = inject(Router);
/**
* Run targeted Doctor checks for a specific setup wizard step.
* Shows progress toast and updates on completion.
*/
recheckForStep(stepId: SetupStepId): void {
const checkIds = getCheckIdsForStep(stepId);
if (checkIds.length === 0) return;
const toastId = this.toast.show({
type: 'info',
title: 'Running Re-check...',
message: `Verifying ${checkIds.length} check(s) for ${stepId}`,
duration: 0,
dismissible: true,
});
this.store.startRun({ mode: 'quick', includeRemediation: true, checkIds });
const checkInterval = setInterval(() => {
const state = this.store.state();
if (state === 'completed') {
clearInterval(checkInterval);
const summary = this.store.summary();
const runId = this.store.currentRunId();
this.toast.update(toastId, {
type: summary && summary.failed > 0 ? 'error' : 'success',
title: 'Re-check Complete',
message: summary
? `${summary.passed} pass, ${summary.warnings} warn, ${summary.failed} fail`
: 'Re-check complete',
duration: 8000,
action: {
label: 'View Details',
onClick: () => {
this.router.navigate(['/platform/ops/doctor'], {
queryParams: runId ? { runId } : {},
});
},
},
});
} else if (state === 'error') {
clearInterval(checkInterval);
this.toast.update(toastId, {
type: 'error',
title: 'Re-check Failed',
message: this.store.error() ?? 'An error occurred',
duration: 8000,
});
}
}, 500);
// Safety timeout
setTimeout(() => clearInterval(checkInterval), 300000);
}
/**
* Show a success toast offering a re-check after a wizard step completes.
*/
offerRecheck(stepId: SetupStepId, stepName: string): void {
this.toast.show({
type: 'success',
title: `${stepName} configured successfully`,
message: 'Run Doctor re-check to verify',
duration: 10000,
dismissible: true,
action: {
label: 'Run Re-check',
onClick: () => this.recheckForStep(stepId),
},
});
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable, InjectionToken, inject } from '@angular/core'; import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable, of, delay } from 'rxjs'; import { Observable, of, delay } from 'rxjs';
import { DoctorTrendResponse } from '../../../core/doctor/doctor-trend.models';
import { import {
CheckListResponse, CheckListResponse,
CheckMetadata, CheckMetadata,
@@ -40,6 +41,9 @@ export interface DoctorApi {
/** Delete a report by ID. */ /** Delete a report by ID. */
deleteReport(reportId: string): Observable<void>; deleteReport(reportId: string): Observable<void>;
/** Get health trend data for sparklines. */
getTrends?(categories?: string[], limit?: number): Observable<DoctorTrendResponse[]>;
} }
export const DOCTOR_API = new InjectionToken<DoctorApi>('DOCTOR_API'); export const DOCTOR_API = new InjectionToken<DoctorApi>('DOCTOR_API');
@@ -94,6 +98,13 @@ export class HttpDoctorClient implements DoctorApi {
deleteReport(reportId: string): Observable<void> { deleteReport(reportId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/reports/${reportId}`); return this.http.delete<void>(`${this.baseUrl}/reports/${reportId}`);
} }
getTrends(categories?: string[], limit?: number): Observable<DoctorTrendResponse[]> {
const params: Record<string, string> = {};
if (categories?.length) params['categories'] = categories.join(',');
if (limit != null) params['limit'] = limit.toString();
return this.http.get<DoctorTrendResponse[]>(`${this.baseUrl}/trends`, { params });
}
} }
/** /**
@@ -319,4 +330,16 @@ export class MockDoctorClient implements DoctorApi {
deleteReport(reportId: string): Observable<void> { deleteReport(reportId: string): Observable<void> {
return of(undefined).pipe(delay(50)); return of(undefined).pipe(delay(50));
} }
getTrends(categories?: string[], limit = 12): Observable<DoctorTrendResponse[]> {
const cats = categories ?? ['security', 'platform'];
const responses: DoctorTrendResponse[] = cats.map((category) => ({
category,
points: Array.from({ length: limit }, (_, i) => ({
timestamp: new Date(Date.now() - (limit - i) * 3600000).toISOString(),
score: 70 + Math.round(Math.random() * 25),
})),
}));
return of(responses).pipe(delay(100));
}
} }

View File

@@ -316,6 +316,24 @@ export class DoctorStore {
this.errorSignal.set(message); this.errorSignal.set(message);
} }
/** Get results filtered by category. */
resultsByCategory(category: string): CheckResult[] {
const report = this.reportSignal();
if (!report) return [];
return report.results.filter((r) => r.category === category);
}
/** Get summary counts for a category. */
summaryByCategory(category: string): { pass: number; warn: number; fail: number; total: number } {
const results = this.resultsByCategory(category);
return {
pass: results.filter((r) => r.severity === 'pass').length,
warn: results.filter((r) => r.severity === 'warn').length,
fail: results.filter((r) => r.severity === 'fail').length,
total: results.length,
};
}
/** Set category filter. */ /** Set category filter. */
setCategoryFilter(category: DoctorCategory | null): void { setCategoryFilter(category: DoctorCategory | null): void {
this.categoryFilterSignal.set(category); this.categoryFilterSignal.set(category);

View File

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IntegrationService } from './integration.service'; import { IntegrationService } from './integration.service';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { import {
Integration, Integration,
IntegrationType, IntegrationType,
@@ -18,7 +19,7 @@ import {
*/ */
@Component({ @Component({
selector: 'app-integration-list', selector: 'app-integration-list',
imports: [CommonModule, RouterModule, FormsModule], imports: [CommonModule, RouterModule, FormsModule, DoctorChecksInlineComponent],
template: ` template: `
<div class="integration-list"> <div class="integration-list">
<header class="list-header"> <header class="list-header">
@@ -44,6 +45,8 @@ import {
/> />
</section> </section>
<st-doctor-checks-inline category="integration" heading="Integration Health Checks" />
@if (loading) { @if (loading) {
<div class="loading">Loading integrations...</div> <div class="loading">Loading integrations...</div>
} @else if (integrations.length === 0) { } @else if (integrations.length === 0) {

View File

@@ -0,0 +1,136 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
PlatformHealthSummary,
SERVICE_STATE_COLORS,
SERVICE_STATE_TEXT_COLORS,
formatLatency,
formatErrorRate,
} from '../../../core/api/platform-health.models';
@Component({
selector: 'app-kpi-strip',
standalone: true,
imports: [CommonModule],
template: `
<section class="kpi-strip">
<div class="kpi-card">
<div class="kpi-label-row">
<span class="kpi-label">Services</span>
<span class="kpi-dot" [class]="SERVICE_STATE_COLORS[summary.overallState]"></span>
</div>
<p class="kpi-value">
@if (summary.totalServices != null) {
{{ summary.healthyCount ?? 0 }}/{{ summary.totalServices }}
} @else { — }
</p>
<p class="kpi-sub">Healthy</p>
</div>
<div class="kpi-card">
<span class="kpi-label">Avg Latency</span>
<p class="kpi-value">{{ formatLatency(summary.averageLatencyMs) }}</p>
<p class="kpi-sub">P95 across services</p>
</div>
<div class="kpi-card">
<span class="kpi-label">Error Rate</span>
<p class="kpi-value" [class]="getErrorRateColor(summary.averageErrorRate)">
{{ formatErrorRate(summary.averageErrorRate) }}
</p>
<p class="kpi-sub">Platform-wide</p>
</div>
<div class="kpi-card">
<span class="kpi-label">Incidents</span>
<p class="kpi-value" [class]="summary.activeIncidents > 0 ? 'text-error' : 'text-success'">
{{ summary.activeIncidents }}
</p>
<p class="kpi-sub">Active</p>
</div>
<div class="kpi-card">
<span class="kpi-label">Status</span>
<div class="kpi-status-row">
<span class="kpi-dot-lg" [class]="SERVICE_STATE_COLORS[summary.overallState]"></span>
<p class="kpi-value" [class]="SERVICE_STATE_TEXT_COLORS[summary.overallState]">
{{ summary.overallState | titlecase }}
</p>
</div>
</div>
</section>
`,
styles: [`
.kpi-strip {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1rem;
}
.kpi-card {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1rem;
}
.kpi-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.kpi-label {
font-size: .875rem;
color: var(--color-text-secondary);
}
.kpi-value {
font-size: 1.5rem;
font-weight: var(--font-weight-bold);
color: var(--color-text-heading);
margin: 0;
}
.kpi-sub {
font-size: .75rem;
color: var(--color-text-muted);
margin: 0;
}
.kpi-dot {
width: .75rem;
height: .75rem;
border-radius: var(--radius-full);
}
.kpi-dot-lg {
width: 1rem;
height: 1rem;
border-radius: var(--radius-full);
}
.kpi-status-row {
display: flex;
align-items: center;
gap: .5rem;
margin-top: .25rem;
}
.text-error { color: var(--color-status-error); }
.text-success { color: var(--color-status-success); }
@media (max-width: 1024px) {
.kpi-strip { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 640px) {
.kpi-strip { grid-template-columns: repeat(2, 1fr); }
}
`],
})
export class KpiStripComponent {
@Input({ required: true }) summary!: PlatformHealthSummary;
readonly SERVICE_STATE_COLORS = SERVICE_STATE_COLORS;
readonly SERVICE_STATE_TEXT_COLORS = SERVICE_STATE_TEXT_COLORS;
readonly formatLatency = formatLatency;
readonly formatErrorRate = formatErrorRate;
getErrorRateColor(rate: number | null | undefined): string {
if (rate == null) return 'text-success';
if (rate >= 5) return 'text-error';
if (rate >= 1) return 'text-warning';
return 'text-success';
}
}

View File

@@ -0,0 +1,252 @@
import { Component, computed, Input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import {
ServiceHealth,
SERVICE_STATE_COLORS,
SERVICE_STATE_BG_LIGHT,
formatUptime,
formatLatency,
formatErrorRate,
} from '../../../core/api/platform-health.models';
@Component({
selector: 'app-service-health-grid',
standalone: true,
imports: [CommonModule, RouterModule, FormsModule],
template: `
<section class="service-grid-container" [class.service-grid-container--compact]="compact">
<div class="service-grid-header">
<h2>Service Health</h2>
<select [(ngModel)]="groupBy" class="group-select">
<option value="state">Group by State</option>
<option value="none">No Grouping</option>
</select>
</div>
<div class="service-grid-body">
@if ((services ?? []).length === 0) {
<p class="empty">No services available in current snapshot</p>
} @else if (groupBy() === 'state') {
@if (unhealthy().length > 0) {
<div class="state-group">
<h3 class="state-label state-label--unhealthy">Unhealthy ({{ unhealthy().length }})</h3>
<div class="cards" [class.cards--compact]="compact">
@for (svc of unhealthy(); track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
<span class="svc-card__dot" [class]="getStateColor(svc.state)"></span>
</div>
<div class="svc-card__stats">
<div><span class="stat-label">Uptime:</span> {{ formatUptime(svc.uptime) }}</div>
<div><span class="stat-label">P95:</span> {{ formatLatency(svc.latencyP95Ms) }}</div>
<div><span class="stat-label">Errors:</span> {{ formatErrorRate(svc.errorRate) }}</div>
<div><span class="stat-label">Checks:</span> {{ passingChecks(svc) }}/{{ (svc.checks ?? []).length }}</div>
</div>
</a>
}
</div>
</div>
}
@if (degraded().length > 0) {
<div class="state-group">
<h3 class="state-label state-label--degraded">Degraded ({{ degraded().length }})</h3>
<div class="cards" [class.cards--compact]="compact">
@for (svc of degraded(); track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
<span class="svc-card__dot" [class]="getStateColor(svc.state)"></span>
</div>
<div class="svc-card__stats">
<div><span class="stat-label">Uptime:</span> {{ formatUptime(svc.uptime) }}</div>
<div><span class="stat-label">P95:</span> {{ formatLatency(svc.latencyP95Ms) }}</div>
<div><span class="stat-label">Errors:</span> {{ formatErrorRate(svc.errorRate) }}</div>
<div><span class="stat-label">Checks:</span> {{ passingChecks(svc) }}/{{ (svc.checks ?? []).length }}</div>
</div>
</a>
}
</div>
</div>
}
@if (healthy().length > 0) {
<div class="state-group">
<h3 class="state-label state-label--healthy">Healthy ({{ healthy().length }})</h3>
<div class="cards" [class.cards--compact]="compact">
@for (svc of healthy(); track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
<span class="svc-card__dot" [class]="getStateColor(svc.state)"></span>
</div>
<div class="svc-card__stats">
<div><span class="stat-label">Uptime:</span> {{ formatUptime(svc.uptime) }}</div>
<div><span class="stat-label">P95:</span> {{ formatLatency(svc.latencyP95Ms) }}</div>
<div><span class="stat-label">Errors:</span> {{ formatErrorRate(svc.errorRate) }}</div>
<div><span class="stat-label">Checks:</span> {{ passingChecks(svc) }}/{{ (svc.checks ?? []).length }}</div>
</div>
</a>
}
</div>
</div>
}
} @else {
<div class="cards" [class.cards--compact]="compact">
@for (svc of services ?? []; track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
<span class="svc-card__dot" [class]="getStateColor(svc.state)"></span>
</div>
<div class="svc-card__stats">
<div><span class="stat-label">Uptime:</span> {{ formatUptime(svc.uptime) }}</div>
<div><span class="stat-label">P95:</span> {{ formatLatency(svc.latencyP95Ms) }}</div>
<div><span class="stat-label">Errors:</span> {{ formatErrorRate(svc.errorRate) }}</div>
<div><span class="stat-label">Checks:</span> {{ passingChecks(svc) }}/{{ (svc.checks ?? []).length }}</div>
</div>
</a>
}
</div>
}
</div>
</section>
`,
styles: [`
.service-grid-container {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
}
.service-grid-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--color-border-primary);
}
.service-grid-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
}
.group-select {
padding: .25rem .75rem;
font-size: .875rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-family: inherit;
}
.service-grid-body { padding: 1rem; }
.state-group { margin-bottom: 1rem; }
.state-group:last-child { margin-bottom: 0; }
.state-label {
font-size: .875rem;
font-weight: var(--font-weight-medium);
margin: 0 0 .5rem;
}
.state-label--unhealthy { color: var(--color-status-error); }
.state-label--degraded { color: var(--color-status-warning); }
.state-label--healthy { color: var(--color-status-success); }
.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: .75rem;
}
.cards--compact {
grid-template-columns: repeat(2, 1fr);
}
.svc-card {
display: block;
padding: .75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
transition: box-shadow .15s;
}
.svc-card:hover { box-shadow: 0 4px 6px -1px rgba(0,0,0,.08); }
.svc-card__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: .5rem;
}
.svc-card__name {
font-weight: var(--font-weight-medium);
color: var(--color-text-heading);
}
.svc-card__dot {
width: .75rem;
height: .75rem;
border-radius: var(--radius-full);
}
.svc-card__stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .25rem;
font-size: .75rem;
}
.stat-label { color: var(--color-text-muted); }
.empty {
text-align: center;
padding: 1.5rem;
font-size: .875rem;
color: var(--color-text-muted);
}
/* State backgrounds */
:host ::ng-deep .state-bg--healthy { background: rgba(34,197,94,.06); border-color: rgba(34,197,94,.2); }
:host ::ng-deep .state-bg--degraded { background: rgba(234,179,8,.06); border-color: rgba(234,179,8,.2); }
:host ::ng-deep .state-bg--unhealthy { background: rgba(239,68,68,.06); border-color: rgba(239,68,68,.2); }
:host ::ng-deep .state-bg--unknown { background: var(--color-surface-secondary); }
.service-grid-container--compact .service-grid-header { padding: .65rem; }
.service-grid-container--compact .service-grid-body { padding: .65rem; }
@media (max-width: 1024px) {
.cards { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 640px) {
.cards { grid-template-columns: 1fr; }
}
`],
})
export class ServiceHealthGridComponent {
@Input() services: ServiceHealth[] | null = [];
@Input() compact = false;
readonly groupBy = signal<'state' | 'none'>('state');
readonly formatUptime = formatUptime;
readonly formatLatency = formatLatency;
readonly formatErrorRate = formatErrorRate;
readonly healthy = computed(() =>
(this.services ?? []).filter((s) => s.state === 'healthy')
);
readonly degraded = computed(() =>
(this.services ?? []).filter((s) => s.state === 'degraded')
);
readonly unhealthy = computed(() =>
(this.services ?? []).filter((s) => s.state === 'unhealthy' || s.state === 'unknown')
);
passingChecks(svc: ServiceHealth): number {
return (svc.checks ?? []).filter((c) => c.status === 'pass').length;
}
getStateBg(state: string): string {
return SERVICE_STATE_BG_LIGHT[state as keyof typeof SERVICE_STATE_BG_LIGHT] ?? '';
}
getStateColor(state: string): string {
return SERVICE_STATE_COLORS[state as keyof typeof SERVICE_STATE_COLORS] ?? '';
}
}

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
interface WorkflowCard { interface WorkflowCard {
id: string; id: string;
@@ -12,7 +13,7 @@ interface WorkflowCard {
@Component({ @Component({
selector: 'app-platform-ops-overview-page', selector: 'app-platform-ops-overview-page',
standalone: true, standalone: true,
imports: [RouterLink], imports: [RouterLink, DoctorChecksInlineComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<section class="ops-overview"> <section class="ops-overview">
@@ -64,6 +65,8 @@ interface WorkflowCard {
</div> </div>
</section> </section>
<st-doctor-checks-inline category="core" heading="Core Platform Checks" />
<section class="ops-overview__secondary"> <section class="ops-overview__secondary">
<h2>Secondary Operator Tools</h2> <h2>Secondary Operator Tools</h2>
<div class="ops-overview__links"> <div class="ops-overview__links">

View File

@@ -9,6 +9,7 @@ import { PlatformContextStore } from '../../../../core/context/platform-context.
import { ReleaseManagementStore } from '../release.store'; import { ReleaseManagementStore } from '../release.store';
import { getEvidencePostureLabel, getGateStatusLabel, getRiskTierLabel } from '../../../../core/api/release-management.models'; import { getEvidencePostureLabel, getGateStatusLabel, getRiskTierLabel } from '../../../../core/api/release-management.models';
import type { ManagedRelease } from '../../../../core/api/release-management.models'; import type { ManagedRelease } from '../../../../core/api/release-management.models';
import { DegradedStateBannerComponent } from '../../../../shared/components/degraded-state-banner/degraded-state-banner.component';
interface PlatformListResponse<T> { items: T[]; total: number; limit: number; offset: number; } interface PlatformListResponse<T> { items: T[]; total: number; limit: number; offset: number; }
interface PlatformItemResponse<T> { item: T; } interface PlatformItemResponse<T> { item: T; }
@@ -126,10 +127,14 @@ interface ReleaseRunAuditProjectionDto {
}>; }>;
} }
interface ReloadOptions {
background?: boolean;
}
@Component({ @Component({
selector: 'app-release-detail', selector: 'app-release-detail',
standalone: true, standalone: true,
imports: [RouterLink, FormsModule], imports: [RouterLink, FormsModule, DegradedStateBannerComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<section class="workbench"> <section class="workbench">
@@ -154,6 +159,32 @@ interface ReleaseRunAuditProjectionDto {
</div> </div>
</header> </header>
@if (mode() === 'run') {
<div class="live-sync">
<span class="live-sync__status">{{ liveSyncStatus() }}</span>
<span class="live-sync__time">
Last sync: {{ lastSyncAt() ? fmt(lastSyncAt()!) : 'n/a' }}
</span>
<button type="button" (click)="refreshNow()" [disabled]="refreshing()">
{{ refreshing() ? 'Refreshing...' : 'Refresh now' }}
</button>
</div>
}
@if (runSyncImpact(); as impact) {
<app-degraded-state-banner
[impact]="impact.impact"
[title]="impact.title"
[message]="impact.message"
[correlationId]="impact.correlationId"
[lastKnownGoodAt]="impact.lastKnownGoodAt"
[readOnly]="impact.readOnly"
[retryable]="true"
retryLabel="Retry run sync"
(retryRequested)="refreshNow()"
/>
}
<nav class="tabs"> <nav class="tabs">
@for (tab of tabs(); track tab.id) { @for (tab of tabs(); track tab.id) {
<a [routerLink]="[detailBasePath(), releaseId(), tab.id]" [class.active]="activeTab()===tab.id">{{ tab.label }}</a> <a [routerLink]="[detailBasePath(), releaseId(), tab.id]" [class.active]="activeTab()===tab.id">{{ tab.label }}</a>
@@ -319,6 +350,7 @@ interface ReleaseRunAuditProjectionDto {
tr:last-child td{border-bottom:none}tr.sel{background:color-mix(in srgb,var(--color-brand-primary) 10%,transparent)} tr:last-child td{border-bottom:none}tr.sel{background:color-mix(in srgb,var(--color-brand-primary) 10%,transparent)}
button{border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);background:var(--color-surface-primary);padding:.24rem .46rem;font-size:.72rem;cursor:pointer} button{border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);background:var(--color-surface-primary);padding:.24rem .46rem;font-size:.72rem;cursor:pointer}
button.primary{border-color:var(--color-brand-primary);background:var(--color-brand-primary);color:var(--color-text-heading)} .banner{padding:.6rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)} button.primary{border-color:var(--color-brand-primary);background:var(--color-brand-primary);color:var(--color-text-heading)} .banner{padding:.6rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
.live-sync{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.42rem .55rem}.live-sync__status{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--color-text-secondary)}.live-sync__time{font-size:.72rem;color:var(--color-text-secondary)}
@media (max-width: 980px){.split{grid-template-columns:1fr}} @media (max-width: 980px){.split{grid-template-columns:1fr}}
`], `],
}) })
@@ -351,6 +383,10 @@ export class ReleaseDetailComponent {
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly refreshing = signal(false);
readonly lastSyncAt = signal<string | null>(null);
readonly syncError = signal<string | null>(null);
readonly syncFailureCount = signal(0);
readonly activeTab = signal<string>('timeline'); readonly activeTab = signal<string>('timeline');
readonly releaseId = signal(''); readonly releaseId = signal('');
@@ -474,6 +510,41 @@ export class ReleaseDetailComponent {
readonly getEvidencePostureLabel = getEvidencePostureLabel; readonly getEvidencePostureLabel = getEvidencePostureLabel;
readonly getRiskTierLabel = getRiskTierLabel; readonly getRiskTierLabel = getRiskTierLabel;
readonly modeLabel = computed(() => (this.mode() === 'version' ? 'Release Version' : 'Release Run')); readonly modeLabel = computed(() => (this.mode() === 'version' ? 'Release Version' : 'Release Run'));
readonly runIsTerminal = computed(() => {
const run = this.runDetail();
if (!run) {
return false;
}
return this.isTerminalRun(run.status, run.outcome);
});
readonly liveSyncStatus = computed(() => {
if (this.refreshing()) {
return 'SYNCING';
}
if (this.syncFailureCount() > 0) {
return 'DEGRADED';
}
return 'LIVE';
});
readonly runSyncImpact = computed(() => {
if (this.mode() !== 'run' || this.syncFailureCount() === 0) {
return null;
}
const run = this.runDetail();
const gateVerdict = this.runGateDecision()?.verdict.toLowerCase() ?? '';
const blocking = Boolean(run?.blockedByDataIntegrity) || gateVerdict === 'block';
const impact = blocking ? 'BLOCKING' : 'DEGRADED';
return {
impact,
title: 'Run detail live sync degraded',
message: this.syncError() ?? 'Live refresh failed. Showing last-known-good run projection.',
correlationId: run?.correlationKey ?? null,
lastKnownGoodAt: this.lastSyncAt() ?? run?.updatedAt ?? null,
readOnly: true,
};
});
constructor() { constructor() {
this.context.initialize(); this.context.initialize();
@@ -489,13 +560,35 @@ export class ReleaseDetailComponent {
this.activeTab.set(this.normalizeTab(params.get('tab'))); this.activeTab.set(this.normalizeTab(params.get('tab')));
this.selectedTimelineId.set(null); this.selectedTimelineId.set(null);
this.selectedTargets.set(new Set<string>()); this.selectedTargets.set(new Set<string>());
this.lastSyncAt.set(null);
this.syncError.set(null);
this.syncFailureCount.set(0);
if (id) this.reload(id); if (id) this.reload(id);
}); });
effect(() => { effect(() => {
this.context.contextVersion(); this.context.contextVersion();
const id = this.releaseId(); const id = this.releaseId();
if (id) this.reload(id); if (id) this.reload(id, { background: true });
});
effect((onCleanup) => {
if (this.mode() !== 'run' || this.runIsTerminal()) {
return;
}
const id = this.releaseId();
if (!id) {
return;
}
const handle = globalThis.setInterval(() => {
this.reload(id, { background: true });
}, 15000);
onCleanup(() => {
globalThis.clearInterval(handle);
});
}); });
} }
@@ -507,6 +600,15 @@ export class ReleaseDetailComponent {
void this.router.navigate([this.detailBasePath(), id, normalized]); void this.router.navigate([this.detailBasePath(), id, normalized]);
} }
refreshNow(): void {
const id = this.releaseId();
if (!id) {
return;
}
this.syncError.set(null);
this.reload(id, { background: true });
}
canPromote(): boolean { return this.preflightChecks().every((c) => c.status !== 'fail'); } canPromote(): boolean { return this.preflightChecks().every((c) => c.status !== 'fail'); }
toggleTarget(targetId: string, event: Event): void { toggleTarget(targetId: string, event: Event): void {
@@ -521,7 +623,7 @@ export class ReleaseDetailComponent {
setBaseline(id: string): void { this.baselineId.set(id); this.loadDiff(); } setBaseline(id: string): void { this.baselineId.set(id); this.loadDiff(); }
openFinding(): void { void this.router.navigate(['/security/triage'], { queryParams: { releaseId: this.releaseContextId() } }); } openFinding(): void { void this.router.navigate(['/security/triage'], { queryParams: { releaseId: this.releaseContextId() } }); }
createException(): void { void this.router.navigate(['/security/advisories-vex'], { queryParams: { releaseId: this.releaseContextId(), tab: 'exceptions' } }); } createException(): void { void this.router.navigate(['/security/disposition'], { queryParams: { releaseId: this.releaseContextId(), tab: 'exceptions' } }); }
replayRun(): void { void this.router.navigate(['/evidence/verification/replay'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); } replayRun(): void { void this.router.navigate(['/evidence/verification/replay'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
exportRunEvidence(): void { void this.router.navigate(['/evidence/capsules'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); } exportRunEvidence(): void { void this.router.navigate(['/evidence/capsules'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
openAgentLogs(target: string): void { void this.router.navigate(['/platform/ops/jobs-queues'], { queryParams: { releaseId: this.releaseContextId(), target } }); } openAgentLogs(target: string): void { void this.router.navigate(['/platform/ops/jobs-queues'], { queryParams: { releaseId: this.releaseContextId(), target } }); }
@@ -539,19 +641,28 @@ export class ReleaseDetailComponent {
return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} }
private reload(entityId: string): void { private reload(entityId: string, options: ReloadOptions = {}): void {
this.loading.set(true); const background = options.background ?? false;
this.error.set(null);
if (background) {
if (this.loading() || this.refreshing()) {
return;
}
this.refreshing.set(true);
} else {
this.loading.set(true);
this.error.set(null);
}
if (this.mode() === 'run') { if (this.mode() === 'run') {
this.loadRunWorkbench(entityId); this.loadRunWorkbench(entityId, background);
return; return;
} }
this.loadVersionWorkbench(entityId); this.loadVersionWorkbench(entityId, background);
} }
private loadVersionWorkbench(releaseId: string): void { private loadVersionWorkbench(releaseId: string, background = false): void {
this.store.selectRelease(releaseId); this.store.selectRelease(releaseId);
this.runDetail.set(null); this.runDetail.set(null);
this.runTimeline.set(null); this.runTimeline.set(null);
@@ -586,16 +697,18 @@ export class ReleaseDetailComponent {
this.baselines.set(baseline); this.baselines.set(baseline);
if (!this.baselineId() && baseline.length > 0) this.baselineId.set(baseline[0].releaseId); if (!this.baselineId() && baseline.length > 0) this.baselineId.set(baseline[0].releaseId);
this.loadDiff(); this.loadDiff();
this.loading.set(false); this.completeVersionLoad(background);
}, },
error: (err: unknown) => { error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load release workbench.'); this.completeVersionLoad(
this.loading.set(false); background,
err instanceof Error ? err.message : 'Failed to load release workbench.',
);
}, },
}); });
} }
private loadRunWorkbench(runId: string): void { private loadRunWorkbench(runId: string, background = false): void {
const runBase = `/api/v2/releases/runs/${runId}`; const runBase = `/api/v2/releases/runs/${runId}`;
const runDetail$ = this.http.get<PlatformItemResponse<ReleaseRunDetailProjectionDto>>(runBase).pipe(map((r) => r.item), catchError(() => of(null))); const runDetail$ = this.http.get<PlatformItemResponse<ReleaseRunDetailProjectionDto>>(runBase).pipe(map((r) => r.item), catchError(() => of(null)));
const timeline$ = this.http.get<PlatformItemResponse<ReleaseRunTimelineProjectionDto>>(`${runBase}/timeline`).pipe(map((r) => r.item), catchError(() => of(null))); const timeline$ = this.http.get<PlatformItemResponse<ReleaseRunTimelineProjectionDto>>(`${runBase}/timeline`).pipe(map((r) => r.item), catchError(() => of(null)));
@@ -622,8 +735,7 @@ export class ReleaseDetailComponent {
}).pipe(take(1)).subscribe({ }).pipe(take(1)).subscribe({
next: ({ runDetail, timeline, gate, approvals, deployments, securityInputs, evidence, rollback, replay, audit }) => { next: ({ runDetail, timeline, gate, approvals, deployments, securityInputs, evidence, rollback, replay, audit }) => {
if (!runDetail) { if (!runDetail) {
this.error.set('Run detail is unavailable for this route.'); this.completeRunLoad(background, null, 'Run detail is unavailable for this route.');
this.loading.set(false);
return; return;
} }
@@ -727,11 +839,14 @@ export class ReleaseDetailComponent {
environment: runDetail.targetEnvironment ?? 'global', environment: runDetail.targetEnvironment ?? 'global',
} satisfies SecuritySbomDiffRow))); } satisfies SecuritySbomDiffRow)));
this.loading.set(false); this.completeRunLoad(background, runDetail.updatedAt);
}, },
error: (err: unknown) => { error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load release run workbench.'); this.completeRunLoad(
this.loading.set(false); background,
null,
err instanceof Error ? err.message : 'Failed to load release run workbench.',
);
}, },
}); });
} }
@@ -968,5 +1083,93 @@ export class ReleaseDetailComponent {
return 'draft'; return 'draft';
} }
private completeVersionLoad(background: boolean, errorMessage?: string): void {
if (background) {
this.refreshing.set(false);
if (errorMessage) {
this.syncError.set(errorMessage);
this.syncFailureCount.update((count) => count + 1);
} else {
this.syncError.set(null);
this.syncFailureCount.set(0);
}
return;
}
if (errorMessage) {
this.error.set(errorMessage);
}
this.loading.set(false);
}
private completeRunLoad(
background: boolean,
syncedAt: string | null,
errorMessage?: string,
): void {
if (background) {
this.refreshing.set(false);
if (errorMessage) {
this.syncError.set(errorMessage);
this.syncFailureCount.update((count) => count + 1);
} else {
this.syncError.set(null);
this.syncFailureCount.set(0);
this.lastSyncAt.set(syncedAt ?? new Date().toISOString());
}
return;
}
if (errorMessage) {
this.error.set(errorMessage);
this.syncError.set(errorMessage);
this.syncFailureCount.update((count) => count + 1);
} else {
this.syncError.set(null);
this.syncFailureCount.set(0);
this.lastSyncAt.set(syncedAt ?? new Date().toISOString());
}
this.loading.set(false);
this.refreshing.set(false);
}
private isTerminalRun(status: string, outcome: string): boolean {
const normalizedStatus = status.toLowerCase();
const normalizedOutcome = outcome.toLowerCase();
const terminalStatuses = new Set([
'completed',
'succeeded',
'failed',
'rejected',
'blocked',
'cancelled',
'canceled',
'rolled_back',
'rollback_complete',
]);
const terminalOutcomes = new Set([
'deployed',
'success',
'failed',
'error',
'blocked',
'rejected',
'cancelled',
'canceled',
'rolled_back',
]);
if (terminalStatuses.has(normalizedStatus) || terminalOutcomes.has(normalizedOutcome)) {
return true;
}
if (normalizedStatus.includes('rollback') || normalizedOutcome.includes('rollback')) {
return true;
}
return false;
}
} }

View File

@@ -1,6 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { catchError, map, take } from 'rxjs/operators'; import { catchError, map, take } from 'rxjs/operators';
@@ -57,13 +58,13 @@ interface PlatformListResponse<T> {
@Component({ @Component({
selector: 'app-security-risk-overview', selector: 'app-security-risk-overview',
standalone: true, standalone: true,
imports: [RouterLink], imports: [RouterLink, DoctorChecksInlineComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<section class="overview"> <section class="overview">
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>Security / Overview</h1> <h1>Security / Posture</h1>
<p>Blocker-first posture for release decisions, freshness confidence, and disposition risk.</p> <p>Blocker-first posture for release decisions, freshness confidence, and disposition risk.</p>
</div> </div>
<div class="scope"> <div class="scope">
@@ -117,6 +118,8 @@ interface PlatformListResponse<T> {
</article> </article>
</section> </section>
<st-doctor-checks-inline category="security" heading="Security Health Checks" />
<div class="grid"> <div class="grid">
<article class="panel"> <article class="panel">
<div class="panel-header"> <div class="panel-header">
@@ -127,7 +130,7 @@ interface PlatformListResponse<T> {
@for (blocker of topBlockers(); track blocker.findingId) { @for (blocker of topBlockers(); track blocker.findingId) {
<li> <li>
<a [routerLink]="['/security/triage', blocker.findingId]">{{ blocker.cveId || blocker.findingId }}</a> <a [routerLink]="['/security/triage', blocker.findingId]">{{ blocker.cveId || blocker.findingId }}</a>
<span>{{ blocker.releaseName }} <20> {{ blocker.region || 'global' }}/{{ blocker.environment }}</span> <span>{{ blocker.releaseName }} <20> {{ blocker.region || 'global' }}/{{ blocker.environment }}</span>
</li> </li>
} @empty { } @empty {
<li class="empty">No blockers in the selected scope.</li> <li class="empty">No blockers in the selected scope.</li>
@@ -138,7 +141,7 @@ interface PlatformListResponse<T> {
<article class="panel"> <article class="panel">
<div class="panel-header"> <div class="panel-header">
<h3>Expiring Waivers</h3> <h3>Expiring Waivers</h3>
<a [routerLink]="['/security/advisories-vex']" [queryParams]="{ tab: 'vex-library' }">Disposition</a> <a [routerLink]="['/security/disposition']" [queryParams]="{ tab: 'vex-library' }">Disposition</a>
</div> </div>
<ul> <ul>
@for (waiver of expiringWaivers(); track waiver.findingId) { @for (waiver of expiringWaivers(); track waiver.findingId) {
@@ -158,14 +161,14 @@ interface PlatformListResponse<T> {
<a routerLink="/platform/integrations/feeds">Configure sources</a> <a routerLink="/platform/integrations/feeds">Configure sources</a>
</div> </div>
<p class="meta"> <p class="meta">
Conflicts: <strong>{{ conflictCount() }}</strong> <20> Conflicts: <strong>{{ conflictCount() }}</strong> <20>
Unverified statements: <strong>{{ unresolvedVexCount() }}</strong> Unverified statements: <strong>{{ unresolvedVexCount() }}</strong>
</p> </p>
<ul> <ul>
@for (provider of providerHealthRows(); track provider.sourceId) { @for (provider of providerHealthRows(); track provider.sourceId) {
<li> <li>
<span>{{ provider.sourceName }}</span> <span>{{ provider.sourceName }}</span>
<span>{{ provider.status }} <20> {{ provider.freshness }}</span> <span>{{ provider.status }} <20> {{ provider.freshness }}</span>
</li> </li>
} @empty { } @empty {
<li class="empty">No provider health rows for current scope.</li> <li class="empty">No provider health rows for current scope.</li>
@@ -176,10 +179,10 @@ interface PlatformListResponse<T> {
<article class="panel"> <article class="panel">
<div class="panel-header"> <div class="panel-header">
<h3>Supply-Chain Coverage</h3> <h3>Supply-Chain Coverage</h3>
<a routerLink="/security/supply-chain-data/coverage">Coverage & Unknowns</a> <a routerLink="/security/sbom/coverage">Coverage & Unknowns</a>
</div> </div>
<p class="meta"> <p class="meta">
Reachability unknowns: <strong>{{ unknownReachabilityCount() }}</strong> <20> Reachability unknowns: <strong>{{ unknownReachabilityCount() }}</strong> <20>
Stale SBOM rows: <strong>{{ sbomStaleCount() }}</strong> Stale SBOM rows: <strong>{{ sbomStaleCount() }}</strong>
</p> </p>
<ul> <ul>
@@ -187,7 +190,7 @@ interface PlatformListResponse<T> {
<a [routerLink]="['/security/triage']" [queryParams]="{ reachability: 'unreachable' }">Inspect unknown/unreachable findings</a> <a [routerLink]="['/security/triage']" [queryParams]="{ reachability: 'unreachable' }">Inspect unknown/unreachable findings</a>
</li> </li>
<li> <li>
<a routerLink="/security/supply-chain-data/reachability">Open reachability coverage board</a> <a routerLink="/security/reachability">Open reachability coverage board</a>
</li> </li>
</ul> </ul>
</article> </article>
@@ -448,4 +451,4 @@ export class SecurityRiskOverviewComponent {
if (environment) params = params.set('environment', environment); if (environment) params = params.set('environment', environment);
return params; return params;
} }
} }

View File

@@ -34,6 +34,7 @@ import {
SetupSession, SetupSession,
ExecuteStepRequest, ExecuteStepRequest,
} from '../models/setup-wizard.models'; } from '../models/setup-wizard.models';
import { DoctorRecheckService } from '../../doctor/services/doctor-recheck.service';
@Component({ @Component({
selector: 'app-setup-wizard', selector: 'app-setup-wizard',
@@ -1437,6 +1438,7 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
private readonly api = inject(SetupWizardApiService); private readonly api = inject(SetupWizardApiService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly doctorRecheck = inject(DoctorRecheckService);
readonly isReconfigureMode = signal(false); readonly isReconfigureMode = signal(false);
readonly showAllSteps = signal(false); readonly showAllSteps = signal(false);
@@ -2020,7 +2022,16 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
private initializeWizard(): void { private initializeWizard(): void {
this.state.loading.set(true); this.state.loading.set(true);
const resumeStep = this.route.snapshot.queryParamMap.get('resume'); // Support deep-link from Doctor "Fix in Setup" button
const stepParam = this.route.snapshot.queryParamMap.get('step');
const modeParam = this.route.snapshot.queryParamMap.get('mode');
if (modeParam === 'reconfigure') {
this.isReconfigureMode.set(true);
}
const resumeStep = stepParam
?? this.route.snapshot.queryParamMap.get('resume');
const resumeStepId = resumeStep && this.validStepIds.has(resumeStep) const resumeStepId = resumeStep && this.validStepIds.has(resumeStep)
? (resumeStep as SetupStepId) ? (resumeStep as SetupStepId)
: null; : null;
@@ -2111,6 +2122,9 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
next: (result) => { next: (result) => {
if (result.status === 'completed') { if (result.status === 'completed') {
this.state.markCurrentStepCompleted(result.appliedConfig); this.state.markCurrentStepCompleted(result.appliedConfig);
if (this.isReconfigureMode()) {
this.doctorRecheck.offerRecheck(step.id, step.name);
}
} else if (result.status === 'failed') { } else if (result.status === 'failed') {
this.state.markCurrentStepFailed(result.error ?? 'Step execution failed'); this.state.markCurrentStepFailed(result.error ?? 'Step execution failed');
} }

View File

@@ -0,0 +1,366 @@
import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { Subject, interval, takeUntil, startWith, switchMap, forkJoin } from 'rxjs';
import { PlatformHealthClient } from '../../core/api/platform-health.client';
import { PlatformHealthSummary, Incident } from '../../core/api/platform-health.models';
import { DoctorStore } from '../doctor/services/doctor.store';
import { KpiStripComponent } from '../platform-health/components/kpi-strip.component';
import { ServiceHealthGridComponent } from '../platform-health/components/service-health-grid.component';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { SummaryStripComponent } from '../doctor/components/summary-strip/summary-strip.component';
import { CheckResultComponent } from '../doctor/components/check-result/check-result.component';
import {
INCIDENT_SEVERITY_COLORS,
} from '../../core/api/platform-health.models';
@Component({
selector: 'app-system-health-page',
standalone: true,
imports: [
CommonModule,
RouterModule,
KpiStripComponent,
ServiceHealthGridComponent,
DoctorChecksInlineComponent,
SummaryStripComponent,
CheckResultComponent,
],
template: `
<div class="system-health">
<header class="system-health__header">
<div>
<h1>System Health</h1>
<p class="subtitle">Unified view of platform services and diagnostics</p>
</div>
<div class="system-health__actions">
@if (autoRefreshActive()) {
<span class="auto-refresh-badge">Auto-refresh: 10s</span>
}
<button class="btn btn-secondary" (click)="refresh()" [disabled]="refreshing()">
<span [class.spin]="refreshing()">&#x21bb;</span> Refresh
</button>
<button class="btn btn-primary" (click)="runQuickDiagnostics()"
[disabled]="doctorStore.isRunning()">
Quick Diagnostics
</button>
</div>
</header>
<!-- Tabs -->
<nav class="system-health__tabs" role="tablist">
@for (tab of tabs; track tab.id) {
<button class="tab" [class.tab--active]="activeTab() === tab.id"
role="tab" [attr.aria-selected]="activeTab() === tab.id"
(click)="activeTab.set(tab.id)">
{{ tab.label }}
</button>
}
</nav>
@if (error()) {
<div class="error-banner">{{ error() }}</div>
}
<!-- Tab Content -->
@switch (activeTab()) {
@case ('overview') {
<div class="tab-content">
@if (summary()) {
<app-kpi-strip [summary]="summary()!" />
}
<div class="overview-grid">
<div class="overview-grid__services">
<app-service-health-grid [services]="summary()?.services ?? []" [compact]="true" />
</div>
<div class="overview-grid__doctor">
<h3>Top Diagnostic Issues</h3>
@if (doctorStore.failedResults().length > 0) {
@for (result of doctorStore.failedResults().slice(0, 5); track result.checkId) {
<st-check-result [result]="result" />
}
} @else if (doctorStore.hasReport()) {
<p class="no-issues">All checks passing.</p>
} @else {
<st-doctor-checks-inline category="core" heading="Core Platform" />
}
</div>
</div>
</div>
}
@case ('services') {
<div class="tab-content">
<app-service-health-grid [services]="summary()?.services ?? []" />
</div>
}
@case ('diagnostics') {
<div class="tab-content">
@if (doctorStore.summary(); as docSummary) {
<st-summary-strip
[summary]="docSummary"
[duration]="doctorStore.report()?.durationMs"
[overallSeverity]="doctorStore.report()?.overallSeverity" />
}
<div class="diagnostics-results">
@for (result of doctorStore.filteredResults(); track result.checkId) {
<st-check-result [result]="result" />
}
@if (doctorStore.filteredResults().length === 0 && !doctorStore.hasReport()) {
<p class="empty-state">No diagnostics run yet. Click "Quick Diagnostics" to start.</p>
}
</div>
</div>
}
@case ('incidents') {
<div class="tab-content">
@if (incidents().length > 0) {
<div class="incidents-timeline">
@for (incident of incidents(); track incident.id) {
<div class="incident-row">
<div class="incident-time">
{{ incident.startedAt | date:'shortTime' }}
</div>
<div class="incident-dot"
[class]="incident.state === 'active' ? 'incident-dot--active' : 'incident-dot--resolved'">
</div>
<div class="incident-content">
<div class="incident-head">
<span class="severity-badge"
[class]="INCIDENT_SEVERITY_COLORS[incident.severity]">
{{ incident.severity }}
</span>
<span class="incident-title">{{ incident.title }}</span>
@if (incident.state === 'resolved') {
<span class="resolved-badge">(Resolved)</span>
}
</div>
<p class="incident-desc">{{ incident.description }}</p>
<p class="incident-affected">
Affected: {{ incident.affectedServices.join(', ') }}
</p>
</div>
</div>
}
</div>
} @else {
<p class="empty-state">No incidents in the last 24 hours.</p>
}
</div>
}
}
</div>
`,
styles: [`
.system-health { display: grid; gap: .75rem; padding: 1.5rem; }
.system-health__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.system-health__header h1 { margin: 0; font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
.subtitle { margin: .2rem 0 0; font-size: .82rem; color: var(--color-text-secondary); }
.system-health__actions { display: flex; align-items: center; gap: .5rem; }
.auto-refresh-badge {
font-size: .72rem;
color: var(--color-text-muted);
padding: .2rem .5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
}
.system-health__tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border-primary);
}
.tab {
padding: .5rem 1rem;
font-size: .82rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-family: inherit;
}
.tab:hover { color: var(--color-text-primary); }
.tab--active {
color: var(--color-brand-primary);
border-bottom-color: var(--color-brand-primary);
}
.tab-content { display: grid; gap: .75rem; }
.error-banner {
padding: .65rem;
font-size: .8rem;
color: var(--color-status-error);
border: 1px solid rgba(239,68,68,.3);
border-radius: var(--radius-md);
background: rgba(239,68,68,.06);
}
.overview-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
}
.overview-grid__doctor {
display: grid;
gap: .5rem;
align-content: start;
}
.overview-grid__doctor h3 {
margin: 0;
font-size: .88rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
}
.no-issues {
font-size: .8rem;
color: var(--color-status-success);
margin: 0;
}
.diagnostics-results { display: grid; gap: .35rem; }
.empty-state {
text-align: center;
padding: 2rem;
font-size: .85rem;
color: var(--color-text-muted);
}
.incidents-timeline { display: grid; gap: .75rem; }
.incident-row { display: flex; align-items: flex-start; gap: 1rem; }
.incident-time { font-size: .75rem; color: var(--color-text-muted); width: 4rem; padding-top: .25rem; }
.incident-dot {
width: .75rem;
height: .75rem;
border-radius: var(--radius-full);
margin-top: .375rem;
flex-shrink: 0;
}
.incident-dot--active { background: var(--color-status-error); }
.incident-dot--resolved { background: var(--color-text-muted); }
.incident-content { flex: 1; }
.incident-head { display: flex; align-items: center; gap: .5rem; }
.severity-badge {
padding: .1rem .4rem;
font-size: .7rem;
font-weight: var(--font-weight-medium);
border-radius: var(--radius-sm);
}
.incident-title { font-weight: var(--font-weight-medium); color: var(--color-text-heading); }
.resolved-badge { font-size: .75rem; color: var(--color-status-success); }
.incident-desc { margin: .25rem 0 0; font-size: .82rem; color: var(--color-text-secondary); }
.incident-affected { margin: .25rem 0 0; font-size: .75rem; color: var(--color-text-muted); }
.btn {
padding: .4rem .75rem;
font-size: .82rem;
border-radius: var(--radius-md);
cursor: pointer;
border: 1px solid var(--color-border-primary);
font-family: inherit;
display: inline-flex;
align-items: center;
gap: .35rem;
}
.btn-primary {
background: var(--color-brand-primary);
color: #fff;
border-color: var(--color-brand-primary);
}
.btn-secondary {
background: var(--color-surface-primary);
color: var(--color-text-primary);
}
.spin { display: inline-block; animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@media (max-width: 1024px) {
.overview-grid { grid-template-columns: 1fr; }
}
`],
})
export class SystemHealthPageComponent implements OnInit, OnDestroy {
private readonly healthClient = inject(PlatformHealthClient);
readonly doctorStore = inject(DoctorStore);
private readonly destroy$ = new Subject<void>();
readonly summary = signal<PlatformHealthSummary | null>(null);
readonly incidents = signal<Incident[]>([]);
readonly error = signal<string | null>(null);
readonly refreshing = signal(false);
readonly autoRefreshActive = signal(true);
readonly activeTab = signal<'overview' | 'services' | 'diagnostics' | 'incidents'>('overview');
readonly INCIDENT_SEVERITY_COLORS = INCIDENT_SEVERITY_COLORS;
readonly tabs = [
{ id: 'overview' as const, label: 'Overview' },
{ id: 'services' as const, label: 'Services' },
{ id: 'diagnostics' as const, label: 'Diagnostics' },
{ id: 'incidents' as const, label: 'Incidents' },
];
ngOnInit(): void {
interval(10000)
.pipe(
startWith(0),
takeUntil(this.destroy$),
switchMap(() => {
this.error.set(null);
return forkJoin({
summary: this.healthClient.getSummary(),
incidents: this.healthClient.getIncidents(24, true),
});
})
)
.subscribe({
next: ({ summary, incidents }) => {
this.summary.set(summary);
this.incidents.set(incidents.incidents ?? []);
this.error.set(null);
},
error: () => {
this.error.set('Unable to load platform health data. Try refreshing.');
},
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
refresh(): void {
this.refreshing.set(true);
this.error.set(null);
forkJoin({
summary: this.healthClient.getSummary(),
incidents: this.healthClient.getIncidents(24, true),
}).subscribe({
next: ({ summary, incidents }) => {
this.summary.set(summary);
this.incidents.set(incidents.incidents ?? []);
this.refreshing.set(false);
},
error: () => {
this.error.set('Unable to load platform health data. Try refreshing.');
this.refreshing.set(false);
},
});
}
runQuickDiagnostics(): void {
this.doctorStore.startRun({ mode: 'quick', includeRemediation: true });
}
}

View File

@@ -62,7 +62,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur
<div class="actions"> <div class="actions">
<a [routerLink]="['/topology/targets']" [queryParams]="{ environment: environmentId() }">Open Targets</a> <a [routerLink]="['/topology/targets']" [queryParams]="{ environment: environmentId() }">Open Targets</a>
<a [routerLink]="['/topology/agents']" [queryParams]="{ environment: environmentId() }">Open Agents</a> <a [routerLink]="['/topology/agents']" [queryParams]="{ environment: environmentId() }">Open Agents</a>
<a [routerLink]="['/releases/activity']" [queryParams]="{ environment: environmentId() }">Open Deployments</a> <a [routerLink]="['/releases/runs']" [queryParams]="{ environment: environmentId() }">Open Runs</a>
<a [routerLink]="['/security/triage']" [queryParams]="{ environment: environmentId() }">Open Security Triage</a> <a [routerLink]="['/security/triage']" [queryParams]="{ environment: environmentId() }">Open Security Triage</a>
</div> </div>
</article> </article>
@@ -114,7 +114,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur
@case ('deployments') { @case ('deployments') {
<article class="card"> <article class="card">
<h2>Deployments</h2> <h2>Runs</h2>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -133,7 +133,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur
<td>{{ run.occurredAt }}</td> <td>{{ run.occurredAt }}</td>
</tr> </tr>
} @empty { } @empty {
<tr><td colspan="4" class="muted">No deployment activity in this scope.</td></tr> <tr><td colspan="4" class="muted">No run activity in this scope.</td></tr>
} }
</tbody> </tbody>
</table> </table>
@@ -416,7 +416,7 @@ export class TopologyEnvironmentDetailPageComponent {
readonly tabs: Array<{ id: EnvironmentTab; label: string }> = [ readonly tabs: Array<{ id: EnvironmentTab; label: string }> = [
{ id: 'overview', label: 'Overview' }, { id: 'overview', label: 'Overview' },
{ id: 'targets', label: 'Targets' }, { id: 'targets', label: 'Targets' },
{ id: 'deployments', label: 'Deployments' }, { id: 'deployments', label: 'Runs' },
{ id: 'agents', label: 'Agents' }, { id: 'agents', label: 'Agents' },
{ id: 'security', label: 'Security' }, { id: 'security', label: 'Security' },
{ id: 'evidence', label: 'Evidence' }, { id: 'evidence', label: 'Evidence' },

View File

@@ -161,7 +161,7 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
<a [routerLink]="['/topology/environments', selectedEnvironmentId(), 'posture']">Open Environment</a> <a [routerLink]="['/topology/environments', selectedEnvironmentId(), 'posture']">Open Environment</a>
<a [routerLink]="['/topology/targets']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Targets</a> <a [routerLink]="['/topology/targets']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Targets</a>
<a [routerLink]="['/topology/agents']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Agents</a> <a [routerLink]="['/topology/agents']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Agents</a>
<a [routerLink]="['/releases/activity']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Deployments</a> <a [routerLink]="['/releases/runs']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Runs</a>
</div> </div>
</article> </article>
} }

View File

@@ -11,13 +11,14 @@ import {
import { Router, RouterLink, NavigationEnd } from '@angular/router'; import { Router, RouterLink, NavigationEnd } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AUTH_SERVICE, AuthService } from '../../core/auth'; import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
import type { StellaOpsScope } from '../../core/auth'; import type { StellaOpsScope } from '../../core/auth';
import { APPROVAL_API } from '../../core/api/approval.client'; import { APPROVAL_API } from '../../core/api/approval.client';
import type { ApprovalApi } from '../../core/api/approval.client'; import type { ApprovalApi } from '../../core/api/approval.client';
import { SidebarNavGroupComponent } from './sidebar-nav-group.component'; import { SidebarNavGroupComponent } from './sidebar-nav-group.component';
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component'; import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
import { DoctorTrendService } from '../../core/doctor/doctor-trend.service';
/** /**
* Navigation structure for the shell. * Navigation structure for the shell.
@@ -29,6 +30,7 @@ export interface NavSection {
icon: string; icon: string;
route: string; route: string;
badge$?: () => number | null; badge$?: () => number | null;
sparklineData$?: () => number[];
children?: NavItem[]; children?: NavItem[];
requiredScopes?: readonly StellaOpsScope[]; requiredScopes?: readonly StellaOpsScope[];
requireAnyScope?: readonly StellaOpsScope[]; requireAnyScope?: readonly StellaOpsScope[];
@@ -90,6 +92,7 @@ export interface NavSection {
[route]="section.route" [route]="section.route"
[children]="section.children" [children]="section.children"
[expanded]="expandedGroups().has(section.id)" [expanded]="expandedGroups().has(section.id)"
[sparklineData]="section.sparklineData$ ? section.sparklineData$() : []"
(expandedChange)="onGroupToggle(section.id, $event)" (expandedChange)="onGroupToggle(section.id, $event)"
></app-sidebar-nav-group> ></app-sidebar-nav-group>
} @else { } @else {
@@ -272,36 +275,77 @@ export class AppSidebarComponent {
private readonly authService = inject(AUTH_SERVICE) as AuthService; private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly approvalApi = inject(APPROVAL_API, { optional: true }) as ApprovalApi | null; private readonly approvalApi = inject(APPROVAL_API, { optional: true }) as ApprovalApi | null;
private readonly doctorTrendService = inject(DoctorTrendService);
@Output() mobileClose = new EventEmitter<void>(); @Output() mobileClose = new EventEmitter<void>();
private readonly pendingApprovalsCount = signal(0); private readonly pendingApprovalsCount = signal(0);
/** Track which groups are expanded default open: Releases, Security, Platform. */ /** Track which groups are expanded - default open: Releases, Security, Platform. */
readonly expandedGroups = signal<Set<string>>(new Set(['releases', 'security', 'platform'])); readonly expandedGroups = signal<Set<string>>(new Set(['releases', 'security', 'platform']));
/** /**
* Navigation sections — Pack 22 consolidated IA. * Navigation sections - canonical IA.
* Root modules: Dashboard, Releases, Security, Evidence, Topology, Platform. * Root modules: Mission Control, Releases, Security, Evidence, Topology, Platform.
*/ */
readonly navSections: NavSection[] = [ readonly navSections: NavSection[] = [
{ {
id: 'dashboard', id: 'dashboard',
label: 'Dashboard', label: 'Mission Control',
icon: 'dashboard', icon: 'dashboard',
route: '/dashboard', route: '/dashboard',
requireAnyScope: [
StellaOpsScopes.UI_READ,
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.SCANNER_READ,
],
}, },
{ {
id: 'releases', id: 'releases',
label: 'Releases', label: 'Releases',
icon: 'package', icon: 'package',
route: '/releases', route: '/releases',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.RELEASE_WRITE,
StellaOpsScopes.RELEASE_PUBLISH,
],
children: [ children: [
{ id: 'rel-versions', label: 'Release Versions', route: '/releases/versions', icon: 'package' }, {
{ id: 'rel-runs', label: 'Release Runs', route: '/releases/runs', icon: 'clock' }, id: 'rel-versions',
{ id: 'rel-approvals', label: 'Approvals Queue', route: '/releases/approvals', icon: 'check-circle', badge: 0 }, label: 'Release Versions',
route: '/releases/versions',
icon: 'package',
requireAnyScope: [StellaOpsScopes.RELEASE_READ, StellaOpsScopes.RELEASE_WRITE],
},
{
id: 'rel-runs',
label: 'Release Runs',
route: '/releases/runs',
icon: 'clock',
requireAnyScope: [StellaOpsScopes.RELEASE_READ, StellaOpsScopes.RELEASE_WRITE],
},
{
id: 'rel-approvals',
label: 'Approvals Queue',
route: '/releases/approvals',
icon: 'check-circle',
badge: 0,
requireAnyScope: [
StellaOpsScopes.RELEASE_PUBLISH,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.EXCEPTION_APPROVE,
],
},
{ id: 'rel-hotfix', label: 'Hotfix Lane', route: '/releases/hotfix', icon: 'zap' }, { id: 'rel-hotfix', label: 'Hotfix Lane', route: '/releases/hotfix', icon: 'zap' },
{ id: 'rel-create', label: 'Create Version', route: '/releases/versions/new', icon: 'settings' }, {
id: 'rel-create',
label: 'Create Version',
route: '/releases/versions/new',
icon: 'settings',
requireAnyScope: [StellaOpsScopes.RELEASE_WRITE, StellaOpsScopes.RELEASE_PUBLISH],
},
], ],
}, },
{ {
@@ -309,11 +353,22 @@ export class AppSidebarComponent {
label: 'Security', label: 'Security',
icon: 'shield', icon: 'shield',
route: '/security', route: '/security',
sparklineData$: () => this.doctorTrendService.securityTrend(),
requireAnyScope: [
StellaOpsScopes.SCANNER_READ,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.ADVISORY_READ,
StellaOpsScopes.VEX_READ,
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.FINDINGS_READ,
StellaOpsScopes.VULN_VIEW,
],
children: [ children: [
{ id: 'sec-overview', label: 'Overview', route: '/security/overview', icon: 'chart' }, { id: 'sec-overview', label: 'Posture', route: '/security/posture', icon: 'chart' },
{ id: 'sec-triage', label: 'Triage', route: '/security/triage', icon: 'list' }, { id: 'sec-triage', label: 'Triage', route: '/security/triage', icon: 'list' },
{ id: 'sec-advisories', label: 'Advisories & VEX', route: '/security/advisories-vex', icon: 'shield-off' }, { id: 'sec-disposition', label: 'Disposition Center', route: '/security/disposition', icon: 'shield-off' },
{ id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data/lake', icon: 'graph' }, { id: 'sec-sbom', label: 'SBOM', route: '/security/sbom/lake', icon: 'graph' },
{ id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' },
{ id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' }, { id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' },
], ],
}, },
@@ -322,13 +377,19 @@ export class AppSidebarComponent {
label: 'Evidence', label: 'Evidence',
icon: 'file-text', icon: 'file-text',
route: '/evidence', route: '/evidence',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.AUTHORITY_AUDIT_READ,
StellaOpsScopes.SIGNER_READ,
StellaOpsScopes.VEX_EXPORT,
],
children: [ children: [
{ id: 'ev-overview', label: 'Overview', route: '/evidence/overview', icon: 'home' }, { id: 'ev-capsules', label: 'Decision Capsules', route: '/evidence/capsules', icon: 'archive' },
{ id: 'ev-search', label: 'Search', route: '/evidence/search', icon: 'search' }, { id: 'ev-verify', label: 'Replay & Verify', route: '/evidence/verification/replay', icon: 'refresh' },
{ id: 'ev-capsules', label: 'Capsules', route: '/evidence/capsules', icon: 'archive' }, { id: 'ev-exports', label: 'Export Center', route: '/evidence/exports', icon: 'download' },
{ id: 'ev-verify', label: 'Verify & Replay', route: '/evidence/verify-replay', icon: 'refresh' },
{ id: 'ev-exports', label: 'Exports', route: '/evidence/exports', icon: 'download' },
{ id: 'ev-audit', label: 'Audit Log', route: '/evidence/audit-log', icon: 'book-open' }, { id: 'ev-audit', label: 'Audit Log', route: '/evidence/audit-log', icon: 'book-open' },
{ id: 'ev-trust', label: 'Trust & Signing', route: '/platform/setup/trust-signing', icon: 'shield' },
], ],
}, },
{ {
@@ -336,13 +397,20 @@ export class AppSidebarComponent {
label: 'Topology', label: 'Topology',
icon: 'server', icon: 'server',
route: '/topology', route: '/topology',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.UI_ADMIN,
],
children: [ children: [
{ id: 'top-overview', label: 'Overview', route: '/topology/overview', icon: 'chart' }, { id: 'top-overview', label: 'Overview', route: '/topology/overview', icon: 'chart' },
{ id: 'top-regions', label: 'Regions & Environments', route: '/topology/regions', icon: 'globe' }, { id: 'top-regions', label: 'Regions & Environments', route: '/topology/regions', icon: 'globe' },
{ id: 'top-targets', label: 'Targets', route: '/topology/targets', icon: 'package' }, { id: 'top-environments', label: 'Environment Posture', route: '/topology/environments', icon: 'list' },
{ id: 'top-targets', label: 'Targets / Runtimes', route: '/topology/targets', icon: 'package' },
{ id: 'top-hosts', label: 'Hosts', route: '/topology/hosts', icon: 'hard-drive' }, { id: 'top-hosts', label: 'Hosts', route: '/topology/hosts', icon: 'hard-drive' },
{ id: 'top-agents', label: 'Agents', route: '/topology/agents', icon: 'cpu' }, { id: 'top-agents', label: 'Agents', route: '/topology/agents', icon: 'cpu' },
{ id: 'top-paths', label: 'Promotion Paths', route: '/topology/promotion-paths', icon: 'git-merge' }, { id: 'top-paths', label: 'Promotion Graph', route: '/topology/promotion-graph', icon: 'git-merge' },
], ],
}, },
{ {
@@ -350,15 +418,22 @@ export class AppSidebarComponent {
label: 'Platform', label: 'Platform',
icon: 'settings', icon: 'settings',
route: '/platform', route: '/platform',
sparklineData$: () => this.doctorTrendService.platformTrend(),
requireAnyScope: [
StellaOpsScopes.UI_ADMIN,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.HEALTH_READ,
StellaOpsScopes.NOTIFY_VIEWER,
],
children: [ children: [
{ id: 'plat-home', label: 'Overview', route: '/platform', icon: 'home' }, { id: 'plat-home', label: 'Overview', route: '/platform', icon: 'home' },
{ id: 'plat-ops', label: 'Ops', route: '/platform/ops', icon: 'activity' }, { id: 'plat-ops', label: 'Ops', route: '/platform/ops', icon: 'activity' },
{ id: 'plat-jobs', label: 'Jobs & Queues', route: '/platform/ops/jobs-queues', icon: 'play' }, { id: 'plat-jobs', label: 'Jobs & Queues', route: '/platform/ops/jobs-queues', icon: 'play' },
{ id: 'plat-integrity', label: 'Data Integrity', route: '/platform/ops/data-integrity', icon: 'shield' }, { id: 'plat-integrity', label: 'Data Integrity', route: '/platform/ops/data-integrity', icon: 'shield' },
{ id: 'plat-health', label: 'Health & SLO', route: '/platform/ops/health-slo', icon: 'heart' }, { id: 'plat-system-health', label: 'System Health', route: '/platform/ops/system-health', icon: 'heart' },
{ id: 'plat-feeds', label: 'Feeds & Airgap', route: '/platform/ops/feeds-airgap', icon: 'rss' }, { id: 'plat-feeds', label: 'Feeds & Airgap', route: '/platform/ops/feeds-airgap', icon: 'rss' },
{ id: 'plat-quotas', label: 'Quotas & Limits', route: '/platform/ops/quotas', icon: 'bar-chart' }, { id: 'plat-quotas', label: 'Quotas & Limits', route: '/platform/ops/quotas', icon: 'bar-chart' },
{ id: 'plat-diagnostics', label: 'Diagnostics', route: '/platform/ops/doctor', icon: 'alert' },
{ id: 'plat-integrations', label: 'Integrations', route: '/platform/integrations', icon: 'plug' }, { id: 'plat-integrations', label: 'Integrations', route: '/platform/integrations', icon: 'plug' },
{ id: 'plat-setup', label: 'Setup', route: '/platform/setup', icon: 'cog' }, { id: 'plat-setup', label: 'Setup', route: '/platform/setup', icon: 'cog' },
], ],
@@ -379,6 +454,7 @@ export class AppSidebarComponent {
.subscribe((event) => { .subscribe((event) => {
if (event instanceof NavigationEnd) { if (event instanceof NavigationEnd) {
this.loadPendingApprovalsBadge(); this.loadPendingApprovalsBadge();
this.doctorTrendService.refresh();
} }
}); });
} }
@@ -468,3 +544,4 @@ export class AppSidebarComponent {
}); });
} }
} }

View File

@@ -14,6 +14,7 @@ import { filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component'; import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
import { SidebarSparklineComponent } from './sidebar-sparkline.component';
/** /**
* SidebarNavGroupComponent - Collapsible navigation group for dark sidebar. * SidebarNavGroupComponent - Collapsible navigation group for dark sidebar.
@@ -23,7 +24,7 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
@Component({ @Component({
selector: 'app-sidebar-nav-group', selector: 'app-sidebar-nav-group',
standalone: true, standalone: true,
imports: [SidebarNavItemComponent], imports: [SidebarNavItemComponent, SidebarSparklineComponent],
template: ` template: `
<div class="nav-group" [class.nav-group--expanded]="expanded"> <div class="nav-group" [class.nav-group--expanded]="expanded">
<!-- Group header --> <!-- Group header -->
@@ -65,6 +66,9 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
</span> </span>
<span class="nav-group__label">{{ label }}</span> <span class="nav-group__label">{{ label }}</span>
@if (sparklineData.length >= 2) {
<app-sidebar-sparkline [points]="sparklineData" />
}
<svg class="nav-group__chevron" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"> <svg class="nav-group__chevron" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/> <polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
</svg> </svg>
@@ -202,6 +206,7 @@ export class SidebarNavGroupComponent implements OnInit {
@Input({ required: true }) route!: string; @Input({ required: true }) route!: string;
@Input() children: NavItem[] = []; @Input() children: NavItem[] = [];
@Input() expanded = false; @Input() expanded = false;
@Input() sparklineData: number[] = [];
@Output() expandedChange = new EventEmitter<boolean>(); @Output() expandedChange = new EventEmitter<boolean>();

View File

@@ -0,0 +1,58 @@
import { Component, computed, Input } from '@angular/core';
/**
* Tiny SVG sparkline for sidebar nav sections.
* Renders a 40x16px polyline from numeric data points.
*/
@Component({
selector: 'app-sidebar-sparkline',
standalone: true,
template: `
@if (polylinePoints()) {
<svg
class="sparkline"
width="40"
height="16"
viewBox="0 0 40 16"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline
[attr.points]="polylinePoints()"
fill="none"
stroke="var(--color-sidebar-sparkline, #f5a623)"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
}
`,
styles: [`
.sparkline {
flex-shrink: 0;
opacity: 0.7;
}
`],
})
export class SidebarSparklineComponent {
@Input() points: number[] = [];
readonly polylinePoints = computed(() => {
if (this.points.length < 2) return null;
const pts = this.points;
const min = Math.min(...pts);
const max = Math.max(...pts);
const range = max - min || 1;
const stepX = 40 / (pts.length - 1);
return pts
.map((val, i) => {
const x = i * stepX;
const y = 16 - ((val - min) / range) * 14 - 1; // 1px padding
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
});
}

View File

@@ -4,7 +4,9 @@ import {
Output, Output,
EventEmitter, EventEmitter,
inject, inject,
computed, ElementRef,
HostListener,
signal,
} from '@angular/core'; } from '@angular/core';
import { AuthSessionStore } from '../../core/auth/auth-session.store'; import { AuthSessionStore } from '../../core/auth/auth-session.store';
@@ -51,13 +53,31 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
<app-global-search></app-global-search> <app-global-search></app-global-search>
</div> </div>
<!-- Context chips row --> <!-- Context chips row (desktop) -->
<div class="topbar__context"> <div class="topbar__context topbar__context--desktop">
<app-context-chips></app-context-chips> <app-context-chips></app-context-chips>
</div> </div>
<!-- Right section: Tenant + User --> <!-- Right section: Tenant + User -->
<div class="topbar__right"> <div class="topbar__right">
<!-- Scope controls (tablet/mobile) -->
<div class="topbar__scope-wrap">
<button
type="button"
class="topbar__scope-toggle"
[attr.aria-expanded]="scopePanelOpen()"
aria-haspopup="dialog"
(click)="toggleScopePanel()"
>
Scope
</button>
@if (scopePanelOpen()) {
<div class="topbar__scope-panel" role="dialog" aria-label="Global scope controls">
<app-context-chips></app-context-chips>
</div>
}
</div>
<!-- Tenant selector --> <!-- Tenant selector -->
@if (activeTenant()) { @if (activeTenant()) {
<div class="topbar__tenant"> <div class="topbar__tenant">
@@ -137,10 +157,56 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
gap: 0.5rem; gap: 0.5rem;
} }
.topbar__scope-wrap {
display: none;
position: relative;
}
.topbar__scope-toggle {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.6875rem;
font-family: var(--font-family-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.35rem 0.55rem;
cursor: pointer;
}
.topbar__scope-toggle:hover {
border-color: var(--color-border-secondary);
color: var(--color-text-primary);
}
.topbar__scope-toggle:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.topbar__scope-panel {
position: absolute;
right: 0;
top: calc(100% + 0.4rem);
z-index: 120;
min-width: 340px;
max-width: min(92vw, 420px);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
box-shadow: var(--shadow-dropdown);
padding: 0.6rem;
}
@media (max-width: 1199px) { @media (max-width: 1199px) {
.topbar__context { .topbar__context {
display: none; display: none;
} }
.topbar__scope-wrap {
display: block;
}
} }
.topbar__right { .topbar__right {
@@ -160,6 +226,12 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
} }
} }
@media (max-width: 767px) {
.topbar__scope-panel {
right: -3.5rem;
}
}
.topbar__tenant-btn { .topbar__tenant-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -199,9 +271,38 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
export class AppTopbarComponent { export class AppTopbarComponent {
private readonly sessionStore = inject(AuthSessionStore); private readonly sessionStore = inject(AuthSessionStore);
private readonly consoleStore = inject(ConsoleSessionStore); private readonly consoleStore = inject(ConsoleSessionStore);
private readonly elementRef = inject(ElementRef<HTMLElement>);
@Output() menuToggle = new EventEmitter<void>(); @Output() menuToggle = new EventEmitter<void>();
readonly isAuthenticated = this.sessionStore.isAuthenticated; readonly isAuthenticated = this.sessionStore.isAuthenticated;
readonly activeTenant = this.consoleStore.selectedTenantId; readonly activeTenant = this.consoleStore.selectedTenantId;
readonly scopePanelOpen = signal(false);
toggleScopePanel(): void {
this.scopePanelOpen.update((open) => !open);
}
closeScopePanel(): void {
this.scopePanelOpen.set(false);
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.closeScopePanel();
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
const target = event.target as Node | null;
if (!target) {
return;
}
const host = this.elementRef.nativeElement;
const insideScope = host.querySelector('.topbar__scope-wrap')?.contains(target) ?? false;
if (!insideScope) {
this.closeScopePanel();
}
}
} }

View File

@@ -20,7 +20,7 @@ import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
class="chip" class="chip"
[class.chip--on]="isEnabled()" [class.chip--on]="isEnabled()"
[class.chip--off]="!isEnabled()" [class.chip--off]="!isEnabled()"
routerLink="/evidence-audit/trust-signing" routerLink="/platform/setup/trust-signing"
[attr.title]="tooltip()" [attr.title]="tooltip()"
> >
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"> <svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -97,3 +97,4 @@ export class EvidenceModeChipComponent {
: 'Evidence signing scopes are not active for this session.' : 'Evidence signing scopes are not active for this session.'
); );
} }

View File

@@ -22,7 +22,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
class="chip" class="chip"
[class.chip--fresh]="!isStale()" [class.chip--fresh]="!isStale()"
[class.chip--stale]="isStale()" [class.chip--stale]="isStale()"
routerLink="/platform-ops/feeds" routerLink="/platform/ops/feeds-airgap"
[attr.title]="tooltip()" [attr.title]="tooltip()"
> >
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"> <svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -122,3 +122,4 @@ export class FeedSnapshotChipComponent {
return `${freshness.message} (snapshot ${freshness.bundleCreatedAt}).`; return `${freshness.message} (snapshot ${freshness.bundleCreatedAt}).`;
}); });
} }

View File

@@ -19,7 +19,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
class="chip" class="chip"
[class.chip--ok]="status() === 'ok'" [class.chip--ok]="status() === 'ok'"
[class.chip--degraded]="status() === 'degraded'" [class.chip--degraded]="status() === 'degraded'"
routerLink="/administration/offline" routerLink="/platform/ops/offline-kit"
[attr.title]="tooltip()" [attr.title]="tooltip()"
aria-live="polite" aria-live="polite"
> >
@@ -112,3 +112,4 @@ export class OfflineStatusChipComponent {
return 'Online mode active with live backend connectivity.'; return 'Online mode active with live backend connectivity.';
}); });
} }

View File

@@ -19,7 +19,7 @@ import { PolicyPackStore } from '../../features/policy-studio/services/policy-pa
template: ` template: `
<a <a
class="chip" class="chip"
routerLink="/settings/policy" routerLink="/administration/policy-governance"
[attr.title]="tooltip()" [attr.title]="tooltip()"
> >
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"> <svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -90,3 +90,4 @@ export class PolicyBaselineChipComponent {
return `Active policy baseline: ${activePack.name} ${activePack.version}. Click to manage policies.`; return `Active policy baseline: ${activePack.name} ${activePack.version}. Click to manage policies.`;
}); });
} }

View File

@@ -7,31 +7,23 @@ import {
HostListener, HostListener,
ElementRef, ElementRef,
ViewChild, ViewChild,
OnInit,
OnDestroy,
} from '@angular/core'; } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Subject, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
/** import { SearchClient } from '../../core/api/search.client';
* Search result item structure. import type {
*/ SearchResponse,
export interface SearchResult { SearchResult as ApiSearchResult,
id: string; } from '../../core/api/search.models';
type: 'release' | 'digest' | 'cve' | 'environment' | 'target';
label: string; export type SearchResult = ApiSearchResult;
sublabel?: string;
route: string;
}
/**
* GlobalSearchComponent - Unified search across releases, digests, CVEs, environments, targets.
*
* Features:
* - Keyboard shortcut (Cmd/Ctrl+K) to open
* - Categorized results dropdown
* - Recent searches
* - Navigation on selection
*/
@Component({ @Component({
selector: 'app-global-search', selector: 'app-global-search',
standalone: true, standalone: true,
@@ -43,43 +35,37 @@ export interface SearchResult {
<circle cx="11" cy="11" r="8" fill="none" stroke="currentColor" stroke-width="2"/> <circle cx="11" cy="11" r="8" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="21" y1="21" x2="16.65" y2="16.65" stroke="currentColor" stroke-width="2"/> <line x1="21" y1="21" x2="16.65" y2="16.65" stroke="currentColor" stroke-width="2"/>
</svg> </svg>
<input <input
#searchInput #searchInput
type="text" type="text"
class="search__input" class="search__input"
placeholder="Search releases, digests, CVEs..." placeholder="Search runs, digests, CVEs, capsules, targets..."
[(ngModel)]="query" [ngModel]="query()"
(ngModelChange)="onQueryChange($event)"
(focus)="onFocus()" (focus)="onFocus()"
(blur)="onBlur()" (blur)="onBlur()"
(keydown)="onKeydown($event)" (keydown)="onKeydown($event)"
(input)="onSearch()"
aria-label="Global search" aria-label="Global search"
aria-autocomplete="list" aria-autocomplete="list"
[attr.aria-expanded]="showResults()" [attr.aria-expanded]="showResults()"
aria-controls="search-results" aria-controls="search-results"
/> />
<kbd class="search__shortcut" aria-hidden="true">{{ shortcutLabel }}</kbd> <kbd class="search__shortcut" aria-hidden="true">{{ shortcutLabel }}</kbd>
</div> </div>
<!-- Results dropdown -->
@if (showResults()) { @if (showResults()) {
<div <div class="search__results" id="search-results" role="listbox" tabindex="-1">
class="search__results"
id="search-results"
role="listbox"
tabindex="-1"
>
@if (isLoading()) { @if (isLoading()) {
<div class="search__loading">Searching...</div> <div class="search__loading">Searching...</div>
} @else if (results().length === 0 && query().trim().length > 0) { } @else if (query().trim().length >= 2 && groupedResults().length === 0) {
<div class="search__empty">No results found</div> <div class="search__empty">No results found</div>
} @else { } @else if (query().trim().length >= 2) {
@for (group of groupedResults(); track group.type) { @for (group of groupedResults(); track group.type) {
<div class="search__group"> <div class="search__group">
<div class="search__group-label">{{ group.label }}</div> <div class="search__group-label">{{ group.label }} ({{ group.totalCount }})</div>
@for (result of group.items; track result.id; let i = $index) { @for (result of group.results; track result.id; let i = $index) {
<button <button
type="button" type="button"
class="search__result" class="search__result"
@@ -88,78 +74,86 @@ export interface SearchResult {
[attr.aria-selected]="selectedIndex() === getResultIndex(group.type, i)" [attr.aria-selected]="selectedIndex() === getResultIndex(group.type, i)"
(click)="onSelect(result)" (click)="onSelect(result)"
(mouseenter)="selectedIndex.set(getResultIndex(group.type, i))" (mouseenter)="selectedIndex.set(getResultIndex(group.type, i))"
> >
<span class="search__result-icon"> <span class="search__result-icon">
@switch (result.type) { @switch (result.type) {
@case ('release') { @case ('artifact') {
<svg viewBox="0 0 24 24" width="16" height="16"> <svg viewBox="0 0 24 24" width="16" height="16">
<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" fill="none" stroke="currentColor" stroke-width="2"/> <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" fill="none" stroke="currentColor" stroke-width="2"/>
</svg> </svg>
} }
@case ('digest') {
<svg viewBox="0 0 24 24" width="16" height="16">
<rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="7" y1="8" x2="17" y2="8" stroke="currentColor" stroke-width="2"/>
<line x1="7" y1="12" x2="17" y2="12" stroke="currentColor" stroke-width="2"/>
<line x1="7" y1="16" x2="13" y2="16" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('cve') { @case ('cve') {
<svg viewBox="0 0 24 24" width="16" height="16"> <svg viewBox="0 0 24 24" width="16" height="16">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" fill="none" stroke="currentColor" stroke-width="2"/> <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg> </svg>
} }
@case ('environment') { @case ('policy') {
<svg viewBox="0 0 24 24" width="16" height="16"> <svg viewBox="0 0 24 24" width="16" height="16">
<rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/> <path d="M12 2l8 4v6c0 5-3.5 9.5-8 10-4.5-.5-8-5-8-10V6l8-4z" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="21" x2="16" y2="21" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" stroke-width="2"/>
</svg> </svg>
} }
@case ('target') { @case ('job') {
<svg viewBox="0 0 24 24" width="16" height="16">
<rect x="3" y="5" width="18" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M7 9h10M7 13h6" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('finding') {
<svg viewBox="0 0 24 24" width="16" height="16"> <svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/> <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="12" r="6" fill="none" stroke="currentColor" stroke-width="2"/> <line x1="12" y1="8" x2="12" y2="12" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="12" r="2" fill="currentColor"/> <line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('vex') {
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M12 2l8 4v6c0 5-3.5 9.5-8 10-4.5-.5-8-5-8-10V6l8-4z" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="9 12 11 14 15 10" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('integration') {
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M7 7h4v4H7zM13 13h4v4h-4zM11 9h2v2h-2zM9 11h2v2H9zM13 11h2v2h-2z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg> </svg>
} }
} }
</span> </span>
<span class="search__result-text"> <span class="search__result-text">
<span class="search__result-label">{{ result.label }}</span> <span class="search__result-label">{{ result.title }}</span>
@if (result.sublabel) { @if (result.subtitle) {
<span class="search__result-sublabel">{{ result.sublabel }}</span> <span class="search__result-sublabel">{{ result.subtitle }}</span>
} }
</span> </span>
</button> </button>
} }
</div> </div>
} }
} @else {
<!-- Recent searches --> <div class="search__group">
@if (recentSearches().length > 0 && query().trim().length === 0) { <div class="search__group-label">Recent</div>
<div class="search__group"> @for (recent of recentSearches(); track recent; let i = $index) {
<div class="search__group-label">Recent</div> <button
@for (recent of recentSearches(); track recent) { type="button"
<button class="search__result"
type="button" [class.search__result--selected]="selectedIndex() === i"
class="search__result" (click)="selectRecent(recent)"
(click)="query.set(recent); onSearch()" (mouseenter)="selectedIndex.set(i)"
> >
<svg class="search__result-icon" viewBox="0 0 24 24" width="16" height="16"> <svg class="search__result-icon" viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/> <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="12 6 12 12 16 14" fill="none" stroke="currentColor" stroke-width="2"/> <polyline points="12 6 12 12 16 14" fill="none" stroke="currentColor" stroke-width="2"/>
</svg> </svg>
<span class="search__result-label">{{ recent }}</span> <span class="search__result-label">{{ recent }}</span>
</button> </button>
} } @empty {
</div> <div class="search__empty">Type at least 2 characters</div>
} }
</div>
} }
</div> </div>
} }
</div> </div>
`, `,
styles: [` styles: [`
.search { .search {
position: relative; position: relative;
@@ -314,57 +308,73 @@ export interface SearchResult {
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class GlobalSearchComponent { export class GlobalSearchComponent implements OnInit, OnDestroy {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly elementRef = inject(ElementRef); private readonly searchClient = inject(SearchClient);
private readonly destroy$ = new Subject<void>();
private readonly searchTerms$ = new Subject<string>();
private readonly recentSearchStorageKey = 'stella-recent-searches';
@ViewChild('searchInput') searchInputRef!: ElementRef<HTMLInputElement>; @ViewChild('searchInput') searchInputRef!: ElementRef<HTMLInputElement>;
/** Show Ctrl+K on Windows/Linux, ⌘K on macOS */
readonly shortcutLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K'; readonly shortcutLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K';
readonly query = signal(''); readonly query = signal('');
readonly isFocused = signal(false); readonly isFocused = signal(false);
readonly isLoading = signal(false); readonly isLoading = signal(false);
readonly results = signal<SearchResult[]>([]);
readonly selectedIndex = signal(0); readonly selectedIndex = signal(0);
readonly searchResponse = signal<SearchResponse | null>(null);
readonly recentSearches = signal<string[]>([]); readonly recentSearches = signal<string[]>([]);
readonly showResults = computed(() => this.isFocused() && (this.query().trim().length > 0 || this.recentSearches().length > 0)); readonly showResults = computed(() => this.isFocused() && (this.query().trim().length > 0 || this.recentSearches().length > 0));
readonly groupedResults = computed(() => this.searchResponse()?.groups ?? []);
readonly flatResults = computed(() => this.groupedResults().flatMap((group) => group.results));
readonly groupedResults = computed(() => { ngOnInit(): void {
const groups: { type: string; label: string; items: SearchResult[] }[] = []; this.searchTerms$
const resultsByType = new Map<string, SearchResult[]>(); .pipe(
debounceTime(200),
distinctUntilChanged(),
switchMap((term) => {
if (term.length < 2) {
this.searchResponse.set(null);
this.isLoading.set(false);
this.selectedIndex.set(0);
return of(null);
}
for (const result of this.results()) { this.isLoading.set(true);
if (!resultsByType.has(result.type)) { return this.searchClient.search(term).pipe(
resultsByType.set(result.type, []); catchError(() =>
} of({
resultsByType.get(result.type)!.push(result); query: term,
} groups: [],
totalCount: 0,
durationMs: 0,
} satisfies SearchResponse),
),
);
}),
takeUntil(this.destroy$),
)
.subscribe((response) => {
if (!response) {
return;
}
const typeLabels: Record<string, string> = { this.searchResponse.set(response);
release: 'Releases', this.selectedIndex.set(0);
digest: 'Digests', this.isLoading.set(false);
cve: 'Vulnerabilities',
environment: 'Environments',
target: 'Targets',
};
for (const [type, items] of resultsByType) {
groups.push({
type,
label: typeLabels[type] || type,
items,
}); });
} }
return groups; ngOnDestroy(): void {
}); this.destroy$.next();
this.destroy$.complete();
}
@HostListener('document:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
onGlobalKeydown(event: KeyboardEvent): void { onGlobalKeydown(event: KeyboardEvent): void {
// Cmd/Ctrl+K to focus search
if ((event.metaKey || event.ctrlKey) && event.key === 'k') { if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault(); event.preventDefault();
this.searchInputRef?.nativeElement?.focus(); this.searchInputRef?.nativeElement?.focus();
@@ -377,31 +387,37 @@ export class GlobalSearchComponent {
} }
onBlur(): void { onBlur(): void {
// Delay to allow click on results
setTimeout(() => { setTimeout(() => {
this.isFocused.set(false); this.isFocused.set(false);
}, 200); }, 200);
} }
onKeydown(event: KeyboardEvent): void { onQueryChange(value: string): void {
const results = this.results(); this.query.set(value);
const totalResults = results.length; this.selectedIndex.set(0);
this.searchTerms$.next(value.trim());
}
onKeydown(event: KeyboardEvent): void {
const count = this.getNavigableItemCount();
switch (event.key) { switch (event.key) {
case 'ArrowDown': case 'ArrowDown':
if (count === 0) {
return;
}
event.preventDefault(); event.preventDefault();
this.selectedIndex.update((i) => (i + 1) % totalResults); this.selectedIndex.update((index) => (index + 1) % count);
break; break;
case 'ArrowUp': case 'ArrowUp':
if (count === 0) {
return;
}
event.preventDefault(); event.preventDefault();
this.selectedIndex.update((i) => (i - 1 + totalResults) % totalResults); this.selectedIndex.update((index) => (index - 1 + count) % count);
break; break;
case 'Enter': case 'Enter':
event.preventDefault(); event.preventDefault();
const selected = results[this.selectedIndex()]; this.selectCurrent();
if (selected) {
this.onSelect(selected);
}
break; break;
case 'Escape': case 'Escape':
this.isFocused.set(false); this.isFocused.set(false);
@@ -410,93 +426,62 @@ export class GlobalSearchComponent {
} }
} }
onSearch(): void {
const q = this.query().trim();
if (q.length < 2) {
this.results.set([]);
return;
}
this.isLoading.set(true);
// TODO: Wire to actual search API
// Mock results for now
setTimeout(() => {
const mockResults: SearchResult[] = [];
// Match releases
if (q.startsWith('v') || q.match(/^\d/)) {
mockResults.push({
id: 'rel-1',
type: 'release',
label: `v1.2.5`,
sublabel: 'sha256:7aa...2f',
route: '/releases/v1.2.5',
});
}
// Match digests
if (q.startsWith('sha') || q.match(/^[0-9a-f]{6,}/i)) {
mockResults.push({
id: 'dig-1',
type: 'digest',
label: 'sha256:7aa...2f',
sublabel: 'Release v1.2.5',
route: '/releases/v1.2.5',
});
}
// Match CVEs
if (q.toUpperCase().startsWith('CVE') || q.match(/^\d{4}-\d+/)) {
mockResults.push({
id: 'cve-1',
type: 'cve',
label: 'CVE-2026-12345',
sublabel: 'Critical - Remote Code Execution',
route: '/security/vulnerabilities/CVE-2026-12345',
});
}
// Match environments
if (['dev', 'qa', 'stag', 'prod'].some((e) => e.includes(q.toLowerCase()))) {
mockResults.push({
id: 'env-1',
type: 'environment',
label: 'Production',
sublabel: '5 targets',
route: '/topology/regions',
});
}
this.results.set(mockResults);
this.selectedIndex.set(0);
this.isLoading.set(false);
}, 200);
}
onSelect(result: SearchResult): void { onSelect(result: SearchResult): void {
this.saveRecentSearch(this.query()); this.saveRecentSearch(this.query());
this.query.set(''); this.query.set('');
this.selectedIndex.set(0);
this.searchResponse.set(null);
this.isFocused.set(false); this.isFocused.set(false);
void this.router.navigate([result.route]); void this.router.navigateByUrl(result.route);
} }
getResultIndex(type: string, indexInGroup: number): number { selectRecent(query: string): void {
this.query.set(query);
this.searchTerms$.next(query.trim());
}
getResultIndex(groupType: string, indexInGroup: number): number {
let offset = 0; let offset = 0;
for (const group of this.groupedResults()) { for (const group of this.groupedResults()) {
if (group.type === type) { if (group.type === groupType) {
return offset + indexInGroup; return offset + indexInGroup;
} }
offset += group.items.length; offset += group.results.length;
} }
return 0; return 0;
} }
private selectCurrent(): void {
if (this.query().trim().length >= 2) {
const selected = this.flatResults()[this.selectedIndex()];
if (selected) {
this.onSelect(selected);
}
return;
}
const recent = this.recentSearches()[this.selectedIndex()];
if (!recent) {
return;
}
this.selectRecent(recent);
}
private getNavigableItemCount(): number {
if (this.query().trim().length >= 2) {
return this.flatResults().length;
}
return this.recentSearches().length;
}
private loadRecentSearches(): void { private loadRecentSearches(): void {
try { try {
const stored = localStorage.getItem('stella-recent-searches'); const stored = localStorage.getItem(this.recentSearchStorageKey);
if (stored) { if (stored) {
this.recentSearches.set(JSON.parse(stored)); const parsed = JSON.parse(stored);
this.recentSearches.set(Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []);
} else {
this.recentSearches.set([]);
} }
} catch { } catch {
this.recentSearches.set([]); this.recentSearches.set([]);
@@ -504,16 +489,17 @@ export class GlobalSearchComponent {
} }
private saveRecentSearch(query: string): void { private saveRecentSearch(query: string): void {
if (!query.trim()) return; const normalized = query.trim();
if (!normalized) {
const recent = this.recentSearches(); return;
const updated = [query, ...recent.filter((r) => r !== query)].slice(0, 5); }
this.recentSearches.set(updated);
const next = [normalized, ...this.recentSearches().filter((item) => item !== normalized)].slice(0, 5);
this.recentSearches.set(next);
try { try {
localStorage.setItem('stella-recent-searches', JSON.stringify(updated)); localStorage.setItem(this.recentSearchStorageKey, JSON.stringify(next));
} catch { } catch {
// Ignore storage errors // Ignore localStorage failures.
} }
} }
} }

View File

@@ -7,8 +7,8 @@ import { Routes } from '@angular/router';
export const DASHBOARD_ROUTES: Routes = [ export const DASHBOARD_ROUTES: Routes = [
{ {
path: '', path: '',
title: 'Dashboard', title: 'Mission Control',
data: { breadcrumb: 'Dashboard' }, data: { breadcrumb: 'Mission Control' },
loadComponent: () => loadComponent: () =>
import('../features/dashboard-v3/dashboard-v3.component').then( import('../features/dashboard-v3/dashboard-v3.component').then(
(m) => m.DashboardV3Component (m) => m.DashboardV3Component

View File

@@ -4,7 +4,7 @@ export const EVIDENCE_ROUTES: Routes = [
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'overview', redirectTo: 'capsules',
}, },
{ {
path: 'overview', path: 'overview',
@@ -56,8 +56,8 @@ export const EVIDENCE_ROUTES: Routes = [
}, },
{ {
path: 'verification/replay', path: 'verification/replay',
title: 'Replay & Determinism', title: 'Replay & Verify',
data: { breadcrumb: 'Verify & Replay' }, data: { breadcrumb: 'Replay & Verify' },
loadComponent: () => loadComponent: () =>
import('../features/evidence-export/replay-controls.component').then( import('../features/evidence-export/replay-controls.component').then(
(m) => m.ReplayControlsComponent, (m) => m.ReplayControlsComponent,

View File

@@ -60,7 +60,7 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// =========================================== // ===========================================
{ path: 'release-control', redirectTo: '/releases', pathMatch: 'full' }, { path: 'release-control', redirectTo: '/releases', pathMatch: 'full' },
{ path: 'release-control/releases', redirectTo: '/releases', pathMatch: 'full' }, { path: 'release-control/releases', redirectTo: '/releases', pathMatch: 'full' },
{ path: 'release-control/releases/:id', redirectTo: '/releases/:id', pathMatch: 'full' }, { path: 'release-control/releases/:id', redirectTo: '/releases/runs/:id/timeline', pathMatch: 'full' },
{ path: 'release-control/approvals', redirectTo: '/releases/approvals', pathMatch: 'full' }, { path: 'release-control/approvals', redirectTo: '/releases/approvals', pathMatch: 'full' },
{ path: 'release-control/approvals/:id', redirectTo: '/releases/approvals/:id', pathMatch: 'full' }, { path: 'release-control/approvals/:id', redirectTo: '/releases/approvals/:id', pathMatch: 'full' },
{ path: 'release-control/runs', redirectTo: '/releases/runs', pathMatch: 'full' }, { path: 'release-control/runs', redirectTo: '/releases/runs', pathMatch: 'full' },
@@ -69,25 +69,25 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
{ path: 'release-control/hotfixes', redirectTo: '/releases/hotfix', pathMatch: 'full' }, { path: 'release-control/hotfixes', redirectTo: '/releases/hotfix', pathMatch: 'full' },
{ path: 'release-control/regions', redirectTo: '/topology/regions', pathMatch: 'full' }, { path: 'release-control/regions', redirectTo: '/topology/regions', pathMatch: 'full' },
{ path: 'release-control/regions/:region', redirectTo: '/topology/regions', pathMatch: 'full' }, { path: 'release-control/regions/:region', redirectTo: '/topology/regions', pathMatch: 'full' },
{ path: 'release-control/regions/:region/environments/:env', redirectTo: '/topology/environments', pathMatch: 'full' }, { path: 'release-control/regions/:region/environments/:env', redirectTo: '/topology/environments/:env/posture', pathMatch: 'full' },
{ path: 'release-control/setup', redirectTo: '/platform/setup', pathMatch: 'full' }, { path: 'release-control/setup', redirectTo: '/topology/promotion-graph', pathMatch: 'full' },
{ path: 'release-control/setup/environments-paths', redirectTo: '/topology/promotion-paths', pathMatch: 'full' }, { path: 'release-control/setup/environments-paths', redirectTo: '/topology/promotion-graph', pathMatch: 'full' },
{ path: 'release-control/setup/targets-agents', redirectTo: '/topology/targets', pathMatch: 'full' }, { path: 'release-control/setup/targets-agents', redirectTo: '/topology/targets', pathMatch: 'full' },
{ path: 'release-control/setup/workflows', redirectTo: '/platform/setup/workflows-gates', pathMatch: 'full' }, { path: 'release-control/setup/workflows', redirectTo: '/topology/workflows', pathMatch: 'full' },
{ path: 'release-control/governance', redirectTo: '/platform/setup/workflows-gates', pathMatch: 'full' }, { path: 'release-control/governance', redirectTo: '/topology/workflows', pathMatch: 'full' },
{ path: 'security-risk', redirectTo: '/security', pathMatch: 'full' }, { path: 'security-risk', redirectTo: '/security', pathMatch: 'full' },
{ path: 'security-risk/findings', redirectTo: '/security/triage', pathMatch: 'full' }, { path: 'security-risk/findings', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'security-risk/findings/:findingId', redirectTo: '/security/triage/:findingId', pathMatch: 'full' }, { path: 'security-risk/findings/:findingId', redirectTo: '/security/triage/:findingId', pathMatch: 'full' },
{ path: 'security-risk/vulnerabilities', redirectTo: '/security/triage', pathMatch: 'full' }, { path: 'security-risk/vulnerabilities', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'security-risk/vulnerabilities/:vulnId', redirectTo: '/security/triage', pathMatch: 'full' }, { path: 'security-risk/vulnerabilities/:vulnId', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'security-risk/sbom', redirectTo: '/security/supply-chain-data/graph', pathMatch: 'full' }, { path: 'security-risk/sbom', redirectTo: '/security/sbom/graph', pathMatch: 'full' },
{ path: 'security-risk/sbom-lake', redirectTo: '/security/supply-chain-data/lake', pathMatch: 'full' }, { path: 'security-risk/sbom-lake', redirectTo: '/security/sbom/lake', pathMatch: 'full' },
{ path: 'security-risk/vex', redirectTo: '/security/advisories-vex', pathMatch: 'full' }, { path: 'security-risk/vex', redirectTo: '/security/disposition', pathMatch: 'full' },
{ path: 'security-risk/exceptions', redirectTo: '/security/advisories-vex', pathMatch: 'full' }, { path: 'security-risk/exceptions', redirectTo: '/security/disposition', pathMatch: 'full' },
{ path: 'security-risk/advisory-sources', redirectTo: '/platform/integrations/feeds', pathMatch: 'full' }, { path: 'security-risk/advisory-sources', redirectTo: '/platform/integrations/feeds', pathMatch: 'full' },
{ path: 'evidence-audit', redirectTo: '/evidence/overview', pathMatch: 'full' }, { path: 'evidence-audit', redirectTo: '/evidence/capsules', pathMatch: 'full' },
{ path: 'evidence-audit/packs', redirectTo: '/evidence/capsules', pathMatch: 'full' }, { path: 'evidence-audit/packs', redirectTo: '/evidence/capsules', pathMatch: 'full' },
{ path: 'evidence-audit/packs/:packId', redirectTo: '/evidence/capsules/:packId', pathMatch: 'full' }, { path: 'evidence-audit/packs/:packId', redirectTo: '/evidence/capsules/:packId', pathMatch: 'full' },
{ path: 'evidence-audit/bundles', redirectTo: '/evidence/exports', pathMatch: 'full' }, { path: 'evidence-audit/bundles', redirectTo: '/evidence/exports', pathMatch: 'full' },
@@ -116,23 +116,23 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// =========================================== // ===========================================
{ path: 'findings', redirectTo: '/security/triage', pathMatch: 'full' }, { path: 'findings', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'findings/:scanId', redirectTo: '/security/scans/:scanId', pathMatch: 'full' }, { path: 'findings/:scanId', redirectTo: '/security/scans/:scanId', pathMatch: 'full' },
{ path: 'security/sbom', redirectTo: '/security/supply-chain-data/graph', pathMatch: 'full' }, { path: 'security/sbom', redirectTo: '/security/sbom/graph', pathMatch: 'full' },
{ path: 'security/vex', redirectTo: '/security/advisories-vex', pathMatch: 'full' }, { path: 'security/vex', redirectTo: '/security/disposition', pathMatch: 'full' },
{ path: 'security/exceptions', redirectTo: '/security/advisories-vex', pathMatch: 'full' }, { path: 'security/exceptions', redirectTo: '/security/disposition', pathMatch: 'full' },
{ path: 'security/advisory-sources', redirectTo: '/platform/integrations/feeds', pathMatch: 'full' }, { path: 'security/advisory-sources', redirectTo: '/platform/integrations/feeds', pathMatch: 'full' },
{ path: 'scans/:scanId', redirectTo: '/security/scans/:scanId', pathMatch: 'full' }, { path: 'scans/:scanId', redirectTo: '/security/scans/:scanId', pathMatch: 'full' },
{ path: 'vulnerabilities', redirectTo: '/security/triage', pathMatch: 'full' }, { path: 'vulnerabilities', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'vulnerabilities/:vulnId', redirectTo: '/security/triage', pathMatch: 'full' }, { path: 'vulnerabilities/:vulnId', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'graph', redirectTo: '/security/supply-chain-data/graph', pathMatch: 'full' }, { path: 'graph', redirectTo: '/security/sbom/graph', pathMatch: 'full' },
{ path: 'lineage', redirectTo: '/security/lineage', pathMatch: 'full' }, { path: 'lineage', redirectTo: '/security/lineage', pathMatch: 'full' },
{ path: 'lineage/:artifact/compare', redirectTo: '/security/lineage/:artifact/compare', pathMatch: 'full' }, { path: 'lineage/:artifact/compare', redirectTo: '/security/lineage/:artifact/compare', pathMatch: 'full' },
{ path: 'lineage/compare', redirectTo: '/security/lineage/compare', pathMatch: 'full' }, { path: 'lineage/compare', redirectTo: '/security/lineage/compare', pathMatch: 'full' },
{ path: 'compare/:currentId', redirectTo: '/security/lineage/compare/:currentId', pathMatch: 'full' }, { path: 'compare/:currentId', redirectTo: '/security/lineage/compare/:currentId', pathMatch: 'full' },
{ path: 'reachability', redirectTo: '/security/findings', pathMatch: 'full' }, { path: 'reachability', redirectTo: '/security/reachability', pathMatch: 'full' },
{ path: 'analyze/unknowns', redirectTo: '/security/unknowns', pathMatch: 'full' }, { path: 'analyze/unknowns', redirectTo: '/security/unknowns', pathMatch: 'full' },
{ path: 'analyze/patch-map', redirectTo: '/security/patch-map', pathMatch: 'full' }, { path: 'analyze/patch-map', redirectTo: '/security/patch-map', pathMatch: 'full' },
{ path: 'analytics', redirectTo: '/security/supply-chain-data/lake', pathMatch: 'full' }, { path: 'analytics', redirectTo: '/security/sbom/lake', pathMatch: 'full' },
{ path: 'analytics/sbom-lake', redirectTo: '/security/supply-chain-data/lake', pathMatch: 'full' }, { path: 'analytics/sbom-lake', redirectTo: '/security/sbom/lake', pathMatch: 'full' },
{ path: 'cvss/receipts/:receiptId', redirectTo: '/evidence/receipts/cvss/:receiptId', pathMatch: 'full' }, { path: 'cvss/receipts/:receiptId', redirectTo: '/evidence/receipts/cvss/:receiptId', pathMatch: 'full' },
// =========================================== // ===========================================
@@ -214,14 +214,18 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// =========================================== // ===========================================
// Integrations -> Integrations // Integrations -> Integrations
// =========================================== // ===========================================
{ path: 'sbom-sources', redirectTo: '/integrations/sbom-sources', pathMatch: 'full' }, { path: 'sbom-sources', redirectTo: '/platform/integrations/sbom-sources', pathMatch: 'full' },
// =========================================== // ===========================================
// Settings -> canonical v2 domains // Settings -> canonical v2 domains
// =========================================== // ===========================================
{ path: 'settings/integrations', redirectTo: '/platform/integrations', pathMatch: 'full' }, { path: 'settings/integrations', redirectTo: '/platform/integrations', pathMatch: 'full' },
{ path: 'settings/integrations/:id', redirectTo: '/platform/integrations/:id', pathMatch: 'full' }, { path: 'settings/integrations/:id', redirectTo: '/platform/integrations/:id', pathMatch: 'full' },
{ path: 'settings/release-control', redirectTo: '/platform/setup', pathMatch: 'full' }, { path: 'settings/release-control', redirectTo: '/topology/promotion-graph', pathMatch: 'full' },
{ path: 'settings/release-control/environments', redirectTo: '/topology/regions', pathMatch: 'full' },
{ path: 'settings/release-control/targets', redirectTo: '/topology/targets', pathMatch: 'full' },
{ path: 'settings/release-control/agents', redirectTo: '/topology/agents', pathMatch: 'full' },
{ path: 'settings/release-control/workflows', redirectTo: '/topology/workflows', pathMatch: 'full' },
{ path: 'settings/trust', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' }, { path: 'settings/trust', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' },
{ path: 'settings/trust/:page', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' }, { path: 'settings/trust/:page', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' },
{ path: 'settings/policy', redirectTo: '/administration/policy-governance', pathMatch: 'full' }, { path: 'settings/policy', redirectTo: '/administration/policy-governance', pathMatch: 'full' },
@@ -241,8 +245,8 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
{ path: 'release-orchestrator/releases', redirectTo: '/releases', pathMatch: 'full' }, { path: 'release-orchestrator/releases', redirectTo: '/releases', pathMatch: 'full' },
{ path: 'release-orchestrator/approvals', redirectTo: '/releases/approvals', pathMatch: 'full' }, { path: 'release-orchestrator/approvals', redirectTo: '/releases/approvals', pathMatch: 'full' },
{ path: 'release-orchestrator/deployments', redirectTo: '/releases/runs', pathMatch: 'full' }, { path: 'release-orchestrator/deployments', redirectTo: '/releases/runs', pathMatch: 'full' },
{ path: 'release-orchestrator/workflows', redirectTo: '/platform/setup/workflows-gates', pathMatch: 'full' }, { path: 'release-orchestrator/workflows', redirectTo: '/topology/workflows', pathMatch: 'full' },
{ path: 'release-orchestrator/evidence', redirectTo: '/evidence/overview', pathMatch: 'full' }, { path: 'release-orchestrator/evidence', redirectTo: '/evidence/capsules', pathMatch: 'full' },
// =========================================== // ===========================================
// Evidence -> Evidence & Audit // Evidence -> Evidence & Audit

View File

@@ -38,6 +38,15 @@ export const OPERATIONS_ROUTES: Routes = [
(m) => m.dataIntegrityRoutes, (m) => m.dataIntegrityRoutes,
), ),
}, },
{
path: 'system-health',
title: 'System Health',
data: { breadcrumb: 'System Health' },
loadComponent: () =>
import('../features/system-health/system-health-page.component').then(
(m) => m.SystemHealthPageComponent,
),
},
{ {
path: 'health-slo', path: 'health-slo',
title: 'Health & SLO', title: 'Health & SLO',

View File

@@ -4,12 +4,12 @@ export const SECURITY_ROUTES: Routes = [
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'overview', redirectTo: 'posture',
}, },
{ {
path: 'overview', path: 'posture',
title: 'Security Overview', title: 'Security Posture',
data: { breadcrumb: 'Overview' }, data: { breadcrumb: 'Posture' },
loadComponent: () => loadComponent: () =>
import('../features/security-risk/security-risk-overview.component').then( import('../features/security-risk/security-risk-overview.component').then(
(m) => m.SecurityRiskOverviewComponent, (m) => m.SecurityRiskOverviewComponent,
@@ -34,23 +34,23 @@ export const SECURITY_ROUTES: Routes = [
), ),
}, },
{ {
path: 'advisories-vex', path: 'disposition',
title: 'Advisories & VEX', title: 'Disposition Center',
data: { breadcrumb: 'Advisories & VEX' }, data: { breadcrumb: 'Disposition Center' },
loadComponent: () => loadComponent: () =>
import('../features/security/security-disposition-page.component').then( import('../features/security/security-disposition-page.component').then(
(m) => m.SecurityDispositionPageComponent, (m) => m.SecurityDispositionPageComponent,
), ),
}, },
{ {
path: 'supply-chain-data', path: 'sbom',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'supply-chain-data/lake', redirectTo: 'sbom/lake',
}, },
{ {
path: 'supply-chain-data/:mode', path: 'sbom/:mode',
title: 'Supply-Chain Data', title: 'SBOM',
data: { breadcrumb: 'Supply-Chain Data' }, data: { breadcrumb: 'SBOM' },
loadComponent: () => loadComponent: () =>
import('../features/security/security-sbom-explorer-page.component').then( import('../features/security/security-sbom-explorer-page.component').then(
(m) => m.SecuritySbomExplorerPageComponent, (m) => m.SecuritySbomExplorerPageComponent,
@@ -68,9 +68,9 @@ export const SECURITY_ROUTES: Routes = [
// Canonical compatibility aliases. // Canonical compatibility aliases.
{ {
path: 'posture', path: 'overview',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'overview', redirectTo: 'posture',
}, },
{ {
path: 'findings', path: 'findings',
@@ -83,34 +83,28 @@ export const SECURITY_ROUTES: Routes = [
redirectTo: 'triage/:findingId', redirectTo: 'triage/:findingId',
}, },
{ {
path: 'disposition', path: 'advisories-vex',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'advisories-vex', redirectTo: 'disposition',
},
{
path: 'sbom',
pathMatch: 'full',
redirectTo: 'supply-chain-data/lake',
},
{
path: 'sbom/:mode',
pathMatch: 'full',
redirectTo: 'supply-chain-data/:mode',
}, },
{ {
path: 'reachability', path: 'reachability',
pathMatch: 'full', title: 'Reachability',
redirectTo: 'triage', data: { breadcrumb: 'Reachability' },
loadComponent: () =>
import('../features/reachability/reachability-center.component').then(
(m) => m.ReachabilityCenterComponent,
),
}, },
{ {
path: 'vex', path: 'vex',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'advisories-vex', redirectTo: 'disposition',
}, },
{ {
path: 'exceptions', path: 'exceptions',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'advisories-vex', redirectTo: 'disposition',
}, },
{ {
path: 'advisory-sources', path: 'advisory-sources',
@@ -132,12 +126,22 @@ export const SECURITY_ROUTES: Routes = [
{ {
path: 'sbom-explorer', path: 'sbom-explorer',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'supply-chain-data/lake', redirectTo: 'sbom/lake',
}, },
{ {
path: 'sbom-explorer/:mode', path: 'sbom-explorer/:mode',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'supply-chain-data/:mode', redirectTo: 'sbom/:mode',
},
{
path: 'supply-chain-data',
pathMatch: 'full',
redirectTo: 'sbom/lake',
},
{
path: 'supply-chain-data/:mode',
pathMatch: 'full',
redirectTo: 'sbom/:mode',
}, },
{ {

View File

@@ -105,11 +105,11 @@ export const TOPOLOGY_ROUTES: Routes = [
), ),
}, },
{ {
path: 'promotion-paths', path: 'promotion-graph',
title: 'Topology Promotion Paths', title: 'Topology Promotion Graph',
data: { data: {
breadcrumb: 'Promotion Paths', breadcrumb: 'Promotion Graph',
title: 'Promotion Paths', title: 'Promotion Graph',
description: 'Promotion path configurations and gate ownership.', description: 'Promotion path configurations and gate ownership.',
}, },
loadComponent: () => loadComponent: () =>
@@ -117,6 +117,11 @@ export const TOPOLOGY_ROUTES: Routes = [
(m) => m.TopologyPromotionPathsPageComponent, (m) => m.TopologyPromotionPathsPageComponent,
), ),
}, },
{
path: 'promotion-paths',
pathMatch: 'full',
redirectTo: 'promotion-graph',
},
{ {
path: 'workflows', path: 'workflows',
title: 'Topology Workflows', title: 'Topology Workflows',

View File

@@ -29,6 +29,7 @@ import {
addRecentSearch, addRecentSearch,
clearRecentSearches, clearRecentSearches,
} from '../../../core/api/search.models'; } from '../../../core/api/search.models';
import { DoctorQuickCheckService } from '../../../features/doctor/services/doctor-quick-check.service';
@Component({ @Component({
selector: 'app-command-palette', selector: 'app-command-palette',
@@ -203,6 +204,7 @@ import {
export class CommandPaletteComponent implements OnInit, OnDestroy { export class CommandPaletteComponent implements OnInit, OnDestroy {
private readonly searchClient = inject(SearchClient); private readonly searchClient = inject(SearchClient);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly doctorQuickCheck = inject(DoctorQuickCheckService);
private readonly destroy$ = new Subject<void>(); private readonly destroy$ = new Subject<void>();
private readonly searchQuery$ = new Subject<string>(); private readonly searchQuery$ = new Subject<string>();
@@ -215,15 +217,23 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
searchResponse = signal<SearchResponse | null>(null); searchResponse = signal<SearchResponse | null>(null);
recentSearches = signal<RecentSearch[]>([]); recentSearches = signal<RecentSearch[]>([]);
quickActions = DEFAULT_QUICK_ACTIONS; quickActions: QuickAction[] = DEFAULT_QUICK_ACTIONS;
readonly highlightMatch = highlightMatch; readonly highlightMatch = highlightMatch;
isActionMode = computed(() => this.query.startsWith('>')); isActionMode = computed(() => this.query.startsWith('>'));
filteredActions = computed(() => filterQuickActions(this.query)); filteredActions = computed(() => filterQuickActions(this.query, this.quickActions));
private flatResults: SearchResult[] = []; private flatResults: SearchResult[] = [];
ngOnInit(): void { ngOnInit(): void {
// Merge Doctor quick actions (with bound callbacks) into the actions list
const doctorActions = this.doctorQuickCheck.getQuickActions();
const doctorIds = new Set(doctorActions.map((a) => a.id));
this.quickActions = [
...DEFAULT_QUICK_ACTIONS.filter((a) => !doctorIds.has(a.id)),
...doctorActions,
];
this.recentSearches.set(getRecentSearches()); this.recentSearches.set(getRecentSearches());
this.searchQuery$.pipe(debounceTime(200), distinctUntilChanged(), takeUntil(this.destroy$)) this.searchQuery$.pipe(debounceTime(200), distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe((query) => { .subscribe((query) => {
@@ -345,7 +355,15 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
viewAllResults(type: string): void { viewAllResults(type: string): void {
this.close(); this.close();
const routes: Record<string, string> = { cve: '/vulnerabilities', artifact: '/triage/artifacts', policy: '/policy-studio/packs', job: '/platform-ops/orchestrator/jobs', finding: '/findings', vex: '/admin/vex-hub', integration: '/integrations' }; const routes: Record<string, string> = {
cve: '/security/triage',
artifact: '/security/triage',
policy: '/policy-studio/packs',
job: '/platform/ops/jobs-queues',
finding: '/security/triage',
vex: '/security/disposition',
integration: '/platform/integrations',
};
if (routes[type]) this.router.navigate([routes[type]], { queryParams: { q: this.query } }); if (routes[type]) this.router.navigate([routes[type]], { queryParams: { q: this.query } });
} }
} }

View File

@@ -0,0 +1,168 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core';
type ImpactLevel = 'BLOCKING' | 'DEGRADED' | 'INFO';
@Component({
selector: 'app-degraded-state-banner',
standalone: true,
template: `
<section class="impact" [class]="'impact impact--' + impactTone()">
<header class="impact__header">
<span class="impact__badge">{{ impact }}</span>
<h3>{{ title }}</h3>
</header>
<p class="impact__message">{{ message }}</p>
<div class="impact__meta">
@if (lastKnownGoodAt) {
<span>Last known good: {{ formatTime(lastKnownGoodAt) }}</span>
}
@if (readOnly) {
<span>Mode: read-only fallback</span>
}
</div>
<div class="impact__actions">
@if (retryable) {
<button type="button" (click)="retryRequested.emit()">{{ retryLabel }}</button>
}
@if (correlationId) {
<button type="button" (click)="copyCorrelationId()">
{{ copied() ? 'Copied' : 'Copy Correlation ID' }}
</button>
<code>{{ correlationId }}</code>
}
</div>
</section>
`,
styles: [`
.impact {
border: 1px solid var(--color-border-primary);
border-left-width: 4px;
border-radius: var(--radius-md);
padding: 0.55rem 0.65rem;
background: var(--color-surface-primary);
display: grid;
gap: 0.35rem;
}
.impact--blocking {
border-left-color: var(--color-status-error-text);
background: color-mix(in srgb, var(--color-status-error-bg) 55%, var(--color-surface-primary));
}
.impact--degraded {
border-left-color: var(--color-status-warning-text);
background: color-mix(in srgb, var(--color-status-warning-bg) 55%, var(--color-surface-primary));
}
.impact--info {
border-left-color: var(--color-status-info-text);
background: color-mix(in srgb, var(--color-status-info-bg) 55%, var(--color-surface-primary));
}
.impact__header {
display: flex;
align-items: center;
gap: 0.45rem;
}
.impact__header h3 {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-primary);
}
.impact__badge {
font-size: 0.65rem;
letter-spacing: 0.05em;
text-transform: uppercase;
border: 1px solid currentColor;
border-radius: var(--radius-full);
padding: 0.04rem 0.4rem;
font-weight: 700;
}
.impact__message {
margin: 0;
font-size: 0.74rem;
color: var(--color-text-secondary);
}
.impact__meta {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
font-size: 0.68rem;
color: var(--color-text-secondary);
}
.impact__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
}
.impact__actions button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.7rem;
padding: 0.18rem 0.5rem;
cursor: pointer;
}
.impact__actions code {
font-size: 0.65rem;
color: var(--color-text-secondary);
word-break: break-all;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DegradedStateBannerComponent {
@Input() impact: ImpactLevel = 'INFO';
@Input() title = 'Service impact';
@Input() message = 'Some supporting services are degraded.';
@Input() correlationId: string | null = null;
@Input() lastKnownGoodAt: string | null = null;
@Input() readOnly = false;
@Input() retryable = true;
@Input() retryLabel = 'Retry';
@Output() readonly retryRequested = new EventEmitter<void>();
readonly copied = signal(false);
readonly impactTone = computed(() => this.impact.toLowerCase());
async copyCorrelationId(): Promise<void> {
if (!this.correlationId) {
return;
}
try {
await navigator.clipboard.writeText(this.correlationId);
this.copied.set(true);
setTimeout(() => this.copied.set(false), 1500);
} catch {
this.copied.set(false);
}
}
formatTime(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}

View File

@@ -0,0 +1,117 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, Routes, provideRouter } from '@angular/router';
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
import { PlatformContextUrlSyncService } from '../../app/core/context/platform-context-url-sync.service';
@Component({
standalone: true,
template: '',
})
class DummyComponent {}
async function settleRouter(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitForCondition(predicate: () => boolean): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (predicate()) {
return;
}
await settleRouter();
}
}
describe('PlatformContextUrlSyncService', () => {
let router: Router;
let service: PlatformContextUrlSyncService;
let contextStore: {
initialize: jasmine.Spy;
initialized: ReturnType<typeof signal<boolean>>;
contextVersion: ReturnType<typeof signal<number>>;
scopeQueryPatch: jasmine.Spy;
applyScopeQueryParams: jasmine.Spy;
};
beforeEach(async () => {
contextStore = {
initialize: jasmine.createSpy('initialize'),
initialized: signal(true),
contextVersion: signal(0),
scopeQueryPatch: jasmine.createSpy('scopeQueryPatch').and.returnValue({
regions: 'us-east',
environments: 'prod',
timeWindow: '7d',
}),
applyScopeQueryParams: jasmine.createSpy('applyScopeQueryParams'),
};
const routes: Routes = [
{ path: '', component: DummyComponent },
{ path: 'dashboard', component: DummyComponent },
{ path: 'security', component: DummyComponent },
{ path: 'setup', component: DummyComponent },
{ path: '**', component: DummyComponent },
];
await TestBed.configureTestingModule({
imports: [DummyComponent],
providers: [
provideRouter(routes),
{ provide: PlatformContextStore, useValue: contextStore },
],
}).compileComponents();
router = TestBed.inject(Router);
service = TestBed.inject(PlatformContextUrlSyncService);
service.initialize();
router.initialNavigation();
});
it('hydrates scope from URL query parameters on scope-managed routes', async () => {
await router.navigateByUrl('/security?regions=eu-west&environments=stage&timeWindow=30d');
await settleRouter();
expect(contextStore.applyScopeQueryParams).toHaveBeenCalled();
const latestCall = contextStore.applyScopeQueryParams.calls.mostRecent();
expect(latestCall).toBeDefined();
if (!latestCall) {
return;
}
expect(latestCall.args[0]).toEqual(
jasmine.objectContaining({
regions: 'eu-west',
environments: 'stage',
timeWindow: '30d',
}),
);
});
it('persists scope query parameters to URL when context changes', async () => {
await router.navigateByUrl('/dashboard');
await settleRouter();
contextStore.contextVersion.update((value) => value + 1);
await waitForCondition(() => router.url.includes('regions=us-east'));
expect(router.url).toContain('/dashboard');
expect(router.url).toContain('regions=us-east');
expect(router.url).toContain('environments=prod');
expect(router.url).toContain('timeWindow=7d');
});
it('skips setup route from scope sync management', async () => {
await router.navigateByUrl('/setup?regions=us-east');
await settleRouter();
contextStore.applyScopeQueryParams.calls.reset();
contextStore.contextVersion.update((value) => value + 1);
await settleRouter();
expect(contextStore.applyScopeQueryParams).not.toHaveBeenCalled();
expect(router.url).toBe('/setup?regions=us-east');
});
});

View File

@@ -0,0 +1,121 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { DoctorChecksInlineComponent } from '../../app/features/doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { DoctorStore } from '../../app/features/doctor/services/doctor.store';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
import { CheckResult, DoctorReport } from '../../app/features/doctor/models/doctor.models';
function buildMockReport(): DoctorReport {
return {
runId: 'run-1',
status: 'completed',
startedAt: '2026-02-20T10:00:00Z',
completedAt: '2026-02-20T10:01:00Z',
durationMs: 60000,
overallSeverity: 'warn',
summary: { passed: 2, info: 0, warnings: 1, failed: 1, skipped: 0, total: 4 },
results: [
{ checkId: 'check.security.tls', pluginId: 'security-tls', severity: 'pass', diagnosis: 'TLS OK', category: 'security', durationMs: 100, executedAt: '2026-02-20T10:00:01Z', likelyCauses: [], evidence: { description: '', data: {} } } as CheckResult,
{ checkId: 'check.security.certs', pluginId: 'security-certs', severity: 'warn', diagnosis: 'Cert expiring', category: 'security', durationMs: 200, executedAt: '2026-02-20T10:00:02Z', likelyCauses: [], evidence: { description: '', data: {} } } as CheckResult,
{ checkId: 'check.core.config', pluginId: 'core-config', severity: 'fail', diagnosis: 'Config missing', category: 'core', durationMs: 50, executedAt: '2026-02-20T10:00:03Z', likelyCauses: [], evidence: { description: '', data: {} } } as CheckResult,
{ checkId: 'check.core.db', pluginId: 'core-db', severity: 'pass', diagnosis: 'DB OK', category: 'core', durationMs: 150, executedAt: '2026-02-20T10:00:04Z', likelyCauses: [], evidence: { description: '', data: {} } } as CheckResult,
],
};
}
const mockDoctorApi = {
listChecks: () => ({ subscribe: () => {} }),
listPlugins: () => ({ subscribe: () => {} }),
startRun: () => ({ subscribe: () => {} }),
getRunResult: () => ({ subscribe: () => {} }),
streamRunProgress: () => ({ subscribe: () => {} }),
listReports: () => ({ subscribe: () => {} }),
deleteReport: () => ({ subscribe: () => {} }),
};
describe('DoctorChecksInlineComponent', () => {
let fixture: ComponentFixture<DoctorChecksInlineComponent>;
let component: DoctorChecksInlineComponent;
let store: DoctorStore;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DoctorChecksInlineComponent],
providers: [
provideRouter([]),
DoctorStore,
{ provide: DOCTOR_API, useValue: mockDoctorApi },
],
}).compileComponents();
store = TestBed.inject(DoctorStore);
fixture = TestBed.createComponent(DoctorChecksInlineComponent);
component = fixture.componentInstance;
component.category = 'security';
fixture.detectChanges();
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('should show zero counts when no report is loaded', () => {
const summary = component.summary();
expect(summary.pass).toBe(0);
expect(summary.warn).toBe(0);
expect(summary.fail).toBe(0);
expect(summary.total).toBe(0);
});
it('should show correct summary counts for the category', () => {
const report = buildMockReport();
(store as any).reportSignal.set(report);
fixture.detectChanges();
const summary = component.summary();
expect(summary.pass).toBe(1);
expect(summary.warn).toBe(1);
expect(summary.fail).toBe(0);
expect(summary.total).toBe(2);
});
it('should filter results by category', () => {
const report = buildMockReport();
(store as any).reportSignal.set(report);
fixture.detectChanges();
const results = component.results();
expect(results.length).toBe(2);
expect(results.every((r) => r.category === 'security')).toBeTrue();
});
it('should toggle expanded state on toggle()', () => {
expect(component.expanded).toBeFalse();
component.toggle();
expect(component.expanded).toBeTrue();
component.toggle();
expect(component.expanded).toBeFalse();
});
it('should limit visible results to maxResults', () => {
component.maxResults = 1;
const report = buildMockReport();
(store as any).reportSignal.set(report);
fixture.detectChanges();
expect(component.visibleResults().length).toBe(1);
});
it('should show results for core category when category is core', () => {
component.category = 'core';
const report = buildMockReport();
(store as any).reportSignal.set(report);
fixture.detectChanges();
const summary = component.summary();
expect(summary.pass).toBe(1);
expect(summary.fail).toBe(1);
expect(summary.total).toBe(2);
});
});

View File

@@ -0,0 +1,119 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { DoctorNotificationService } from '../../app/core/doctor/doctor-notification.service';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
import { ToastService } from '../../app/core/services/toast.service';
describe('DoctorNotificationService', () => {
let service: DoctorNotificationService;
let toastService: ToastService;
let mockApi: any;
beforeEach(() => {
vi.useFakeTimers();
// Clear localStorage
localStorage.removeItem('stellaops_doctor_last_seen_report');
localStorage.removeItem('stellaops_doctor_notifications_muted');
mockApi = {
listChecks: () => of({ checks: [], total: 0 }),
listPlugins: () => of({ plugins: [], total: 0 }),
startRun: () => of({ runId: 'test' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: vi.fn().mockReturnValue(of({ reports: [], total: 0 })),
deleteReport: () => of(),
};
TestBed.configureTestingModule({
providers: [
provideRouter([]),
DoctorNotificationService,
ToastService,
{ provide: DOCTOR_API, useValue: mockApi },
],
});
service = TestBed.inject(DoctorNotificationService);
toastService = TestBed.inject(ToastService);
});
afterEach(() => {
vi.useRealTimers();
localStorage.removeItem('stellaops_doctor_last_seen_report');
localStorage.removeItem('stellaops_doctor_notifications_muted');
});
it('should start unmuted by default', () => {
expect(service.muted()).toBeFalse();
});
it('should toggle mute state', () => {
service.toggleMute();
expect(service.muted()).toBeTrue();
expect(localStorage.getItem('stellaops_doctor_notifications_muted')).toBe('true');
service.toggleMute();
expect(service.muted()).toBeFalse();
});
it('should not show toast when no reports exist', () => {
spyOn(toastService, 'show');
service.start();
vi.advanceTimersByTime(10000);
expect(mockApi.listReports).toHaveBeenCalled();
expect(toastService.show).not.toHaveBeenCalled();
});
it('should show toast when new report has failures', () => {
const report = {
runId: 'run-new',
summary: { passed: 3, info: 0, warnings: 0, failed: 2, skipped: 0, total: 5 },
};
mockApi.listReports.mockReturnValue(of({ reports: [report], total: 1 }));
spyOn(toastService, 'show');
service.start();
vi.advanceTimersByTime(10000);
expect(toastService.show).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'error',
title: 'Doctor Run Complete',
})
);
});
it('should not show toast for same report twice', () => {
const report = {
runId: 'run-1',
summary: { passed: 3, info: 0, warnings: 1, failed: 0, skipped: 0, total: 4 },
};
mockApi.listReports.mockReturnValue(of({ reports: [report], total: 1 }));
localStorage.setItem('stellaops_doctor_last_seen_report', 'run-1');
spyOn(toastService, 'show');
service.start();
vi.advanceTimersByTime(10000);
expect(toastService.show).not.toHaveBeenCalled();
});
it('should not show toast for passing reports', () => {
const report = {
runId: 'run-pass',
summary: { passed: 5, info: 0, warnings: 0, failed: 0, skipped: 0, total: 5 },
};
mockApi.listReports.mockReturnValue(of({ reports: [report], total: 1 }));
spyOn(toastService, 'show');
service.start();
vi.advanceTimersByTime(10000);
expect(toastService.show).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,107 @@
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { DoctorQuickCheckService } from '../../app/features/doctor/services/doctor-quick-check.service';
import { DoctorStore } from '../../app/features/doctor/services/doctor.store';
import { ToastService } from '../../app/core/services/toast.service';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
import { of } from 'rxjs';
describe('DoctorQuickCheckService', () => {
let service: DoctorQuickCheckService;
let store: DoctorStore;
let toastService: ToastService;
let router: Router;
let mockApi: any;
beforeEach(() => {
mockApi = {
listChecks: () => of({ checks: [], total: 0 }),
listPlugins: () => of({ plugins: [], total: 0 }),
startRun: () => of({ runId: 'quick-run-1' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: () => of({ reports: [], total: 0 }),
deleteReport: () => of(),
};
TestBed.configureTestingModule({
providers: [
provideRouter([]),
DoctorQuickCheckService,
DoctorStore,
ToastService,
{ provide: DOCTOR_API, useValue: mockApi },
],
});
service = TestBed.inject(DoctorQuickCheckService);
store = TestBed.inject(DoctorStore);
toastService = TestBed.inject(ToastService);
router = TestBed.inject(Router);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should return two quick actions', () => {
const actions = service.getQuickActions();
expect(actions.length).toBe(2);
expect(actions[0].id).toBe('doctor-quick');
expect(actions[1].id).toBe('doctor-full');
});
it('should have bound action callbacks on quick actions', () => {
const actions = service.getQuickActions();
expect(actions[0].action).toBeDefined();
expect(actions[1].action).toBeDefined();
});
it('should show progress toast on runQuickCheck', () => {
spyOn(toastService, 'show').and.returnValue('toast-1');
spyOn(store, 'startRun');
service.runQuickCheck();
expect(toastService.show).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'info',
title: 'Running Quick Health Check...',
duration: 0,
})
);
expect(store.startRun).toHaveBeenCalledWith(
jasmine.objectContaining({ mode: 'quick', includeRemediation: true })
);
});
it('should start full run and navigate on runFullDiagnostics', () => {
spyOn(store, 'startRun');
spyOn(router, 'navigate');
service.runFullDiagnostics();
expect(store.startRun).toHaveBeenCalledWith(
jasmine.objectContaining({ mode: 'full', includeRemediation: true })
);
expect(router.navigate).toHaveBeenCalledWith(['/platform/ops/doctor']);
});
it('quick actions should have correct keywords', () => {
const actions = service.getQuickActions();
const quickAction = actions.find((a) => a.id === 'doctor-quick')!;
const fullAction = actions.find((a) => a.id === 'doctor-full')!;
expect(quickAction.keywords).toContain('doctor');
expect(quickAction.keywords).toContain('health');
expect(fullAction.keywords).toContain('diagnostics');
expect(fullAction.keywords).toContain('comprehensive');
});
it('quick actions should have correct shortcuts', () => {
const actions = service.getQuickActions();
expect(actions[0].shortcut).toBe('>doctor');
expect(actions[1].shortcut).toBe('>diagnostics');
});
});

View File

@@ -0,0 +1,122 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { of } from 'rxjs';
import { DoctorRecheckService } from '../../app/features/doctor/services/doctor-recheck.service';
import { DoctorStore } from '../../app/features/doctor/services/doctor.store';
import { ToastService } from '../../app/core/services/toast.service';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
describe('DoctorRecheckService', () => {
let service: DoctorRecheckService;
let store: DoctorStore;
let toastService: ToastService;
let router: Router;
let mockApi: any;
beforeEach(() => {
mockApi = {
listChecks: () => of({ checks: [], total: 0 }),
listPlugins: () => of({ plugins: [], total: 0 }),
startRun: () => of({ runId: 'recheck-run-1' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: () => of({ reports: [], total: 0 }),
deleteReport: () => of(),
};
TestBed.configureTestingModule({
providers: [
provideRouter([]),
DoctorRecheckService,
DoctorStore,
ToastService,
{ provide: DOCTOR_API, useValue: mockApi },
],
});
service = TestBed.inject(DoctorRecheckService);
store = TestBed.inject(DoctorStore);
toastService = TestBed.inject(ToastService);
router = TestBed.inject(Router);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('recheckForStep', () => {
it('should start a run with checkIds for the given step', () => {
spyOn(toastService, 'show').and.returnValue('toast-1');
spyOn(store, 'startRun');
service.recheckForStep('database');
expect(store.startRun).toHaveBeenCalledWith(
jasmine.objectContaining({
mode: 'quick',
includeRemediation: true,
checkIds: ['check.database.connectivity', 'check.database.migrations'],
})
);
});
it('should show progress toast', () => {
spyOn(toastService, 'show').and.returnValue('toast-1');
spyOn(store, 'startRun');
service.recheckForStep('cache');
expect(toastService.show).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'info',
title: 'Running Re-check...',
duration: 0,
})
);
});
it('should not start a run for welcome step (no checks)', () => {
spyOn(store, 'startRun');
service.recheckForStep('welcome');
expect(store.startRun).not.toHaveBeenCalled();
});
});
describe('offerRecheck', () => {
it('should show success toast with re-check action', () => {
spyOn(toastService, 'show').and.returnValue('toast-1');
service.offerRecheck('database', 'Database');
expect(toastService.show).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'success',
title: 'Database configured successfully',
duration: 10000,
action: jasmine.objectContaining({ label: 'Run Re-check' }),
})
);
});
it('should trigger recheckForStep when action is clicked', () => {
let capturedAction: any;
spyOn(toastService, 'show').and.callFake((opts: any) => {
capturedAction = opts.action;
return 'toast-1';
});
spyOn(store, 'startRun');
service.offerRecheck('authority', 'Authority');
capturedAction.onClick();
expect(store.startRun).toHaveBeenCalledWith(
jasmine.objectContaining({
checkIds: ['check.authority.plugin.configured', 'check.authority.plugin.connectivity'],
})
);
});
});
});

View File

@@ -0,0 +1,77 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { DoctorTrendService } from '../../app/core/doctor/doctor-trend.service';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
import { DoctorTrendResponse } from '../../app/core/doctor/doctor-trend.models';
describe('DoctorTrendService', () => {
let service: DoctorTrendService;
let mockApi: any;
const mockTrends: DoctorTrendResponse[] = [
{
category: 'security',
points: [
{ timestamp: '2026-02-20T09:00:00Z', score: 80 },
{ timestamp: '2026-02-20T10:00:00Z', score: 85 },
{ timestamp: '2026-02-20T11:00:00Z', score: 90 },
],
},
{
category: 'platform',
points: [
{ timestamp: '2026-02-20T09:00:00Z', score: 70 },
{ timestamp: '2026-02-20T10:00:00Z', score: 75 },
],
},
];
beforeEach(() => {
mockApi = {
listChecks: () => of({ checks: [], total: 0 }),
listPlugins: () => of({ plugins: [], total: 0 }),
startRun: () => of({ runId: 'test' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: () => of({ reports: [], total: 0 }),
deleteReport: () => of(),
getTrends: jasmine.createSpy('getTrends').and.returnValue(of(mockTrends)),
};
TestBed.configureTestingModule({
providers: [
DoctorTrendService,
{ provide: DOCTOR_API, useValue: mockApi },
],
});
service = TestBed.inject(DoctorTrendService);
});
it('should initialize with empty trends', () => {
expect(service.securityTrend()).toEqual([]);
expect(service.platformTrend()).toEqual([]);
});
it('should populate trends on refresh()', () => {
service.refresh();
expect(mockApi.getTrends).toHaveBeenCalledWith(['security', 'platform'], 12);
expect(service.securityTrend()).toEqual([80, 85, 90]);
expect(service.platformTrend()).toEqual([70, 75]);
});
it('should clear trends on error', () => {
// First populate
service.refresh();
expect(service.securityTrend().length).toBe(3);
// Now error
mockApi.getTrends.and.returnValue(throwError(() => new Error('Network error')));
service.refresh();
expect(service.securityTrend()).toEqual([]);
expect(service.platformTrend()).toEqual([]);
});
});

View File

@@ -0,0 +1,106 @@
import {
DOCTOR_WIZARD_MAPPINGS,
getWizardStepForCheck,
getCheckIdsForStep,
buildWizardDeepLink,
} from '../../app/features/doctor/models/doctor-wizard-mapping';
describe('DoctorWizardMapping', () => {
describe('DOCTOR_WIZARD_MAPPINGS', () => {
it('should contain mappings for all infrastructure steps', () => {
const stepIds = new Set(DOCTOR_WIZARD_MAPPINGS.map((m) => m.stepId));
expect(stepIds.has('database')).toBeTrue();
expect(stepIds.has('cache')).toBeTrue();
expect(stepIds.has('migrations')).toBeTrue();
expect(stepIds.has('authority')).toBeTrue();
expect(stepIds.has('users')).toBeTrue();
});
it('should contain mappings for integration steps', () => {
const stepIds = new Set(DOCTOR_WIZARD_MAPPINGS.map((m) => m.stepId));
expect(stepIds.has('vault')).toBeTrue();
expect(stepIds.has('registry')).toBeTrue();
expect(stepIds.has('scm')).toBeTrue();
expect(stepIds.has('sources')).toBeTrue();
expect(stepIds.has('notify')).toBeTrue();
});
it('should not contain welcome step', () => {
const stepIds = new Set(DOCTOR_WIZARD_MAPPINGS.map((m) => m.stepId));
expect(stepIds.has('welcome')).toBeFalse();
});
it('should have unique check IDs', () => {
const ids = DOCTOR_WIZARD_MAPPINGS.map((m) => m.checkId);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe('getWizardStepForCheck', () => {
it('should return mapping for known check', () => {
const result = getWizardStepForCheck('check.database.connectivity');
expect(result).toBeDefined();
expect(result!.stepId).toBe('database');
expect(result!.label).toBe('Database Connectivity');
});
it('should return undefined for unknown check', () => {
expect(getWizardStepForCheck('check.nonexistent')).toBeUndefined();
});
it('should return correct step for vault auth check', () => {
const result = getWizardStepForCheck('check.integration.vault.auth');
expect(result).toBeDefined();
expect(result!.stepId).toBe('vault');
});
it('should return correct step for telemetry check', () => {
const result = getWizardStepForCheck('check.telemetry.otlp.connectivity');
expect(result).toBeDefined();
expect(result!.stepId).toBe('telemetry');
});
});
describe('getCheckIdsForStep', () => {
it('should return check IDs for database step', () => {
const ids = getCheckIdsForStep('database');
expect(ids).toContain('check.database.connectivity');
expect(ids).toContain('check.database.migrations');
expect(ids.length).toBe(2);
});
it('should return check IDs for llm step', () => {
const ids = getCheckIdsForStep('llm');
expect(ids).toContain('check.ai.llm.config');
expect(ids).toContain('check.ai.provider.openai');
expect(ids).toContain('check.ai.provider.claude');
expect(ids).toContain('check.ai.provider.gemini');
expect(ids.length).toBe(4);
});
it('should return empty array for welcome step', () => {
const ids = getCheckIdsForStep('welcome');
expect(ids.length).toBe(0);
});
it('should return single check for telemetry step', () => {
const ids = getCheckIdsForStep('telemetry');
expect(ids.length).toBe(1);
expect(ids[0]).toBe('check.telemetry.otlp.connectivity');
});
});
describe('buildWizardDeepLink', () => {
it('should build correct deep link for database', () => {
expect(buildWizardDeepLink('database')).toBe('/setup/wizard?step=database&mode=reconfigure');
});
it('should build correct deep link for authority', () => {
expect(buildWizardDeepLink('authority')).toBe('/setup/wizard?step=authority&mode=reconfigure');
});
it('should build correct deep link for telemetry', () => {
expect(buildWizardDeepLink('telemetry')).toBe('/setup/wizard?step=telemetry&mode=reconfigure');
});
});
});

View File

@@ -1,21 +1,58 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { of } from 'rxjs';
import { GlobalSearchComponent, SearchResult } from '../../app/layout/global-search/global-search.component'; import { SearchClient } from '../../app/core/api/search.client';
import {
GlobalSearchComponent,
SearchResult,
} from '../../app/layout/global-search/global-search.component';
describe('GlobalSearchComponent', () => { describe('GlobalSearchComponent', () => {
let fixture: ComponentFixture<GlobalSearchComponent>; let fixture: ComponentFixture<GlobalSearchComponent>;
let component: GlobalSearchComponent; let component: GlobalSearchComponent;
let router: { navigate: jasmine.Spy }; let router: { navigateByUrl: jasmine.Spy };
let searchClient: { search: jasmine.Spy };
beforeEach(async () => { beforeEach(async () => {
router = { router = {
navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)), navigateByUrl: jasmine.createSpy('navigateByUrl').and.returnValue(Promise.resolve(true)),
};
searchClient = {
search: jasmine.createSpy('search').and.returnValue(
of({
query: 'CVE-2026',
groups: [
{
type: 'cve',
label: 'CVEs',
totalCount: 1,
hasMore: false,
results: [
{
id: 'cve-1',
type: 'cve',
title: 'CVE-2026-12345',
subtitle: 'Critical',
route: '/security/triage?cve=CVE-2026-12345',
matchScore: 100,
},
],
},
],
totalCount: 1,
durationMs: 4,
}),
),
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [GlobalSearchComponent], imports: [GlobalSearchComponent],
providers: [{ provide: Router, useValue: router }], providers: [
{ provide: Router, useValue: router },
{ provide: SearchClient, useValue: searchClient },
],
}).compileComponents(); }).compileComponents();
localStorage.clear(); localStorage.clear();
@@ -28,43 +65,39 @@ describe('GlobalSearchComponent', () => {
localStorage.clear(); localStorage.clear();
}); });
async function waitForDebounce(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 240));
}
it('renders the global search input and shortcut hint', () => { it('renders the global search input and shortcut hint', () => {
const text = fixture.nativeElement.textContent as string; const text = fixture.nativeElement.textContent as string;
const input = fixture.nativeElement.querySelector('input[aria-label="Global search"]') as HTMLInputElement; const input = fixture.nativeElement.querySelector('input[aria-label="Global search"]') as HTMLInputElement;
expect(input).toBeTruthy(); expect(input).toBeTruthy();
expect(input.placeholder).toContain('Search releases'); expect(input.placeholder).toContain('Search runs');
expect(text).toContain('K'); expect(text).toContain('K');
}); });
it('produces categorized results for matching query terms', async () => { it('queries SearchClient and renders grouped results', async () => {
component.query.set('CVE-2026'); component.onFocus();
component.onSearch(); component.onQueryChange('CVE-2026');
expect(component.isLoading()).toBeTrue(); await waitForDebounce();
await new Promise((resolve) => setTimeout(resolve, 220));
fixture.detectChanges(); fixture.detectChanges();
expect(component.isLoading()).toBeFalse(); expect(searchClient.search).toHaveBeenCalledWith('CVE-2026');
expect(component.results().length).toBeGreaterThan(0); expect(component.groupedResults().length).toBe(1);
expect(component.results().some((result) => result.type === 'cve')).toBeTrue(); expect(component.groupedResults()[0].type).toBe('cve');
expect(component.groupedResults().some((group) => group.type === 'cve')).toBeTrue(); expect(component.flatResults().length).toBe(1);
}); });
it('clears results when the query is shorter than two characters', () => { it('does not query API for terms shorter than two characters', async () => {
component.results.set([ component.onFocus();
{ component.onQueryChange('a');
id: 'existing', await waitForDebounce();
type: 'release', fixture.detectChanges();
label: 'v1.0.0',
route: '/releases/v1.0.0',
},
]);
component.query.set('a'); expect(searchClient.search).not.toHaveBeenCalled();
component.onSearch(); expect(component.searchResponse()).toBeNull();
expect(component.results()).toEqual([]);
}); });
it('navigates to selected result and persists recent search', () => { it('navigates to selected result and persists recent search', () => {
@@ -72,14 +105,15 @@ describe('GlobalSearchComponent', () => {
const result: SearchResult = { const result: SearchResult = {
id: 'cve-1', id: 'cve-1',
type: 'cve', type: 'cve',
label: 'CVE-2026-12345', title: 'CVE-2026-12345',
sublabel: 'Critical', subtitle: 'Critical',
route: '/security/vulnerabilities/CVE-2026-12345', route: '/security/triage?cve=CVE-2026-12345',
matchScore: 100,
}; };
component.onSelect(result); component.onSelect(result);
expect(router.navigate).toHaveBeenCalledWith(['/security/vulnerabilities/CVE-2026-12345']); expect(router.navigateByUrl).toHaveBeenCalledWith('/security/triage?cve=CVE-2026-12345');
const stored = JSON.parse(localStorage.getItem('stella-recent-searches') ?? '[]') as string[]; const stored = JSON.parse(localStorage.getItem('stella-recent-searches') ?? '[]') as string[];
expect(stored[0]).toBe('CVE-2026'); expect(stored[0]).toBe('CVE-2026');
}); });

View File

@@ -0,0 +1,65 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SidebarSparklineComponent } from '../../app/layout/app-sidebar/sidebar-sparkline.component';
describe('SidebarSparklineComponent', () => {
let fixture: ComponentFixture<SidebarSparklineComponent>;
let component: SidebarSparklineComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SidebarSparklineComponent],
}).compileComponents();
fixture = TestBed.createComponent(SidebarSparklineComponent);
component = fixture.componentInstance;
});
it('should render nothing when points has fewer than 2 items', () => {
component.points = [50];
fixture.detectChanges();
const svg = fixture.nativeElement.querySelector('svg');
expect(svg).toBeNull();
});
it('should render nothing when points is empty', () => {
component.points = [];
fixture.detectChanges();
const svg = fixture.nativeElement.querySelector('svg');
expect(svg).toBeNull();
});
it('should render SVG when points has 2 or more items', () => {
component.points = [50, 75, 60, 90];
fixture.detectChanges();
const svg = fixture.nativeElement.querySelector('svg');
expect(svg).toBeTruthy();
expect(svg.getAttribute('width')).toBe('40');
expect(svg.getAttribute('height')).toBe('16');
});
it('should generate correct polyline points', () => {
component.points = [0, 100];
fixture.detectChanges();
const polyline = fixture.nativeElement.querySelector('polyline');
expect(polyline).toBeTruthy();
const pointsAttr = polyline.getAttribute('points');
expect(pointsAttr).toBeTruthy();
// First point should be at x=0, last at x=40
expect(pointsAttr).toContain('0.0');
expect(pointsAttr).toContain('40.0');
});
it('should handle flat data (all same values)', () => {
component.points = [50, 50, 50];
fixture.detectChanges();
const polyline = fixture.nativeElement.querySelector('polyline');
expect(polyline).toBeTruthy();
});
});

View File

@@ -0,0 +1,87 @@
import { TestBed } from '@angular/core/testing';
import { Router, Routes, provideRouter } from '@angular/router';
import { Component } from '@angular/core';
import { AUTH_SERVICE } from '../../app/core/auth';
import { LegacyRouteTelemetryService } from '../../app/core/guards/legacy-route-telemetry.service';
import { TelemetryClient } from '../../app/core/telemetry/telemetry.client';
import {
LEGACY_REDIRECT_ROUTES,
LEGACY_REDIRECT_ROUTE_TEMPLATES,
} from '../../app/routes/legacy-redirects.routes';
@Component({
standalone: true,
template: '',
})
class DummyComponent {}
describe('LegacyRouteTelemetryService', () => {
let service: LegacyRouteTelemetryService;
let router: Router;
let telemetry: { emit: jasmine.Spy };
beforeEach(async () => {
telemetry = {
emit: jasmine.createSpy('emit'),
};
const routes: Routes = [
...LEGACY_REDIRECT_ROUTES,
{ path: 'platform/ops/health-slo', component: DummyComponent },
{ path: 'security/triage', component: DummyComponent },
{ path: 'topology/regions', component: DummyComponent },
{ path: '**', component: DummyComponent },
];
await TestBed.configureTestingModule({
imports: [DummyComponent],
providers: [
provideRouter(routes),
{ provide: TelemetryClient, useValue: telemetry },
{
provide: AUTH_SERVICE,
useValue: {
user: () => ({ id: 'user-1', tenantId: 'tenant-1' }),
},
},
],
}).compileComponents();
router = TestBed.inject(Router);
service = TestBed.inject(LegacyRouteTelemetryService);
service.initialize();
router.initialNavigation();
});
it('tracks route map size from canonical legacy redirect templates', () => {
expect(service.getLegacyRouteCount()).toBe(LEGACY_REDIRECT_ROUTE_TEMPLATES.length);
});
it('emits legacy_route_hit telemetry for redirecting legacy URLs', async () => {
await router.navigateByUrl('/ops/health?tab=slo');
expect(telemetry.emit).toHaveBeenCalledTimes(1);
expect(telemetry.emit).toHaveBeenCalledWith(
'legacy_route_hit',
jasmine.objectContaining({
oldPath: '/ops/health',
newPath: '/platform/ops/health-slo',
tenantId: 'tenant-1',
userId: 'user-1',
}),
);
expect(service.currentLegacyRoute()).toEqual(
jasmine.objectContaining({
oldPath: '/ops/health',
newPath: '/platform/ops/health-slo',
}),
);
});
it('does not emit telemetry for canonical URLs', async () => {
await router.navigateByUrl('/platform/ops/health-slo');
expect(telemetry.emit).not.toHaveBeenCalled();
});
});

View File

@@ -10,6 +10,7 @@ import { of } from 'rxjs';
import { AppSidebarComponent } from '../../app/layout/app-sidebar/app-sidebar.component'; import { AppSidebarComponent } from '../../app/layout/app-sidebar/app-sidebar.component';
import { AUTH_SERVICE } from '../../app/core/auth'; import { AUTH_SERVICE } from '../../app/core/auth';
import { APPROVAL_API } from '../../app/core/api/approval.client'; import { APPROVAL_API } from '../../app/core/api/approval.client';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
const CANONICAL_DOMAIN_IDS = [ const CANONICAL_DOMAIN_IDS = [
'dashboard', 'dashboard',
@@ -30,7 +31,7 @@ const CANONICAL_DOMAIN_ROUTES = [
] as const; ] as const;
const EXPECTED_SECTION_LABELS: Record<string, string> = { const EXPECTED_SECTION_LABELS: Record<string, string> = {
dashboard: 'Dashboard', dashboard: 'Mission Control',
releases: 'Releases', releases: 'Releases',
security: 'Security', security: 'Security',
evidence: 'Evidence', evidence: 'Evidence',
@@ -75,6 +76,7 @@ describe('AppSidebarComponent nav model (navigation)', () => {
provideRouter([]), provideRouter([]),
{ provide: AUTH_SERVICE, useValue: authSpy }, { provide: AUTH_SERVICE, useValue: authSpy },
{ provide: APPROVAL_API, useValue: approvalApiSpy }, { provide: APPROVAL_API, useValue: approvalApiSpy },
{ provide: DOCTOR_API, useValue: {} },
], ],
}).compileComponents(); }).compileComponents();
@@ -127,7 +129,7 @@ describe('AppSidebarComponent nav model (navigation)', () => {
const capsules = evidence.children?.find((child) => child.id === 'ev-capsules'); const capsules = evidence.children?.find((child) => child.id === 'ev-capsules');
const verify = evidence.children?.find((child) => child.id === 'ev-verify'); const verify = evidence.children?.find((child) => child.id === 'ev-verify');
expect(capsules?.route).toBe('/evidence/capsules'); expect(capsules?.route).toBe('/evidence/capsules');
expect(verify?.route).toBe('/evidence/verify-replay'); expect(verify?.route).toBe('/evidence/verification/replay');
}); });
it('Platform group owns ops/integrations/setup shortcuts', () => { it('Platform group owns ops/integrations/setup shortcuts', () => {

View File

@@ -11,6 +11,7 @@ import { SECURITY_ROUTES } from '../../app/routes/security.routes';
import { TOPOLOGY_ROUTES } from '../../app/routes/topology.routes'; import { TOPOLOGY_ROUTES } from '../../app/routes/topology.routes';
import { integrationHubRoutes } from '../../app/features/integration-hub/integration-hub.routes'; import { integrationHubRoutes } from '../../app/features/integration-hub/integration-hub.routes';
import { PLATFORM_SETUP_ROUTES } from '../../app/features/platform/setup/platform-setup.routes'; import { PLATFORM_SETUP_ROUTES } from '../../app/features/platform/setup/platform-setup.routes';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
function joinPath(prefix: string, path: string | undefined): string | null { function joinPath(prefix: string, path: string | undefined): string | null {
if (path === undefined) return null; if (path === undefined) return null;
@@ -50,6 +51,7 @@ describe('AppSidebarComponent route integrity (navigation)', () => {
providers: [ providers: [
provideRouter([]), provideRouter([]),
{ provide: AUTH_SERVICE, useValue: authSpy }, { provide: AUTH_SERVICE, useValue: authSpy },
{ provide: DOCTOR_API, useValue: {} },
], ],
}).compileComponents(); }).compileComponents();
@@ -74,7 +76,7 @@ describe('AppSidebarComponent route integrity (navigation)', () => {
for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path); for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path); for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path);
for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path); for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path);
allowed.add('/security/supply-chain-data/lake'); allowed.add('/security/sbom/lake');
for (const section of component.navSections) { for (const section of component.navSections) {
expect(allowed.has(section.route)).toBeTrue(); expect(allowed.has(section.route)).toBeTrue();
@@ -93,17 +95,18 @@ describe('AppSidebarComponent route integrity (navigation)', () => {
for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path); for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path); for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path);
for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path); for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path);
allowed.add('/security/supply-chain-data/lake'); allowed.add('/security/sbom/lake');
const required = [ const required = [
'/releases/versions', '/releases/versions',
'/releases/runs', '/releases/runs',
'/security/triage', '/security/triage',
'/security/advisories-vex', '/security/disposition',
'/security/supply-chain-data/lake', '/security/sbom/lake',
'/evidence/capsules', '/evidence/capsules',
'/evidence/verify-replay', '/evidence/verification/replay',
'/topology/agents', '/topology/agents',
'/topology/promotion-graph',
'/platform/ops/jobs-queues', '/platform/ops/jobs-queues',
'/platform/ops/feeds-airgap', '/platform/ops/feeds-airgap',
'/platform/integrations/runtime-hosts', '/platform/integrations/runtime-hosts',

View File

@@ -0,0 +1,136 @@
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject, of } from 'rxjs';
import { signal } from '@angular/core';
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
import { ReleaseDetailComponent } from '../../app/features/release-orchestrator/releases/release-detail/release-detail.component';
import { ReleaseManagementStore } from '../../app/features/release-orchestrator/releases/release.store';
describe('ReleaseDetailComponent live refresh contract', () => {
let component: ReleaseDetailComponent;
beforeEach(async () => {
const routeData$ = new BehaviorSubject({ semanticObject: 'run' });
const paramMap$ = new BehaviorSubject(convertToParamMap({}));
const contextStore = {
initialize: jasmine.createSpy('initialize'),
contextVersion: signal(0),
selectedRegions: signal<string[]>([]),
selectedEnvironments: signal<string[]>([]),
};
const releaseStore = {
selectRelease: jasmine.createSpy('selectRelease'),
selectedRelease: signal(null),
};
await TestBed.configureTestingModule({
imports: [ReleaseDetailComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
data: routeData$.asObservable(),
paramMap: paramMap$.asObservable(),
},
},
{ provide: HttpClient, useValue: { get: jasmine.createSpy('get').and.returnValue(of(null)) } },
{ provide: PlatformContextStore, useValue: contextStore },
{ provide: ReleaseManagementStore, useValue: releaseStore },
],
}).compileComponents();
component = TestBed.createComponent(ReleaseDetailComponent).componentInstance;
});
it('marks terminal run states correctly', () => {
component.runDetail.set({
runId: 'run-1',
releaseId: 'rel-1',
releaseName: 'api',
releaseSlug: 'api',
releaseType: 'standard',
releaseVersionId: 'ver-1',
releaseVersionNumber: 1,
releaseVersionDigest: 'sha256:abc',
lane: 'standard',
status: 'running',
outcome: 'in_progress',
targetEnvironment: 'stage',
targetRegion: 'us-east',
scopeSummary: 'stage->prod',
requestedAt: '2026-02-20T10:00:00Z',
updatedAt: '2026-02-20T10:01:00Z',
needsApproval: false,
blockedByDataIntegrity: false,
correlationKey: 'corr-1',
statusRow: {
runStatus: 'running',
gateStatus: 'pass',
approvalStatus: 'approved',
dataTrustStatus: 'healthy',
},
});
expect(component.runIsTerminal()).toBeFalse();
component.runDetail.update((run) => ({
...run!,
status: 'completed',
outcome: 'deployed',
}));
expect(component.runIsTerminal()).toBeTrue();
});
it('derives degraded run impact with blocking severity when sync failures occur', () => {
component.runDetail.set({
runId: 'run-2',
releaseId: 'rel-2',
releaseName: 'billing',
releaseSlug: 'billing',
releaseType: 'hotfix',
releaseVersionId: 'ver-2',
releaseVersionNumber: 2,
releaseVersionDigest: 'sha256:def',
lane: 'hotfix',
status: 'running',
outcome: 'in_progress',
targetEnvironment: 'prod',
targetRegion: 'eu-west',
scopeSummary: 'stage->prod',
requestedAt: '2026-02-20T11:00:00Z',
updatedAt: '2026-02-20T11:01:00Z',
needsApproval: true,
blockedByDataIntegrity: true,
correlationKey: 'corr-2',
statusRow: {
runStatus: 'running',
gateStatus: 'block',
approvalStatus: 'pending',
dataTrustStatus: 'stale',
},
});
component.runGateDecision.set({
runId: 'run-2',
verdict: 'block',
blockers: ['stale-feeds'],
riskBudgetDelta: 42,
});
component.syncError.set('Live refresh failed');
component.syncFailureCount.set(2);
expect(component.liveSyncStatus()).toBe('DEGRADED');
expect(component.runSyncImpact()).toEqual(
jasmine.objectContaining({
impact: 'BLOCKING',
correlationId: 'corr-2',
readOnly: true,
}),
);
});
});

View File

@@ -21,11 +21,13 @@ function paramTokens(path: string): string[] {
describe('Legacy Route Migration Framework (routes)', () => { describe('Legacy Route Migration Framework (routes)', () => {
it('maps every legacy redirect target to a defined top-level route segment', () => { it('maps every legacy redirect target to a defined top-level route segment', () => {
const topLevelSegments = new Set([ const topLevelSegments = new Set([
'release-control', 'dashboard',
'security-risk', 'releases',
'evidence-audit', 'security',
'evidence',
'topology',
'platform',
'integrations', 'integrations',
'platform-ops',
'administration', 'administration',
]); ]);
@@ -38,13 +40,16 @@ describe('Legacy Route Migration Framework (routes)', () => {
} }
}); });
it('preserves route parameter placeholders in redirect definitions', () => { it('does not introduce unknown route parameter placeholders in redirect definitions', () => {
for (const route of LEGACY_REDIRECT_ROUTE_TEMPLATES) { for (const route of LEGACY_REDIRECT_ROUTE_TEMPLATES) {
if (!route.path) continue; if (!route.path) continue;
expect( const sourceTokens = paramTokens(route.path);
paramTokens(route.redirectTo), for (const targetToken of paramTokens(route.redirectTo)) {
`Redirect parameter mismatch for ${route.path} -> ${route.redirectTo}` expect(
).toEqual(paramTokens(route.path)); sourceTokens,
`Redirect parameter mismatch for ${route.path} -> ${route.redirectTo}`
).toContain(targetToken);
}
} }
}); });
@@ -54,9 +59,9 @@ describe('Legacy Route Migration Framework (routes)', () => {
beforeEach(async () => { beforeEach(async () => {
const testRoutes: Routes = [ const testRoutes: Routes = [
...LEGACY_REDIRECT_ROUTES, ...LEGACY_REDIRECT_ROUTES,
{ path: 'platform-ops/health', component: DummyRouteTargetComponent }, { path: 'platform/ops/health-slo', component: DummyRouteTargetComponent },
{ path: 'security-risk/artifacts/:artifactId', component: DummyRouteTargetComponent }, { path: 'security/artifacts/:artifactId', component: DummyRouteTargetComponent },
{ path: 'release-control/regions', component: DummyRouteTargetComponent }, { path: 'topology/regions', component: DummyRouteTargetComponent },
{ path: '**', component: DummyRouteTargetComponent }, { path: '**', component: DummyRouteTargetComponent },
]; ];
@@ -71,17 +76,17 @@ describe('Legacy Route Migration Framework (routes)', () => {
it('redirects legacy operations paths to platform ops canonical paths', async () => { it('redirects legacy operations paths to platform ops canonical paths', async () => {
await router.navigateByUrl('/ops/health'); await router.navigateByUrl('/ops/health');
expect(router.url).toBe('/platform-ops/health'); expect(router.url).toBe('/platform/ops/health-slo');
}); });
it('preserves route params and query params when redirecting triage artifact detail', async () => { it('preserves route params and query params when redirecting triage artifact detail', async () => {
await router.navigateByUrl('/triage/artifacts/artifact-123?tab=evidence'); await router.navigateByUrl('/triage/artifacts/artifact-123?tab=evidence');
expect(router.url).toBe('/security-risk/artifacts/artifact-123?tab=evidence'); expect(router.url).toBe('/security/artifacts/artifact-123?tab=evidence');
}); });
it('redirects release orchestrator environments to release control domain', async () => { it('redirects release orchestrator environments to topology domain', async () => {
await router.navigateByUrl('/release-orchestrator/environments'); await router.navigateByUrl('/release-orchestrator/environments');
expect(router.url).toBe('/release-control/regions'); expect(router.url).toBe('/topology/regions');
}); });
}); });
}); });

View File

@@ -9,6 +9,7 @@ import { setupWizardRoutes } from '../../app/features/setup-wizard/setup-wizard.
import { SetupWizardApiService } from '../../app/features/setup-wizard/services/setup-wizard-api.service'; import { SetupWizardApiService } from '../../app/features/setup-wizard/services/setup-wizard-api.service';
import { SetupWizardStateService } from '../../app/features/setup-wizard/services/setup-wizard-state.service'; import { SetupWizardStateService } from '../../app/features/setup-wizard/services/setup-wizard-state.service';
import { SetupSession } from '../../app/features/setup-wizard/models/setup-wizard.models'; import { SetupSession } from '../../app/features/setup-wizard/models/setup-wizard.models';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
const sessionFixture: SetupSession = { const sessionFixture: SetupSession = {
sessionId: 'session-1', sessionId: 'session-1',
@@ -120,6 +121,16 @@ describe('setup-wizard-live-api-wiring behavior', () => {
} as any; } as any;
beforeEach(async () => { beforeEach(async () => {
const mockDoctorApi = {
listChecks: () => of({ checks: [], total: 0 }),
listPlugins: () => of({ plugins: [], total: 0 }),
startRun: () => of({ runId: 'test' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: () => of({ reports: [], total: 0 }),
deleteReport: () => of(),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [SetupWizardComponent], imports: [SetupWizardComponent],
providers: [ providers: [
@@ -137,6 +148,7 @@ describe('setup-wizard-live-api-wiring behavior', () => {
navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)), navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)),
}, },
}, },
{ provide: DOCTOR_API, useValue: mockDoctorApi },
], ],
}) })
.overrideComponent(SetupWizardComponent, { .overrideComponent(SetupWizardComponent, {

View File

@@ -0,0 +1,88 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { SystemHealthPageComponent } from '../../app/features/system-health/system-health-page.component';
import { PlatformHealthClient } from '../../app/core/api/platform-health.client';
import { DoctorStore } from '../../app/features/doctor/services/doctor.store';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
const mockHealthClient = {
getSummary: () => of({
totalServices: 5,
healthyCount: 4,
degradedCount: 1,
unhealthyCount: 0,
unknownCount: 0,
overallState: 'healthy',
averageLatencyMs: 45,
averageErrorRate: 0.1,
activeIncidents: 0,
lastUpdated: '2026-02-20T10:00:00Z',
services: [],
}),
getDependencyGraph: () => of({ nodes: [], edges: [] }),
getIncidents: () => of({ incidents: [] }),
};
const mockDoctorApi = {
listChecks: () => of({ checks: [] }),
listPlugins: () => of({ plugins: [] }),
startRun: () => of({ runId: 'test' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: () => of({ reports: [] }),
deleteReport: () => of(),
};
describe('SystemHealthPageComponent', () => {
let fixture: ComponentFixture<SystemHealthPageComponent>;
let component: SystemHealthPageComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SystemHealthPageComponent],
providers: [
provideRouter([]),
{ provide: PlatformHealthClient, useValue: mockHealthClient },
DoctorStore,
{ provide: DOCTOR_API, useValue: mockDoctorApi },
],
}).compileComponents();
fixture = TestBed.createComponent(SystemHealthPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('should default to overview tab', () => {
expect(component.activeTab()).toBe('overview');
});
it('should switch tabs', () => {
component.activeTab.set('services');
expect(component.activeTab()).toBe('services');
component.activeTab.set('diagnostics');
expect(component.activeTab()).toBe('diagnostics');
component.activeTab.set('incidents');
expect(component.activeTab()).toBe('incidents');
});
it('should have 4 tabs defined', () => {
expect(component.tabs.length).toBe(4);
expect(component.tabs.map(t => t.id)).toEqual(['overview', 'services', 'diagnostics', 'incidents']);
});
it('should trigger quick diagnostics', () => {
const store = TestBed.inject(DoctorStore);
spyOn(store, 'startRun');
component.runQuickDiagnostics();
expect(store.startRun).toHaveBeenCalledWith({ mode: 'quick', includeRemediation: true });
});
});

View File

@@ -28,6 +28,11 @@ describe('TOPOLOGY_ROUTES dedicated pages', () => {
expect(await loadComponentName('targets')).toContain('TopologyTargetsPageComponent'); expect(await loadComponentName('targets')).toContain('TopologyTargetsPageComponent');
expect(await loadComponentName('hosts')).toContain('TopologyHostsPageComponent'); expect(await loadComponentName('hosts')).toContain('TopologyHostsPageComponent');
expect(await loadComponentName('agents')).toContain('TopologyAgentsPageComponent'); expect(await loadComponentName('agents')).toContain('TopologyAgentsPageComponent');
expect(await loadComponentName('promotion-paths')).toContain('TopologyPromotionPathsPageComponent'); expect(await loadComponentName('promotion-graph')).toContain('TopologyPromotionPathsPageComponent');
});
it('keeps promotion-paths as an alias redirect', () => {
const alias = TOPOLOGY_ROUTES.find((item) => item.path === 'promotion-paths');
expect(alias?.redirectTo).toBe('promotion-graph');
}); });
}); });