diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index c066bf1c6..e3ca2e094 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -473,7 +473,7 @@ services: aliases: - attestor-tileproxy.stella-ops.local healthcheck: - test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"] + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/8080'"] <<: *healthcheck-tcp labels: *release-labels @@ -1086,6 +1086,15 @@ services: ConnectionStrings__Default: *postgres-connection ConnectionStrings__Redis: "cache.stella-ops.local:6379" 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: - *cert-volume tmpfs: @@ -1528,7 +1537,7 @@ services: - smremote.stella-ops.local frontdoor: {} healthcheck: - test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"] + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/8080'"] <<: *healthcheck-tcp labels: *release-labels diff --git a/devops/docker/healthcheck.sh b/devops/docker/healthcheck.sh index 23ae48f6e..5eb086c72 100644 --- a/devops/docker/healthcheck.sh +++ b/devops/docker/healthcheck.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash set -eu HOST="${HEALTH_HOST:-127.0.0.1}" PORT="${HEALTH_PORT:-8080}" diff --git a/docs-archived/implplan/SPRINT_20260220_034_FE_unified_system_health_view.md b/docs-archived/implplan/SPRINT_20260220_034_FE_unified_system_health_view.md new file mode 100644 index 000000000..a74446704 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260220_034_FE_unified_system_health_view.md @@ -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. diff --git a/docs-archived/implplan/SPRINT_20260220_035_FE_contextual_doctor_inline_checks.md b/docs-archived/implplan/SPRINT_20260220_035_FE_contextual_doctor_inline_checks.md new file mode 100644 index 000000000..bdc581b74 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260220_035_FE_contextual_doctor_inline_checks.md @@ -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=` + +### 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. diff --git a/docs-archived/implplan/SPRINT_20260220_036_FE_sidebar_trend_sparklines.md b/docs-archived/implplan/SPRINT_20260220_036_FE_sidebar_trend_sparklines.md new file mode 100644 index 000000000..2cfbabb1d --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260220_036_FE_sidebar_trend_sparklines.md @@ -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` 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`, `platformTrend: Signal`. +- `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. diff --git a/docs-archived/implplan/SPRINT_20260220_037_FE_doctor_toast_notifications.md b/docs-archived/implplan/SPRINT_20260220_037_FE_doctor_toast_notifications.md new file mode 100644 index 000000000..42c9f9c24 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260220_037_FE_doctor_toast_notifications.md @@ -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): 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=`. +- `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. diff --git a/docs-archived/implplan/SPRINT_20260220_038_FE_command_palette_doctor_actions.md b/docs-archived/implplan/SPRINT_20260220_038_FE_command_palette_doctor_actions.md new file mode 100644 index 000000000..aaef03477 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260220_038_FE_command_palette_doctor_actions.md @@ -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. diff --git a/docs-archived/implplan/SPRINT_20260220_039_FE_setup_wizard_doctor_recheck.md b/docs-archived/implplan/SPRINT_20260220_039_FE_setup_wizard_doctor_recheck.md new file mode 100644 index 000000000..c73418de6 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260220_039_FE_setup_wizard_doctor_recheck.md @@ -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=&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()`. +- 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. diff --git a/docs/implplan/SPRINT_20260220_040_FE_ui_advisory_gap_closure.md b/docs/implplan/SPRINT_20260220_040_FE_ui_advisory_gap_closure.md new file mode 100644 index 000000000..908e85a44 --- /dev/null +++ b/docs/implplan/SPRINT_20260220_040_FE_ui_advisory_gap_closure.md @@ -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. diff --git a/docs/modules/ui/v2-rewire/S00_route_deprecation_map.md b/docs/modules/ui/v2-rewire/S00_route_deprecation_map.md index 91243aebf..96d91eaeb 100644 --- a/docs/modules/ui/v2-rewire/S00_route_deprecation_map.md +++ b/docs/modules/ui/v2-rewire/S00_route_deprecation_map.md @@ -1,4 +1,4 @@ -# S00 Route Deprecation Map (Pack 22 Canonical) +# S00 Route Deprecation Map (Pack 22/23 Canonical) Status: Active Date: 2026-02-20 @@ -7,14 +7,14 @@ Canonical source: `source-of-truth.md`, `pack-22.md` ## 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*`) -- `/security` (workspace subroots under `/security/overview`, `/security/triage`, `/security/advisories-vex`, `/security/supply-chain-data/*`) -- `/evidence` (capsule-first subroots under `/evidence/overview`, `/evidence/capsules`, `/evidence/exports/export`, `/evidence/verification/*`) +- `/security` (workspace subroots under `/security/posture`, `/security/triage`, `/security/disposition`, `/security/sbom/*`, `/security/reachability`) +- `/evidence` (capsule-first subroots under `/evidence/capsules`, `/evidence/exports`, `/evidence/verification/*`, `/evidence/audit-log`) - `/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 @@ -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` | | `/integrations/*` (legacy root) | `/platform/integrations/*` | `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 @@ -49,29 +49,40 @@ Define deterministic route migration from pre-Pack22 root families to Pack22 can | `/release-control/promotions` | `/releases/runs` | `redirect` | | `/release-control/hotfixes` | `/releases/hotfix` | `redirect` | | `/release-control/regions` | `/topology/regions` | `redirect` | -| `/release-control/setup` | `/platform/setup` | `redirect` | -| `/release-control/setup/environments-paths` | `/topology/environments` | `redirect` | +| `/release-control/setup` | `/topology/promotion-graph` | `redirect` | +| `/release-control/setup/environments-paths` | `/topology/promotion-graph` | `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 | Legacy path | Canonical target | Action | | --- | --- | --- | -| `/security-risk` | `/security/overview` | `redirect` | +| `/security-risk` | `/security/posture` | `redirect` | | `/security-risk/findings*` | `/security/triage*` | `redirect` | | `/security-risk/vulnerabilities*` | `/security/triage*` | `redirect` | -| `/security-risk/vex` | `/security/advisories-vex` | `redirect` | -| `/security-risk/exceptions` | `/security/advisories-vex` | `redirect` | -| `/security-risk/sbom` | `/security/supply-chain-data/graph` | `redirect` | -| `/security-risk/sbom-lake` | `/security/supply-chain-data/lake` | `redirect` | +| `/security-risk/vex` | `/security/disposition` | `redirect` | +| `/security-risk/exceptions` | `/security/disposition` | `redirect` | +| `/security-risk/sbom` | `/security/sbom/graph` | `redirect` | +| `/security-risk/sbom-lake` | `/security/sbom/lake` | `redirect` | | `/security-risk/advisory-sources` | `/platform/integrations/feeds` | `redirect` | +| `/sbom-sources` | `/platform/integrations/sbom-sources` | `redirect` | ## Evidence and Operations renames | Legacy path | Canonical target | Action | | --- | --- | --- | -| `/evidence-audit` | `/evidence/overview` | `redirect` | +| `/evidence-audit` | `/evidence/capsules` | `redirect` | | `/evidence-audit/packs*` | `/evidence/capsules*` | `redirect` | | `/evidence-audit/audit-log` | `/evidence/audit-log` | `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`, - `newPath`, - 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. ## Cutover checkpoint diff --git a/docs/modules/ui/v2-rewire/S01_a11y_perf_acceptance.md b/docs/modules/ui/v2-rewire/S01_a11y_perf_acceptance.md new file mode 100644 index 000000000..a08f3ce67 --- /dev/null +++ b/docs/modules/ui/v2-rewire/S01_a11y_perf_acceptance.md @@ -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`. diff --git a/docs/modules/ui/v2-rewire/S02_ui_verification_plan.md b/docs/modules/ui/v2-rewire/S02_ui_verification_plan.md new file mode 100644 index 000000000..84bf2d28c --- /dev/null +++ b/docs/modules/ui/v2-rewire/S02_ui_verification_plan.md @@ -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. diff --git a/docs/modules/ui/v2-rewire/authority-matrix.md b/docs/modules/ui/v2-rewire/authority-matrix.md index a6a788b2c..b430645d8 100644 --- a/docs/modules/ui/v2-rewire/authority-matrix.md +++ b/docs/modules/ui/v2-rewire/authority-matrix.md @@ -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 | | --- | --- | --- | --- | -| 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. | | 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. | @@ -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. | | 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. | -| 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 | 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`) | -| Bundle naming | Bundle-first labels in packs 12/21 | UI term is `Release`; bundle semantics remain in data model (`pack-22.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 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`) | | 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`) | | 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. 2. Start with listed authoritative pack(s). 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. | diff --git a/docs/modules/ui/v2-rewire/source-of-truth.md b/docs/modules/ui/v2-rewire/source-of-truth.md index d45922fb9..166a61d20 100644 --- a/docs/modules/ui/v2-rewire/source-of-truth.md +++ b/docs/modules/ui/v2-rewire/source-of-truth.md @@ -22,13 +22,12 @@ Working directory: `docs/modules/ui/v2-rewire` Canonical top-level modules are: -- `Dashboard` +- `Mission Control` - `Releases` - `Security` - `Evidence` - `Topology` - `Platform` -- `Administration` ### 2.2 Global context @@ -49,16 +48,15 @@ These are authoritative for planning and replace older conflicting placements: - `Release Control` root is decomposed: - release lifecycle surfaces move to `Releases`, - 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. -- `VEX` and `Exceptions` are exposed as one UX concept: - - `Security -> Triage` disposition rail + detail tabs, - - `Security -> Advisories & VEX` for provider/library/conflict/trust operations, - - backend data models remain distinct. -- SBOM, reachability, and unknowns are unified under `Security -> Supply-Chain Data` tabs. -- Advisory feed and VEX source configuration belongs to `Integrations`, not Security. -- `Policy Governance` remains under `Administration`. -- Trust posture must be reachable from `Evidence`, while admin-owner trust mutations remain governed by administration scopes. +- `VEX` and `Exceptions` remain distinct data models, but are exposed in one operator workspace: + - `Security -> Disposition Center` tabs (`VEX Statements`, `Exceptions`, `Expiring`), + - feeds/source configuration lives in `Platform -> Integrations -> Feeds`. +- SBOM Graph/Lake are one `Security -> SBOM` workspace with mode tabs. +- Reachability is a first-class surface under `Security -> Reachability`. +- `Policy Governance` remains administration-owned under `Platform -> Setup`. +- Trust posture is visible in `Evidence`, while signing/trust mutation stays in `Platform -> Setup -> Trust & Signing`. ## 3) Canonical screen authorities @@ -76,7 +74,7 @@ Superseded for overlapping decisions: - `pack-21.md` and lower packs for root module grouping and naming. -### 3.2 Dashboard +### 3.2 Mission Control Authoritative packs: @@ -108,7 +106,7 @@ 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. Superseded: @@ -137,26 +135,27 @@ Authoritative packs: - `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. -### 3.9 Administration +### 3.9 Platform Administration 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. ## 4) Normalized terminology (canonical names) Use these terms in sprint tickets/specs: -- `Bundle` -> `Release` -- `Create Bundle` -> `Create Release` -- `Current Release` -> `Deploy Release` -- `Run Timeline` -> `Activity` (cross-release) or `Timeline` (release detail tab) +- `Bundle` -> `Release Version` +- `Create Bundle` -> `Create Release Version` +- `Current Release` -> `Deploy/Promote` +- `Run/Timeline/Pipeline` -> `Release Run` - `Security & Risk` -> `Security` - `Evidence & Audit` -> `Evidence` +- `Evidence Pack/Bundle` -> `Decision Capsule` - `Platform Ops` -> `Platform -> Ops` -- `Integrations` root -> `Platform -> Integrations` -- `Setup` root -> `Platform -> Setup` +- `Integrations` root -> `Platform -> Integrations` (alias-window only at `/integrations`) +- `Setup` root -> `Platform -> Setup` (includes administration-owned setup/governance) - `Regions & Environments` menu -> `Topology` module + global context switchers ## 5) Planning gaps to schedule first diff --git a/src/Web/StellaOps.Web/src/app/app.component.ts b/src/Web/StellaOps.Web/src/app/app.component.ts index 864292b7b..eeed6ed40 100644 --- a/src/Web/StellaOps.Web/src/app/app.component.ts +++ b/src/Web/StellaOps.Web/src/app/app.component.ts @@ -23,6 +23,7 @@ import { AppShellComponent } from './layout/app-shell/app-shell.component'; import { BrandingService } from './core/branding/branding.service'; import { LegacyRouteTelemetryService } from './core/guards/legacy-route-telemetry.service'; import { LegacyUrlBannerComponent } from './shared/ui/legacy-url-banner/legacy-url-banner.component'; +import { PlatformContextUrlSyncService } from './core/context/platform-context-url-sync.service'; @Component({ selector: 'app-root', @@ -59,6 +60,7 @@ export class AppComponent { private readonly consoleStore = inject(ConsoleSessionStore); private readonly brandingService = inject(BrandingService); private readonly legacyRouteTelemetry = inject(LegacyRouteTelemetryService); + private readonly contextUrlSync = inject(PlatformContextUrlSyncService); private readonly destroyRef = inject(DestroyRef); @@ -90,6 +92,9 @@ export class AppComponent { // Initialize legacy route telemetry tracking (ROUTE-002) this.legacyRouteTelemetry.initialize(); + + // Keep global scope in sync with route query parameters. + this.contextUrlSync.initialize(); } readonly isAuthenticated = this.sessionStore.isAuthenticated; diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index 0e3733d6d..b11cbce78 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -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_BASE_URL, RiskHttpClient } from './core/api/risk-http.client'; 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 { AuthHttpInterceptor } from './core/auth/auth-http.interceptor'; import { AuthSessionStore } from './core/auth/auth-session.store'; @@ -962,5 +964,13 @@ export const appConfig: ApplicationConfig = { }, AocHttpClient, { provide: AOC_API, useExisting: AocHttpClient }, + + // Doctor background services + provideAppInitializer(() => { + inject(DoctorTrendService).start(); + }), + provideAppInitializer(() => { + inject(DoctorNotificationService).start(); + }), ], }; diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index 3f018de58..06571a408 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { requireAuthGuard, + requireAnyScopeGuard, requireOrchViewerGuard, requireOrchOperatorGuard, requirePolicyAuthorGuard, @@ -11,26 +12,92 @@ import { requirePolicyReviewOrApproveGuard, requirePolicyViewerGuard, requireAnalyticsViewerGuard, + StellaOpsScopes, } from './core/auth'; import { requireConfigGuard } from './core/config/config.guard'; import { requireBackendsReachableGuard } from './core/config/backends-reachable.guard'; 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 = [ // ======================================================================== - // V2 CANONICAL DOMAIN ROUTES (SPRINT_20260218_006) - // Seven root domains per S00 spec freeze (docs/modules/ui/v2-rewire/source-of-truth.md). - // Old v1 routes redirect to these canonical paths via V1_ALIAS_REDIRECT_ROUTES below. + // V2 CANONICAL DOMAIN ROUTES + // Canonical operator roots per source-of-truth: + // 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: '', pathMatch: 'full', - title: 'Dashboard', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], - data: { breadcrumb: 'Dashboard' }, + title: 'Mission Control', + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireMissionControlGuard], + data: { breadcrumb: 'Mission Control' }, loadChildren: () => import('./routes/dashboard.routes').then( (m) => m.DASHBOARD_ROUTES @@ -38,9 +105,9 @@ export const routes: Routes = [ }, { path: 'dashboard', - title: 'Dashboard', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], - data: { breadcrumb: 'Dashboard' }, + title: 'Mission Control', + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireMissionControlGuard], + data: { breadcrumb: 'Mission Control' }, loadChildren: () => import('./routes/dashboard.routes').then( (m) => m.DASHBOARD_ROUTES @@ -56,7 +123,7 @@ export const routes: Routes = [ { path: 'releases', title: 'Releases', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireReleasesGuard], data: { breadcrumb: 'Releases' }, loadChildren: () => import('./routes/releases.routes').then( @@ -68,7 +135,7 @@ export const routes: Routes = [ { path: 'security', title: 'Security', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireSecurityGuard], data: { breadcrumb: 'Security' }, loadChildren: () => import('./routes/security.routes').then( @@ -80,7 +147,7 @@ export const routes: Routes = [ { path: 'evidence', title: 'Evidence', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireEvidenceGuard], data: { breadcrumb: 'Evidence' }, loadChildren: () => import('./routes/evidence.routes').then( @@ -88,14 +155,11 @@ export const routes: Routes = [ ), }, - // Domain 5: Integrations (already canonical — kept as-is) - // /integrations already loaded below; no path change for this domain. - - // Domain 6: Topology + // Domain 5: Topology { path: 'topology', title: 'Topology', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireTopologyGuard], data: { breadcrumb: 'Topology' }, loadChildren: () => import('./routes/topology.routes').then( @@ -103,11 +167,11 @@ export const routes: Routes = [ ), }, - // Domain 7: Platform + // Domain 6: Platform { path: 'platform', title: 'Platform', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard], data: { breadcrumb: 'Platform' }, loadChildren: () => 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', pathMatch: 'full', redirectTo: '/platform/setup', }, - // Domain 9: Operations (legacy alias root retained for migration window) + // Legacy root alias: Operations { path: 'operations', title: 'Operations', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard], data: { breadcrumb: 'Operations' }, loadChildren: () => 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', title: 'Administration', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard], data: { breadcrumb: 'Administration' }, loadChildren: () => import('./routes/administration.routes').then( @@ -173,7 +237,7 @@ export const routes: Routes = [ { path: 'deployments', pathMatch: 'full', - redirectTo: '/releases/activity', + redirectTo: '/releases/runs', }, // Legacy Security alias @@ -203,7 +267,7 @@ export const routes: Routes = [ { path: 'platform-ops', title: 'Operations', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard], data: { breadcrumb: 'Operations' }, loadChildren: () => import('./routes/operations.routes').then( @@ -222,12 +286,12 @@ export const routes: Routes = [ { path: 'settings/release-control', pathMatch: 'full', - redirectTo: '/topology', + redirectTo: '/topology/promotion-graph', }, { path: 'settings/release-control/environments', pathMatch: 'full', - redirectTo: '/topology/environments', + redirectTo: '/topology/regions', }, { path: 'settings/release-control/targets', @@ -750,7 +814,7 @@ export const routes: Routes = [ // Integration Hub (SPRINT_20251229_011_FE_integration_hub_ui) { path: 'integrations', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard], loadChildren: () => import('./features/integration-hub/integration-hub.routes').then((m) => m.integrationHubRoutes), }, diff --git a/src/Web/StellaOps.Web/src/app/core/api/search.client.ts b/src/Web/StellaOps.Web/src/app/core/api/search.client.ts index b825d9b1c..633802bdd 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/search.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/search.client.ts @@ -117,7 +117,7 @@ export class SearchClient { type: 'cve' as SearchEntityType, title: item.id, subtitle: item.description?.substring(0, 100), - route: `/vulnerabilities/${item.id}`, + route: `/security/triage?cve=${encodeURIComponent(item.id)}`, severity: item.severity?.toLowerCase() as SearchResult['severity'], matchScore: 100, })) @@ -139,7 +139,7 @@ export class SearchClient { type: 'artifact' as SearchEntityType, title: `${item.repository}:${item.tag}`, subtitle: item.digest.substring(0, 16), - route: `/triage/artifacts/${encodeURIComponent(item.digest)}`, + route: `/security/triage?artifact=${encodeURIComponent(item.digest)}`, matchScore: 100, })) ), @@ -182,7 +182,7 @@ export class SearchClient { title: `job-${item.id.substring(0, 8)}`, subtitle: `${item.type} (${item.status})`, description: item.artifactRef, - route: `/platform-ops/orchestrator/jobs/${item.id}`, + route: `/platform/ops/orchestrator/jobs/${item.id}`, matchScore: 100, })) ), @@ -237,7 +237,7 @@ export class SearchClient { type: 'vex' as SearchEntityType, title: item.cveId, subtitle: `${item.status} - ${item.product}`, - route: `/admin/vex-hub/${item.id}`, + route: `/security/disposition?statementId=${encodeURIComponent(item.id)}`, matchScore: 100, })) ), @@ -259,7 +259,7 @@ export class SearchClient { type: 'integration' as SearchEntityType, title: item.name, subtitle: `${item.type} (${item.status})`, - route: `/integrations/${item.id}`, + route: `/platform/integrations/${item.id}`, matchScore: 100, })) ), diff --git a/src/Web/StellaOps.Web/src/app/core/api/search.models.ts b/src/Web/StellaOps.Web/src/app/core/api/search.models.ts index 61f2612d5..f43794a21 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/search.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/search.models.ts @@ -127,7 +127,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [ shortcut: '>jobs', description: 'Navigate to job list', icon: 'workflow', - route: '/platform-ops/orchestrator/jobs', + route: '/platform/ops/jobs-queues', keywords: ['jobs', 'orchestrator', 'list'], }, { @@ -145,7 +145,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [ shortcut: '>settings', description: 'Navigate to settings', icon: 'settings', - route: '/console/profile', + route: '/platform/setup', keywords: ['settings', 'config', 'preferences'], }, { @@ -154,8 +154,24 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [ shortcut: '>health', description: 'View platform health status', icon: 'heart-pulse', - route: '/ops/health', - keywords: ['health', 'status', 'platform', 'ops'], + route: '/platform/ops/system-health', + 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', @@ -163,16 +179,17 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [ shortcut: '>integrations', description: 'View and manage integrations', icon: 'plug', - route: '/integrations', + route: '/platform/integrations', 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(); - 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.label.toLowerCase().includes(normalizedQuery) || action.shortcut.toLowerCase().includes(normalizedQuery) diff --git a/src/Web/StellaOps.Web/src/app/core/context/global-context-http.interceptor.ts b/src/Web/StellaOps.Web/src/app/core/context/global-context-http.interceptor.ts index 1c89053c0..11f95e045 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/global-context-http.interceptor.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/global-context-http.interceptor.ts @@ -14,16 +14,20 @@ export class GlobalContextHttpInterceptor implements HttpInterceptor { } let params = request.params; - const region = this.context.selectedRegions()[0]; - const environment = this.context.selectedEnvironments()[0]; + const regions = this.context.selectedRegions(); + const environments = this.context.selectedEnvironments(); const timeWindow = this.context.timeWindow(); - if (region && !params.has('region')) { - params = params.set('region', region); + if (regions.length > 0 && !params.has('regions') && !params.has('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')) { params = params.set('timeWindow', timeWindow); } @@ -37,6 +41,7 @@ export class GlobalContextHttpInterceptor implements HttpInterceptor { url.includes('/api/v2/security') || url.includes('/api/v2/evidence') || url.includes('/api/v2/topology') || + url.includes('/api/v2/platform') || url.includes('/api/v2/integrations') ); } diff --git a/src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts b/src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts new file mode 100644 index 000000000..7b0aea83f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts @@ -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); + } finally { + this.syncingFromUrl = false; + } + } + + private applyPatch( + target: Record, + patch: Record, + ): 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, + right: Record, + ): boolean { + return JSON.stringify(this.normalizeQuery(left)) === JSON.stringify(this.normalizeQuery(right)); + } + + private normalizeQuery(query: Record): Record { + const normalized: Record = {}; + 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 = {}; + 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') + ); + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts index f6d766ad1..5ee0045e6 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts @@ -29,12 +29,22 @@ export interface PlatformContextPreferences { } 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' }) export class PlatformContextStore { private readonly http = inject(HttpClient); private persistPaused = false; private readonly apiDisabled = this.shouldDisableApiCalls(); + private readonly initialQueryOverride = this.readScopeQueryFromLocation(); readonly regions = signal([]); readonly environments = signal([]); @@ -152,26 +162,104 @@ export class PlatformContextStore { this.bumpContextVersion(); } + scopeQueryPatch(): Record { + 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): 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 { this.http .get('/api/v2/context/preferences') .pipe(take(1)) .subscribe({ 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( - prefs?.regions ?? [], + hydrated.regions, this.regions().map((item) => item.regionId), ); this.selectedRegions.set(preferredRegions); - this.timeWindow.set((prefs?.timeWindow ?? DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW); - this.loadEnvironments(preferredRegions, prefs?.environments ?? [], false); + this.timeWindow.set(hydrated.timeWindow); + this.loadEnvironments(preferredRegions, hydrated.environments, false); }, error: () => { // 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.timeWindow.set(DEFAULT_TIME_WINDOW); - this.loadEnvironments([], [], false); + this.timeWindow.set(fallbackState.timeWindow); + this.loadEnvironments(preferredRegions, fallbackState.environments, false); }, }); } @@ -257,6 +345,119 @@ export class PlatformContextStore { 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 = {}; + 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): 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, 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(); + 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, 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[] { const allowed = new Set(allowedValues.map((value) => value.toLowerCase())); const deduped = new Map(); diff --git a/src/Web/StellaOps.Web/src/app/core/doctor/doctor-notification.service.ts b/src/Web/StellaOps.Web/src/app/core/doctor/doctor-notification.service.ts new file mode 100644 index 000000000..0b2cbd83e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/doctor/doctor-notification.service.ts @@ -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(DOCTOR_API); + private readonly toast = inject(ToastService); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + private intervalId: ReturnType | 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; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.models.ts b/src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.models.ts new file mode 100644 index 000000000..7ea0db124 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.models.ts @@ -0,0 +1,9 @@ +export interface DoctorTrendPoint { + timestamp: string; + score: number; +} + +export interface DoctorTrendResponse { + category: string; + points: DoctorTrendPoint[]; +} diff --git a/src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.service.ts b/src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.service.ts new file mode 100644 index 000000000..a5df718f8 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/doctor/doctor-trend.service.ts @@ -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(DOCTOR_API); + private readonly destroyRef = inject(DestroyRef); + + private intervalId: ReturnType | null = null; + + /** Last 12 trend scores for the security category. */ + readonly securityTrend = signal([]); + + /** Last 12 trend scores for the platform category. */ + readonly platformTrend = signal([]); + + /** 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([]); + }, + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/doctor/index.ts b/src/Web/StellaOps.Web/src/app/core/doctor/index.ts new file mode 100644 index 000000000..f8a734ad8 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/doctor/index.ts @@ -0,0 +1,3 @@ +export * from './doctor-trend.models'; +export * from './doctor-trend.service'; +export * from './doctor-notification.service'; diff --git a/src/Web/StellaOps.Web/src/app/core/guards/legacy-route-telemetry.service.ts b/src/Web/StellaOps.Web/src/app/core/guards/legacy-route-telemetry.service.ts index 4dc6efe20..3c113af75 100644 --- a/src/Web/StellaOps.Web/src/app/core/guards/legacy-route-telemetry.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/guards/legacy-route-telemetry.service.ts @@ -1,199 +1,55 @@ /** * 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. - * Listens to router events and detects when navigation originated from a legacy redirect. + * Tracks usage of legacy routes during the alias window by resolving legacy + * hits against the canonical redirect templates in `legacy-redirects.routes.ts`. */ 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 { filter, pairwise, map } from 'rxjs'; +import { filter } from 'rxjs'; import { TelemetryClient } from '../telemetry/telemetry.client'; import { AUTH_SERVICE, AuthService } from '../auth/auth.service'; +import { LEGACY_REDIRECT_ROUTE_TEMPLATES } from '../../routes/legacy-redirects.routes'; -/** - * Map of legacy route patterns to their new canonical paths. - * Used to detect when a route was accessed via legacy URL. - */ -const LEGACY_ROUTE_MAP: Record = { - // 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', +interface CompiledLegacyRouteTemplate { + sourcePath: string; + targetTemplate: string; + regex: RegExp; + paramNames: string[]; +} - 'security-risk': '/security', - 'security-risk/findings': '/security/findings', - 'security-risk/vulnerabilities': '/security/vulnerabilities', - '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', +interface PendingLegacyRoute { + oldPath: string; + expectedNewPath: string; +} - 'evidence-audit': '/evidence', - 'evidence-audit/packs': '/evidence/packs', - 'evidence-audit/bundles': '/evidence/bundles', - 'evidence-audit/evidence': '/evidence/evidence', - 'evidence-audit/proofs': '/evidence/proofs', - 'evidence-audit/audit-log': '/evidence/audit-log', - 'evidence-audit/replay': '/evidence/replay', +const COMPILED_LEGACY_ROUTE_TEMPLATES: readonly CompiledLegacyRouteTemplate[] = [...LEGACY_REDIRECT_ROUTE_TEMPLATES] + .sort((left, right) => right.path.length - left.path.length) + .map((template) => { + const paramNames: string[] = []; + const sourcePath = template.path.replace(/^\/+/, '').replace(/\/+$/, ''); + const regexPattern = sourcePath + .split('/') + .map((segment) => { + if (segment.startsWith(':')) { + const name = segment.slice(1); + paramNames.push(name); + return `(?<${name}>[^/]+)`; + } + return escapeRegex(segment); + }) + .join('/'); - 'platform-ops': '/operations', - 'platform-ops/data-integrity': '/operations/data-integrity', - 'platform-ops/orchestrator': '/operations/orchestrator', - 'platform-ops/health': '/operations/health', - 'platform-ops/quotas': '/operations/quotas', - '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/' }, -]; + return { + sourcePath, + targetTemplate: template.redirectTo, + regex: new RegExp(`^${regexPattern}$`), + paramNames, + }; + }); export interface LegacyRouteHitEvent { eventType: 'legacy_route_hit'; @@ -218,147 +74,126 @@ export class LegacyRouteTelemetryService { private readonly authService = inject(AUTH_SERVICE) as AuthService; private readonly destroyRef = inject(DestroyRef); - private pendingLegacyRoute: string | null = null; + private pendingLegacyRoute: PendingLegacyRoute | null = null; 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(null); - /** - * Initialize the telemetry service. - * Should be called once during app bootstrap. - */ initialize(): void { - if (this.initialized) return; + if (this.initialized) { + return; + } this.initialized = true; - // Track NavigationStart to capture the initial URL before redirect - this.router.events.pipe( - filter((e): e is NavigationStart => e instanceof NavigationStart), - takeUntilDestroyed(this.destroyRef) - ).subscribe(event => { - const path = this.normalizePath(event.url); - if (this.isLegacyRoute(path)) { - this.pendingLegacyRoute = path; - } - }); + this.router.events + .pipe( + filter((event): event is NavigationStart => event instanceof NavigationStart), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((event) => { + const path = this.normalizePath(event.url); + const resolved = this.resolveLegacyRedirect(path); + this.pendingLegacyRoute = resolved + ? { oldPath: path, expectedNewPath: resolved } + : null; + }); - // Track NavigationEnd to confirm the redirect completed - this.router.events.pipe( - filter((e): e is NavigationEnd => e instanceof NavigationEnd), - takeUntilDestroyed(this.destroyRef) - ).subscribe(event => { - if (this.pendingLegacyRoute) { - const oldPath = this.pendingLegacyRoute; - const newPath = this.normalizePath(event.urlAfterRedirects); + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((event) => { + if (!this.pendingLegacyRoute) { + return; + } - // Only emit if we actually redirected to a different path - if (oldPath !== newPath) { - this.emitLegacyRouteHit(oldPath, newPath); + const oldPath = this.pendingLegacyRoute.oldPath; + const resolvedPath = this.pendingLegacyRoute.expectedNewPath; + 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; - } - }); + }); } - /** - * Check if a path matches a known legacy route. - */ - private isLegacyRoute(path: string): boolean { - // Check exact matches first - if (LEGACY_ROUTE_MAP[path]) { - return true; - } - - // Check pattern matches - for (const { pattern } of LEGACY_ROUTE_PATTERNS) { - if (pattern.test(path)) { - return true; - } - } - - return false; + clearCurrentLegacyRoute(): void { + this.currentLegacyRoute.set(null); + } + + getLegacyRouteCount(): number { + return COMPILED_LEGACY_ROUTE_TEMPLATES.length; + } + + private resolveLegacyRedirect(path: string): string | null { + for (const template of COMPILED_LEGACY_ROUTE_TEMPLATES) { + const match = template.regex.exec(path); + if (!match) { + continue; + } + + let target = template.targetTemplate; + 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 { let path = url; - // Remove query string const queryIndex = path.indexOf('?'); if (queryIndex !== -1) { path = path.substring(0, queryIndex); } - // Remove fragment const fragmentIndex = path.indexOf('#'); if (fragmentIndex !== -1) { path = path.substring(0, fragmentIndex); } - // Remove leading slash - if (path.startsWith('/')) { - path = path.substring(1); - } - - // Remove trailing slash - if (path.endsWith('/')) { - path = path.substring(0, path.length - 1); - } - + path = path.replace(/^\/+/, '').replace(/\/+$/, ''); return path; } - /** - * Emit telemetry event for legacy route hit. - */ + private asAbsolutePath(path: string): string { + if (!path) { + return '/'; + } + return path.startsWith('/') ? path : `/${path}`; + } + private emitLegacyRouteHit(oldPath: string, newPath: string): void { const user = this.authService.user(); - // Set current legacy route info for banner this.currentLegacyRoute.set({ - oldPath: `/${oldPath}`, - newPath, + oldPath: this.asAbsolutePath(oldPath), + newPath: this.asAbsolutePath(newPath), timestamp: Date.now(), }); this.telemetry.emit('legacy_route_hit', { - oldPath: `/${oldPath}`, - newPath, + oldPath: this.asAbsolutePath(oldPath), + newPath: this.asAbsolutePath(newPath), tenantId: user?.tenantId ?? null, userId: user?.id ?? null, userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown', 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, '\\$&'); +} diff --git a/src/Web/StellaOps.Web/src/app/core/services/toast.service.ts b/src/Web/StellaOps.Web/src/app/core/services/toast.service.ts index 4b3059a6b..551438901 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/toast.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/toast.service.ts @@ -82,6 +82,24 @@ export class ToastService { return this.show({ type: 'info', title, message, ...options }); } + /** Update an existing toast in-place. */ + update(id: string, options: Partial): 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(id: string): void { this._toasts.update(toasts => toasts.filter(t => t.id !== id)); diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.html b/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.html index 56075bc71..26a246746 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.html +++ b/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.html @@ -16,6 +16,11 @@ + @if (wizardLink) { + + } diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.ts b/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.ts index b085e75d7..874b2e8d3 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.ts @@ -4,6 +4,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { CheckResult } from '../../models/doctor.models'; import { RemediationPanelComponent } from '../remediation-panel/remediation-panel.component'; import { EvidenceViewerComponent } from '../evidence-viewer/evidence-viewer.component'; +import { getWizardStepForCheck, buildWizardDeepLink } from '../../models/doctor-wizard-mapping'; @Component({ selector: 'st-check-result', @@ -16,6 +17,7 @@ export class CheckResultComponent { @Input() expanded = false; @Input() fixEnabled = false; @Output() rerun = new EventEmitter(); + @Output() fixInSetup = new EventEmitter(); 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`; } + 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 { event.stopPropagation(); this.rerun.emit(); } + + onFixInSetup(event: Event): void { + event.stopPropagation(); + const link = this.wizardLink; + if (link) this.fixInSetup.emit(link); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/components/doctor-checks-inline/doctor-checks-inline.component.ts b/src/Web/StellaOps.Web/src/app/features/doctor/components/doctor-checks-inline/doctor-checks-inline.component.ts new file mode 100644 index 000000000..954d75f4f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/doctor/components/doctor-checks-inline/doctor-checks-inline.component.ts @@ -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: ` +
+
+
+ + {{ heading || 'Health Checks' }} +
+ + @if (summary(); as s) { +
+ {{ s.pass }} pass + / + {{ s.warn }} warn + / + {{ s.fail }} fail +
+ } @else { + No report + } + + +
+ + @if (expanded) { +
+ @for (result of visibleResults(); track result.checkId) { + + } + + @if (results().length === 0) { +

No checks for this category.

+ } + +
+ + + Open Full Diagnostics + +
+
+ } +
+ `, + 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 }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts index 1ee49d7ca..ca9615658 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; import { DoctorStore } from './services/doctor.store'; 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 { readonly store = inject(DoctorStore); private readonly configService = inject(AppConfigService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); readonly fixEnabled = this.configService.config.doctor?.fixEnabled ?? false; readonly showExportDialog = signal(false); @@ -49,6 +52,16 @@ export class DoctorDashboardComponent implements OnInit { // Load metadata on init this.store.fetchPlugins(); 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 { diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/index.ts b/src/Web/StellaOps.Web/src/app/features/doctor/index.ts index 5b49fdbb1..1e787b9bc 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/index.ts +++ b/src/Web/StellaOps.Web/src/app/features/doctor/index.ts @@ -9,6 +9,7 @@ export * from './services/doctor.store'; // Components 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/check-result/check-result.component'; export * from './components/remediation-panel/remediation-panel.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/models/doctor-wizard-mapping.ts b/src/Web/StellaOps.Web/src/app/features/doctor/models/doctor-wizard-mapping.ts new file mode 100644 index 000000000..e2ade6fc8 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/doctor/models/doctor-wizard-mapping.ts @@ -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`; +} diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor-quick-check.service.ts b/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor-quick-check.service.ts new file mode 100644 index 000000000..59a1972ad --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor-quick-check.service.ts @@ -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(), + }, + ]; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor-recheck.service.ts b/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor-recheck.service.ts new file mode 100644 index 000000000..fe5239412 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor-recheck.service.ts @@ -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), + }, + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.client.ts b/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.client.ts index db91c71c4..43054c6ec 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.client.ts +++ b/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.client.ts @@ -1,6 +1,7 @@ import { Injectable, InjectionToken, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of, delay } from 'rxjs'; +import { DoctorTrendResponse } from '../../../core/doctor/doctor-trend.models'; import { CheckListResponse, CheckMetadata, @@ -40,6 +41,9 @@ export interface DoctorApi { /** Delete a report by ID. */ deleteReport(reportId: string): Observable; + + /** Get health trend data for sparklines. */ + getTrends?(categories?: string[], limit?: number): Observable; } export const DOCTOR_API = new InjectionToken('DOCTOR_API'); @@ -94,6 +98,13 @@ export class HttpDoctorClient implements DoctorApi { deleteReport(reportId: string): Observable { return this.http.delete(`${this.baseUrl}/reports/${reportId}`); } + + getTrends(categories?: string[], limit?: number): Observable { + const params: Record = {}; + if (categories?.length) params['categories'] = categories.join(','); + if (limit != null) params['limit'] = limit.toString(); + return this.http.get(`${this.baseUrl}/trends`, { params }); + } } /** @@ -319,4 +330,16 @@ export class MockDoctorClient implements DoctorApi { deleteReport(reportId: string): Observable { return of(undefined).pipe(delay(50)); } + + getTrends(categories?: string[], limit = 12): Observable { + 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)); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.store.ts b/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.store.ts index 981792aca..905118b1b 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.store.ts +++ b/src/Web/StellaOps.Web/src/app/features/doctor/services/doctor.store.ts @@ -316,6 +316,24 @@ export class DoctorStore { 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. */ setCategoryFilter(category: DoctorCategory | null): void { this.categoryFilterSignal.set(category); diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts index 40f069c68..c765b5d99 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { IntegrationService } from './integration.service'; +import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component'; import { Integration, IntegrationType, @@ -18,7 +19,7 @@ import { */ @Component({ selector: 'app-integration-list', - imports: [CommonModule, RouterModule, FormsModule], + imports: [CommonModule, RouterModule, FormsModule, DoctorChecksInlineComponent], template: `
@@ -44,6 +45,8 @@ import { /> + + @if (loading) {
Loading integrations...
} @else if (integrations.length === 0) { diff --git a/src/Web/StellaOps.Web/src/app/features/platform-health/components/kpi-strip.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-health/components/kpi-strip.component.ts new file mode 100644 index 000000000..6a071c7d7 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/platform-health/components/kpi-strip.component.ts @@ -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: ` +
+
+
+ Services + +
+

+ @if (summary.totalServices != null) { + {{ summary.healthyCount ?? 0 }}/{{ summary.totalServices }} + } @else { — } +

+

Healthy

+
+ +
+ Avg Latency +

{{ formatLatency(summary.averageLatencyMs) }}

+

P95 across services

+
+ +
+ Error Rate +

+ {{ formatErrorRate(summary.averageErrorRate) }} +

+

Platform-wide

+
+ +
+ Incidents +

+ {{ summary.activeIncidents }} +

+

Active

+
+ +
+ Status +
+ +

+ {{ summary.overallState | titlecase }} +

+
+
+
+ `, + 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'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/platform-health/components/service-health-grid.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-health/components/service-health-grid.component.ts new file mode 100644 index 000000000..5b8fe7a53 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/platform-health/components/service-health-grid.component.ts @@ -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: ` +
+
+

Service Health

+ +
+
+ @if ((services ?? []).length === 0) { +

No services available in current snapshot

+ } @else if (groupBy() === 'state') { + @if (unhealthy().length > 0) { + + } + @if (degraded().length > 0) { + + } + @if (healthy().length > 0) { + + } + } @else { + + } +
+
+ `, + 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] ?? ''; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts index cf82d6de9..88cd98bf3 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component'; interface WorkflowCard { id: string; @@ -12,7 +13,7 @@ interface WorkflowCard { @Component({ selector: 'app-platform-ops-overview-page', standalone: true, - imports: [RouterLink], + imports: [RouterLink, DoctorChecksInlineComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -64,6 +65,8 @@ interface WorkflowCard {
+ +

Secondary Operator Tools

+ @if (mode() === 'run') { +
+ {{ liveSyncStatus() }} + + Last sync: {{ lastSyncAt() ? fmt(lastSyncAt()!) : 'n/a' }} + + +
+ } + + @if (runSyncImpact(); as impact) { + + } +