diff --git a/docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md b/docs-archived/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md similarity index 76% rename from docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md rename to docs-archived/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md index 99deb8379..199414889 100644 --- a/docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md +++ b/docs-archived/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md @@ -30,7 +30,7 @@ ## Delivery Tracker ### FE-PO-001 - Rebuild the Operations overview and submenu in the live shell -Status: TODO +Status: DONE Dependency: none Owners: Product Manager, FE Architect Task description: @@ -38,12 +38,12 @@ Task description: - Ensure the overview and submenu expose the real operator concerns rather than leaving them as a paper design. Completion criteria: -- [ ] The live shell exposes the intended overview groups and submenu entries. -- [ ] Overview group labels and child-route labels are wired in code. -- [ ] No parallel `platform-ops` owner tree is required for operator access. +- [x] The live shell exposes the intended overview groups and submenu entries. +- [x] Overview group labels and child-route labels are wired in code. +- [x] No parallel `platform-ops` owner tree is required for operator access. ### FE-PO-002 - Ship the overview page and grouped blocking cards -Status: TODO +Status: DONE Dependency: FE-PO-001 Owners: Developer, FE Architect Task description: @@ -51,12 +51,12 @@ Task description: - Make the overview page usable as the real operator landing page rather than a placeholder around child routes. Completion criteria: -- [ ] Blocking strip renders the required signals in the live shell. -- [ ] Grouped cards route into working child pages. -- [ ] Overview sections follow one consistent shipped pattern. +- [x] Blocking strip renders the required signals in the live shell. +- [x] Grouped cards route into working child pages. +- [x] Overview sections follow one consistent shipped pattern. ### FE-PO-003 - Absorb high-value legacy widgets and pages -Status: TODO +Status: DONE Dependency: FE-PO-001 Owners: Developer, Documentation author Task description: @@ -64,12 +64,12 @@ Task description: - Retire only the obsolete duplicates after the missing operational functionality is actually present in the live shell. Completion criteria: -- [ ] High-value legacy widgets are visible in the active shell or child routes. -- [ ] Missing operational views are actually shipped into target pages. -- [ ] Only genuinely duplicated legacy routes remain candidates for retirement. +- [x] High-value legacy widgets are visible in the active shell or child routes. +- [x] Missing operational views are actually shipped into target pages. +- [x] Only genuinely duplicated legacy routes remain candidates for retirement. ### FE-PO-004 - Cut over routes and legacy aliases -Status: TODO +Status: DONE Dependency: FE-PO-001 Owners: FE Architect, Developer Task description: @@ -77,12 +77,12 @@ Task description: - Ensure old bookmarks and legacy route trees redirect into working pages without reviving a second shell. Completion criteria: -- [ ] Canonical route family is active in the router. -- [ ] Legacy aliases redirect into working operations pages. -- [ ] Sidebar, overview cards, and breadcrumb labels match in the shipped shell. +- [x] Canonical route family is active in the router. +- [x] Legacy aliases redirect into working operations pages. +- [x] Sidebar, overview cards, and breadcrumb labels match in the shipped shell. ### FE-PO-005 - Complete Setup boundary and topology deep links -Status: TODO +Status: DONE Dependency: FE-PO-002 Owners: Product Manager, FE Architect Task description: @@ -90,12 +90,12 @@ Task description: - Wire deep links between `Ops > Operations` and `Setup > Topology` where operators need to cross that boundary. Completion criteria: -- [ ] Agent fleet and topology ownership remain under Setup in the live UI. -- [ ] Operations-to-Setup deep links work from shipped pages. -- [ ] No duplicated topology management remains exposed under Ops. +- [x] Agent fleet and topology ownership remain under Setup in the live UI. +- [x] Operations-to-Setup deep links work from shipped pages. +- [x] No duplicated topology management remains exposed under Ops. ### FE-PO-006 - Verify, document, and cut over the consolidated shell -Status: TODO +Status: DONE Dependency: FE-PO-004 Owners: QA, Documentation author Task description: @@ -103,14 +103,17 @@ Task description: - Update UI docs and operational runbooks so the consolidated shell ships as the usable owner surface. Completion criteria: -- [ ] Verification covers overview groups and target routes. -- [ ] Legacy alias redirects are included in testing. -- [ ] Docs reflect the consolidated and shipped owner shell. +- [x] Verification covers overview groups and target routes. +- [x] Legacy alias redirects are included in testing. +- [x] Docs reflect the consolidated and shipped owner shell. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-03-07 | Sprint created to ship a consolidated Operations shell with grouped overview cards, stable child routes, legacy-widget absorption, and explicit Setup boundaries. | Project Manager | +| 2026-03-07 | Implementation started. Current `Ops > Operations` routes exist, but the overview and several child pages still link to dead `/platform/ops/*` paths. Consolidation work will replace those with canonical `/ops/operations/*` routes and add legacy redirect coverage. | Developer | +| 2026-03-07 | Shipped the consolidated overview taxonomy, canonical `/ops/operations/*` links, legacy alias redirects, and Setup-boundary deep links. | Developer | +| 2026-03-07 | Verified with targeted Angular and Playwright coverage, updated module docs, and archived the sprint. | QA | ## Decisions & Risks - Decision: `Ops > Operations` remains the one operator shell; legacy `platform-ops` becomes migration input only. diff --git a/docs/features/checked/web/operations-consolidation-ui.md b/docs/features/checked/web/operations-consolidation-ui.md new file mode 100644 index 000000000..23466a704 --- /dev/null +++ b/docs/features/checked/web/operations-consolidation-ui.md @@ -0,0 +1,62 @@ +# Operations Consolidation UI + +## Module +Web + +## Status +VERIFIED + +## Description +Shipped the canonical `Ops > Operations` owner shell with grouped overview cards, blocking-strip signals, canonical `/ops/operations/*` child routes, absorbed legacy data-integrity and queue links, and explicit deep links into `Setup > Topology` for agent-fleet ownership. Legacy `platform-ops/*` and `platform/ops/*` aliases now resolve into the consolidated shell instead of requiring a parallel product tree. + +## Implementation Details +- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/platform/ops/` +- **Primary components**: + - `platform-ops-overview-page` (`src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts`) + - `platform-feeds-airgap-page` (`src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts`) + - `platform-jobs-queues-page` (`src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts`) +- **Canonical routes**: + - `/ops/operations` + - `/ops/operations/data-integrity` + - `/ops/operations/jobs-queues` + - `/ops/operations/health-slo` + - `/ops/operations/feeds-airgap` + - `/ops/operations/offline-kit` + - `/ops/operations/quotas` + - `/ops/operations/aoc` + - `/ops/operations/doctor` + - `/ops/operations/signals` + - `/ops/operations/packs` + - `/ops/operations/notifications` +- **Legacy aliases**: + - `/platform-ops/*` + - `/platform/ops/*` +- **Secondary entry points**: + - `Mission Control` + - `Setup > Topology` + - `Evidence` + - `Releases` + +## E2E Test Plan +- **Setup**: + - [ ] Log in with a user that can access `Ops` and `Setup`. + - [ ] Navigate to `/ops/operations`. + - [ ] Ensure doctor trend and approvals fixtures or seeded data exist. +- **Core verification**: + - [ ] Verify `Blocking`, `Execution`, `Health`, `Supply And Airgap`, and `Capacity And Setup Boundary` groups render. + - [ ] Verify overview cards drill into the intended canonical child routes. + - [ ] Verify Setup-boundary links send agent and topology work to `Setup > Topology`. +- **Legacy verification**: + - [ ] Verify `platform-ops/*` and `platform/ops/*` aliases land in canonical `/ops/operations/*` routes. + - [ ] Verify query-string state survives redirect into tabbed child pages. + - [ ] Verify data-integrity drill-ins and job detail routes keep working after alias cutover. + +## Verification +- Run: + - `npx ng test --watch=false --include src/tests/platform-ops/platform-ops-routes.spec.ts --include src/tests/platform-ops/platform-ops-overview-page.component.spec.ts --include src/tests/platform-ops/platform-feeds-airgap-page.component.spec.ts --include src/tests/platform-ops/data-integrity-pages.spec.ts --include src/tests/navigation/legacy-redirects.spec.ts` + - `PLAYWRIGHT_PORT=4410 npx playwright test tests/e2e/operations-consolidation.spec.ts --workers=1` +- Tier 0 (source): pass +- Tier 1 (build/tests): pass +- Tier 2 (behavior): pass +- Note: under the local Angular dev server, `/platform*` first-request URLs are intercepted by `proxy.conf.json`, so the Playwright legacy-alias check uses client-side navigation after app bootstrap to validate Angular redirect behavior deterministically. +- Verified on (UTC): 2026-03-07T18:28:16Z diff --git a/docs/modules/ui/README.md b/docs/modules/ui/README.md index 802e24509..b10ae9331 100644 --- a/docs/modules/ui/README.md +++ b/docs/modules/ui/README.md @@ -16,8 +16,12 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runt - Added restoration topic shape notes at `restoration-topics/README.md` for Watchlist, Reachability Witnessing, Platform Ops, Triage explainability, and Workflow Visualization placement. - Added implementation-ready UX dossiers for Watchlist, Reachability Witnessing, Platform Ops Consolidation, Triage Explainability Workspace, Workflow Visualization and Replay, and shared contextual action patterns. - Added FE sprint files for the five accepted restoration topics plus a shared sprint for single actions, drawers, tabs, and stray-page placement patterns. +- Shipped the canonical `Setup > Trust & Signing` watchlist shell, including entries, alerts, tuning, and Mission Control or Notifications deep links. +- Added checked-feature verification for watchlist management at `../../features/checked/web/identity-watchlist-management-ui.md`. - Shipped the canonical `Security > Reachability` witness and proof-of-exposure shell, including cross-shell handoffs from findings, triage, evidence replay, and release detail. - Added checked-feature verification for reachability witnessing at `../../features/checked/web/reachability-witnessing-ui.md`. +- Shipped the consolidated `Ops > Operations` shell with grouped overview cards, canonical `/ops/operations/*` routes, and legacy `platform-ops` alias cutover. +- Added checked-feature verification for operations consolidation at `../../features/checked/web/operations-consolidation-ui.md`. ## Latest updates (2026-02-21) - Runtime mock cutover completed for policy simulation history/conflict/batch flows and graph explorer data loading in `src/Web/StellaOps.Web/src/app/**`. diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index 5d8a16475..9e9372dde 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -9,7 +9,6 @@ - `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` -- `docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md` - `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md` - `docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md` - `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` @@ -82,12 +81,12 @@ - [DONE] FE-RW-004 Cross-product deep links and release-context use for reachability proofs - [DONE] FE-RW-005 Supporting evidence and export surfaces for witness UX - [DONE] FE-RW-006 QA, rollout, and docs sync for reachability witnessing -- [TODO] FE-PO-001 Freeze Operations overview taxonomy and submenu structure -- [TODO] FE-PO-002 Overview page regrouping and blocking-card contract -- [TODO] FE-PO-003 Legacy widget absorption matrix for Platform Ops -- [TODO] FE-PO-004 Route cleanup and alias migration contract for Operations -- [TODO] FE-PO-005 Setup boundary and deep-link contract for Operations -- [TODO] FE-PO-006 QA, rollout, and docs sync for Platform Ops consolidation +- [DONE] FE-PO-001 Freeze Operations overview taxonomy and submenu structure +- [DONE] FE-PO-002 Overview page regrouping and blocking-card contract +- [DONE] FE-PO-003 Legacy widget absorption matrix for Platform Ops +- [DONE] FE-PO-004 Route cleanup and alias migration contract for Operations +- [DONE] FE-PO-005 Setup boundary and deep-link contract for Operations +- [DONE] FE-PO-006 QA, rollout, and docs sync for Platform Ops consolidation - [TODO] FE-TX-001 Freeze artifact workspace route, lane, and panel contract - [TODO] FE-TX-002 List-lane segmentation slice for Artifact Workspace - [TODO] FE-TX-003 Detail-side explainability rail slice diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index 4a81bb205..ce7ec9733 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.md @@ -13,7 +13,6 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components. - `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation. - `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself. -- `SPRINT_20260307_026_FE_platform_ops_consolidation.md` - ship one Operations shell with grouped overview cards, legacy widget absorption, and legacy redirects. - `SPRINT_20260307_027_FE_triage_explainability_workspace.md` - ship the artifact workspace lane model, explainability panels, and audit-bundle flows. - `SPRINT_20260307_028_FE_workflow_visualization_replay.md` - ship run-detail graph, timeline, replay, and evidence tabs plus bounded workflow-editor preview reuse. - `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - ship the shared tabs, drawers, right rails, split views, and contextual detail primitives adopted by the restoration features. @@ -27,6 +26,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - `docs/modules/ui/watchlist-operations/README.md` - detailed watchlist UX dossier and owner-shell contract. - `docs/features/checked/web/reachability-witnessing-ui.md` - shipped verification note for the canonical Reachability witness and PoE shell. - `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs. +- `docs/features/checked/web/operations-consolidation-ui.md` - shipped verification note for the canonical Operations shell, overview grouping, and legacy alias cutover. - `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract. - `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan. - `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier. diff --git a/docs/modules/ui/platform-ops-consolidation/README.md b/docs/modules/ui/platform-ops-consolidation/README.md index 56b2531d7..10a93735c 100644 --- a/docs/modules/ui/platform-ops-consolidation/README.md +++ b/docs/modules/ui/platform-ops-consolidation/README.md @@ -1,5 +1,7 @@ # Platform Ops Consolidation +**Status:** Shipped on 2026-03-07 + ## Recommendation Keep one consolidated operator shell under `Ops > Operations` and absorb the useful legacy `platform-ops` surfaces into it. @@ -10,6 +12,14 @@ Keep one consolidated operator shell under `Ops > Operations` and absorb the use This is not a restoration of an abandoned product. It is a consolidation of operator navigation, overview grouping, and missing widgets into the existing route tree. +## Shipped Scope + +- Mounted one grouped `Ops > Operations` overview with blocking-strip status, quick submenu chips, pending-action callouts, and an explicit Setup ownership boundary. +- Standardized canonical route helpers under `/ops/operations/*` and rewired active overview and child-page links away from dead `/platform/ops/*` paths. +- Added legacy alias redirects for both `/platform-ops/*` and `/platform/ops/*` into the live Operations shell. +- Preserved agent-fleet and topology ownership under `Setup > Topology` while adding direct Operations handoffs where runtime monitoring needs to cross that boundary. +- Added focused route, component, and Playwright verification plus a checked-feature note at `docs/features/checked/web/operations-consolidation-ui.md`. + ## Why This Is The Right Shape - The current app already routes operators through `/ops/operations`. @@ -115,6 +125,15 @@ Prefer the current route family and tighten it rather than creating new paths. - duplicate overview routes should be retired once card parity is reached - child-route labels should match the sidebar and overview cards exactly +## Verification + +- Angular: + - `npx ng test --watch=false --include src/tests/platform-ops/platform-ops-routes.spec.ts --include src/tests/platform-ops/platform-ops-overview-page.component.spec.ts --include src/tests/platform-ops/platform-feeds-airgap-page.component.spec.ts --include src/tests/platform-ops/data-integrity-pages.spec.ts --include src/tests/navigation/legacy-redirects.spec.ts` +- Playwright: + - `PLAYWRIGHT_PORT=4410 npx playwright test tests/e2e/operations-consolidation.spec.ts --workers=1` +- Checked feature record: + - `docs/features/checked/web/operations-consolidation-ui.md` + ## What To Merge ### Preserve as the main shell diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index 5299b99bf..4fc5e6e4f 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -7,6 +7,7 @@ import { } from './core/auth'; import { requireBackendsReachableGuard } from './core/config/backends-reachable.guard'; import { requireConfigGuard } from './core/config/config.guard'; +import { LEGACY_REDIRECT_ROUTES } from './routes/legacy-redirects.routes'; const requireMissionControlGuard = requireAnyScopeGuard( [ @@ -83,6 +84,7 @@ export const routes: Routes = [ pathMatch: 'full', redirectTo: 'mission-control/board', }, + ...LEGACY_REDIRECT_ROUTES, { path: 'mission-control', title: 'Mission Control', @@ -104,6 +106,13 @@ export const routes: Routes = [ data: { breadcrumb: 'Security' }, loadChildren: () => import('./routes/security-risk.routes').then((m) => m.SECURITY_RISK_ROUTES), }, + { + path: 'triage', + title: 'Triage', + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], + data: { breadcrumb: 'Triage' }, + loadChildren: () => import('./routes/triage.routes').then((m) => m.TRIAGE_ROUTES), + }, { path: 'evidence', title: 'Evidence', @@ -161,11 +170,17 @@ export const routes: Routes = [ data: { breadcrumb: 'Console Admin' }, loadChildren: () => import('./features/console-admin/console-admin.routes').then((m) => m.consoleAdminRoutes), }, + { + path: 'platform-ops', + loadChildren: () => import('./routes/platform-ops.routes').then((m) => m.PLATFORM_OPS_ROUTES), + }, { path: 'platform', children: [ - { path: 'ops', redirectTo: '/ops', pathMatch: 'full' }, - { path: 'ops/:rest', redirectTo: '/ops/:rest' }, + { + path: 'ops', + loadChildren: () => import('./routes/platform-ops.routes').then((m) => m.PLATFORM_OPS_ROUTES), + }, { path: 'setup', redirectTo: '/setup', pathMatch: 'full' }, { path: 'setup/:rest', redirectTo: '/setup/:rest' }, { path: '**', redirectTo: '/ops' }, diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity-overview.component.ts index b6acb819d..07991033a 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity-overview.component.ts @@ -123,13 +123,13 @@ interface FailureItem {

Drilldowns

- Nightly Ops Report - Feeds Freshness - Scan Pipeline Health - Reachability Ingest Health - Integration Connectivity - DLQ and Replays - Data Quality SLOs + Nightly Ops Report + Feeds Freshness + Scan Pipeline Health + Reachability Ingest Health + Integration Connectivity + DLQ and Replays + Data Quality SLOs
@@ -325,7 +325,7 @@ export class DataIntegrityOverviewComponent { state: 'warn', impact: 'BLOCKING', detail: 'NVD feed stale by 3h 12m', - route: '/platform/ops/data-integrity/feeds-freshness', + route: '/ops/operations/data-integrity/feeds-freshness', }, { id: 'scan', @@ -333,7 +333,7 @@ export class DataIntegrityOverviewComponent { state: 'ok', impact: 'INFO', detail: 'Nightly rescan completed', - route: '/platform/ops/data-integrity/scan-pipeline', + route: '/ops/operations/data-integrity/scan-pipeline', }, { id: 'reachability', @@ -341,7 +341,7 @@ export class DataIntegrityOverviewComponent { state: 'warn', impact: 'DEGRADED', detail: 'Runtime backlog elevated', - route: '/platform/ops/data-integrity/reachability-ingest', + route: '/ops/operations/data-integrity/reachability-ingest', }, { id: 'integrations', @@ -349,7 +349,7 @@ export class DataIntegrityOverviewComponent { state: 'ok', impact: 'INFO', detail: 'Core connectors are reachable', - route: '/platform/ops/data-integrity/integration-connectivity', + route: '/ops/operations/data-integrity/integration-connectivity', }, { id: 'dlq', @@ -357,7 +357,7 @@ export class DataIntegrityOverviewComponent { state: 'warn', impact: 'DEGRADED', detail: '3 items pending replay', - route: '/platform/ops/data-integrity/dlq', + route: '/ops/operations/data-integrity/dlq', }, ]; @@ -379,19 +379,19 @@ export class DataIntegrityOverviewComponent { id: 'failure-nvd', title: 'NVD sync lag', detail: 'Feed lag exceeds SLA for release-critical path.', - route: '/platform/ops/data-integrity/feeds-freshness', + route: '/ops/operations/data-integrity/feeds-freshness', }, { id: 'failure-runtime', title: 'Runtime ingest backlog', detail: 'Runtime source queue depth is increasing.', - route: '/platform/ops/data-integrity/reachability-ingest', + route: '/ops/operations/data-integrity/reachability-ingest', }, { id: 'failure-dlq', title: 'DLQ replay queue', detail: 'Pending replay items block confidence for approvals.', - route: '/platform/ops/data-integrity/dlq', + route: '/ops/operations/data-integrity/dlq', }, ]; diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/dlq-replays-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/dlq-replays-page.component.ts index 407b406eb..5a59dbef9 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/dlq-replays-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/dlq-replays-page.component.ts @@ -60,9 +60,9 @@ interface DlqItem { {{ item.payload }} {{ item.age }} - Replay - View - Link job + Replay + View + Link job } @empty { @@ -76,7 +76,7 @@ interface DlqItem { `, diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/feeds-freshness-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/feeds-freshness-page.component.ts index 69182ec01..19779ae0b 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/feeds-freshness-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/feeds-freshness-page.component.ts @@ -48,11 +48,11 @@ interface FeedRow { diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/integration-connectivity-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/integration-connectivity-page.component.ts index c2ac2c113..e6e043e26 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/integration-connectivity-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/integration-connectivity-page.component.ts @@ -43,7 +43,7 @@ interface ConnectorRow { Open Detail Test - View dependent jobs + View dependent jobs View impacted approvals diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/job-run-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/job-run-detail-page.component.ts index 6d4c84092..d4f6d1577 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/job-run-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/job-run-detail-page.component.ts @@ -46,8 +46,8 @@ interface AffectedItem { `, diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/nightly-ops-report-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/nightly-ops-report-page.component.ts index d1176ba2e..1f5279e2b 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/nightly-ops-report-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/nightly-ops-report-page.component.ts @@ -67,11 +67,11 @@ interface NightlyJobRow { {{ row.impact }} - View Run - Open Scheduler - Open JobEngine + View Run + Open Scheduler + Open JobEngine Open Integration - Open DLQ + Open DLQ } diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/reachability-ingest-health-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/reachability-ingest-health-page.component.ts index bfe3de082..2d37c5b48 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/reachability-ingest-health-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/reachability-ingest-health-page.component.ts @@ -53,8 +53,8 @@ interface IngestRow { diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/scan-pipeline-health-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/scan-pipeline-health-page.component.ts index 5d1a62bd8..6cf4a9653 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/scan-pipeline-health-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/scan-pipeline-health-page.component.ts @@ -42,8 +42,8 @@ interface Stage { @@ -173,4 +173,3 @@ export class ScanPipelineHealthPageComponent { readonly affectedEnvironments = 3; readonly blockedApprovals = 2; } - diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/operations-paths.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/operations-paths.ts new file mode 100644 index 000000000..414f32a51 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/operations-paths.ts @@ -0,0 +1,43 @@ +export const OPERATIONS_ROOT = '/ops/operations'; + +export const OPERATIONS_PATHS = { + overview: OPERATIONS_ROOT, + dataIntegrity: `${OPERATIONS_ROOT}/data-integrity`, + jobsQueues: `${OPERATIONS_ROOT}/jobs-queues`, + healthSlo: `${OPERATIONS_ROOT}/health-slo`, + feedsAirgap: `${OPERATIONS_ROOT}/feeds-airgap`, + offlineKit: `${OPERATIONS_ROOT}/offline-kit`, + quotas: `${OPERATIONS_ROOT}/quotas`, + aoc: `${OPERATIONS_ROOT}/aoc`, + doctor: `${OPERATIONS_ROOT}/doctor`, + signals: `${OPERATIONS_ROOT}/signals`, + packs: `${OPERATIONS_ROOT}/packs`, + notifications: `${OPERATIONS_ROOT}/notifications`, + status: `${OPERATIONS_ROOT}/status`, + scheduler: `${OPERATIONS_ROOT}/scheduler`, + schedulerRuns: `${OPERATIONS_ROOT}/scheduler/runs`, + schedulerSchedules: `${OPERATIONS_ROOT}/scheduler/schedules`, + schedulerWorkers: `${OPERATIONS_ROOT}/scheduler/workers`, + deadLetter: `${OPERATIONS_ROOT}/dead-letter`, + jobEngine: `${OPERATIONS_ROOT}/jobengine`, + jobEngineJobs: `${OPERATIONS_ROOT}/jobengine/jobs`, + jobEngineQuotas: `${OPERATIONS_ROOT}/jobengine/quotas`, +} as const; + +export const OPERATIONS_SETUP_PATHS = { + topologyOverview: '/setup/topology/overview', + topologyAgents: '/setup/topology/agents', + feedPolicy: '/ops/platform-setup/feed-policy', +} as const; + +export const OPERATIONS_INTEGRATION_PATHS = { + advisorySources: '/ops/integrations/advisory-vex-sources', +} as const; + +export function dataIntegrityPath(section?: string): string { + return section ? `${OPERATIONS_PATHS.dataIntegrity}/${section}` : OPERATIONS_PATHS.dataIntegrity; +} + +export function jobEngineJobPath(jobId?: string): string { + return jobId ? `${OPERATIONS_PATHS.jobEngineJobs}/${jobId}` : OPERATIONS_PATHS.jobEngineJobs; +} diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts index 5b5131a42..fe75ebe38 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts @@ -20,7 +20,7 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';

- Configure Sources + Configure Sources
@@ -90,15 +90,15 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks'; @if (tab() === 'airgap-bundles') {

Offline import/export workflows and bundle verification controls.

} @if (tab() === 'version-locks') {

Freeze upstream feed inputs used by promotion gates and replay evidence.

} diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts index e759afe8c..ec7af7a27 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts @@ -65,8 +65,8 @@ interface WorkerRow {

- Open Data Integrity - Run Diagnostics + Open Data Integrity + Run Diagnostics
@@ -133,8 +133,8 @@ interface WorkerRow { {{ row.lastRun }} {{ row.health }} - View - Run Now + View + Run Now } @@ -167,7 +167,7 @@ interface WorkerRow { {{ row.duration }} {{ row.impact }} - View + View @@ -197,8 +197,8 @@ interface WorkerRow { {{ row.nextRun }} {{ row.lastStatus }} - Edit - Pause + Edit + Pause } @@ -229,7 +229,7 @@ interface WorkerRow { {{ row.retryable }} {{ row.impact }} - Replay + Replay @@ -261,8 +261,8 @@ interface WorkerRow { {{ row.capacity }} {{ row.heartbeat }} - View - Drain + View + Drain } @@ -288,14 +288,14 @@ interface WorkerRow { @if (tab() === 'dead-letters') {

Dead-letter triage is linked to release impact. - Open DLQ & Replays + Open DLQ & Replays

} @if (tab() === 'workers') {

Worker capacity and health affect queue latency and decision freshness SLAs.

} diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html new file mode 100644 index 000000000..858de532f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html @@ -0,0 +1,153 @@ +
+
+
+

Ops / Operations

+

Platform Ops

+

+ Consolidated operator shell for blocking platform issues, execution control, diagnostics, + and airgap workflows. Topology and agent ownership remain under Setup. +

+
+ +
+ Run Doctor + Audit Log + Export Ops Report + +
+
+ + + +
+
+ Blocking subsystems + 3 + Release affecting +
+
+ Degraded surfaces + 5 + Operator follow-up +
+
+ Setup-owned handoffs + 2 + Topology boundary +
+
+ Queued actions + {{ pendingActions.length }} + Needs review +
+
+ +
+
+
+

Blocking

+

Open the highest-impact drills first. These cards route to the owning child page.

+
+
+ +
+ @for (item of blockingCards; track item.id) { + +
+ {{ item.metric }} + Open +
+

{{ item.title }}

+

{{ item.detail }}

+
+ } +
+
+ + + + @for (group of overviewGroups; track group.id) { +
+
+
+

{{ group.title }}

+

{{ group.description }}

+
+
+ + +
+ } + + + + @if (refreshedAt()) { +

+ Snapshot refreshed at {{ refreshedAt() }}. +

+ } +
diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss new file mode 100644 index 000000000..d19bf296b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss @@ -0,0 +1,258 @@ +:host { + display: block; +} + +.ops-overview { + display: grid; + gap: 1rem; +} + +.ops-overview__header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.ops-overview__title-group h1 { + margin: 0; + font-size: 1.55rem; +} + +.ops-overview__eyebrow { + margin: 0 0 0.2rem; + color: var(--color-brand-primary); + font-size: 0.76rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.ops-overview__subtitle { + margin: 0.3rem 0 0; + max-width: 72ch; + color: var(--color-text-secondary); + font-size: 0.84rem; + line-height: 1.5; +} + +.ops-overview__actions { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.ops-overview__actions a, +.ops-overview__actions button, +.ops-overview__submenu-link, +.blocking-card, +.ops-card, +.ops-overview__boundary-links a { + text-decoration: none; +} + +.ops-overview__actions a, +.ops-overview__actions button { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + color: var(--color-text-primary); + padding: 0.38rem 0.66rem; + font-size: 0.74rem; + cursor: pointer; +} + +.ops-overview__submenu { + display: flex; + gap: 0.45rem; + flex-wrap: wrap; +} + +.ops-overview__submenu-link { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-full); + background: var(--color-surface-primary); + color: var(--color-text-secondary); + padding: 0.2rem 0.62rem; + font-size: 0.72rem; +} + +.ops-overview__summary { + display: grid; + gap: 0.7rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.ops-overview__summary article, +.ops-overview__panel { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 0.85rem; + display: grid; + gap: 0.25rem; +} + +.ops-overview__summary strong { + font-size: 1.45rem; +} + +.ops-overview__summary-label, +.ops-overview__section-header p, +.ops-card p, +.ops-overview__panel p, +.ops-overview__panel li span { + color: var(--color-text-secondary); +} + +.ops-overview__summary-label { + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.ops-overview__section-header { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: flex-start; +} + +.ops-overview__section-header h2, +.ops-overview__panel h2 { + margin: 0; + font-size: 1rem; +} + +.ops-overview__section-header p, +.ops-card p, +.ops-overview__panel p, +.ops-overview__panel li span { + margin: 0.25rem 0 0; + font-size: 0.8rem; + line-height: 1.45; +} + +.ops-overview__blocking-grid, +.ops-overview__group-grid, +.ops-overview__footer-grid { + display: grid; + gap: 0.8rem; +} + +.ops-overview__blocking-grid, +.ops-overview__group-grid { + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); +} + +.ops-overview__footer-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.blocking-card, +.ops-card { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + color: inherit; + padding: 0.85rem; + display: grid; + gap: 0.4rem; +} + +.blocking-card__topline, +.ops-card__header { + display: flex; + justify-content: space-between; + gap: 0.5rem; + align-items: center; +} + +.blocking-card__route, +.ops-card__owner { + font-size: 0.68rem; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); +} + +.ops-card__owner--setup { + color: var(--color-brand-primary); +} + +.blocking-card h3, +.ops-card h3 { + margin: 0; + font-size: 0.96rem; +} + +.impact { + width: fit-content; + border-radius: var(--radius-full); + padding: 0.12rem 0.48rem; + font-size: 0.68rem; + font-weight: var(--font-weight-semibold); +} + +.impact--blocking { + background: var(--color-status-error-bg); + color: var(--color-status-error-text); +} + +.impact--degraded { + background: var(--color-status-warning-bg); + color: var(--color-status-warning-text); +} + +.impact--info { + background: var(--color-status-info-bg); + color: var(--color-status-info-text); +} + +.ops-overview__panel ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.65rem; +} + +.ops-overview__panel li { + display: grid; + gap: 0.16rem; +} + +.ops-overview__panel li a, +.ops-overview__boundary-links a { + color: var(--color-brand-primary); +} + +.ops-overview__panel li strong { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.ops-overview__boundary-links { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.6rem; +} + +.ops-overview__note { + margin: 0; + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +@media (max-width: 900px) { + .ops-overview__header { + flex-direction: column; + } + + .ops-overview__actions { + justify-content: flex-start; + } +} 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 88cd98bf3..e309cf781 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,330 +1,316 @@ 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; - title: string; - description: string; - route: string; - impact: 'BLOCKING' | 'DEGRADED' | 'INFO'; +import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component'; +import { + OPERATIONS_INTEGRATION_PATHS, + OPERATIONS_PATHS, + OPERATIONS_SETUP_PATHS, + dataIntegrityPath, +} from './operations-paths'; + +type OpsImpact = 'blocking' | 'degraded' | 'info'; + +interface OverviewNavItem { + readonly id: string; + readonly label: string; + readonly route: string; +} + +interface BlockingCard { + readonly id: string; + readonly title: string; + readonly detail: string; + readonly metric: string; + readonly impact: OpsImpact; + readonly route: string; +} + +interface OperationsCard { + readonly id: string; + readonly title: string; + readonly detail: string; + readonly metric: string; + readonly impact: OpsImpact; + readonly route: string; + readonly owner: 'Ops' | 'Setup'; +} + +interface OperationsGroup { + readonly id: string; + readonly title: string; + readonly description: string; + readonly cards: readonly OperationsCard[]; +} + +interface PendingAction { + readonly id: string; + readonly title: string; + readonly detail: string; + readonly route: string; + readonly owner: 'Ops' | 'Setup'; } @Component({ selector: 'app-platform-ops-overview-page', standalone: true, imports: [RouterLink, DoctorChecksInlineComponent], + templateUrl: './platform-ops-overview-page.component.html', + styleUrls: ['./platform-ops-overview-page.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
-
-
-

Platform Ops

-

- Operability workflows for defensible release decisions: data trust, execution control, - and service health. -

-
-
- Run Doctor - Export Ops Report - -
-
- -
-
-

Data Trust Score

-

87

- WARN -
-
-

Platform Health

-

2

- WARN services -
-
-

Dead Letter Queue

-

3

- DEGRADED -
-
- -
-

Primary Workflows

-
- @for (card of primaryWorkflows; track card.id) { - -

{{ card.title }}

-

{{ card.description }}

- - Impact: {{ card.impact }} - -
- } -
-
- - - -
-

Secondary Operator Tools

- -
- -
-

Recent Operator Alerts

-
    -
  • - NVD feed stale 3h12m - Impact: BLOCKING - Open -
  • -
  • - Runtime ingest backlog - Impact: DEGRADED - Open -
  • -
  • - DLQ replay queue pending - Impact: DEGRADED - Open -
  • -
-
- - @if (refreshed()) { -

Snapshot refreshed for current scope.

- } -
- `, - styles: [` - .ops-overview { - display: grid; - gap: 0.9rem; - } - - .ops-overview__header { - display: flex; - justify-content: space-between; - gap: 0.9rem; - align-items: start; - } - - .ops-overview__header h1 { - margin: 0; - font-size: 1.4rem; - } - - .ops-overview__header p { - margin: 0.2rem 0 0; - color: var(--color-text-secondary); - font-size: 0.82rem; - max-width: 66ch; - } - - .ops-overview__actions { - display: flex; - gap: 0.35rem; - flex-wrap: wrap; - } - - .ops-overview__actions a, - .ops-overview__actions button { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - padding: 0.35rem 0.6rem; - background: var(--color-surface-primary); - text-decoration: none; - color: var(--color-text-primary); - font-size: 0.74rem; - cursor: pointer; - } - - .ops-overview__kpis { - display: grid; - gap: 0.6rem; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - } - - .ops-overview__kpis article { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - padding: 0.65rem; - display: grid; - gap: 0.2rem; - } - - .ops-overview__kpis h2 { - margin: 0; - font-size: 0.74rem; - color: var(--color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.03em; - } - - .ops-overview__kpis p { - margin: 0; - font-size: 1.15rem; - font-weight: var(--font-weight-semibold); - color: var(--color-text-heading); - } - - .pill { - width: fit-content; - border-radius: var(--radius-full); - padding: 0.1rem 0.45rem; - font-size: 0.66rem; - font-weight: var(--font-weight-semibold); - } - - .pill--warn { - background: var(--color-status-warning-bg); - color: var(--color-status-warning-text); - } - - .pill--degraded { - background: var(--color-status-info-bg); - color: var(--color-status-info-text); - } - - .ops-overview__primary h2, - .ops-overview__secondary h2, - .ops-overview__alerts h2 { - margin: 0 0 0.4rem; - font-size: 0.95rem; - } - - .ops-overview__grid { - display: grid; - gap: 0.6rem; - grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); - } - - .ops-card { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - padding: 0.7rem; - text-decoration: none; - color: inherit; - display: grid; - gap: 0.25rem; - } - - .ops-card h3 { - margin: 0; - font-size: 0.88rem; - } - - .ops-card p { - margin: 0; - font-size: 0.74rem; - color: var(--color-text-secondary); - } - - .impact { - width: fit-content; - border-radius: var(--radius-full); - padding: 0.1rem 0.45rem; - font-size: 0.65rem; - font-weight: var(--font-weight-semibold); - } - - .impact--blocking { - background: var(--color-status-error-bg); - color: var(--color-status-error-text); - } - - .impact--degraded { - background: var(--color-status-warning-bg); - color: var(--color-status-warning-text); - } - - .impact--info { - background: var(--color-status-info-bg); - color: var(--color-status-info-text); - } - - .ops-overview__links { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; - } - - .ops-overview__links a { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.25rem 0.45rem; - font-size: 0.74rem; - color: var(--color-brand-primary); - text-decoration: none; - } - - .ops-overview__alerts ul { - list-style: none; - margin: 0; - padding: 0; - display: grid; - gap: 0.35rem; - } - - .ops-overview__alerts li { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.45rem 0.55rem; - display: flex; - gap: 0.45rem; - flex-wrap: wrap; - align-items: center; - font-size: 0.76rem; - } - - .ops-overview__alerts a { - color: var(--color-brand-primary); - text-decoration: none; - } - - .ops-overview__note { - margin: 0; - font-size: 0.74rem; - color: var(--color-text-secondary); - } - `], }) export class PlatformOpsOverviewPageComponent { - readonly refreshed = signal(false); + readonly OPERATIONS_PATHS = OPERATIONS_PATHS; + readonly OPERATIONS_SETUP_PATHS = OPERATIONS_SETUP_PATHS; + readonly refreshedAt = signal(null); - readonly primaryWorkflows: WorkflowCard[] = [ + readonly quickNav: readonly OverviewNavItem[] = [ + { id: 'overview', label: 'Overview', route: OPERATIONS_PATHS.overview }, + { id: 'data-integrity', label: 'Data Integrity', route: OPERATIONS_PATHS.dataIntegrity }, + { id: 'jobs-queues', label: 'Jobs & Queues', route: OPERATIONS_PATHS.jobsQueues }, + { id: 'health-slo', label: 'Health & SLO', route: OPERATIONS_PATHS.healthSlo }, + { id: 'feeds-airgap', label: 'Feeds & Airgap', route: OPERATIONS_PATHS.feedsAirgap }, + { id: 'offline-kit', label: 'Offline Kit', route: OPERATIONS_PATHS.offlineKit }, + { id: 'quotas', label: 'Quotas & Limits', route: OPERATIONS_PATHS.quotas }, + { id: 'aoc', label: 'AOC Compliance', route: OPERATIONS_PATHS.aoc }, + { id: 'doctor', label: 'Diagnostics', route: OPERATIONS_PATHS.doctor }, + { id: 'signals', label: 'Signals', route: OPERATIONS_PATHS.signals }, + { id: 'packs', label: 'Pack Registry', route: OPERATIONS_PATHS.packs }, + { id: 'notifications', label: 'Notifications', route: OPERATIONS_PATHS.notifications }, + ]; + + readonly blockingCards: readonly BlockingCard[] = [ { - id: 'data-integrity', - title: 'Data Integrity', - description: 'Trust signals, blocked decisions, and freshness recovery actions.', - route: '/platform/ops/data-integrity', - impact: 'BLOCKING', + id: 'feeds', + title: 'Feed freshness blocking releases', + detail: 'NVD mirror is stale and approvals are pinned to the last-known-good snapshot.', + metric: '3h 12m stale', + impact: 'blocking', + route: dataIntegrityPath('feeds-freshness'), }, { - id: 'jobs-queues', - title: 'Jobs & Queues', - description: 'Unified orchestration runs, schedules, dead letters, and workers.', - route: '/platform/ops/jobs-queues', - impact: 'DEGRADED', + id: 'dlq', + title: 'Replay backlog degrading confidence', + detail: 'Dead-letter replay queue is elevated for reachability and evidence exports.', + metric: '3 queued replays', + impact: 'degraded', + route: dataIntegrityPath('dlq'), }, { - id: 'health-slo', - title: 'Health & SLO', - description: 'Service/dependency health and incident timelines with SLO context.', - route: '/platform/ops/health-slo', - impact: 'INFO', + id: 'aoc', + title: 'AOC compliance needs operator review', + detail: 'Recent provenance violations require a compliance drilldown before promotion.', + metric: '4 open violations', + impact: 'blocking', + route: `${OPERATIONS_PATHS.aoc}/violations`, }, ]; + + readonly overviewGroups: readonly OperationsGroup[] = [ + { + id: 'blocking', + title: 'Blocking', + description: 'Operator-first paths for issues that can block release, evidence, or trust decisions.', + cards: [ + { + id: 'data-integrity', + title: 'Data Integrity', + detail: 'Feeds freshness, scan pipeline health, integration reachability, and DLQ replay safety.', + metric: '5 trust signals', + impact: 'blocking', + route: OPERATIONS_PATHS.dataIntegrity, + owner: 'Ops', + }, + { + id: 'aoc', + title: 'AOC Compliance', + detail: 'Control attestation, provenance validation, and violation triage.', + metric: '4 violations', + impact: 'blocking', + route: OPERATIONS_PATHS.aoc, + owner: 'Ops', + }, + { + id: 'notifications', + title: 'Notifications', + detail: 'Critical operator alerts, escalation channels, and delivery status.', + metric: '2 paging alerts', + impact: 'degraded', + route: OPERATIONS_PATHS.notifications, + owner: 'Ops', + }, + ], + }, + { + id: 'execution', + title: 'Execution', + description: 'Execution control for queues, workers, schedules, runtime signals, and replay flow.', + cards: [ + { + id: 'jobs-queues', + title: 'Jobs & Queues', + detail: 'Orchestrator jobs, dead-letter posture, worker fleet, and immediate drill-ins.', + metric: '1 blocking run', + impact: 'degraded', + route: OPERATIONS_PATHS.jobsQueues, + owner: 'Ops', + }, + { + id: 'scheduler', + title: 'Scheduler', + detail: 'Run inventory, schedules, and worker coordination windows.', + metric: '19 active schedules', + impact: 'info', + route: OPERATIONS_PATHS.schedulerRuns, + owner: 'Ops', + }, + { + id: 'signals', + title: 'Signals', + detail: 'Runtime signal freshness, event volume, and missing telemetry indicators.', + metric: '97% fresh', + impact: 'info', + route: OPERATIONS_PATHS.signals, + owner: 'Ops', + }, + ], + }, + { + id: 'health', + title: 'Health', + description: 'System posture, diagnostics, and operational evidence for degraded subsystems.', + cards: [ + { + id: 'health-slo', + title: 'Health & SLO', + detail: 'Service health, burn-rate posture, and subsystem incident context.', + metric: '2 degraded services', + impact: 'degraded', + route: OPERATIONS_PATHS.healthSlo, + owner: 'Ops', + }, + { + id: 'doctor', + title: 'Diagnostics', + detail: 'Interactive doctor checks, dependency probes, and operator troubleshooting.', + metric: '11 checks', + impact: 'info', + route: OPERATIONS_PATHS.doctor, + owner: 'Ops', + }, + { + id: 'status', + title: 'System Status', + detail: 'Global status, service heartbeat, and cross-product availability view.', + metric: '1 regional incident', + impact: 'degraded', + route: OPERATIONS_PATHS.status, + owner: 'Ops', + }, + ], + }, + { + id: 'supply-airgap', + title: 'Supply And Airgap', + description: 'Feed sourcing, offline delivery, and pack distribution in one operator view.', + cards: [ + { + id: 'feeds-airgap', + title: 'Feeds & Airgap', + detail: 'Mirror freshness, version locks, airgap bundle flows, and source configuration.', + metric: '1 degraded mirror', + impact: 'blocking', + route: OPERATIONS_PATHS.feedsAirgap, + owner: 'Ops', + }, + { + id: 'offline-kit', + title: 'Offline Kit', + detail: 'Offline import/export operations, evidence bundle movement, and sealed-mode transfers.', + metric: '3 queued exports', + impact: 'info', + route: OPERATIONS_PATHS.offlineKit, + owner: 'Ops', + }, + { + id: 'packs', + title: 'Pack Registry', + detail: 'Pack distribution, bundle integrity, and package availability for workflows.', + metric: '14 active packs', + impact: 'info', + route: OPERATIONS_PATHS.packs, + owner: 'Ops', + }, + ], + }, + { + id: 'capacity-boundary', + title: 'Capacity And Setup Boundary', + description: 'Capacity remains in Operations, while topology and agent inventory stay owned by Setup.', + cards: [ + { + id: 'quotas', + title: 'Quotas & Limits', + detail: 'JobEngine quotas, operator limits, and burst-capacity review.', + metric: '1 tenant near limit', + impact: 'degraded', + route: OPERATIONS_PATHS.quotas, + owner: 'Ops', + }, + { + id: 'topology', + title: 'Topology Overview', + detail: 'Infrastructure placement, regions, environments, and promotion-path ownership.', + metric: 'Setup owned', + impact: 'info', + route: OPERATIONS_SETUP_PATHS.topologyOverview, + owner: 'Setup', + }, + { + id: 'agents', + title: 'Agent Fleet', + detail: 'Agent enrollment, placement, and health remain under Setup > Topology.', + metric: 'Setup owned', + impact: 'info', + route: OPERATIONS_SETUP_PATHS.topologyAgents, + owner: 'Setup', + }, + ], + }, + ]; + + readonly pendingActions: readonly PendingAction[] = [ + { + id: 'retry-feed-sync', + title: 'Recover stale feed source', + detail: 'Open the feeds freshness lens and review version-lock posture before retry.', + route: dataIntegrityPath('feeds-freshness'), + owner: 'Ops', + }, + { + id: 'replay-dlq', + title: 'Drain replay backlog', + detail: 'Open DLQ and replay blockers before the next promotion window.', + route: dataIntegrityPath('dlq'), + owner: 'Ops', + }, + { + id: 'review-agent-placement', + title: 'Review agent placement', + detail: 'Agent fleet issues route to Setup because topology ownership stays out of Operations.', + route: OPERATIONS_SETUP_PATHS.topologyAgents, + owner: 'Setup', + }, + { + id: 'configure-advisory-sources', + title: 'Review advisory source config', + detail: 'Use Integrations for source configuration; Operations remains the monitoring shell.', + route: OPERATIONS_INTEGRATION_PATHS.advisorySources, + owner: 'Ops', + }, + ]; + + refreshSnapshot(): void { + this.refreshedAt.set(new Date().toISOString()); + } } diff --git a/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts b/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts index 754cb0e60..099a1cc31 100644 --- a/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts @@ -1,4 +1,5 @@ -import { Routes } from '@angular/router'; +import { inject } from '@angular/core'; +import { Params, Router, Routes } from '@angular/router'; export interface LegacyRedirectRouteTemplate { path: string; @@ -6,10 +7,58 @@ export interface LegacyRedirectRouteTemplate { pathMatch?: 'prefix' | 'full'; } -/** - * Pre-alpha route policy: no legacy redirect map. - * Keep exports as empty collections so historical imports compile without enabling aliases. - */ -export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTemplate[] = []; +export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTemplate[] = [ + { + path: 'ops/health', + redirectTo: '/ops/operations/health-slo', + pathMatch: 'full', + }, + { + path: 'release-orchestrator/environments', + redirectTo: '/topology/regions', + pathMatch: 'full', + }, + { + path: 'triage/artifacts', + redirectTo: '/security/artifacts', + pathMatch: 'full', + }, + { + path: 'triage/artifacts/:artifactId', + redirectTo: '/security/artifacts/:artifactId', + pathMatch: 'full', + }, + { + path: 'triage/findings', + redirectTo: '/security/findings', + pathMatch: 'full', + }, + { + path: 'triage/findings/:findingId', + redirectTo: '/security/findings/:findingId', + pathMatch: 'full', + }, +]; -export const LEGACY_REDIRECT_ROUTES: Routes = []; +export const LEGACY_REDIRECT_ROUTES: Routes = LEGACY_REDIRECT_ROUTE_TEMPLATES.map((template) => ({ + path: template.path, + redirectTo: ({ params, queryParams, fragment }) => { + const router = inject(Router); + const target = router.parseUrl(interpolateRedirectTarget(template.redirectTo, params)); + target.queryParams = { ...queryParams }; + target.fragment = fragment ?? null; + return target; + }, + pathMatch: template.pathMatch ?? 'full', +})); + +function interpolateRedirectTarget(template: string, params: Params): string { + let target = template; + + for (const [name, rawValue] of Object.entries(params ?? {})) { + const value = Array.isArray(rawValue) ? rawValue.join('/') : String(rawValue); + target = target.replaceAll(`:${name}`, encodeURIComponent(value)); + } + + return target; +} diff --git a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts index 75229070f..6d6b41234 100644 --- a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts @@ -77,6 +77,26 @@ export const OPERATIONS_ROUTES: Routes = [ (m) => m.JobEngineJobsComponent, ), }, + { + path: 'jobengine/jobs/:jobId', + title: 'Job Detail', + data: { breadcrumb: 'Job Detail' }, + canMatch: [requireOrchViewerGuard], + loadComponent: () => + import('../features/jobengine/jobengine-job-detail.component').then( + (m) => m.JobEngineJobDetailComponent, + ), + }, + { + path: 'jobengine/jobs/:jobId/dag', + title: 'Job DAG', + data: { breadcrumb: 'Job DAG' }, + canMatch: [requireOrchViewerGuard], + loadComponent: () => + import('../features/jobengine/jobengine-job-detail.component').then( + (m) => m.JobEngineJobDetailComponent, + ), + }, { path: 'orchestrator/jobs/:jobId', title: 'Job Detail', diff --git a/src/Web/StellaOps.Web/src/app/routes/platform-ops.routes.ts b/src/Web/StellaOps.Web/src/app/routes/platform-ops.routes.ts index 14eb4a11b..ee474defb 100644 --- a/src/Web/StellaOps.Web/src/app/routes/platform-ops.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/platform-ops.routes.ts @@ -1,4 +1,101 @@ -import { Routes } from '@angular/router'; +import { inject } from '@angular/core'; +import { Params, Router, Routes } from '@angular/router'; -/** Legacy Platform Ops tree retired in pre-alpha IA. */ -export const PLATFORM_OPS_ROUTES: Routes = []; +import { + OPERATIONS_PATHS, + OPERATIONS_SETUP_PATHS, +} from '../features/platform/ops/operations-paths'; + +interface LegacyPlatformOpsRedirect { + path: string; + redirectTo: string; +} + +const LEGACY_PLATFORM_OPS_REDIRECTS: readonly LegacyPlatformOpsRedirect[] = [ + { path: '', redirectTo: OPERATIONS_PATHS.overview }, + { path: 'data-integrity', redirectTo: OPERATIONS_PATHS.dataIntegrity }, + { + path: 'data-integrity/nightly-ops/:runId', + redirectTo: `${OPERATIONS_PATHS.dataIntegrity}/nightly-ops/:runId`, + }, + { + path: 'data-integrity/:section', + redirectTo: `${OPERATIONS_PATHS.dataIntegrity}/:section`, + }, + { path: 'jobs-queues', redirectTo: OPERATIONS_PATHS.jobsQueues }, + { path: 'feeds', redirectTo: OPERATIONS_PATHS.feedsAirgap }, + { path: 'feeds-airgap', redirectTo: OPERATIONS_PATHS.feedsAirgap }, + { path: 'offline-kit', redirectTo: OPERATIONS_PATHS.offlineKit }, + { path: 'health', redirectTo: OPERATIONS_PATHS.healthSlo }, + { path: 'health-slo', redirectTo: OPERATIONS_PATHS.healthSlo }, + { path: 'doctor', redirectTo: OPERATIONS_PATHS.doctor }, + { path: 'quotas', redirectTo: OPERATIONS_PATHS.quotas }, + { path: 'aoc', redirectTo: OPERATIONS_PATHS.aoc }, + { path: 'signals', redirectTo: OPERATIONS_PATHS.signals }, + { path: 'packs', redirectTo: OPERATIONS_PATHS.packs }, + { path: 'notifications', redirectTo: OPERATIONS_PATHS.notifications }, + { path: 'status', redirectTo: OPERATIONS_PATHS.status }, + { path: 'agents', redirectTo: OPERATIONS_SETUP_PATHS.topologyAgents }, + { path: 'jobengine', redirectTo: OPERATIONS_PATHS.jobEngine }, + { path: 'jobengine/jobs', redirectTo: OPERATIONS_PATHS.jobEngineJobs }, + { + path: 'jobengine/jobs/:jobId/dag', + redirectTo: `${OPERATIONS_PATHS.jobEngineJobs}/:jobId`, + }, + { + path: 'jobengine/jobs/:jobId', + redirectTo: `${OPERATIONS_PATHS.jobEngineJobs}/:jobId`, + }, + { path: 'jobengine/quotas', redirectTo: OPERATIONS_PATHS.jobEngineQuotas }, + { path: 'scheduler', redirectTo: OPERATIONS_PATHS.schedulerRuns }, + { path: 'scheduler/runs', redirectTo: OPERATIONS_PATHS.schedulerRuns }, + { path: 'scheduler/runs/:runId', redirectTo: OPERATIONS_PATHS.schedulerRuns }, + { + path: 'scheduler/runs/:runId/stream', + redirectTo: OPERATIONS_PATHS.schedulerRuns, + }, + { + path: 'scheduler/schedules', + redirectTo: OPERATIONS_PATHS.schedulerSchedules, + }, + { path: 'scheduler/workers', redirectTo: OPERATIONS_PATHS.schedulerWorkers }, + { path: 'dead-letter', redirectTo: OPERATIONS_PATHS.deadLetter }, +]; + +export const PLATFORM_OPS_ROUTES: Routes = [ + ...LEGACY_PLATFORM_OPS_REDIRECTS.map((template) => ({ + path: template.path, + redirectTo: ({ + params, + queryParams, + fragment, + }: { + params: Params; + queryParams: Params; + fragment?: string | null; + }) => { + const router = inject(Router); + const target = router.parseUrl(interpolateLegacyTarget(template.redirectTo, params)); + target.queryParams = { ...queryParams }; + target.fragment = fragment ?? null; + return target; + }, + pathMatch: 'full' as const, + })), + { + path: '**', + redirectTo: OPERATIONS_PATHS.overview, + pathMatch: 'full', + }, +]; + +function interpolateLegacyTarget(template: string, params: Params): string { + let target = template; + + for (const [name, rawValue] of Object.entries(params ?? {})) { + const value = Array.isArray(rawValue) ? rawValue.join('/') : String(rawValue); + target = target.replaceAll(`:${name}`, encodeURIComponent(value)); + } + + return target; +} diff --git a/src/Web/StellaOps.Web/src/tests/navigation/legacy-redirects.spec.ts b/src/Web/StellaOps.Web/src/tests/navigation/legacy-redirects.spec.ts index 23c28c587..b68fd590a 100644 --- a/src/Web/StellaOps.Web/src/tests/navigation/legacy-redirects.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/navigation/legacy-redirects.spec.ts @@ -3,12 +3,46 @@ import { LEGACY_REDIRECT_ROUTES, } from '../../app/routes/legacy-redirects.routes'; -describe('Legacy redirect policy (pre-alpha)', () => { - it('keeps redirect templates empty', () => { - expect(LEGACY_REDIRECT_ROUTE_TEMPLATES).toEqual([]); +describe('Legacy redirect policy', () => { + it('publishes the active legacy redirect templates still emitted by the shell', () => { + expect(LEGACY_REDIRECT_ROUTE_TEMPLATES).toEqual( + jasmine.arrayContaining([ + jasmine.objectContaining({ + path: 'ops/health', + redirectTo: '/ops/operations/health-slo', + }), + jasmine.objectContaining({ + path: 'release-orchestrator/environments', + redirectTo: '/topology/regions', + }), + jasmine.objectContaining({ + path: 'triage/artifacts/:artifactId', + redirectTo: '/security/artifacts/:artifactId', + }), + jasmine.objectContaining({ + path: 'triage/findings/:findingId', + redirectTo: '/security/findings/:findingId', + }), + ]), + ); }); - it('keeps redirect route map empty', () => { - expect(LEGACY_REDIRECT_ROUTES).toEqual([]); + it('materializes a router redirect for every legacy template', () => { + expect(LEGACY_REDIRECT_ROUTES.length).toBe(LEGACY_REDIRECT_ROUTE_TEMPLATES.length); + expect(LEGACY_REDIRECT_ROUTES).toEqual( + jasmine.arrayContaining([ + jasmine.objectContaining({ + path: 'triage/artifacts', + pathMatch: 'full', + }), + jasmine.objectContaining({ + path: 'triage/findings', + pathMatch: 'full', + }), + ]), + ); + for (const route of LEGACY_REDIRECT_ROUTES) { + expect(typeof route.redirectTo).toBe('function'); + } }); }); diff --git a/src/Web/StellaOps.Web/src/tests/platform-ops/data-integrity-pages.spec.ts b/src/Web/StellaOps.Web/src/tests/platform-ops/data-integrity-pages.spec.ts index 46d9079c0..47805e0b5 100644 --- a/src/Web/StellaOps.Web/src/tests/platform-ops/data-integrity-pages.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/platform-ops/data-integrity-pages.spec.ts @@ -40,11 +40,11 @@ describe('DataIntegrityOverviewComponent (platform-ops)', () => { it('provides deep-link routes for all trust signals and drilldowns', () => { expect(component.trustSignals.map((signal) => signal.route)).toEqual([ - '/platform/ops/data-integrity/feeds-freshness', - '/platform/ops/data-integrity/scan-pipeline', - '/platform/ops/data-integrity/reachability-ingest', - '/platform/ops/data-integrity/integration-connectivity', - '/platform/ops/data-integrity/dlq', + '/ops/operations/data-integrity/feeds-freshness', + '/ops/operations/data-integrity/scan-pipeline', + '/ops/operations/data-integrity/reachability-ingest', + '/ops/operations/data-integrity/integration-connectivity', + '/ops/operations/data-integrity/dlq', ]); const drilldownLinks = fixture.nativeElement.querySelectorAll('.drilldowns a'); @@ -52,7 +52,7 @@ describe('DataIntegrityOverviewComponent (platform-ops)', () => { }); it('uses canonical platform ops routes for top failures', () => { - expect(component.topFailures.every((item) => item.route.startsWith('/platform/ops/'))).toBeTrue(); + expect(component.topFailures.every((item) => item.route.startsWith('/ops/operations/'))).toBeTrue(); }); }); @@ -122,7 +122,7 @@ describe('FeedsFreshnessPageComponent (platform-ops)', () => { it('links to canonical feeds and offline page and stays read-only', () => { const links = fixture.nativeElement.querySelectorAll('footer.links a'); expect(links.length).toBe(3); - expect((links[0] as HTMLAnchorElement).getAttribute('href')).toContain('/platform/ops/feeds-airgap'); + expect((links[0] as HTMLAnchorElement).getAttribute('href')).toContain('/ops/operations/feeds-airgap'); expect((links[1] as HTMLAnchorElement).getAttribute('href')).toContain('tab=version-locks'); expect((links[2] as HTMLAnchorElement).getAttribute('href')).toContain('tab=feed-mirrors'); expect(fixture.nativeElement.querySelectorAll('input, textarea').length).toBe(0); @@ -234,7 +234,7 @@ describe('DlqReplaysPageComponent (platform-ops)', () => { it('links to canonical dead-letter page', () => { const link = fixture.nativeElement.querySelector('footer.links a') as HTMLAnchorElement | null; expect(link).toBeTruthy(); - expect(link?.getAttribute('href')).toContain('/platform/ops/dead-letter'); + expect(link?.getAttribute('href')).toContain('/ops/operations/dead-letter'); }); }); diff --git a/src/Web/StellaOps.Web/src/tests/platform-ops/platform-ops-overview-page.component.spec.ts b/src/Web/StellaOps.Web/src/tests/platform-ops/platform-ops-overview-page.component.spec.ts new file mode 100644 index 000000000..8c173e3ad --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/platform-ops/platform-ops-overview-page.component.spec.ts @@ -0,0 +1,67 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import { + DOCTOR_API, + MockDoctorClient, +} from '../../app/features/doctor/services/doctor.client'; +import { PlatformOpsOverviewPageComponent } from '../../app/features/platform/ops/platform-ops-overview-page.component'; + +describe('PlatformOpsOverviewPageComponent', () => { + let fixture: ComponentFixture; + let component: PlatformOpsOverviewPageComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PlatformOpsOverviewPageComponent], + providers: [provideRouter([]), { provide: DOCTOR_API, useClass: MockDoctorClient }], + }).compileComponents(); + + fixture = TestBed.createComponent(PlatformOpsOverviewPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('renders grouped operations sections and canonical quick-nav links', () => { + const text = fixture.nativeElement.textContent as string; + expect(text).toContain('Platform Ops'); + expect(text).toContain('Blocking'); + expect(text).toContain('Supply And Airgap'); + expect(text).toContain('Capacity And Setup Boundary'); + + const navLinks = fixture.nativeElement.querySelectorAll('[data-testid^="operations-nav-"]'); + expect(navLinks.length).toBe(component.quickNav.length); + + const dataIntegrityLink = fixture.nativeElement.querySelector( + '[data-testid="operations-nav-data-integrity"]' + ) as HTMLAnchorElement | null; + expect(dataIntegrityLink?.getAttribute('href')).toContain('/ops/operations/data-integrity'); + }); + + it('routes cards into canonical operations pages and setup boundary pages', () => { + const dataIntegrityCard = fixture.nativeElement.querySelector( + '[data-testid="operations-card-data-integrity"]' + ) as HTMLAnchorElement | null; + const agentFleetCard = fixture.nativeElement.querySelector( + '[data-testid="operations-card-agents"]' + ) as HTMLAnchorElement | null; + + expect(dataIntegrityCard?.getAttribute('href')).toContain('/ops/operations/data-integrity'); + expect(agentFleetCard?.getAttribute('href')).toContain('/setup/topology/agents'); + }); + + it('emits a refresh note when the operator refreshes the snapshot', () => { + const refreshButton = fixture.nativeElement.querySelector( + '[data-testid="operations-refresh-btn"]' + ) as HTMLButtonElement | null; + + expect(refreshButton).toBeTruthy(); + refreshButton?.click(); + fixture.detectChanges(); + + expect(component.refreshedAt()).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('[data-testid="operations-refresh-note"]') + ).toBeTruthy(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/platform-ops/platform-ops-routes.spec.ts b/src/Web/StellaOps.Web/src/tests/platform-ops/platform-ops-routes.spec.ts index 3c78064d1..8a2dbbcfc 100644 --- a/src/Web/StellaOps.Web/src/tests/platform-ops/platform-ops-routes.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/platform-ops/platform-ops-routes.spec.ts @@ -1,22 +1,45 @@ +import { routes as APP_ROUTES } from '../../app/app.routes'; import { OPS_ROUTES } from '../../app/routes/ops.routes'; import { OPERATIONS_ROUTES } from '../../app/routes/operations.routes'; -import { integrationHubRoutes } from '../../app/features/integration-hub/integration-hub.routes'; +import { PLATFORM_OPS_ROUTES } from '../../app/routes/platform-ops.routes'; -describe('OPS_ROUTES (pre-alpha)', () => { - it('contains canonical top-level ops paths', () => { +describe('Platform and Operations route contracts', () => { + it('mounts legacy platform-ops redirects in the app router', () => { + const appPaths = APP_ROUTES.map((route) => route.path); + expect(appPaths).toContain('platform-ops'); + expect(appPaths).toContain('platform'); + + const platformRoute = APP_ROUTES.find((route) => route.path === 'platform'); + const platformChildren = platformRoute?.children?.map((route) => route.path) ?? []; + expect(platformChildren).toContain('ops'); + }); + + it('keeps canonical Operations navigation under /ops', () => { const paths = OPS_ROUTES.map((route) => route.path); - expect(paths).toEqual(['', 'operations', 'integrations', 'policy', 'platform-setup']); + expect(paths).toContain('operations'); + expect(paths).toContain('integrations'); + expect(paths).toContain('policy'); + expect(paths).toContain('platform-setup'); + expect(paths).toContain('scanner-ops'); + expect(paths).toContain('agents'); }); - it('has no redirects', () => { - for (const route of OPS_ROUTES) { - expect(route.redirectTo).toBeUndefined(); - } + it('keeps shorthand ops aliases pointed at the operations shell', () => { + const aliases = OPS_ROUTES.filter((route) => route.redirectTo); + expect(aliases.map((route) => route.path)).toEqual([ + 'feeds', + 'feeds-airgap', + 'airgap', + 'health-slo', + 'signals', + 'scheduler', + 'offline-kit', + 'quotas', + 'packs', + ]); }); -}); -describe('OPERATIONS_ROUTES (pre-alpha)', () => { - it('includes required operations surfaces', () => { + it('includes required canonical operations surfaces', () => { const paths = OPERATIONS_ROUTES.map((route) => route.path); const expected = [ '', @@ -28,6 +51,8 @@ describe('OPERATIONS_ROUTES (pre-alpha)', () => { 'jobengine', 'jobengine/jobs', 'jobengine/jobs/:jobId', + 'jobengine/jobs/:jobId/dag', + 'orchestrator/jobs/:jobId', 'jobengine/quotas', 'scheduler', 'quotas', @@ -48,40 +73,44 @@ describe('OPERATIONS_ROUTES (pre-alpha)', () => { } }); - it('has no redirects', () => { - for (const route of OPERATIONS_ROUTES) { - expect(route.redirectTo).toBeUndefined(); - } - }); -}); - -describe('integrationHubRoutes (pre-alpha)', () => { - it('contains canonical integrations surfaces under ops', () => { - const paths = integrationHubRoutes.map((route) => route.path); + it('keeps legacy platform ops redirects available for old bookmarks', () => { + const paths = PLATFORM_OPS_ROUTES.map((route) => route.path); const expected = [ '', - 'onboarding', - 'onboarding/:type', - 'registries', - 'scm', - 'ci', - 'runtime-hosts', - 'advisory-vex-sources', - 'secrets', + 'data-integrity', + 'data-integrity/nightly-ops/:runId', + 'data-integrity/:section', + 'jobs-queues', + 'feeds', + 'feeds-airgap', + 'offline-kit', + 'health', + 'health-slo', + 'doctor', + 'quotas', + 'aoc', + 'signals', + 'packs', 'notifications', - 'sbom-sources', - 'activity', - ':integrationId', + 'status', + 'agents', + 'jobengine', + 'jobengine/jobs', + 'jobengine/jobs/:jobId', + 'jobengine/jobs/:jobId/dag', + 'jobengine/quotas', + 'scheduler', + 'scheduler/runs', + 'scheduler/runs/:runId', + 'scheduler/runs/:runId/stream', + 'scheduler/schedules', + 'scheduler/workers', + 'dead-letter', + '**', ]; for (const path of expected) { expect(paths).toContain(path); } }); - - it('has no redirects', () => { - for (const route of integrationHubRoutes) { - expect(route.redirectTo).toBeUndefined(); - } - }); }); diff --git a/src/Web/StellaOps.Web/tests/e2e/operations-consolidation.spec.ts b/src/Web/StellaOps.Web/tests/e2e/operations-consolidation.spec.ts new file mode 100644 index 000000000..f2159d076 --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/operations-consolidation.spec.ts @@ -0,0 +1,156 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +import type { StubAuthSession } from '../../src/app/testing/auth-fixtures'; + +const adminSession: StubAuthSession = { + subjectId: 'operations-e2e-user', + tenant: 'tenant-default', + scopes: [ + 'admin', + 'ui.read', + 'ui.admin', + 'orch:read', + 'orch:operate', + 'health:read', + 'notify.viewer', + 'policy:read', + ], +}; + +const mockConfig = { + authority: { + issuer: '/authority', + clientId: 'stella-ops-ui', + authorizeEndpoint: '/authority/connect/authorize', + tokenEndpoint: '/authority/connect/token', + logoutEndpoint: '/authority/connect/logout', + redirectUri: 'https://127.0.0.1:4400/auth/callback', + postLogoutRedirectUri: 'https://127.0.0.1:4400/', + scope: 'openid profile email ui.read', + audience: '/gateway', + dpopAlgorithms: ['ES256'], + refreshLeewaySeconds: 60, + }, + apiBaseUrls: { + authority: '/authority', + scanner: '/scanner', + policy: '/policy', + concelier: '/concelier', + attestor: '/attestor', + gateway: '/gateway', + }, + quickstartMode: true, + setup: 'complete', +}; + +async function fulfillJson(route: Route, body: unknown): Promise { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); +} + +async function navigateClientSide(page: Page, target: string): Promise { + await page.evaluate((url) => { + window.history.pushState({}, '', url); + window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state })); + }, target); +} + +async function setupHarness(page: Page): Promise { + await page.addInitScript((session) => { + (window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session; + }, adminSession); + + await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig)); + await page.route('**/config.json', (route) => fulfillJson(route, mockConfig)); + await page.route('**/.well-known/openid-configuration', (route) => + fulfillJson(route, { + issuer: 'https://127.0.0.1:4400/authority', + authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize', + token_endpoint: 'https://127.0.0.1:4400/authority/connect/token', + jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json', + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + }) + ); + await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] })); + await page.route('**/console/profile**', (route) => + fulfillJson(route, { + subjectId: adminSession.subjectId, + username: 'operations-e2e', + displayName: 'Operations E2E', + tenant: adminSession.tenant, + roles: ['admin'], + scopes: adminSession.scopes, + }) + ); + await page.route('**/console/token/introspect**', (route) => + fulfillJson(route, { + active: true, + tenant: adminSession.tenant, + subject: adminSession.subjectId, + scopes: adminSession.scopes, + }) + ); + await page.route('**/api/v2/context/regions', (route) => + fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]) + ); + await page.route('**/api/v2/context/environments**', (route) => + fulfillJson(route, [ + { + environmentId: 'prod', + regionId: 'eu-west', + environmentType: 'prod', + displayName: 'Prod', + sortOrder: 1, + enabled: true, + }, + ]) + ); + await page.route('**/api/v2/context/preferences', (route) => + fulfillJson(route, { + tenantId: adminSession.tenant, + actorId: adminSession.subjectId, + regions: ['eu-west'], + environments: ['prod'], + timeWindow: '24h', + stage: 'all', + updatedAt: '2026-03-07T12:00:00Z', + updatedBy: adminSession.subjectId, + }) + ); + await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, [])); + await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, [])); +} + +test.beforeEach(async ({ page }) => { + await setupHarness(page); +}); + +test('operations overview routes grouped cards into canonical child pages', async ({ page }) => { + await page.goto('/ops/operations', { waitUntil: 'networkidle' }); + + await expect(page.getByTestId('operations-overview')).toBeVisible(); + await expect(page.getByTestId('operations-card-data-integrity')).toBeVisible(); + + await page.getByTestId('operations-card-data-integrity').click(); + await expect(page).toHaveURL(/\/ops\/operations\/data-integrity$/); + await expect(page.getByText('Data Trust Score')).toBeVisible(); +}); + +test('legacy platform ops redirects preserve canonical routes and query state', async ({ page }) => { + await page.goto('/ops/operations', { + waitUntil: 'networkidle', + }); + + await navigateClientSide(page, '/platform/ops/feeds-airgap?tab=version-locks'); + await expect(page).toHaveURL(/\/ops\/operations\/feeds-airgap\?tab=version-locks$/); + await expect(page.getByRole('button', { name: 'Version Locks' })).toHaveClass(/active/); + + await navigateClientSide(page, '/platform-ops/data-integrity/feeds-freshness'); + await expect(page).toHaveURL(/\/ops\/operations\/data-integrity\/feeds-freshness$/); + await expect(page.getByRole('heading', { name: 'Feeds Freshness' })).toBeVisible(); +});