feat(ui): ship quota health aoc operations cutover

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
// Sprint: SPRINT_20251229_029_FE - Operator Quota Dashboard
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
import { Subject, skip, takeUntil } from 'rxjs';
import { QuotaClient } from '../../core/api/quota.client';
import { QuotaForecast, QuotaCategory } from '../../core/api/quota.models';
import { quotasPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-quota-forecast',
@@ -14,7 +15,7 @@ import { QuotaForecast, QuotaCategory } from '../../core/api/quota.models';
<div class="forecast-page">
<header class="page-header">
<div class="header-content">
<a routerLink="/ops/quotas" class="back-link">&larr; Back to Dashboard</a>
<a [routerLink]="quotasOverviewPath" class="back-link">&larr; Back to Dashboard</a>
<h1>Quota Forecast</h1>
<p class="subtitle">Predictive quota exhaustion analysis and recommendations</p>
</div>
@@ -594,7 +595,10 @@ import { QuotaForecast, QuotaCategory } from '../../core/api/quota.models';
})
export class QuotaForecastComponent implements OnInit, OnDestroy {
private readonly quotaClient = inject(QuotaClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroy$ = new Subject<void>();
readonly quotasOverviewPath = quotasPath();
readonly loading = signal(false);
readonly forecasts = signal<QuotaForecast[]>([]);
@@ -615,7 +619,18 @@ export class QuotaForecastComponent implements OnInit, OnDestroy {
this.forecasts().filter((f) => f.exhaustionDays === null);
ngOnInit(): void {
this.loadData();
this.applyQueryParams(
this.route.snapshot.queryParamMap.get('category'),
this.route.snapshot.queryParamMap.get('tenantId'),
);
this.loadData(false);
this.route.queryParamMap
.pipe(skip(1), takeUntil(this.destroy$))
.subscribe((params) => {
this.applyQueryParams(params.get('category'), params.get('tenantId'));
this.loadData(false);
});
}
ngOnDestroy(): void {
@@ -624,10 +639,14 @@ export class QuotaForecastComponent implements OnInit, OnDestroy {
}
refreshData(): void {
this.loadData();
this.loadData(false);
}
loadData(): 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';
}
}

View File

@@ -1,11 +1,12 @@
// Sprint: SPRINT_20251229_029_FE - Operator Quota Dashboard
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil, interval, switchMap, filter } from 'rxjs';
import { Subject, takeUntil, interval, switchMap, filter, skip } from 'rxjs';
import { QuotaClient } from '../../core/api/quota.client';
import { QuotaReportRequest, QuotaReportResponse, QuotaCategory } from '../../core/api/quota.models';
import { quotasPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-quota-report-export',
@@ -14,7 +15,7 @@ import { QuotaReportRequest, QuotaReportResponse, QuotaCategory } from '../../co
<div class="report-page">
<header class="page-header">
<div class="header-content">
<a routerLink="/ops/quotas" class="back-link">&larr; Back to Dashboard</a>
<a [routerLink]="quotasOverviewPath" class="back-link">&larr; Back to Dashboard</a>
<h1>Quota Report Export</h1>
<p class="subtitle">Generate comprehensive quota reports for capacity planning</p>
</div>
@@ -669,8 +670,11 @@ import { QuotaReportRequest, QuotaReportResponse, QuotaCategory } from '../../co
`]
})
export class QuotaReportExportComponent implements OnInit, OnDestroy {
private static readonly REPORT_HISTORY_STORAGE_KEY = 'stellaops.quota-report-history';
private readonly quotaClient = inject(QuotaClient);
private readonly route = inject(ActivatedRoute);
private readonly destroy$ = new Subject<void>();
readonly quotasOverviewPath = quotasPath();
readonly generating = signal(false);
readonly activeReport = signal<QuotaReportResponse | null>(null);
@@ -701,6 +705,13 @@ export class QuotaReportExportComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.setDateRange(30);
this.loadReportHistory();
this.applyQueryParams(
this.route.snapshot.queryParamMap.get('category'),
this.route.snapshot.queryParamMap.get('tenantId'),
);
this.route.queryParamMap
.pipe(skip(1), takeUntil(this.destroy$))
.subscribe((params) => this.applyQueryParams(params.get('category'), params.get('tenantId')));
}
ngOnDestroy(): void {
@@ -743,6 +754,7 @@ export class QuotaReportExportComponent implements OnInit, OnDestroy {
.subscribe({
next: (response) => {
this.activeReport.set(response);
this.upsertHistory(response);
this.generating.set(false);
// Poll for completion if processing
@@ -766,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';
}
}

View File

@@ -1,10 +1,11 @@
// Sprint: SPRINT_20251229_029_FE - Operator Quota Dashboard
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subject, takeUntil, switchMap, of } from 'rxjs';
import { QuotaClient } from '../../core/api/quota.client';
import { TenantQuotaBreakdown, QuotaForecast } from '../../core/api/quota.models';
import { TenantQuotaBreakdown } from '../../core/api/quota.models';
import { quotasPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-tenant-quota-detail',
@@ -12,7 +13,7 @@ import { TenantQuotaBreakdown, QuotaForecast } from '../../core/api/quota.models
template: `
<div class="tenant-detail-page">
<header class="page-header">
<a routerLink="/ops/quotas/tenants" class="back-link">&larr; Back to Tenant List</a>
<a [routerLink]="quotaTenantsPath" class="back-link">&larr; Back to Tenant List</a>
@if (breakdown()) {
<h1>{{ breakdown()?.tenantName }}</h1>
}
@@ -152,10 +153,10 @@ import { TenantQuotaBreakdown, QuotaForecast } from '../../core/api/quota.models
<span class="icon">download</span>
Export Report
</button>
<button class="btn btn-primary" (click)="contactSupport()">
<a class="btn btn-primary" [href]="supportMailto()">
<span class="icon">help</span>
Contact Support
</button>
</a>
</section>
</div>
}
@@ -163,7 +164,7 @@ import { TenantQuotaBreakdown, QuotaForecast } from '../../core/api/quota.models
@if (!loading() && !breakdown()) {
<div class="empty-state">
<p>Tenant not found</p>
<a routerLink="/ops/quotas/tenants" class="link">Return to tenant list</a>
<a [routerLink]="quotaTenantsPath" class="link">Return to tenant list</a>
</div>
}
</div>
@@ -461,7 +462,9 @@ import { TenantQuotaBreakdown, QuotaForecast } from '../../core/api/quota.models
export class TenantQuotaDetailComponent implements OnInit, OnDestroy {
private readonly quotaClient = inject(QuotaClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroy$ = new Subject<void>();
readonly quotaTenantsPath = quotasPath('tenants');
readonly loading = signal(false);
readonly breakdown = signal<TenantQuotaBreakdown | null>(null);
@@ -555,16 +558,63 @@ export class TenantQuotaDetailComponent implements OnInit, OnDestroy {
viewAuditLog(): void {
const tenantId = this.breakdown()?.tenantId;
if (tenantId) {
window.location.href = `/evidence/audit-log?tenantId=${encodeURIComponent(tenantId)}`;
void this.router.navigate(['/evidence/audit-log'], {
queryParams: { tenantId },
});
}
}
exportReport(): void {
// Trigger report export via API
console.log('Export report for tenant:', this.breakdown()?.tenantId);
const breakdown = this.breakdown();
if (!breakdown) {
return;
}
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}`)}`;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,214 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
const adminSession: StubAuthSession = {
subjectId: 'ops-cutover-user',
tenant: 'tenant-default',
scopes: [
'admin',
'ui.read',
'ui.admin',
'orch:read',
'orch:operate',
'health:read',
'policy:read',
],
};
const mockConfig = {
authority: {
issuer: '/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: '/authority/connect/authorize',
tokenEndpoint: '/authority/connect/token',
logoutEndpoint: '/authority/connect/logout',
redirectUri: 'https://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
scope: 'openid profile email ui.read',
audience: '/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
async function fulfillJson(route: Route, body: unknown): Promise<void> {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
}
async function navigateClientSide(page: Page, target: string): Promise<void> {
await page.evaluate((url) => {
window.history.pushState({}, '', url);
window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state }));
}, target);
}
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, adminSession);
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/.well-known/openid-configuration', (route) =>
fulfillJson(route, {
issuer: 'https://127.0.0.1:4400/authority',
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
}),
);
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
await page.route('**/console/profile**', (route) =>
fulfillJson(route, {
subjectId: adminSession.subjectId,
username: 'ops-cutover',
displayName: 'Ops Cutover',
tenant: adminSession.tenant,
roles: ['admin'],
scopes: adminSession.scopes,
}),
);
await page.route('**/console/token/introspect**', (route) =>
fulfillJson(route, {
active: true,
tenant: adminSession.tenant,
subject: adminSession.subjectId,
scopes: adminSession.scopes,
}),
);
await page.route('**/api/v2/context/regions', (route) =>
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]),
);
await page.route('**/api/v2/context/environments**', (route) =>
fulfillJson(route, [
{
environmentId: 'prod',
regionId: 'eu-west',
environmentType: 'prod',
displayName: 'Prod',
sortOrder: 1,
enabled: true,
},
]),
);
await page.route('**/api/v2/context/preferences', (route) =>
fulfillJson(route, {
tenantId: adminSession.tenant,
actorId: adminSession.subjectId,
regions: ['eu-west'],
environments: ['prod'],
timeWindow: '24h',
stage: 'all',
updatedAt: '2026-03-08T10:00:00Z',
updatedBy: adminSession.subjectId,
}),
);
await page.route('**/api/v1/authority/quotas/alerts', (route) =>
fulfillJson(route, {
thresholds: [
{ category: 'license', enabled: true, warningThreshold: 80, criticalThreshold: 95 },
{ category: 'jobs', enabled: true, warningThreshold: 70, criticalThreshold: 90 },
{ category: 'api', enabled: true, warningThreshold: 85, criticalThreshold: 95 },
],
channels: [{ type: 'email', enabled: true, target: 'ops@example.com', events: ['warning', 'critical'] }],
escalationMinutes: 30,
}),
);
await page.route('**/api/v1/platform/health/services/scanner', (route) =>
fulfillJson(route, {
service: {
name: 'scanner',
displayName: 'Scanner',
state: 'healthy',
uptime: 99.98,
latencyP50Ms: 12,
latencyP95Ms: 45,
latencyP99Ms: 91,
errorRate: 0.12,
checks: [{ name: 'db', status: 'pass', lastChecked: '2026-03-08T10:00:00Z' }],
lastUpdated: '2026-03-08T10:00:00Z',
version: '1.4.2',
dependencies: ['authority'],
},
dependencyStatus: [],
metricHistory: [],
recentErrors: [],
}),
);
await page.route('**/api/v1/platform/health/services/scanner/alerts/config', (route) =>
fulfillJson(route, {
degradedThreshold: { errorRatePercent: 1, latencyP95Ms: 200 },
unhealthyThreshold: { errorRatePercent: 5, latencyP95Ms: 500 },
notificationChannels: ['email'],
enabled: true,
}),
);
await page.route('**/gateway/api/v1/aoc/provenance/validate', async (route) => {
const request = route.request();
const body = request.postDataJSON() as { inputType: string; inputValue: string };
await fulfillJson(route, {
inputValue: body.inputValue,
inputType: body.inputType,
isComplete: true,
validatedAt: '2026-03-08T10:05:00Z',
validationErrors: [],
steps: [
{
stepType: 'ingestion',
status: 'valid',
timestamp: '2026-03-08T10:00:00Z',
label: `Validated ${body.inputValue}`,
hash: 'sha256:1234567890abcdef',
},
],
});
});
});
test('old quota alert deep links land on canonical operations route', async ({ page }) => {
await page.goto('/ops/operations', { waitUntil: 'networkidle' });
await navigateClientSide(page, '/ops/quotas/alerts?category=api');
await expect(page).toHaveURL(/\/ops\/operations\/quotas\/alerts\?category=api(?:&.*)?$/);
await expect(page.getByRole('heading', { name: 'Quota Alert Configuration' })).toBeVisible();
await expect(page.getByText('API Rate Limit')).toBeVisible();
});
test('legacy platform health detail bookmarks land on canonical health route', async ({ page }) => {
await page.goto('/ops/operations', { waitUntil: 'networkidle' });
await navigateClientSide(page, '/platform/ops/health-slo/services/scanner');
await expect(page).toHaveURL(/\/ops\/operations\/health-slo\/services\/scanner(?:\?.*)?$/);
await expect(page.getByRole('heading', { name: 'Scanner' })).toBeVisible();
});
test('legacy AOC provenance links land on canonical route and keep validation input', async ({ page }) => {
await page.goto('/ops/operations', { waitUntil: 'networkidle' });
await navigateClientSide(page, '/ops/aoc/provenance?type=cve_id&value=CVE-2026-0001');
await expect(page).toHaveURL(/\/ops\/operations\/aoc\/provenance\?type=cve_id&value=CVE-2026-0001(?:&.*)?$/);
await expect(page.getByRole('heading', { name: 'Provenance Chain Validator' })).toBeVisible();
await expect(page.getByText('Complete Chain')).toBeVisible();
await expect(page.getByText('Validated CVE-2026-0001')).toBeVisible();
});