feat(ui): ship consolidated operations shell

This commit is contained in:
master
2026-03-07 20:31:32 +02:00
parent 55701483ea
commit a3f532359b
29 changed files with 1447 additions and 454 deletions

View File

@@ -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.

View 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

View File

@@ -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/**`.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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' },

View File

@@ -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',
},
];

View File

@@ -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>
`,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
`,

View File

@@ -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>
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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');
}
});
});

View File

@@ -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');
});
});

View File

@@ -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();
});
});

View File

@@ -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();
}
});
});

View 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();
});