feat(ui): ship quota health aoc operations cutover

This commit is contained in:
master
2026-03-08 08:18:51 +02:00
parent c9484c33ee
commit ac22ee3ce2
31 changed files with 1241 additions and 93 deletions

View File

@@ -0,0 +1,122 @@
# Sprint 20260308_003_FE - Quota Health AOC Operations Cutover
## Topic & Scope
- Complete the `Ops > Operations` ownership cutover for `Quotas & Limits`, `Health & SLO`, and `AOC Compliance`.
- Replace stale `/ops/*`, `/platform/ops/*`, and `/platform-ops/*` deep links with canonical `/ops/operations/*` routes while preserving bookmarks and query state.
- Finish the missing operator workflows inside these surfaces so filters, drill-ins, exports, and handoff actions are actually usable.
- Working directory: `src/Web/StellaOps.Web/`.
- Expected evidence: targeted Angular tests, Playwright route-and-flow coverage, checked-feature documentation, and archived sprint notes.
## Dependencies & Concurrency
- Depends on the shipped Operations shell and contextual primitives from archived Operations and Offline Kit sprints.
- Safe parallelism: backend modules are out of scope; only frontend code, frontend docs, and verification assets are permitted.
## Documentation Prerequisites
- `AGENTS.md`
- `docs/modules/ui/AGENTS.md`
- `src/Web/StellaOps.Web/AGENTS.md`
- `docs/modules/ui/README.md`
- `docs/modules/ui/architecture.md`
- `docs/modules/ui/implementation_plan.md`
- `docs/modules/ui/platform-ops-consolidation/README.md`
- `docs/modules/platform/architecture-overview.md`
## Delivery Tracker
### FE-QHA-001 - Freeze canonical route and alias contract
Status: DONE
Dependency: none
Owners: Developer / Implementer
Task description:
- Reconcile the quota, health, and AOC surfaces with the existing `Ops > Operations` owner shell. Add any missing shorthand or legacy aliases required for stale links that still exist in the UI and for known bookmark patterns.
- Ensure redirects preserve query params and fragments, and cover both root-level `/ops/*` aliases and old `platform` paths where operators still land today.
Completion criteria:
- [ ] Canonical helpers exist for quota, health, and AOC operations routes.
- [ ] Old quota, health, and AOC bookmarks land on the canonical owner pages without losing query state.
- [ ] Navigation config no longer points AOC to stale `/ops/aoc` paths.
- [x] Canonical helpers exist for quota, health, and AOC operations routes.
- [x] Old quota, health, and AOC bookmarks land on the canonical owner pages without losing query state.
- [x] Navigation config no longer points AOC to stale `/ops/aoc` paths.
### FE-QHA-002 - Complete quota operator workflows
Status: DONE
Dependency: FE-QHA-001
Owners: Developer / Implementer
Task description:
- Finish the missing quota workflows so dashboard drill-ins, forecast actions, alert configuration, tenant detail actions, and report export behave as real operator flows rather than placeholders or dead links.
- Any fallback for missing backend endpoints must still be usable offline and must not present fake success as if an integration happened.
Completion criteria:
- [ ] Quota drill-ins and back links use canonical operations routes.
- [ ] Dashboard and forecast query-driven filters work and preserve intent across pages.
- [ ] Tenant detail export and report actions are usable.
- [ ] Quota alert test/preview flow is usable without relying on a nonexistent backend endpoint.
- [ ] Quota report history no longer renders as a permanent placeholder-only section.
- [x] Quota drill-ins and back links use canonical operations routes.
- [x] Dashboard and forecast query-driven filters work and preserve intent across pages.
- [x] Tenant detail export and report actions are usable.
- [x] Quota alert test/preview flow is usable without relying on a nonexistent backend endpoint.
- [x] Quota report history no longer renders as a permanent placeholder-only section.
### FE-QHA-003 - Complete health and AOC operator workflows
Status: DONE
Dependency: FE-QHA-001
Owners: Developer / Implementer
Task description:
- Repair the remaining health and AOC page actions so breadcrumbs, drill-ins, provenance validation, filter controls, and incident navigation stay inside the mounted owner shell.
- Wire any ignored filter state to API calls or deterministic client-side behavior so the pages do what they claim.
Completion criteria:
- [ ] Platform Health service and incident pages return to canonical health routes.
- [ ] Health overview cards and drill-ins use canonical routes.
- [ ] AOC provenance validation navigates through Angular routing and preserves query intent.
- [ ] AOC violations filters actually affect the loaded results.
- [ ] AOC child pages use canonical owner-shell breadcrumbs and links.
- [x] Platform Health service and incident pages return to canonical health routes.
- [x] Health overview cards and drill-ins use canonical routes.
- [x] AOC provenance validation navigates through Angular routing and preserves query intent.
- [x] AOC violations filters actually affect the loaded results.
- [x] AOC child pages use canonical owner-shell breadcrumbs and links.
### FE-QHA-004 - Verify cutover, sync docs, and archive
Status: DONE
Dependency: FE-QHA-002, FE-QHA-003
Owners: Developer / Implementer, QA
Task description:
- Add focused tests for the repaired routes and flows, then run targeted Angular and Playwright verification. Record the shipped behavior in the checked-feature docs and archive the sprint only when every task is done.
Completion criteria:
- [ ] Targeted Angular tests cover redirect contracts and the repaired quota/health/AOC behaviors.
- [ ] Playwright verifies at least one end-to-end journey through the restored surfaces.
- [ ] UI docs and checked-feature notes reflect the shipped behavior.
- [ ] Sprint moved to `docs-archived/implplan/` only after all tasks are marked DONE.
- [x] Targeted Angular tests cover redirect contracts and the repaired quota/health/AOC behaviors.
- [x] Playwright verifies at least one end-to-end journey through the restored surfaces.
- [x] UI docs and checked-feature notes reflect the shipped behavior.
- [x] Sprint moved to `docs-archived/implplan/` only after all tasks are marked DONE.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-08 | Sprint created and moved to DOING for Quota/Health/AOC operations cutover. | Codex |
| 2026-03-08 | Added canonical route helpers and repaired `ops`, `platform-ops`, and `platform/ops` aliases for quota, health, and AOC child routes. | Codex |
| 2026-03-08 | Completed quota query-driven actions, tenant-detail export, alert payload generation, health drill-ins, and AOC route-backed provenance or filter flows. | Codex |
| 2026-03-08 | Verified targeted Angular coverage with `npm test -- --watch=false --include src/tests/platform-ops/platform-ops-routes.spec.ts --include src/tests/quotas/quota-operations-cutover.spec.ts --include src/tests/aoc_verification/guard-violations-list.component.spec.ts --include src/tests/platform_health/platform-health-dashboard.spec.ts`: 13 tests passed. | QA |
| 2026-03-08 | Verified browser flows with a prestarted local Angular test server plus `PLAYWRIGHT_BASE_URL=https://127.0.0.1:4400 npx playwright test tests/e2e/quota-health-aoc-operations.spec.ts --workers=1`: 3 scenarios passed. | QA |
| 2026-03-08 | Production build passed via `npm run build`; existing bundle-budget warnings remain unchanged from the baseline. | QA |
## Decisions & Risks
- Risk: some quota and alert behaviors have no dedicated backend endpoint. Mitigation: ship honest offline-safe fallbacks such as route-aware prefill, downloadable artifacts, or clipboard payloads instead of fake API success.
- Risk: quota status filtering is not represented in the existing client signature. Mitigation: use deterministic client-side filtering when the backend cannot filter directly, and document the limitation if discovered in verification.
- Risk: stale bookmarks may exist outside the currently searched UI files. Mitigation: add alias coverage at the route layer instead of only fixing component links.
- Delivery rule satisfied: the canonical routes are mounted, the operator actions are usable, and targeted Tier 1 plus Tier 2 UI verification passed.
- Docs synced:
- `docs/modules/ui/quota-health-aoc-operations/README.md`
- `docs/features/checked/web/quota-health-aoc-operations-ui.md`
- `docs/modules/ui/README.md`
- `docs/modules/ui/implementation_plan.md`
- `docs/modules/ui/TASKS.md`
## Next Checkpoints
- 2026-03-08: archive sprint and commit the delivered cutover.

View File

@@ -0,0 +1,71 @@
# Quota Health AOC Operations UI
## Module
Web
## Status
VERIFIED
## Description
Shipped the canonical `Ops > Operations` cutover for `Quotas & Limits`, `Health & SLO`, and `AOC Compliance`. The work repaired stale `/ops/*` and `platform-ops` deep links, completed quota actions that still stopped at placeholder behavior, and kept health and AOC drill-ins inside the mounted operations shell with usable route-backed filter state.
## Implementation Details
- **Feature directories**:
- `src/Web/StellaOps.Web/src/app/features/quota-dashboard/`
- `src/Web.StellaOps.Web/src/app/features/platform-health/`
- `src/Web.StellaOps.Web/src/app/features/aoc-compliance/`
- `src/Web.StellaOps.Web/src/app/features/platform/ops/`
- **Primary routes**:
- `/ops/operations/quotas`
- `/ops/operations/quotas/tenants`
- `/ops/operations/quotas/forecast`
- `/ops/operations/quotas/alerts`
- `/ops/operations/quotas/reports`
- `/ops/operations/health-slo`
- `/ops/operations/health-slo/services/:serviceName`
- `/ops/operations/health-slo/incidents`
- `/ops/operations/aoc`
- `/ops/operations/aoc/violations`
- `/ops/operations/aoc/provenance`
- `/ops/operations/aoc/ingestion`
- `/ops/operations/aoc/report`
- **Legacy aliases**:
- `/ops/quotas/*`
- `/ops/aoc/*`
- `/ops/health-slo/*`
- `/platform-ops/*`
- `/platform/ops/*`
- **Notable repaired behaviors**:
- quota dashboard query-driven category loading
- quota forecast action routing into alerts or reports
- tenant-detail CSV export and audit-log handoff
- quota alert test-payload generation
- route-backed AOC provenance validation
- AOC guard-violation request filtering
## E2E Test Plan
- **Setup**:
- [x] Start the local Angular test server with `npm run serve:test`.
- [x] Use a test session with Ops and admin scopes.
- **Core verification**:
- [x] Verify old quota alert deep links land on the canonical Operations route.
- [x] Verify stale platform health detail bookmarks land on canonical health service detail.
- [x] Verify old AOC provenance links keep query intent and still render a successful validation result.
- **Cutover verification**:
- [x] Verify the Angular unit suite covers route alias inventory, quota query actions, and AOC violation filter behavior.
- [x] Verify the production build still completes after the cutover.
## Verification
- Run:
- `npm test -- --watch=false --include src/tests/platform-ops/platform-ops-routes.spec.ts --include src/tests/quotas/quota-operations-cutover.spec.ts --include src/tests/aoc_verification/guard-violations-list.component.spec.ts --include src/tests/platform_health/platform-health-dashboard.spec.ts`
- `npm run serve:test`
- `PLAYWRIGHT_BASE_URL=https://127.0.0.1:4400 npx playwright test tests/e2e/quota-health-aoc-operations.spec.ts --workers=1`
- `npm run build`
- Tier 0 (source): pass
- Tier 1 (build/tests): pass
- Tier 2 (behavior): pass
- Notes:
- Angular targeted tests passed: `4` files, `13` tests.
- Playwright passed: `3` scenarios.
- Production build passed; existing bundle-budget warnings remain unchanged from the baseline.
- Verified on (UTC): 2026-03-08T06:10:00Z

View File

@@ -24,6 +24,8 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runt
- Added checked-feature verification for operations consolidation at `../../features/checked/web/operations-consolidation-ui.md`.
- Shipped the canonical offline and air-gap operations flow under `Ops > Operations`, including repaired stale `/ops/*` and `/platform-ops/*` deep links, usable Offline Kit actions, and Evidence or Trust handoffs.
- Added checked-feature verification for offline operations at `../../features/checked/web/offline-operations-ui.md`.
- Shipped the canonical `Quotas & Limits`, `Health & SLO`, and `AOC Compliance` cutover under `Ops > Operations`, including repaired legacy aliases, usable quota exports and payload generation, and route-backed AOC filtering or provenance validation.
- Added checked-feature verification for quota, health, and AOC operations at `../../features/checked/web/quota-health-aoc-operations-ui.md`.
- Shipped the shared contextual placement primitives for tabs, submenu pills, route-aware drawers, list-detail shells, grouped overview cards, and return-to-context headers under `src/Web/StellaOps.Web/src/app/shared/ui/`.
- Added checked-feature verification for the contextual primitives and their first adopted surfaces at `../../features/checked/web/contextual-actions-patterns-ui.md`.
@@ -72,6 +74,7 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runt
- ./reachability-witnessing/README.md
- ./platform-ops-consolidation/README.md
- ./offline-operations/README.md
- ./quota-health-aoc-operations/README.md
- ./triage-explainability-workspace/README.md
- ./workflow-visualization-replay/README.md
- ./contextual-actions-patterns/README.md

View File

@@ -92,6 +92,10 @@
- [DONE] FE-OFF-003 Complete supporting export, verification, and trust workflows
- [DONE] FE-OFF-004 Verify canonical offline operations journeys
- [DONE] FE-OFF-005 Sync docs, archive the sprint, and record the shipped feature
- [DONE] FE-QHA-001 Freeze canonical route and alias contract
- [DONE] FE-QHA-002 Complete quota operator workflows
- [DONE] FE-QHA-003 Complete health and AOC operator workflows
- [DONE] FE-QHA-004 Verify cutover, sync docs, and archive
- [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

View File

@@ -140,7 +140,7 @@ These are mostly not dropped products. They are current or near-current capabili
- consolidated ops operations subtree
### 10. Quota, Platform Health, And AOC Operations
- Type: `wire-in / preserve`
- Type: `shipped`
- Confidence: `high`
- Branches:
- `Quota Dashboard`
@@ -149,6 +149,8 @@ These are mostly not dropped products. They are current or near-current capabili
- `Platform`
- Target:
- `/ops/operations/*`
- Status:
- shipped on 2026-03-08 as the canonical Operations child-route cutover
### 11. Topology And Trust Administration
- Type: `wire-in / preserve`

View File

@@ -29,9 +29,11 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- `docs/features/checked/web/contextual-actions-patterns-ui.md` - shipped verification note for the shared contextual route-state, headers, drawers, list-detail shells, grouped overview cards, and first adopted restoration surfaces.
- `docs/features/checked/web/unified-audit-surfaces-ui.md` - shipped verification note for the Evidence-owned audit shell, admin bookmark redirects, repaired audit subview links, and secondary handoff entry points.
- `docs/features/checked/web/offline-operations-ui.md` - shipped verification note for the canonical Offline Kit and Feeds & Airgap owner routes, repaired stale aliases, and completed offline shell actions.
- `docs/features/checked/web/quota-health-aoc-operations-ui.md` - shipped verification note for canonical quota, health, and AOC owner routes, repaired deep links, route-backed filters, and completed operator actions.
- `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/offline-operations/README.md` - detailed owner-shell contract for Offline Kit, Feeds & Airgap, Evidence handoffs, and stale alias policy.
- `docs/modules/ui/quota-health-aoc-operations/README.md` - canonical owner-shell contract for quota, health, and AOC operations cutover plus alias and action rules.
- `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier.
- `docs/modules/ui/workflow-visualization-replay/README.md` - detailed run-detail graph, timeline, replay, and evidence UX dossier.
- `docs/modules/ui/contextual-actions-patterns/README.md` - shared placement contract for stray actions, pages, drawers, and tabs.

View File

@@ -0,0 +1,65 @@
# Quota Health AOC Operations
## Purpose
- Finish the `Ops > Operations` cutover for `Quotas & Limits`, `Health & SLO`, and `AOC Compliance`.
- Replace stale route fragments and half-wired actions with one usable operator flow under the mounted Operations shell.
## Canonical Owner
- Owner shell: `Ops > Operations`
- Primary routes:
- `/ops/operations/quotas`
- `/ops/operations/quotas/tenants`
- `/ops/operations/quotas/forecast`
- `/ops/operations/quotas/alerts`
- `/ops/operations/quotas/reports`
- `/ops/operations/health-slo`
- `/ops/operations/health-slo/services/:serviceName`
- `/ops/operations/health-slo/incidents`
- `/ops/operations/aoc`
- `/ops/operations/aoc/violations`
- `/ops/operations/aoc/provenance`
- `/ops/operations/aoc/ingestion`
- `/ops/operations/aoc/report`
## Legacy Alias Policy
- Preserve shorthand and stale bookmarks by redirecting:
- `/ops/quotas/*`
- `/ops/aoc/*`
- `/ops/health-slo/*`
- `/platform-ops/quotas/*`
- `/platform-ops/aoc/*`
- `/platform-ops/health-slo/*`
- `/platform/ops/quotas/*`
- `/platform/ops/aoc/*`
- `/platform/ops/health-slo/*`
- Redirects must preserve query params and fragments because these pages use route-backed filter or drill-in state.
## UX Rules
- `Quotas & Limits` owns quota drill-ins, forecast routing, alert thresholds, and report export.
- `Health & SLO` owns service detail and incident history; service tiles must not deep-link into stale `platform` paths.
- `AOC Compliance` owns provenance validation, violation triage, ingestion monitoring, and compliance export.
- Cross-shell actions should stay contextual:
- quota tenant detail can hand off into `Evidence > Audit Log`
- critical quota forecasts can hand off into quota reports with prefilled category intent
- AOC provenance validation must stay bookmarkable through query params
## Shipped In This Cut
- Added canonical route helpers and alias coverage for quota, health, and AOC old bookmarks.
- Rewired the Operations navigation tree so AOC no longer points at stale `/ops/aoc` paths.
- Made quota dashboard category chips drive real history and forecast loading through the URL.
- Repaired quota forecast, alert, tenant-detail, and report flows so they use canonical routes and usable local export or payload generation instead of dead links or console placeholders.
- Repaired health breadcrumbs and service drill-ins to stay inside the mounted `Health & SLO` subtree.
- Repaired AOC provenance navigation and made guard-violation filters affect the actual request payload.
## Preserved Value
- Keep:
- quota capacity planning and threshold tuning
- service-level health drill-ins and incident export
- AOC provenance explanation and guard-violation triage
- Why:
- these are not abandoned product ideas; they are real operator surfaces that had route and workflow drift after the Operations shell consolidation
## Related Docs
- `docs/modules/ui/platform-ops-consolidation/README.md`
- `docs/features/checked/web/quota-health-aoc-operations-ui.md`
- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md`

View File

@@ -211,7 +211,7 @@ export class AocClient {
/**
* Gets AOC compliance dashboard data including metrics, violations, and ingestion flow.
*/
getComplianceDashboard(filters?: AocDashboardFilters): Observable<AocComplianceDashboardData> {
getComplianceDashboard(filters?: Partial<AocDashboardFilters>): Observable<AocComplianceDashboardData> {
let params = new HttpParams();
if (filters?.dateRange) {
params = params.set('startDate', filters.dateRange.start);
@@ -232,7 +232,7 @@ export class AocClient {
getGuardViolations(
page = 1,
pageSize = 20,
filters?: AocDashboardFilters
filters?: Partial<AocDashboardFilters>
): Observable<GuardViolationsPagedResponse> {
let params = new HttpParams()
.set('page', page.toString())
@@ -243,6 +243,9 @@ export class AocClient {
}
if (filters?.sources?.length) params = params.set('sources', filters.sources.join(','));
if (filters?.modules?.length) params = params.set('modules', filters.modules.join(','));
if (filters?.violationReasons?.length) {
params = params.set('violationReasons', filters.violationReasons.join(','));
}
return this.http.get<GuardViolationsPagedResponse>(`${this.baseUrl}/compliance/violations`, {
params,
headers: this.buildHeaders(),

View File

@@ -1,4 +1,4 @@
import { OPERATIONS_PATHS } from '../../features/platform/ops/operations-paths';
import { OPERATIONS_PATHS, aocPath } from '../../features/platform/ops/operations-paths';
import { NavGroup, NavigationConfig } from './navigation.types';
/**
@@ -415,38 +415,38 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'aoc-compliance',
label: 'AOC Compliance',
route: '/ops/aoc',
route: OPERATIONS_PATHS.aoc,
icon: 'shield-check',
tooltip: 'Guard violations, ingestion flow, and provenance chain validation',
children: [
{
id: 'aoc-dashboard',
label: 'Dashboard',
route: '/ops/aoc',
route: OPERATIONS_PATHS.aoc,
tooltip: 'AOC compliance metrics and KPIs',
},
{
id: 'aoc-violations',
label: 'Guard Violations',
route: '/ops/aoc/violations',
route: aocPath('violations'),
tooltip: 'View rejected payloads and reasons',
},
{
id: 'aoc-ingestion',
label: 'Ingestion Flow',
route: '/ops/aoc/ingestion',
route: aocPath('ingestion'),
tooltip: 'Real-time ingestion metrics per source',
},
{
id: 'aoc-provenance',
label: 'Provenance Validator',
route: '/ops/aoc/provenance',
route: aocPath('provenance'),
tooltip: 'Validate provenance chains for advisories',
},
{
id: 'aoc-report',
label: 'Compliance Report',
route: '/ops/aoc/report',
route: aocPath('report'),
tooltip: 'Export compliance reports for auditors',
},
],

View File

@@ -6,7 +6,7 @@
import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { RouterModule } from '@angular/router';
import { Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AocClient } from '../../core/api/aoc.client';
import {
@@ -15,6 +15,7 @@ import {
GuardViolation,
IngestionFlowSummary,
} from '../../core/api/aoc.models';
import { aocPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-aoc-compliance-dashboard',
@@ -671,6 +672,8 @@ import {
})
export class AocComplianceDashboardComponent implements OnInit {
private readonly aocClient = inject(AocClient);
private readonly router = inject(Router);
readonly aocOverviewPath = aocPath();
// State signals
readonly data = signal<AocComplianceDashboardData | null>(null);
@@ -745,8 +748,12 @@ export class AocComplianceDashboardComponent implements OnInit {
validateProvenance(): void {
if (!this.provenanceInputValue.trim()) return;
// Navigate to provenance validator with params
window.location.href = `/ops/aoc/provenance?type=${this.provenanceInputType}&value=${encodeURIComponent(this.provenanceInputValue)}`;
void this.router.navigate([aocPath('provenance')], {
queryParams: {
type: this.provenanceInputType,
value: this.provenanceInputValue.trim(),
},
});
}
getTrendIcon(trend?: 'up' | 'down' | 'stable'): string {

View File

@@ -5,6 +5,7 @@ import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AocClient } from '../../core/api/aoc.client';
import { ComplianceReportSummary, ComplianceReportRequest, ComplianceReportFormat } from '../../core/api/aoc.models';
import { aocPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-compliance-report',
@@ -14,7 +15,7 @@ import { ComplianceReportSummary, ComplianceReportRequest, ComplianceReportForma
<div class="report-page">
<header class="page-header">
<div class="breadcrumb">
<a routerLink="/ops/aoc">AOC Compliance</a> / Compliance Report
<a [routerLink]="aocOverviewPath">AOC Compliance</a> / Compliance Report
</div>
<h1>Export Compliance Report</h1>
<p class="description">Generate AOC compliance reports for auditors</p>
@@ -125,6 +126,7 @@ import { ComplianceReportSummary, ComplianceReportRequest, ComplianceReportForma
})
export class ComplianceReportComponent {
private readonly aocClient = inject(AocClient);
readonly aocOverviewPath = aocPath();
readonly report = signal<ComplianceReportSummary | null>(null);
readonly generating = signal(false);

View File

@@ -1,10 +1,12 @@
// Sprint: SPRINT_20251229_027_PLATFORM - AOC Compliance Dashboard
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { skip } from 'rxjs';
import { AocClient } from '../../core/api/aoc.client';
import { GuardViolation, GuardViolationsPagedResponse, GuardViolationReason } from '../../core/api/aoc.models';
import { GuardViolation, GuardViolationReason } from '../../core/api/aoc.models';
import { aocPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-guard-violations-list',
@@ -14,19 +16,19 @@ import { GuardViolation, GuardViolationsPagedResponse, GuardViolationReason } fr
<div class="violations-page">
<header class="page-header">
<div class="breadcrumb">
<a routerLink="/ops/aoc">AOC Compliance</a> / Guard Violations
<a [routerLink]="aocOverviewPath">AOC Compliance</a> / Guard Violations
</div>
<h1>Guard Violations</h1>
</header>
<div class="filters">
<select [(ngModel)]="reasonFilter" (change)="loadViolations()">
<select [(ngModel)]="reasonFilter" (ngModelChange)="onFiltersChanged()">
<option value="">All Reasons</option>
@for (reason of reasons; track reason) {
<option [value]="reason">{{ formatReason(reason) }}</option>
}
</select>
<select [(ngModel)]="moduleFilter" (change)="loadViolations()">
<select [(ngModel)]="moduleFilter" (ngModelChange)="onFiltersChanged()">
<option value="">All Modules</option>
<option value="concelier">Concelier</option>
<option value="excititor">Excititor</option>
@@ -99,6 +101,9 @@ import { GuardViolation, GuardViolationsPagedResponse, GuardViolationReason } fr
})
export class GuardViolationsListComponent implements OnInit {
private readonly aocClient = inject(AocClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly aocOverviewPath = aocPath();
readonly violations = signal<GuardViolation[]>([]);
readonly loading = signal(false);
@@ -108,16 +113,37 @@ export class GuardViolationsListComponent implements OnInit {
readonly totalPages = signal(1);
reasonFilter = '';
moduleFilter = '';
moduleFilter: '' | 'concelier' | 'excititor' = '';
readonly reasons: GuardViolationReason[] = [
'schema_invalid', 'untrusted_source', 'duplicate', 'malformed_timestamp', 'missing_required_fields', 'hash_mismatch'
];
ngOnInit(): void { this.loadViolations(); }
ngOnInit(): void {
this.applyQueryParams(
this.route.snapshot.queryParamMap.get('reason'),
this.route.snapshot.queryParamMap.get('module'),
this.route.snapshot.queryParamMap.get('page'),
);
this.loadViolations();
this.route.queryParamMap
.pipe(skip(1))
.subscribe((params) => {
this.applyQueryParams(
params.get('reason'),
params.get('module'),
params.get('page'),
);
this.loadViolations();
});
}
loadViolations(): void {
this.loading.set(true);
this.aocClient.getGuardViolations(this.page(), 20).subscribe({
this.aocClient.getGuardViolations(this.page(), 20, {
modules: this.moduleFilter ? [this.moduleFilter] : undefined,
violationReasons: this.reasonFilter ? [this.reasonFilter as GuardViolationReason] : undefined,
}).subscribe({
next: (res) => {
this.violations.set(res.items);
this.totalCount.set(res.totalCount);
@@ -125,12 +151,48 @@ export class GuardViolationsListComponent implements OnInit {
this.totalPages.set(Math.ceil(res.totalCount / res.pageSize) || 1);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
prevPage(): void { this.page.update(p => Math.max(1, p - 1)); this.loadViolations(); }
nextPage(): void { this.page.update(p => p + 1); this.loadViolations(); }
onFiltersChanged(): void {
this.page.set(1);
this.syncQueryParams();
}
prevPage(): void {
this.page.update(p => Math.max(1, p - 1));
this.syncQueryParams();
}
nextPage(): void {
this.page.update(p => p + 1);
this.syncQueryParams();
}
retry(v: GuardViolation): void { this.aocClient.retryIngestion(v.id).subscribe(() => this.loadViolations()); }
formatReason(r: string): string { return r.replace(/_/g, ' '); }
formatTime(ts: string): string { return new Date(ts).toLocaleString(); }
private applyQueryParams(rawReason: string | null, rawModule: string | null, rawPage: string | null): void {
this.reasonFilter = this.reasons.includes(rawReason as GuardViolationReason) ? rawReason ?? '' : '';
this.moduleFilter = rawModule === 'concelier' || rawModule === 'excititor' ? rawModule : '';
const page = Number.parseInt(rawPage ?? '1', 10);
this.page.set(Number.isFinite(page) && page > 0 ? page : 1);
}
private syncQueryParams(): void {
void this.router.navigate([], {
relativeTo: this.route,
queryParams: {
reason: this.reasonFilter || null,
module: this.moduleFilter || null,
page: this.page() > 1 ? this.page() : null,
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
}
}

View File

@@ -3,7 +3,8 @@ import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy }
import { RouterModule } from '@angular/router';
import { AocClient } from '../../core/api/aoc.client';
import { IngestionFlowSummary, IngestionSourceMetrics } from '../../core/api/aoc.models';
import { IngestionFlowSummary } from '../../core/api/aoc.models';
import { aocPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-ingestion-flow',
@@ -13,7 +14,7 @@ import { IngestionFlowSummary, IngestionSourceMetrics } from '../../core/api/aoc
<div class="ingestion-page">
<header class="page-header">
<div class="breadcrumb">
<a routerLink="/ops/aoc">AOC Compliance</a> / Ingestion Flow
<a [routerLink]="aocOverviewPath">AOC Compliance</a> / Ingestion Flow
</div>
<h1>Ingestion Flow Metrics</h1>
<button class="btn-secondary" (click)="refresh()"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> Refresh</button>
@@ -117,6 +118,7 @@ import { IngestionFlowSummary, IngestionSourceMetrics } from '../../core/api/aoc
})
export class IngestionFlowComponent implements OnInit {
private readonly aocClient = inject(AocClient);
readonly aocOverviewPath = aocPath();
readonly flow = signal<IngestionFlowSummary | null>(null);
readonly concelierSources = computed(() => this.flow()?.sources.filter(s => s.module === 'concelier') || []);

View File

@@ -1,10 +1,11 @@
// Sprint: SPRINT_20251229_027_PLATFORM - AOC Compliance Dashboard
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AocClient } from '../../core/api/aoc.client';
import { ProvenanceChain, ProvenanceStep } from '../../core/api/aoc.models';
import { ProvenanceChain } from '../../core/api/aoc.models';
import { aocPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-provenance-validator',
@@ -14,7 +15,7 @@ import { ProvenanceChain, ProvenanceStep } from '../../core/api/aoc.models';
<div class="provenance-page">
<header class="page-header">
<div class="breadcrumb">
<a routerLink="/ops/aoc">AOC Compliance</a> / Provenance Validator
<a [routerLink]="aocOverviewPath">AOC Compliance</a> / Provenance Validator
</div>
<h1>Provenance Chain Validator</h1>
<p class="description">Trace the evidence chain from upstream source to final attestation</p>
@@ -140,6 +141,8 @@ import { ProvenanceChain, ProvenanceStep } from '../../core/api/aoc.models';
export class ProvenanceValidatorComponent implements OnInit {
private readonly aocClient = inject(AocClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly aocOverviewPath = aocPath();
readonly chain = signal<ProvenanceChain | null>(null);
readonly loading = signal(false);
@@ -150,11 +153,34 @@ export class ProvenanceValidatorComponent implements OnInit {
ngOnInit(): void {
this.route.queryParams.subscribe(params => {
if (params['type']) this.inputType = params['type'];
if (params['value']) { this.inputValue = params['value']; this.validate(); }
if (params['value']) {
this.inputValue = params['value'];
this.runValidation();
}
});
}
validate(): void {
if (!this.inputValue.trim()) return;
const currentType = this.route.snapshot.queryParamMap.get('type');
const currentValue = this.route.snapshot.queryParamMap.get('value');
if (currentType === this.inputType && currentValue === this.inputValue.trim()) {
this.runValidation();
return;
}
void this.router.navigate([], {
relativeTo: this.route,
queryParams: {
type: this.inputType,
value: this.inputValue.trim(),
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
}
private runValidation(): void {
if (!this.inputValue.trim()) return;
this.loading.set(true);
this.aocClient.validateProvenanceChain(this.inputType, this.inputValue).subscribe({

View File

@@ -10,6 +10,7 @@ import {
formatLatency,
formatErrorRate,
} from '../../../core/api/platform-health.models';
import { healthServicePath } from '../../platform/ops/operations-paths';
@Component({
selector: 'app-service-health-grid',
@@ -33,7 +34,7 @@ import {
<h3 class="state-label state-label--unhealthy">Unhealthy ({{ unhealthy().length }})</h3>
<div class="cards" [class.cards--compact]="compact">
@for (svc of unhealthy(); track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
<a [routerLink]="healthServicePath(svc.name)"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
@@ -55,7 +56,7 @@ import {
<h3 class="state-label state-label--degraded">Degraded ({{ degraded().length }})</h3>
<div class="cards" [class.cards--compact]="compact">
@for (svc of degraded(); track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
<a [routerLink]="healthServicePath(svc.name)"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
@@ -77,7 +78,7 @@ import {
<h3 class="state-label state-label--healthy">Healthy ({{ healthy().length }})</h3>
<div class="cards" [class.cards--compact]="compact">
@for (svc of healthy(); track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
<a [routerLink]="healthServicePath(svc.name)"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
@@ -97,7 +98,7 @@ import {
} @else {
<div class="cards" [class.cards--compact]="compact">
@for (svc of services ?? []; track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
<a [routerLink]="healthServicePath(svc.name)"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
@@ -222,6 +223,7 @@ import {
export class ServiceHealthGridComponent {
@Input() services: ServiceHealth[] | null = [];
@Input() compact = false;
readonly healthServicePath = healthServicePath;
readonly groupBy = signal<'state' | 'none'>('state');
readonly formatUptime = formatUptime;

View File

@@ -9,6 +9,7 @@ import {
IncidentSeverity,
INCIDENT_SEVERITY_COLORS,
} from '../../core/api/platform-health.models';
import { healthSloPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-incident-timeline',
@@ -17,7 +18,7 @@ import {
<div class="incident-timeline p-6">
<header class="mb-6">
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
<a routerLink="/ops/health" class="hover:text-blue-600">Platform Health</a>
<a [routerLink]="healthOverviewPath" class="hover:text-blue-600">Platform Health</a>
<span>/</span>
<span>Incidents</span>
</div>
@@ -210,6 +211,7 @@ import {
})
export class IncidentTimelineComponent implements OnInit {
private readonly healthClient = inject(PlatformHealthClient);
readonly healthOverviewPath = healthSloPath();
// State
incidents = signal<Incident[]>([]);

View File

@@ -15,6 +15,7 @@ import {
formatLatency,
formatErrorRate,
} from '../../core/api/platform-health.models';
import { healthSloPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-service-detail',
@@ -23,7 +24,7 @@ import {
<div class="service-detail p-6">
<header class="mb-6">
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
<a routerLink="/ops/health" class="hover:text-blue-600">Platform Health</a>
<a [routerLink]="healthOverviewPath" class="hover:text-blue-600">Platform Health</a>
<span>/</span>
<span>{{ detail()?.service.displayName ?? 'Loading...' }}</span>
</div>
@@ -331,6 +332,7 @@ import {
export class ServiceDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly healthClient = inject(PlatformHealthClient);
readonly healthOverviewPath = healthSloPath();
// State
detail = signal<ServiceDetail | null>(null);

View File

@@ -38,6 +38,26 @@ export function dataIntegrityPath(section?: string): string {
return section ? `${OPERATIONS_PATHS.dataIntegrity}/${section}` : OPERATIONS_PATHS.dataIntegrity;
}
export function healthSloPath(section?: string): string {
return section ? `${OPERATIONS_PATHS.healthSlo}/${section}` : OPERATIONS_PATHS.healthSlo;
}
export function healthServicePath(serviceName: string): string {
return `${OPERATIONS_PATHS.healthSlo}/services/${encodeURIComponent(serviceName)}`;
}
export function quotasPath(section?: string): string {
return section ? `${OPERATIONS_PATHS.quotas}/${section}` : OPERATIONS_PATHS.quotas;
}
export function quotaTenantPath(tenantId?: string): string {
return tenantId ? `${OPERATIONS_PATHS.quotas}/tenants/${encodeURIComponent(tenantId)}` : `${OPERATIONS_PATHS.quotas}/tenants`;
}
export function aocPath(section?: string): string {
return section ? `${OPERATIONS_PATHS.aoc}/${section}` : OPERATIONS_PATHS.aoc;
}
export function jobEngineJobPath(jobId?: string): string {
return jobId ? `${OPERATIONS_PATHS.jobEngineJobs}/${jobId}` : OPERATIONS_PATHS.jobEngineJobs;
}

View File

@@ -1,11 +1,12 @@
// Sprint: SPRINT_20251229_029_FE - Operator Quota Dashboard
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
import { Subject, skip, takeUntil } from 'rxjs';
import { QuotaClient } from '../../core/api/quota.client';
import { QuotaAlertConfig, QuotaAlertThreshold, QuotaAlertChannel, QuotaCategory } from '../../core/api/quota.models';
import { QuotaAlertConfig, QuotaAlertChannel, QuotaCategory } from '../../core/api/quota.models';
import { quotasPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-quota-alert-config',
@@ -14,7 +15,7 @@ import { QuotaAlertConfig, QuotaAlertThreshold, QuotaAlertChannel, QuotaCategory
<div class="alert-config-page">
<header class="page-header">
<div class="header-content">
<a routerLink="/ops/quotas" class="back-link">&larr; Back to Dashboard</a>
<a [routerLink]="quotasOverviewPath" class="back-link">&larr; Back to Dashboard</a>
<h1>Quota Alert Configuration</h1>
<p class="subtitle">Configure thresholds and notification channels for quota alerts</p>
</div>
@@ -43,7 +44,7 @@ import { QuotaAlertConfig, QuotaAlertThreshold, QuotaAlertChannel, QuotaCategory
<div class="card-body">
<div class="threshold-grid">
@for (threshold of config()?.thresholds; track threshold; let i = $index) {
<div class="threshold-card">
<div class="threshold-card" [class.threshold-card--focused]="focusedCategory() === threshold.category">
<div class="threshold-header">
<label class="checkbox-label">
<input
@@ -219,7 +220,7 @@ import { QuotaAlertConfig, QuotaAlertThreshold, QuotaAlertChannel, QuotaCategory
<section class="card test-section">
<div class="card-header">
<h2>Test Alert</h2>
<p class="description">Send a test alert to verify your configuration</p>
<p class="description">Generate a deterministic test payload to verify your configuration</p>
</div>
<div class="card-body">
<div class="test-controls">
@@ -229,7 +230,7 @@ import { QuotaAlertConfig, QuotaAlertThreshold, QuotaAlertChannel, QuotaCategory
<option value="recovery">Recovery Alert</option>
</select>
<button class="btn btn-secondary" (click)="sendTestAlert()" [disabled]="testingSent()">
{{ testingSent() ? 'Test Sent!' : 'Send Test Alert' }}
{{ testingSent() ? 'Payload Ready' : 'Download Test Payload' }}
</button>
</div>
</div>
@@ -356,6 +357,11 @@ import { QuotaAlertConfig, QuotaAlertThreshold, QuotaAlertChannel, QuotaCategory
border-radius: var(--radius-lg);
}
.threshold-card--focused {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.threshold-header {
margin-bottom: 1rem;
}
@@ -580,13 +586,16 @@ import { QuotaAlertConfig, QuotaAlertThreshold, QuotaAlertChannel, QuotaCategory
})
export class QuotaAlertConfigComponent implements OnInit, OnDestroy {
private readonly quotaClient = inject(QuotaClient);
private readonly route = inject(ActivatedRoute);
private readonly destroy$ = new Subject<void>();
readonly quotasOverviewPath = quotasPath();
readonly loading = signal(false);
readonly saving = signal(false);
readonly config = signal<QuotaAlertConfig | null>(null);
readonly isDirty = signal(false);
readonly testingSent = signal(false);
readonly focusedCategory = signal<QuotaCategory | null>(null);
quietHoursStart = '';
quietHoursEnd = '';
@@ -594,6 +603,10 @@ export class QuotaAlertConfigComponent implements OnInit, OnDestroy {
testAlertType: 'warning' | 'critical' | 'recovery' = 'warning';
ngOnInit(): void {
this.applyCategoryQueryParam(this.route.snapshot.queryParamMap.get('category'));
this.route.queryParamMap
.pipe(skip(1), takeUntil(this.destroy$))
.subscribe((params) => this.applyCategoryQueryParam(params.get('category')));
this.loadConfig();
}
@@ -719,10 +732,33 @@ export class QuotaAlertConfigComponent implements OnInit, OnDestroy {
}
sendTestAlert(): void {
const payload = JSON.stringify(
{
generatedAt: new Date().toISOString(),
alertType: this.testAlertType,
focusedCategory: this.focusedCategory(),
quietHours: this.config()?.quietHours ?? null,
escalationMinutes: this.escalationMinutes,
channels: (this.config()?.channels ?? [])
.filter((channel) => channel.enabled)
.map((channel) => ({
type: channel.type,
target: channel.target,
events: [...channel.events].sort(),
})),
},
null,
2,
);
const blob = new Blob([payload], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `quota-alert-test-${this.testAlertType}.json`;
anchor.click();
URL.revokeObjectURL(url);
this.testingSent.set(true);
setTimeout(() => this.testingSent.set(false), 3000);
// In a real implementation, this would call an API endpoint to send a test alert
console.log('Test alert sent:', this.testAlertType);
}
getCategoryLabel(category: QuotaCategory): string {
@@ -765,4 +801,16 @@ export class QuotaAlertConfigComponent implements OnInit, OnDestroy {
};
return placeholders[type] || '';
}
private applyCategoryQueryParam(rawCategory: string | null): void {
this.focusedCategory.set(this.isQuotaCategory(rawCategory) ? rawCategory : null);
}
private isQuotaCategory(value: string | null): value is QuotaCategory {
return value === 'license'
|| value === 'jobs'
|| value === 'api'
|| value === 'storage'
|| value === 'scans';
}
}

View File

@@ -1,8 +1,8 @@
// Sprint: SPRINT_20251229_029_FE - Operator Quota Dashboard
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
import { RouterModule } from '@angular/router';
import { Subject, takeUntil, interval, startWith, switchMap, forkJoin } from 'rxjs';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subject, takeUntil, interval, startWith, forkJoin, skip } from 'rxjs';
import { QuotaClient } from '../../core/api/quota.client';
import {
QuotaDashboardSummary,
@@ -11,7 +11,9 @@ import {
TrendDirection,
RateLimitViolation,
QuotaForecast,
QuotaCategory,
} from '../../core/api/quota.models';
import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
@Component({
selector: 'app-quota-dashboard',
@@ -898,8 +900,11 @@ import {
})
export class QuotaDashboardComponent implements OnInit, OnDestroy {
private readonly quotaClient = inject(QuotaClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroy$ = new Subject<void>();
private readonly refreshInterval = 30000; // 30 seconds
readonly quotasOverviewPath = OPERATIONS_PATHS.quotas;
readonly loading = signal(false);
readonly refreshing = signal(false);
@@ -908,9 +913,9 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
readonly forecasts = signal<QuotaForecast[]>([]);
readonly topTenants = signal<any[]>([]);
readonly recentViolations = signal<RateLimitViolation[]>([]);
readonly selectedCategories = signal<string[]>(['license', 'jobs', 'api', 'storage']);
readonly selectedCategories = signal<QuotaCategory[]>(['license', 'jobs', 'api', 'storage']);
readonly quotaCategories = ['license', 'jobs', 'api', 'storage', 'scans'];
readonly quotaCategories: QuotaCategory[] = ['license', 'jobs', 'api', 'storage', 'scans'];
readonly consumptionKpis = computed(() => {
const consumption = this.summary()?.consumption || [];
@@ -921,6 +926,15 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
});
ngOnInit(): void {
this.applyCategoryQueryParam(this.route.snapshot.queryParamMap.get('category'));
this.route.queryParamMap
.pipe(skip(1), takeUntil(this.destroy$))
.subscribe((params) => {
this.applyCategoryQueryParam(params.get('category'));
this.loadData();
});
// Auto-refresh every 30 seconds, including the initial fetch.
interval(this.refreshInterval)
.pipe(
@@ -942,11 +956,15 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
private loadData(isManualRefresh = false): void {
this.loading.set(true);
const selectedCategories = this.selectedCategories();
const forecastCategory = selectedCategories.length === 1
? selectedCategories[0] as QuotaCategory
: undefined;
forkJoin({
summary: this.quotaClient.getDashboardSummary(),
history: this.quotaClient.getConsumptionHistory(),
forecasts: this.quotaClient.getQuotaForecast(),
history: this.quotaClient.getConsumptionHistory(undefined, undefined, selectedCategories),
forecasts: this.quotaClient.getQuotaForecast(forecastCategory),
tenants: this.quotaClient.getTenantQuotas(undefined, 'percentage', 'desc', 5, 0),
violations: this.quotaClient.getRateLimitViolations(undefined, undefined, undefined, 10),
})
@@ -968,13 +986,16 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
});
}
toggleCategory(category: string): void {
toggleCategory(category: QuotaCategory): void {
const current = this.selectedCategories();
if (current.includes(category)) {
this.selectedCategories.set(current.filter(c => c !== category));
} else {
this.selectedCategories.set([...current, category]);
}
this.syncCategoryQueryParam();
this.loadData();
}
getCategoryLabel(category: string): string {
@@ -1014,4 +1035,33 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
formatTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
private applyCategoryQueryParam(rawCategory: string | null): void {
if (!rawCategory) {
this.selectedCategories.set(['license', 'jobs', 'api', 'storage']);
return;
}
const categories = rawCategory
.split(',')
.map((value) => value.trim())
.filter((value): value is QuotaCategory => this.quotaCategories.includes(value as QuotaCategory));
this.selectedCategories.set(categories.length ? categories : ['license', 'jobs', 'api', 'storage']);
}
private syncCategoryQueryParam(): void {
const selected = this.selectedCategories();
const allSelected = selected.length === 4
&& (['license', 'jobs', 'api', 'storage'] as QuotaCategory[])
.every((category) => selected.includes(category));
const category = allSelected ? null : selected.join(',');
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { category },
queryParamsHandling: 'merge',
replaceUrl: true,
});
}
}

View File

@@ -1,11 +1,12 @@
// Sprint: SPRINT_20251229_029_FE - Operator Quota Dashboard
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
import { Subject, skip, takeUntil } from 'rxjs';
import { QuotaClient } from '../../core/api/quota.client';
import { QuotaForecast, QuotaCategory } from '../../core/api/quota.models';
import { quotasPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-quota-forecast',
@@ -14,7 +15,7 @@ import { QuotaForecast, QuotaCategory } from '../../core/api/quota.models';
<div class="forecast-page">
<header class="page-header">
<div class="header-content">
<a routerLink="/ops/quotas" class="back-link">&larr; Back to Dashboard</a>
<a [routerLink]="quotasOverviewPath" class="back-link">&larr; Back to Dashboard</a>
<h1>Quota Forecast</h1>
<p class="subtitle">Predictive quota exhaustion analysis and recommendations</p>
</div>
@@ -594,7 +595,10 @@ import { QuotaForecast, QuotaCategory } from '../../core/api/quota.models';
})
export class QuotaForecastComponent implements OnInit, OnDestroy {
private readonly quotaClient = inject(QuotaClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroy$ = new Subject<void>();
readonly quotasOverviewPath = quotasPath();
readonly loading = signal(false);
readonly forecasts = signal<QuotaForecast[]>([]);
@@ -615,7 +619,18 @@ export class QuotaForecastComponent implements OnInit, OnDestroy {
this.forecasts().filter((f) => f.exhaustionDays === null);
ngOnInit(): void {
this.loadData();
this.applyQueryParams(
this.route.snapshot.queryParamMap.get('category'),
this.route.snapshot.queryParamMap.get('tenantId'),
);
this.loadData(false);
this.route.queryParamMap
.pipe(skip(1), takeUntil(this.destroy$))
.subscribe((params) => {
this.applyQueryParams(params.get('category'), params.get('tenantId'));
this.loadData(false);
});
}
ngOnDestroy(): void {
@@ -624,10 +639,14 @@ export class QuotaForecastComponent implements OnInit, OnDestroy {
}
refreshData(): void {
this.loadData();
this.loadData(false);
}
loadData(syncRoute = true): void {
if (syncRoute) {
this.syncQueryParams();
}
loadData(): void {
this.loading.set(true);
const category = this.selectedCategory as QuotaCategory | undefined;
const tenantId = this.tenantId || undefined;
@@ -664,15 +683,50 @@ export class QuotaForecastComponent implements OnInit, OnDestroy {
}
viewHistory(category: QuotaCategory): void {
window.location.href = `/ops/quotas?category=${category}`;
void this.router.navigate([quotasPath()], {
queryParams: { category },
});
}
takeAction(forecast: QuotaForecast): void {
// Based on severity, open appropriate action dialog
if (forecast.severity === 'critical') {
window.location.href = '/admin/registries?action=upgrade';
} else {
window.location.href = `/ops/quotas/alerts?category=${forecast.category}`;
}
void this.router.navigate([quotasPath('reports')], {
queryParams: {
action: 'capacity-plan',
category: forecast.category,
tenantId: this.tenantId || null,
},
});
return;
}
void this.router.navigate([quotasPath('alerts')], {
queryParams: { category: forecast.category },
});
}
private applyQueryParams(rawCategory: string | null, rawTenantId: string | null): void {
this.selectedCategory = this.isQuotaCategory(rawCategory) ? rawCategory : '';
this.tenantId = rawTenantId ?? '';
}
private syncQueryParams(): void {
void this.router.navigate([], {
relativeTo: this.route,
queryParams: {
category: this.selectedCategory || null,
tenantId: this.tenantId || null,
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
}
private isQuotaCategory(value: string | null): value is QuotaCategory {
return value === 'license'
|| value === 'jobs'
|| value === 'api'
|| value === 'storage'
|| value === 'scans';
}
}

View File

@@ -1,11 +1,12 @@
// Sprint: SPRINT_20251229_029_FE - Operator Quota Dashboard
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil, interval, switchMap, filter } from 'rxjs';
import { Subject, takeUntil, interval, switchMap, filter, skip } from 'rxjs';
import { QuotaClient } from '../../core/api/quota.client';
import { QuotaReportRequest, QuotaReportResponse, QuotaCategory } from '../../core/api/quota.models';
import { quotasPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-quota-report-export',
@@ -14,7 +15,7 @@ import { QuotaReportRequest, QuotaReportResponse, QuotaCategory } from '../../co
<div class="report-page">
<header class="page-header">
<div class="header-content">
<a routerLink="/ops/quotas" class="back-link">&larr; Back to Dashboard</a>
<a [routerLink]="quotasOverviewPath" class="back-link">&larr; Back to Dashboard</a>
<h1>Quota Report Export</h1>
<p class="subtitle">Generate comprehensive quota reports for capacity planning</p>
</div>
@@ -669,8 +670,11 @@ import { QuotaReportRequest, QuotaReportResponse, QuotaCategory } from '../../co
`]
})
export class QuotaReportExportComponent implements OnInit, OnDestroy {
private static readonly REPORT_HISTORY_STORAGE_KEY = 'stellaops.quota-report-history';
private readonly quotaClient = inject(QuotaClient);
private readonly route = inject(ActivatedRoute);
private readonly destroy$ = new Subject<void>();
readonly quotasOverviewPath = quotasPath();
readonly generating = signal(false);
readonly activeReport = signal<QuotaReportResponse | null>(null);
@@ -701,6 +705,13 @@ export class QuotaReportExportComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.setDateRange(30);
this.loadReportHistory();
this.applyQueryParams(
this.route.snapshot.queryParamMap.get('category'),
this.route.snapshot.queryParamMap.get('tenantId'),
);
this.route.queryParamMap
.pipe(skip(1), takeUntil(this.destroy$))
.subscribe((params) => this.applyQueryParams(params.get('category'), params.get('tenantId')));
}
ngOnDestroy(): void {
@@ -743,6 +754,7 @@ export class QuotaReportExportComponent implements OnInit, OnDestroy {
.subscribe({
next: (response) => {
this.activeReport.set(response);
this.upsertHistory(response);
this.generating.set(false);
// Poll for completion if processing
@@ -766,18 +778,19 @@ export class QuotaReportExportComponent implements OnInit, OnDestroy {
.subscribe({
next: (response) => {
this.activeReport.set(response);
if (response.status === 'completed') {
this.loadReportHistory();
}
this.upsertHistory(response);
},
});
}
private loadReportHistory(): void {
// In a real implementation, this would load from an API
// For now, we'll show placeholder data
try {
const stored = localStorage.getItem(QuotaReportExportComponent.REPORT_HISTORY_STORAGE_KEY);
this.reportHistory.set(stored ? JSON.parse(stored) : []);
} catch {
this.reportHistory.set([]);
}
}
getStatusIcon(status: string | undefined): string {
switch (status) {
@@ -803,4 +816,36 @@ export class QuotaReportExportComponent implements OnInit, OnDestroy {
if (!date) return '-';
return new Date(date).toLocaleString();
}
private applyQueryParams(rawCategory: string | null, rawTenantId: string | null): void {
this.selectedCategories = this.isQuotaCategory(rawCategory)
? [rawCategory]
: ['license', 'jobs', 'api', 'storage'];
this.tenantIds = rawTenantId ?? '';
}
private upsertHistory(report: QuotaReportResponse): void {
const nextHistory = [
{
...report,
dateRange: `${this.startDate} -> ${this.endDate}`,
format: this.format,
},
...this.reportHistory().filter((entry) => entry.reportId !== report.reportId),
].slice(0, 10);
this.reportHistory.set(nextHistory);
localStorage.setItem(
QuotaReportExportComponent.REPORT_HISTORY_STORAGE_KEY,
JSON.stringify(nextHistory),
);
}
private isQuotaCategory(value: string | null): value is QuotaCategory {
return value === 'license'
|| value === 'jobs'
|| value === 'api'
|| value === 'storage'
|| value === 'scans';
}
}

View File

@@ -1,10 +1,11 @@
// Sprint: SPRINT_20251229_029_FE - Operator Quota Dashboard
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subject, takeUntil, switchMap, of } from 'rxjs';
import { QuotaClient } from '../../core/api/quota.client';
import { TenantQuotaBreakdown, QuotaForecast } from '../../core/api/quota.models';
import { TenantQuotaBreakdown } from '../../core/api/quota.models';
import { quotasPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-tenant-quota-detail',
@@ -12,7 +13,7 @@ import { TenantQuotaBreakdown, QuotaForecast } from '../../core/api/quota.models
template: `
<div class="tenant-detail-page">
<header class="page-header">
<a routerLink="/ops/quotas/tenants" class="back-link">&larr; Back to Tenant List</a>
<a [routerLink]="quotaTenantsPath" class="back-link">&larr; Back to Tenant List</a>
@if (breakdown()) {
<h1>{{ breakdown()?.tenantName }}</h1>
}
@@ -152,10 +153,10 @@ import { TenantQuotaBreakdown, QuotaForecast } from '../../core/api/quota.models
<span class="icon">download</span>
Export Report
</button>
<button class="btn btn-primary" (click)="contactSupport()">
<a class="btn btn-primary" [href]="supportMailto()">
<span class="icon">help</span>
Contact Support
</button>
</a>
</section>
</div>
}
@@ -163,7 +164,7 @@ import { TenantQuotaBreakdown, QuotaForecast } from '../../core/api/quota.models
@if (!loading() && !breakdown()) {
<div class="empty-state">
<p>Tenant not found</p>
<a routerLink="/ops/quotas/tenants" class="link">Return to tenant list</a>
<a [routerLink]="quotaTenantsPath" class="link">Return to tenant list</a>
</div>
}
</div>
@@ -461,7 +462,9 @@ import { TenantQuotaBreakdown, QuotaForecast } from '../../core/api/quota.models
export class TenantQuotaDetailComponent implements OnInit, OnDestroy {
private readonly quotaClient = inject(QuotaClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroy$ = new Subject<void>();
readonly quotaTenantsPath = quotasPath('tenants');
readonly loading = signal(false);
readonly breakdown = signal<TenantQuotaBreakdown | null>(null);
@@ -555,16 +558,63 @@ export class TenantQuotaDetailComponent implements OnInit, OnDestroy {
viewAuditLog(): void {
const tenantId = this.breakdown()?.tenantId;
if (tenantId) {
window.location.href = `/evidence/audit-log?tenantId=${encodeURIComponent(tenantId)}`;
void this.router.navigate(['/evidence/audit-log'], {
queryParams: { tenantId },
});
}
}
exportReport(): void {
// Trigger report export via API
console.log('Export report for tenant:', this.breakdown()?.tenantId);
const breakdown = this.breakdown();
if (!breakdown) {
return;
}
contactSupport(): void {
window.location.href = 'mailto:support@stellaops.io?subject=Quota%20Inquiry';
const rows = [
['Tenant', breakdown.tenantName],
['Tenant ID', breakdown.tenantId],
['Plan', breakdown.planName],
['License Start', breakdown.licensePeriod?.start ?? ''],
['License End', breakdown.licensePeriod?.end ?? ''],
[''],
['Quota', 'Current', 'Limit', 'Percentage'],
...this.quotaItems().map((item) => [
item.label,
String(item.current),
String(item.limit),
String(item.percentage),
]),
[''],
['Resource Type', 'Percentage'],
...(breakdown.usageByResourceType ?? []).map((resource) => [
resource.type,
String(resource.percentage),
]),
];
if (breakdown.forecast) {
rows.push(
[''],
['Forecast Severity', breakdown.forecast.severity],
['Forecast Exhaustion Days', breakdown.forecast.exhaustionDays == null ? 'none' : String(breakdown.forecast.exhaustionDays)],
['Forecast Recommendation', breakdown.forecast.recommendation],
);
}
const content = rows
.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([content], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `tenant-quota-${breakdown.tenantId}-${new Date().toISOString().slice(0, 10)}.csv`;
anchor.click();
URL.revokeObjectURL(url);
}
supportMailto(): string {
const tenantId = this.breakdown()?.tenantId ?? 'unknown-tenant';
return `mailto:support@stellaops.io?subject=${encodeURIComponent(`Quota inquiry for ${tenantId}`)}`;
}
}

View File

@@ -6,6 +6,7 @@ import { FormsModule } from '@angular/forms';
import { Subject, takeUntil, debounceTime, distinctUntilChanged } from 'rxjs';
import { QuotaClient } from '../../core/api/quota.client';
import { TenantQuotaUsage, QuotaStatus, TrendDirection } from '../../core/api/quota.models';
import { quotasPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-tenant-quota-table',
@@ -14,7 +15,7 @@ import { TenantQuotaUsage, QuotaStatus, TrendDirection } from '../../core/api/qu
<div class="tenant-quota-page">
<header class="page-header">
<div class="header-content">
<a routerLink="/ops/quotas" class="back-link">&larr; Back to Dashboard</a>
<a [routerLink]="quotasOverviewPath" class="back-link">&larr; Back to Dashboard</a>
<h1>Tenant Quota Usage</h1>
<p class="subtitle">Per-tenant quota consumption and trend analysis</p>
</div>
@@ -32,7 +33,7 @@ import { TenantQuotaUsage, QuotaStatus, TrendDirection } from '../../core/api/qu
</div>
<div class="filter-group">
<label>Status:</label>
<select [(ngModel)]="statusFilter" (ngModelChange)="loadData()">
<select [(ngModel)]="statusFilter" (ngModelChange)="onStatusFilterChange()">
<option value="">All</option>
<option value="healthy">Healthy</option>
<option value="warning">Warning</option>
@@ -42,7 +43,7 @@ import { TenantQuotaUsage, QuotaStatus, TrendDirection } from '../../core/api/qu
</div>
<div class="filter-group">
<label>Sort by:</label>
<select [(ngModel)]="sortBy" (ngModelChange)="loadData()">
<select [(ngModel)]="sortBy" (ngModelChange)="onSortByChange()">
<option value="percentage">Usage %</option>
<option value="tenantName">Name</option>
<option value="trendPercentage">Trend</option>
@@ -51,7 +52,7 @@ import { TenantQuotaUsage, QuotaStatus, TrendDirection } from '../../core/api/qu
</div>
<div class="filter-group">
<label>Order:</label>
<select [(ngModel)]="sortDir" (ngModelChange)="loadData()">
<select [(ngModel)]="sortDir" (ngModelChange)="onSortDirChange()">
<option value="desc">Descending</option>
<option value="asc">Ascending</option>
</select>
@@ -441,6 +442,7 @@ export class TenantQuotaTableComponent implements OnInit, OnDestroy {
private readonly quotaClient = inject(QuotaClient);
private readonly destroy$ = new Subject<void>();
private readonly searchSubject = new Subject<string>();
readonly quotasOverviewPath = quotasPath();
readonly loading = signal(false);
readonly tenants = signal<TenantQuotaUsage[]>([]);
@@ -478,20 +480,46 @@ export class TenantQuotaTableComponent implements OnInit, OnDestroy {
}
onSearchChange(query: string): void {
this.currentPage.set(0);
this.searchSubject.next(query);
}
onStatusFilterChange(): void {
this.currentPage.set(0);
this.loadData();
}
onSortByChange(): void {
this.currentPage.set(0);
this.loadData();
}
onSortDirChange(): void {
this.currentPage.set(0);
this.loadData();
}
loadData(): void {
this.loading.set(true);
const offset = this.currentPage() * this.pageSize;
const requiresClientFiltering = Boolean(this.statusFilter);
const limit = requiresClientFiltering ? 1000 : this.pageSize;
const requestOffset = requiresClientFiltering ? 0 : offset;
this.quotaClient
.getTenantQuotas(this.searchQuery, this.sortBy, this.sortDir, this.pageSize, offset)
.getTenantQuotas(this.searchQuery, this.sortBy, this.sortDir, limit, requestOffset)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response) => {
this.tenants.set(response.items);
this.totalTenants.set(response.total);
const filteredItems = requiresClientFiltering
? response.items.filter((tenant) => this.getWorstStatus(tenant) === this.statusFilter)
: response.items;
const pagedItems = requiresClientFiltering
? filteredItems.slice(offset, offset + this.pageSize)
: filteredItems;
this.tenants.set(pagedItems);
this.totalTenants.set(requiresClientFiltering ? filteredItems.length : response.total);
this.loading.set(false);
},
error: () => {

View File

@@ -1,11 +1,12 @@
// Sprint: SPRINT_20251229_029_FE - Operator Quota Dashboard
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
import { RouterModule } from '@angular/router';
import { Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
import { QuotaClient } from '../../core/api/quota.client';
import { RateLimitViolation, RateLimitStatus } from '../../core/api/quota.models';
import { quotaTenantPath, quotasPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-throttle-context',
@@ -14,7 +15,7 @@ import { RateLimitViolation, RateLimitStatus } from '../../core/api/quota.models
<div class="throttle-page">
<header class="page-header">
<div class="header-content">
<a routerLink="/ops/quotas" class="back-link">&larr; Back to Dashboard</a>
<a [routerLink]="quotasOverviewPath" class="back-link">&larr; Back to Dashboard</a>
<h1>Throttle Events &amp; Rate Limits</h1>
<p class="subtitle">Recent 429 violations with context and recommendations</p>
</div>
@@ -611,7 +612,9 @@ import { RateLimitViolation, RateLimitStatus } from '../../core/api/quota.models
})
export class ThrottleContextComponent implements OnInit, OnDestroy {
private readonly quotaClient = inject(QuotaClient);
private readonly router = inject(Router);
private readonly destroy$ = new Subject<void>();
readonly quotasOverviewPath = quotasPath();
readonly loading = signal(false);
readonly violations = signal<RateLimitViolation[]>([]);
@@ -713,7 +716,7 @@ export class ThrottleContextComponent implements OnInit, OnDestroy {
}
viewTenantDetails(tenantId: string): void {
window.location.href = `/ops/quotas/tenants/${tenantId}`;
void this.router.navigateByUrl(quotaTenantPath(tenantId));
}
copyViolation(violation: RateLimitViolation): void {

View File

@@ -94,6 +94,16 @@ export const OPS_ROUTES: Routes = [
redirectTo: 'operations/health-slo',
pathMatch: 'full',
},
{
path: 'health-slo/services/:serviceName',
redirectTo: preserveOpsRedirect('/ops/operations/health-slo/services/:serviceName'),
pathMatch: 'full',
},
{
path: 'health-slo/incidents',
redirectTo: 'operations/health-slo/incidents',
pathMatch: 'full',
},
{
path: 'signals',
redirectTo: 'operations/signals',
@@ -119,6 +129,26 @@ export const OPS_ROUTES: Routes = [
redirectTo: 'operations/quotas',
pathMatch: 'full',
},
{
path: 'quotas/tenants/:tenantId',
redirectTo: preserveOpsRedirect('/ops/operations/quotas/tenants/:tenantId'),
pathMatch: 'full',
},
{
path: 'quotas/:page',
redirectTo: preserveOpsRedirect('/ops/operations/quotas/:page'),
pathMatch: 'full',
},
{
path: 'aoc',
redirectTo: 'operations/aoc',
pathMatch: 'full',
},
{
path: 'aoc/:page',
redirectTo: preserveOpsRedirect('/ops/operations/aoc/:page'),
pathMatch: 'full',
},
{
path: 'packs',
redirectTo: 'operations/packs',

View File

@@ -41,9 +41,20 @@ const LEGACY_PLATFORM_OPS_REDIRECTS: readonly LegacyPlatformOpsRedirect[] = [
{ path: 'offline-kit/:page', redirectTo: `${OPERATIONS_PATHS.offlineKit}/:page` },
{ path: 'health', redirectTo: OPERATIONS_PATHS.healthSlo },
{ path: 'health-slo', redirectTo: OPERATIONS_PATHS.healthSlo },
{
path: 'health-slo/services/:serviceName',
redirectTo: `${OPERATIONS_PATHS.healthSlo}/services/:serviceName`,
},
{
path: 'health-slo/incidents',
redirectTo: `${OPERATIONS_PATHS.healthSlo}/incidents`,
},
{ path: 'doctor', redirectTo: OPERATIONS_PATHS.doctor },
{ path: 'quotas', redirectTo: OPERATIONS_PATHS.quotas },
{ path: 'quotas/tenants/:tenantId', redirectTo: `${OPERATIONS_PATHS.quotas}/tenants/:tenantId` },
{ path: 'quotas/:page', redirectTo: `${OPERATIONS_PATHS.quotas}/:page` },
{ path: 'aoc', redirectTo: OPERATIONS_PATHS.aoc },
{ path: 'aoc/:page', redirectTo: `${OPERATIONS_PATHS.aoc}/:page` },
{ path: 'signals', redirectTo: OPERATIONS_PATHS.signals },
{ path: 'packs', redirectTo: OPERATIONS_PATHS.packs },
{ path: 'notifications', redirectTo: OPERATIONS_PATHS.notifications },

View File

@@ -0,0 +1,55 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { of } from 'rxjs';
import { AocClient } from '../../app/core/api/aoc.client';
import { GuardViolationsListComponent } from '../../app/features/aoc-compliance/guard-violations-list.component';
class AocClientStub {
readonly getGuardViolations = jasmine.createSpy('getGuardViolations').and.returnValue(of({
items: [
{
id: 'viol-1',
timestamp: '2026-03-08T10:00:00Z',
source: 'nvd',
reason: 'duplicate',
message: 'Already ingested',
module: 'concelier',
canRetry: true,
},
],
totalCount: 1,
pageSize: 20,
hasMore: false,
} as any));
readonly retryIngestion = jasmine.createSpy('retryIngestion').and.returnValue(of({ success: true }));
}
describe('GuardViolationsListComponent', () => {
it('applies route-backed reason and module filters to the AOC request', async () => {
const aocClient = new AocClientStub();
await TestBed.configureTestingModule({
imports: [GuardViolationsListComponent],
providers: [
provideRouter([{ path: '', component: GuardViolationsListComponent }]),
{ provide: AocClient, useValue: aocClient },
],
}).compileComponents();
const harness = await RouterTestingHarness.create();
const component = await harness.navigateByUrl('/?reason=duplicate&module=concelier', GuardViolationsListComponent);
expect(component.reasonFilter).toBe('duplicate');
expect(component.moduleFilter).toBe('concelier');
expect(aocClient.getGuardViolations).toHaveBeenCalledWith(
1,
20,
jasmine.objectContaining({
modules: ['concelier'],
violationReasons: ['duplicate'],
}),
);
});
});

View File

@@ -34,11 +34,17 @@ describe('Platform and Operations route contracts', () => {
'feeds-airgap',
'airgap',
'health-slo',
'health-slo/services/:serviceName',
'health-slo/incidents',
'signals',
'scheduler',
'offline-kit',
'offline-kit/:page',
'quotas',
'quotas/tenants/:tenantId',
'quotas/:page',
'aoc',
'aoc/:page',
'packs',
]);
});
@@ -94,9 +100,14 @@ describe('Platform and Operations route contracts', () => {
'offline-kit/:page',
'health',
'health-slo',
'health-slo/services/:serviceName',
'health-slo/incidents',
'doctor',
'quotas',
'quotas/tenants/:tenantId',
'quotas/:page',
'aoc',
'aoc/:page',
'signals',
'packs',
'notifications',

View File

@@ -0,0 +1,152 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { of } from 'rxjs';
import { QuotaClient } from '../../app/core/api/quota.client';
import { QuotaAlertConfigComponent } from '../../app/features/quota-dashboard/quota-alert-config.component';
import { QuotaDashboardComponent } from '../../app/features/quota-dashboard/quota-dashboard.component';
import { QuotaForecastComponent } from '../../app/features/quota-dashboard/quota-forecast.component';
import { quotasPath } from '../../app/features/platform/ops/operations-paths';
class QuotaClientStub {
readonly getDashboardSummary = jasmine.createSpy('getDashboardSummary').and.returnValue(of({
entitlement: {
planId: 'plan-pro',
planName: 'Pro',
features: ['Quota Dashboard'],
limits: {
artifacts: 10000,
users: 250,
scansPerDay: 1000,
storageMb: 102400,
concurrentJobs: 50,
apiRequestsPerMinute: 5000,
},
validFrom: '2026-01-01T00:00:00Z',
validTo: '2026-12-31T23:59:59Z',
},
consumption: [],
tenantCount: 4,
activeAlerts: 0,
recentViolations: 0,
} as any));
readonly getConsumptionHistory = jasmine.createSpy('getConsumptionHistory').and.returnValue(of({
points: [],
} as any));
readonly getQuotaForecast = jasmine.createSpy('getQuotaForecast').and.returnValue(of([]));
readonly getTenantQuotas = jasmine.createSpy('getTenantQuotas').and.returnValue(of({ items: [], total: 0 }));
readonly getRateLimitViolations = jasmine.createSpy('getRateLimitViolations').and.returnValue(of({
items: [],
total: 0,
period: { start: '2026-03-01T00:00:00Z', end: '2026-03-02T00:00:00Z' },
} as any));
readonly getAlertConfig = jasmine.createSpy('getAlertConfig').and.returnValue(of({
thresholds: [
{ category: 'license', enabled: true, warningThreshold: 80, criticalThreshold: 95 },
{ category: 'jobs', enabled: true, warningThreshold: 70, criticalThreshold: 90 },
{ category: 'api', enabled: true, warningThreshold: 85, criticalThreshold: 95 },
{ category: 'storage', enabled: false, warningThreshold: 70, criticalThreshold: 85 },
{ category: 'scans', enabled: true, warningThreshold: 80, criticalThreshold: 90 },
],
channels: [
{ type: 'email', enabled: true, target: 'ops@example.com', events: ['warning', 'critical'] },
],
escalationMinutes: 30,
} as any));
readonly saveAlertConfig = jasmine.createSpy('saveAlertConfig').and.callFake((config) => of(config));
}
describe('Quota operations cutover', () => {
it('applies dashboard category query state to history and forecast loading', async () => {
const quotaClient = new QuotaClientStub();
await TestBed.configureTestingModule({
imports: [QuotaDashboardComponent],
providers: [
provideRouter([{ path: '', component: QuotaDashboardComponent }]),
{ provide: QuotaClient, useValue: quotaClient },
],
}).compileComponents();
const harness = await RouterTestingHarness.create();
const component = await harness.navigateByUrl('/?category=api', QuotaDashboardComponent);
expect(component.selectedCategories()).toEqual(['api']);
expect(quotaClient.getConsumptionHistory).toHaveBeenCalledWith(undefined, undefined, ['api']);
expect(quotaClient.getQuotaForecast).toHaveBeenCalledWith('api');
});
it('routes forecast actions into canonical quota pages', async () => {
const quotaClient = new QuotaClientStub();
await TestBed.configureTestingModule({
imports: [QuotaForecastComponent],
providers: [
provideRouter([{ path: '', component: QuotaForecastComponent }]),
{ provide: QuotaClient, useValue: quotaClient },
],
}).compileComponents();
const harness = await RouterTestingHarness.create();
const component = await harness.navigateByUrl('/?category=jobs&tenantId=tenant-acme', QuotaForecastComponent);
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.takeAction({
category: 'jobs',
exhaustionDays: 3,
confidence: 0.95,
trendSlope: 0.12,
recommendation: 'Upgrade capacity.',
severity: 'critical',
});
expect(navigateSpy).toHaveBeenCalledWith(
[quotasPath('reports')],
{
queryParams: {
action: 'capacity-plan',
category: 'jobs',
tenantId: 'tenant-acme',
},
},
);
});
it('focuses alert config from query state and downloads a deterministic test payload', async () => {
const quotaClient = new QuotaClientStub();
await TestBed.configureTestingModule({
imports: [QuotaAlertConfigComponent],
providers: [
provideRouter([{ path: '', component: QuotaAlertConfigComponent }]),
{ provide: QuotaClient, useValue: quotaClient },
],
}).compileComponents();
const harness = await RouterTestingHarness.create();
const component = await harness.navigateByUrl('/?category=api', QuotaAlertConfigComponent);
const realCreateElement = document.createElement.bind(document);
const anchor = realCreateElement('a');
const clickSpy = spyOn(anchor, 'click').and.stub();
spyOn(URL, 'createObjectURL').and.returnValue('blob:quota-alert');
spyOn(URL, 'revokeObjectURL').and.stub();
spyOn(document, 'createElement').and.callFake((tagName: string) => {
if (tagName.toLowerCase() === 'a') {
return anchor;
}
return realCreateElement(tagName);
});
expect(component.focusedCategory()).toBe('api');
component.sendTestAlert();
expect(component.testingSent()).toBeTrue();
expect(clickSpy).toHaveBeenCalled();
expect(anchor.download).toBe('quota-alert-test-warning.json');
});
});

View File

@@ -0,0 +1,214 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
const adminSession: StubAuthSession = {
subjectId: 'ops-cutover-user',
tenant: 'tenant-default',
scopes: [
'admin',
'ui.read',
'ui.admin',
'orch:read',
'orch:operate',
'health:read',
'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);
}
test.beforeEach(async ({ page }) => {
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: 'ops-cutover',
displayName: 'Ops Cutover',
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-08T10:00:00Z',
updatedBy: adminSession.subjectId,
}),
);
await page.route('**/api/v1/authority/quotas/alerts', (route) =>
fulfillJson(route, {
thresholds: [
{ category: 'license', enabled: true, warningThreshold: 80, criticalThreshold: 95 },
{ category: 'jobs', enabled: true, warningThreshold: 70, criticalThreshold: 90 },
{ category: 'api', enabled: true, warningThreshold: 85, criticalThreshold: 95 },
],
channels: [{ type: 'email', enabled: true, target: 'ops@example.com', events: ['warning', 'critical'] }],
escalationMinutes: 30,
}),
);
await page.route('**/api/v1/platform/health/services/scanner', (route) =>
fulfillJson(route, {
service: {
name: 'scanner',
displayName: 'Scanner',
state: 'healthy',
uptime: 99.98,
latencyP50Ms: 12,
latencyP95Ms: 45,
latencyP99Ms: 91,
errorRate: 0.12,
checks: [{ name: 'db', status: 'pass', lastChecked: '2026-03-08T10:00:00Z' }],
lastUpdated: '2026-03-08T10:00:00Z',
version: '1.4.2',
dependencies: ['authority'],
},
dependencyStatus: [],
metricHistory: [],
recentErrors: [],
}),
);
await page.route('**/api/v1/platform/health/services/scanner/alerts/config', (route) =>
fulfillJson(route, {
degradedThreshold: { errorRatePercent: 1, latencyP95Ms: 200 },
unhealthyThreshold: { errorRatePercent: 5, latencyP95Ms: 500 },
notificationChannels: ['email'],
enabled: true,
}),
);
await page.route('**/gateway/api/v1/aoc/provenance/validate', async (route) => {
const request = route.request();
const body = request.postDataJSON() as { inputType: string; inputValue: string };
await fulfillJson(route, {
inputValue: body.inputValue,
inputType: body.inputType,
isComplete: true,
validatedAt: '2026-03-08T10:05:00Z',
validationErrors: [],
steps: [
{
stepType: 'ingestion',
status: 'valid',
timestamp: '2026-03-08T10:00:00Z',
label: `Validated ${body.inputValue}`,
hash: 'sha256:1234567890abcdef',
},
],
});
});
});
test('old quota alert deep links land on canonical operations route', async ({ page }) => {
await page.goto('/ops/operations', { waitUntil: 'networkidle' });
await navigateClientSide(page, '/ops/quotas/alerts?category=api');
await expect(page).toHaveURL(/\/ops\/operations\/quotas\/alerts\?category=api(?:&.*)?$/);
await expect(page.getByRole('heading', { name: 'Quota Alert Configuration' })).toBeVisible();
await expect(page.getByText('API Rate Limit')).toBeVisible();
});
test('legacy platform health detail bookmarks land on canonical health route', async ({ page }) => {
await page.goto('/ops/operations', { waitUntil: 'networkidle' });
await navigateClientSide(page, '/platform/ops/health-slo/services/scanner');
await expect(page).toHaveURL(/\/ops\/operations\/health-slo\/services\/scanner(?:\?.*)?$/);
await expect(page.getByRole('heading', { name: 'Scanner' })).toBeVisible();
});
test('legacy AOC provenance links land on canonical route and keep validation input', async ({ page }) => {
await page.goto('/ops/operations', { waitUntil: 'networkidle' });
await navigateClientSide(page, '/ops/aoc/provenance?type=cve_id&value=CVE-2026-0001');
await expect(page).toHaveURL(/\/ops\/operations\/aoc\/provenance\?type=cve_id&value=CVE-2026-0001(?:&.*)?$/);
await expect(page.getByRole('heading', { name: 'Provenance Chain Validator' })).toBeVisible();
await expect(page.getByText('Complete Chain')).toBeVisible();
await expect(page.getByText('Validated CVE-2026-0001')).toBeVisible();
});