feat(ui): ship quota health aoc operations cutover
This commit is contained in:
@@ -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.
|
||||
71
docs/features/checked/web/quota-health-aoc-operations-ui.md
Normal file
71
docs/features/checked/web/quota-health-aoc-operations-ui.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
65
docs/modules/ui/quota-health-aoc-operations/README.md
Normal file
65
docs/modules/ui/quota-health-aoc-operations/README.md
Normal 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`
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') || []);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">← Back to Dashboard</a>
|
||||
<a [routerLink]="quotasOverviewPath" class="back-link">← 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">← Back to Dashboard</a>
|
||||
<a [routerLink]="quotasOverviewPath" class="back-link">← 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(): void {
|
||||
loadData(syncRoute = true): void {
|
||||
if (syncRoute) {
|
||||
this.syncQueryParams();
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">← Back to Dashboard</a>
|
||||
<a [routerLink]="quotasOverviewPath" class="back-link">← 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,17 +778,18 @@ 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
|
||||
this.reportHistory.set([]);
|
||||
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 {
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">← Back to Tenant List</a>
|
||||
<a [routerLink]="quotaTenantsPath" class="back-link">← 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
contactSupport(): void {
|
||||
window.location.href = 'mailto:support@stellaops.io?subject=Quota%20Inquiry';
|
||||
supportMailto(): string {
|
||||
const tenantId = this.breakdown()?.tenantId ?? 'unknown-tenant';
|
||||
return `mailto:support@stellaops.io?subject=${encodeURIComponent(`Quota inquiry for ${tenantId}`)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">← Back to Dashboard</a>
|
||||
<a [routerLink]="quotasOverviewPath" class="back-link">← 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: () => {
|
||||
|
||||
@@ -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">← Back to Dashboard</a>
|
||||
<a [routerLink]="quotasOverviewPath" class="back-link">← Back to Dashboard</a>
|
||||
<h1>Throttle Events & 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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user