feat(ui): ship consolidated operations shell
This commit is contained in:
@@ -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.
|
||||
62
docs/features/checked/web/operations-consolidation-ui.md
Normal file
62
docs/features/checked/web/operations-consolidation-ui.md
Normal file
@@ -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
|
||||
@@ -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/**`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -123,13 +123,13 @@ interface FailureItem {
|
||||
<section class="panel" aria-label="Drilldowns">
|
||||
<h2>Drilldowns</h2>
|
||||
<div class="drilldowns">
|
||||
<a routerLink="/platform/ops/data-integrity/nightly-ops">Nightly Ops Report</a>
|
||||
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Feeds Freshness</a>
|
||||
<a routerLink="/platform/ops/data-integrity/scan-pipeline">Scan Pipeline Health</a>
|
||||
<a routerLink="/platform/ops/data-integrity/reachability-ingest">Reachability Ingest Health</a>
|
||||
<a routerLink="/platform/ops/data-integrity/integration-connectivity">Integration Connectivity</a>
|
||||
<a routerLink="/platform/ops/data-integrity/dlq">DLQ and Replays</a>
|
||||
<a routerLink="/platform/ops/data-integrity/slos">Data Quality SLOs</a>
|
||||
<a routerLink="/ops/operations/data-integrity/nightly-ops">Nightly Ops Report</a>
|
||||
<a routerLink="/ops/operations/data-integrity/feeds-freshness">Feeds Freshness</a>
|
||||
<a routerLink="/ops/operations/data-integrity/scan-pipeline">Scan Pipeline Health</a>
|
||||
<a routerLink="/ops/operations/data-integrity/reachability-ingest">Reachability Ingest Health</a>
|
||||
<a routerLink="/ops/operations/data-integrity/integration-connectivity">Integration Connectivity</a>
|
||||
<a routerLink="/ops/operations/data-integrity/dlq">DLQ and Replays</a>
|
||||
<a routerLink="/ops/operations/data-integrity/slos">Data Quality SLOs</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -60,9 +60,9 @@ interface DlqItem {
|
||||
<td>{{ item.payload }}</td>
|
||||
<td>{{ item.age }}</td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/ops/dead-letter">Replay</a>
|
||||
<a routerLink="/platform/ops/dead-letter">View</a>
|
||||
<a routerLink="/platform/ops/data-integrity/nightly-ops">Link job</a>
|
||||
<a routerLink="/ops/operations/dead-letter">Replay</a>
|
||||
<a routerLink="/ops/operations/dead-letter">View</a>
|
||||
<a routerLink="/ops/operations/data-integrity/nightly-ops">Link job</a>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
@@ -76,7 +76,7 @@ interface DlqItem {
|
||||
</div>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/platform/ops/dead-letter">Open Dead Letter</a>
|
||||
<a routerLink="/ops/operations/dead-letter">Open Dead Letter</a>
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -48,11 +48,11 @@ interface FeedRow {
|
||||
</table>
|
||||
|
||||
<footer class="links">
|
||||
<a [routerLink]="['/platform/ops/feeds-airgap']">Open Feeds & Airgap</a>
|
||||
<a [routerLink]="['/platform/ops/feeds-airgap']" [queryParams]="{ tab: 'version-locks' }">
|
||||
<a [routerLink]="['/ops/operations/feeds-airgap']">Open Feeds & Airgap</a>
|
||||
<a [routerLink]="['/ops/operations/feeds-airgap']" [queryParams]="{ tab: 'version-locks' }">
|
||||
Apply Version Lock
|
||||
</a>
|
||||
<a [routerLink]="['/platform/ops/feeds-airgap']" [queryParams]="{ tab: 'feed-mirrors' }">
|
||||
<a [routerLink]="['/ops/operations/feeds-airgap']" [queryParams]="{ tab: 'feed-mirrors' }">
|
||||
Retry source sync
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
@@ -43,7 +43,7 @@ interface ConnectorRow {
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/integrations">Open Detail</a>
|
||||
<a routerLink="/platform/integrations">Test</a>
|
||||
<a routerLink="/platform/ops/data-integrity/nightly-ops">View dependent jobs</a>
|
||||
<a routerLink="/ops/operations/data-integrity/nightly-ops">View dependent jobs</a>
|
||||
<a routerLink="/releases/approvals">View impacted approvals</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -46,8 +46,8 @@ interface AffectedItem {
|
||||
<footer class="links">
|
||||
<a routerLink="/releases/approvals">Open impacted approvals</a>
|
||||
<a routerLink="/releases/versions">Open bundles</a>
|
||||
<a routerLink="/platform/ops/data-integrity/dlq">Open DLQ bucket</a>
|
||||
<a routerLink="/platform/ops/jobengine/jobs">Open logs</a>
|
||||
<a routerLink="/ops/operations/data-integrity/dlq">Open DLQ bucket</a>
|
||||
<a routerLink="/ops/operations/jobengine/jobs">Open logs</a>
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -67,11 +67,11 @@ interface NightlyJobRow {
|
||||
</td>
|
||||
<td>{{ row.impact }}</td>
|
||||
<td class="actions">
|
||||
<a [routerLink]="['/platform/ops/data-integrity/nightly-ops', row.runId]">View Run</a>
|
||||
<a routerLink="/platform/ops/scheduler/runs">Open Scheduler</a>
|
||||
<a routerLink="/platform/ops/jobengine/jobs">Open JobEngine</a>
|
||||
<a [routerLink]="['/ops/operations/data-integrity/nightly-ops', row.runId]">View Run</a>
|
||||
<a routerLink="/ops/operations/scheduler/runs">Open Scheduler</a>
|
||||
<a routerLink="/ops/operations/jobengine/jobs">Open JobEngine</a>
|
||||
<a routerLink="/platform/integrations">Open Integration</a>
|
||||
<a routerLink="/platform/ops/dead-letter">Open DLQ</a>
|
||||
<a routerLink="/ops/operations/dead-letter">Open DLQ</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ interface IngestRow {
|
||||
</table>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/platform/ops/agents">Open Agents</a>
|
||||
<a routerLink="/platform/ops/data-integrity/dlq">Open DLQ bucket</a>
|
||||
<a routerLink="/setup/topology/agents">Open Agents</a>
|
||||
<a routerLink="/ops/operations/data-integrity/dlq">Open DLQ bucket</a>
|
||||
<a routerLink="/releases/approvals">Open impacted approvals</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -42,8 +42,8 @@ interface Stage {
|
||||
</section>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/platform/ops/data-integrity/nightly-ops">Nightly Ops Report</a>
|
||||
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Feeds Freshness</a>
|
||||
<a routerLink="/ops/operations/data-integrity/nightly-ops">Nightly Ops Report</a>
|
||||
<a routerLink="/ops/operations/data-integrity/feeds-freshness">Feeds Freshness</a>
|
||||
<a routerLink="/platform/integrations">Integrations</a>
|
||||
<a routerLink="/security/findings">Security Findings</a>
|
||||
</footer>
|
||||
@@ -173,4 +173,3 @@ export class ScanPipelineHealthPageComponent {
|
||||
readonly affectedEnvironments = 3;
|
||||
readonly blockedApprovals = 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
</p>
|
||||
</div>
|
||||
<div class="feeds-offline__actions">
|
||||
<a routerLink="/platform/integrations/feeds">Configure Sources</a>
|
||||
<a routerLink="/ops/integrations/advisory-vex-sources">Configure Sources</a>
|
||||
<button type="button">Sync Now</button>
|
||||
<button type="button">Import Airgap Bundle</button>
|
||||
</div>
|
||||
@@ -90,15 +90,15 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
@if (tab() === 'airgap-bundles') {
|
||||
<p>Offline import/export workflows and bundle verification controls.</p>
|
||||
<div class="panel__links">
|
||||
<a routerLink="/platform/ops/offline-kit">Open Offline Kit Operations</a>
|
||||
<a routerLink="/ops/operations/offline-kit">Open Offline Kit Operations</a>
|
||||
<a routerLink="/evidence/exports">Export Evidence Bundle</a>
|
||||
</div>
|
||||
}
|
||||
@if (tab() === 'version-locks') {
|
||||
<p>Freeze upstream feed inputs used by promotion gates and replay evidence.</p>
|
||||
<div class="panel__links">
|
||||
<a routerLink="/platform/setup/feed-policy">Open Feed Policy</a>
|
||||
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Open Freshness Lens</a>
|
||||
<a routerLink="/ops/platform-setup/feed-policy">Open Feed Policy</a>
|
||||
<a routerLink="/ops/operations/data-integrity/feeds-freshness">Open Freshness Lens</a>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
@@ -65,8 +65,8 @@ interface WorkerRow {
|
||||
</p>
|
||||
</div>
|
||||
<div class="jobs-queues__actions">
|
||||
<a routerLink="/platform/ops/data-integrity">Open Data Integrity</a>
|
||||
<a routerLink="/platform/ops/doctor">Run Diagnostics</a>
|
||||
<a routerLink="/ops/operations/data-integrity">Open Data Integrity</a>
|
||||
<a routerLink="/ops/operations/doctor">Run Diagnostics</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -133,8 +133,8 @@ interface WorkerRow {
|
||||
<td>{{ row.lastRun }}</td>
|
||||
<td><span class="pill" [class]="'pill pill--' + row.health.toLowerCase()">{{ row.health }}</span></td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/ops/jobs-queues">View</a>
|
||||
<a routerLink="/platform/ops/jobs-queues">Run Now</a>
|
||||
<a routerLink="/ops/operations/jobs-queues">View</a>
|
||||
<a routerLink="/ops/operations/jobs-queues">Run Now</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -167,7 +167,7 @@ interface WorkerRow {
|
||||
<td>{{ row.duration }}</td>
|
||||
<td><span class="impact" [class]="'impact impact--' + row.impact.toLowerCase()">{{ row.impact }}</span></td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/ops/jobs-queues">View</a>
|
||||
<a routerLink="/ops/operations/jobs-queues">View</a>
|
||||
<button type="button" (click)="copyCorrelationId(row.correlationId)">Copy CorrID</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -197,8 +197,8 @@ interface WorkerRow {
|
||||
<td>{{ row.nextRun }}</td>
|
||||
<td><span class="pill" [class]="'pill pill--' + row.lastStatus.toLowerCase()">{{ row.lastStatus }}</span></td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/ops/jobs-queues">Edit</a>
|
||||
<a routerLink="/platform/ops/jobs-queues">Pause</a>
|
||||
<a routerLink="/ops/operations/jobs-queues">Edit</a>
|
||||
<a routerLink="/ops/operations/jobs-queues">Pause</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -229,7 +229,7 @@ interface WorkerRow {
|
||||
<td>{{ row.retryable }}</td>
|
||||
<td><span class="impact" [class]="'impact impact--' + row.impact.toLowerCase()">{{ row.impact }}</span></td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/ops/jobs-queues">Replay</a>
|
||||
<a routerLink="/ops/operations/jobs-queues">Replay</a>
|
||||
<button type="button" (click)="copyCorrelationId(row.correlationId)">Copy CorrID</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -261,8 +261,8 @@ interface WorkerRow {
|
||||
<td>{{ row.capacity }}</td>
|
||||
<td>{{ row.heartbeat }}</td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/ops/jobs-queues">View</a>
|
||||
<a routerLink="/platform/ops/jobs-queues">Drain</a>
|
||||
<a routerLink="/ops/operations/jobs-queues">View</a>
|
||||
<a routerLink="/ops/operations/jobs-queues">Drain</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -288,14 +288,14 @@ interface WorkerRow {
|
||||
@if (tab() === 'dead-letters') {
|
||||
<p>
|
||||
Dead-letter triage is linked to release impact.
|
||||
<a routerLink="/platform/ops/data-integrity/dlq">Open DLQ & Replays</a>
|
||||
<a routerLink="/ops/operations/data-integrity/dlq">Open DLQ & Replays</a>
|
||||
</p>
|
||||
}
|
||||
@if (tab() === 'workers') {
|
||||
<p>Worker capacity and health affect queue latency and decision freshness SLAs.</p>
|
||||
}
|
||||
<div class="drawer__links">
|
||||
<a routerLink="/platform/ops/data-integrity">Open Data Integrity</a>
|
||||
<a routerLink="/ops/operations/data-integrity">Open Data Integrity</a>
|
||||
<a routerLink="/evidence/audit-log">Open Audit Log</a>
|
||||
<a routerLink="/releases/runs">Impacted Decisions</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<section class="ops-overview" data-testid="operations-overview">
|
||||
<header class="ops-overview__header">
|
||||
<div class="ops-overview__title-group">
|
||||
<p class="ops-overview__eyebrow">Ops / Operations</p>
|
||||
<h1>Platform Ops</h1>
|
||||
<p class="ops-overview__subtitle">
|
||||
Consolidated operator shell for blocking platform issues, execution control, diagnostics,
|
||||
and airgap workflows. Topology and agent ownership remain under Setup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="ops-overview__actions">
|
||||
<a [routerLink]="OPERATIONS_PATHS.doctor">Run Doctor</a>
|
||||
<a routerLink="/evidence/audit-log">Audit Log</a>
|
||||
<a routerLink="/evidence/exports">Export Ops Report</a>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="operations-refresh-btn"
|
||||
(click)="refreshSnapshot()"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="ops-overview__submenu" aria-label="Operations navigation">
|
||||
@for (item of quickNav; track item.id) {
|
||||
<a
|
||||
class="ops-overview__submenu-link"
|
||||
[routerLink]="item.route"
|
||||
[attr.data-testid]="'operations-nav-' + item.id"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<section class="ops-overview__summary" aria-label="Operations posture summary">
|
||||
<article>
|
||||
<span class="ops-overview__summary-label">Blocking subsystems</span>
|
||||
<strong>3</strong>
|
||||
<span class="impact impact--blocking">Release affecting</span>
|
||||
</article>
|
||||
<article>
|
||||
<span class="ops-overview__summary-label">Degraded surfaces</span>
|
||||
<strong>5</strong>
|
||||
<span class="impact impact--degraded">Operator follow-up</span>
|
||||
</article>
|
||||
<article>
|
||||
<span class="ops-overview__summary-label">Setup-owned handoffs</span>
|
||||
<strong>2</strong>
|
||||
<span class="impact impact--info">Topology boundary</span>
|
||||
</article>
|
||||
<article>
|
||||
<span class="ops-overview__summary-label">Queued actions</span>
|
||||
<strong>{{ pendingActions.length }}</strong>
|
||||
<span class="impact impact--info">Needs review</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="ops-overview__blocking" aria-labelledby="operations-blocking-title">
|
||||
<div class="ops-overview__section-header">
|
||||
<div>
|
||||
<h2 id="operations-blocking-title">Blocking</h2>
|
||||
<p>Open the highest-impact drills first. These cards route to the owning child page.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ops-overview__blocking-grid">
|
||||
@for (item of blockingCards; track item.id) {
|
||||
<a
|
||||
class="blocking-card"
|
||||
[routerLink]="item.route"
|
||||
[attr.data-testid]="'operations-blocking-' + item.id"
|
||||
>
|
||||
<div class="blocking-card__topline">
|
||||
<span class="impact" [class]="'impact impact--' + item.impact">{{ item.metric }}</span>
|
||||
<span class="blocking-card__route">Open</span>
|
||||
</div>
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>{{ item.detail }}</p>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<st-doctor-checks-inline category="core" heading="Critical diagnostics" />
|
||||
|
||||
@for (group of overviewGroups; track group.id) {
|
||||
<section class="ops-overview__group" [attr.data-testid]="'operations-group-' + group.id">
|
||||
<div class="ops-overview__section-header">
|
||||
<div>
|
||||
<h2>{{ group.title }}</h2>
|
||||
<p>{{ group.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ops-overview__group-grid">
|
||||
@for (card of group.cards; track card.id) {
|
||||
<a
|
||||
class="ops-card"
|
||||
[routerLink]="card.route"
|
||||
[attr.data-testid]="'operations-card-' + card.id"
|
||||
>
|
||||
<div class="ops-card__header">
|
||||
<span class="ops-card__owner" [class.ops-card__owner--setup]="card.owner === 'Setup'">
|
||||
{{ card.owner }}
|
||||
</span>
|
||||
<span class="impact" [class]="'impact impact--' + card.impact">{{ card.metric }}</span>
|
||||
</div>
|
||||
|
||||
<h3>{{ card.title }}</h3>
|
||||
<p>{{ card.detail }}</p>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="ops-overview__footer-grid">
|
||||
<article class="ops-overview__panel" data-testid="operations-pending-actions">
|
||||
<h2>Pending Operator Actions</h2>
|
||||
<ul>
|
||||
@for (item of pendingActions; track item.id) {
|
||||
<li>
|
||||
<a [routerLink]="item.route">{{ item.title }}</a>
|
||||
<span>{{ item.detail }}</span>
|
||||
<strong>{{ item.owner }}</strong>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="ops-overview__panel" data-testid="operations-setup-boundary">
|
||||
<h2>Setup Boundary</h2>
|
||||
<p>
|
||||
Operations can monitor topology impact, but inventory ownership remains in Setup. These
|
||||
links deliberately route out of Operations to avoid duplicating infrastructure management.
|
||||
</p>
|
||||
<div class="ops-overview__boundary-links">
|
||||
<a [routerLink]="OPERATIONS_SETUP_PATHS.topologyOverview">Open topology overview</a>
|
||||
<a [routerLink]="OPERATIONS_SETUP_PATHS.topologyAgents">Open agent fleet</a>
|
||||
<a [routerLink]="OPERATIONS_SETUP_PATHS.feedPolicy">Open feed policy</a>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@if (refreshedAt()) {
|
||||
<p class="ops-overview__note" data-testid="operations-refresh-note">
|
||||
Snapshot refreshed at {{ refreshedAt() }}.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<section class="ops-overview">
|
||||
<header class="ops-overview__header">
|
||||
<div>
|
||||
<h1>Platform Ops</h1>
|
||||
<p>
|
||||
Operability workflows for defensible release decisions: data trust, execution control,
|
||||
and service health.
|
||||
</p>
|
||||
</div>
|
||||
<div class="ops-overview__actions">
|
||||
<a routerLink="/platform/ops/doctor">Run Doctor</a>
|
||||
<a routerLink="/evidence/exports">Export Ops Report</a>
|
||||
<button type="button" (click)="refreshed.set(true)">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="ops-overview__kpis" aria-label="Ops posture snapshot">
|
||||
<article>
|
||||
<h2>Data Trust Score</h2>
|
||||
<p>87</p>
|
||||
<span class="pill pill--warn">WARN</span>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Platform Health</h2>
|
||||
<p>2</p>
|
||||
<span class="pill pill--warn">WARN services</span>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Dead Letter Queue</h2>
|
||||
<p>3</p>
|
||||
<span class="pill pill--degraded">DEGRADED</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="ops-overview__primary">
|
||||
<h2>Primary Workflows</h2>
|
||||
<div class="ops-overview__grid">
|
||||
@for (card of primaryWorkflows; track card.id) {
|
||||
<a class="ops-card" [routerLink]="card.route">
|
||||
<h3>{{ card.title }}</h3>
|
||||
<p>{{ card.description }}</p>
|
||||
<span class="impact" [class]="'impact impact--' + card.impact.toLowerCase()">
|
||||
Impact: {{ card.impact }}
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<st-doctor-checks-inline category="core" heading="Core Platform Checks" />
|
||||
|
||||
<section class="ops-overview__secondary">
|
||||
<h2>Secondary Operator Tools</h2>
|
||||
<div class="ops-overview__links">
|
||||
<a routerLink="/platform/ops/feeds-airgap">Feeds & Airgap</a>
|
||||
<a routerLink="/platform/ops/quotas">Quotas & Limits</a>
|
||||
<a routerLink="/platform/ops/doctor">Diagnostics</a>
|
||||
<a routerLink="/topology/agents">Topology Health</a>
|
||||
<a routerLink="/evidence/capsules">Decision Capsule Stats</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ops-overview__alerts">
|
||||
<h2>Recent Operator Alerts</h2>
|
||||
<ul>
|
||||
<li>
|
||||
NVD feed stale 3h12m
|
||||
<span class="impact impact--blocking">Impact: BLOCKING</span>
|
||||
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Open</a>
|
||||
</li>
|
||||
<li>
|
||||
Runtime ingest backlog
|
||||
<span class="impact impact--degraded">Impact: DEGRADED</span>
|
||||
<a routerLink="/platform/ops/data-integrity/reachability-ingest">Open</a>
|
||||
</li>
|
||||
<li>
|
||||
DLQ replay queue pending
|
||||
<span class="impact impact--degraded">Impact: DEGRADED</span>
|
||||
<a routerLink="/platform/ops/data-integrity/dlq">Open</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@if (refreshed()) {
|
||||
<p class="ops-overview__note">Snapshot refreshed for current scope.</p>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
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<string | null>(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: '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: '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: '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',
|
||||
description: 'Trust signals, blocked decisions, and freshness recovery actions.',
|
||||
route: '/platform/ops/data-integrity',
|
||||
impact: 'BLOCKING',
|
||||
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',
|
||||
description: 'Unified orchestration runs, schedules, dead letters, and workers.',
|
||||
route: '/platform/ops/jobs-queues',
|
||||
impact: 'DEGRADED',
|
||||
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',
|
||||
description: 'Service/dependency health and incident timelines with SLO context.',
|
||||
route: '/platform/ops/health-slo',
|
||||
impact: 'INFO',
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<PlatformOpsOverviewPageComponent>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
156
src/Web/StellaOps.Web/tests/e2e/operations-consolidation.spec.ts
Normal file
156
src/Web/StellaOps.Web/tests/e2e/operations-consolidation.spec.ts
Normal file
@@ -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<void> {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function navigateClientSide(page: Page, target: string): Promise<void> {
|
||||
await page.evaluate((url) => {
|
||||
window.history.pushState({}, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state }));
|
||||
}, target);
|
||||
}
|
||||
|
||||
async function setupHarness(page: Page): Promise<void> {
|
||||
await page.addInitScript((session) => {
|
||||
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
|
||||
}, adminSession);
|
||||
|
||||
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
|
||||
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
|
||||
await page.route('**/.well-known/openid-configuration', (route) =>
|
||||
fulfillJson(route, {
|
||||
issuer: 'https://127.0.0.1:4400/authority',
|
||||
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
|
||||
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
|
||||
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
})
|
||||
);
|
||||
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
|
||||
await page.route('**/console/profile**', (route) =>
|
||||
fulfillJson(route, {
|
||||
subjectId: adminSession.subjectId,
|
||||
username: '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();
|
||||
});
|
||||
Reference in New Issue
Block a user