doctor and setup fixes
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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 036–039 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.
|
||||||
@@ -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.
|
||||||
@@ -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, 037–039.
|
||||||
|
- 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.
|
||||||
@@ -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 034–036, 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.
|
||||||
@@ -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 034–036, 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.
|
||||||
@@ -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 034–038.
|
||||||
|
|
||||||
|
## 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.
|
||||||
158
docs/implplan/SPRINT_20260220_040_FE_ui_advisory_gap_closure.md
Normal file
158
docs/implplan/SPRINT_20260220_040_FE_ui_advisory_gap_closure.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
47
docs/modules/ui/v2-rewire/S01_a11y_perf_acceptance.md
Normal file
47
docs/modules/ui/v2-rewire/S01_a11y_perf_acceptance.md
Normal 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`.
|
||||||
46
docs/modules/ui/v2-rewire/S02_ui_verification_plan.md
Normal file
46
docs/modules/ui/v2-rewire/S02_ui_verification_plan.md
Normal 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.
|
||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface DoctorTrendPoint {
|
||||||
|
timestamp: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoctorTrendResponse {
|
||||||
|
category: string;
|
||||||
|
points: DoctorTrendPoint[];
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/Web/StellaOps.Web/src/app/core/doctor/index.ts
Normal file
3
src/Web/StellaOps.Web/src/app/core/doctor/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './doctor-trend.models';
|
||||||
|
export * from './doctor-trend.service';
|
||||||
|
export * from './doctor-notification.service';
|
||||||
@@ -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, '\\$&');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()">↻</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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|
||||||
|
|||||||
@@ -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(' ');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}).`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user