ui progressing

This commit is contained in:
master
2026-02-20 23:32:20 +02:00
parent ca5e7888d6
commit 1ec797d5e8
191 changed files with 32771 additions and 6504 deletions

View File

@@ -40,6 +40,7 @@ import { AuthSessionStore } from './core/auth/auth-session.store';
import { TenantActivationService } from './core/auth/tenant-activation.service';
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
import { TenantHttpInterceptor } from './core/auth/tenant-http.interceptor';
import { GlobalContextHttpInterceptor } from './core/context/global-context-http.interceptor';
import { seedAuthSession, type StubAuthSession } from './testing';
import { CVSS_API_BASE_URL } from './core/api/cvss.client';
import { AUTH_SERVICE } from './core/auth';
@@ -124,43 +125,35 @@ import {
RELEASE_DASHBOARD_API,
RELEASE_DASHBOARD_API_BASE_URL,
ReleaseDashboardHttpClient,
MockReleaseDashboardClient,
} from './core/api/release-dashboard.client';
import {
RELEASE_ENVIRONMENT_API,
RELEASE_ENVIRONMENT_API_BASE_URL,
ReleaseEnvironmentHttpClient,
MockReleaseEnvironmentClient,
} from './core/api/release-environment.client';
import {
RELEASE_MANAGEMENT_API,
ReleaseManagementHttpClient,
MockReleaseManagementClient,
} from './core/api/release-management.client';
import {
WORKFLOW_API,
WorkflowHttpClient,
MockWorkflowClient,
} from './core/api/workflow.client';
import {
APPROVAL_API,
ApprovalHttpClient,
MockApprovalClient,
} from './core/api/approval.client';
import {
DEPLOYMENT_API,
DeploymentHttpClient,
MockDeploymentClient,
} from './core/api/deployment.client';
import {
RELEASE_EVIDENCE_API,
ReleaseEvidenceHttpClient,
MockReleaseEvidenceClient,
} from './core/api/release-evidence.client';
import {
DOCTOR_API,
HttpDoctorClient,
MockDoctorClient,
} from './features/doctor/services/doctor.client';
import {
WITNESS_API,
@@ -185,7 +178,6 @@ import {
import {
VULN_ANNOTATION_API,
HttpVulnAnnotationClient,
MockVulnAnnotationClient,
} from './core/api/vuln-annotation.client';
import {
AUTHORITY_ADMIN_API,
@@ -268,6 +260,11 @@ export const appConfig: ApplicationConfig = {
useClass: TenantHttpInterceptor,
multi: true,
},
{
provide: HTTP_INTERCEPTORS,
useClass: GlobalContextHttpInterceptor,
multi: true,
},
{
provide: CONCELIER_EXPORTER_API_BASE_URL,
useValue: '/api/v1/concelier/exporters/trivy-db',
@@ -630,7 +627,7 @@ export const appConfig: ApplicationConfig = {
provide: NOTIFY_API,
useExisting: NotifyApiHttpClient,
},
// Release Dashboard API (using mock - no backend endpoint yet)
// Release Dashboard API (runtime HTTP client)
{
provide: RELEASE_DASHBOARD_API_BASE_URL,
deps: [AppConfigService],
@@ -645,7 +642,6 @@ export const appConfig: ApplicationConfig = {
},
},
ReleaseDashboardHttpClient,
MockReleaseDashboardClient,
{
provide: RELEASE_DASHBOARD_API,
useExisting: ReleaseDashboardHttpClient,
@@ -665,49 +661,42 @@ export const appConfig: ApplicationConfig = {
},
},
ReleaseEnvironmentHttpClient,
MockReleaseEnvironmentClient,
{
provide: RELEASE_ENVIRONMENT_API,
useExisting: ReleaseEnvironmentHttpClient,
},
// Release Management API (Sprint 111_003 - using mock until backend is available)
// Release Management API (runtime HTTP client)
ReleaseManagementHttpClient,
MockReleaseManagementClient,
{
provide: RELEASE_MANAGEMENT_API,
useExisting: ReleaseManagementHttpClient,
},
// Workflow API (Sprint 111_004 - using mock until backend is available)
// Workflow API (runtime HTTP client)
WorkflowHttpClient,
MockWorkflowClient,
{
provide: WORKFLOW_API,
useExisting: WorkflowHttpClient,
},
// Approval API (using mock - no backend endpoint yet)
// Approval API (runtime HTTP client)
ApprovalHttpClient,
MockApprovalClient,
{
provide: APPROVAL_API,
useExisting: ApprovalHttpClient,
},
// Deployment API (Sprint 111_006 - using mock until backend is available)
// Deployment API (runtime HTTP client)
DeploymentHttpClient,
MockDeploymentClient,
{
provide: DEPLOYMENT_API,
useExisting: DeploymentHttpClient,
},
// Release Evidence API (Sprint 111_007 - using mock until backend is available)
// Release Evidence API (runtime HTTP client)
ReleaseEvidenceHttpClient,
MockReleaseEvidenceClient,
{
provide: RELEASE_EVIDENCE_API,
useExisting: ReleaseEvidenceHttpClient,
},
// Doctor API (HTTP paths corrected; using mock until gateway auth chain is configured)
// Doctor API (runtime HTTP client)
HttpDoctorClient,
MockDoctorClient,
{
provide: DOCTOR_API,
useExisting: HttpDoctorClient,
@@ -752,9 +741,8 @@ export const appConfig: ApplicationConfig = {
provide: TRUST_API,
useExisting: TrustHttpService,
},
// Vuln Annotation API (using mock until backend is available)
// Vuln Annotation API (runtime HTTP client)
HttpVulnAnnotationClient,
MockVulnAnnotationClient,
{
provide: VULN_ANNOTATION_API,
useExisting: HttpVulnAnnotationClient,

View File

@@ -52,58 +52,89 @@ export const routes: Routes = [
redirectTo: '/',
},
// Domain 2: Release Control
// Domain 2: Releases
{
path: 'release-control',
title: 'Release Control',
path: 'releases',
title: 'Releases',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Release Control' },
data: { breadcrumb: 'Releases' },
loadChildren: () =>
import('./routes/release-control.routes').then(
(m) => m.RELEASE_CONTROL_ROUTES
import('./routes/releases.routes').then(
(m) => m.RELEASES_ROUTES
),
},
// Domain 3: Security & Risk (formerly /security)
// Domain 3: Security
{
path: 'security-risk',
title: 'Security & Risk',
path: 'security',
title: 'Security',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Security & Risk' },
data: { breadcrumb: 'Security' },
loadChildren: () =>
import('./routes/security-risk.routes').then(
(m) => m.SECURITY_RISK_ROUTES
import('./routes/security.routes').then(
(m) => m.SECURITY_ROUTES
),
},
// Domain 4: Evidence and Audit (formerly /evidence)
// Domain 4: Evidence
{
path: 'evidence-audit',
title: 'Evidence & Audit',
path: 'evidence',
title: 'Evidence',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Evidence & Audit' },
data: { breadcrumb: 'Evidence' },
loadChildren: () =>
import('./routes/evidence-audit.routes').then(
(m) => m.EVIDENCE_AUDIT_ROUTES
import('./routes/evidence.routes').then(
(m) => m.EVIDENCE_ROUTES
),
},
// Domain 5: Integrations (already canonical — kept as-is)
// /integrations already loaded below; no path change for this domain.
// Domain 6: Platform Ops — canonical P0-P9 surface (SPRINT_20260218_008)
// Domain 6: Topology
{
path: 'platform-ops',
title: 'Platform Ops',
path: 'topology',
title: 'Topology',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Platform Ops' },
data: { breadcrumb: 'Topology' },
loadChildren: () =>
import('./routes/platform-ops.routes').then(
(m) => m.PLATFORM_OPS_ROUTES
import('./routes/topology.routes').then(
(m) => m.TOPOLOGY_ROUTES
),
},
// Domain 7: Administration (canonical A0-A7 surface — SPRINT_20260218_007)
// Domain 7: Platform
{
path: 'platform',
title: 'Platform',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Platform' },
loadChildren: () =>
import('./routes/platform.routes').then(
(m) => m.PLATFORM_ROUTES
),
},
// Domain 8: Administration (legacy root retained as alias to Platform Setup)
{
path: 'administration',
pathMatch: 'full',
redirectTo: '/platform/setup',
},
// Domain 9: Operations (legacy alias root retained for migration window)
{
path: 'operations',
title: 'Operations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Operations' },
loadChildren: () =>
import('./routes/operations.routes').then(
(m) => m.OPERATIONS_ROUTES
),
},
// Domain 10: Administration deep-link compatibility surface
{
path: 'administration',
title: 'Administration',
@@ -123,36 +154,36 @@ export const routes: Routes = [
// Convert to redirects and remove at SPRINT_20260218_016 after confirming traffic.
// ========================================================================
// Release Control domain aliases
// Releases domain aliases
{
path: 'approvals',
pathMatch: 'full',
redirectTo: '/release-control/approvals',
redirectTo: '/releases/approvals',
},
{
path: 'environments',
pathMatch: 'full',
redirectTo: '/release-control/regions',
redirectTo: '/topology/environments',
},
{
path: 'releases',
path: 'release-control',
pathMatch: 'full',
redirectTo: '/release-control/releases',
redirectTo: '/releases',
},
{
path: 'deployments',
pathMatch: 'full',
redirectTo: '/release-control/deployments',
redirectTo: '/releases/activity',
},
// Security & Risk domain alias
// Legacy Security alias
{
path: 'security',
path: 'security-risk',
pathMatch: 'full',
redirectTo: '/security-risk',
redirectTo: '/security',
},
// Analytics alias (served under security-risk in v2)
// Analytics alias (served under Security in v3)
{
path: 'analytics',
title: 'Analytics',
@@ -161,22 +192,22 @@ export const routes: Routes = [
import('./features/analytics/analytics.routes').then((m) => m.ANALYTICS_ROUTES),
},
// Evidence and Audit domain alias
// Legacy Evidence alias
{
path: 'evidence',
path: 'evidence-audit',
pathMatch: 'full',
redirectTo: '/evidence-audit',
redirectTo: '/evidence',
},
// Platform Ops domain alias
// Legacy Operations aliases
{
path: 'operations',
title: 'Platform Ops',
path: 'platform-ops',
title: 'Operations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Platform Ops' },
data: { breadcrumb: 'Operations' },
loadChildren: () =>
import('./routes/platform-ops.routes').then(
(m) => m.PLATFORM_OPS_ROUTES
import('./routes/operations.routes').then(
(m) => m.OPERATIONS_ROUTES
),
},
@@ -191,27 +222,27 @@ export const routes: Routes = [
{
path: 'settings/release-control',
pathMatch: 'full',
redirectTo: '/release-control/setup',
redirectTo: '/topology',
},
{
path: 'settings/release-control/environments',
pathMatch: 'full',
redirectTo: '/release-control/setup/environments-paths',
redirectTo: '/topology/environments',
},
{
path: 'settings/release-control/targets',
pathMatch: 'full',
redirectTo: '/release-control/setup/targets-agents',
redirectTo: '/topology/targets',
},
{
path: 'settings/release-control/agents',
pathMatch: 'full',
redirectTo: '/release-control/setup/targets-agents',
redirectTo: '/topology/agents',
},
{
path: 'settings/release-control/workflows',
pathMatch: 'full',
redirectTo: '/release-control/setup/workflows',
redirectTo: '/topology/workflows',
},
// Administration domain alias — settings

View File

@@ -4,7 +4,7 @@
*/
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay } from 'rxjs';
import { Observable, catchError, delay, map, of } from 'rxjs';
import type {
ApprovalRequest,
ApprovalDetail,
@@ -32,18 +32,33 @@ export interface ApprovalApi {
@Injectable()
export class ApprovalHttpClient implements ApprovalApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/release-orchestrator/approvals';
private readonly queueBaseUrl = '/api/v2/releases/approvals';
private readonly detailBaseUrl = '/api/v1/approvals';
private readonly legacyBaseUrl = '/api/release-orchestrator/approvals';
listApprovals(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
if (filter?.urgencies?.length || (filter?.statuses?.length ?? 0) > 1) {
return this.listApprovalsLegacy(filter);
}
const params: Record<string, string> = {};
if (filter?.statuses?.length) params['statuses'] = filter.statuses.join(',');
if (filter?.urgencies?.length) params['urgencies'] = filter.urgencies.join(',');
if (filter?.statuses?.length) params['status'] = filter.statuses[0];
if (filter?.environment) params['environment'] = filter.environment;
return this.http.get<ApprovalRequest[]>(this.baseUrl, { params });
return this.http.get<any>(this.queueBaseUrl, { params }).pipe(
map((rows) => {
const items = Array.isArray(rows) ? rows : (rows?.items ?? []);
return items.map((row: any) => this.mapV2ApprovalSummary(row));
}),
catchError(() => this.listApprovalsLegacy(filter))
);
}
getApproval(id: string): Observable<ApprovalDetail> {
return this.http.get<ApprovalDetail>(`${this.baseUrl}/${id}`);
return this.http.get<any>(`${this.detailBaseUrl}/${id}`).pipe(
map(row => this.mapV2ApprovalDetail(row)),
catchError(() => this.http.get<ApprovalDetail>(`${this.legacyBaseUrl}/${id}`))
);
}
getPromotionPreview(releaseId: string, targetEnvironmentId: string): Observable<PromotionPreview> {
@@ -67,19 +82,97 @@ export class ApprovalHttpClient implements ApprovalApi {
}
approve(id: string, comment: string): Observable<ApprovalDetail> {
return this.http.post<ApprovalDetail>(`${this.baseUrl}/${id}/approve`, { comment });
return this.http.post<any>(`${this.detailBaseUrl}/${id}/decision`, {
action: 'approve',
comment,
actor: 'ui-operator',
}).pipe(
map(row => this.mapV2ApprovalDetail(row)),
catchError(() => this.http.post<ApprovalDetail>(`${this.legacyBaseUrl}/${id}/approve`, { comment }))
);
}
reject(id: string, comment: string): Observable<ApprovalDetail> {
return this.http.post<ApprovalDetail>(`${this.baseUrl}/${id}/reject`, { comment });
return this.http.post<any>(`${this.detailBaseUrl}/${id}/decision`, {
action: 'reject',
comment,
actor: 'ui-operator',
}).pipe(
map(row => this.mapV2ApprovalDetail(row)),
catchError(() => this.http.post<ApprovalDetail>(`${this.legacyBaseUrl}/${id}/reject`, { comment }))
);
}
batchApprove(ids: string[], comment: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/batch-approve`, { ids, comment });
return this.http.post<void>(`${this.legacyBaseUrl}/batch-approve`, { ids, comment });
}
batchReject(ids: string[], comment: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/batch-reject`, { ids, comment });
return this.http.post<void>(`${this.legacyBaseUrl}/batch-reject`, { ids, comment });
}
private listApprovalsLegacy(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
const params: Record<string, string> = {};
if (filter?.statuses?.length) params['statuses'] = filter.statuses.join(',');
if (filter?.urgencies?.length) params['urgencies'] = filter.urgencies.join(',');
if (filter?.environment) params['environment'] = filter.environment;
return this.http.get<ApprovalRequest[]>(this.legacyBaseUrl, { params });
}
private mapV2ApprovalSummary(row: any): ApprovalRequest {
return {
id: row.approvalId ?? row.id,
releaseId: row.releaseId,
releaseName: row.releaseName,
releaseVersion: row.releaseVersion ?? row.releaseName,
sourceEnvironment: row.sourceEnvironment,
targetEnvironment: row.targetEnvironment,
requestedBy: row.requestedBy,
requestedAt: row.requestedAt,
urgency: row.urgency ?? 'normal',
justification: row.justification ?? '',
status: row.status ?? 'pending',
currentApprovals: row.currentApprovals ?? 0,
requiredApprovals: row.requiredApprovals ?? 0,
gatesPassed: row.gatesPassed ?? ((row.blockers?.length ?? 0) === 0),
scheduledTime: row.scheduledTime ?? null,
expiresAt: row.expiresAt ?? row.requestedAt ?? '',
};
}
private mapV2ApprovalDetail(row: any): ApprovalDetail {
return {
...this.mapV2ApprovalSummary(row),
gateResults: (row.gateResults ?? []).map((gate: any) => ({
gateId: gate.gateId,
gateName: gate.gateName,
type: gate.type,
status: gate.status,
message: gate.message,
details: gate.details ?? {},
evaluatedAt: gate.evaluatedAt ?? '',
})),
actions: (row.actions ?? []).map((action: any) => ({
id: action.id,
approvalId: action.approvalId,
action: action.action,
actor: action.actor,
comment: action.comment,
timestamp: action.timestamp,
})),
approvers: (row.approvers ?? []).map((approver: any) => ({
id: approver.id,
name: approver.name,
email: approver.email,
hasApproved: approver.hasApproved,
approvedAt: approver.approvedAt ?? null,
})),
releaseComponents: (row.releaseComponents ?? []).map((component: any) => ({
name: component.name,
version: component.version,
digest: component.digest,
})),
};
}
}

View File

@@ -3,9 +3,28 @@
* Sprint: SPRINT_20260110_111_003_FE_release_management_ui
*/
export type ReleaseWorkflowStatus = 'draft' | 'ready' | 'deploying' | 'deployed' | 'failed' | 'rolled_back';
export type ReleaseWorkflowStatus =
| 'draft'
| 'ready'
| 'deploying'
| 'deployed'
| 'failed'
| 'rolled_back';
export type ReleaseType = 'standard' | 'hotfix';
export type ReleaseGateStatus = 'pass' | 'warn' | 'block' | 'pending' | 'unknown';
export type ReleaseRiskTier = 'critical' | 'high' | 'medium' | 'low' | 'none' | 'unknown';
export type ReleaseEvidencePosture = 'verified' | 'partial' | 'missing' | 'replay_mismatch' | 'unknown';
export type ComponentType = 'container' | 'helm' | 'script';
export type ReleaseEventType = 'created' | 'promoted' | 'approved' | 'rejected' | 'deployed' | 'failed' | 'rolled_back';
export type ReleaseEventType =
| 'created'
| 'promoted'
| 'approved'
| 'rejected'
| 'deployed'
| 'failed'
| 'rolled_back';
export type DeploymentStrategy = 'rolling' | 'blue_green' | 'canary' | 'recreate';
export interface ManagedRelease {
@@ -14,12 +33,31 @@ export interface ManagedRelease {
version: string;
description: string;
status: ReleaseWorkflowStatus;
releaseType: ReleaseType | string;
slug: string;
digest: string | null;
currentStage: string | null;
currentEnvironment: string | null;
targetEnvironment: string | null;
targetRegion: string | null;
componentCount: number;
gateStatus: ReleaseGateStatus;
gateBlockingCount: number;
gatePendingApprovals: number;
gateBlockingReasons: string[];
riskCriticalReachable: number;
riskHighReachable: number;
riskTrend: string;
riskTier: ReleaseRiskTier;
evidencePosture: ReleaseEvidencePosture;
needsApproval: boolean;
blocked: boolean;
hotfixLane: boolean;
replayMismatch: boolean;
createdAt: string;
createdBy: string;
updatedAt: string;
lastActor: string;
deployedAt: string | null;
deploymentStrategy: DeploymentStrategy;
}
@@ -84,7 +122,16 @@ export interface AddComponentRequest {
export interface ReleaseFilter {
search?: string;
statuses?: ReleaseWorkflowStatus[];
stages?: string[];
types?: string[];
gateStatuses?: ReleaseGateStatus[];
riskTiers?: ReleaseRiskTier[];
blocked?: boolean;
needsApproval?: boolean;
hotfixLane?: boolean;
replayMismatch?: boolean;
environment?: string;
region?: string;
sortField?: string;
sortOrder?: 'asc' | 'desc';
page?: number;
@@ -123,6 +170,43 @@ export function getStatusColor(status: ReleaseWorkflowStatus): string {
return colors[status] || 'var(--color-text-secondary)';
}
export function getGateStatusLabel(status: ReleaseGateStatus): string {
const labels: Record<ReleaseGateStatus, string> = {
pass: 'Pass',
warn: 'Warn',
block: 'Block',
pending: 'Pending',
unknown: 'Unknown',
};
return labels[status] ?? 'Unknown';
}
export function getRiskTierLabel(tier: ReleaseRiskTier): string {
const labels: Record<ReleaseRiskTier, string> = {
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
none: 'None',
unknown: 'Unknown',
};
return labels[tier] ?? 'Unknown';
}
export function getEvidencePostureLabel(posture: ReleaseEvidencePosture): string {
const labels: Record<ReleaseEvidencePosture, string> = {
verified: 'Verified',
partial: 'Partial',
missing: 'Missing',
replay_mismatch: 'Replay Mismatch',
unknown: 'Unknown',
};
return labels[posture] ?? 'Unknown';
}
export function getEventIcon(type: ReleaseEventType): string {
const icons: Record<ReleaseEventType, string> = {
created: '+',

View File

@@ -5,8 +5,9 @@
import { Injectable, InjectionToken, Inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { delay, map } from 'rxjs/operators';
import { catchError, delay, map } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { PlatformContextStore } from '../context/platform-context.store';
// ============================================================================
// Models
@@ -59,12 +60,34 @@ export const SECURITY_FINDINGS_API_BASE_URL = new InjectionToken<string>('SECURI
// HTTP Implementation
// ============================================================================
interface SecurityFindingProjectionDto {
findingId: string;
cveId: string;
severity: string;
packageName: string;
componentName: string;
releaseId: string;
releaseName: string;
environment: string;
region: string;
reachable: boolean;
reachabilityScore: number;
effectiveDisposition: string;
vexStatus: string;
updatedAt: string;
}
interface SecurityFindingsResponseDto {
items: SecurityFindingProjectionDto[];
}
@Injectable()
export class SecurityFindingsHttpClient implements SecurityFindingsApi {
constructor(
private readonly http: HttpClient,
@Inject(SECURITY_FINDINGS_API_BASE_URL) private readonly baseUrl: string,
private readonly authSession: AuthSessionStore,
private readonly context: PlatformContextStore,
) {}
listFindings(filter?: FindingsFilter): Observable<FindingDto[]> {
@@ -74,18 +97,48 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
if (filter?.environment) params = params.set('environment', filter.environment);
if (filter?.limit) params = params.set('limit', filter.limit.toString());
if (filter?.sort) params = params.set('sort', filter.sort);
return this.http.get<any>(`${this.baseUrl}/api/v1/findings/summaries`, {
params,
headers: this.buildHeaders(),
}).pipe(
map((res: any) => Array.isArray(res) ? res : (res?.items ?? [])),
);
const selectedRegion = this.context.selectedRegions()[0];
if (selectedRegion) {
params = params.set('region', selectedRegion);
}
if (!filter?.environment) {
const selectedEnvironment = this.context.selectedEnvironments()[0];
if (selectedEnvironment) {
params = params.set('environment', selectedEnvironment);
}
}
return this.http
.get<SecurityFindingsResponseDto>(`${this.baseUrl}/api/v2/security/findings`, {
params,
headers: this.buildHeaders(),
})
.pipe(
map((res) => (res?.items ?? []).map((row) => this.mapV2Finding(row))),
catchError(() =>
this.http.get<any>(`${this.baseUrl}/api/v1/findings/summaries`, {
params,
headers: this.buildHeaders(),
}).pipe(
map((res: any) => (Array.isArray(res) ? res : (res?.items ?? []))),
),
),
);
}
getFinding(findingId: string): Observable<FindingDetailDto> {
return this.http.get<FindingDetailDto>(`${this.baseUrl}/api/v1/findings/${findingId}/summary`, {
headers: this.buildHeaders(),
});
return this.http
.get<any>(`${this.baseUrl}/api/v2/security/disposition/${findingId}`, {
headers: this.buildHeaders(),
})
.pipe(
map((res) => this.mapDispositionToDetail(res?.item ?? res, findingId)),
catchError(() =>
this.http.get<FindingDetailDto>(`${this.baseUrl}/api/v1/findings/${findingId}/summary`, {
headers: this.buildHeaders(),
}),
),
);
}
private buildHeaders(): HttpHeaders {
@@ -96,6 +149,59 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
}
return new HttpHeaders(headers);
}
private mapV2Finding(row: SecurityFindingProjectionDto): FindingDto {
return {
id: row.findingId,
package: row.packageName,
version: row.componentName || 'n/a',
severity: this.mapSeverity(row.severity),
cvss: Math.round((Math.max(0, row.reachabilityScore ?? 0) / 10) * 10) / 10,
reachable: row.reachable,
reachabilityConfidence: row.reachabilityScore,
vexStatus: row.vexStatus || row.effectiveDisposition || 'none',
releaseId: row.releaseId,
releaseVersion: row.releaseName,
delta: 'carried',
environments: row.environment ? [row.environment] : [],
firstSeen: row.updatedAt,
};
}
private mapDispositionToDetail(row: any, fallbackId: string): FindingDetailDto {
const base = this.mapV2Finding({
findingId: row?.findingId ?? fallbackId,
cveId: row?.cveId ?? fallbackId,
severity: 'medium',
packageName: row?.packageName ?? 'unknown',
componentName: row?.componentName ?? 'unknown',
releaseId: row?.releaseId ?? '',
releaseName: row?.releaseName ?? '',
environment: row?.environment ?? '',
region: row?.region ?? '',
reachable: true,
reachabilityScore: 0,
effectiveDisposition: row?.effectiveDisposition ?? 'unknown',
vexStatus: row?.vex?.status ?? row?.effectiveDisposition ?? 'none',
updatedAt: row?.updatedAt ?? new Date().toISOString(),
});
return {
...base,
description: `Disposition: ${row?.effectiveDisposition ?? 'unknown'}`,
references: [],
affectedVersions: [],
fixedVersions: [],
};
}
private mapSeverity(value: string): FindingDto['severity'] {
const normalized = (value ?? '').toUpperCase();
if (normalized === 'CRITICAL' || normalized === 'HIGH' || normalized === 'MEDIUM' || normalized === 'LOW') {
return normalized;
}
return 'MEDIUM';
}
}
// ============================================================================

View File

@@ -0,0 +1,43 @@
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { PlatformContextStore } from './platform-context.store';
@Injectable()
export class GlobalContextHttpInterceptor implements HttpInterceptor {
private readonly context = inject(PlatformContextStore);
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (!this.isPack22ContextAwareRoute(request.url)) {
return next.handle(request);
}
let params = request.params;
const region = this.context.selectedRegions()[0];
const environment = this.context.selectedEnvironments()[0];
const timeWindow = this.context.timeWindow();
if (region && !params.has('region')) {
params = params.set('region', region);
}
if (environment && !params.has('environment')) {
params = params.set('environment', environment);
}
if (timeWindow && !params.has('timeWindow')) {
params = params.set('timeWindow', timeWindow);
}
return next.handle(request.clone({ params }));
}
private isPack22ContextAwareRoute(url: string): boolean {
return (
url.includes('/api/v2/releases') ||
url.includes('/api/v2/security') ||
url.includes('/api/v2/evidence') ||
url.includes('/api/v2/topology') ||
url.includes('/api/v2/integrations')
);
}
}

View File

@@ -0,0 +1,317 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { take } from 'rxjs';
export interface PlatformContextRegion {
regionId: string;
displayName: string;
sortOrder: number;
enabled: boolean;
}
export interface PlatformContextEnvironment {
environmentId: string;
regionId: string;
environmentType: string;
displayName: string;
sortOrder: number;
enabled: boolean;
}
export interface PlatformContextPreferences {
tenantId: string;
actorId: string;
regions: string[];
environments: string[];
timeWindow: string;
updatedAt: string;
updatedBy: string;
}
const DEFAULT_TIME_WINDOW = '24h';
@Injectable({ providedIn: 'root' })
export class PlatformContextStore {
private readonly http = inject(HttpClient);
private persistPaused = false;
private readonly apiDisabled = this.shouldDisableApiCalls();
readonly regions = signal<PlatformContextRegion[]>([]);
readonly environments = signal<PlatformContextEnvironment[]>([]);
readonly selectedRegions = signal<string[]>([]);
readonly selectedEnvironments = signal<string[]>([]);
readonly timeWindow = signal(DEFAULT_TIME_WINDOW);
readonly loading = signal(false);
readonly initialized = signal(false);
readonly error = signal<string | null>(null);
// Incremented on context updates so route-level stores can trigger refetch.
readonly contextVersion = signal(0);
readonly regionSummary = computed(() => {
const selected = this.selectedRegions();
if (selected.length === 0) {
return 'All regions';
}
if (selected.length === 1) {
const region = this.regions().find((item) => item.regionId === selected[0]);
return region?.displayName ?? selected[0];
}
return `${selected.length} regions`;
});
readonly environmentSummary = computed(() => {
const selected = this.selectedEnvironments();
if (selected.length === 0) {
return 'All environments';
}
if (selected.length === 1) {
const env = this.environments().find((item) => item.environmentId === selected[0]);
return env?.displayName ?? selected[0];
}
return `${selected.length} environments`;
});
initialize(): void {
if (this.initialized() || this.loading()) {
return;
}
if (this.apiDisabled) {
this.loading.set(false);
this.error.set(null);
this.initialized.set(true);
this.persistPaused = false;
return;
}
this.loading.set(true);
this.error.set(null);
this.persistPaused = true;
this.http
.get<PlatformContextRegion[]>('/api/v2/context/regions')
.pipe(take(1))
.subscribe({
next: (regions) => {
const sortedRegions = [...(regions ?? [])].sort((a, b) => {
if (a.sortOrder !== b.sortOrder) {
return a.sortOrder - b.sortOrder;
}
return a.displayName.localeCompare(b.displayName, 'en', { sensitivity: 'base' });
});
this.regions.set(sortedRegions);
this.loadPreferences();
},
error: (err: unknown) => {
this.error.set(this.normalizeError(err, 'Failed to load global regions.'));
this.loading.set(false);
this.persistPaused = false;
},
});
}
setRegions(regionIds: string[]): void {
const next = this.normalizeIds(regionIds, this.regions().map((item) => item.regionId));
if (this.arraysEqual(next, this.selectedRegions())) {
return;
}
this.selectedRegions.set(next);
this.loadEnvironments(next, this.selectedEnvironments(), true);
}
setEnvironments(environmentIds: string[]): void {
const next = this.normalizeIds(
environmentIds,
this.environments().map((item) => item.environmentId),
);
if (this.arraysEqual(next, this.selectedEnvironments())) {
return;
}
this.selectedEnvironments.set(next);
this.persistPreferences();
this.bumpContextVersion();
}
setTimeWindow(timeWindow: string): void {
const normalized = (timeWindow || DEFAULT_TIME_WINDOW).trim();
if (normalized === this.timeWindow()) {
return;
}
this.timeWindow.set(normalized);
this.persistPreferences();
this.bumpContextVersion();
}
private loadPreferences(): void {
this.http
.get<PlatformContextPreferences>('/api/v2/context/preferences')
.pipe(take(1))
.subscribe({
next: (prefs) => {
const preferredRegions = this.normalizeIds(
prefs?.regions ?? [],
this.regions().map((item) => item.regionId),
);
this.selectedRegions.set(preferredRegions);
this.timeWindow.set((prefs?.timeWindow ?? DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW);
this.loadEnvironments(preferredRegions, prefs?.environments ?? [], false);
},
error: () => {
// Preferences are optional; continue with default empty context.
this.selectedRegions.set([]);
this.selectedEnvironments.set([]);
this.timeWindow.set(DEFAULT_TIME_WINDOW);
this.loadEnvironments([], [], false);
},
});
}
private loadEnvironments(
regionIds: string[],
preferredEnvironmentIds: string[],
persistAfterLoad: boolean,
): void {
let params = new HttpParams();
if (regionIds.length > 0) {
params = params.set('regions', regionIds.join(','));
}
this.http
.get<PlatformContextEnvironment[]>('/api/v2/context/environments', { params })
.pipe(take(1))
.subscribe({
next: (environments) => {
const sortedEnvironments = [...(environments ?? [])].sort((a, b) => {
if (a.sortOrder !== b.sortOrder) {
return a.sortOrder - b.sortOrder;
}
if (a.regionId !== b.regionId) {
return a.regionId.localeCompare(b.regionId, 'en', { sensitivity: 'base' });
}
return a.displayName.localeCompare(b.displayName, 'en', { sensitivity: 'base' });
});
this.environments.set(sortedEnvironments);
const nextEnvironments = this.normalizeIds(
preferredEnvironmentIds,
sortedEnvironments.map((item) => item.environmentId),
);
this.selectedEnvironments.set(nextEnvironments);
if (persistAfterLoad) {
this.persistPreferences();
}
this.finishInitialization();
this.bumpContextVersion();
},
error: (err: unknown) => {
this.error.set(this.normalizeError(err, 'Failed to load global environments.'));
this.environments.set([]);
this.selectedEnvironments.set([]);
if (persistAfterLoad) {
this.persistPreferences();
}
this.finishInitialization();
this.bumpContextVersion();
},
});
}
private persistPreferences(): void {
if (this.persistPaused || this.apiDisabled) {
return;
}
const payload = {
regions: this.selectedRegions(),
environments: this.selectedEnvironments(),
timeWindow: this.timeWindow(),
};
this.http
.put<PlatformContextPreferences>('/api/v2/context/preferences', payload)
.pipe(take(1))
.subscribe({
error: (err: unknown) => {
this.error.set(this.normalizeError(err, 'Failed to persist global context preferences.'));
},
});
}
private finishInitialization(): void {
this.loading.set(false);
this.initialized.set(true);
this.persistPaused = false;
}
private normalizeIds(values: string[], allowedValues: string[]): string[] {
const allowed = new Set(allowedValues.map((value) => value.toLowerCase()));
const deduped = new Map<string, string>();
for (const raw of values ?? []) {
const trimmed = (raw ?? '').trim();
if (!trimmed) {
continue;
}
const normalized = trimmed.toLowerCase();
if (!allowed.has(normalized)) {
continue;
}
if (!deduped.has(normalized)) {
deduped.set(normalized, normalized);
}
}
return [...deduped.values()];
}
private arraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false;
}
for (let i = 0; i < left.length; i += 1) {
if (left[i] !== right[i]) {
return false;
}
}
return true;
}
private normalizeError(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
private bumpContextVersion(): void {
this.contextVersion.update((value) => value + 1);
}
private shouldDisableApiCalls(): boolean {
const userAgent = (globalThis as { navigator?: { userAgent?: string } }).navigator?.userAgent ?? '';
if (userAgent.toLowerCase().includes('jsdom')) {
return true;
}
const protocol = (globalThis as { location?: { protocol?: string } }).location?.protocol ?? '';
return protocol === 'about:';
}
}

View File

@@ -19,6 +19,44 @@ import { AUTH_SERVICE, AuthService } from '../auth/auth.service';
* Used to detect when a route was accessed via legacy URL.
*/
const LEGACY_ROUTE_MAP: Record<string, string> = {
// Pack 22 root migration aliases
'release-control': '/releases',
'release-control/releases': '/releases',
'release-control/approvals': '/releases/approvals',
'release-control/runs': '/releases/activity',
'release-control/deployments': '/releases/activity',
'release-control/promotions': '/releases/activity',
'release-control/hotfixes': '/releases',
'release-control/regions': '/topology/regions',
'release-control/setup': '/topology',
'security-risk': '/security',
'security-risk/findings': '/security/findings',
'security-risk/vulnerabilities': '/security/vulnerabilities',
'security-risk/disposition': '/security/disposition',
'security-risk/sbom': '/security/sbom-explorer/graph',
'security-risk/sbom-lake': '/security/sbom-explorer/table',
'security-risk/vex': '/security/disposition',
'security-risk/exceptions': '/security/disposition',
'security-risk/advisory-sources': '/integrations/feeds',
'evidence-audit': '/evidence',
'evidence-audit/packs': '/evidence/packs',
'evidence-audit/bundles': '/evidence/bundles',
'evidence-audit/evidence': '/evidence/evidence',
'evidence-audit/proofs': '/evidence/proofs',
'evidence-audit/audit-log': '/evidence/audit-log',
'evidence-audit/replay': '/evidence/replay',
'platform-ops': '/operations',
'platform-ops/data-integrity': '/operations/data-integrity',
'platform-ops/orchestrator': '/operations/orchestrator',
'platform-ops/health': '/operations/health',
'platform-ops/quotas': '/operations/quotas',
'platform-ops/feeds': '/operations/feeds',
'platform-ops/offline-kit': '/operations/offline-kit',
'platform-ops/agents': '/topology/agents',
// Home & Dashboard
'dashboard/sources': '/operations/feeds',
'home': '/',
@@ -104,6 +142,12 @@ const LEGACY_ROUTE_MAP: Record<string, string> = {
* These use regex to match dynamic segments.
*/
const LEGACY_ROUTE_PATTERNS: Array<{ pattern: RegExp; oldPrefix: string; newPrefix: string }> = [
{ pattern: /^release-control\/releases\/([^/]+)$/, oldPrefix: 'release-control/releases/', newPrefix: '/releases/' },
{ pattern: /^release-control\/approvals\/([^/]+)$/, oldPrefix: 'release-control/approvals/', newPrefix: '/releases/approvals/' },
{ pattern: /^security-risk\/findings\/([^/]+)$/, oldPrefix: 'security-risk/findings/', newPrefix: '/security/findings/' },
{ pattern: /^security-risk\/vulnerabilities\/([^/]+)$/, oldPrefix: 'security-risk/vulnerabilities/', newPrefix: '/security/vulnerabilities/' },
{ pattern: /^evidence-audit\/packs\/([^/]+)$/, oldPrefix: 'evidence-audit/packs/', newPrefix: '/evidence/packs/' },
// Scan/finding details
{ pattern: /^findings\/([^/]+)$/, oldPrefix: 'findings/', newPrefix: '/security/scans/' },
{ pattern: /^scans\/([^/]+)$/, oldPrefix: 'scans/', newPrefix: '/security/scans/' },

View File

@@ -94,7 +94,7 @@ interface HistoryEvent {
template: `
<div class="approval-detail-v2">
<header class="decision-header">
<a routerLink="/release-control/approvals" class="back-link">Back to Approvals</a>
<a routerLink="/releases/approvals" class="back-link">Back to Approvals</a>
<div class="decision-header__title-row">
<h1>Approval Detail</h1>
@@ -337,7 +337,7 @@ interface HistoryEvent {
<div class="footer-links">
<a routerLink="/platform-ops/data-integrity/reachability-ingest">Open Reachability Ingest Health</a>
<a routerLink="/release-control/environments">Open Env Detail</a>
<a routerLink="/topology/environments">Open Env Detail</a>
</div>
</section>
}

View File

@@ -1,618 +1,220 @@
import { Component, ChangeDetectionStrategy, OnInit, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { catchError, of } from 'rxjs';
import { APPROVAL_API } from '../../core/api/approval.client';
import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.models';
type DataIntegrityStatus = 'OK' | 'WARN' | 'FAIL';
type QueueTab = 'pending' | 'approved' | 'rejected' | 'expiring' | 'my-team';
/**
* ApprovalsInboxComponent - Approval decision cockpit.
* Wired to real APPROVAL_API for live data.
*/
@Component({
selector: 'app-approvals-inbox',
imports: [CommonModule, RouterLink, FormsModule],
template: `
<div class="approvals">
<header class="approvals__header">
<div>
<h1 class="approvals__title">Approvals</h1>
<p class="approvals__subtitle">
Decide promotions with policy + reachability, backed by signed evidence.
</p>
</div>
selector: 'app-approvals-inbox',
standalone: true,
imports: [RouterLink, FormsModule],
template: `
<section class="approvals">
<header>
<h1>Release Run Approvals Queue</h1>
<p>Run-centric approval queue with gate/env/hotfix/risk filtering.</p>
</header>
@if (dataIntegrityBannerVisible()) {
<section class="data-integrity-banner" [class]="'data-integrity-banner data-integrity-banner--' + dataIntegrityStatus().toLowerCase()">
<div>
<p class="data-integrity-banner__title">
Data Integrity {{ dataIntegrityStatus() }}
</p>
<p class="data-integrity-banner__detail">{{ dataIntegritySummary() }}</p>
</div>
<div class="data-integrity-banner__actions">
<a routerLink="/platform-ops/data-integrity">Open Data Integrity</a>
<button type="button" (click)="dismissDataIntegrityBanner()">Dismiss</button>
</div>
</section>
}
<nav class="tabs" aria-label="Approvals queue tabs">
@for (tab of tabs; track tab.id) {
<a [routerLink]="[]" [queryParams]="{ tab: tab.id }" [class.active]="activeTab()===tab.id">{{ tab.label }}</a>
}
</nav>
<!-- Filters -->
<div class="approvals__filters">
<div class="filter-group">
<span class="filter-group__label">Status</span>
<div class="filter-chips">
@for (status of statusOptions; track status.value) {
<button type="button"
class="filter-chip"
[class.filter-chip--active]="currentStatusFilter === status.value"
(click)="onStatusChipClick(status.value)">
{{ status.label }}
</button>
}
</div>
</div>
<div class="filters">
<select [(ngModel)]="gateTypeFilter" (ngModelChange)="applyFilters()">
<option value="all">Gate Type: All</option>
<option value="policy">Policy</option>
<option value="ops">Ops</option>
<option value="security">Security</option>
</select>
<div class="filter-group filter-group--env" [class.filter-group--visible]="currentStatusFilter !== null">
<span class="filter-group__label">Environment</span>
<div class="filter-chips">
@for (env of environmentOptions; track env.value) {
<button type="button"
class="filter-chip"
[class.filter-chip--active]="currentEnvironmentFilter === env.value"
(click)="onEnvironmentFilter(env.value)">
{{ env.label }}
</button>
}
</div>
</div>
<select [(ngModel)]="envFilter" (ngModelChange)="applyFilters()">
<option value="all">Environment: All</option>
<option value="dev">Dev</option>
<option value="qa">QA</option>
<option value="staging">Staging</option>
<option value="prod">Prod</option>
</select>
<div class="filter-search-wrapper">
<svg class="filter-search-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input type="text" class="filter-search" placeholder="Search approvals..." [(ngModel)]="searchQuery" (ngModelChange)="onSearchChange()" />
@if (searchQuery) {
<button type="button" class="filter-search-clear" (click)="clearSearch()">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
}
</div>
<select [(ngModel)]="hotfixFilter" (ngModelChange)="applyFilters()">
<option value="all">Hotfix: All</option>
<option value="true">Hotfix Only</option>
<option value="false">Non-hotfix</option>
</select>
<select [(ngModel)]="riskFilter" (ngModelChange)="applyFilters()">
<option value="all">Risk: All</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="normal">Normal/Low</option>
</select>
</div>
@if (loading()) {
<div class="loading-banner">Loading approvals...</div>
}
@if (loading()) { <div class="banner">Loading approvals...</div> }
@if (error()) { <div class="banner error">{{ error() }}</div> }
@if (error()) {
<div class="error-banner">{{ error() }}</div>
}
<!-- Approvals list -->
@if (!loading()) {
<section class="approvals__section">
<h2 class="approvals__section-title">Results ({{ approvals().length }})</h2>
@for (approval of approvals(); track approval.id) {
<div class="approval-card">
<div class="approval-card__header">
<a [routerLink]="['/release-control/releases', approval.releaseId]" class="approval-card__release">
{{ approval.releaseName }} v{{ approval.releaseVersion }}
</a>
<span class="approval-card__flow">{{ approval.sourceEnvironment }} &rarr; {{ approval.targetEnvironment }}</span>
<span class="approval-card__meta">Requested by: {{ approval.requestedBy }} &bull; {{ timeAgo(approval.requestedAt) }}</span>
</div>
<div class="approval-card__changes">
<strong>JUSTIFICATION:</strong>
{{ approval.justification }}
</div>
<div class="approval-card__gates">
<div class="gates-row">
<div class="gate-item" [class]="approval.gatesPassed ? 'gate-item--pass' : 'gate-item--block'">
<span class="gate-item__badge">{{ approval.gatesPassed ? 'PASS' : 'BLOCK' }}</span>
<span class="gate-item__name">Policy Gates</span>
</div>
<div class="gate-item">
<span class="gate-item__badge">{{ approval.currentApprovals }}/{{ approval.requiredApprovals }}</span>
<span class="gate-item__name">Approvals</span>
</div>
</div>
</div>
<div class="approval-card__actions">
@if (approval.status === 'pending') {
<button type="button" class="btn btn--success" (click)="approveRequest(approval.id)">Approve</button>
<button type="button" class="btn btn--danger" (click)="rejectRequest(approval.id)">Reject</button>
}
<a [routerLink]="['/release-control/approvals', approval.id]" class="btn btn--secondary">View Details</a>
</div>
</div>
} @empty {
<div class="empty-state">No approvals match the current filters</div>
}
</section>
<table>
<thead>
<tr>
<th>Release</th>
<th>Flow</th>
<th>Gate Type</th>
<th>Risk</th>
<th>Status</th>
<th>Requester</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (approval of filtered(); track approval.id) {
<tr>
<td><a [routerLink]="['/releases/runs', approval.releaseId, 'timeline']">{{ approval.releaseName }} {{ approval.releaseVersion }}</a></td>
<td>{{ approval.sourceEnvironment }} ? {{ approval.targetEnvironment }}</td>
<td>{{ deriveGateType(approval) }}</td>
<td>{{ approval.urgency }}</td>
<td>{{ approval.status }}</td>
<td>{{ approval.requestedBy }}</td>
<td>{{ timeRemaining(approval.expiresAt) }}</td>
<td><a [routerLink]="['/releases/runs', approval.releaseId, 'approvals']" [queryParams]="{ approvalId: approval.id }">Open</a></td>
</tr>
} @empty {
<tr><td colspan="8">No approvals match the active queue filters.</td></tr>
}
</tbody>
</table>
}
</div>
</section>
`,
styles: [`
.approvals {
max-width: 1200px;
margin: 0 auto;
}
.approvals__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
margin-bottom: 1.5rem;
}
.approvals__title {
margin: 0 0 0.5rem;
font-size: 1.75rem;
font-weight: var(--font-weight-semibold);
}
.approvals__subtitle {
margin: 0;
color: var(--color-text-secondary);
}
.approvals__filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.data-integrity-banner {
margin-bottom: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 0.75rem;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.data-integrity-banner--warn {
background: var(--color-status-warning-bg);
border-color: var(--color-status-warning-text);
}
.data-integrity-banner--fail {
background: var(--color-status-error-bg);
border-color: var(--color-status-error-text);
}
.data-integrity-banner__title {
margin: 0;
font-size: 0.82rem;
font-weight: var(--font-weight-semibold);
}
.data-integrity-banner__detail {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.data-integrity-banner__actions {
display: flex;
gap: 0.6rem;
align-items: center;
flex-wrap: wrap;
}
.data-integrity-banner__actions a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.82rem;
}
.data-integrity-banner__actions button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
padding: 0.25rem 0.55rem;
cursor: pointer;
font-size: 0.78rem;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-group__label {
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.filter-chips {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.filter-chip {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-2xl);
background: var(--color-surface-primary);
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.filter-chip:hover {
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
.filter-chip--active {
background: var(--color-brand-primary);
border-color: var(--color-brand-primary);
color: white;
}
.filter-chip--active:hover {
background: var(--color-brand-primary-hover);
border-color: var(--color-brand-primary-hover);
color: white;
}
.filter-group--env {
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 0.3s ease, opacity 0.25s ease, margin 0.3s ease;
margin-top: 0;
}
.filter-group--env.filter-group--visible {
max-height: 60px;
opacity: 1;
margin-top: 0;
}
.filter-search-wrapper {
position: relative;
flex: 1;
min-width: 200px;
}
.filter-search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
pointer-events: none;
}
.filter-search {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--color-surface-primary);
}
.filter-search:focus {
outline: none;
border-color: var(--color-brand-primary);
}
.filter-search-clear {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
}
.filter-search-clear:hover {
color: var(--color-text-primary);
}
.loading-banner {
padding: 2rem;
text-align: center;
color: var(--color-text-secondary);
}
.error-banner {
padding: 1rem;
margin-bottom: 1rem;
background: var(--color-status-error-bg);
border: 1px solid rgba(248, 113, 113, 0.5);
color: var(--color-status-error);
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.empty-state {
padding: 2rem;
text-align: center;
color: var(--color-text-secondary);
}
.approvals__section {
margin-bottom: 2rem;
}
.approvals__section-title {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
}
.approval-card {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 1rem;
}
.approval-card__header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.approval-card__release {
font-weight: var(--font-weight-semibold);
color: var(--color-brand-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.approval-card__flow {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.approval-card__meta {
font-size: 0.75rem;
color: var(--color-text-secondary);
margin-left: auto;
}
.approval-card__changes {
font-size: 0.875rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
}
.approval-card__gates {
margin-bottom: 1rem;
}
.gates-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.gate-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.gate-item__badge {
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
font-weight: var(--font-weight-semibold);
border-radius: var(--radius-sm);
}
.gate-item--pass .gate-item__badge {
background: var(--color-severity-low-bg);
color: var(--color-status-success-text);
}
.gate-item--warn .gate-item__badge {
background: var(--color-severity-medium-bg);
color: var(--color-status-warning-text);
}
.gate-item--block .gate-item__badge {
background: var(--color-severity-critical-bg);
color: var(--color-status-error-text);
}
.approval-card__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
text-decoration: none;
cursor: pointer;
transition: background-color 0.15s;
}
.btn--success {
background: var(--color-status-success);
color: white;
&:hover {
background: var(--color-status-success-text);
}
}
.btn--danger {
background: var(--color-severity-critical);
color: white;
&:hover {
background: var(--color-status-error-text);
}
}
.btn--secondary {
background: var(--color-surface-secondary);
color: var(--color-text-primary);
&:hover {
background: var(--color-nav-hover);
}
}
.btn--ghost {
background: transparent;
color: var(--color-brand-primary);
&:hover {
background: var(--color-brand-soft);
}
}
styles: [`
.approvals{display:grid;gap:.6rem}.approvals header h1{margin:0}.approvals header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
.tabs,.filters{display:flex;gap:.3rem;flex-wrap:wrap}.tabs a{border:1px solid var(--color-border-primary);border-radius:var(--radius-full);padding:.12rem .5rem;font-size:.72rem;color:var(--color-text-secondary);text-decoration:none}
.tabs a.active{border-color:var(--color-brand-primary);color:var(--color-brand-primary)}
.filters select{border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);background:var(--color-surface-primary);padding:.24rem .45rem;font-size:.72rem}
.banner,table{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)} .banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid var(--color-border-primary);padding:.4rem .45rem;font-size:.72rem;text-align:left;vertical-align:top}th{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase}tr:last-child td{border-bottom:none}
`],
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ApprovalsInboxComponent implements OnInit {
export class ApprovalsInboxComponent {
private readonly api = inject(APPROVAL_API);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly dataIntegrityStatus = signal<DataIntegrityStatus>('WARN');
readonly dataIntegritySummary = signal('NVD stale 3h | SBOM rescan FAILED | Runtime ingest lagging');
readonly dataIntegrityDismissed = signal(false);
readonly dataIntegrityBannerVisible = computed(
() => this.dataIntegrityStatus() !== 'OK' && !this.dataIntegrityDismissed()
);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly approvals = signal<ApprovalRequest[]>([]);
readonly filtered = signal<ApprovalRequest[]>([]);
readonly statusOptions = [
{ value: '', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'rejected', label: 'Rejected' },
readonly activeTab = signal<QueueTab>('pending');
readonly tabs: Array<{ id: QueueTab; label: string }> = [
{ id: 'pending', label: 'Pending' },
{ id: 'approved', label: 'Approved' },
{ id: 'rejected', label: 'Rejected' },
{ id: 'expiring', label: 'Expiring' },
{ id: 'my-team', label: 'My Team' },
];
readonly environmentOptions = [
{ value: '', label: 'All' },
{ value: 'dev', label: 'Dev' },
{ value: 'qa', label: 'QA' },
{ value: 'staging', label: 'Staging' },
{ value: 'prod', label: 'Prod' },
];
gateTypeFilter = 'all';
envFilter = 'all';
hotfixFilter = 'all';
riskFilter = 'all';
currentStatusFilter: string = 'pending';
currentEnvironmentFilter: string = '';
searchQuery: string = '';
constructor() {
this.route.queryParamMap.subscribe((params) => {
const tab = (params.get('tab') ?? 'pending') as QueueTab;
if (this.tabs.some((item) => item.id === tab)) {
this.activeTab.set(tab);
} else {
this.activeTab.set('pending');
}
ngOnInit(): void {
if (sessionStorage.getItem('approvals.data-integrity-banner-dismissed') === '1') {
this.dataIntegrityDismissed.set(true);
this.load();
});
}
deriveGateType(approval: ApprovalRequest): 'policy' | 'ops' | 'security' {
const releaseName = approval.releaseName.toLowerCase();
if (!approval.gatesPassed || releaseName.includes('policy')) {
return 'policy';
}
this.loadApprovals();
if (approval.urgency === 'critical' || approval.urgency === 'high') {
return 'security';
}
return 'ops';
}
dismissDataIntegrityBanner(): void {
this.dataIntegrityDismissed.set(true);
sessionStorage.setItem('approvals.data-integrity-banner-dismissed', '1');
applyFilters(): void {
const tab = this.activeTab();
const now = Date.now();
let rows = [...this.approvals()];
if (tab === 'expiring') {
rows = rows.filter((item) => item.status === 'pending' && (new Date(item.expiresAt).getTime() - now) <= 24 * 60 * 60 * 1000);
} else if (tab === 'my-team') {
rows = rows.filter((item) => item.status === 'pending' && item.requestedBy.toLowerCase().includes('team'));
} else {
rows = rows.filter((item) => item.status === tab);
}
if (this.gateTypeFilter !== 'all') {
rows = rows.filter((item) => this.deriveGateType(item) === this.gateTypeFilter);
}
if (this.envFilter !== 'all') {
rows = rows.filter((item) => item.targetEnvironment.toLowerCase().includes(this.envFilter));
}
if (this.hotfixFilter !== 'all') {
const hotfix = this.hotfixFilter === 'true';
rows = rows.filter((item) => item.releaseName.toLowerCase().includes('hotfix') === hotfix);
}
if (this.riskFilter !== 'all') {
if (this.riskFilter === 'normal') {
rows = rows.filter((item) => item.urgency === 'normal' || item.urgency === 'low');
} else {
rows = rows.filter((item) => item.urgency === this.riskFilter);
}
}
this.filtered.set(rows.sort((a, b) => new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()));
}
onStatusChipClick(value: string): void {
this.currentStatusFilter = value;
this.loadApprovals();
}
onEnvironmentFilter(value: string): void {
this.currentEnvironmentFilter = value;
this.loadApprovals();
}
onSearchChange(): void {
this.loadApprovals();
}
clearSearch(): void {
this.searchQuery = '';
this.loadApprovals();
}
approveRequest(id: string): void {
// Route to the detail page so the user can provide a decision reason
// before the action fires. The detail page has the full Decision panel.
this.router.navigate(['/release-control/approvals', id]);
}
rejectRequest(id: string): void {
// Route to the detail page so the user can provide a rejection reason.
this.router.navigate(['/release-control/approvals', id]);
}
timeAgo(dateStr: string): string {
const ms = Date.now() - new Date(dateStr).getTime();
timeRemaining(expiresAt: string): string {
const ms = new Date(expiresAt).getTime() - Date.now();
if (ms <= 0) return 'expired';
const hours = Math.floor(ms / 3600000);
if (hours < 1) return 'just now';
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
if (hours >= 24) {
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
}
const minutes = Math.floor((ms % 3600000) / 60000);
return `${hours}h ${minutes}m`;
}
private loadApprovals(): void {
private load(): void {
this.loading.set(true);
this.error.set(null);
const filter: any = {};
if (this.currentStatusFilter) {
filter.statuses = [this.currentStatusFilter];
}
if (this.currentEnvironmentFilter) {
filter.environment = this.currentEnvironmentFilter;
}
this.api.listApprovals(filter).pipe(
const tab = this.activeTab();
let statuses: ApprovalStatus[] | undefined;
if (tab === 'approved') statuses = ['approved'];
else if (tab === 'rejected') statuses = ['rejected'];
else statuses = ['pending'];
this.api.listApprovals({ statuses }).pipe(
catchError(() => {
this.error.set('Failed to load approvals. The backend may be unavailable.');
return of([]);
})
).subscribe(approvals => {
this.approvals.set(approvals);
this.error.set('Failed to load approvals queue.');
return of([] as ApprovalRequest[]);
}),
).subscribe((rows) => {
this.approvals.set(rows);
this.applyFilters();
this.loading.set(false);
});
}
}

View File

@@ -2,7 +2,7 @@
* Approvals Routes — Decision Cockpit
* Updated: SPRINT_20260218_011_FE_ui_v2_rewire_approvals_decision_cockpit (A6-01 through A6-05)
*
* Canonical approval surfaces under /release-control/approvals:
* Canonical approval surfaces under /releases/approvals:
* '' — Approvals queue (A6-01)
* :id — Decision cockpit with full operational context (A6-02 through A6-04):
* Overview, Gates, Security, Reachability, Ops/Data, Evidence, Replay/Verify, History
@@ -35,6 +35,8 @@ export const APPROVALS_ROUTES: Routes = [
decisionTabs: ['overview', 'gates', 'security', 'reachability', 'ops-data', 'evidence', 'replay', 'history'],
},
loadComponent: () =>
import('./approval-detail-page.component').then((m) => m.ApprovalDetailPageComponent),
import('../release-orchestrator/approvals/approval-detail/approval-detail.component').then(
(m) => m.ApprovalDetailComponent
),
},
];

View File

@@ -2,14 +2,12 @@
* Approval Detail Store
* Sprint: SPRINT_20260118_005_FE_approvals_feature (APPR-009)
*
* Signal-based state management for the approval detail page.
* Handles approval data, gate results, witness data, comments, and decision actions.
* API-backed state management for approval detail workflows.
*/
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
// === Interfaces ===
import { Injectable, computed, inject, signal } from '@angular/core';
import { catchError, forkJoin, of } from 'rxjs';
export interface Approval {
id: string;
@@ -93,14 +91,35 @@ export interface SecurityDiffEntry {
confidence: number;
}
// === Store ===
interface ApprovalV2Dto {
id: string;
releaseId: string;
releaseVersion: string;
sourceEnvironment: string;
targetEnvironment: string;
status: string;
requestedBy: string;
requestedAt: string;
expiresAt?: string;
releaseComponents?: Array<{ name: string; version: string; digest: string }>;
gateResults?: Array<{ gateId: string; gateName: string; status: string; message?: string }>;
actions?: Array<{ id: string; action: string; actor: string; comment: string; timestamp: string }>;
manifestDigest?: string;
}
interface ApprovalGatesResponse {
gates?: Array<{ gateId: string; gateName: string; status: string; message?: string }>;
}
interface ApprovalSecuritySnapshotResponse {
topFindings?: Array<{ cve: string; component: string; severity: string; reachability: string }>;
}
@Injectable({ providedIn: 'root' })
export class ApprovalDetailStore {
private http = inject(HttpClient);
private apiBase = '/api/approvals';
private apiBase = '/api/v1/approvals';
// === Core State ===
readonly approval = signal<Approval | null>(null);
readonly diffSummary = signal<DiffSummary | null>(null);
readonly gateResults = signal<GateResult[]>([]);
@@ -108,14 +127,11 @@ export class ApprovalDetailStore {
readonly comments = signal<ApprovalComment[]>([]);
readonly securityDiff = signal<SecurityDiffEntry[]>([]);
// === Loading & Error State ===
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly submitting = signal(false);
readonly commentSubmitting = signal(false);
// === Computed Properties ===
readonly approvalId = computed(() => this.approval()?.id ?? null);
readonly isPending = computed(() => {
@@ -129,21 +145,10 @@ export class ApprovalDetailStore {
return new Date(approval.expiresAt) < new Date();
});
readonly canApprove = computed(() => {
return this.isPending() && !this.hasBlockingGates() && !this.isExpired();
});
readonly canReject = computed(() => {
return this.isPending() && !this.isExpired();
});
readonly hasBlockingGates = computed(() => {
return this.gateResults().some(g => g.status === 'BLOCK');
});
readonly hasWarningGates = computed(() => {
return this.gateResults().some(g => g.status === 'WARN');
});
readonly canApprove = computed(() => this.isPending() && !this.hasBlockingGates() && !this.isExpired());
readonly canReject = computed(() => this.isPending() && !this.isExpired());
readonly hasBlockingGates = computed(() => this.gateResults().some(g => g.status === 'BLOCK'));
readonly hasWarningGates = computed(() => this.gateResults().some(g => g.status === 'WARN'));
readonly overallGateStatus = computed(() => {
const gates = this.gateResults();
@@ -160,209 +165,160 @@ export class ApprovalDetailStore {
});
readonly criticalFindings = computed(() => {
return this.securityDiff()
.filter(e => e.severity === 'critical' && e.changeType === 'new');
return this.securityDiff().filter(e => e.severity === 'critical' && e.changeType === 'new');
});
readonly promotionRoute = computed(() => {
const approval = this.approval();
if (!approval) return '';
return `${approval.fromEnvironment} ${approval.toEnvironment}`;
return `${approval.fromEnvironment} -> ${approval.toEnvironment}`;
});
// === Actions ===
/**
* Load approval detail data
*/
load(approvalId: string): void {
this.loading.set(true);
this.error.set(null);
this.witness.set(null);
// In a real app, this would be HTTP calls
// For now, simulate with mock data
setTimeout(() => {
this.approval.set({
id: approvalId,
releaseId: 'rel-123',
releaseVersion: 'v1.2.5',
bundleDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9',
fromEnvironment: 'QA',
toEnvironment: 'Staging',
status: 'pending',
requestedBy: 'ci-bot',
requestedAt: '2026-01-17T15:00:00Z',
expiresAt: '2026-01-24T15:00:00Z',
});
forkJoin({
detail: this.http.get<ApprovalV2Dto>(`${this.apiBase}/${approvalId}`),
gates: this.http.get<ApprovalGatesResponse>(`${this.apiBase}/${approvalId}/gates`).pipe(catchError(() => of(null))),
security: this.http.get<ApprovalSecuritySnapshotResponse>(`${this.apiBase}/${approvalId}/security-snapshot`).pipe(catchError(() => of(null))),
evidence: this.http.get<{ decisionDigest?: string }>(`${this.apiBase}/${approvalId}/evidence`).pipe(catchError(() => of(null))),
ops: this.http.get<{ opsConfidence?: { status?: string } }>(`${this.apiBase}/${approvalId}/ops-health`).pipe(catchError(() => of(null))),
}).subscribe({
next: ({ detail, gates, security, evidence, ops }) => {
this.approval.set(this.mapApproval(detail));
this.gateResults.set(this.mapGates(gates?.gates ?? detail.gateResults ?? []));
this.securityDiff.set(this.mapSecurityDiff(security?.topFindings ?? []));
this.diffSummary.set(this.mapDiffSummary(detail, security?.topFindings ?? []));
this.comments.set(this.mapComments(detail.actions ?? []));
this.loading.set(false);
this.diffSummary.set({
componentsAdded: 2,
componentsRemoved: 1,
componentsUpdated: 5,
newCves: 3,
fixedCves: 7,
reachableCves: 1,
unreachableCves: 2,
uncertainCves: 0,
securityScoreDelta: -5, // Lower is better
licensesChanged: false,
});
this.gateResults.set([
{ gateId: 'sbom', name: 'SBOM Signed', status: 'PASS' },
{ gateId: 'provenance', name: 'Provenance', status: 'PASS' },
{ gateId: 'reachability', name: 'Reachability', status: 'WARN', reason: '1 reachable CVE', canRequestException: true },
{ gateId: 'vex', name: 'VEX Consensus', status: 'PASS' },
{ gateId: 'license', name: 'License Compliance', status: 'PASS' },
]);
this.securityDiff.set([
{ cveId: 'CVE-2026-1234', component: 'log4j-core', version: '2.14.1', severity: 'critical', changeType: 'new', reachability: 'reachable', confidence: 0.87 },
{ cveId: 'CVE-2026-5678', component: 'spring-core', version: '5.3.12', severity: 'high', changeType: 'new', reachability: 'unreachable', confidence: 0.95 },
{ cveId: 'CVE-2025-9999', component: 'jackson-databind', version: '2.13.0', severity: 'medium', changeType: 'new', reachability: 'unreachable', confidence: 0.92 },
{ cveId: 'CVE-2025-1111', component: 'lodash', version: '4.17.19', severity: 'high', changeType: 'fixed', reachability: 'unreachable', confidence: 1.0 },
{ cveId: 'CVE-2025-2222', component: 'express', version: '4.17.0', severity: 'medium', changeType: 'fixed', reachability: 'unreachable', confidence: 1.0 },
]);
this.comments.set([
{ id: 'c1', author: 'ci-bot', authorEmail: 'ci@acme.com', content: 'Automated promotion request triggered by successful QA deployment.', createdAt: '2026-01-17T15:00:00Z', type: 'system' },
{ id: 'c2', author: 'Jane Smith', authorEmail: 'jane@acme.com', content: 'I\'ve reviewed the reachable CVE. The affected code path is behind a feature flag that\'s disabled in production.', createdAt: '2026-01-17T16:30:00Z', type: 'comment' },
]);
this.loading.set(false);
}, 300);
const missingData = [gates, security, evidence, ops].filter(item => item == null).length;
if (missingData > 0) {
this.error.set('Approval loaded with partial v2 packet data. Some linked tabs may be unavailable.');
}
},
error: (err: unknown) => {
this.reset();
this.error.set(this.extractErrorMessage(err, 'Failed to load approval detail'));
this.loading.set(false);
},
});
}
/**
* Approve the promotion request
*/
approve(comment?: string): void {
const approval = this.approval();
if (!approval || !this.canApprove()) return;
this.submitting.set(true);
console.log(`Approving ${approval.id}`, comment ? `with comment: ${comment}` : '');
// In real app, would POST to /api/approvals/{id}/approve
setTimeout(() => {
this.approval.update(a => a ? { ...a, status: 'approved', decidedAt: new Date().toISOString(), decidedBy: 'Current User' } : null);
if (comment) {
this.comments.update(list => [
...list,
{ id: `c${Date.now()}`, author: 'Current User', authorEmail: 'user@acme.com', content: comment, createdAt: new Date().toISOString(), type: 'decision' },
]);
}
this.submitting.set(false);
}, 500);
this.error.set(null);
this.postDecision(approval.id, 'approve', comment).subscribe({
next: (detail) => {
this.applyDecisionResult(detail);
this.submitting.set(false);
},
error: (err: unknown) => {
this.error.set(this.extractErrorMessage(err, 'Approve action failed'));
this.submitting.set(false);
},
});
}
/**
* Reject the promotion request
*/
reject(comment: string): void {
const approval = this.approval();
if (!approval || !this.canReject()) return;
this.submitting.set(true);
console.log(`Rejecting ${approval.id} with reason: ${comment}`);
// In real app, would POST to /api/approvals/{id}/reject
setTimeout(() => {
this.approval.update(a => a ? { ...a, status: 'rejected', decidedAt: new Date().toISOString(), decidedBy: 'Current User', decisionComment: comment } : null);
this.comments.update(list => [
...list,
{ id: `c${Date.now()}`, author: 'Current User', authorEmail: 'user@acme.com', content: `Rejected: ${comment}`, createdAt: new Date().toISOString(), type: 'decision' },
]);
this.submitting.set(false);
}, 500);
this.error.set(null);
this.postDecision(approval.id, 'reject', comment).subscribe({
next: (detail) => {
this.applyDecisionResult(detail);
this.submitting.set(false);
},
error: (err: unknown) => {
this.error.set(this.extractErrorMessage(err, 'Reject action failed'));
this.submitting.set(false);
},
});
}
/**
* Add a comment to the approval
*/
addComment(content: string): void {
if (!content.trim()) return;
const approval = this.approval();
if (!approval || !content.trim()) return;
this.commentSubmitting.set(true);
// Optimistic update
const optimisticComment: ApprovalComment = {
id: `optimistic-${Date.now()}`,
author: 'Current User',
authorEmail: 'user@acme.com',
content,
createdAt: new Date().toISOString(),
type: 'comment',
};
this.comments.update(list => [...list, optimisticComment]);
// In real app, would POST to /api/approvals/{id}/comments
setTimeout(() => {
// Update with real ID from server
this.comments.update(list =>
list.map(c => c.id === optimisticComment.id ? { ...c, id: `c${Date.now()}` } : c)
);
this.commentSubmitting.set(false);
}, 200);
this.error.set(null);
this.postDecision(approval.id, 'comment', content.trim()).subscribe({
next: (detail) => {
this.applyDecisionResult(detail);
this.commentSubmitting.set(false);
},
error: (err: unknown) => {
this.error.set(this.extractErrorMessage(err, 'Failed to add comment'));
this.commentSubmitting.set(false);
},
});
}
/**
* Request an exception for a blocking gate
*/
requestException(gateId: string): void {
console.log(`Requesting exception for gate: ${gateId}`);
// This opens the exception modal - handled by the component
// The actual exception request is handled by the modal component
this.error.set(`Exception request for gate ${gateId} requires policy exception endpoint integration.`);
}
/**
* Load witness data for a specific finding
*/
loadWitness(findingId: string): void {
console.log(`Loading witness for ${findingId}`);
const approval = this.approval();
if (!approval) {
this.error.set('Load approval detail before requesting witness evidence.');
return;
}
// In real app, would GET /api/witnesses/{findingId}
setTimeout(() => {
this.witness.set({
findingId,
component: 'log4j-core',
version: '2.14.1',
description: 'Remote code execution via JNDI lookup',
state: 'reachable',
confidence: 0.87,
confidenceExplanation: 'Static analysis found path; runtime signals confirm usage',
callPath: [
{ function: 'main()', file: 'App.java', line: 25, type: 'entry' },
{ function: 'handleRequest()', file: 'Controller.java', line: 142, type: 'call' },
{ function: 'log()', file: 'LogService.java', line: 87, type: 'call' },
{ function: 'lookup()', file: 'log4j-core/LogManager.java', line: 256, type: 'sink' },
],
analysisDetails: {
guards: [],
dynamicLoading: false,
reflection: false,
conditionalExecution: null,
dataFlowConfidence: 0.92,
},
});
}, 200);
this.error.set(null);
this.http.get<ApprovalSecuritySnapshotResponse>(`${this.apiBase}/${approval.id}/security-snapshot`).subscribe({
next: (snapshot) => {
const findings = snapshot.topFindings ?? [];
const finding =
findings.find(item => item.cve.toLowerCase() === findingId.toLowerCase()) ??
findings.find(item => item.reachability.toLowerCase().includes('reachable')) ??
findings[0];
if (!finding) {
this.witness.set(null);
this.error.set('No witness data available for this approval.');
return;
}
this.witness.set({
findingId: finding.cve,
component: finding.component,
version: 'unknown',
description: `${finding.severity} finding derived from approval security snapshot`,
state: this.mapReachabilityState(finding.reachability),
confidence: 0.7,
confidenceExplanation: 'Derived from approval security snapshot endpoint.',
callPath: [
{ function: 'approvalDecision', file: 'approval-security-snapshot', line: 1, type: 'entry' },
{ function: 'findingResolution', file: finding.component, line: 1, type: 'sink' },
],
analysisDetails: {
guards: [],
dynamicLoading: false,
reflection: false,
conditionalExecution: null,
dataFlowConfidence: 0.7,
},
});
},
error: (err: unknown) => {
this.witness.set(null);
this.error.set(this.extractErrorMessage(err, 'Failed to load witness data'));
},
});
}
/**
* Clear witness data
*/
clearWitness(): void {
this.witness.set(null);
}
/**
* Refresh approval data
*/
refresh(): void {
const approval = this.approval();
if (approval) {
@@ -370,9 +326,6 @@ export class ApprovalDetailStore {
}
}
/**
* Reset store state
*/
reset(): void {
this.approval.set(null);
this.diffSummary.set(null);
@@ -385,4 +338,169 @@ export class ApprovalDetailStore {
this.submitting.set(false);
this.commentSubmitting.set(false);
}
private postDecision(
approvalId: string,
action: 'approve' | 'reject' | 'comment',
comment?: string
) {
return this.http.post<ApprovalV2Dto>(`${this.apiBase}/${approvalId}/decision`, {
action,
comment,
actor: 'ui-operator',
});
}
private applyDecisionResult(detail: ApprovalV2Dto): void {
const current = this.approval();
const mapped = this.mapApproval(detail);
this.approval.set({
...mapped,
decidedAt: mapped.decidedAt ?? current?.decidedAt,
decidedBy: mapped.decidedBy ?? current?.decidedBy,
});
this.gateResults.set(this.mapGates(detail.gateResults ?? []));
this.comments.set(this.mapComments(detail.actions ?? []));
}
private mapApproval(detail: ApprovalV2Dto): Approval {
const lastDecision = (detail.actions ?? [])
.filter(item => item.action === 'approve' || item.action === 'reject')
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
return {
id: detail.id,
releaseId: detail.releaseId,
releaseVersion: detail.releaseVersion,
bundleDigest: detail.manifestDigest ?? detail.releaseComponents?.[0]?.digest ?? 'unknown',
fromEnvironment: detail.sourceEnvironment,
toEnvironment: detail.targetEnvironment,
status: this.mapApprovalStatus(detail.status),
requestedBy: detail.requestedBy,
requestedAt: detail.requestedAt,
decidedBy: lastDecision?.actor,
decidedAt: lastDecision?.timestamp,
decisionComment: lastDecision?.comment,
expiresAt: detail.expiresAt,
};
}
private mapApprovalStatus(status: string | undefined): Approval['status'] {
switch ((status ?? '').toLowerCase()) {
case 'approved':
return 'approved';
case 'rejected':
return 'rejected';
case 'expired':
return 'expired';
default:
return 'pending';
}
}
private mapGates(
gates: Array<{ gateId: string; gateName: string; status: string; message?: string }>
): GateResult[] {
return gates.map(gate => ({
gateId: gate.gateId,
name: gate.gateName,
status: this.mapGateStatus(gate.status),
reason: gate.message,
canRequestException: this.mapGateStatus(gate.status) !== 'PASS',
}));
}
private mapGateStatus(status: string | undefined): GateResult['status'] {
switch ((status ?? '').toLowerCase()) {
case 'failed':
return 'BLOCK';
case 'warning':
return 'WARN';
case 'skipped':
return 'SKIP';
default:
return 'PASS';
}
}
private mapSecurityDiff(
findings: Array<{ cve: string; component: string; severity: string; reachability: string }>
): SecurityDiffEntry[] {
return findings.map((finding) => ({
cveId: finding.cve,
component: finding.component,
version: 'unknown',
severity: this.mapSeverity(finding.severity),
changeType: 'new',
reachability: this.mapReachabilityState(finding.reachability),
confidence: 0.7,
}));
}
private mapDiffSummary(
detail: ApprovalV2Dto,
findings: Array<{ cve: string; component: string; severity: string; reachability: string }>
): DiffSummary {
const reachable = findings.filter(item => this.mapReachabilityState(item.reachability) === 'reachable').length;
const unreachable = findings.filter(item => this.mapReachabilityState(item.reachability) === 'unreachable').length;
const uncertain = findings.length - reachable - unreachable;
return {
componentsAdded: detail.releaseComponents?.length ?? 0,
componentsRemoved: 0,
componentsUpdated: detail.releaseComponents?.length ?? 0,
newCves: findings.length,
fixedCves: 0,
reachableCves: reachable,
unreachableCves: unreachable,
uncertainCves: uncertain,
securityScoreDelta: 0,
licensesChanged: false,
};
}
private mapComments(actions: Array<{ id: string; action: string; actor: string; comment: string; timestamp: string }>): ApprovalComment[] {
return actions
.slice()
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
.map(action => ({
id: action.id,
author: action.actor,
authorEmail: `${action.actor}@stellaops.local`,
content: action.comment,
createdAt: action.timestamp,
type: action.action === 'approve' || action.action === 'reject' ? 'decision' : 'comment',
}));
}
private mapSeverity(severity: string | undefined): SecurityDiffEntry['severity'] {
const normalized = (severity ?? '').toLowerCase();
switch (normalized) {
case 'critical':
case 'high':
case 'medium':
case 'low':
return normalized as SecurityDiffEntry['severity'];
default:
return 'medium';
}
}
private mapReachabilityState(reachability: string | undefined): SecurityDiffEntry['reachability'] {
const normalized = (reachability ?? '').toLowerCase();
if (normalized.includes('not_reachable') || normalized.includes('unreachable')) {
return 'unreachable';
}
if (normalized.includes('reachable')) {
return 'reachable';
}
return 'uncertain';
}
private extractErrorMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object' && 'message' in err && typeof (err as { message?: unknown }).message === 'string') {
return (err as { message: string }).message;
}
return fallback;
}
}

View File

@@ -7,6 +7,9 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { RELEASE_DASHBOARD_API, type ReleaseDashboardApi } from '../../core/api/release-dashboard.client';
import type { ActiveDeployment, DashboardData, PendingApproval, PipelineEnvironment, RecentRelease } from '../../core/api/release-dashboard.models';
// =============================================
// Models
@@ -78,6 +81,7 @@ export interface GateSummary {
})
export class ControlPlaneStore {
private http = inject(HttpClient);
private dashboardApi = inject<ReleaseDashboardApi>(RELEASE_DASHBOARD_API);
// =============================================
// State Signals
@@ -159,11 +163,17 @@ export class ControlPlaneStore {
this.error.set(null);
try {
// In production, these would be API calls
// For now, load mock data
await this.loadMockData();
const data = await firstValueFrom(this.dashboardApi.getDashboardData());
this.pipeline.set(this.mapPipeline(data));
this.inbox.set(this.mapInbox(data.pendingApprovals, data.activeDeployments));
this.promotions.set(this.mapPromotions(data.pendingApprovals, data.recentReleases));
this.driftDelta.set(this.mapDriftDelta(data.pendingApprovals));
this.lastRefresh.set(new Date());
} catch (e) {
this.pipeline.set(null);
this.inbox.set(null);
this.promotions.set([]);
this.driftDelta.set(null);
this.error.set(e instanceof Error ? e.message : 'Failed to load Control Plane data');
} finally {
this.loading.set(false);
@@ -194,9 +204,14 @@ export class ControlPlaneStore {
* Triggers a promotion deployment.
*/
async deployPromotion(promotionId: string): Promise<void> {
console.log('Deploying promotion:', promotionId);
// TODO: API call to trigger deployment
// await this.http.post(`/api/promotions/${promotionId}/deploy`, {}).toPromise();
const promotion = this.promotions().find(item => item.id === promotionId);
const releaseId = promotion?.releaseId;
if (!releaseId) {
this.error.set(`Promotion ${promotionId} cannot be deployed because releaseId is missing.`);
return;
}
await firstValueFrom(this.http.post<void>(`/api/release-orchestrator/releases/${releaseId}/deploy`, {}));
await this.refresh();
}
@@ -212,138 +227,153 @@ export class ControlPlaneStore {
// Private Methods
// =============================================
private async loadMockData(): Promise<void> {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 300));
private mapPipeline(data: DashboardData): EnvironmentPipelineState {
const sorted = [...data.pipelineData.environments].sort((a, b) => a.order - b.order);
// Mock pipeline state
this.pipeline.set({
environments: [
{
name: 'DEV',
version: 'v1.3.0',
status: 'ok',
targetCount: 4,
healthyTargets: 4,
lastDeployment: '10m ago',
driftStatus: 'synced',
},
{
name: 'QA',
version: 'v1.2.5',
status: 'ok',
targetCount: 4,
healthyTargets: 4,
lastDeployment: '2h ago',
driftStatus: 'synced',
},
{
name: 'STAGING',
version: 'v1.2.4',
status: 'pending',
targetCount: 6,
healthyTargets: 6,
lastDeployment: '6h ago',
driftStatus: 'drifted',
},
{
name: 'PROD',
version: 'v1.2.3',
status: 'ok',
targetCount: 20,
healthyTargets: 20,
lastDeployment: '1d ago',
driftStatus: 'synced',
},
],
return {
environments: sorted.map((env) => this.mapEnvironment(env, data.recentReleases, data.activeDeployments)),
lastUpdated: new Date().toISOString(),
});
};
}
// Mock inbox
this.inbox.set({
items: [
{
id: '1',
type: 'approval',
title: '3 approvals pending',
description: 'Release promotions awaiting review',
severity: 'warning',
createdAt: new Date().toISOString(),
actionLink: '/approvals',
},
{
id: '2',
type: 'blocked',
title: '1 blocked promotion (reachability)',
description: 'Critical CVE reachable in v1.2.6',
severity: 'critical',
createdAt: new Date().toISOString(),
actionLink: '/approvals/blocked-1',
},
{
id: '3',
type: 'deployment',
title: '2 failed deployments (retry available)',
description: 'Transient network errors',
severity: 'warning',
createdAt: new Date().toISOString(),
actionLink: '/deployments?status=failed',
},
{
id: '4',
type: 'key-expiry',
title: '1 key expiring in 14 days',
description: 'Signing key needs rotation',
severity: 'info',
createdAt: new Date().toISOString(),
actionLink: '/evidence-audit/trust-signing/keys',
},
],
totalCount: 4,
});
private mapEnvironment(
env: PipelineEnvironment,
releases: RecentRelease[],
deployments: ActiveDeployment[]
): EnvironmentState {
const relatedDeploymentTargets = deployments
.filter((deployment) => this.matchesEnvironment(deployment.environment, env))
.reduce((sum, deployment) => sum + deployment.totalTargets, 0);
// Mock promotions
this.promotions.set([
{
id: 'promo-1',
releaseVersion: 'v1.2.5',
releaseId: 'rel-v1.2.5',
fromEnv: 'QA',
toEnv: 'Staging',
status: 'waiting',
const targetCount = relatedDeploymentTargets > 0 ? relatedDeploymentTargets : Math.max(env.releaseCount, 1);
const healthyTargets = env.healthStatus === 'unhealthy' ? 0 : targetCount;
const latestRelease = releases.find((release) => this.matchesEnvironment(release.currentEnvironment, env));
return {
name: env.displayName || env.name,
version: latestRelease?.version ?? 'unknown',
status: this.mapEnvironmentStatus(env),
targetCount,
healthyTargets,
lastDeployment: latestRelease?.createdAt ? this.formatRelativeTime(latestRelease.createdAt) : 'unknown',
driftStatus: this.mapDriftStatus(env),
};
}
private mapInbox(pending: PendingApproval[], deployments: ActiveDeployment[]): ActionInboxState {
const pendingItems: ActionInboxItem[] = pending.map((approval) => ({
id: approval.id,
type: 'approval',
title: `${approval.releaseName} ${approval.releaseVersion} pending approval`,
description: `${approval.sourceEnvironment} -> ${approval.targetEnvironment}`,
severity: approval.urgency === 'critical' || approval.urgency === 'high' ? 'critical' : 'warning',
createdAt: approval.requestedAt,
actionLink: `/release-control/approvals/${approval.id}`,
}));
const deploymentItems: ActionInboxItem[] = deployments.map((deployment) => ({
id: deployment.id,
type: deployment.status === 'paused' ? 'blocked' : 'deployment',
title: `${deployment.releaseName} ${deployment.releaseVersion} deployment`,
description: `${deployment.environment} (${deployment.completedTargets}/${deployment.totalTargets} targets)`,
severity: deployment.status === 'paused' ? 'critical' : 'info',
createdAt: deployment.startedAt,
actionLink: `/release-control/runs`,
}));
const items = [...pendingItems, ...deploymentItems]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return {
items,
totalCount: items.length,
};
}
private mapPromotions(pending: PendingApproval[], releases: RecentRelease[]): PendingPromotion[] {
return pending.map((approval) => {
const release = releases.find((item) => item.id === approval.releaseId);
const gateStatus: GateSummary['status'] = approval.urgency === 'critical' ? 'WARN' : 'PASS';
return {
id: approval.id,
releaseVersion: approval.releaseVersion,
releaseId: approval.releaseId,
fromEnv: approval.sourceEnvironment,
toEnv: approval.targetEnvironment,
status: release?.status === 'ready' ? 'auto-approved' : 'waiting',
gates: [
{ name: 'SBOM', status: 'PASS' },
{ name: 'Reachability', status: 'WARN' },
{ name: 'Promotion Readiness', status: gateStatus },
],
riskDelta: '+2 new CVEs',
requestedAt: new Date().toISOString(),
requestedBy: 'ci-pipeline',
},
{
id: 'promo-2',
releaseVersion: 'v1.2.6',
releaseId: 'rel-v1.2.6',
fromEnv: 'Dev',
toEnv: 'QA',
status: 'auto-approved',
gates: [
{ name: 'SBOM', status: 'PASS' },
{ name: 'Reachability', status: 'PASS' },
],
riskDelta: 'net safer',
requestedAt: new Date().toISOString(),
requestedBy: 'ci-pipeline',
},
]);
// Mock drift delta
this.driftDelta.set({
promotionsBlocked: 2,
cvesUpdated: 5,
reachableCves: 1,
feedStaleRisks: 1,
configDrifts: 0,
lastEvidenceTime: new Date(Date.now() - 3600000).toISOString(),
riskDelta: approval.urgency === 'critical' ? 'heightened review required' : 'stable',
requestedAt: approval.requestedAt,
requestedBy: approval.requestedBy,
};
});
}
private mapDriftDelta(pending: PendingApproval[]): DriftRiskDelta {
const blocked = pending.filter((approval) => approval.urgency === 'critical').length;
return {
promotionsBlocked: blocked,
cvesUpdated: 0,
reachableCves: blocked,
feedStaleRisks: 0,
configDrifts: 0,
lastEvidenceTime: new Date().toISOString(),
};
}
private mapEnvironmentStatus(env: PipelineEnvironment): EnvironmentState['status'] {
if (env.healthStatus === 'unhealthy') {
return 'failed';
}
if (env.healthStatus === 'degraded') {
return 'blocked';
}
if (env.pendingCount > 0) {
return 'pending';
}
return 'ok';
}
private mapDriftStatus(env: PipelineEnvironment): EnvironmentState['driftStatus'] {
if (env.healthStatus === 'unknown') {
return 'unknown';
}
if (env.healthStatus === 'degraded' || env.healthStatus === 'unhealthy') {
return 'drifted';
}
return 'synced';
}
private matchesEnvironment(environmentName: string | null | undefined, env: PipelineEnvironment): boolean {
if (!environmentName) {
return false;
}
const normalized = environmentName.toLowerCase();
return normalized === env.name.toLowerCase() || normalized === env.displayName.toLowerCase();
}
private formatRelativeTime(isoTime: string): string {
const millis = Date.now() - new Date(isoTime).getTime();
if (!Number.isFinite(millis) || millis < 0) {
return 'unknown';
}
const minutes = Math.floor(millis / 60000);
if (minutes < 1) {
return 'just now';
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
return `${Math.floor(hours / 24)}d ago`;
}
}

View File

@@ -1,4 +1,4 @@
/**
/**
* Dashboard V3 - Mission Board
* Sprint: SPRINT_20260218_012_FE_ui_v2_rewire_dashboard_v3_mission_board (D7-01 through D7-05)
*
@@ -94,19 +94,19 @@ interface MissionSummary {
<div class="summary-card" [class.warning]="summary().blockedPromotions > 0">
<div class="summary-value">{{ summary().activePromotions }}</div>
<div class="summary-label">Active Promotions</div>
<a routerLink="/release-control/promotions" class="summary-link">View all</a>
<a routerLink="/releases/runs" class="summary-link">View all</a>
</div>
<div class="summary-card" [class.critical]="summary().blockedPromotions > 0">
<div class="summary-value">{{ summary().blockedPromotions }}</div>
<div class="summary-label">Blocked Promotions</div>
<a routerLink="/release-control/approvals" class="summary-link">Review</a>
<a routerLink="/releases/approvals" class="summary-link">Review</a>
</div>
<div class="summary-card">
<div class="summary-value env-name">{{ summary().highestRiskEnv }}</div>
<div class="summary-label">Highest Risk Environment</div>
<a routerLink="/security-risk/risk" class="summary-link">Risk detail</a>
<a routerLink="/security" class="summary-link">Risk detail</a>
</div>
<div class="summary-card" [class.warning]="summary().dataIntegrityStatus === 'degraded'"
@@ -116,7 +116,7 @@ interface MissionSummary {
{{ summary().dataIntegrityStatus | titlecase }}
</div>
<div class="summary-label">Data Integrity</div>
<a routerLink="/platform-ops/data-integrity" class="summary-link">Ops detail</a>
<a routerLink="/platform/ops/data-integrity" class="summary-link">Ops detail</a>
</div>
</section>
@@ -124,7 +124,7 @@ interface MissionSummary {
<section class="pipeline-board" aria-label="Regional pipeline board">
<div class="section-header">
<h2 class="section-title">Regional Pipeline</h2>
<a routerLink="/release-control/environments" class="section-link">All environments</a>
<a routerLink="/topology/environments" class="section-link">All environments</a>
</div>
<div class="env-grid">
@@ -174,10 +174,10 @@ interface MissionSummary {
<div class="env-card-footer">
<span class="last-deployed">Deployed {{ env.lastDeployedAt }}</span>
<div class="env-links">
<a [routerLink]="['/release-control/environments', env.id]" class="env-link">
<a [routerLink]="['/topology/environments', env.id, 'posture']" class="env-link">
Detail
</a>
<a [routerLink]="['/security-risk/findings']" [queryParams]="{ env: env.id }" class="env-link">
<a [routerLink]="['/security/findings']" [queryParams]="{ env: env.id }" class="env-link">
Findings
</a>
</div>
@@ -196,7 +196,7 @@ interface MissionSummary {
<section class="risk-table" aria-label="Environments at risk">
<div class="section-header">
<h2 class="section-title">Environments at Risk</h2>
<a routerLink="/release-control/environments" class="section-link">Open environments</a>
<a routerLink="/topology/environments" class="section-link">Open environments</a>
</div>
@if (riskEnvironments().length === 0) {
@@ -227,7 +227,7 @@ interface MissionSummary {
<td>{{ env.birCoverage }}</td>
<td>{{ env.lastDeployedAt }}</td>
<td>
<a [routerLink]="['/release-control/environments', env.id]">Open</a>
<a [routerLink]="['/topology/environments', env.id, 'posture']">Open</a>
</td>
</tr>
}
@@ -243,7 +243,7 @@ interface MissionSummary {
<section class="domain-card" aria-label="SBOM snapshot">
<div class="card-header">
<h2 class="card-title">SBOM Findings Snapshot</h2>
<a routerLink="/security-risk/sbom" class="card-link">View SBOM</a>
<a routerLink="/security/sbom/lake" class="card-link">View SBOM</a>
</div>
<div class="card-body">
<div class="snapshot-stat">
@@ -265,8 +265,8 @@ interface MissionSummary {
}
</div>
<div class="card-footer">
<a routerLink="/security-risk/findings" [queryParams]="{ reachability: 'critical' }" class="card-action">Open Findings</a>
<a routerLink="/release-control" class="card-action">Release Control</a>
<a routerLink="/security/findings" [queryParams]="{ reachability: 'critical' }" class="card-action">Open Findings</a>
<a routerLink="/releases/runs" class="card-action">Release Runs</a>
</div>
</section>
@@ -274,7 +274,7 @@ interface MissionSummary {
<section class="domain-card" aria-label="Reachability summary">
<div class="card-header">
<h2 class="card-title">Reachability</h2>
<a routerLink="/security-risk/reachability" class="card-link">View reachability</a>
<a routerLink="/security/findings" class="card-link">View reachability</a>
</div>
<div class="card-body">
<div class="bir-matrix">
@@ -305,7 +305,7 @@ interface MissionSummary {
</p>
</div>
<div class="card-footer">
<a routerLink="/security-risk/reachability" class="card-action">Deep analysis</a>
<a routerLink="/security/findings" class="card-action">Deep analysis</a>
</div>
</section>
@@ -313,7 +313,7 @@ interface MissionSummary {
<section class="domain-card" aria-label="Nightly ops signals">
<div class="card-header">
<h2 class="card-title">Nightly Ops Signals</h2>
<a routerLink="/platform-ops/data-integrity" class="card-link">Open Data Integrity</a>
<a routerLink="/platform/ops/data-integrity" class="card-link">Open Data Integrity</a>
</div>
<div class="card-body">
@for (signal of nightlyOpsSignals(); track signal.id) {
@@ -327,32 +327,32 @@ interface MissionSummary {
}
</div>
<div class="card-footer">
<a routerLink="/platform-ops/data-integrity" class="card-action">Open Data Integrity</a>
<a routerLink="/platform/ops/data-integrity" class="card-action">Open Data Integrity</a>
</div>
</section>
</div>
<!-- Cross-domain navigation links -->
<nav class="domain-nav" aria-label="Domain navigation">
<a routerLink="/release-control" class="domain-nav-item">
<a routerLink="/releases/runs" class="domain-nav-item">
<span class="domain-icon">&#9654;</span>
Release Control
Release Runs
</a>
<a routerLink="/security-risk" class="domain-nav-item">
<a routerLink="/security" class="domain-nav-item">
<span class="domain-icon">&#9632;</span>
Security &amp; Risk
</a>
<a routerLink="/platform-ops" class="domain-nav-item">
<a routerLink="/platform/ops" class="domain-nav-item">
<span class="domain-icon">&#9670;</span>
Platform Ops
Platform
</a>
<a routerLink="/evidence-audit" class="domain-nav-item">
<a routerLink="/evidence" class="domain-nav-item">
<span class="domain-icon">&#9679;</span>
Evidence &amp; Audit
Evidence (Decision Capsules)
</a>
<a routerLink="/administration" class="domain-nav-item">
<a routerLink="/platform/setup" class="domain-nav-item">
<span class="domain-icon">&#9881;</span>
Administration
Platform Setup
</a>
</nav>
</div>
@@ -1033,3 +1033,4 @@ export class DashboardV3Component {
this.selectedTimeWindow.set(select.value);
}
}

View File

@@ -115,6 +115,27 @@
}
</section>
<section class="detail-section">
<h4 class="section-title">Approvals</h4>
@if ((exception()!.approvals ?? []).length === 0) {
<span class="detail-value">No approvals recorded.</span>
} @else {
<ul class="audit-list">
@for (approval of exception()!.approvals ?? []; track approval.approvalId) {
<li>
<span class="detail-label">{{ approval.approvedBy }}</span>
<span class="detail-value">
{{ formatDate(approval.approvedAt) }}
@if (approval.comment) {
· {{ approval.comment }}
}
</span>
</li>
}
</ul>
}
</section>
<section class="detail-section">
<h4 class="section-title">Extend expiry</h4>
<div class="extend-row">

View File

@@ -44,7 +44,7 @@ export type ActivityEventType =
template: `
<div class="integration-activity">
<header class="activity-header">
<a routerLink="/integrations" class="back-link"><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" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a>
<a routerLink="/platform/integrations" class="back-link"><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" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a>
<h1>Integration Activity</h1>
<p class="subtitle">Audit trail for all integration lifecycle events</p>
</header>
@@ -127,7 +127,7 @@ export type ActivityEventType =
<span class="event-timestamp">{{ formatTimestamp(event.timestamp) }}</span>
</div>
<div class="event-title">
<a [routerLink]="['/integrations', event.integrationId]" class="integration-link">
<a [routerLink]="['/platform/integrations', event.integrationId]" class="integration-link">
{{ event.integrationName }}
</a>
<span class="provider-badge">{{ event.integrationProvider }}</span>

View File

@@ -13,6 +13,8 @@ import {
getProviderLabel,
} from './integration.models';
type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'events' | 'health';
/**
* Integration detail component showing health, activity, and configuration.
* Sprint: SPRINT_20251229_011_FE_integration_hub_ui
@@ -24,7 +26,7 @@ import {
@if (integration) {
<div class="integration-detail">
<header class="detail-header">
<a routerLink="/integrations" class="back-link"><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" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a>
<a routerLink="/platform/integrations" class="back-link"><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" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a>
<h1>{{ integration.name }}</h1>
<span [class]="'status-badge status-' + getStatusColor(integration.status)">
{{ getStatusLabel(integration.status) }}
@@ -56,9 +58,10 @@ import {
</section>
<nav class="detail-tabs">
<button [class.active]="activeTab === 'overview'" (click)="activeTab = 'overview'">Overview</button>
<button [class.active]="activeTab === 'credentials'" (click)="activeTab = 'credentials'">Credentials</button>
<button [class.active]="activeTab === 'scopes-rules'" (click)="activeTab = 'scopes-rules'">Scopes & Rules</button>
<button [class.active]="activeTab === 'events'" (click)="activeTab = 'events'">Events</button>
<button [class.active]="activeTab === 'health'" (click)="activeTab = 'health'">Health</button>
<button [class.active]="activeTab === 'activity'" (click)="activeTab = 'activity'">Activity</button>
<button [class.active]="activeTab === 'settings'" (click)="activeTab = 'settings'">Settings</button>
</nav>
<section class="tab-content">
@switch (activeTab) {
@@ -91,10 +94,62 @@ import {
</div>
}
@if (!integration.tags) {
<p class="placeholder">No tags.</p>
<p class="placeholder">No tags.</p>
}
</div>
}
@case ('credentials') {
<div class="tab-panel">
<h2>Credentials</h2>
<dl class="config-list">
<dt>Auth Reference</dt>
<dd>{{ integration.authRef || 'Not configured' }}</dd>
<dt>Credential Status</dt>
<dd>{{ integration.lastTestSuccess ? 'Valid on last check' : 'Requires attention' }}</dd>
<dt>Last Validation</dt>
<dd>{{ integration.lastTestedAt ? (integration.lastTestedAt | date:'medium') : 'Never' }}</dd>
<dt>Rotation</dt>
<dd>Managed by integration owner workflow.</dd>
</dl>
<div class="settings-actions">
<button class="btn-secondary" (click)="editIntegration()">Edit Integration</button>
<button class="btn-danger" (click)="deleteIntegration()">Delete Integration</button>
</div>
</div>
}
@case ('scopes-rules') {
<div class="tab-panel">
<h2>Scopes & Rules</h2>
<ul class="rules-list">
@for (rule of scopeRules; track rule) {
<li>{{ rule }}</li>
}
</ul>
</div>
}
@case ('events') {
<div class="tab-panel">
<h2>Events</h2>
<table class="event-table" aria-label="Integration events table">
<thead>
<tr>
<th>Timestamp</th>
<th>Event</th>
<th>Correlation ID</th>
</tr>
</thead>
<tbody>
@for (event of recentEvents; track event.timestamp + event.correlationId) {
<tr>
<td>{{ event.timestamp }}</td>
<td>{{ event.message }}</td>
<td>{{ event.correlationId }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@case ('health') {
<div class="tab-panel">
<h2>Health</h2>
@@ -129,21 +184,6 @@ import {
}
</div>
}
@case ('activity') {
<div class="tab-panel">
<h2>Activity</h2>
<p class="placeholder">Activity timeline coming soon...</p>
</div>
}
@case ('settings') {
<div class="tab-panel">
<h2>Settings</h2>
<div class="settings-actions">
<button class="btn-secondary" (click)="editIntegration()">Edit Integration</button>
<button class="btn-danger" (click)="deleteIntegration()">Delete Integration</button>
</div>
</div>
}
}
</section>
</div>
@@ -271,6 +311,37 @@ import {
.settings-actions {
display: flex;
gap: 1rem;
margin-top: 0.75rem;
}
.rules-list {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.35rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.event-table {
width: 100%;
border-collapse: collapse;
}
.event-table th,
.event-table td {
border-bottom: 1px solid var(--color-border-primary);
text-align: left;
padding: 0.6rem 0.4rem;
font-size: 0.82rem;
white-space: nowrap;
}
.event-table th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.btn-primary, .btn-secondary, .btn-danger {
@@ -329,11 +400,21 @@ export class IntegrationDetailComponent implements OnInit {
readonly failureIconSvg = `<svg ${this.svgAttrs}><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`;
integration?: Integration;
activeTab = 'overview';
activeTab: IntegrationDetailTab = 'overview';
testing = false;
checking = false;
lastTestResult?: TestConnectionResponse;
lastHealthResult?: IntegrationHealthResponse;
readonly scopeRules = [
'Read scope required for release and evidence queries.',
'Write scope required only for connector mutation operations.',
'Production connectors require explicit approval before credential updates.',
];
readonly recentEvents = [
{ timestamp: '2026-02-20 10:04 UTC', message: 'Health check passed', correlationId: 'corr-int-1004' },
{ timestamp: '2026-02-20 09:42 UTC', message: 'Token validation warning (latency)', correlationId: 'corr-int-0942' },
{ timestamp: '2026-02-20 08:18 UTC', message: 'Connection test executed', correlationId: 'corr-int-0818' },
];
ngOnInit(): void {
const integrationId = this.route.snapshot.paramMap.get('integrationId');
@@ -408,7 +489,7 @@ export class IntegrationDetailComponent implements OnInit {
editIntegration(): void {
if (!this.integration) return;
void this.router.navigate(['/integrations', this.integration.integrationId], {
void this.router.navigate(['/platform/integrations', this.integration.integrationId], {
queryParams: { edit: '1' },
queryParamsHandling: 'merge',
});
@@ -419,7 +500,7 @@ export class IntegrationDetailComponent implements OnInit {
if (confirm('Are you sure you want to delete this integration?')) {
this.integrationService.delete(this.integration.integrationId).subscribe({
next: () => {
void this.router.navigate(['/integrations']);
void this.router.navigate(['/platform/integrations']);
},
error: (err) => {
alert('Failed to delete integration: ' + err.message);

View File

@@ -1,211 +1,161 @@
import { Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { IntegrationService } from './integration.service';
import { IntegrationType } from './integration.models';
/**
* Integration Hub main dashboard component.
* Sprint: SPRINT_20251229_011_FE_integration_hub_ui
*/
@Component({
selector: 'app-integration-hub',
imports: [RouterModule],
template: `
<div class="integration-hub">
<header class="hub-header">
selector: 'app-integration-hub',
standalone: true,
imports: [RouterModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="integration-hub">
<header>
<h1>Integration Hub</h1>
<p class="subtitle">
Manage registries, SCM providers, CI systems, and feed sources.
<p>
External system connectors for release, security, and evidence flows.
Topology runtime inventory is managed under Topology.
</p>
</header>
<nav class="hub-nav">
<a routerLink="registries" routerLinkActive="active" class="nav-tile">
<span class="tile-icon"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg></span>
<span class="tile-label">Registries</span>
<span class="tile-count">{{ stats.registries }}</span>
<nav class="tiles">
<a routerLink="registries" class="tile">
<span>Registries</span>
<strong>{{ stats.registries }}</strong>
</a>
<a routerLink="scm" routerLinkActive="active" class="nav-tile">
<span class="tile-icon"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<span class="tile-label">SCM</span>
<span class="tile-count">{{ stats.scm }}</span>
<a routerLink="scm" class="tile">
<span>SCM</span>
<strong>{{ stats.scm }}</strong>
</a>
<a routerLink="ci" routerLinkActive="active" class="nav-tile">
<span class="tile-icon"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="tile-label">CI/CD</span>
<span class="tile-count">{{ stats.ci }}</span>
<a routerLink="ci" class="tile">
<span>CI/CD</span>
<strong>{{ stats.ci }}</strong>
</a>
<a routerLink="hosts" routerLinkActive="active" class="nav-tile">
<span class="tile-icon"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
<span class="tile-label">Hosts</span>
<span class="tile-count">{{ stats.hosts }}</span>
<a routerLink="runtime-hosts" class="tile">
<span>Runtimes / Hosts</span>
<strong>{{ stats.runtimeHosts }}</strong>
</a>
<a routerLink="feeds" routerLinkActive="active" class="nav-tile">
<span class="tile-icon"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span>
<span class="tile-label">Feeds</span>
<span class="tile-count">{{ stats.feeds }}</span>
<a routerLink="feeds" class="tile">
<span>Advisory Sources</span>
<strong>{{ stats.advisorySources }}</strong>
</a>
<a routerLink="vex-sources" class="tile">
<span>VEX Sources</span>
<strong>{{ stats.vexSources }}</strong>
</a>
<a routerLink="secrets" class="tile">
<span>Secrets</span>
<strong>{{ stats.secrets }}</strong>
</a>
</nav>
<section class="hub-actions">
<button class="btn-primary" (click)="addIntegration()">
+ Add Integration
</button>
<a routerLink="activity" class="btn-secondary">View Activity</a>
<section class="actions">
<button type="button" (click)="addIntegration()">+ Add Integration</button>
<a routerLink="activity">View Activity</a>
</section>
<section class="hub-summary">
<section class="activity" aria-live="polite">
<h2>Recent Activity</h2>
<div class="coming-soon" role="status" aria-live="polite">
<div class="coming-soon__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</div>
<div>
<p class="coming-soon__title">Activity stream is coming soon</p>
<p class="coming-soon__description">
Connector timeline events will appear here once ingestion telemetry is fully enabled.
</p>
</div>
</div>
<p class="activity__title">Activity stream is coming soon</p>
<p class="activity__text">
Connector timeline events will appear here once integration telemetry wiring is complete.
</p>
</section>
</div>
</section>
`,
styles: [`
styles: [`
.integration-hub {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.hub-header {
margin-bottom: 2rem;
}
.hub-header h1 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
}
.subtitle {
color: var(--color-text-secondary);
margin: 0;
}
.hub-nav {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
gap: 0.8rem;
max-width: 1150px;
margin: 0 auto;
padding: 1rem 0;
}
.nav-tile {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
background: var(--color-surface-primary);
header h1 {
margin: 0;
font-size: 1.4rem;
}
header p {
margin: 0.25rem 0 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
max-width: 72ch;
}
.tiles {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.tile {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
text-decoration: none;
color: inherit;
transition: all 0.2s;
padding: 0.65rem;
display: grid;
gap: 0.2rem;
}
.nav-tile:hover {
border-color: var(--color-brand-primary);
box-shadow: var(--shadow-md);
}
.nav-tile.active {
border-color: var(--color-brand-primary);
background: var(--color-nav-hover);
}
.tile-icon {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
color: var(--color-brand-primary);
}
.tile-label {
font-weight: var(--font-weight-medium);
}
.tile-count {
font-size: 0.875rem;
.tile span {
font-size: 0.76rem;
color: var(--color-text-secondary);
}
.hub-actions {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.btn-primary, .btn-secondary {
padding: 0.75rem 1.5rem;
border-radius: var(--radius-md);
font-weight: var(--font-weight-medium);
cursor: pointer;
text-decoration: none;
}
.btn-primary {
background: var(--color-brand-primary);
.tile strong {
font-size: 1rem;
color: var(--color-text-heading);
border: none;
}
.btn-secondary {
background: transparent;
color: var(--color-brand-primary);
border: 1px solid var(--color-brand-primary);
}
.hub-summary h2 {
font-size: 1.25rem;
margin: 0 0 1rem;
}
.coming-soon {
.actions {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 1rem;
gap: 0.45rem;
flex-wrap: wrap;
}
.actions button,
.actions a {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
text-decoration: none;
color: var(--color-brand-primary);
font-size: 0.74rem;
padding: 0.3rem 0.55rem;
cursor: pointer;
}
.activity {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.25rem;
}
.coming-soon__icon {
width: 2rem;
height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
color: var(--color-brand-primary);
background: var(--color-brand-soft);
flex-shrink: 0;
}
.coming-soon__title {
.activity h2 {
margin: 0;
font-size: 0.9375rem;
font-size: 0.95rem;
}
.activity__title {
margin: 0;
font-size: 0.82rem;
font-weight: var(--font-weight-semibold);
}
.coming-soon__description {
margin: 0.25rem 0 0;
.activity__text {
margin: 0;
font-size: 0.76rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
`]
`],
})
export class IntegrationHubComponent {
private readonly integrationService = inject(IntegrationService);
@@ -215,8 +165,10 @@ export class IntegrationHubComponent {
registries: 0,
scm: 0,
ci: 0,
hosts: 0,
feeds: 0,
runtimeHosts: 0,
advisorySources: 0,
vexSources: 0,
secrets: 0,
};
constructor() {
@@ -224,25 +176,39 @@ export class IntegrationHubComponent {
}
private loadStats(): void {
// Load integration counts by type
this.integrationService.list({ type: IntegrationType.Registry, pageSize: 1 }).subscribe({
next: (res) => this.stats.registries = res.totalCount,
next: (res) => (this.stats.registries = res.totalCount),
error: () => (this.stats.registries = 0),
});
this.integrationService.list({ type: IntegrationType.Scm, pageSize: 1 }).subscribe({
next: (res) => this.stats.scm = res.totalCount,
next: (res) => (this.stats.scm = res.totalCount),
error: () => (this.stats.scm = 0),
});
this.integrationService.list({ type: IntegrationType.CiCd, pageSize: 1 }).subscribe({
next: (res) => this.stats.ci = res.totalCount,
next: (res) => (this.stats.ci = res.totalCount),
error: () => (this.stats.ci = 0),
});
this.integrationService.list({ type: IntegrationType.RuntimeHost, pageSize: 1 }).subscribe({
next: (res) => this.stats.hosts = res.totalCount,
next: (res) => (this.stats.runtimeHosts = res.totalCount),
error: () => (this.stats.runtimeHosts = 0),
});
this.integrationService.list({ type: IntegrationType.FeedMirror, pageSize: 1 }).subscribe({
next: (res) => this.stats.feeds = res.totalCount,
next: (res) => {
this.stats.advisorySources = res.totalCount;
this.stats.vexSources = res.totalCount;
},
error: () => {
this.stats.advisorySources = 0;
this.stats.vexSources = 0;
},
});
this.integrationService.list({ type: IntegrationType.RepoSource, pageSize: 1 }).subscribe({
next: (res) => (this.stats.secrets = res.totalCount),
error: () => (this.stats.secrets = 0),
});
}
addIntegration(): void {
void this.router.navigate(['/integrations/onboarding/registry']);
void this.router.navigate(['/platform/integrations/onboarding/registry']);
}
}

View File

@@ -1,26 +1,25 @@
/**
* Integration Hub Routes
* Updated: SPRINT_20260218_008_FE_ui_v2_rewire_integrations_platform_ops_data_integrity (I3-01, I3-03)
* Updated: SPRINT_20260220_031_FE_platform_global_ops_integrations_setup_advisory_recheck
*
* Canonical Integrations taxonomy:
* '' Hub overview with health summary and category navigation
* registries Container registries
* scm Source control managers
* ci CI/CD pipelines
* hosts — Target runtimes / hosts
* secrets — Secrets managers / vaults
* feeds — Advisory feed connectors
* notifications — Notification providers
* :id Integration detail (standard contract template)
* '' - Hub overview with health summary and category navigation
* registries - Container registries
* scm - Source control managers
* ci - CI/CD systems
* runtime-hosts - Runtime and host connector inventory
* feeds - Advisory source connectors
* vex-sources - VEX source connectors
* secrets - Secrets managers / vaults
* :id - Integration detail (standard contract template)
*
* Data Integrity cross-link: connectivity/freshness owned here;
* decision impact consumed by Security & Risk.
* Ownership boundary:
* hosts/targets/agents are managed in Topology and only aliased here.
*/
import { Routes } from '@angular/router';
export const integrationHubRoutes: Routes = [
// Root — Integrations overview with health summary and category navigation
{
path: '',
title: 'Integrations',
@@ -29,7 +28,6 @@ export const integrationHubRoutes: Routes = [
import('./integration-hub.component').then((m) => m.IntegrationHubComponent),
},
// Onboarding flow
{
path: 'onboarding',
title: 'Add Integration',
@@ -45,7 +43,6 @@ export const integrationHubRoutes: Routes = [
import('../integrations/integrations-hub.component').then((m) => m.IntegrationsHubComponent),
},
// Category: Container Registries
{
path: 'registries',
title: 'Registries',
@@ -53,8 +50,6 @@ export const integrationHubRoutes: Routes = [
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
},
// Category: Source Control
{
path: 'scm',
title: 'Source Control',
@@ -62,8 +57,6 @@ export const integrationHubRoutes: Routes = [
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
},
// Category: CI/CD Pipelines
{
path: 'ci',
title: 'CI/CD',
@@ -76,45 +69,69 @@ export const integrationHubRoutes: Routes = [
pathMatch: 'full',
redirectTo: 'ci',
},
// Category: Targets / Runtimes
{
path: 'hosts',
title: 'Targets / Runtimes',
data: { breadcrumb: 'Targets / Runtimes', type: 'Host' },
path: 'runtime-hosts',
title: 'Runtimes / Hosts',
data: { breadcrumb: 'Runtimes / Hosts', type: 'RuntimeHost' },
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
},
{
path: 'runtimes-hosts',
pathMatch: 'full',
redirectTo: 'runtime-hosts',
},
// Topology ownership aliases.
{
path: 'hosts',
pathMatch: 'full',
redirectTo: '/topology/hosts',
},
{
path: 'targets-runtimes',
pathMatch: 'full',
redirectTo: 'hosts',
redirectTo: '/topology/targets',
},
{
path: 'targets',
pathMatch: 'full',
redirectTo: 'hosts',
redirectTo: '/topology/targets',
},
{
path: 'agents',
pathMatch: 'full',
redirectTo: '/topology/agents',
},
{
path: 'feeds',
title: 'Advisory Sources',
data: { breadcrumb: 'Advisory Sources', type: 'FeedMirror' },
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
},
{
path: 'vex-sources',
title: 'VEX Sources',
data: { breadcrumb: 'VEX Sources', type: 'FeedMirror' },
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
},
{
path: 'advisory-vex',
pathMatch: 'full',
redirectTo: 'feeds',
},
// Category: Secrets Managers
{
path: 'secrets',
title: 'Secrets',
data: { breadcrumb: 'Secrets', type: 'Secrets' },
data: { breadcrumb: 'Secrets', type: 'RepoSource' },
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
},
// Category: Advisory Feed Connectors
{
path: 'feeds',
title: 'Advisory Feeds',
data: { breadcrumb: 'Advisory Feeds', type: 'Feed' },
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
},
// Category: Notification Providers
{
path: 'notifications',
title: 'Notification Providers',
@@ -123,7 +140,6 @@ export const integrationHubRoutes: Routes = [
import('./integration-list.component').then((m) => m.IntegrationListComponent),
},
// SBOM sources (canonical path under integrations)
{
path: 'sbom-sources',
title: 'SBOM Sources',
@@ -132,7 +148,6 @@ export const integrationHubRoutes: Routes = [
import('../sbom-sources/sbom-sources.routes').then((m) => m.SBOM_SOURCES_ROUTES),
},
// Activity log
{
path: 'activity',
title: 'Activity',
@@ -141,7 +156,6 @@ export const integrationHubRoutes: Routes = [
import('./integration-activity.component').then((m) => m.IntegrationActivityComponent),
},
// Integration detail — standard contract template (I3-03)
{
path: ':integrationId',
title: 'Integration Detail',

View File

@@ -67,7 +67,7 @@ import {
@for (integration of integrations; track integration.integrationId) {
<tr>
<td>
<a [routerLink]="['/integrations', integration.integrationId]">{{ integration.name }}</a>
<a [routerLink]="['/platform/integrations', integration.integrationId]">{{ integration.name }}</a>
</td>
<td>{{ getProviderName(integration.provider) }}</td>
<td>
@@ -85,7 +85,7 @@ import {
<button (click)="editIntegration(integration)" title="Edit"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button>
<button (click)="testConnection(integration)" title="Test Connection"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22v-5"/><path d="M9 7V2"/><path d="M15 7V2"/><path d="M6 13V8h12v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4Z"/></svg></button>
<button (click)="checkHealth(integration)" title="Check Health"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></button>
<a [routerLink]="['/integrations', integration.integrationId]" title="View Details"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a>
<a [routerLink]="['/platform/integrations', integration.integrationId]" title="View Details"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a>
</td>
</tr>
}
@@ -331,12 +331,12 @@ export class IntegrationListComponent implements OnInit {
}
editIntegration(integration: Integration): void {
void this.router.navigate(['/integrations', integration.integrationId], { queryParams: { edit: true } });
void this.router.navigate(['/platform/integrations', integration.integrationId], { queryParams: { edit: true } });
}
addIntegration(): void {
void this.router.navigate(
['/integrations/onboarding', this.getOnboardingTypeSegment(this.integrationType)]
['/platform/integrations/onboarding', this.getOnboardingTypeSegment(this.integrationType)]
);
}
@@ -363,7 +363,9 @@ export class IntegrationListComponent implements OnInit {
case IntegrationType.RuntimeHost:
return 'host';
case IntegrationType.FeedMirror:
return 'registry';
return 'feed';
case IntegrationType.RepoSource:
return 'secrets';
case IntegrationType.Registry:
default:
return 'registry';

View File

@@ -10,12 +10,14 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { UpperCasePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
type SignalStatus = 'ok' | 'warn' | 'fail';
type SignalState = 'ok' | 'warn' | 'fail';
type SignalImpact = 'BLOCKING' | 'DEGRADED' | 'INFO';
interface TrustSignal {
id: string;
label: string;
status: SignalStatus;
state: SignalState;
impact: SignalImpact;
detail: string;
route: string;
}
@@ -74,8 +76,14 @@ interface FailureItem {
@for (item of trustSignals; track item.id) {
<a class="trust-item" [routerLink]="item.route">
<span class="trust-item__label">{{ item.label }}</span>
<span class="trust-item__status" [class]="'trust-item__status trust-item__status--' + item.status">
{{ item.status | uppercase }}
<span class="trust-item__status" [class]="'trust-item__status trust-item__status--' + item.state">
{{ item.state | uppercase }}
</span>
<span
class="trust-item__impact"
[class]="'trust-item__impact trust-item__impact--' + item.impact.toLowerCase()"
>
Impact: {{ item.impact }}
</span>
<span class="trust-item__detail">{{ item.detail }}</span>
</a>
@@ -90,7 +98,7 @@ interface FailureItem {
<ul class="list">
@for (decision of impactedDecisions; track decision.id) {
<li>
<a [routerLink]="'/release-control/approvals'" [queryParams]="{ releaseId: decision.id }">{{ decision.name }}</a>
<a [routerLink]="'/releases/approvals'" [queryParams]="{ releaseId: decision.id }">{{ decision.name }}</a>
<span>{{ decision.reason }}</span>
</li>
} @empty {
@@ -115,13 +123,13 @@ interface FailureItem {
<section class="panel" aria-label="Drilldowns">
<h2>Drilldowns</h2>
<div class="drilldowns">
<a routerLink="/platform-ops/data-integrity/nightly-ops">Nightly Ops Report</a>
<a routerLink="/platform-ops/data-integrity/feeds-freshness">Feeds Freshness</a>
<a routerLink="/platform-ops/data-integrity/scan-pipeline">Scan Pipeline Health</a>
<a routerLink="/platform-ops/data-integrity/reachability-ingest">Reachability Ingest Health</a>
<a routerLink="/platform-ops/data-integrity/integration-connectivity">Integration Connectivity</a>
<a routerLink="/platform-ops/data-integrity/dlq">DLQ and Replays</a>
<a routerLink="/platform-ops/data-integrity/slos">Data Quality SLOs</a>
<a routerLink="/platform/ops/data-integrity/nightly-ops">Nightly Ops Report</a>
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Feeds Freshness</a>
<a routerLink="/platform/ops/data-integrity/scan-pipeline">Scan Pipeline Health</a>
<a routerLink="/platform/ops/data-integrity/reachability-ingest">Reachability Ingest Health</a>
<a routerLink="/platform/ops/data-integrity/integration-connectivity">Integration Connectivity</a>
<a routerLink="/platform/ops/data-integrity/dlq">DLQ and Replays</a>
<a routerLink="/platform/ops/data-integrity/slos">Data Quality SLOs</a>
</div>
</section>
</div>
@@ -239,6 +247,29 @@ interface FailureItem {
color: var(--color-text-muted);
}
.trust-item__impact {
width: fit-content;
border-radius: var(--radius-full);
padding: 0.1rem 0.45rem;
font-size: 0.66rem;
font-weight: var(--font-weight-semibold);
}
.trust-item__impact--blocking {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.trust-item__impact--degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.trust-item__impact--info {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
.grid-two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -291,37 +322,42 @@ export class DataIntegrityOverviewComponent {
{
id: 'feeds',
label: 'Feeds Freshness',
status: 'warn',
state: 'warn',
impact: 'BLOCKING',
detail: 'NVD feed stale by 3h 12m',
route: '/platform-ops/data-integrity/feeds-freshness',
route: '/platform/ops/data-integrity/feeds-freshness',
},
{
id: 'scan',
label: 'SBOM Pipeline',
status: 'ok',
state: 'ok',
impact: 'INFO',
detail: 'Nightly rescan completed',
route: '/platform-ops/data-integrity/scan-pipeline',
route: '/platform/ops/data-integrity/scan-pipeline',
},
{
id: 'reachability',
label: 'Reachability Ingest',
status: 'warn',
state: 'warn',
impact: 'DEGRADED',
detail: 'Runtime backlog elevated',
route: '/platform-ops/data-integrity/reachability-ingest',
route: '/platform/ops/data-integrity/reachability-ingest',
},
{
id: 'integrations',
label: 'Integrations',
status: 'ok',
state: 'ok',
impact: 'INFO',
detail: 'Core connectors are reachable',
route: '/platform-ops/data-integrity/integration-connectivity',
route: '/platform/ops/data-integrity/integration-connectivity',
},
{
id: 'dlq',
label: 'DLQ',
status: 'warn',
state: 'warn',
impact: 'DEGRADED',
detail: '3 items pending replay',
route: '/platform-ops/data-integrity/dlq',
route: '/platform/ops/data-integrity/dlq',
},
];
@@ -343,19 +379,19 @@ export class DataIntegrityOverviewComponent {
id: 'failure-nvd',
title: 'NVD sync lag',
detail: 'Feed lag exceeds SLA for release-critical path.',
route: '/platform-ops/data-integrity/feeds-freshness',
route: '/platform/ops/data-integrity/feeds-freshness',
},
{
id: 'failure-runtime',
title: 'Runtime ingest backlog',
detail: 'Runtime source queue depth is increasing.',
route: '/platform-ops/data-integrity/reachability-ingest',
route: '/platform/ops/data-integrity/reachability-ingest',
},
{
id: 'failure-dlq',
title: 'DLQ replay queue',
detail: 'Pending replay items block confidence for approvals.',
route: '/platform-ops/data-integrity/dlq',
route: '/platform/ops/data-integrity/dlq',
},
];

View File

@@ -49,7 +49,7 @@ interface SloRow {
<footer class="links">
<a routerLink="/administration/system">Open System SLO Monitoring</a>
<a routerLink="/release-control/approvals">Open impacted approvals</a>
<a routerLink="/releases/approvals">Open impacted approvals</a>
</footer>
</div>
`,
@@ -160,3 +160,4 @@ export class DataQualitySlosPageComponent {
},
];
}

View File

@@ -60,9 +60,9 @@ interface DlqItem {
<td>{{ item.payload }}</td>
<td>{{ item.age }}</td>
<td class="actions">
<a routerLink="/platform-ops/dead-letter">Replay</a>
<a routerLink="/platform-ops/dead-letter">View</a>
<a routerLink="/platform-ops/data-integrity/nightly-ops">Link job</a>
<a routerLink="/platform/ops/dead-letter">Replay</a>
<a routerLink="/platform/ops/dead-letter">View</a>
<a routerLink="/platform/ops/data-integrity/nightly-ops">Link job</a>
</td>
</tr>
} @empty {
@@ -76,7 +76,7 @@ interface DlqItem {
</div>
<footer class="links">
<a routerLink="/platform-ops/dead-letter">Open Dead Letter</a>
<a routerLink="/platform/ops/dead-letter">Open Dead Letter</a>
</footer>
</div>
`,
@@ -216,3 +216,4 @@ export class DlqReplaysPageComponent {
this.items.filter((item) => item.bucketId === this.selectedBucketId())
);
}

View File

@@ -48,9 +48,13 @@ interface FeedRow {
</table>
<footer class="links">
<a routerLink="/platform-ops/feeds">Open Feeds and AirGap Ops</a>
<a routerLink="/platform-ops/feeds/locks">Apply Version Lock</a>
<a routerLink="/platform-ops/feeds/mirror">Retry source sync</a>
<a [routerLink]="['/platform/ops/feeds-airgap']">Open Feeds & Airgap</a>
<a [routerLink]="['/platform/ops/feeds-airgap']" [queryParams]="{ tab: 'version-locks' }">
Apply Version Lock
</a>
<a [routerLink]="['/platform/ops/feeds-airgap']" [queryParams]="{ tab: 'feed-mirrors' }">
Retry source sync
</a>
</footer>
</div>
`,
@@ -160,3 +164,4 @@ export class FeedsFreshnessPageComponent {
},
];
}

View File

@@ -41,10 +41,10 @@ interface ConnectorRow {
<td>{{ row.dependentPipelines }}</td>
<td>{{ row.impact }}</td>
<td class="actions">
<a routerLink="/integrations">Open Detail</a>
<a routerLink="/integrations">Test</a>
<a routerLink="/platform-ops/data-integrity/nightly-ops">View dependent jobs</a>
<a routerLink="/release-control/approvals">View impacted approvals</a>
<a routerLink="/platform/integrations">Open Detail</a>
<a routerLink="/platform/integrations">Test</a>
<a routerLink="/platform/ops/data-integrity/nightly-ops">View dependent jobs</a>
<a routerLink="/releases/approvals">View impacted approvals</a>
</td>
</tr>
}
@@ -52,7 +52,7 @@ interface ConnectorRow {
</table>
<footer class="links">
<a routerLink="/integrations">Open Integrations Hub</a>
<a routerLink="/platform/integrations">Open Integrations Hub</a>
</footer>
</div>
`,
@@ -174,3 +174,5 @@ export class IntegrationConnectivityPageComponent {
},
];
}

View File

@@ -26,7 +26,7 @@ interface AffectedItem {
<section class="panel">
<h2>Integration Reference</h2>
<a routerLink="/integrations">Jenkins connector (job trigger source)</a>
<a routerLink="/platform/integrations">Jenkins connector (job trigger source)</a>
</section>
<section class="panel">
@@ -44,10 +44,10 @@ interface AffectedItem {
</section>
<footer class="links">
<a routerLink="/release-control/approvals">Open impacted approvals</a>
<a routerLink="/release-control/bundles">Open bundles</a>
<a routerLink="/platform-ops/data-integrity/dlq">Open DLQ bucket</a>
<a routerLink="/platform-ops/orchestrator/jobs">Open logs</a>
<a routerLink="/releases/approvals">Open impacted approvals</a>
<a routerLink="/releases/versions">Open bundles</a>
<a routerLink="/platform/ops/data-integrity/dlq">Open DLQ bucket</a>
<a routerLink="/platform/ops/orchestrator/jobs">Open logs</a>
</footer>
</div>
`,
@@ -149,3 +149,5 @@ export class DataIntegrityJobRunDetailPageComponent implements OnInit, OnDestroy
this.breadcrumbService.clearContextCrumbs();
}
}

View File

@@ -67,11 +67,11 @@ interface NightlyJobRow {
</td>
<td>{{ row.impact }}</td>
<td class="actions">
<a [routerLink]="['/platform-ops/data-integrity/nightly-ops', row.runId]">View Run</a>
<a routerLink="/platform-ops/scheduler/runs">Open Scheduler</a>
<a routerLink="/platform-ops/orchestrator/jobs">Open Orchestrator</a>
<a routerLink="/integrations">Open Integration</a>
<a routerLink="/platform-ops/dead-letter">Open DLQ</a>
<a [routerLink]="['/platform/ops/data-integrity/nightly-ops', row.runId]">View Run</a>
<a routerLink="/platform/ops/scheduler/runs">Open Scheduler</a>
<a routerLink="/platform/ops/orchestrator/jobs">Open Orchestrator</a>
<a routerLink="/platform/integrations">Open Integration</a>
<a routerLink="/platform/ops/dead-letter">Open DLQ</a>
</td>
</tr>
}
@@ -250,3 +250,4 @@ export class NightlyOpsReportPageComponent {
},
];
}

View File

@@ -53,9 +53,9 @@ interface IngestRow {
</table>
<footer class="links">
<a routerLink="/platform-ops/agents">Open Agents</a>
<a routerLink="/platform-ops/data-integrity/dlq">Open DLQ bucket</a>
<a routerLink="/release-control/approvals">Open impacted approvals</a>
<a routerLink="/platform/ops/agents">Open Agents</a>
<a routerLink="/platform/ops/data-integrity/dlq">Open DLQ bucket</a>
<a routerLink="/releases/approvals">Open impacted approvals</a>
</footer>
</div>
`,
@@ -198,3 +198,5 @@ export class ReachabilityIngestHealthPageComponent {
},
];
}

View File

@@ -42,9 +42,9 @@ interface Stage {
</section>
<footer class="links">
<a routerLink="/platform-ops/data-integrity/nightly-ops">Nightly Ops Report</a>
<a routerLink="/platform-ops/data-integrity/feeds-freshness">Feeds Freshness</a>
<a routerLink="/integrations">Integrations</a>
<a routerLink="/platform/ops/data-integrity/nightly-ops">Nightly Ops Report</a>
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Feeds Freshness</a>
<a routerLink="/platform/integrations">Integrations</a>
<a routerLink="/security-risk/findings">Security Findings</a>
</footer>
</div>
@@ -173,3 +173,4 @@ export class ScanPipelineHealthPageComponent {
readonly affectedEnvironments = 3;
readonly blockedApprovals = 2;
}

View File

@@ -0,0 +1,286 @@
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
@Component({
selector: 'app-platform-feeds-airgap-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="feeds-offline">
<header class="feeds-offline__header">
<div>
<h1>Feeds & Airgap</h1>
<p>
Feed mirror freshness, airgap bundle workflows, and version lock controls for deterministic
release decisions.
</p>
</div>
<div class="feeds-offline__actions">
<a routerLink="/platform/integrations/feeds">Configure Sources</a>
<button type="button">Sync Now</button>
<button type="button">Import Airgap Bundle</button>
</div>
</header>
<nav class="tabs">
<button type="button" [class.active]="tab() === 'feed-mirrors'" (click)="tab.set('feed-mirrors')">
Feed Mirrors
</button>
<button type="button" [class.active]="tab() === 'airgap-bundles'" (click)="tab.set('airgap-bundles')">
Airgap Bundles
</button>
<button type="button" [class.active]="tab() === 'version-locks'" (click)="tab.set('version-locks')">
Version Locks
</button>
</nav>
<section class="summary">
<span>Mirrors 2</span>
<span>Synced 1</span>
<span>Stale 1</span>
<span>Errors 1</span>
<span>Storage 12.4 GB</span>
</section>
<section class="status-banner">
<strong>Feeds degraded</strong>
<span>Impact: BLOCKING</span>
<span>Mode: last-known-good snapshot (read-only)</span>
<code>corr-feed-8841</code>
<button type="button">Retry</button>
</section>
<article class="panel">
@if (tab() === 'feed-mirrors') {
<table aria-label="Feed mirrors table">
<thead>
<tr>
<th>Name</th>
<th>Source</th>
<th>Last Sync</th>
<th>Freshness</th>
<th>Status</th>
<th>Impact</th>
</tr>
</thead>
<tbody>
<tr>
<td>NVD Mirror</td>
<td>https://nvd.nist.gov</td>
<td>08:10 UTC</td>
<td>Stale 3h12m</td>
<td>WARN</td>
<td>BLOCKING</td>
</tr>
<tr>
<td>OSV Mirror</td>
<td>https://osv.dev</td>
<td>11:58 UTC</td>
<td>Fresh</td>
<td>OK</td>
<td>INFO</td>
</tr>
</tbody>
</table>
}
@if (tab() === 'airgap-bundles') {
<p>Offline import/export workflows and bundle verification controls.</p>
<div class="panel__links">
<a routerLink="/platform/ops/offline-kit">Open Offline Kit Operations</a>
<a routerLink="/evidence/exports">Export Evidence Bundle</a>
</div>
}
@if (tab() === 'version-locks') {
<p>Freeze upstream feed inputs used by promotion gates and replay evidence.</p>
<div class="panel__links">
<a routerLink="/platform/setup/feed-policy">Open Feed Policy</a>
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Open Freshness Lens</a>
</div>
}
</article>
</section>
`,
styles: [`
.feeds-offline {
display: grid;
gap: 0.65rem;
}
.feeds-offline__header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: start;
}
.feeds-offline__header h1 {
margin: 0;
}
.feeds-offline__header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 68ch;
}
.feeds-offline__actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.feeds-offline__actions a,
.feeds-offline__actions button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.73rem;
padding: 0.28rem 0.5rem;
cursor: pointer;
}
.tabs {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.tabs button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.15rem 0.6rem;
font-size: 0.72rem;
cursor: pointer;
}
.tabs button.active {
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
.summary {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.summary span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.7rem;
padding: 0.12rem 0.45rem;
}
.status-banner {
border: 1px solid var(--color-status-warning-text);
border-radius: var(--radius-md);
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
padding: 0.45rem 0.55rem;
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
align-items: center;
font-size: 0.73rem;
}
.status-banner code {
font-size: 0.68rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
padding: 0.05rem 0.3rem;
}
.status-banner button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-brand-primary);
cursor: pointer;
font-size: 0.67rem;
padding: 0.08rem 0.34rem;
}
.panel {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.6rem;
display: grid;
gap: 0.4rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
padding: 0.4rem;
font-size: 0.74rem;
white-space: nowrap;
}
th {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.panel p {
margin: 0;
font-size: 0.76rem;
color: var(--color-text-secondary);
}
.panel__links {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.panel a {
font-size: 0.74rem;
color: var(--color-brand-primary);
text-decoration: none;
}
`],
})
export class PlatformFeedsAirgapPageComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
readonly tab = signal<FeedsOfflineTab>('feed-mirrors');
ngOnInit(): void {
this.route.queryParamMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
const requested = params.get('tab');
if (
requested === 'feed-mirrors' ||
requested === 'airgap-bundles' ||
requested === 'version-locks'
) {
this.tab.set(requested);
}
});
}
}

View File

@@ -0,0 +1,640 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
type JobsQueuesTab = 'jobs' | 'runs' | 'schedules' | 'dead-letters' | 'workers';
type JobImpact = 'BLOCKING' | 'DEGRADED' | 'INFO';
interface JobDefinitionRow {
id: string;
name: string;
type: string;
lastRun: string;
health: 'OK' | 'WARN' | 'DLQ';
}
interface JobRunRow {
id: string;
job: string;
status: 'RUNNING' | 'COMPLETED' | 'FAILED' | 'DEAD-LETTER';
startedAt: string;
duration: string;
impact: JobImpact;
correlationId: string;
}
interface ScheduleRow {
id: string;
name: string;
cron: string;
nextRun: string;
lastStatus: 'OK' | 'WARN' | 'FAIL';
}
interface DeadLetterRow {
id: string;
timestamp: string;
job: string;
error: string;
retryable: 'YES' | 'NO';
impact: JobImpact;
correlationId: string;
}
interface WorkerRow {
id: string;
name: string;
queue: string;
state: 'HEALTHY' | 'DEGRADED';
capacity: string;
heartbeat: string;
}
@Component({
selector: 'app-platform-jobs-queues-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="jobs-queues">
<header class="jobs-queues__header">
<div>
<h1>Jobs & Queues</h1>
<p>
Unified operator surface for orchestrator jobs, scheduler runs, schedules,
dead letters, and worker fleet posture.
</p>
</div>
<div class="jobs-queues__actions">
<a routerLink="/platform/ops/data-integrity">Open Data Integrity</a>
<a routerLink="/platform/ops/doctor">Run Diagnostics</a>
</div>
</header>
<nav class="tabs" aria-label="Jobs and queues tabs">
<button type="button" [class.active]="tab() === 'jobs'" (click)="tab.set('jobs')">Jobs</button>
<button type="button" [class.active]="tab() === 'runs'" (click)="tab.set('runs')">Runs</button>
<button type="button" [class.active]="tab() === 'schedules'" (click)="tab.set('schedules')">Schedules</button>
<button type="button" [class.active]="tab() === 'dead-letters'" (click)="tab.set('dead-letters')">Dead Letters</button>
<button type="button" [class.active]="tab() === 'workers'" (click)="tab.set('workers')">Workers</button>
</nav>
<section class="kpis" aria-label="Queue summary">
<span>Running {{ runsByStatus('RUNNING') }}</span>
<span>Failed {{ runsByStatus('FAILED') }}</span>
<span>Dead-letter {{ runsByStatus('DEAD-LETTER') }}</span>
<span>Schedules {{ schedules.length }}</span>
<span>Workers {{ workers.length }}</span>
</section>
<section class="filters">
<label>
Search
<input type="search" placeholder="Job id, run id, correlation id" />
</label>
<label>
Status
<select>
<option>All</option>
<option>RUNNING</option>
<option>FAILED</option>
<option>DEAD-LETTER</option>
<option>COMPLETED</option>
</select>
</label>
<label>
Type
<select>
<option>All</option>
<option>security</option>
<option>supply</option>
<option>evidence</option>
<option>feeds</option>
</select>
</label>
</section>
@if (tab() === 'jobs') {
<section class="table-wrap">
<table aria-label="Job definitions table">
<thead>
<tr>
<th>Job</th>
<th>Type</th>
<th>Last Run</th>
<th>Health</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (row of jobs; track row.id) {
<tr>
<td>{{ row.name }}</td>
<td>{{ row.type }}</td>
<td>{{ row.lastRun }}</td>
<td><span class="pill" [class]="'pill pill--' + row.health.toLowerCase()">{{ row.health }}</span></td>
<td class="actions">
<a routerLink="/platform/ops/jobs-queues">View</a>
<a routerLink="/platform/ops/jobs-queues">Run Now</a>
</td>
</tr>
}
</tbody>
</table>
</section>
}
@if (tab() === 'runs') {
<section class="table-wrap">
<table aria-label="Job runs table">
<thead>
<tr>
<th>Run ID</th>
<th>Job</th>
<th>Status</th>
<th>Started</th>
<th>Duration</th>
<th>Impact</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (row of runs; track row.id) {
<tr>
<td>{{ row.id }}</td>
<td>{{ row.job }}</td>
<td><span class="pill" [class]="'pill pill--' + row.status.toLowerCase().replace('-', '')">{{ row.status }}</span></td>
<td>{{ row.startedAt }}</td>
<td>{{ row.duration }}</td>
<td><span class="impact" [class]="'impact impact--' + row.impact.toLowerCase()">{{ row.impact }}</span></td>
<td class="actions">
<a routerLink="/platform/ops/jobs-queues">View</a>
<button type="button" (click)="copyCorrelationId(row.correlationId)">Copy CorrID</button>
</td>
</tr>
}
</tbody>
</table>
</section>
}
@if (tab() === 'schedules') {
<section class="table-wrap">
<table aria-label="Schedules table">
<thead>
<tr>
<th>Schedule</th>
<th>Cron</th>
<th>Next Run</th>
<th>Last Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (row of schedules; track row.id) {
<tr>
<td>{{ row.name }}</td>
<td>{{ row.cron }}</td>
<td>{{ row.nextRun }}</td>
<td><span class="pill" [class]="'pill pill--' + row.lastStatus.toLowerCase()">{{ row.lastStatus }}</span></td>
<td class="actions">
<a routerLink="/platform/ops/jobs-queues">Edit</a>
<a routerLink="/platform/ops/jobs-queues">Pause</a>
</td>
</tr>
}
</tbody>
</table>
</section>
}
@if (tab() === 'dead-letters') {
<section class="table-wrap">
<table aria-label="Dead letters table">
<thead>
<tr>
<th>Timestamp</th>
<th>Job</th>
<th>Error</th>
<th>Retryable</th>
<th>Impact</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (row of deadLetters; track row.id) {
<tr>
<td>{{ row.timestamp }}</td>
<td>{{ row.job }}</td>
<td>{{ row.error }}</td>
<td>{{ row.retryable }}</td>
<td><span class="impact" [class]="'impact impact--' + row.impact.toLowerCase()">{{ row.impact }}</span></td>
<td class="actions">
<a routerLink="/platform/ops/jobs-queues">Replay</a>
<button type="button" (click)="copyCorrelationId(row.correlationId)">Copy CorrID</button>
</td>
</tr>
}
</tbody>
</table>
</section>
}
@if (tab() === 'workers') {
<section class="table-wrap">
<table aria-label="Workers table">
<thead>
<tr>
<th>Worker</th>
<th>Queue</th>
<th>State</th>
<th>Capacity</th>
<th>Last Heartbeat</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (row of workers; track row.id) {
<tr>
<td>{{ row.name }}</td>
<td>{{ row.queue }}</td>
<td><span class="pill" [class]="'pill pill--' + row.state.toLowerCase()">{{ row.state }}</span></td>
<td>{{ row.capacity }}</td>
<td>{{ row.heartbeat }}</td>
<td class="actions">
<a routerLink="/platform/ops/jobs-queues">View</a>
<a routerLink="/platform/ops/jobs-queues">Drain</a>
</td>
</tr>
}
</tbody>
</table>
</section>
}
<section class="drawer">
<h2>Context</h2>
@if (tab() === 'jobs') {
<p>Jobs define recurring and ad hoc automation units used by release/security/evidence pipelines.</p>
}
@if (tab() === 'runs') {
<p>
Active issue: <strong>run-004 is in dead-letter</strong> due to upstream feed rate limiting.
Impact: <span class="impact impact--blocking">BLOCKING</span>
</p>
}
@if (tab() === 'schedules') {
<p>Schedules control deterministic execution windows and regional workload sequencing.</p>
}
@if (tab() === 'dead-letters') {
<p>
Dead-letter triage is linked to release impact.
<a routerLink="/platform/ops/data-integrity/dlq">Open DLQ & Replays</a>
</p>
}
@if (tab() === 'workers') {
<p>Worker capacity and health affect queue latency and decision freshness SLAs.</p>
}
<div class="drawer__links">
<a routerLink="/platform/ops/data-integrity">Open Data Integrity</a>
<a routerLink="/evidence/audit-log">Open Audit Log</a>
<a routerLink="/releases/runs">Impacted Decisions</a>
</div>
</section>
</section>
`,
styles: [`
.jobs-queues {
display: grid;
gap: 0.65rem;
}
.jobs-queues__header {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: start;
}
.jobs-queues__header h1 {
margin: 0;
}
.jobs-queues__header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 72ch;
}
.jobs-queues__actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.jobs-queues__actions a {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.74rem;
padding: 0.3rem 0.55rem;
}
.tabs {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.tabs button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.16rem 0.6rem;
font-size: 0.72rem;
cursor: pointer;
}
.tabs button.active {
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
.kpis {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.kpis span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.7rem;
padding: 0.13rem 0.45rem;
}
.filters {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.55rem;
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.filters label {
display: grid;
gap: 0.18rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.filters input,
.filters select {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
font-size: 0.78rem;
padding: 0.28rem 0.4rem;
min-width: 170px;
}
.table-wrap {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--color-border-primary);
padding: 0.45rem 0.4rem;
text-align: left;
font-size: 0.74rem;
white-space: nowrap;
}
th {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.pill,
.impact {
border-radius: var(--radius-full);
padding: 0.1rem 0.4rem;
font-size: 0.64rem;
font-weight: var(--font-weight-semibold);
}
.pill--running,
.pill--healthy,
.pill--ok {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
}
.pill--completed {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
.pill--failed,
.pill--deadletter,
.pill--fail {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.pill--warn,
.pill--dlq,
.pill--degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.impact--blocking {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.impact--degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.impact--info {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
.actions {
display: flex;
gap: 0.32rem;
align-items: center;
}
.actions a,
.actions button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-brand-primary);
text-decoration: none;
cursor: pointer;
font-size: 0.67rem;
padding: 0.14rem 0.35rem;
}
.drawer {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.6rem;
display: grid;
gap: 0.3rem;
}
.drawer h2 {
margin: 0;
font-size: 0.9rem;
}
.drawer p {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.drawer__links {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.drawer__links a,
.drawer a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.73rem;
}
`],
})
export class PlatformJobsQueuesPageComponent {
readonly tab = signal<JobsQueuesTab>('jobs');
readonly jobs: JobDefinitionRow[] = [
{ id: 'job-def-1', name: 'Container scan', type: 'security', lastRun: '08:03 UTC (2m)', health: 'OK' },
{ id: 'job-def-2', name: 'SBOM generation', type: 'supply', lastRun: '07:55 UTC (1m)', health: 'OK' },
{ id: 'job-def-3', name: 'Compliance export', type: 'evidence', lastRun: '06:00 UTC (5m)', health: 'OK' },
{ id: 'job-def-4', name: 'Vulnerability sync (NVD)', type: 'feeds', lastRun: '05:12 UTC (FAIL)', health: 'DLQ' },
];
readonly runs: JobRunRow[] = [
{
id: 'run-001',
job: 'Container scan',
status: 'RUNNING',
startedAt: '08:03 UTC',
duration: '5m',
impact: 'INFO',
correlationId: 'corr-run-001',
},
{
id: 'run-002',
job: 'SBOM generation',
status: 'COMPLETED',
startedAt: '07:55 UTC',
duration: '1m',
impact: 'INFO',
correlationId: 'corr-run-002',
},
{
id: 'run-003',
job: 'Compliance export',
status: 'FAILED',
startedAt: '06:00 UTC',
duration: '2m',
impact: 'DEGRADED',
correlationId: 'corr-run-003',
},
{
id: 'run-004',
job: 'Vulnerability sync (NVD)',
status: 'DEAD-LETTER',
startedAt: '05:12 UTC',
duration: '-',
impact: 'BLOCKING',
correlationId: 'corr-dlq-9031',
},
];
readonly schedules: ScheduleRow[] = [
{ id: 'sch-1', name: 'Nightly supply scan', cron: '0 2 * * *', nextRun: '02:00 UTC', lastStatus: 'OK' },
{ id: 'sch-2', name: 'Advisory sync', cron: '*/30 * * * *', nextRun: '22:30 UTC', lastStatus: 'WARN' },
{ id: 'sch-3', name: 'Evidence export', cron: '0 6 * * *', nextRun: '06:00 UTC', lastStatus: 'FAIL' },
];
readonly deadLetters: DeadLetterRow[] = [
{
id: 'dlq-1',
timestamp: '05:12 UTC',
job: 'Vulnerability sync (NVD)',
error: 'HTTP 429 rate limit',
retryable: 'YES',
impact: 'BLOCKING',
correlationId: 'corr-dlq-9031',
},
{
id: 'dlq-2',
timestamp: '05:08 UTC',
job: 'Reachability ingest',
error: 'Runtime timeout',
retryable: 'YES',
impact: 'DEGRADED',
correlationId: 'corr-dlq-9030',
},
{
id: 'dlq-3',
timestamp: '04:55 UTC',
job: 'Evidence export',
error: 'S3 access denied',
retryable: 'NO',
impact: 'BLOCKING',
correlationId: 'corr-dlq-9027',
},
];
readonly workers: WorkerRow[] = [
{ id: 'wrk-1', name: 'worker-east-01', queue: 'security', state: 'HEALTHY', capacity: '8/10', heartbeat: '5s ago' },
{ id: 'wrk-2', name: 'worker-east-02', queue: 'feeds', state: 'DEGRADED', capacity: '10/10', heartbeat: '24s ago' },
{ id: 'wrk-3', name: 'worker-eu-01', queue: 'supply', state: 'HEALTHY', capacity: '6/10', heartbeat: '7s ago' },
];
runsByStatus(status: JobRunRow['status']): number {
return this.runs.filter((row) => row.status === status).length;
}
copyCorrelationId(correlationId: string): void {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(correlationId).catch(() => null);
}
}
}

View File

@@ -0,0 +1,327 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
interface WorkflowCard {
id: string;
title: string;
description: string;
route: string;
impact: 'BLOCKING' | 'DEGRADED' | 'INFO';
}
@Component({
selector: 'app-platform-ops-overview-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="ops-overview">
<header class="ops-overview__header">
<div>
<h1>Platform Ops</h1>
<p>
Operability workflows for defensible release decisions: data trust, execution control,
and service health.
</p>
</div>
<div class="ops-overview__actions">
<a routerLink="/platform/ops/doctor">Run Doctor</a>
<a routerLink="/evidence/exports">Export Ops Report</a>
<button type="button" (click)="refreshed.set(true)">Refresh</button>
</div>
</header>
<section class="ops-overview__kpis" aria-label="Ops posture snapshot">
<article>
<h2>Data Trust Score</h2>
<p>87</p>
<span class="pill pill--warn">WARN</span>
</article>
<article>
<h2>Platform Health</h2>
<p>2</p>
<span class="pill pill--warn">WARN services</span>
</article>
<article>
<h2>Dead Letter Queue</h2>
<p>3</p>
<span class="pill pill--degraded">DEGRADED</span>
</article>
</section>
<section class="ops-overview__primary">
<h2>Primary Workflows</h2>
<div class="ops-overview__grid">
@for (card of primaryWorkflows; track card.id) {
<a class="ops-card" [routerLink]="card.route">
<h3>{{ card.title }}</h3>
<p>{{ card.description }}</p>
<span class="impact" [class]="'impact impact--' + card.impact.toLowerCase()">
Impact: {{ card.impact }}
</span>
</a>
}
</div>
</section>
<section class="ops-overview__secondary">
<h2>Secondary Operator Tools</h2>
<div class="ops-overview__links">
<a routerLink="/platform/ops/feeds-airgap">Feeds & Airgap</a>
<a routerLink="/platform/ops/quotas">Quotas & Limits</a>
<a routerLink="/platform/ops/doctor">Diagnostics</a>
<a routerLink="/topology/agents">Topology Health</a>
<a routerLink="/evidence/capsules">Decision Capsule Stats</a>
</div>
</section>
<section class="ops-overview__alerts">
<h2>Recent Operator Alerts</h2>
<ul>
<li>
NVD feed stale 3h12m
<span class="impact impact--blocking">Impact: BLOCKING</span>
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Open</a>
</li>
<li>
Runtime ingest backlog
<span class="impact impact--degraded">Impact: DEGRADED</span>
<a routerLink="/platform/ops/data-integrity/reachability-ingest">Open</a>
</li>
<li>
DLQ replay queue pending
<span class="impact impact--degraded">Impact: DEGRADED</span>
<a routerLink="/platform/ops/data-integrity/dlq">Open</a>
</li>
</ul>
</section>
@if (refreshed()) {
<p class="ops-overview__note">Snapshot refreshed for current scope.</p>
}
</section>
`,
styles: [`
.ops-overview {
display: grid;
gap: 0.9rem;
}
.ops-overview__header {
display: flex;
justify-content: space-between;
gap: 0.9rem;
align-items: start;
}
.ops-overview__header h1 {
margin: 0;
font-size: 1.4rem;
}
.ops-overview__header p {
margin: 0.2rem 0 0;
color: var(--color-text-secondary);
font-size: 0.82rem;
max-width: 66ch;
}
.ops-overview__actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.ops-overview__actions a,
.ops-overview__actions button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: 0.35rem 0.6rem;
background: var(--color-surface-primary);
text-decoration: none;
color: var(--color-text-primary);
font-size: 0.74rem;
cursor: pointer;
}
.ops-overview__kpis {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.ops-overview__kpis article {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.2rem;
}
.ops-overview__kpis h2 {
margin: 0;
font-size: 0.74rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ops-overview__kpis p {
margin: 0;
font-size: 1.15rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
}
.pill {
width: fit-content;
border-radius: var(--radius-full);
padding: 0.1rem 0.45rem;
font-size: 0.66rem;
font-weight: var(--font-weight-semibold);
}
.pill--warn {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.pill--degraded {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
.ops-overview__primary h2,
.ops-overview__secondary h2,
.ops-overview__alerts h2 {
margin: 0 0 0.4rem;
font-size: 0.95rem;
}
.ops-overview__grid {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.ops-card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.7rem;
text-decoration: none;
color: inherit;
display: grid;
gap: 0.25rem;
}
.ops-card h3 {
margin: 0;
font-size: 0.88rem;
}
.ops-card p {
margin: 0;
font-size: 0.74rem;
color: var(--color-text-secondary);
}
.impact {
width: fit-content;
border-radius: var(--radius-full);
padding: 0.1rem 0.45rem;
font-size: 0.65rem;
font-weight: var(--font-weight-semibold);
}
.impact--blocking {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.impact--degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.impact--info {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
.ops-overview__links {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.ops-overview__links a {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.25rem 0.45rem;
font-size: 0.74rem;
color: var(--color-brand-primary);
text-decoration: none;
}
.ops-overview__alerts ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.ops-overview__alerts li {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.45rem 0.55rem;
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
align-items: center;
font-size: 0.76rem;
}
.ops-overview__alerts a {
color: var(--color-brand-primary);
text-decoration: none;
}
.ops-overview__note {
margin: 0;
font-size: 0.74rem;
color: var(--color-text-secondary);
}
`],
})
export class PlatformOpsOverviewPageComponent {
readonly refreshed = signal(false);
readonly primaryWorkflows: WorkflowCard[] = [
{
id: 'data-integrity',
title: 'Data Integrity',
description: 'Trust signals, blocked decisions, and freshness recovery actions.',
route: '/platform/ops/data-integrity',
impact: 'BLOCKING',
},
{
id: 'jobs-queues',
title: 'Jobs & Queues',
description: 'Unified orchestration runs, schedules, dead letters, and workers.',
route: '/platform/ops/jobs-queues',
impact: 'DEGRADED',
},
{
id: 'health-slo',
title: 'Health & SLO',
description: 'Service/dependency health and incident timelines with SLO context.',
route: '/platform/ops/health-slo',
impact: 'INFO',
},
];
}

View File

@@ -0,0 +1,190 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-platform-home-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="platform-home">
<header class="platform-home__header">
<div>
<h1>Platform</h1>
<p>
Operate and configure the infrastructure substrate that powers release control,
security posture, and evidence-grade decisions.
</p>
</div>
</header>
<section class="platform-home__doors">
<a class="door" routerLink="/platform/ops">
<h2>Platform Ops</h2>
<p>Runtime reliability, pipelines, queues, mirrors, quotas, and diagnostics.</p>
<span>Open</span>
</a>
<a class="door" routerLink="/platform/integrations">
<h2>Integrations</h2>
<p>Connector health, credentials, scopes, and external dependency observability.</p>
<span>Open</span>
</a>
<a class="door" routerLink="/platform/setup">
<h2>Setup</h2>
<p>Promotion topology, workflow defaults, templates, and guardrails.</p>
<span>Open</span>
</a>
</section>
<section class="platform-home__snapshot">
<h2>Status Snapshot</h2>
<div class="snapshot-grid">
<article>
<strong>Health</strong>
<span>OK</span>
</article>
<article>
<strong>Data Integrity</strong>
<span>WARN (3 signals)</span>
</article>
<article>
<strong>Dead Letters</strong>
<span>3 pending</span>
</article>
<article>
<strong>Mirrors</strong>
<span>2 stale</span>
</article>
<article>
<strong>Quotas</strong>
<span>72% used</span>
</article>
<article>
<strong>Offline</strong>
<span>Online</span>
</article>
</div>
</section>
<section class="platform-home__actions">
<a routerLink="/platform/ops/doctor">Run Diagnostics</a>
<a routerLink="/platform/integrations/onboarding/registry">Add Integration</a>
<a routerLink="/platform/setup/promotion-paths">Configure Promotion Paths</a>
</section>
</section>
`,
styles: [`
.platform-home {
display: grid;
gap: 0.8rem;
}
.platform-home__header h1 {
margin: 0;
font-size: 1.45rem;
}
.platform-home__header p {
margin: 0.2rem 0 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
max-width: 72ch;
}
.platform-home__doors {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.door {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.7rem;
text-decoration: none;
color: inherit;
display: grid;
gap: 0.25rem;
}
.door h2 {
margin: 0;
font-size: 0.95rem;
}
.door p {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.door span {
width: fit-content;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
font-size: 0.68rem;
color: var(--color-brand-primary);
padding: 0.1rem 0.4rem;
}
.platform-home__snapshot {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.5rem;
}
.platform-home__snapshot h2 {
margin: 0;
font-size: 0.95rem;
}
.snapshot-grid {
display: grid;
gap: 0.45rem;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.snapshot-grid article {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
padding: 0.45rem;
display: grid;
gap: 0.18rem;
}
.snapshot-grid strong {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.snapshot-grid span {
font-size: 0.76rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.platform-home__actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.platform-home__actions a {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.3rem 0.55rem;
text-decoration: none;
color: var(--color-brand-primary);
font-size: 0.74rem;
}
`],
})
export class PlatformHomePageComponent {}

View File

@@ -0,0 +1,182 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface GuardrailRow {
domain: string;
defaultValue: string;
impact: 'BLOCKING' | 'DEGRADED' | 'INFO';
}
@Component({
selector: 'app-platform-setup-defaults-guardrails-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="setup-page">
<header>
<h1>Defaults & Guardrails</h1>
<p>
Configure control-plane defaults that shape promotion behavior, evidence completeness,
and degraded-mode policy handling.
</p>
</header>
<article class="card">
<h2>Default Controls</h2>
<table aria-label="Defaults and guardrails table">
<thead>
<tr>
<th>Domain</th>
<th>Default</th>
<th>Impact if Violated</th>
</tr>
</thead>
<tbody>
@for (row of guardrails; track row.domain) {
<tr>
<td>{{ row.domain }}</td>
<td>{{ row.defaultValue }}</td>
<td><span class="impact" [class]="'impact impact--' + row.impact.toLowerCase()">{{ row.impact }}</span></td>
</tr>
}
</tbody>
</table>
</article>
<article class="card">
<h2>Global Behaviors</h2>
<ul>
<li>Require correlation IDs in all degraded/offline error banners.</li>
<li>Default export profile includes policy trace and decision evidence.</li>
<li>Promotion defaults follow region risk tier unless explicit override exists.</li>
</ul>
</article>
<footer class="links">
<a routerLink="/platform/setup/gate-profiles">Open Gate Profiles</a>
<a routerLink="/platform/setup/feed-policy">Open Feed Policy</a>
<a routerLink="/platform/ops/data-integrity">Open Data Integrity</a>
</footer>
</section>
`,
styles: [`
.setup-page {
display: grid;
gap: 0.7rem;
}
header h1 {
margin: 0;
font-size: 1.35rem;
}
header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 74ch;
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.35rem;
}
.card h2 {
margin: 0;
font-size: 0.95rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--color-border-primary);
padding: 0.38rem 0.42rem;
text-align: left;
font-size: 0.74rem;
}
th {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.impact {
border-radius: var(--radius-full);
padding: 0.1rem 0.42rem;
font-size: 0.64rem;
font-weight: var(--font-weight-semibold);
}
.impact--blocking {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.impact--degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.impact--info {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
ul {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.22rem;
font-size: 0.76rem;
color: var(--color-text-secondary);
}
.links {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.links a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.74rem;
}
`],
})
export class PlatformSetupDefaultsGuardrailsPageComponent {
readonly guardrails: GuardrailRow[] = [
{
domain: 'Promotion policy gate',
defaultValue: 'Require policy + approvals for prod lanes',
impact: 'BLOCKING',
},
{
domain: 'Data integrity gate',
defaultValue: 'Warn on degraded reachability coverage below 80%',
impact: 'DEGRADED',
},
{
domain: 'Evidence bundle profile',
defaultValue: 'Attach decision capsule and audit trace by default',
impact: 'INFO',
},
{
domain: 'Feed freshness enforcement',
defaultValue: 'Block prod promotions when critical feeds are stale',
impact: 'BLOCKING',
},
];
}

View File

@@ -0,0 +1,184 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface FeedSlaRow {
source: string;
freshnessSla: string;
staleBehavior: string;
decisionImpact: 'BLOCKING' | 'DEGRADED' | 'INFO';
}
@Component({
selector: 'app-platform-setup-feed-policy-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="setup-page">
<header>
<h1>Feed Policy</h1>
<p>
Define advisory and VEX feed freshness requirements for promotion decisions.
Source connectors are managed in Integrations; mirror operations are managed in Ops.
</p>
</header>
<article class="card">
<h2>Freshness SLA</h2>
<table aria-label="Feed freshness policy">
<thead>
<tr>
<th>Source</th>
<th>Freshness SLA</th>
<th>Stale Behavior</th>
<th>Decision Impact</th>
</tr>
</thead>
<tbody>
@for (row of policies; track row.source) {
<tr>
<td>{{ row.source }}</td>
<td>{{ row.freshnessSla }}</td>
<td>{{ row.staleBehavior }}</td>
<td><span class="impact" [class]="'impact impact--' + row.decisionImpact.toLowerCase()">{{ row.decisionImpact }}</span></td>
</tr>
}
</tbody>
</table>
</article>
<article class="card">
<h2>Promotion Behavior</h2>
<ul>
<li><strong>Prod promotions:</strong> block when critical feed SLA is violated.</li>
<li><strong>Stage promotions:</strong> allow degraded passage with warning and evidence note.</li>
<li><strong>Overrides:</strong> require security exception justification and approval chain.</li>
</ul>
</article>
<footer class="links">
<a routerLink="/platform/integrations/feeds">Manage Feed Connectors</a>
<a routerLink="/platform/ops/feeds-airgap">Open Feeds & Airgap Operations</a>
<a routerLink="/security/advisories-vex">Open Advisories & VEX</a>
</footer>
</section>
`,
styles: [`
.setup-page {
display: grid;
gap: 0.7rem;
}
header h1 {
margin: 0;
font-size: 1.35rem;
}
header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 74ch;
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.35rem;
}
.card h2 {
margin: 0;
font-size: 0.95rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--color-border-primary);
padding: 0.38rem 0.42rem;
text-align: left;
font-size: 0.74rem;
white-space: nowrap;
}
th {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.impact {
border-radius: var(--radius-full);
padding: 0.1rem 0.42rem;
font-size: 0.64rem;
font-weight: var(--font-weight-semibold);
}
.impact--blocking {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.impact--degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.impact--info {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
ul {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.22rem;
font-size: 0.76rem;
color: var(--color-text-secondary);
}
.links {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.links a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.74rem;
}
`],
})
export class PlatformSetupFeedPolicyPageComponent {
readonly policies: FeedSlaRow[] = [
{
source: 'NVD',
freshnessSla: '1h',
staleBehavior: 'Block prod promotions',
decisionImpact: 'BLOCKING',
},
{
source: 'GitHub Advisories',
freshnessSla: '2h',
staleBehavior: 'Warn on stale, allow with evidence note',
decisionImpact: 'DEGRADED',
},
{
source: 'VEX Repository',
freshnessSla: '6h',
staleBehavior: 'Warn when stale',
decisionImpact: 'INFO',
},
];
}

View File

@@ -0,0 +1,161 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface GateProfileRow {
name: string;
releasePath: string;
requirements: string;
escalation: string;
}
@Component({
selector: 'app-platform-setup-gate-profiles-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="setup-page">
<header>
<h1>Gate Profiles</h1>
<p>
Gate profiles define the release control baseline for approvals, policy, data integrity,
and evidence completeness at each promotion stage.
</p>
</header>
<article class="card">
<h2>Profiles</h2>
<table aria-label="Gate profiles table">
<thead>
<tr>
<th>Profile</th>
<th>Release Path</th>
<th>Requirements</th>
<th>Escalation</th>
</tr>
</thead>
<tbody>
@for (profile of profiles; track profile.name) {
<tr>
<td>{{ profile.name }}</td>
<td>{{ profile.releasePath }}</td>
<td>{{ profile.requirements }}</td>
<td>{{ profile.escalation }}</td>
</tr>
}
</tbody>
</table>
</article>
<article class="card">
<h2>Profile Selection Rules</h2>
<ul>
<li>Production environments default to <strong>strict-prod</strong>.</li>
<li>Canary and stage environments default to <strong>risk-aware</strong>.</li>
<li>Hotfix lanes may switch to <strong>expedited-hotfix</strong> with explicit approval.</li>
</ul>
</article>
<footer class="links">
<a routerLink="/platform/setup/workflows-gates">Open Workflows & Gates</a>
<a routerLink="/platform/setup/defaults-guardrails">Open Defaults & Guardrails</a>
<a routerLink="/security/overview">Open Security Baseline</a>
</footer>
</section>
`,
styles: [`
.setup-page {
display: grid;
gap: 0.7rem;
}
header h1 {
margin: 0;
font-size: 1.35rem;
}
header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 76ch;
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.35rem;
}
.card h2 {
margin: 0;
font-size: 0.95rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--color-border-primary);
padding: 0.38rem 0.42rem;
text-align: left;
font-size: 0.74rem;
}
th {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
ul {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.22rem;
font-size: 0.76rem;
color: var(--color-text-secondary);
}
.links {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.links a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.74rem;
}
`],
})
export class PlatformSetupGateProfilesPageComponent {
readonly profiles: GateProfileRow[] = [
{
name: 'strict-prod',
releasePath: 'stage -> prod',
requirements: 'Policy pass, approvals, data integrity green, evidence required',
escalation: 'Block until resolved',
},
{
name: 'risk-aware',
releasePath: 'dev -> stage',
requirements: 'Policy pass with degraded tolerance and warning capture',
escalation: 'Warn and continue',
},
{
name: 'expedited-hotfix',
releasePath: 'stage -> prod-hotfix',
requirements: 'Reduced approvals, evidence replay required post-deploy',
escalation: 'Manual escalation required',
},
];
}

View File

@@ -0,0 +1,152 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-platform-setup-home',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="setup-home">
<header>
<h1>Platform Setup</h1>
<p>Configure inventory, promotion defaults, workflow gates, feed policy, and guardrails.</p>
</header>
<div class="readiness">
<span>Regions configured: 2</span>
<span>Environments: 6</span>
<span>Workflows: 3</span>
<span>Gate profiles: 3</span>
<span>Templates: 3</span>
<span>Feed policies: 3</span>
<span>Global guardrails: 4</span>
</div>
<div class="cards">
<article>
<h3>Regions & Environments</h3>
<p>Region-first setup, risk tiers, and promotion entry controls.</p>
<a routerLink="/platform/setup/regions-environments">Open</a>
</article>
<article>
<h3>Promotion Paths</h3>
<p>Graph, rules, and validation of promotion flow constraints.</p>
<a routerLink="/platform/setup/promotion-paths">Open</a>
</article>
<article>
<h3>Workflows & Gates</h3>
<p>Workflow, gate profile, and rollback strategy mapping.</p>
<a routerLink="/platform/setup/workflows-gates">Open</a>
</article>
<article>
<h3>Gate Profiles</h3>
<p>Dedicated profile library for strict, risk-aware, and expedited lanes.</p>
<a routerLink="/platform/setup/gate-profiles">Open</a>
</article>
<article>
<h3>Release Templates</h3>
<p>Release template defaults aligned with run and evidence workflows.</p>
<a routerLink="/platform/setup/release-templates">Open</a>
</article>
<article>
<h3>Feed Policy</h3>
<p>Freshness thresholds and staleness behavior for decision gating.</p>
<a routerLink="/platform/setup/feed-policy">Open</a>
</article>
<article>
<h3>Defaults & Guardrails</h3>
<p>Control-plane defaults for policy impact labels and degraded-mode behavior.</p>
<a routerLink="/platform/setup/defaults-guardrails">Open</a>
</article>
</div>
<footer class="links">
<a routerLink="/topology/overview">Open Topology Posture</a>
<a routerLink="/security/overview">Open Security Baseline</a>
</footer>
</section>
`,
styles: [`
.setup-home {
display: grid;
gap: 0.6rem;
}
.setup-home header h1 {
margin: 0;
}
.setup-home header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.45rem;
}
.readiness {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.readiness span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
font-size: 0.7rem;
color: var(--color-text-secondary);
padding: 0.12rem 0.45rem;
}
.cards article {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.6rem;
display: grid;
gap: 0.25rem;
}
.cards h3 {
margin: 0;
font-size: 0.86rem;
}
.cards p {
margin: 0;
font-size: 0.74rem;
color: var(--color-text-secondary);
}
.cards a {
font-size: 0.74rem;
color: var(--color-brand-primary);
text-decoration: none;
}
.links {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.links a {
font-size: 0.74rem;
color: var(--color-brand-primary);
text-decoration: none;
}
`],
})
export class PlatformSetupHomeComponent {}

View File

@@ -0,0 +1,138 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface PromotionRule {
id: number;
from: string;
to: string;
requirements: string;
crossRegion: 'yes' | 'no';
}
@Component({
selector: 'app-platform-setup-promotion-paths-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="setup-page">
<header>
<h1>Promotion Paths</h1>
<p>Define release movement rules across environments and gate requirements.</p>
</header>
<article class="graph">
<h2>Path Map</h2>
<p><code>dev --(approvals)--> stage --(policy+ops)--> prod</code></p>
</article>
<article class="rules">
<h2>Rules</h2>
<table aria-label="Promotion rules">
<thead>
<tr>
<th>Rule</th>
<th>From</th>
<th>To</th>
<th>Requirements</th>
<th>Cross-region</th>
</tr>
</thead>
<tbody>
@for (rule of rules; track rule.id) {
<tr>
<td>{{ rule.id }}</td>
<td>{{ rule.from }}</td>
<td>{{ rule.to }}</td>
<td>{{ rule.requirements }}</td>
<td>{{ rule.crossRegion }}</td>
</tr>
}
</tbody>
</table>
</article>
<footer class="links">
<a routerLink="/releases/runs">Open Release Runs</a>
<a routerLink="/topology/promotion-paths">Open Topology Path View</a>
</footer>
</section>
`,
styles: [`
.setup-page {
display: grid;
gap: 0.7rem;
}
header h1 {
margin: 0;
font-size: 1.35rem;
}
header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.graph,
.rules {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.6rem;
display: grid;
gap: 0.3rem;
}
.graph h2,
.rules h2 {
margin: 0;
font-size: 0.92rem;
}
.graph p {
margin: 0;
font-size: 0.78rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--color-border-primary);
padding: 0.35rem 0.4rem;
text-align: left;
font-size: 0.74rem;
}
th {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.links {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.links a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.74rem;
}
`],
})
export class PlatformSetupPromotionPathsPageComponent {
readonly rules: PromotionRule[] = [
{ id: 1, from: 'dev', to: 'stage', requirements: 'approvals', crossRegion: 'no' },
{ id: 2, from: 'stage', to: 'prod', requirements: 'policy+ops gate', crossRegion: 'no' },
{ id: 3, from: 'stage', to: 'prod-canary', requirements: 'risk-aware gate', crossRegion: 'yes' },
];
}

View File

@@ -0,0 +1,180 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface RegionRow {
environment: string;
riskTier: 'low' | 'medium' | 'high';
promotionEntry: 'yes' | 'guarded';
status: 'ok' | 'warn';
}
@Component({
selector: 'app-platform-setup-regions-environments-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="setup-page">
<header>
<h1>Regions & Environments</h1>
<p>
Region-first setup inventory used by release workflows, policy gates, and global context
selectors.
</p>
</header>
<div class="actions">
<button type="button">+ Add Region</button>
<button type="button">+ Add Environment</button>
<button type="button">Import</button>
<button type="button">Export</button>
</div>
<article class="region">
<h2>Region: us-east</h2>
<table aria-label="US East environments">
<thead>
<tr>
<th>Environment</th>
<th>Risk Tier</th>
<th>Promotion Entry</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of usEast; track row.environment) {
<tr>
<td>{{ row.environment }}</td>
<td>{{ row.riskTier }}</td>
<td>{{ row.promotionEntry }}</td>
<td>{{ row.status }}</td>
</tr>
}
</tbody>
</table>
</article>
<article class="region">
<h2>Region: eu-west</h2>
<table aria-label="EU West environments">
<thead>
<tr>
<th>Environment</th>
<th>Risk Tier</th>
<th>Promotion Entry</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of euWest; track row.environment) {
<tr>
<td>{{ row.environment }}</td>
<td>{{ row.riskTier }}</td>
<td>{{ row.promotionEntry }}</td>
<td>{{ row.status }}</td>
</tr>
}
</tbody>
</table>
</article>
<footer class="links">
<a routerLink="/topology/environments">Open Topology Environment Posture</a>
<a routerLink="/security/overview">Open Security Policy Baseline</a>
</footer>
</section>
`,
styles: [`
.setup-page {
display: grid;
gap: 0.7rem;
}
header h1 {
margin: 0;
font-size: 1.35rem;
}
header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 72ch;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.actions button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-size: 0.74rem;
padding: 0.3rem 0.55rem;
cursor: pointer;
}
.region {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.6rem;
display: grid;
gap: 0.35rem;
}
.region h2 {
margin: 0;
font-size: 0.92rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--color-border-primary);
padding: 0.35rem 0.4rem;
text-align: left;
font-size: 0.74rem;
}
th {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.links {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.links a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.74rem;
}
`],
})
export class PlatformSetupRegionsEnvironmentsPageComponent {
readonly usEast: RegionRow[] = [
{ environment: 'dev-us-east', riskTier: 'low', promotionEntry: 'yes', status: 'ok' },
{ environment: 'stage-us-east', riskTier: 'medium', promotionEntry: 'yes', status: 'ok' },
{ environment: 'prod-us-east', riskTier: 'high', promotionEntry: 'guarded', status: 'warn' },
];
readonly euWest: RegionRow[] = [
{ environment: 'dev-eu-west', riskTier: 'low', promotionEntry: 'yes', status: 'ok' },
{ environment: 'stage-eu-west', riskTier: 'medium', promotionEntry: 'yes', status: 'ok' },
{ environment: 'prod-eu-west', riskTier: 'high', promotionEntry: 'guarded', status: 'ok' },
];
}

View File

@@ -0,0 +1,130 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface TemplateRow {
name: string;
releaseType: 'standard' | 'hotfix';
gateProfile: string;
evidencePack: string;
}
@Component({
selector: 'app-platform-setup-release-templates-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="setup-page">
<header>
<h1>Release Templates</h1>
<p>Template defaults for release creation, gating, and evidence export requirements.</p>
</header>
<article class="templates">
<table aria-label="Release templates">
<thead>
<tr>
<th>Name</th>
<th>Release Type</th>
<th>Gate Profile</th>
<th>Evidence Pack</th>
</tr>
</thead>
<tbody>
@for (row of templates; track row.name) {
<tr>
<td>{{ row.name }}</td>
<td>{{ row.releaseType }}</td>
<td>{{ row.gateProfile }}</td>
<td>{{ row.evidencePack }}</td>
</tr>
}
</tbody>
</table>
</article>
<footer class="links">
<a routerLink="/releases/versions/new">Create Release from Template</a>
<a routerLink="/evidence/exports">View Evidence Export Formats</a>
</footer>
</section>
`,
styles: [`
.setup-page {
display: grid;
gap: 0.7rem;
}
header h1 {
margin: 0;
font-size: 1.35rem;
}
header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.templates {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.6rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--color-border-primary);
padding: 0.35rem 0.4rem;
text-align: left;
font-size: 0.74rem;
}
th {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.links {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.links a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.74rem;
}
`],
})
export class PlatformSetupReleaseTemplatesPageComponent {
readonly templates: TemplateRow[] = [
{
name: 'standard-regional',
releaseType: 'standard',
gateProfile: 'strict-prod',
evidencePack: 'decision-capsule-v3',
},
{
name: 'canary-regional',
releaseType: 'standard',
gateProfile: 'risk-aware',
evidencePack: 'decision-capsule-canary',
},
{
name: 'hotfix-expedited',
releaseType: 'hotfix',
gateProfile: 'expedited-hotfix',
evidencePack: 'decision-capsule-hotfix',
},
];
}

View File

@@ -0,0 +1,161 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface WorkflowRow {
name: string;
path: string;
gateProfile: string;
rollback: 'auto' | 'manual';
}
@Component({
selector: 'app-platform-setup-workflows-gates-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="setup-page">
<header>
<h1>Workflows & Gates</h1>
<p>
Maintain workflow catalog, gate profiles, and rollback defaults for each release path.
</p>
</header>
<article class="catalog">
<h2>Workflow Catalog</h2>
<table aria-label="Workflow catalog">
<thead>
<tr>
<th>Name</th>
<th>Path</th>
<th>Gate Profile</th>
<th>Rollback</th>
</tr>
</thead>
<tbody>
@for (row of workflows; track row.name) {
<tr>
<td>{{ row.name }}</td>
<td>{{ row.path }}</td>
<td>{{ row.gateProfile }}</td>
<td>{{ row.rollback }}</td>
</tr>
}
</tbody>
</table>
</article>
<article class="profiles">
<h2>Gate Profiles</h2>
<ul>
<li><strong>strict-prod:</strong> blocks stale feeds and unknown runtime reachability.</li>
<li><strong>risk-aware:</strong> allows degraded posture with explicit warnings.</li>
<li><strong>expedited-hotfix:</strong> reduced approvals with post-deploy evidence requirement.</li>
</ul>
</article>
<footer class="links">
<a routerLink="/security/overview">Open Security Policy Baseline</a>
<a routerLink="/topology/workflows">Open Topology Workflow Inventory</a>
</footer>
</section>
`,
styles: [`
.setup-page {
display: grid;
gap: 0.7rem;
}
header h1 {
margin: 0;
font-size: 1.35rem;
}
header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 72ch;
}
.catalog,
.profiles {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.6rem;
display: grid;
gap: 0.3rem;
}
.catalog h2,
.profiles h2 {
margin: 0;
font-size: 0.92rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--color-border-primary);
padding: 0.35rem 0.4rem;
text-align: left;
font-size: 0.74rem;
}
th {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.profiles ul {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.25rem;
font-size: 0.76rem;
color: var(--color-text-secondary);
}
.links {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.links a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.74rem;
}
`],
})
export class PlatformSetupWorkflowsGatesPageComponent {
readonly workflows: WorkflowRow[] = [
{
name: 'standard-blue-green',
path: 'dev -> stage -> prod',
gateProfile: 'strict-prod',
rollback: 'auto',
},
{
name: 'canary-regional',
path: 'stage -> prod-canary -> prod',
gateProfile: 'risk-aware',
rollback: 'manual',
},
{
name: 'hotfix-fast-track',
path: 'stage -> prod',
gateProfile: 'expedited-hotfix',
rollback: 'manual',
},
];
}

View File

@@ -0,0 +1,88 @@
import { Routes } from '@angular/router';
export const PLATFORM_SETUP_ROUTES: Routes = [
{
path: '',
title: 'Platform Setup',
data: { breadcrumb: 'Setup' },
loadComponent: () =>
import('./platform-setup-home.component').then((m) => m.PlatformSetupHomeComponent),
},
{
path: 'regions-environments',
title: 'Setup Regions & Environments',
data: { breadcrumb: 'Regions & Environments' },
loadComponent: () =>
import('./platform-setup-regions-environments-page.component').then(
(m) => m.PlatformSetupRegionsEnvironmentsPageComponent,
),
},
{
path: 'promotion-paths',
title: 'Setup Promotion Paths',
data: { breadcrumb: 'Promotion Paths' },
loadComponent: () =>
import('./platform-setup-promotion-paths-page.component').then(
(m) => m.PlatformSetupPromotionPathsPageComponent,
),
},
{
path: 'workflows-gates',
title: 'Setup Workflows & Gates',
data: { breadcrumb: 'Workflows & Gates' },
loadComponent: () =>
import('./platform-setup-workflows-gates-page.component').then(
(m) => m.PlatformSetupWorkflowsGatesPageComponent,
),
},
{
path: 'release-templates',
title: 'Release Templates',
data: { breadcrumb: 'Release Templates' },
loadComponent: () =>
import('./platform-setup-release-templates-page.component').then(
(m) => m.PlatformSetupReleaseTemplatesPageComponent,
),
},
{
path: 'feed-policy',
title: 'Feed Policy',
data: { breadcrumb: 'Feed Policy' },
loadComponent: () =>
import('./platform-setup-feed-policy-page.component').then(
(m) => m.PlatformSetupFeedPolicyPageComponent,
),
},
{
path: 'gate-profiles',
title: 'Gate Profiles',
data: { breadcrumb: 'Gate Profiles' },
loadComponent: () =>
import('./platform-setup-gate-profiles-page.component').then(
(m) => m.PlatformSetupGateProfilesPageComponent,
),
},
{
path: 'defaults-guardrails',
title: 'Defaults & Guardrails',
data: { breadcrumb: 'Defaults & Guardrails' },
loadComponent: () =>
import('./platform-setup-defaults-guardrails-page.component').then(
(m) => m.PlatformSetupDefaultsGuardrailsPageComponent,
),
},
{
path: 'defaults',
pathMatch: 'full',
redirectTo: 'defaults-guardrails',
},
{
path: 'trust-signing',
title: 'Trust & Signing',
data: { breadcrumb: 'Trust & Signing' },
loadComponent: () =>
import('../../settings/trust/trust-settings-page.component').then(
(m) => m.TrustSettingsPageComponent,
),
},
];

View File

@@ -31,7 +31,7 @@ import {
<!-- Header -->
<header class="detail-header">
<div class="breadcrumb">
<a routerLink="/approvals" class="back-link">Approvals</a>
<a routerLink="/releases/approvals" class="back-link">Approvals</a>
<span class="separator">/</span>
<span>{{ approval()!.releaseName }}</span>
</div>
@@ -255,10 +255,19 @@ import {
</section>
</aside>
</div>
} @else if (store.error()) {
<div class="error-state">
<p>Failed to load approval details.</p>
<p class="error-detail">{{ store.error() }}</p>
<div class="error-actions">
<button class="btn btn-secondary" type="button" (click)="retryLoad()">Retry</button>
<a routerLink="/releases/approvals" class="btn btn-secondary">Back to Queue</a>
</div>
</div>
} @else {
<div class="error-state">
<p>Approval not found</p>
<a routerLink="/approvals" class="btn btn-secondary">Back to Queue</a>
<a routerLink="/releases/approvals" class="btn btn-secondary">Back to Queue</a>
</div>
}
</div>
@@ -781,6 +790,18 @@ import {
padding: 60px 20px;
}
.error-detail {
color: var(--color-text-secondary);
margin-top: 0.5rem;
}
.error-actions {
display: inline-flex;
gap: 0.75rem;
margin-top: 1rem;
align-items: center;
}
.spinner {
width: 40px;
height: 40px;
@@ -859,6 +880,13 @@ export class ApprovalDetailComponent implements OnInit, OnDestroy {
this.cancelAction();
}
retryLoad(): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.store.loadApproval(id);
}
}
isExpiringSoon(expiresAt: string): boolean {
const hoursUntilExpiry = (new Date(expiresAt).getTime() - Date.now()) / 3600000;
return hoursUntilExpiry < 4;

View File

@@ -127,6 +127,7 @@ export class ApprovalStore {
loadApproval(id: string): void {
this._loading.set(true);
this._error.set(null);
this._selectedApproval.set(null);
this.api.getApproval(id).subscribe({
next: (approval) => {
@@ -134,6 +135,7 @@ export class ApprovalStore {
this._loading.set(false);
},
error: (err) => {
this._selectedApproval.set(null);
this._error.set(err.message || 'Failed to load approval');
this._loading.set(false);
},

View File

@@ -2,7 +2,7 @@
* Release Management Store (Angular Signals)
* Sprint: SPRINT_20260110_111_003_FE_release_management_ui
*/
import { Injectable, inject, signal, computed } from '@angular/core';
import { Injectable, inject, signal, computed, effect } from '@angular/core';
import { RELEASE_MANAGEMENT_API } from '../../../core/api/release-management.client';
import type {
ManagedRelease,
@@ -15,10 +15,12 @@ import type {
ReleaseFilter,
ReleaseWorkflowStatus,
} from '../../../core/api/release-management.models';
import { PlatformContextStore } from '../../../core/context/platform-context.store';
@Injectable({ providedIn: 'root' })
export class ReleaseManagementStore {
private readonly api = inject(RELEASE_MANAGEMENT_API);
private readonly context = inject(PlatformContextStore);
// State signals
private readonly _releases = signal<ManagedRelease[]>([]);
@@ -100,6 +102,14 @@ export class ReleaseManagementStore {
return release?.status === 'draft';
});
constructor() {
this.context.initialize();
effect(() => {
this.context.contextVersion();
this.loadReleases(this._filter());
});
}
// Actions
loadReleases(filter?: ReleaseFilter): void {
this._loading.set(true);

View File

@@ -0,0 +1,332 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
interface ReleaseActivityProjection {
activityId: string;
releaseId: string;
releaseName: string;
eventType: string;
status: string;
targetEnvironment?: string | null;
targetRegion?: string | null;
actorId: string;
occurredAt: string;
correlationKey: string;
}
interface PlatformListResponse<T> {
items: T[];
count: number;
}
@Component({
selector: 'app-releases-activity',
standalone: true,
imports: [RouterLink, FormsModule],
template: `
<section class="activity">
<header>
<h1>Release Runs</h1>
<p>Single run index across timeline/table/correlations with lane and operability filtering.</p>
</header>
<div class="context">
<span>{{ context.regionSummary() }}</span>
<span>{{ context.environmentSummary() }}</span>
<span>{{ context.timeWindow() }}</span>
</div>
<nav class="mode-tabs" aria-label="Run list views">
<a [routerLink]="[]" [queryParams]="mergeQuery({ view: 'timeline' })" [class.active]="viewMode() === 'timeline'">Timeline</a>
<a [routerLink]="[]" [queryParams]="mergeQuery({ view: 'table' })" [class.active]="viewMode() === 'table'">Table</a>
<a [routerLink]="[]" [queryParams]="mergeQuery({ view: 'correlations' })" [class.active]="viewMode() === 'correlations'">Correlations</a>
</nav>
<div class="filters">
<select [(ngModel)]="statusFilter" (ngModelChange)="applyFilters()">
<option value="all">Status: All</option>
<option value="pending_approval">Pending Approval</option>
<option value="approved">Approved</option>
<option value="published">Published</option>
<option value="blocked">Blocked</option>
<option value="rejected">Rejected</option>
</select>
<select [(ngModel)]="laneFilter" (ngModelChange)="applyFilters()">
<option value="all">Lane: All</option>
<option value="standard">Standard</option>
<option value="hotfix">Hotfix</option>
</select>
<select [(ngModel)]="envFilter" (ngModelChange)="applyFilters()">
<option value="all">Environment: All</option>
<option value="dev">Dev</option>
<option value="stage">Stage</option>
<option value="prod">Prod</option>
</select>
<select [(ngModel)]="outcomeFilter" (ngModelChange)="applyFilters()">
<option value="all">Outcome: All</option>
<option value="success">Success</option>
<option value="in_progress">In Progress</option>
<option value="failed">Failed</option>
</select>
<select [(ngModel)]="needsApprovalFilter" (ngModelChange)="applyFilters()">
<option value="all">Needs Approval: All</option>
<option value="true">Needs Approval</option>
<option value="false">No Approval Needed</option>
</select>
<select [(ngModel)]="integrityFilter" (ngModelChange)="applyFilters()">
<option value="all">Data Integrity: All</option>
<option value="blocked">Blocked</option>
<option value="clear">Clear</option>
</select>
</div>
@if (error()) {
<div class="banner error">{{ error() }}</div>
}
@if (loading()) {
<div class="banner">Loading release runs...</div>
} @else {
@if (viewMode() === 'correlations') {
<div class="clusters">
@for (cluster of correlationClusters(); track cluster.key) {
<article>
<h3>{{ cluster.key }}</h3>
<p>{{ cluster.count }} events · {{ cluster.releases }} release version(s)</p>
<p>{{ cluster.environments }}</p>
</article>
} @empty {
<div class="banner">No run correlations match the current filters.</div>
}
</div>
} @else {
<table>
<thead>
<tr>
<th>Run</th>
<th>Release Version</th>
<th>Lane</th>
<th>Outcome</th>
<th>Environment</th>
<th>Needs Approval</th>
<th>Data Integrity</th>
<th>When</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows(); track row.activityId) {
<tr>
<td><a [routerLink]="['/releases/runs', row.releaseId, 'timeline']">{{ row.activityId }}</a></td>
<td>{{ row.releaseName }}</td>
<td>{{ deriveLane(row) }}</td>
<td>{{ deriveOutcome(row) }}</td>
<td>{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}</td>
<td>{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}</td>
<td>{{ deriveDataIntegrity(row) }}</td>
<td>{{ formatDate(row.occurredAt) }}</td>
</tr>
} @empty {
<tr><td colspan="8">No runs match the active filters.</td></tr>
}
</tbody>
</table>
}
}
</section>
`,
styles: [`
.activity{display:grid;gap:.6rem}.activity header h1{margin:0}.activity header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
.context{display:flex;gap:.35rem;flex-wrap:wrap}.context span{border:1px solid var(--color-border-primary);border-radius:var(--radius-full);padding:.1rem .45rem;font-size:.7rem;color:var(--color-text-secondary)}
.mode-tabs{display:flex;gap:.25rem;flex-wrap:wrap}.mode-tabs a{border:1px solid var(--color-border-primary);border-radius:var(--radius-full);padding:.12rem .5rem;font-size:.72rem;color:var(--color-text-secondary);text-decoration:none}
.mode-tabs a.active{border-color:var(--color-brand-primary);color:var(--color-brand-primary)}
.filters{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.35rem}
.filters select{border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);background:var(--color-surface-primary);padding:.24rem .45rem;font-size:.72rem}
.banner,table,.clusters article{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)}
.banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid var(--color-border-primary);padding:.4rem .5rem;font-size:.72rem;text-align:left;vertical-align:top}th{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase}
tr:last-child td{border-bottom:none}.clusters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.45rem}.clusters article{padding:.55rem}.clusters h3{margin:0;font-size:.82rem}.clusters p{margin:.2rem 0;color:var(--color-text-secondary);font-size:.74rem}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReleasesActivityComponent {
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly rows = signal<ReleaseActivityProjection[]>([]);
readonly viewMode = signal<'timeline' | 'table' | 'correlations'>('timeline');
statusFilter = 'all';
laneFilter = 'all';
envFilter = 'all';
outcomeFilter = 'all';
needsApprovalFilter = 'all';
integrityFilter = 'all';
readonly filteredRows = computed(() => {
let rows = [...this.rows()];
if (this.statusFilter !== 'all') {
rows = rows.filter((item) => item.status.toLowerCase() === this.statusFilter);
}
if (this.laneFilter !== 'all') {
rows = rows.filter((item) => this.deriveLane(item) === this.laneFilter);
}
if (this.envFilter !== 'all') {
rows = rows.filter((item) => (item.targetEnvironment ?? '').toLowerCase().includes(this.envFilter));
}
if (this.outcomeFilter !== 'all') {
rows = rows.filter((item) => this.deriveOutcome(item) === this.outcomeFilter);
}
if (this.needsApprovalFilter !== 'all') {
const expected = this.needsApprovalFilter === 'true';
rows = rows.filter((item) => this.deriveNeedsApproval(item) === expected);
}
if (this.integrityFilter !== 'all') {
rows = rows.filter((item) => this.deriveDataIntegrity(item) === this.integrityFilter);
}
return rows;
});
readonly correlationClusters = computed(() => {
const map = new Map<string, { key: string; count: number; releaseSet: Set<string>; envSet: Set<string> }>();
for (const row of this.filteredRows()) {
const key = row.correlationKey || 'uncorrelated';
const next = map.get(key) ?? { key, count: 0, releaseSet: new Set<string>(), envSet: new Set<string>() };
next.count += 1;
next.releaseSet.add(row.releaseId);
next.envSet.add(row.targetEnvironment || '-');
map.set(key, next);
}
return [...map.values()]
.map((item) => ({
key: item.key,
count: item.count,
releases: item.releaseSet.size,
environments: [...item.envSet].join(', '),
}))
.sort((left, right) => right.count - left.count || left.key.localeCompare(right.key, 'en', { sensitivity: 'base' }));
});
constructor() {
this.context.initialize();
this.route.data.subscribe((data) => {
const lane = (data['defaultLane'] as string | undefined) ?? null;
if (lane === 'hotfix') {
this.laneFilter = 'hotfix';
}
});
this.route.queryParamMap.subscribe((params) => {
const view = (params.get('view') ?? 'timeline').toLowerCase();
if (view === 'timeline' || view === 'table' || view === 'correlations') {
this.viewMode.set(view);
} else {
this.viewMode.set('timeline');
}
this.statusFilter = params.get('status') ?? this.statusFilter;
this.laneFilter = params.get('lane') ?? this.laneFilter;
this.envFilter = params.get('env') ?? this.envFilter;
this.outcomeFilter = params.get('outcome') ?? this.outcomeFilter;
this.needsApprovalFilter = params.get('needsApproval') ?? this.needsApprovalFilter;
this.integrityFilter = params.get('integrity') ?? this.integrityFilter;
});
effect(() => {
this.context.contextVersion();
this.load();
});
}
mergeQuery(next: Record<string, string>): Record<string, string | null> {
return {
view: next['view'] ?? this.viewMode(),
status: this.statusFilter !== 'all' ? this.statusFilter : null,
lane: this.laneFilter !== 'all' ? this.laneFilter : null,
env: this.envFilter !== 'all' ? this.envFilter : null,
outcome: this.outcomeFilter !== 'all' ? this.outcomeFilter : null,
needsApproval: this.needsApprovalFilter !== 'all' ? this.needsApprovalFilter : null,
integrity: this.integrityFilter !== 'all' ? this.integrityFilter : null,
};
}
applyFilters(): void {
void this.router.navigate([], {
relativeTo: this.route,
replaceUrl: true,
queryParams: this.mergeQuery({ view: this.viewMode() }),
});
}
deriveLane(item: ReleaseActivityProjection): 'standard' | 'hotfix' {
return item.releaseName.toLowerCase().includes('hotfix') ? 'hotfix' : 'standard';
}
deriveOutcome(item: ReleaseActivityProjection): 'success' | 'in_progress' | 'failed' {
const status = item.status.toLowerCase();
if (status.includes('published') || status.includes('approved') || status.includes('deployed')) {
return 'success';
}
if (status.includes('blocked') || status.includes('rejected') || status.includes('failed')) {
return 'failed';
}
return 'in_progress';
}
deriveNeedsApproval(item: ReleaseActivityProjection): boolean {
const status = item.status.toLowerCase();
return status.includes('pending_approval') || item.eventType.toLowerCase().includes('approval');
}
deriveDataIntegrity(item: ReleaseActivityProjection): 'blocked' | 'clear' {
const status = item.status.toLowerCase();
return status.includes('blocked') ? 'blocked' : 'clear';
}
formatDate(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
private load(): void {
this.loading.set(true);
this.error.set(null);
let params = new HttpParams().set('limit', '200').set('offset', '0');
const region = this.context.selectedRegions()[0];
const environment = this.context.selectedEnvironments()[0];
if (region) params = params.set('region', region);
if (environment) params = params.set('environment', environment);
this.http.get<PlatformListResponse<ReleaseActivityProjection>>('/api/v2/releases/activity', { params }).pipe(take(1)).subscribe({
next: (response) => {
const sorted = [...(response?.items ?? [])].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
this.rows.set(sorted);
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load release runs.');
this.rows.set([]);
this.loading.set(false);
},
});
}
}

View File

@@ -1,5 +1,35 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { take } from 'rxjs';
interface PlatformItemResponse<T> { item: T; }
interface SecurityDispositionProjection {
findingId: string;
cveId: string;
releaseId: string;
releaseName: string;
packageName: string;
componentName: string;
environment: string;
region: string;
effectiveDisposition: string;
policyAction: string;
updatedAt: string;
vex: { status: string; justification: string; statementId?: string | null };
exception: { status: string; reason: string; approvalState: string; expiresAt?: string | null };
}
interface SecurityFindingProjection {
findingId: string;
cveId: string;
severity: string;
reachable: boolean;
reachabilityScore: number;
updatedAt: string;
}
interface SecurityFindingsResponse { items: SecurityFindingProjection[]; }
type DetailTab = 'why' | 'effective-vex' | 'waivers' | 'policy-trace' | 'evidence';
@Component({
selector: 'app-finding-detail-page',
@@ -7,94 +37,226 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="finding-detail">
<header class="header">
<h1>Finding {{ findingId() }}</h1>
<p>CVE-2026-1234 | openssl | Critical | api-gateway | sha256:api123 | prod-eu</p>
<section class="detail">
<header>
<h1>{{ disposition()?.cveId || findingId() }}</h1>
<p>{{ disposition()?.componentName || 'component-unknown' }} <20> {{ disposition()?.region }}/{{ disposition()?.environment }}</p>
</header>
<section class="panel">
<h2>Reachability</h2>
<p>REACHABLE (confidence 91%)</p>
<p>B/I/R evidence age: B 42m | I 38m | R 2h 11m</p>
</section>
<div class="summary-strip">
<span class="chip">Disposition: {{ disposition()?.effectiveDisposition || 'unknown' }}</span>
<span class="chip">Policy: {{ disposition()?.policyAction || 'n/a' }}</span>
<span class="chip">Reachability: {{ finding()?.reachabilityScore ?? 0 }}</span>
<span class="chip">Updated: {{ disposition() ? fmt(disposition()!.updatedAt) : 'n/a' }}</span>
</div>
<section class="panel">
<h2>Impact</h2>
<p>Affected environments: 3 | Affected bundle versions: 2</p>
<p>
Blocked approvals:
<a [routerLink]="['/release-control/approvals']" [queryParams]="{ finding: findingId() }">2 approvals</a>
</p>
</section>
<nav class="tabs" aria-label="Disposition detail tabs">
@for (tab of tabs; track tab.id) {
<a [routerLink]="[]" [queryParams]="{ tab: tab.id }" [class.active]="activeTab()===tab.id">{{ tab.label }}</a>
}
</nav>
<section class="panel">
<h2>Disposition</h2>
<p>VEX statements: 1 linked</p>
<p>Exceptions: none active</p>
</section>
@if (error()) { <div class="banner banner--error">{{ error() }}</div> }
@if (loading()) { <div class="banner">Loading finding detail...</div> }
<section class="actions">
<button type="button">Create Exception Request</button>
<button type="button">Search/Import VEX</button>
<button type="button">Export as Evidence</button>
</section>
@if (!loading() && disposition()) {
@switch (activeTab()) {
@case ('why') {
<article>
<h2>Why This Verdict</h2>
<p>Verdict: <strong>{{ verdictLabel() }}</strong></p>
<p>Reason: {{ whyReason() }}</p>
<p>B/I/R coverage: {{ birCoverage().b }} / {{ birCoverage().i }} / {{ birCoverage().r }}</p>
</article>
}
@case ('effective-vex') {
<article>
<h2>Effective VEX</h2>
<p>Status: <strong>{{ disposition()!.vex.status }}</strong></p>
<p>Justification: {{ disposition()!.vex.justification || 'n/a' }}</p>
<p>Statement: {{ disposition()!.vex.statementId || 'none' }}</p>
<a [routerLink]="['/security/advisories-vex']" [queryParams]="{ tab: 'vex-library' }">Open VEX library</a>
</article>
}
@case ('waivers') {
<article>
<h2>Waivers / Exceptions</h2>
<p>Status: <strong>{{ disposition()!.exception.status }}</strong></p>
<p>Approval: {{ disposition()!.exception.approvalState }}</p>
<p>Reason: {{ disposition()!.exception.reason || 'none' }}</p>
<p>Expires: {{ disposition()!.exception.expiresAt || 'not set' }}</p>
<a [routerLink]="['/security/advisories-vex']" [queryParams]="{ tab: 'conflicts' }">Open conflict queue</a>
</article>
}
@case ('policy-trace') {
<article>
<h2>Policy Gate Trace</h2>
<p>Gate action: <strong>{{ disposition()!.policyAction }}</strong></p>
<p>Effective disposition: {{ disposition()!.effectiveDisposition }}</p>
<p>Evidence age: {{ evidenceAge() }}</p>
<a [routerLink]="['/releases/runs', disposition()!.releaseId, 'security']">Open run gate trace</a>
</article>
}
@case ('evidence') {
<article>
<h2>Evidence Export</h2>
<p>Release: {{ disposition()!.releaseName }}</p>
<p>Component: {{ disposition()!.componentName }} / {{ disposition()!.packageName }}</p>
<a [routerLink]="['/evidence/exports/export']" [queryParams]="{ findingId: findingId() }">Export decision capsule</a>
</article>
}
}
<footer class="actions">
<a [routerLink]="['/security/triage']">Back to triage</a>
<a [routerLink]="['/security/advisories-vex']" [queryParams]="{ tab: 'vex-library' }">Search/Import VEX</a>
<a [routerLink]="['/security/advisories-vex']" [queryParams]="{ tab: 'conflicts' }">Create waiver request</a>
</footer>
}
</section>
`,
styles: [
`
.finding-detail {
display: grid;
gap: 0.85rem;
}
styles: [`
.detail{display:grid;gap:.6rem}
.detail header h1{margin:0}
.detail header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
.header h1 {
margin: 0 0 0.2rem;
font-size: 1.4rem;
}
.summary-strip,.tabs a,.banner,article,.actions{
border:1px solid var(--color-border-primary);
border-radius:var(--radius-md);
background:var(--color-surface-primary);
}
.header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.summary-strip{display:flex;flex-wrap:wrap;gap:.25rem;padding:.45rem}
.chip{border:1px solid var(--color-border-primary);border-radius:var(--radius-full);padding:.08rem .42rem;font-size:.68rem;color:var(--color-text-secondary)}
.panel {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem 0.8rem;
}
.tabs{display:flex;gap:.3rem;flex-wrap:wrap}
.tabs a{padding:.12rem .5rem;font-size:.72rem;color:var(--color-text-secondary);text-decoration:none}
.tabs a.active{border-color:var(--color-brand-primary);color:var(--color-brand-primary)}
.panel h2 {
margin: 0 0 0.35rem;
font-size: 0.9rem;
}
.banner{padding:.65rem;font-size:.8rem;color:var(--color-text-secondary)}
.banner--error{color:var(--color-status-error-text)}
.panel p {
margin: 0.2rem 0;
font-size: 0.82rem;
}
article{padding:.6rem;display:grid;gap:.25rem}
article h2{margin:0 0 .2rem;font-size:.88rem}
article p{margin:0;color:var(--color-text-secondary);font-size:.76rem}
article a{font-size:.74rem;color:var(--color-brand-primary);text-decoration:none}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.actions button {
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
border-radius: var(--radius-md);
padding: 0.45rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
}
`,
],
.actions{padding:.45rem;display:flex;gap:.45rem;flex-wrap:wrap}
.actions a{font-size:.74rem;color:var(--color-brand-primary);text-decoration:none}
`],
})
export class FindingDetailPageComponent {
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly findingId = signal(this.route.snapshot.paramMap.get('findingId') ?? 'unknown-finding');
}
readonly activeTab = signal<DetailTab>('why');
readonly disposition = signal<SecurityDispositionProjection | null>(null);
readonly finding = signal<SecurityFindingProjection | null>(null);
readonly tabs: Array<{ id: DetailTab; label: string }> = [
{ id: 'why', label: 'Why' },
{ id: 'effective-vex', label: 'Effective VEX' },
{ id: 'waivers', label: 'Waivers/Exceptions' },
{ id: 'policy-trace', label: 'Policy Gate Trace' },
{ id: 'evidence', label: 'Evidence Export' },
];
readonly birCoverage = computed(() => {
const score = this.finding()?.reachabilityScore ?? 0;
return {
b: `${Math.min(100, Math.max(0, score))}%`,
i: `${Math.min(100, Math.max(0, score + 8))}%`,
r: `${Math.min(100, Math.max(0, score - 12))}%`,
};
});
readonly evidenceAge = computed(() => {
const updatedAt = this.disposition()?.updatedAt;
if (!updatedAt) return 'unknown';
const ms = Date.now() - new Date(updatedAt).getTime();
if (ms < 0) return 'clock skew';
const hours = Math.floor(ms / 3600000);
if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`;
return `${hours}h`;
});
constructor() {
this.route.paramMap.subscribe((params) => {
const id = params.get('findingId') ?? 'unknown-finding';
this.findingId.set(id);
this.load(id);
});
this.route.queryParamMap.subscribe((params) => {
const tab = (params.get('tab') ?? 'why').toLowerCase();
if (this.tabs.some((item) => item.id === tab)) {
this.activeTab.set(tab as DetailTab);
} else {
this.activeTab.set('why');
}
});
}
verdictLabel(): string {
const row = this.disposition();
if (!row) return 'unknown';
if (row.effectiveDisposition === 'action_required') {
return row.exception.status === 'approved' ? 'needs waiver governance' : 'blocked';
}
return 'ship-ready';
}
whyReason(): string {
const row = this.disposition();
if (!row) return 'No disposition record available.';
if (row.effectiveDisposition === 'action_required' && row.exception.status !== 'approved') {
return 'Reachability and policy action require operator mitigation or waiver approval.';
}
if (row.exception.status === 'approved') {
return 'Waiver approved; policy trace should confirm bounded scope and expiry.';
}
return 'Current policy and VEX state do not block shipment.';
}
fmt(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
private load(findingId: string): void {
this.loading.set(true);
this.error.set(null);
this.http.get<PlatformItemResponse<SecurityDispositionProjection>>(`/api/v2/security/disposition/${findingId}`).pipe(take(1)).subscribe({
next: (response) => {
this.disposition.set(response.item);
this.loadFindingProjection(findingId);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load finding detail.');
this.disposition.set(null);
this.finding.set(null);
this.loading.set(false);
},
});
}
private loadFindingProjection(findingId: string): void {
const params = new HttpParams().set('search', findingId).set('limit', '50').set('offset', '0');
this.http.get<SecurityFindingsResponse>('/api/v2/security/findings', { params }).pipe(take(1)).subscribe({
next: (response) => {
const finding = (response.items ?? []).find((item) => item.findingId === findingId) ?? null;
this.finding.set(finding);
this.loading.set(false);
},
error: () => {
this.finding.set(null);
this.loading.set(false);
},
});
}
}

View File

@@ -1,25 +1,57 @@
/**
* Security & Risk Overview Component
* Sprint: SPRINT_20260218_014_FE_ui_v2_rewire_security_risk_consolidation (S9-01 through S9-05)
*
* Domain overview page for Security & Risk (S0). Decision-first ordering.
* Advisory source health is intentionally delegated to Platform Ops > Data Integrity.
*/
import {
Component,
ChangeDetectionStrategy,
signal,
} from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, map, take } from 'rxjs/operators';
interface RiskSummaryCard {
title: string;
value: string | number;
subtext: string;
severity: 'ok' | 'warning' | 'critical' | 'info';
link: string;
linkLabel: string;
import { PlatformContextStore } from '../../core/context/platform-context.store';
interface SecurityFindingProjection {
findingId: string;
cveId: string;
severity: string;
releaseId: string;
releaseName: string;
environment: string;
region?: string;
reachable: boolean;
reachabilityScore: number;
effectiveDisposition: string;
}
interface SecurityFindingsResponse {
items: SecurityFindingProjection[];
}
interface SecurityDispositionProjection {
findingId: string;
cveId: string;
releaseId: string;
releaseName: string;
environment: string;
region?: string;
effectiveDisposition: string;
policyAction: string;
updatedAt: string;
vex: { status: string };
exception: { status: string; approvalState: string; reason?: string; expiresAt?: string | null };
}
interface SecuritySbomExplorerResponse {
table: Array<{ componentId: string; environment: string; updatedAt: string; criticalReachableCount: number }>;
}
interface IntegrationHealthRow {
sourceId: string;
sourceName: string;
status: string;
freshness: string;
freshnessMinutes?: number | null;
slaMinutes: number;
}
interface PlatformListResponse<T> {
items: T[];
}
@Component({
@@ -28,457 +60,392 @@ interface RiskSummaryCard {
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="security-risk-overview">
<header class="overview-header">
<div class="header-content">
<h1 class="overview-title">Security &amp; Risk</h1>
<p class="overview-subtitle">
Decision-first view of risk posture, findings, vulnerabilities, SBOM health, VEX coverage, and reachability.
</p>
<section class="overview">
<header class="page-header">
<div>
<h1>Security / Overview</h1>
<p>Blocker-first posture for release decisions, freshness confidence, and disposition risk.</p>
</div>
<div class="scope">
<span>Scope</span>
<strong>{{ scopeSummary() }}</strong>
</div>
</header>
<section class="data-confidence-banner" role="status">
<strong>Data Confidence: WARN</strong>
<span>NVD stale 3h; SBOM rescan FAIL; runtime ingest lagging</span>
<a routerLink="/platform-ops/data-integrity">Open Data Integrity</a>
</section>
<div class="status-rail" [class]="'status-rail status-rail--' + confidence().status.toLowerCase()">
<span class="chip">Evidence Rail: ON</span>
<span class="chip">Policy Pack: latest</span>
<span class="chip">Snapshot: {{ confidence().status }}</span>
<span class="summary">{{ confidence().summary }}</span>
<a routerLink="/platform/ops/data-integrity">Drilldown</a>
</div>
<!-- Primary cards: risk-blocking decisions first -->
<section class="cards-grid primary-cards" aria-label="Security risk summary">
<!-- Risk Score Card -->
<a routerLink="/security-risk/risk" class="card card-risk" [class]="riskCard().severity">
<div class="card-label">Risk Overview</div>
<div class="card-value">{{ riskCard().value }}</div>
<div class="card-subtext">{{ riskCard().subtext }}</div>
<div class="card-arrow" aria-hidden="true">&#8594;</div>
</a>
@if (error()) { <div class="banner banner--error">{{ error() }}</div> }
@if (loading()) { <div class="banner">Loading security overview...</div> }
<!-- Findings Card -->
<a routerLink="/security-risk/findings" class="card card-findings" [class]="findingsCard().severity">
<div class="card-label">Findings</div>
<div class="card-value">{{ findingsCard().value }}</div>
<div class="card-subtext">{{ findingsCard().subtext }}</div>
<div class="card-arrow" aria-hidden="true">&#8594;</div>
</a>
@if (!loading()) {
<section class="kpis">
<article>
<h2>Risk Posture</h2>
<p class="value">{{ riskPostureLabel() }}</p>
<small>{{ findingsCount() }} findings in scope</small>
</article>
<article>
<h2>Blocking Items</h2>
<p class="value">{{ blockerCount() }}</p>
<small>Policy action = block</small>
</article>
<article>
<h2>VEX Coverage</h2>
<p class="value">{{ vexCoveragePct() }}%</p>
<small>{{ vexCoveredCount() }}/{{ dispositions().length }} findings</small>
</article>
<article>
<h2>SBOM Health</h2>
<p class="value">{{ sbomFreshCount() }}/{{ sbomRows().length }}</p>
<small>fresh components</small>
</article>
<article>
<h2>Reachability Coverage</h2>
<p class="value">{{ reachabilityCoveragePct() }}%</p>
<small>{{ reachableCount() }} reachable</small>
</article>
<article>
<h2>Unknown Reachability</h2>
<p class="value">{{ unknownReachabilityCount() }}</p>
<small>needs deeper runtime evidence</small>
</article>
</section>
<!-- Vulnerabilities Card -->
<a routerLink="/security-risk/vulnerabilities" class="card card-vulns" [class]="vulnsCard().severity">
<div class="card-label">Vulnerabilities</div>
<div class="card-value">{{ vulnsCard().value }}</div>
<div class="card-subtext">{{ vulnsCard().subtext }}</div>
<div class="card-arrow" aria-hidden="true">&#8594;</div>
</a>
</section>
<div class="grid">
<article class="panel">
<div class="panel-header">
<h3>Top Blocking Items</h3>
<a routerLink="/security/triage">Open triage</a>
</div>
<ul>
@for (blocker of topBlockers(); track blocker.findingId) {
<li>
<a [routerLink]="['/security/triage', blocker.findingId]">{{ blocker.cveId || blocker.findingId }}</a>
<span>{{ blocker.releaseName }} <20> {{ blocker.region || 'global' }}/{{ blocker.environment }}</span>
</li>
} @empty {
<li class="empty">No blockers in the selected scope.</li>
}
</ul>
</article>
<!-- Secondary cards: context and coverage -->
<section class="cards-grid secondary-cards" aria-label="Security context">
<!-- SBOM Health Card -->
<a routerLink="/security-risk/sbom" class="card card-sbom" [class]="sbomCard().severity">
<div class="card-label">SBOM Health</div>
<div class="card-value">{{ sbomCard().value }}</div>
<div class="card-subtext">{{ sbomCard().subtext }}</div>
<div class="card-arrow" aria-hidden="true">&#8594;</div>
</a>
<article class="panel">
<div class="panel-header">
<h3>Expiring Waivers</h3>
<a [routerLink]="['/security/advisories-vex']" [queryParams]="{ tab: 'vex-library' }">Disposition</a>
</div>
<ul>
@for (waiver of expiringWaivers(); track waiver.findingId) {
<li>
<a [routerLink]="['/security/triage', waiver.findingId]" [queryParams]="{ tab: 'waiver' }">{{ waiver.cveId || waiver.findingId }}</a>
<span>expires {{ expiresIn(waiver.exception.expiresAt) }}</span>
</li>
} @empty {
<li class="empty">No waivers expiring in the next 7 days.</li>
}
</ul>
</article>
<!-- VEX Coverage Card -->
<a routerLink="/security-risk/vex" class="card card-vex" [class]="vexCard().severity">
<div class="card-label">VEX Coverage</div>
<div class="card-value">{{ vexCard().value }}</div>
<div class="card-subtext">{{ vexCard().subtext }}</div>
<div class="card-arrow" aria-hidden="true">&#8594;</div>
</a>
<article class="panel">
<div class="panel-header">
<h3>Advisories & VEX Health</h3>
<a routerLink="/platform/integrations/feeds">Configure sources</a>
</div>
<p class="meta">
Conflicts: <strong>{{ conflictCount() }}</strong> <20>
Unverified statements: <strong>{{ unresolvedVexCount() }}</strong>
</p>
<ul>
@for (provider of providerHealthRows(); track provider.sourceId) {
<li>
<span>{{ provider.sourceName }}</span>
<span>{{ provider.status }} <20> {{ provider.freshness }}</span>
</li>
} @empty {
<li class="empty">No provider health rows for current scope.</li>
}
</ul>
</article>
<!-- Reachability Card (second-class: visible, not primary decision surface) -->
<a routerLink="/security-risk/reachability" class="card card-reachability" [class]="reachabilityCard().severity">
<div class="card-label">Reachability</div>
<div class="card-value">{{ reachabilityCard().value }}</div>
<div class="card-subtext">{{ reachabilityCard().subtext }}</div>
<div class="card-arrow" aria-hidden="true">&#8594;</div>
</a>
</section>
<section class="critical-by-env" aria-label="Critical reachable by environment">
<h2 class="context-links-title">Critical Reachable by Environment</h2>
<div class="critical-grid">
@for (env of criticalReachableByEnvironment(); track env.name) {
<article>
<span>{{ env.name }}</span>
<strong>{{ env.count }}</strong>
</article>
}
<article class="panel">
<div class="panel-header">
<h3>Supply-Chain Coverage</h3>
<a routerLink="/security/supply-chain-data/coverage">Coverage & Unknowns</a>
</div>
<p class="meta">
Reachability unknowns: <strong>{{ unknownReachabilityCount() }}</strong> <20>
Stale SBOM rows: <strong>{{ sbomStaleCount() }}</strong>
</p>
<ul>
<li>
<a [routerLink]="['/security/triage']" [queryParams]="{ reachability: 'unreachable' }">Inspect unknown/unreachable findings</a>
</li>
<li>
<a routerLink="/security/supply-chain-data/reachability">Open reachability coverage board</a>
</li>
</ul>
</article>
</div>
</section>
<section class="posture-grid" aria-label="SBOM and VEX posture">
<article class="posture-card">
<h2>SBOM Posture</h2>
<p>Coverage {{ sbomPosture().coverage }}% | Freshness {{ sbomPosture().freshness }} | Pending scans {{ sbomPosture().pending }}</p>
</article>
<article class="posture-card">
<h2>VEX &amp; Exceptions</h2>
<p>Statements {{ vexPosture().statements }} | Expiring exceptions {{ vexPosture().expiringExceptions }}</p>
</article>
</section>
<!-- Contextual navigation links -->
<section class="context-links" aria-label="Related surfaces">
<h2 class="context-links-title">More in Security &amp; Risk</h2>
<div class="context-links-grid">
<a routerLink="/security-risk/lineage" class="context-link">Lineage</a>
<a routerLink="/security-risk/patch-map" class="context-link">Patch Map</a>
<a routerLink="/security-risk/unknowns" class="context-link">Unknowns</a>
<a routerLink="/security-risk/artifacts" class="context-link">Artifacts</a>
<a routerLink="/security-risk/sbom/graph" class="context-link">SBOM Graph</a>
<a routerLink="/security-risk/advisory-sources" class="context-link">Advisory Sources</a>
</div>
</section>
<!-- Advisory source ownership note -->
<aside class="ownership-note" role="note">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
Advisory source health is managed in
<a routerLink="/platform-ops/data-integrity">Platform Ops &gt; Data Integrity</a>.
Security &amp; Risk consumes source decision impact; connectivity and mirror operations are
owned by Platform Ops and Integrations respectively.
</aside>
</div>
}
</section>
`,
styles: [`
.security-risk-overview {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.overview{display:grid;gap:.75rem}
.page-header{display:flex;justify-content:space-between;gap:1rem;align-items:flex-start}
.page-header h1{margin:0}
.page-header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.82rem}
.scope{display:grid;gap:.1rem;text-align:right}
.scope span{font-size:.65rem;text-transform:uppercase;color:var(--color-text-secondary)}
.scope strong{font-size:.78rem}
/* Header */
.overview-header {
border-bottom: 1px solid var(--color-border-primary);
padding-bottom: 1.25rem;
.status-rail,.banner,.kpis article,.panel{
border:1px solid var(--color-border-primary);
border-radius:var(--radius-md);
background:var(--color-surface-primary);
}
.overview-title {
font-size: 1.75rem;
font-weight: var(--font-weight-bold);
margin: 0;
.status-rail{
display:flex;
flex-wrap:wrap;
gap:.35rem .45rem;
align-items:center;
padding:.55rem .65rem;
font-size:.75rem;
}
.overview-subtitle {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 0.35rem 0 0;
.status-rail .chip{
border:1px solid var(--color-border-primary);
border-radius:var(--radius-full);
padding:.1rem .4rem;
color:var(--color-text-secondary);
font-size:.68rem;
}
.status-rail .summary{color:var(--color-text-secondary)}
.status-rail a{margin-left:auto;color:var(--color-brand-primary);text-decoration:none}
.status-rail--warn{border-color:var(--color-status-warning-text)}
.status-rail--fail{border-color:var(--color-status-error-text)}
.data-confidence-banner {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
border: 1px solid #f59e0b;
background: #fffbeb;
color: #92400e;
border-radius: var(--radius-md);
padding: 0.62rem 0.78rem;
font-size: 0.82rem;
.banner{padding:.65rem;font-size:.8rem;color:var(--color-text-secondary)}
.banner--error{color:var(--color-status-error-text)}
.kpis{
display:grid;
grid-template-columns:repeat(auto-fit,minmax(160px,1fr));
gap:.5rem;
}
.data-confidence-banner a {
color: var(--color-brand-primary);
text-decoration: none;
margin-left: auto;
.kpis article{padding:.6rem}
.kpis h2{
margin:0;
font-size:.66rem;
text-transform:uppercase;
letter-spacing:.02em;
color:var(--color-text-secondary);
}
.kpis .value{margin:.2rem 0 0;font-size:1.15rem;font-weight:var(--font-weight-semibold)}
.kpis small{font-size:.68rem;color:var(--color-text-secondary)}
/* Card Grids */
.cards-grid {
display: grid;
gap: 1rem;
.grid{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:.5rem;
}
.panel{padding:.65rem;display:grid;gap:.45rem}
.panel-header{display:flex;justify-content:space-between;align-items:center;gap:.5rem}
.panel-header h3{margin:0;font-size:.85rem}
.panel-header a{font-size:.72rem;color:var(--color-brand-primary);text-decoration:none}
.panel .meta{margin:0;font-size:.74rem;color:var(--color-text-secondary)}
.panel ul{margin:0;padding-left:1rem;display:grid;gap:.25rem}
.panel li{font-size:.76rem;color:var(--color-text-secondary)}
.panel li a{color:var(--color-brand-primary);text-decoration:none}
.panel li span{margin-left:.35rem}
.panel li.empty{list-style:none;padding-left:0;color:var(--color-text-secondary)}
.primary-cards {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.secondary-cards {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.critical-by-env,
.posture-card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
}
.critical-by-env {
padding: 1rem;
display: grid;
gap: 0.7rem;
}
.critical-grid {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.critical-grid article {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: 0.6rem 0.7rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.82rem;
}
.critical-grid strong {
font-size: 1.15rem;
}
.posture-grid {
display: grid;
gap: 0.8rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.posture-card {
padding: 0.8rem 0.9rem;
}
.posture-card h2 {
margin: 0 0 0.35rem;
font-size: 0.86rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.posture-card p {
margin: 0;
font-size: 0.84rem;
color: var(--color-text-secondary);
}
/* Cards */
.card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 1.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
text-decoration: none;
color: var(--color-text-primary);
position: relative;
transition: box-shadow 0.15s, border-color 0.15s;
}
.card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-brand-primary);
}
.card.critical {
border-left: 4px solid var(--color-status-error);
}
.card.warning {
border-left: 4px solid var(--color-status-warning);
}
.card.ok {
border-left: 4px solid var(--color-status-success);
}
.card.info {
border-left: 4px solid var(--color-status-info);
}
.card-label {
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card-value {
font-size: 2rem;
font-weight: var(--font-weight-bold);
line-height: 1;
}
.card-subtext {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.card-arrow {
position: absolute;
right: 1.25rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-brand-primary);
font-size: 1.1rem;
}
/* Context Links */
.context-links {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.context-links-title {
font-size: 0.85rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
margin: 0 0 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.context-links-grid {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.context-link {
display: inline-block;
padding: 0.35rem 0.85rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
font-size: 0.85rem;
color: var(--color-text-primary);
text-decoration: none;
background: var(--color-surface-primary);
transition: background 0.15s;
}
.context-link:hover {
background: var(--color-brand-primary);
color: var(--color-text-heading);
border-color: var(--color-brand-primary);
}
/* Ownership Note */
.ownership-note {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.9rem 1.1rem;
background: var(--color-status-info-bg, rgba(59,130,246,0.08));
border: 1px solid var(--color-status-info, #3b82f6);
border-radius: var(--radius-md);
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.ownership-note svg {
flex-shrink: 0;
margin-top: 0.15rem;
color: var(--color-status-info, #3b82f6);
}
.ownership-note a {
color: var(--color-brand-primary);
text-decoration: none;
}
.ownership-note a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.primary-cards,
.secondary-cards {
grid-template-columns: 1fr;
}
@media (max-width: 1080px){
.grid{grid-template-columns:1fr}
.scope{text-align:left}
}
`],
})
export class SecurityRiskOverviewComponent {
// Risk card — highest-priority decision signal
readonly riskCard = signal<RiskSummaryCard>({
title: 'Risk Overview',
value: 'HIGH',
subtext: '3 environments at elevated risk',
severity: 'critical',
link: '/security-risk/risk',
linkLabel: 'View risk detail',
private readonly http = inject(HttpClient);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly findings = signal<SecurityFindingProjection[]>([]);
readonly dispositions = signal<SecurityDispositionProjection[]>([]);
readonly sbomRows = signal<SecuritySbomExplorerResponse['table']>([]);
readonly feedHealth = signal<IntegrationHealthRow[]>([]);
readonly vexSourceHealth = signal<IntegrationHealthRow[]>([]);
readonly findingsCount = computed(() => this.findings().length);
readonly reachableCount = computed(() => this.findings().filter((item) => item.reachable).length);
readonly blockerCount = computed(() => this.topBlockers().length);
readonly topBlockers = computed(() =>
this.dispositions()
.filter((item) => item.policyAction === 'block' || item.effectiveDisposition === 'action_required')
.slice(0, 8)
);
readonly vexCoveredCount = computed(() =>
this.dispositions().filter((row) => row.vex.status !== 'none' && row.vex.status !== 'unknown').length
);
readonly vexCoveragePct = computed(() => {
const total = this.dispositions().length;
if (total === 0) return 0;
return Math.round((this.vexCoveredCount() / total) * 100);
});
readonly findingsCard = signal<RiskSummaryCard>({
title: 'Findings',
value: 284,
subtext: '8 critical reachable findings',
severity: 'critical',
link: '/security-risk/findings',
linkLabel: 'Explore findings',
readonly sbomFreshCount = computed(() => {
const staleMs = 24 * 60 * 60 * 1000;
return this.sbomRows().filter((row) => Date.now() - new Date(row.updatedAt).getTime() <= staleMs).length;
});
readonly sbomStaleCount = computed(() => Math.max(0, this.sbomRows().length - this.sbomFreshCount()));
readonly unknownReachabilityCount = computed(() =>
this.findings().filter((row) => row.reachabilityScore <= 0).length
);
readonly reachabilityCoveragePct = computed(() => {
const total = this.findings().length;
if (total === 0) return 0;
const scored = this.findings().filter((row) => row.reachabilityScore > 0).length;
return Math.round((scored / total) * 100);
});
readonly vulnsCard = signal<RiskSummaryCard>({
title: 'Vulnerabilities',
value: 1_204,
subtext: '51 affecting prod environments',
severity: 'warning',
link: '/security-risk/vulnerabilities',
linkLabel: 'Explore vulnerabilities',
readonly riskPostureLabel = computed(() => {
const critical = this.findings().filter((item) => item.severity === 'critical').length;
const high = this.findings().filter((item) => item.severity === 'high').length;
if (critical > 0) return 'HIGH';
if (high > 0) return 'ELEVATED';
return 'GUARDED';
});
readonly sbomCard = signal<RiskSummaryCard>({
title: 'SBOM Health',
value: '94%',
subtext: '2 stale, 1 missing SBOM',
severity: 'warning',
link: '/security-risk/sbom',
linkLabel: 'SBOM lake',
readonly expiringWaivers = computed(() => {
const now = Date.now();
const cutoff = now + 7 * 24 * 60 * 60 * 1000;
return this.dispositions()
.filter((row) => {
if (!row.exception.expiresAt || row.exception.status === 'none') return false;
const expiry = new Date(row.exception.expiresAt).getTime();
return expiry > now && expiry <= cutoff;
})
.sort((left, right) => {
const leftExpiry = new Date(left.exception.expiresAt ?? '').getTime();
const rightExpiry = new Date(right.exception.expiresAt ?? '').getTime();
return leftExpiry - rightExpiry;
})
.slice(0, 6);
});
readonly vexCard = signal<RiskSummaryCard>({
title: 'VEX Coverage',
value: '61%',
subtext: '476 CVEs awaiting VEX statement',
severity: 'warning',
link: '/security-risk/vex',
linkLabel: 'VEX hub',
readonly conflictCount = computed(() =>
this.dispositions().filter((row) => row.vex.status === 'affected' && row.exception.status === 'approved').length
);
readonly unresolvedVexCount = computed(() =>
this.dispositions().filter((row) => row.vex.status === 'under_investigation' || row.vex.status === 'none').length
);
readonly providerHealthRows = computed(() =>
[...this.feedHealth(), ...this.vexSourceHealth()]
.slice()
.sort((left, right) => left.sourceName.localeCompare(right.sourceName))
.slice(0, 10)
);
readonly scopeSummary = computed(() => {
const regions = this.context.selectedRegions();
const environments = this.context.selectedEnvironments();
const regionText = regions.length > 0 ? regions.join(', ') : 'all regions';
const envText = environments.length > 0 ? environments.join(', ') : 'all environments';
return `${regionText} / ${envText}`;
});
readonly reachabilityCard = signal<RiskSummaryCard>({
title: 'Reachability',
value: '72% B',
subtext: 'B/I/R: 72% / 88% / 61% coverage',
severity: 'info',
link: '/security-risk/reachability',
linkLabel: 'Reachability center',
readonly confidence = computed(() => {
const feeds = this.feedHealth();
const vex = this.vexSourceHealth();
const all = [...feeds, ...vex];
if (all.length === 0) {
return { status: 'WARN', summary: 'No integrations freshness data available for this scope.' };
}
const failed = all.filter((row) => row.status.toLowerCase() === 'offline' || row.freshness.toLowerCase() === 'stale');
const warn = all.filter((row) => row.freshness.toLowerCase() === 'degraded' || (row.freshnessMinutes ?? 0) > row.slaMinutes);
if (failed.length > 0) {
return { status: 'FAIL', summary: `${failed.length} source(s) offline/stale; decision confidence reduced.` };
}
if (warn.length > 0) {
return { status: 'WARN', summary: `${warn.length} source(s) above freshness SLA.` };
}
return { status: 'OK', summary: 'All advisory and VEX sources are within freshness SLO.' };
});
readonly criticalReachableByEnvironment = signal([
{ name: 'prod-eu', count: 4 },
{ name: 'prod-us', count: 3 },
{ name: 'stage-eu', count: 1 },
]);
constructor() {
this.context.initialize();
effect(() => {
this.context.contextVersion();
this.load();
});
}
readonly sbomPosture = signal({
coverage: 94,
freshness: 'WARN',
pending: 2,
});
expiresIn(expiresAt: string | null | undefined): string {
if (!expiresAt) return 'unknown';
const ms = new Date(expiresAt).getTime() - Date.now();
if (!Number.isFinite(ms)) return 'unknown';
if (ms <= 0) return 'expired';
const hours = Math.floor(ms / (60 * 60 * 1000));
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
readonly vexPosture = signal({
statements: 476,
expiringExceptions: 3,
});
}
private load(): void {
this.loading.set(true);
this.error.set(null);
const params = this.createContextParams();
const findings$ = this.http
.get<SecurityFindingsResponse>('/api/v2/security/findings', { params: params.set('pivot', 'cve') })
.pipe(map((res) => res.items ?? []), catchError(() => of([] as SecurityFindingProjection[])));
const disposition$ = this.http
.get<PlatformListResponse<SecurityDispositionProjection>>('/api/v2/security/disposition', { params })
.pipe(map((res) => res.items ?? []), catchError(() => of([] as SecurityDispositionProjection[])));
const sbom$ = this.http
.get<SecuritySbomExplorerResponse>('/api/v2/security/sbom-explorer', { params: params.set('mode', 'table') })
.pipe(map((res) => res.table ?? []), catchError(() => of([] as SecuritySbomExplorerResponse['table'])));
const feedHealth$ = this.http
.get<PlatformListResponse<IntegrationHealthRow>>('/api/v2/integrations/feeds', { params })
.pipe(map((res) => res.items ?? []), catchError(() => of([] as IntegrationHealthRow[])));
const vexHealth$ = this.http
.get<PlatformListResponse<IntegrationHealthRow>>('/api/v2/integrations/vex-sources', { params })
.pipe(map((res) => res.items ?? []), catchError(() => of([] as IntegrationHealthRow[])));
forkJoin({ findings: findings$, disposition: disposition$, sbom: sbom$, feedHealth: feedHealth$, vexHealth: vexHealth$ })
.pipe(take(1))
.subscribe({
next: ({ findings, disposition, sbom, feedHealth, vexHealth }) => {
this.findings.set(findings);
this.dispositions.set(disposition);
this.sbomRows.set(sbom);
this.feedHealth.set(feedHealth);
this.vexSourceHealth.set(vexHealth);
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load security overview.');
this.loading.set(false);
},
});
}
private createContextParams(): HttpParams {
let params = new HttpParams().set('limit', '200').set('offset', '0');
const region = this.context.selectedRegions()[0];
const environment = this.context.selectedEnvironments()[0];
if (region) params = params.set('region', region);
if (environment) params = params.set('environment', environment);
return params;
}
}

View File

@@ -0,0 +1,399 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, map, take } from 'rxjs/operators';
import { PlatformContextStore } from '../../core/context/platform-context.store';
interface SecurityDispositionProjection {
findingId: string;
cveId: string;
releaseId: string;
releaseName: string;
packageName: string;
componentName: string;
environment: string;
region: string;
effectiveDisposition: string;
policyAction: string;
updatedAt: string;
vex: {
status: string;
justification: string;
statementId?: string | null;
};
exception: {
status: string;
reason: string;
approvalState: string;
exceptionId?: string | null;
expiresAt?: string | null;
};
}
interface IntegrationHealthRow {
sourceId: string;
sourceName: string;
status: string;
freshness: string;
freshnessMinutes?: number | null;
slaMinutes: number;
}
interface PlatformListResponse<T> {
items: T[];
}
type AdvisoryTab = 'providers' | 'vex-library' | 'conflicts' | 'issuer-trust';
@Component({
selector: 'app-security-disposition-page',
standalone: true,
imports: [RouterLink],
template: `
<section class="advisories">
<header>
<h1>Security / Advisories & VEX</h1>
<p>Intel and attestation workspace for provider health, statement conflicts, and issuer trust.</p>
</header>
<div class="ownership-links">
<a routerLink="/platform/integrations/feeds">Configure advisory feeds</a>
<a routerLink="/platform/integrations/vex-sources">Configure VEX sources</a>
</div>
<nav class="tabs" aria-label="Advisories and VEX tabs">
@for (tab of tabs; track tab.id) {
<a [routerLink]="[]" [queryParams]="{ tab: tab.id }" [class.active]="activeTab()===tab.id">{{ tab.label }}</a>
}
</nav>
@if (error()) { <div class="banner banner--error">{{ error() }}</div> }
@if (loading()) { <div class="banner">Loading advisories and VEX workspace...</div> }
@if (!loading()) {
@switch (activeTab()) {
@case ('providers') {
<section class="panel">
<h2>Providers</h2>
<table>
<thead>
<tr>
<th>Source</th>
<th>Channel</th>
<th>Status</th>
<th>Freshness</th>
<th>SLA (min)</th>
</tr>
</thead>
<tbody>
@for (row of providerRows(); track row.sourceId) {
<tr>
<td>{{ row.sourceName }}</td>
<td>{{ row.channel }}</td>
<td>{{ row.status }}</td>
<td>{{ row.freshness }}</td>
<td>{{ row.slaMinutes }}</td>
</tr>
} @empty {
<tr><td colspan="5">No provider data available in current scope.</td></tr>
}
</tbody>
</table>
</section>
}
@case ('vex-library') {
<section class="panel">
<h2>VEX Library</h2>
<table>
<thead>
<tr>
<th>Finding</th>
<th>Release</th>
<th>Effective VEX</th>
<th>Waiver</th>
<th>Updated</th>
<th>Open</th>
</tr>
</thead>
<tbody>
@for (row of vexLibraryRows(); track row.findingId) {
<tr>
<td>{{ row.cveId || row.findingId }}</td>
<td>{{ row.releaseName }}</td>
<td>{{ row.vex.status }}</td>
<td>{{ row.exception.status }}</td>
<td>{{ fmt(row.updatedAt) }}</td>
<td><a [routerLink]="['/security/triage', row.findingId]" [queryParams]="{ tab: 'vex' }">Open</a></td>
</tr>
} @empty {
<tr><td colspan="6">No VEX statements matched the current scope.</td></tr>
}
</tbody>
</table>
</section>
}
@case ('conflicts') {
<section class="panel">
<h2>Conflicts & Resolution</h2>
<table>
<thead>
<tr>
<th>Finding</th>
<th>VEX</th>
<th>Waiver</th>
<th>Policy Action</th>
<th>Resolution</th>
<th>Open</th>
</tr>
</thead>
<tbody>
@for (row of conflictRows(); track row.findingId) {
<tr>
<td>{{ row.cveId || row.findingId }}</td>
<td>{{ row.vex.status }}</td>
<td>{{ row.exception.status }} / {{ row.exception.approvalState }}</td>
<td>{{ row.policyAction }}</td>
<td>{{ conflictResolution(row) }}</td>
<td><a [routerLink]="['/security/triage', row.findingId]" [queryParams]="{ tab: 'policy' }">Explain</a></td>
</tr>
} @empty {
<tr><td colspan="6">No active VEX/waiver conflicts in this scope.</td></tr>
}
</tbody>
</table>
</section>
}
@case ('issuer-trust') {
<section class="panel">
<h2>Issuer Trust</h2>
<table>
<thead>
<tr>
<th>Issuer</th>
<th>Statements</th>
<th>Affected</th>
<th>Not Affected</th>
<th>Trust Signal</th>
</tr>
</thead>
<tbody>
@for (row of issuerTrustRows(); track row.issuer) {
<tr>
<td>{{ row.issuer }}</td>
<td>{{ row.total }}</td>
<td>{{ row.affected }}</td>
<td>{{ row.notAffected }}</td>
<td>{{ row.signal }}</td>
</tr>
} @empty {
<tr><td colspan="5">No issuer-linked statements available.</td></tr>
}
</tbody>
</table>
</section>
}
}
}
</section>
`,
styles: [`
.advisories{display:grid;gap:.65rem}
.advisories header h1{margin:0}
.advisories header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
.ownership-links{display:flex;gap:.35rem;flex-wrap:wrap}
.ownership-links a,.tabs a{
border:1px solid var(--color-border-primary);
border-radius:var(--radius-full);
padding:.12rem .5rem;
font-size:.72rem;
text-decoration:none;
background:var(--color-surface-primary);
}
.ownership-links a{color:var(--color-brand-primary)}
.tabs{display:flex;gap:.3rem;flex-wrap:wrap}
.tabs a{color:var(--color-text-secondary)}
.tabs a.active{border-color:var(--color-brand-primary);color:var(--color-brand-primary)}
.banner,.panel{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)}
.banner{padding:.65rem;font-size:.8rem;color:var(--color-text-secondary)}
.banner--error{color:var(--color-status-error-text)}
.panel{padding:.55rem;display:grid;gap:.45rem}
.panel h2{margin:0;font-size:.85rem}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid var(--color-border-primary);padding:.38rem .45rem;font-size:.72rem;text-align:left;vertical-align:top}
th{font-size:.64rem;color:var(--color-text-secondary);text-transform:uppercase;letter-spacing:.02em}
tr:last-child td{border-bottom:none}
a{color:var(--color-brand-primary)}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SecurityDispositionPageComponent {
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly rows = signal<SecurityDispositionProjection[]>([]);
readonly feedRows = signal<IntegrationHealthRow[]>([]);
readonly vexSourceRows = signal<IntegrationHealthRow[]>([]);
readonly activeTab = signal<AdvisoryTab>('providers');
readonly tabs: Array<{ id: AdvisoryTab; label: string }> = [
{ id: 'providers', label: 'Providers' },
{ id: 'vex-library', label: 'VEX Library' },
{ id: 'conflicts', label: 'Conflicts' },
{ id: 'issuer-trust', label: 'Issuer Trust' },
];
readonly providerRows = computed(() => {
const fromFeeds = this.feedRows().map((row) => ({ ...row, channel: 'advisory-feed' }));
const fromVex = this.vexSourceRows().map((row) => ({ ...row, channel: 'vex-source' }));
return [...fromFeeds, ...fromVex].sort((left, right) => left.sourceName.localeCompare(right.sourceName));
});
readonly vexLibraryRows = computed(() =>
this.rows()
.filter((row) => row.vex.status !== 'none' && row.vex.status !== 'unknown')
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
);
readonly conflictRows = computed(() =>
this.rows().filter((row) => row.vex.status === 'affected' && row.exception.status === 'approved')
);
readonly issuerTrustRows = computed(() => {
const table = new Map<string, { issuer: string; total: number; affected: number; notAffected: number }>();
for (const row of this.rows()) {
if (!row.vex.statementId) {
continue;
}
const issuer = this.extractIssuer(row.vex.statementId);
const current = table.get(issuer) ?? { issuer, total: 0, affected: 0, notAffected: 0 };
current.total += 1;
if (row.vex.status === 'affected') current.affected += 1;
if (row.vex.status === 'not_affected') current.notAffected += 1;
table.set(issuer, current);
}
return [...table.values()]
.map((row) => ({
...row,
signal: this.toTrustSignal(row.affected, row.notAffected, row.total),
}))
.sort((left, right) => right.total - left.total || left.issuer.localeCompare(right.issuer));
});
constructor() {
this.context.initialize();
this.route.queryParamMap.subscribe((params) => {
const tab = (params.get('tab') ?? 'providers') as AdvisoryTab;
if (this.tabs.some((item) => item.id === tab)) {
this.activeTab.set(tab);
} else {
this.activeTab.set('providers');
}
});
effect(() => {
this.context.contextVersion();
this.load();
});
}
fmt(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
conflictResolution(row: SecurityDispositionProjection): string {
if (row.policyAction === 'block') {
return 'Policy keeps finding blocked until trust/waiver reconciliation.';
}
return 'Policy allows workflow under approved waiver scope.';
}
private load(): void {
this.loading.set(true);
this.error.set(null);
const params = this.createContextParams();
const disposition$ = this.http
.get<PlatformListResponse<SecurityDispositionProjection>>('/api/v2/security/disposition', { params })
.pipe(map((response) => response.items ?? []), catchError(() => of([] as SecurityDispositionProjection[])));
const feeds$ = this.http
.get<PlatformListResponse<IntegrationHealthRow>>('/api/v2/integrations/feeds', { params })
.pipe(map((response) => response.items ?? []), catchError(() => of([] as IntegrationHealthRow[])));
const vexSources$ = this.http
.get<PlatformListResponse<IntegrationHealthRow>>('/api/v2/integrations/vex-sources', { params })
.pipe(map((response) => response.items ?? []), catchError(() => of([] as IntegrationHealthRow[])));
forkJoin({ disposition: disposition$, feeds: feeds$, vexSources: vexSources$ })
.pipe(take(1))
.subscribe({
next: ({ disposition, feeds, vexSources }) => {
this.rows.set(disposition);
this.feedRows.set(feeds);
this.vexSourceRows.set(vexSources);
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load advisories and VEX workspace.');
this.rows.set([]);
this.feedRows.set([]);
this.vexSourceRows.set([]);
this.loading.set(false);
},
});
}
private extractIssuer(statementId: string): string {
const normalized = statementId.trim();
if (normalized.length === 0) {
return 'unknown-issuer';
}
const slash = normalized.indexOf('/');
if (slash > 0) {
return normalized.slice(0, slash);
}
const colon = normalized.indexOf(':');
if (colon > 0) {
return normalized.slice(0, colon);
}
const dash = normalized.indexOf('-');
if (dash > 0) {
return normalized.slice(0, dash);
}
return normalized;
}
private toTrustSignal(affected: number, notAffected: number, total: number): string {
if (total === 0) return 'unknown';
if (affected > notAffected) return 'caution';
if (notAffected > affected) return 'trusted';
return 'mixed';
}
private createContextParams(): HttpParams {
let params = new HttpParams().set('limit', '200').set('offset', '0');
const region = this.context.selectedRegions()[0];
const environment = this.context.selectedEnvironments()[0];
if (region) params = params.set('region', region);
if (environment) params = params.set('environment', environment);
return params;
}
}

View File

@@ -0,0 +1,367 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
interface SecuritySbomComponentRow {
componentId: string;
releaseId: string;
releaseName: string;
environment: string;
region: string;
packageName: string;
componentName: string;
componentVersion: string;
supplier: string;
license: string;
vulnerabilityCount: number;
criticalReachableCount: number;
updatedAt: string;
}
interface SecuritySbomExplorerResponse {
mode: string;
table: SecuritySbomComponentRow[];
graphNodes: unknown[];
graphEdges: unknown[];
diff: unknown[];
totalComponents: number;
}
type SupplyChainMode = 'viewer' | 'graph' | 'lake' | 'reachability' | 'coverage';
@Component({
selector: 'app-security-sbom-explorer-page',
standalone: true,
imports: [RouterLink],
template: `
<section class="supply-chain">
<header>
<h1>Security / Supply-Chain Data</h1>
<p>SBOM, reachability, and unknowns workspace with capsule-aligned evidence context.</p>
</header>
<div class="status" [class]="'status status--' + freshnessStatus().toLowerCase()">
<strong>Coverage/Freshness: {{ freshnessStatus() }}</strong>
<span>{{ freshnessSummary() }}</span>
</div>
<nav class="tabs" aria-label="Supply-chain tabs">
@for (tab of tabs; track tab.id) {
<a [routerLink]="['/security/supply-chain-data', tab.id]" [class.active]="mode()===tab.id">{{ tab.label }}</a>
}
</nav>
@if (error()) { <div class="banner banner--error">{{ error() }}</div> }
@if (loading()) { <div class="banner">Loading supply-chain data...</div> }
@if (!loading()) {
@switch (mode()) {
@case ('viewer') {
<section class="panel">
<h2>SBOM Viewer</h2>
<table>
<thead>
<tr>
<th>Component</th>
<th>Version</th>
<th>Package</th>
<th>Release</th>
<th>Env</th>
<th>Vulns</th>
<th>Critical Reachable</th>
</tr>
</thead>
<tbody>
@for (row of tableRows(); track row.componentId) {
<tr>
<td>{{ row.componentName }}</td>
<td>{{ row.componentVersion }}</td>
<td>{{ row.packageName }}</td>
<td><a [routerLink]="['/releases/runs', row.releaseId, 'security']">{{ row.releaseName }}</a></td>
<td>{{ row.region }}/{{ row.environment }}</td>
<td>{{ row.vulnerabilityCount }}</td>
<td>{{ row.criticalReachableCount }}</td>
</tr>
} @empty {
<tr><td colspan="7">No SBOM component rows for current scope.</td></tr>
}
</tbody>
</table>
</section>
}
@case ('graph') {
<section class="panel">
<h2>SBOM Graph</h2>
<div class="stats-grid">
<article><h3>Nodes</h3><p>{{ graphNodeCount() }}</p></article>
<article><h3>Edges</h3><p>{{ graphEdgeCount() }}</p></article>
<article><h3>Components</h3><p>{{ tableRows().length }}</p></article>
</div>
<p class="hint">Use topology and release views for deep graph drilldowns while keeping this workspace as the canonical entry point.</p>
</section>
}
@case ('lake') {
<section class="panel">
<h2>SBOM Lake</h2>
<div class="stats-grid">
<article><h3>Total components</h3><p>{{ tableRows().length }}</p></article>
<article><h3>Vulnerable components</h3><p>{{ vulnerableComponentsCount() }}</p></article>
<article><h3>Critical reachable components</h3><p>{{ criticalReachableComponentsCount() }}</p></article>
<article><h3>Unknown reachability components</h3><p>{{ unknownComponentsCount() }}</p></article>
</div>
<a [routerLink]="['/security/triage']" [queryParams]="{ pivot: 'package' }">Open triage by artifacts</a>
</section>
}
@case ('reachability') {
<section class="panel">
<h2>Reachability Coverage</h2>
<table>
<thead>
<tr>
<th>Environment</th>
<th>Components</th>
<th>Critical Reachable</th>
<th>Unknown</th>
</tr>
</thead>
<tbody>
@for (row of environmentCoverageRows(); track row.key) {
<tr>
<td>{{ row.key }}</td>
<td>{{ row.total }}</td>
<td>{{ row.criticalReachable }}</td>
<td>{{ row.unknown }}</td>
</tr>
} @empty {
<tr><td colspan="4">No reachability coverage rows in current scope.</td></tr>
}
</tbody>
</table>
</section>
}
@case ('coverage') {
<section class="panel">
<h2>Coverage & Unknowns</h2>
<table>
<thead>
<tr>
<th>Environment</th>
<th>Total</th>
<th>Fresh</th>
<th>Stale</th>
<th>Unknown Reachability</th>
</tr>
</thead>
<tbody>
@for (row of freshnessRowsByEnvironment(); track row.key) {
<tr>
<td>{{ row.key }}</td>
<td>{{ row.total }}</td>
<td>{{ row.fresh }}</td>
<td>{{ row.stale }}</td>
<td>{{ row.unknown }}</td>
</tr>
} @empty {
<tr><td colspan="5">No coverage rows for current scope.</td></tr>
}
</tbody>
</table>
</section>
}
}
}
</section>
`,
styles: [`
.supply-chain{display:grid;gap:.65rem}
.supply-chain header h1{margin:0}
.supply-chain header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
.status,.tabs a,.banner,.panel{
border:1px solid var(--color-border-primary);
border-radius:var(--radius-md);
background:var(--color-surface-primary);
}
.status{display:flex;gap:.45rem;align-items:center;flex-wrap:wrap;padding:.55rem;font-size:.78rem}
.status--warn{border-color:var(--color-status-warning-text)}
.tabs{display:flex;gap:.3rem;flex-wrap:wrap}
.tabs a{padding:.12rem .5rem;font-size:.72rem;color:var(--color-text-secondary);text-decoration:none}
.tabs a.active{border-color:var(--color-brand-primary);color:var(--color-brand-primary)}
.banner{padding:.65rem;font-size:.8rem;color:var(--color-text-secondary)}
.banner--error{color:var(--color-status-error-text)}
.panel{padding:.55rem;display:grid;gap:.45rem}
.panel h2{margin:0;font-size:.85rem}
.panel .hint{margin:0;font-size:.74rem;color:var(--color-text-secondary)}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.4rem}
.stats-grid article{border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);padding:.45rem}
.stats-grid h3{margin:0;font-size:.66rem;text-transform:uppercase;color:var(--color-text-secondary)}
.stats-grid p{margin:.18rem 0 0;font-size:1.05rem}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid var(--color-border-primary);padding:.38rem .45rem;font-size:.72rem;text-align:left;vertical-align:top}
th{font-size:.64rem;color:var(--color-text-secondary);text-transform:uppercase;letter-spacing:.02em}
tr:last-child td{border-bottom:none}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SecuritySbomExplorerPageComponent {
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly mode = signal<SupplyChainMode>('viewer');
readonly response = signal<SecuritySbomExplorerResponse | null>(null);
readonly tabs: Array<{ id: SupplyChainMode; label: string }> = [
{ id: 'viewer', label: 'SBOM Viewer' },
{ id: 'graph', label: 'SBOM Graph' },
{ id: 'lake', label: 'SBOM Lake' },
{ id: 'reachability', label: 'Reachability' },
{ id: 'coverage', label: 'Coverage/Unknowns' },
];
readonly tableRows = computed(() => this.response()?.table ?? []);
readonly graphNodeCount = computed(() => this.response()?.graphNodes.length ?? 0);
readonly graphEdgeCount = computed(() => this.response()?.graphEdges.length ?? 0);
readonly vulnerableComponentsCount = computed(() =>
this.tableRows().filter((row) => row.vulnerabilityCount > 0).length
);
readonly criticalReachableComponentsCount = computed(() =>
this.tableRows().filter((row) => row.criticalReachableCount > 0).length
);
readonly unknownComponentsCount = computed(() =>
this.tableRows().filter((row) => row.vulnerabilityCount > 0 && row.criticalReachableCount <= 0).length
);
readonly freshnessStatus = computed(() => {
const rows = this.tableRows();
if (rows.length === 0) return 'WARN';
const stale = rows.filter((row) => this.isStale(row.updatedAt)).length;
return stale > 0 ? 'WARN' : 'OK';
});
readonly freshnessSummary = computed(() => {
const rows = this.tableRows();
if (rows.length === 0) return 'No SBOM components in selected scope.';
const fresh = rows.filter((row) => !this.isStale(row.updatedAt)).length;
return `${fresh}/${rows.length} components are fresh.`;
});
readonly environmentCoverageRows = computed(() => {
const map = new Map<string, { key: string; total: number; criticalReachable: number; unknown: number }>();
for (const row of this.tableRows()) {
const key = `${row.region}/${row.environment}`;
const current = map.get(key) ?? { key, total: 0, criticalReachable: 0, unknown: 0 };
current.total += 1;
if (row.criticalReachableCount > 0) current.criticalReachable += 1;
if (row.vulnerabilityCount > 0 && row.criticalReachableCount <= 0) current.unknown += 1;
map.set(key, current);
}
return [...map.values()].sort((left, right) => left.key.localeCompare(right.key));
});
readonly freshnessRowsByEnvironment = computed(() => {
const map = new Map<string, { key: string; total: number; fresh: number; stale: number; unknown: number }>();
for (const row of this.tableRows()) {
const key = `${row.region}/${row.environment}`;
const current = map.get(key) ?? { key, total: 0, fresh: 0, stale: 0, unknown: 0 };
current.total += 1;
if (this.isStale(row.updatedAt)) current.stale += 1;
else current.fresh += 1;
if (row.vulnerabilityCount > 0 && row.criticalReachableCount <= 0) current.unknown += 1;
map.set(key, current);
}
return [...map.values()].sort((left, right) => left.key.localeCompare(right.key));
});
constructor() {
this.context.initialize();
this.route.paramMap.subscribe((params) => {
const raw = (params.get('mode') ?? 'viewer').toLowerCase();
const normalized = this.normalizeMode(raw);
this.mode.set(normalized);
if (raw !== normalized) {
void this.router.navigate(['/security/supply-chain-data', normalized]);
return;
}
this.load();
});
effect(() => {
this.context.contextVersion();
this.load();
});
}
private load(): void {
this.loading.set(true);
this.error.set(null);
const params = this.createContextParams().set('mode', this.modeToApiMode(this.mode()));
this.http.get<SecuritySbomExplorerResponse>('/api/v2/security/sbom-explorer', { params }).pipe(take(1)).subscribe({
next: (response) => {
this.response.set(response);
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load supply-chain workspace.');
this.response.set(null);
this.loading.set(false);
},
});
}
private normalizeMode(raw: string): SupplyChainMode {
if (raw === 'viewer' || raw === 'graph' || raw === 'lake' || raw === 'reachability' || raw === 'coverage') {
return raw;
}
if (raw === 'sbom' || raw === 'table') {
return 'viewer';
}
if (raw === 'diff' || raw === 'suppliers' || raw === 'licenses' || raw === 'attestations') {
return 'coverage';
}
return 'viewer';
}
private modeToApiMode(mode: SupplyChainMode): string {
if (mode === 'graph') return 'graph';
return 'table';
}
private createContextParams(): HttpParams {
let params = new HttpParams().set('limit', '200').set('offset', '0');
const region = this.context.selectedRegions()[0];
const environment = this.context.selectedEnvironments()[0];
if (region) params = params.set('region', region);
if (environment) params = params.set('environment', environment);
return params;
}
private isStale(updatedAt: string): boolean {
const parsed = new Date(updatedAt).getTime();
if (!Number.isFinite(parsed)) return true;
return Date.now() - parsed > 24 * 60 * 60 * 1000;
}
}

View File

@@ -0,0 +1,212 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, map, take } from 'rxjs/operators';
interface PlatformListResponse<T> {
items: T[];
}
interface EnvironmentInventoryRow {
environmentId: string;
displayName: string;
regionId: string;
status?: string;
}
interface ReleaseActivityRow {
activityId: string;
releaseId: string;
releaseName: string;
status: string;
correlationKey: string;
occurredAt: string;
}
interface SecurityFindingRow {
findingId: string;
cveId: string;
severity: string;
effectiveDisposition: string;
}
interface EvidenceCapsuleRow {
capsuleId: string;
status: string;
updatedAt: string;
}
@Component({
selector: 'app-environment-posture-page',
standalone: true,
imports: [RouterLink],
template: `
<section class="posture">
<header>
<h1>Environment Posture</h1>
<p>{{ environmentLabel() }} · {{ regionLabel() }}</p>
</header>
@if (error()) {
<div class="banner error">{{ error() }}</div>
}
@if (loading()) {
<div class="banner">Loading environment posture...</div>
} @else {
<div class="cards">
<article>
<h3>Release Run Health</h3>
<p>{{ runSummary() }}</p>
<a [routerLink]="['/releases/runs']" [queryParams]="{ env: environmentId() }">Open Release Runs</a>
</article>
<article>
<h3>Security Posture</h3>
<p>{{ securitySummary() }}</p>
<a [routerLink]="['/security/findings']" [queryParams]="{ environment: environmentId() }">Open Findings</a>
</article>
<article>
<h3>Decision Capsule Confidence</h3>
<p>{{ evidenceSummary() }}</p>
<a [routerLink]="['/evidence/capsules']" [queryParams]="{ environment: environmentId() }">Open Decision Capsules</a>
</article>
</div>
<article class="blockers">
<h3>Top Blockers</h3>
<ul>
@for (blocker of blockers(); track blocker) {
<li>{{ blocker }}</li>
} @empty {
<li>No active blockers for this environment.</li>
}
</ul>
</article>
}
</section>
`,
styles: [`
.posture{display:grid;gap:.6rem}.posture header h1{margin:0}.posture header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
.banner,.cards article,.blockers{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)}
.banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)}.banner.error{color:var(--color-status-error-text)}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:.45rem}.cards article{padding:.6rem}.cards h3{margin:0 0 .25rem;font-size:.86rem}.cards p{margin:0 0 .45rem;font-size:.74rem;color:var(--color-text-secondary)}.cards a{font-size:.74rem;color:var(--color-brand-primary);text-decoration:none}
.blockers{padding:.6rem}.blockers h3{margin:0 0 .25rem;font-size:.86rem}.blockers ul{margin:.25rem 0 0;padding-left:1rem}.blockers li{font-size:.74rem;color:var(--color-text-secondary)}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EnvironmentPosturePageComponent {
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly environmentId = signal('');
readonly environmentLabel = signal('Environment');
readonly regionLabel = signal('region');
readonly runRows = signal<ReleaseActivityRow[]>([]);
readonly findingRows = signal<SecurityFindingRow[]>([]);
readonly capsuleRows = signal<EvidenceCapsuleRow[]>([]);
readonly runSummary = computed(() => {
const rows = this.runRows();
if (rows.length === 0) return 'No runs in current scope.';
const blocked = rows.filter((item) => item.status.toLowerCase().includes('blocked')).length;
return `${rows.length} runs · ${blocked} blocked`;
});
readonly securitySummary = computed(() => {
const rows = this.findingRows();
if (rows.length === 0) return 'No active findings in current scope.';
const blocking = rows.filter((item) => item.effectiveDisposition === 'action_required').length;
return `${rows.length} findings · ${blocking} promotion blockers`;
});
readonly evidenceSummary = computed(() => {
const rows = this.capsuleRows();
if (rows.length === 0) return 'No decision capsules in current scope.';
const stale = rows.filter((item) => item.status.toLowerCase().includes('stale')).length;
return `${rows.length} capsules · ${stale} stale`;
});
readonly blockers = computed(() => {
const blockers: string[] = [];
if (this.runRows().some((item) => item.status.toLowerCase().includes('blocked'))) {
blockers.push('Blocked release runs require gate remediation.');
}
if (this.findingRows().some((item) => item.effectiveDisposition === 'action_required')) {
blockers.push('Reachable findings still have action-required disposition.');
}
if (this.capsuleRows().some((item) => item.status.toLowerCase().includes('stale'))) {
blockers.push('Decision capsule freshness is stale.');
}
return blockers;
});
constructor() {
this.route.paramMap.subscribe((params) => {
const id = params.get('environmentId') ?? '';
this.environmentId.set(id);
if (id) {
this.reload(id);
}
});
}
private reload(environmentId: string): void {
this.loading.set(true);
this.error.set(null);
const envParams = new HttpParams().set('limit', '1').set('offset', '0').set('environment', environmentId);
const inventory$ = this.http
.get<PlatformListResponse<EnvironmentInventoryRow>>('/api/v2/topology/environments', { params: envParams })
.pipe(
map((response) => response.items?.[0] ?? null),
catchError(() => of(null)),
);
const runs$ = this.http
.get<PlatformListResponse<ReleaseActivityRow>>('/api/v2/releases/activity', { params: envParams })
.pipe(
map((response) => response.items ?? []),
catchError(() => of([] as ReleaseActivityRow[])),
);
const findings$ = this.http
.get<PlatformListResponse<SecurityFindingRow>>('/api/v2/security/findings', { params: envParams })
.pipe(
map((response) => response.items ?? []),
catchError(() => of([] as SecurityFindingRow[])),
);
const capsules$ = this.http
.get<PlatformListResponse<EvidenceCapsuleRow>>('/api/v2/evidence/packs', { params: envParams })
.pipe(
map((response) => response.items ?? []),
catchError(() => of([] as EvidenceCapsuleRow[])),
);
forkJoin({ inventory: inventory$, runs: runs$, findings: findings$, capsules: capsules$ })
.pipe(take(1))
.subscribe({
next: ({ inventory, runs, findings, capsules }) => {
this.environmentLabel.set(inventory?.displayName ?? environmentId);
this.regionLabel.set(inventory?.regionId ?? 'unknown-region');
this.runRows.set(runs);
this.findingRows.set(findings);
this.capsuleRows.set(capsules);
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load environment posture.');
this.runRows.set([]);
this.findingRows.set([]);
this.capsuleRows.set([]);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,507 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyDataService } from './topology-data.service';
import { TopologyAgent, TopologyTarget } from './topology.models';
type AgentView = 'groups' | 'agents';
interface AgentGroupRow {
id: string;
label: string;
regionId: string;
environmentId: string;
agentCount: number;
targetCount: number;
degradedCount: number;
offlineCount: number;
}
@Component({
selector: 'app-topology-agents-page',
standalone: true,
imports: [FormsModule, RouterLink],
template: `
<section class="agents">
<header class="agents__header">
<div>
<h1>Agents</h1>
<p>Fleet health by group and by agent with impacted target visibility.</p>
</div>
<div class="agents__scope">
<span>{{ context.regionSummary() }}</span>
<span>{{ context.environmentSummary() }}</span>
</div>
</header>
<section class="filters">
<div class="filters__item">
<label for="agents-view">View</label>
<select id="agents-view" [ngModel]="viewMode()" (ngModelChange)="viewMode.set($event)">
<option value="groups">Groups</option>
<option value="agents">All Agents</option>
</select>
</div>
<div class="filters__item filters__item--wide">
<label for="agents-search">Search</label>
<input id="agents-search" type="text" [ngModel]="searchQuery()" (ngModelChange)="searchQuery.set($event)" />
</div>
<div class="filters__item">
<label for="agents-status">Status</label>
<select id="agents-status" [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)">
<option value="all">All</option>
<option value="active">Active</option>
<option value="degraded">Degraded</option>
<option value="offline">Offline</option>
<option value="pending">Pending</option>
</select>
</div>
</section>
@if (error()) {
<div class="banner banner--error">{{ error() }}</div>
}
@if (loading()) {
<div class="banner">Loading agent fleet...</div>
} @else {
<section class="split">
<article class="card">
@if (viewMode() === 'groups') {
<h2>Agent Groups</h2>
<table>
<thead>
<tr>
<th>Group</th>
<th>Region</th>
<th>Environment</th>
<th>Targets</th>
<th>Drift</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (group of filteredGroups(); track group.id) {
<tr [class.active]="selectedGroupId() === group.id" (click)="selectedGroupId.set(group.id)">
<td>{{ group.label }}</td>
<td>{{ group.regionId }}</td>
<td>{{ group.environmentId }}</td>
<td>{{ group.targetCount }}</td>
<td>{{ group.degradedCount + group.offlineCount }}</td>
<td>{{ group.degradedCount + group.offlineCount > 0 ? 'WARN' : 'OK' }}</td>
</tr>
} @empty {
<tr><td colspan="6" class="muted">No groups for current filters.</td></tr>
}
</tbody>
</table>
} @else {
<h2>All Agents</h2>
<table>
<thead>
<tr>
<th>Agent</th>
<th>Region</th>
<th>Environment</th>
<th>Status</th>
<th>Targets</th>
<th>Heartbeat</th>
</tr>
</thead>
<tbody>
@for (agent of filteredAgents(); track agent.agentId) {
<tr [class.active]="selectedAgentId() === agent.agentId" (click)="selectedAgentId.set(agent.agentId)">
<td>{{ agent.agentName }}</td>
<td>{{ agent.regionId }}</td>
<td>{{ agent.environmentId }}</td>
<td>{{ agent.status }}</td>
<td>{{ agent.assignedTargetCount }}</td>
<td>{{ agent.lastHeartbeatAt ?? '-' }}</td>
</tr>
} @empty {
<tr><td colspan="6" class="muted">No agents for current filters.</td></tr>
}
</tbody>
</table>
}
</article>
<article class="card detail">
<h2>Selection</h2>
@if (viewMode() === 'groups') {
@if (selectedGroup()) {
<p><strong>{{ selectedGroup()!.label }}</strong></p>
<p>Agents: {{ selectedGroup()!.agentCount }}</p>
<p>Targets: {{ selectedGroup()!.targetCount }}</p>
<p>Drift: {{ selectedGroup()!.degradedCount + selectedGroup()!.offlineCount }}</p>
<div class="actions">
<a [routerLink]="['/topology/targets']" [queryParams]="{ environment: selectedGroup()!.environmentId }">View Targets</a>
<a [routerLink]="['/topology/environments', selectedGroup()!.environmentId, 'posture']">View Environment</a>
<a [routerLink]="['/platform/ops/doctor']">Open Diagnostics</a>
</div>
} @else {
<p class="muted">Select a group row to inspect fleet impact.</p>
}
} @else {
@if (selectedAgent()) {
<p><strong>{{ selectedAgent()!.agentName }}</strong></p>
<p>Status: {{ selectedAgent()!.status }}</p>
<p>Capabilities: {{ selectedAgent()!.capabilities.join(', ') || '-' }}</p>
<p>Targets: {{ selectedAgent()!.assignedTargetCount }}</p>
<p>Heartbeat: {{ selectedAgent()!.lastHeartbeatAt ?? '-' }}</p>
<div class="actions">
<a [routerLink]="['/topology/targets']" [queryParams]="{ agentId: selectedAgent()!.agentId }">View Targets</a>
<a [routerLink]="['/topology/environments', selectedAgent()!.environmentId, 'posture']">View Environment</a>
<a [routerLink]="['/platform/ops/doctor']">Open Diagnostics</a>
</div>
} @else {
<p class="muted">Select an agent row to inspect details.</p>
}
}
</article>
</section>
}
</section>
`,
styles: [`
.agents {
display: grid;
gap: 0.75rem;
}
.agents__header {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.8rem;
}
.agents__header h1 {
margin: 0;
font-size: 1.3rem;
}
.agents__header p {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.agents__scope {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.agents__scope span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
}
.filters {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.45rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.filters__item {
display: grid;
gap: 0.2rem;
}
.filters__item--wide {
grid-column: span 2;
}
.filters label {
font-size: 0.7rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.filters select,
.filters input {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
font-size: 0.78rem;
padding: 0.32rem 0.42rem;
}
.banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.7rem;
font-size: 0.78rem;
}
.banner--error {
color: var(--color-status-error-text);
}
.split {
display: grid;
gap: 0.6rem;
grid-template-columns: 1.5fr 1fr;
align-items: start;
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.4rem;
}
.card h2 {
margin: 0;
font-size: 0.95rem;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-secondary);
}
th,
td {
text-align: left;
font-size: 0.74rem;
padding: 0.36rem 0.42rem;
border-bottom: 1px solid var(--color-border-primary);
vertical-align: middle;
}
th {
text-transform: uppercase;
color: var(--color-text-secondary);
font-size: 0.67rem;
letter-spacing: 0.03em;
}
tr:last-child td {
border-bottom: none;
}
tbody tr {
cursor: pointer;
}
tbody tr.active {
background: var(--color-brand-primary-10);
}
.detail p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.actions a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.74rem;
}
.muted {
color: var(--color-text-secondary);
font-size: 0.74rem;
}
@media (max-width: 960px) {
.filters__item--wide {
grid-column: span 1;
}
.split {
grid-template-columns: 1fr;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TopologyAgentsPageComponent {
private readonly topologyApi = inject(TopologyDataService);
private readonly route = inject(ActivatedRoute);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly viewMode = signal<AgentView>('groups');
readonly searchQuery = signal('');
readonly statusFilter = signal('all');
readonly selectedGroupId = signal('');
readonly selectedAgentId = signal('');
readonly agents = signal<TopologyAgent[]>([]);
readonly targets = signal<TopologyTarget[]>([]);
readonly groupedAgents = computed<AgentGroupRow[]>(() => {
const groups = new Map<string, AgentGroupRow>();
for (const agent of this.agents()) {
const groupId = `${agent.regionId}:${agent.environmentId}`;
const existing = groups.get(groupId);
const normalizedStatus = agent.status.trim().toLowerCase();
if (!existing) {
groups.set(groupId, {
id: groupId,
label: `agent-${agent.regionId}-${agent.environmentId}`,
regionId: agent.regionId,
environmentId: agent.environmentId,
agentCount: 1,
targetCount: agent.assignedTargetCount,
degradedCount: normalizedStatus === 'degraded' ? 1 : 0,
offlineCount: normalizedStatus === 'offline' ? 1 : 0,
});
continue;
}
existing.agentCount += 1;
existing.targetCount += agent.assignedTargetCount;
if (normalizedStatus === 'degraded') {
existing.degradedCount += 1;
}
if (normalizedStatus === 'offline') {
existing.offlineCount += 1;
}
}
return [...groups.values()].sort((left, right) => left.label.localeCompare(right.label, 'en', { sensitivity: 'base' }));
});
readonly filteredGroups = computed(() => {
const query = this.searchQuery().trim().toLowerCase();
return this.groupedAgents().filter((group) => {
const matchesQuery =
!query ||
[group.label, group.regionId, group.environmentId]
.some((value) => value.toLowerCase().includes(query));
const status = this.groupStatus(group);
const matchesStatus = this.statusFilter() === 'all' || status === this.statusFilter();
return matchesQuery && matchesStatus;
});
});
readonly filteredAgents = computed(() => {
const query = this.searchQuery().trim().toLowerCase();
return this.agents().filter((agent) => {
const matchesQuery =
!query ||
[agent.agentName, agent.agentId, agent.regionId, agent.environmentId]
.some((value) => value.toLowerCase().includes(query));
const status = agent.status.trim().toLowerCase();
const matchesStatus = this.statusFilter() === 'all' || status === this.statusFilter();
return matchesQuery && matchesStatus;
});
});
readonly selectedGroup = computed(() => {
const id = this.selectedGroupId();
if (!id) {
return this.filteredGroups()[0] ?? null;
}
return this.groupedAgents().find((group) => group.id === id) ?? null;
});
readonly selectedAgent = computed(() => {
const id = this.selectedAgentId();
if (!id) {
return this.filteredAgents()[0] ?? null;
}
return this.agents().find((agent) => agent.agentId === id) ?? null;
});
constructor() {
this.context.initialize();
this.route.queryParamMap.subscribe((params) => {
const agentId = params.get('agentId');
if (agentId) {
this.viewMode.set('agents');
this.selectedAgentId.set(agentId);
}
const environment = params.get('environment');
if (environment) {
this.searchQuery.set(environment);
}
});
effect(() => {
this.context.contextVersion();
this.load();
});
}
private load(): void {
this.loading.set(true);
this.error.set(null);
forkJoin({
agents: this.topologyApi.list<TopologyAgent>('/api/v2/topology/agents', this.context).pipe(catchError(() => of([]))),
targets: this.topologyApi.list<TopologyTarget>('/api/v2/topology/targets', this.context).pipe(catchError(() => of([]))),
})
.pipe(take(1))
.subscribe({
next: ({ agents, targets }) => {
this.agents.set(agents);
this.targets.set(targets);
if (!this.selectedGroupId() && this.groupedAgents().length > 0) {
this.selectedGroupId.set(this.groupedAgents()[0].id);
}
if (!this.selectedAgentId() && agents.length > 0) {
this.selectedAgentId.set(agents[0].agentId);
}
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load topology agents.');
this.agents.set([]);
this.targets.set([]);
this.loading.set(false);
},
});
}
private groupStatus(group: AgentGroupRow): string {
if (group.offlineCount > 0) {
return 'offline';
}
if (group.degradedCount > 0) {
return 'degraded';
}
return 'active';
}
}

View File

@@ -0,0 +1,46 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { map, Observable } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { PlatformListResponse } from './topology.models';
@Injectable({ providedIn: 'root' })
export class TopologyDataService {
private readonly http = inject(HttpClient);
list<T>(
endpoint: string,
context: PlatformContextStore,
options?: {
limit?: number;
offset?: number;
regionOverride?: string[];
environmentOverride?: string[];
extraParams?: Record<string, string | null | undefined>;
},
): Observable<T[]> {
const limit = options?.limit ?? 200;
const offset = options?.offset ?? 0;
let params = new HttpParams().set('limit', String(limit)).set('offset', String(offset));
const regions = options?.regionOverride ?? context.selectedRegions();
const environments = options?.environmentOverride ?? context.selectedEnvironments();
if (regions.length > 0) {
params = params.set('region', regions.join(','));
}
if (environments.length > 0) {
params = params.set('environment', environments.join(','));
}
for (const [key, value] of Object.entries(options?.extraParams ?? {})) {
if (value !== undefined && value !== null && value.trim().length > 0) {
params = params.set(key, value);
}
}
return this.http
.get<PlatformListResponse<T>>(endpoint, { params })
.pipe(map((response) => response?.items ?? []));
}
}

View File

@@ -0,0 +1,565 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyDataService } from './topology-data.service';
import {
EvidenceCapsuleRow,
PlatformListResponse,
ReleaseActivityRow,
SecurityFindingRow,
TopologyAgent,
TopologyEnvironment,
TopologyTarget,
} from './topology.models';
type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'security' | 'evidence' | 'data-quality';
@Component({
selector: 'app-topology-environment-detail-page',
standalone: true,
imports: [RouterLink],
template: `
<section class="environment-detail">
<header class="hero">
<div>
<h1>{{ environmentLabel() }}</h1>
<p>{{ regionLabel() }} · {{ environmentTypeLabel() }}</p>
</div>
<div class="hero__stats">
<span>Deploy {{ deployHealth() }}</span>
<span>Targets {{ targetRows().length }}</span>
<span>Agents {{ agentRows().length }}</span>
</div>
</header>
<nav class="tabs" aria-label="Environment detail tabs">
@for (tab of tabs; track tab.id) {
<button type="button" [class.active]="activeTab() === tab.id" (click)="activeTab.set(tab.id)">{{ tab.label }}</button>
}
</nav>
@if (error()) {
<div class="banner banner--error">{{ error() }}</div>
}
@if (loading()) {
<div class="banner">Loading environment detail...</div>
} @else {
@switch (activeTab()) {
@case ('overview') {
<section class="grid grid--two">
<article class="card">
<h2>Overview</h2>
<p>Targets healthy {{ healthyTargets() }} · degraded {{ degradedTargets() }} · unhealthy {{ unhealthyTargets() }}</p>
<p>Findings requiring action {{ blockingFindings() }}</p>
<p>Capsules stale {{ staleCapsules() }}</p>
</article>
<article class="card">
<h2>Operator Actions</h2>
<div class="actions">
<a [routerLink]="['/topology/targets']" [queryParams]="{ environment: environmentId() }">Open Targets</a>
<a [routerLink]="['/topology/agents']" [queryParams]="{ environment: environmentId() }">Open Agents</a>
<a [routerLink]="['/releases/activity']" [queryParams]="{ environment: environmentId() }">Open Deployments</a>
<a [routerLink]="['/security/triage']" [queryParams]="{ environment: environmentId() }">Open Security Triage</a>
</div>
</article>
</section>
<article class="card">
<h2>Top Blockers</h2>
<ul class="list">
@for (blocker of blockers(); track blocker) {
<li>{{ blocker }}</li>
} @empty {
<li>No active blockers for this environment.</li>
}
</ul>
</article>
}
@case ('targets') {
<article class="card">
<h2>Targets</h2>
<table>
<thead>
<tr>
<th>Target</th>
<th>Runtime</th>
<th>Host</th>
<th>Agent</th>
<th>Status</th>
<th>Last Sync</th>
</tr>
</thead>
<tbody>
@for (target of targetRows(); track target.targetId) {
<tr>
<td>{{ target.name }}</td>
<td>{{ target.targetType }}</td>
<td>{{ target.hostId }}</td>
<td>{{ target.agentId }}</td>
<td>{{ target.healthStatus }}</td>
<td>{{ target.lastSyncAt ?? '-' }}</td>
</tr>
} @empty {
<tr><td colspan="6" class="muted">No targets in this environment scope.</td></tr>
}
</tbody>
</table>
</article>
}
@case ('deployments') {
<article class="card">
<h2>Deployments</h2>
<table>
<thead>
<tr>
<th>Release</th>
<th>Status</th>
<th>Correlation</th>
<th>Occurred</th>
</tr>
</thead>
<tbody>
@for (run of runRows(); track run.activityId) {
<tr>
<td>{{ run.releaseName }}</td>
<td>{{ run.status }}</td>
<td>{{ run.correlationKey }}</td>
<td>{{ run.occurredAt }}</td>
</tr>
} @empty {
<tr><td colspan="4" class="muted">No deployment activity in this scope.</td></tr>
}
</tbody>
</table>
</article>
}
@case ('agents') {
<article class="card">
<h2>Agents</h2>
<table>
<thead>
<tr>
<th>Agent</th>
<th>Status</th>
<th>Capabilities</th>
<th>Assigned Targets</th>
<th>Heartbeat</th>
</tr>
</thead>
<tbody>
@for (agent of agentRows(); track agent.agentId) {
<tr>
<td>{{ agent.agentName }}</td>
<td>{{ agent.status }}</td>
<td>{{ agent.capabilities.join(', ') || '-' }}</td>
<td>{{ agent.assignedTargetCount }}</td>
<td>{{ agent.lastHeartbeatAt ?? '-' }}</td>
</tr>
} @empty {
<tr><td colspan="5" class="muted">No agents in this environment scope.</td></tr>
}
</tbody>
</table>
</article>
}
@case ('security') {
<article class="card">
<h2>Security</h2>
<table>
<thead>
<tr>
<th>CVE</th>
<th>Severity</th>
<th>Disposition</th>
</tr>
</thead>
<tbody>
@for (finding of findingRows(); track finding.findingId) {
<tr>
<td>{{ finding.cveId }}</td>
<td>{{ finding.severity }}</td>
<td>{{ finding.effectiveDisposition }}</td>
</tr>
} @empty {
<tr><td colspan="3" class="muted">No active findings in this scope.</td></tr>
}
</tbody>
</table>
</article>
}
@case ('evidence') {
<article class="card">
<h2>Evidence</h2>
<table>
<thead>
<tr>
<th>Capsule</th>
<th>Status</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
@for (capsule of capsuleRows(); track capsule.capsuleId) {
<tr>
<td>{{ capsule.capsuleId }}</td>
<td>{{ capsule.status }}</td>
<td>{{ capsule.updatedAt }}</td>
</tr>
} @empty {
<tr><td colspan="3" class="muted">No decision capsules in this scope.</td></tr>
}
</tbody>
</table>
</article>
}
@case ('data-quality') {
<article class="card">
<h2>Data Quality</h2>
<ul class="list">
<li>Context region: {{ regionLabel() }}</li>
<li>Topology targets covered: {{ targetRows().length }}</li>
<li>Agent heartbeat warnings: {{ degradedAgents() }}</li>
<li>Stale decision capsules: {{ staleCapsules() }}</li>
<li>Action-required findings: {{ blockingFindings() }}</li>
</ul>
</article>
}
}
}
</section>
`,
styles: [`
.environment-detail {
display: grid;
gap: 0.75rem;
}
.hero {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.8rem;
}
.hero h1 {
margin: 0;
font-size: 1.3rem;
}
.hero p {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.hero__stats {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.35rem;
}
.hero__stats span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
white-space: nowrap;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.45rem;
}
.tabs button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
font-size: 0.74rem;
padding: 0.25rem 0.45rem;
cursor: pointer;
}
.tabs button.active {
border-color: var(--color-brand-primary);
background: var(--color-brand-primary-10);
color: var(--color-brand-primary);
}
.banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.7rem;
font-size: 0.78rem;
}
.banner--error {
color: var(--color-status-error-text);
}
.grid {
display: grid;
gap: 0.6rem;
}
.grid--two {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.4rem;
}
.card h2 {
margin: 0;
font-size: 0.95rem;
}
.card p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.76rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.actions a {
color: var(--color-brand-primary);
font-size: 0.74rem;
text-decoration: none;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-secondary);
}
th,
td {
text-align: left;
font-size: 0.74rem;
padding: 0.36rem 0.42rem;
border-bottom: 1px solid var(--color-border-primary);
vertical-align: middle;
}
th {
text-transform: uppercase;
font-size: 0.67rem;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
tr:last-child td {
border-bottom: none;
}
.list {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.2rem;
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.muted {
color: var(--color-text-secondary);
font-size: 0.74rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TopologyEnvironmentDetailPageComponent {
private readonly topologyApi = inject(TopologyDataService);
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
readonly context = inject(PlatformContextStore);
readonly tabs: Array<{ id: EnvironmentTab; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'targets', label: 'Targets' },
{ id: 'deployments', label: 'Deployments' },
{ id: 'agents', label: 'Agents' },
{ id: 'security', label: 'Security' },
{ id: 'evidence', label: 'Evidence' },
{ id: 'data-quality', label: 'Data Quality' },
];
readonly activeTab = signal<EnvironmentTab>('overview');
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly environmentId = signal('');
readonly environmentLabel = signal('Environment');
readonly regionLabel = signal('unknown-region');
readonly environmentTypeLabel = signal('unknown-type');
readonly targetRows = signal<TopologyTarget[]>([]);
readonly agentRows = signal<TopologyAgent[]>([]);
readonly runRows = signal<ReleaseActivityRow[]>([]);
readonly findingRows = signal<SecurityFindingRow[]>([]);
readonly capsuleRows = signal<EvidenceCapsuleRow[]>([]);
readonly healthyTargets = computed(() =>
this.targetRows().filter((item) => item.healthStatus.trim().toLowerCase() === 'healthy').length,
);
readonly degradedTargets = computed(() =>
this.targetRows().filter((item) => item.healthStatus.trim().toLowerCase() === 'degraded').length,
);
readonly unhealthyTargets = computed(() =>
this.targetRows().filter((item) => {
const status = item.healthStatus.trim().toLowerCase();
return status === 'unhealthy' || status === 'offline' || status === 'unknown';
}).length,
);
readonly blockingFindings = computed(
() => this.findingRows().filter((item) => item.effectiveDisposition.trim().toLowerCase() === 'action_required').length,
);
readonly staleCapsules = computed(() =>
this.capsuleRows().filter((item) => item.status.trim().toLowerCase().includes('stale')).length,
);
readonly degradedAgents = computed(
() => this.agentRows().filter((item) => item.status.trim().toLowerCase() !== 'active').length,
);
readonly deployHealth = computed(() => {
if (this.unhealthyTargets() > 0) {
return 'UNHEALTHY';
}
if (this.degradedTargets() > 0 || this.degradedAgents() > 0) {
return 'DEGRADED';
}
return 'HEALTHY';
});
readonly blockers = computed(() => {
const blockers: string[] = [];
if (this.unhealthyTargets() > 0) {
blockers.push('Unhealthy topology targets require runtime remediation.');
}
if (this.blockingFindings() > 0) {
blockers.push('Action-required findings still block promotion.');
}
if (this.staleCapsules() > 0) {
blockers.push('Decision capsule freshness is stale.');
}
if (this.degradedAgents() > 0) {
blockers.push('Agent fleet for this environment has degraded heartbeat status.');
}
return blockers;
});
constructor() {
this.context.initialize();
this.route.paramMap.subscribe((params) => {
const environmentId = params.get('environmentId') ?? '';
this.environmentId.set(environmentId);
if (environmentId) {
this.load(environmentId);
}
});
}
private load(environmentId: string): void {
this.loading.set(true);
this.error.set(null);
const envFilter = [environmentId];
const params = new HttpParams().set('limit', '100').set('offset', '0').set('environment', environmentId);
forkJoin({
environmentRows: this.topologyApi
.list<TopologyEnvironment>('/api/v2/topology/environments', this.context, {
environmentOverride: envFilter,
})
.pipe(catchError(() => of([]))),
targets: this.topologyApi
.list<TopologyTarget>('/api/v2/topology/targets', this.context, { environmentOverride: envFilter })
.pipe(catchError(() => of([]))),
agents: this.topologyApi
.list<TopologyAgent>('/api/v2/topology/agents', this.context, { environmentOverride: envFilter })
.pipe(catchError(() => of([]))),
runs: this.http
.get<PlatformListResponse<ReleaseActivityRow>>('/api/v2/releases/activity', { params })
.pipe(
take(1),
catchError(() => of({ items: [] })),
),
findings: this.http
.get<PlatformListResponse<SecurityFindingRow>>('/api/v2/security/findings', { params })
.pipe(
take(1),
catchError(() => of({ items: [] })),
),
capsules: this.http
.get<PlatformListResponse<EvidenceCapsuleRow>>('/api/v2/evidence/packs', { params })
.pipe(
take(1),
catchError(() => of({ items: [] })),
),
}).subscribe({
next: ({ environmentRows, targets, agents, runs, findings, capsules }) => {
const environment = environmentRows[0];
this.environmentLabel.set(environment?.displayName ?? environmentId);
this.regionLabel.set(environment?.regionId ?? 'unknown-region');
this.environmentTypeLabel.set(environment?.environmentType ?? 'unknown-type');
this.targetRows.set(targets);
this.agentRows.set(agents);
this.runRows.set(runs?.items ?? []);
this.findingRows.set(findings?.items ?? []);
this.capsuleRows.set(capsules?.items ?? []);
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load topology environment detail.');
this.targetRows.set([]);
this.agentRows.set([]);
this.runRows.set([]);
this.findingRows.set([]);
this.capsuleRows.set([]);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,397 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyDataService } from './topology-data.service';
import { TopologyHost, TopologyTarget } from './topology.models';
@Component({
selector: 'app-topology-hosts-page',
standalone: true,
imports: [FormsModule, RouterLink],
template: `
<section class="hosts">
<header class="hosts__header">
<div>
<h1>Hosts</h1>
<p>Operational host inventory with runtime, heartbeat, and target mapping.</p>
</div>
<div class="hosts__scope">
<span>{{ context.regionSummary() }}</span>
<span>{{ context.environmentSummary() }}</span>
</div>
</header>
<section class="filters">
<div class="filters__item filters__item--wide">
<label for="hosts-search">Search</label>
<input id="hosts-search" type="text" [ngModel]="searchQuery()" (ngModelChange)="searchQuery.set($event)" />
</div>
<div class="filters__item">
<label for="hosts-runtime">Runtime</label>
<select id="hosts-runtime" [ngModel]="runtimeFilter()" (ngModelChange)="runtimeFilter.set($event)">
<option value="all">All</option>
@for (runtime of runtimeOptions(); track runtime) {
<option [value]="runtime">{{ runtime }}</option>
}
</select>
</div>
<div class="filters__item">
<label for="hosts-status">Status</label>
<select id="hosts-status" [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)">
<option value="all">All</option>
<option value="healthy">Healthy</option>
<option value="degraded">Degraded</option>
<option value="offline">Offline</option>
<option value="unknown">Unknown</option>
</select>
</div>
</section>
@if (error()) {
<div class="banner banner--error">{{ error() }}</div>
}
@if (loading()) {
<div class="banner">Loading hosts...</div>
} @else {
<section class="split">
<article class="card">
<h2>Hosts</h2>
<table>
<thead>
<tr>
<th>Host</th>
<th>Region</th>
<th>Environment</th>
<th>Runtime</th>
<th>Status</th>
<th>Targets</th>
</tr>
</thead>
<tbody>
@for (host of filteredHosts(); track host.hostId) {
<tr [class.active]="selectedHostId() === host.hostId" (click)="selectedHostId.set(host.hostId)">
<td>{{ host.hostName }}</td>
<td>{{ host.regionId }}</td>
<td>{{ host.environmentId }}</td>
<td>{{ host.runtimeType }}</td>
<td>{{ host.status }}</td>
<td>{{ host.targetCount }}</td>
</tr>
} @empty {
<tr><td colspan="6" class="muted">No hosts for current filters.</td></tr>
}
</tbody>
</table>
</article>
<article class="card detail">
<h2>Selected Host</h2>
@if (selectedHost()) {
<p><strong>{{ selectedHost()!.hostName }}</strong></p>
<p>Status: {{ selectedHost()!.status }}</p>
<p>Runtime: {{ selectedHost()!.runtimeType }}</p>
<p>Agent: {{ selectedHost()!.agentId }}</p>
<p>Last seen: {{ selectedHost()!.lastSeenAt ?? '-' }}</p>
<p>Impacted targets: {{ selectedHostTargets().length }}</p>
<p>Upgrade window: Fri 23:00 UTC</p>
<div class="actions">
<a [routerLink]="['/topology/targets']" [queryParams]="{ hostId: selectedHost()!.hostId }">Open Targets</a>
<a [routerLink]="['/topology/agents']" [queryParams]="{ agentId: selectedHost()!.agentId }">Open Agent</a>
<a [routerLink]="['/platform/integrations']">Open Host Integrations</a>
</div>
} @else {
<p class="muted">Select a host row to inspect runtime drift and impact.</p>
}
</article>
</section>
}
</section>
`,
styles: [`
.hosts {
display: grid;
gap: 0.75rem;
}
.hosts__header {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.8rem;
}
.hosts__header h1 {
margin: 0;
font-size: 1.3rem;
}
.hosts__header p {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.hosts__scope {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.hosts__scope span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
}
.filters {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.45rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.filters__item {
display: grid;
gap: 0.2rem;
}
.filters__item--wide {
grid-column: span 2;
}
.filters label {
font-size: 0.7rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.filters select,
.filters input {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
font-size: 0.78rem;
padding: 0.32rem 0.42rem;
}
.banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.7rem;
font-size: 0.78rem;
}
.banner--error {
color: var(--color-status-error-text);
}
.split {
display: grid;
gap: 0.6rem;
grid-template-columns: 1.45fr 1fr;
align-items: start;
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.4rem;
}
.card h2 {
margin: 0;
font-size: 0.95rem;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-secondary);
}
th,
td {
text-align: left;
font-size: 0.74rem;
padding: 0.36rem 0.42rem;
border-bottom: 1px solid var(--color-border-primary);
vertical-align: middle;
}
th {
text-transform: uppercase;
color: var(--color-text-secondary);
font-size: 0.67rem;
letter-spacing: 0.03em;
}
tr:last-child td {
border-bottom: none;
}
tbody tr {
cursor: pointer;
}
tbody tr.active {
background: var(--color-brand-primary-10);
}
.detail p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.actions a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.74rem;
}
.muted {
color: var(--color-text-secondary);
font-size: 0.74rem;
}
@media (max-width: 960px) {
.filters__item--wide {
grid-column: span 1;
}
.split {
grid-template-columns: 1fr;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TopologyHostsPageComponent {
private readonly topologyApi = inject(TopologyDataService);
private readonly route = inject(ActivatedRoute);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly searchQuery = signal('');
readonly runtimeFilter = signal('all');
readonly statusFilter = signal('all');
readonly selectedHostId = signal('');
readonly hosts = signal<TopologyHost[]>([]);
readonly targets = signal<TopologyTarget[]>([]);
readonly runtimeOptions = computed(() =>
[...new Set(this.hosts().map((item) => item.runtimeType))].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' })),
);
readonly filteredHosts = computed(() => {
const query = this.searchQuery().trim().toLowerCase();
const runtime = this.runtimeFilter();
const status = this.statusFilter();
return this.hosts().filter((item) => {
const matchesQuery =
!query ||
[item.hostName, item.hostId, item.regionId, item.environmentId, item.runtimeType]
.some((value) => value.toLowerCase().includes(query));
const matchesRuntime = runtime === 'all' || item.runtimeType === runtime;
const normalizedStatus = item.status.trim().toLowerCase();
const matchesStatus = status === 'all' || normalizedStatus === status;
return matchesQuery && matchesRuntime && matchesStatus;
});
});
readonly selectedHost = computed(() => {
const selectedId = this.selectedHostId();
if (!selectedId) {
return this.filteredHosts()[0] ?? null;
}
return this.hosts().find((item) => item.hostId === selectedId) ?? null;
});
readonly selectedHostTargets = computed(() => {
const host = this.selectedHost();
if (!host) {
return [];
}
return this.targets().filter((item) => item.hostId === host.hostId);
});
constructor() {
this.context.initialize();
this.route.queryParamMap.subscribe((params) => {
const hostId = params.get('hostId');
if (hostId) {
this.selectedHostId.set(hostId);
}
const environment = params.get('environment');
if (environment) {
this.searchQuery.set(environment);
}
});
effect(() => {
this.context.contextVersion();
this.load();
});
}
private load(): void {
this.loading.set(true);
this.error.set(null);
forkJoin({
hosts: this.topologyApi.list<TopologyHost>('/api/v2/topology/hosts', this.context).pipe(catchError(() => of([]))),
targets: this.topologyApi.list<TopologyTarget>('/api/v2/topology/targets', this.context).pipe(catchError(() => of([]))),
})
.pipe(take(1))
.subscribe({
next: ({ hosts, targets }) => {
this.hosts.set(hosts);
this.targets.set(targets);
if (!this.selectedHostId() && hosts.length > 0) {
this.selectedHostId.set(hosts[0].hostId);
}
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load topology hosts.');
this.hosts.set([]);
this.targets.set([]);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,233 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
interface PlatformListResponse<T> {
items: T[];
count: number;
}
interface TopologyRouteData {
title: string;
endpoint: string;
description: string;
}
@Component({
selector: 'app-topology-inventory-page',
standalone: true,
template: `
<section class="topology">
<header class="topology__header">
<h1>{{ title() }}</h1>
<p>{{ description() }}</p>
</header>
<div class="topology__context">
<span>{{ context.regionSummary() }}</span>
<span>{{ context.environmentSummary() }}</span>
</div>
@if (error()) {
<div class="topology__error">{{ error() }}</div>
}
@if (loading()) {
<div class="topology__loading">Loading {{ title().toLowerCase() }}...</div>
} @else if (rows().length === 0) {
<div class="topology__empty">No data is available for the selected context.</div>
} @else {
<table class="topology__table">
<thead>
<tr>
@for (key of columnKeys(); track key) {
<th>{{ humanizeKey(key) }}</th>
}
</tr>
</thead>
<tbody>
@for (row of rows(); track row.id || row.key || $index) {
<tr>
@for (key of columnKeys(); track key) {
<td>{{ stringifyCell(row[key]) }}</td>
}
</tr>
}
</tbody>
</table>
}
</section>
`,
styles: [`
.topology {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.topology__header h1 {
margin: 0;
font-size: 1.5rem;
}
.topology__header p {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
}
.topology__context {
display: flex;
gap: 0.4rem;
}
.topology__context span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
padding: 0.12rem 0.5rem;
font-size: 0.74rem;
color: var(--color-text-secondary);
background: var(--color-surface-primary);
}
.topology__table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
overflow: hidden;
}
.topology__table th,
.topology__table td {
text-align: left;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--color-border-primary);
font-size: 0.78rem;
}
.topology__table th {
color: var(--color-text-secondary);
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.03em;
}
.topology__table tr:last-child td {
border-bottom: none;
}
.topology__loading,
.topology__empty,
.topology__error {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 1rem;
}
.topology__error {
color: var(--color-status-error-text);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TopologyInventoryPageComponent {
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly rows = signal<Array<Record<string, unknown>>>([]);
readonly title = signal('Topology');
readonly description = signal('Topology inventory');
readonly endpoint = signal('/api/v2/topology/regions');
readonly columnKeys = computed(() => {
const first = this.rows()[0];
if (!first) {
return [];
}
return Object.keys(first).filter((key) => key !== 'metadataJson');
});
constructor() {
this.context.initialize();
this.route.data.subscribe((data) => {
const routeData = data as TopologyRouteData;
this.title.set(routeData.title);
this.description.set(routeData.description);
this.endpoint.set(routeData.endpoint);
this.load();
});
effect(() => {
this.context.contextVersion();
this.load();
});
}
humanizeKey(key: string): string {
return key
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.trim();
}
stringifyCell(value: unknown): string {
if (value === null || value === undefined) {
return '-';
}
if (Array.isArray(value)) {
return value.join(', ');
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
private load(): void {
if (!this.endpoint()) {
return;
}
this.loading.set(true);
this.error.set(null);
let params = new HttpParams().set('limit', '100').set('offset', '0');
const regions = this.context.selectedRegions();
const environments = this.context.selectedEnvironments();
if (regions.length > 0) {
params = params.set('region', regions.join(','));
}
if (environments.length > 0) {
params = params.set('environment', environments.join(','));
}
this.http
.get<PlatformListResponse<Record<string, unknown>>>(this.endpoint(), { params })
.pipe(take(1))
.subscribe({
next: (response) => {
this.rows.set(response?.items ?? []);
this.loading.set(false);
},
error: (err: unknown) => {
const message = err instanceof Error ? err.message : 'Failed to load topology inventory.';
this.error.set(message);
this.rows.set([]);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,653 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyDataService } from './topology-data.service';
import {
TopologyAgent,
TopologyEnvironment,
TopologyHost,
TopologyPromotionPath,
TopologyRegion,
TopologyTarget,
} from './topology.models';
type SearchEntityType = 'environment' | 'target' | 'host' | 'agent';
interface SearchHit {
id: string;
label: string;
sublabel: string;
type: SearchEntityType;
}
@Component({
selector: 'app-topology-overview-page',
standalone: true,
imports: [FormsModule, RouterLink],
template: `
<section class="topology-overview">
<header class="hero">
<div>
<h1>Topology Overview</h1>
<p>Operator mission map for regions, environments, targets, hosts, and agents.</p>
</div>
<div class="hero__scope">
<span>{{ context.regionSummary() }}</span>
<span>{{ context.environmentSummary() }}</span>
</div>
</header>
<section class="search">
<label for="topology-overview-search">Topology Search</label>
<div class="search__row">
<input
id="topology-overview-search"
type="text"
placeholder="env / target / host / agent / group"
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event)"
/>
<button type="button" (click)="openFirstHit()" [disabled]="searchHits().length === 0">Go</button>
</div>
@if (searchQuery().trim().length >= 2) {
<ul class="search__hits">
@for (hit of searchHits(); track hit.id) {
<li>
<button type="button" (click)="openHit(hit)">
<span>{{ hit.label }}</span>
<small>{{ hit.sublabel }}</small>
</button>
</li>
} @empty {
<li class="search__empty">No topology entities match this query.</li>
}
</ul>
}
</section>
@if (error()) {
<div class="banner banner--error">{{ error() }}</div>
}
@if (loading()) {
<div class="banner">Loading topology posture...</div>
} @else {
<section class="grid grid--two">
<article class="card">
<h2>Regions</h2>
<p>{{ regions().length }} regions · {{ environments().length }} environments</p>
<ul>
@for (region of regions().slice(0, 5); track region.regionId) {
<li>
<strong>{{ region.displayName }}</strong>
<span>env {{ region.environmentCount }} · targets {{ region.targetCount }}</span>
</li>
}
</ul>
<a [routerLink]="['/topology/regions']">Open Regions & Environments</a>
</article>
<article class="card">
<h2>Environment Health</h2>
<p>
Healthy {{ environmentHealth().healthy }}
· Degraded {{ environmentHealth().degraded }}
· Unhealthy {{ environmentHealth().unhealthy }}
</p>
<ul>
@for (env of rankedEnvironments().slice(0, 5); track env.environmentId) {
<li>
<strong>{{ env.displayName }}</strong>
<span>{{ env.regionId }} · {{ summarizeEnvironmentHealth(env.environmentId) }}</span>
</li>
}
</ul>
<a [routerLink]="['/topology/environments']">Open Environment Inventory</a>
</article>
</section>
<section class="grid grid--two">
<article class="card">
<h2>Agents & Drift</h2>
<p>
Active {{ agentHealth().active }}
· Degraded {{ agentHealth().degraded }}
· Offline {{ agentHealth().offline }}
</p>
<p>Targets under non-active agents: {{ impactedTargetsByAgentHealth() }}</p>
<a [routerLink]="['/topology/agents']">Open Agents</a>
</article>
<article class="card">
<h2>Promotion Posture</h2>
<p>
Paths {{ promotionSummary().total }}
· Running {{ promotionSummary().running }}
· Failed {{ promotionSummary().failed }}
</p>
<p>Manual approvals required: {{ promotionSummary().manualApprovalCount }}</p>
<a [routerLink]="['/topology/promotion-paths']">Open Promotion Paths</a>
</article>
</section>
<article class="card">
<h2>Top Hotspots</h2>
<ul>
@for (hotspot of hotspots(); track hotspot.targetId) {
<li>
<span>{{ hotspot.name }}</span>
<small>{{ hotspot.regionId }}/{{ hotspot.environmentId }} · {{ hotspot.healthStatus }}</small>
<button type="button" (click)="openTarget(hotspot.targetId)">Open</button>
</li>
} @empty {
<li>No degraded topology hotspots in current scope.</li>
}
</ul>
</article>
}
</section>
`,
styles: [`
.topology-overview {
display: grid;
gap: 0.75rem;
}
.hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.8rem;
}
.hero h1 {
margin: 0;
font-size: 1.35rem;
}
.hero p {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.hero__scope {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.hero__scope span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
}
.search {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.45rem;
}
.search label {
font-size: 0.72rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.search__row {
display: flex;
gap: 0.45rem;
}
.search__row input {
flex: 1;
min-width: 0;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
font-size: 0.8rem;
padding: 0.35rem 0.45rem;
}
.search__row button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
font-size: 0.75rem;
padding: 0.3rem 0.55rem;
cursor: pointer;
}
.search__hits {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.25rem;
max-height: 10rem;
overflow: auto;
}
.search__hits li button {
width: 100%;
text-align: left;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
padding: 0.35rem 0.45rem;
display: grid;
gap: 0.1rem;
cursor: pointer;
}
.search__hits small {
color: var(--color-text-secondary);
font-size: 0.7rem;
}
.search__empty {
border: 1px dashed var(--color-border-primary);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
font-size: 0.75rem;
padding: 0.45rem;
}
.banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.7rem;
font-size: 0.78rem;
}
.banner--error {
color: var(--color-status-error-text);
}
.grid {
display: grid;
gap: 0.6rem;
}
.grid--two {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.35rem;
}
.card h2 {
margin: 0;
font-size: 0.95rem;
}
.card p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.76rem;
}
.card ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.25rem;
}
.card li {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
padding: 0.35rem 0.45rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.card li strong {
font-size: 0.78rem;
}
.card li span,
.card li small {
color: var(--color-text-secondary);
font-size: 0.72rem;
}
.card a {
justify-self: start;
color: var(--color-brand-primary);
font-size: 0.74rem;
text-decoration: none;
}
.card button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
cursor: pointer;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TopologyOverviewPageComponent {
private readonly topologyApi = inject(TopologyDataService);
private readonly router = inject(Router);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly searchQuery = signal('');
readonly regions = signal<TopologyRegion[]>([]);
readonly environments = signal<TopologyEnvironment[]>([]);
readonly targets = signal<TopologyTarget[]>([]);
readonly hosts = signal<TopologyHost[]>([]);
readonly agents = signal<TopologyAgent[]>([]);
readonly promotionPaths = signal<TopologyPromotionPath[]>([]);
readonly searchHits = computed<SearchHit[]>(() => {
const query = this.searchQuery().trim().toLowerCase();
if (query.length < 2) {
return [];
}
const environmentHits = this.environments()
.filter((item) => this.matchesQuery(query, [item.environmentId, item.displayName, item.regionId]))
.map<SearchHit>((item) => ({
id: `env:${item.environmentId}`,
label: item.displayName,
sublabel: `Environment · ${item.regionId}`,
type: 'environment',
}));
const targetHits = this.targets()
.filter((item) => this.matchesQuery(query, [item.name, item.targetType, item.environmentId, item.regionId]))
.map<SearchHit>((item) => ({
id: `target:${item.targetId}`,
label: item.name,
sublabel: `Target · ${item.environmentId}/${item.regionId}`,
type: 'target',
}));
const hostHits = this.hosts()
.filter((item) => this.matchesQuery(query, [item.hostName, item.hostId, item.environmentId, item.regionId]))
.map<SearchHit>((item) => ({
id: `host:${item.hostId}`,
label: item.hostName,
sublabel: `Host · ${item.environmentId}/${item.regionId}`,
type: 'host',
}));
const agentHits = this.agents()
.filter((item) => this.matchesQuery(query, [item.agentName, item.agentId, item.environmentId, item.regionId]))
.map<SearchHit>((item) => ({
id: `agent:${item.agentId}`,
label: item.agentName,
sublabel: `Agent · ${item.environmentId}/${item.regionId}`,
type: 'agent',
}));
return [...environmentHits, ...targetHits, ...hostHits, ...agentHits].slice(0, 14);
});
readonly hotspots = computed(() => {
return this.targets()
.filter((item) => this.normalizeHealth(item.healthStatus) !== 'healthy')
.sort((left, right) => this.hotspotRank(left.healthStatus) - this.hotspotRank(right.healthStatus))
.slice(0, 8);
});
readonly rankedEnvironments = computed(() => {
return [...this.environments()].sort((left, right) => {
const leftRank = this.environmentRiskRank(left.environmentId);
const rightRank = this.environmentRiskRank(right.environmentId);
if (leftRank !== rightRank) {
return rightRank - leftRank;
}
return left.displayName.localeCompare(right.displayName, 'en', { sensitivity: 'base' });
});
});
readonly environmentHealth = computed(() => {
let healthy = 0;
let degraded = 0;
let unhealthy = 0;
for (const env of this.environments()) {
const status = this.environmentHealthStatus(env.environmentId);
if (status === 'healthy') {
healthy += 1;
} else if (status === 'degraded') {
degraded += 1;
} else {
unhealthy += 1;
}
}
return { healthy, degraded, unhealthy };
});
readonly agentHealth = computed(() => {
let active = 0;
let degraded = 0;
let offline = 0;
for (const agent of this.agents()) {
const status = agent.status.trim().toLowerCase();
if (status === 'active') {
active += 1;
} else if (status === 'degraded') {
degraded += 1;
} else {
offline += 1;
}
}
return { active, degraded, offline };
});
readonly impactedTargetsByAgentHealth = computed(() => {
const degradedAgents = new Set(
this.agents()
.filter((item) => item.status.trim().toLowerCase() !== 'active')
.map((item) => item.agentId),
);
return this.targets().filter((item) => degradedAgents.has(item.agentId)).length;
});
readonly promotionSummary = computed(() => {
let running = 0;
let failed = 0;
let manualApprovalCount = 0;
for (const path of this.promotionPaths()) {
const status = path.status.trim().toLowerCase();
if (status === 'running') {
running += 1;
}
if (status === 'failed') {
failed += 1;
}
if (path.requiredApprovals > 0) {
manualApprovalCount += 1;
}
}
return {
total: this.promotionPaths().length,
running,
failed,
manualApprovalCount,
};
});
constructor() {
this.context.initialize();
effect(() => {
this.context.contextVersion();
this.load();
});
}
summarizeEnvironmentHealth(environmentId: string): string {
const status = this.environmentHealthStatus(environmentId);
const targetCount = this.targets().filter((item) => item.environmentId === environmentId).length;
return `${status} · ${targetCount} targets`;
}
openFirstHit(): void {
const first = this.searchHits()[0];
if (first) {
this.openHit(first);
}
}
openHit(hit: SearchHit): void {
const [kind, id] = hit.id.split(':');
if (!id) {
return;
}
if (kind === 'env') {
void this.router.navigate(['/topology/environments', id, 'posture']);
return;
}
if (kind === 'target') {
void this.router.navigate(['/topology/targets'], { queryParams: { targetId: id } });
return;
}
if (kind === 'host') {
void this.router.navigate(['/topology/hosts'], { queryParams: { hostId: id } });
return;
}
if (kind === 'agent') {
void this.router.navigate(['/topology/agents'], { queryParams: { agentId: id } });
}
}
openTarget(targetId: string): void {
void this.router.navigate(['/topology/targets'], { queryParams: { targetId } });
}
private load(): void {
this.loading.set(true);
this.error.set(null);
forkJoin({
regions: this.topologyApi.list<TopologyRegion>('/api/v2/topology/regions', this.context).pipe(catchError(() => of([]))),
environments: this.topologyApi
.list<TopologyEnvironment>('/api/v2/topology/environments', this.context)
.pipe(catchError(() => of([]))),
targets: this.topologyApi.list<TopologyTarget>('/api/v2/topology/targets', this.context).pipe(catchError(() => of([]))),
hosts: this.topologyApi.list<TopologyHost>('/api/v2/topology/hosts', this.context).pipe(catchError(() => of([]))),
agents: this.topologyApi.list<TopologyAgent>('/api/v2/topology/agents', this.context).pipe(catchError(() => of([]))),
paths: this.topologyApi
.list<TopologyPromotionPath>('/api/v2/topology/promotion-paths', this.context)
.pipe(catchError(() => of([]))),
})
.pipe(take(1))
.subscribe({
next: ({ regions, environments, targets, hosts, agents, paths }) => {
this.regions.set(regions);
this.environments.set(environments);
this.targets.set(targets);
this.hosts.set(hosts);
this.agents.set(agents);
this.promotionPaths.set(paths);
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load topology overview.');
this.regions.set([]);
this.environments.set([]);
this.targets.set([]);
this.hosts.set([]);
this.agents.set([]);
this.promotionPaths.set([]);
this.loading.set(false);
},
});
}
private matchesQuery(query: string, values: string[]): boolean {
return values.some((value) => value.toLowerCase().includes(query));
}
private environmentHealthStatus(environmentId: string): 'healthy' | 'degraded' | 'unhealthy' {
const statuses = this.targets()
.filter((item) => item.environmentId === environmentId)
.map((item) => this.normalizeHealth(item.healthStatus));
if (statuses.length === 0) {
return 'degraded';
}
if (statuses.includes('unhealthy') || statuses.includes('offline')) {
return 'unhealthy';
}
if (statuses.includes('degraded') || statuses.includes('unknown')) {
return 'degraded';
}
return 'healthy';
}
private environmentRiskRank(environmentId: string): number {
const status = this.environmentHealthStatus(environmentId);
if (status === 'unhealthy') {
return 3;
}
if (status === 'degraded') {
return 2;
}
return 1;
}
private normalizeHealth(value: string): string {
return value.trim().toLowerCase();
}
private hotspotRank(status: string): number {
const normalized = status.trim().toLowerCase();
if (normalized === 'unhealthy') {
return 0;
}
if (normalized === 'offline') {
return 1;
}
if (normalized === 'degraded') {
return 2;
}
if (normalized === 'unknown') {
return 3;
}
return 4;
}
}

View File

@@ -0,0 +1,397 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyDataService } from './topology-data.service';
import { TopologyEnvironment, TopologyPromotionPath } from './topology.models';
type PathsView = 'graph' | 'rules' | 'inventory';
interface PathRow extends TopologyPromotionPath {
sourceRegionId: string;
targetRegionId: string;
crossRegion: boolean;
}
@Component({
selector: 'app-topology-promotion-paths-page',
standalone: true,
imports: [FormsModule, RouterLink],
template: `
<section class="paths">
<header class="paths__header">
<div>
<h1>Promotion Paths</h1>
<p>Environment graph and rules for cross-environment promotion flow.</p>
</div>
<div class="paths__scope">
<span>{{ context.regionSummary() }}</span>
<span>{{ context.environmentSummary() }}</span>
</div>
</header>
<section class="filters">
<div class="filters__item">
<label for="paths-view">View</label>
<select id="paths-view" [ngModel]="viewMode()" (ngModelChange)="viewMode.set($event)">
<option value="graph">Graph</option>
<option value="rules">Rules table</option>
<option value="inventory">Inventory</option>
</select>
</div>
<div class="filters__item">
<label for="paths-cross-region">Cross-region</label>
<select id="paths-cross-region" [ngModel]="crossRegionFilter()" (ngModelChange)="crossRegionFilter.set($event)">
<option value="all">All</option>
<option value="yes">Cross-region</option>
<option value="no">In-region</option>
</select>
</div>
</section>
@if (error()) {
<div class="banner banner--error">{{ error() }}</div>
}
@if (loading()) {
<div class="banner">Loading promotion paths...</div>
} @else {
@if (viewMode() === 'graph') {
<article class="card">
<h2>Graph</h2>
<ul class="graph">
@for (path of filteredPaths(); track path.pathId) {
<li>
<strong>{{ path.sourceEnvironmentId }}</strong>
<span>-></span>
<strong>{{ path.targetEnvironmentId }}</strong>
<small>
{{ path.crossRegion ? 'cross-region' : 'in-region' }}
· {{ path.status }}
· approvals {{ path.requiredApprovals }}
</small>
</li>
} @empty {
<li class="muted">No promotion paths for current scope.</li>
}
</ul>
</article>
} @else if (viewMode() === 'rules') {
<article class="card">
<h2>Rules Table</h2>
<table>
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Gate Profile</th>
<th>Approvals</th>
<th>Cross-region</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (path of filteredPaths(); track path.pathId) {
<tr>
<td>{{ path.sourceEnvironmentId }}</td>
<td>{{ path.targetEnvironmentId }}</td>
<td>{{ path.gateProfileId }}</td>
<td>{{ path.requiredApprovals }}</td>
<td>{{ path.crossRegion ? 'yes' : 'no' }}</td>
<td>{{ path.status }}</td>
</tr>
} @empty {
<tr><td colspan="6" class="muted">No path rules for current filters.</td></tr>
}
</tbody>
</table>
</article>
} @else {
<article class="card">
<h2>Environment Inventory</h2>
<table>
<thead>
<tr>
<th>Environment</th>
<th>Region</th>
<th>Inbound Paths</th>
<th>Outbound Paths</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (entry of environmentPathInventory(); track entry.environmentId) {
<tr>
<td>{{ entry.environmentId }}</td>
<td>{{ entry.regionId }}</td>
<td>{{ entry.inbound }}</td>
<td>{{ entry.outbound }}</td>
<td>
<a [routerLink]="['/topology/environments', entry.environmentId, 'posture']">Open</a>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="muted">No environment inventory data.</td></tr>
}
</tbody>
</table>
</article>
}
}
</section>
`,
styles: [`
.paths {
display: grid;
gap: 0.75rem;
}
.paths__header {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.8rem;
}
.paths__header h1 {
margin: 0;
font-size: 1.3rem;
}
.paths__header p {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.paths__scope {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.paths__scope span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
}
.filters {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.45rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.filters__item {
display: grid;
gap: 0.2rem;
}
.filters label {
font-size: 0.7rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.filters select {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
font-size: 0.78rem;
padding: 0.32rem 0.42rem;
}
.banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.7rem;
font-size: 0.78rem;
}
.banner--error {
color: var(--color-status-error-text);
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.4rem;
}
.card h2 {
margin: 0;
font-size: 0.95rem;
}
.graph {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.3rem;
}
.graph li {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
padding: 0.35rem 0.45rem;
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
font-size: 0.75rem;
}
.graph li small {
color: var(--color-text-secondary);
font-size: 0.72rem;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-secondary);
}
th,
td {
text-align: left;
font-size: 0.74rem;
padding: 0.36rem 0.42rem;
border-bottom: 1px solid var(--color-border-primary);
vertical-align: middle;
}
th {
text-transform: uppercase;
color: var(--color-text-secondary);
font-size: 0.67rem;
letter-spacing: 0.03em;
}
tr:last-child td {
border-bottom: none;
}
.muted {
color: var(--color-text-secondary);
font-size: 0.74rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TopologyPromotionPathsPageComponent {
private readonly topologyApi = inject(TopologyDataService);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly viewMode = signal<PathsView>('graph');
readonly crossRegionFilter = signal<'all' | 'yes' | 'no'>('all');
readonly environments = signal<TopologyEnvironment[]>([]);
readonly paths = signal<TopologyPromotionPath[]>([]);
readonly enrichedPaths = computed<PathRow[]>(() => {
const envRegionById = new Map(this.environments().map((item) => [item.environmentId, item.regionId]));
return this.paths().map((path) => {
const sourceRegionId = envRegionById.get(path.sourceEnvironmentId) ?? path.regionId;
const targetRegionId = envRegionById.get(path.targetEnvironmentId) ?? path.regionId;
return {
...path,
sourceRegionId,
targetRegionId,
crossRegion: sourceRegionId !== targetRegionId,
};
});
});
readonly filteredPaths = computed(() => {
const filter = this.crossRegionFilter();
return this.enrichedPaths().filter((path) => {
if (filter === 'yes') {
return path.crossRegion;
}
if (filter === 'no') {
return !path.crossRegion;
}
return true;
});
});
readonly environmentPathInventory = computed(() => {
return this.environments()
.map((environment) => {
const inbound = this.paths().filter((path) => path.targetEnvironmentId === environment.environmentId).length;
const outbound = this.paths().filter((path) => path.sourceEnvironmentId === environment.environmentId).length;
return {
environmentId: environment.environmentId,
regionId: environment.regionId,
inbound,
outbound,
};
})
.sort((left, right) => left.environmentId.localeCompare(right.environmentId, 'en', { sensitivity: 'base' }));
});
constructor() {
this.context.initialize();
effect(() => {
this.context.contextVersion();
this.load();
});
}
private load(): void {
this.loading.set(true);
this.error.set(null);
forkJoin({
environments: this.topologyApi
.list<TopologyEnvironment>('/api/v2/topology/environments', this.context)
.pipe(catchError(() => of([]))),
paths: this.topologyApi
.list<TopologyPromotionPath>('/api/v2/topology/promotion-paths', this.context)
.pipe(catchError(() => of([]))),
})
.pipe(take(1))
.subscribe({
next: ({ environments, paths }) => {
this.environments.set(environments);
this.paths.set(paths);
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load topology promotion paths.');
this.environments.set([]);
this.paths.set([]);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,560 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyDataService } from './topology-data.service';
import { TopologyEnvironment, TopologyPromotionPath, TopologyRegion, TopologyTarget } from './topology.models';
type RegionsView = 'region-first' | 'flat' | 'graph';
@Component({
selector: 'app-topology-regions-environments-page',
standalone: true,
imports: [FormsModule, RouterLink],
template: `
<section class="regions-env">
<header class="regions-env__header">
<div>
<h1>Regions & Environments</h1>
<p>Region-first topology inventory with environment posture and drilldowns.</p>
</div>
<div class="regions-env__scope">
<span>{{ context.regionSummary() }}</span>
<span>{{ context.environmentSummary() }}</span>
</div>
</header>
<section class="filters">
<div class="filters__item">
<label for="regions-view">View</label>
<select id="regions-view" [ngModel]="viewMode()" (ngModelChange)="viewMode.set($event)">
<option value="region-first">Region-first</option>
<option value="flat">Flat list</option>
<option value="graph">Graph</option>
</select>
</div>
<div class="filters__item filters__item--wide">
<label for="regions-search">Search</label>
<input
id="regions-search"
type="text"
placeholder="Search region or environment"
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event)"
/>
</div>
</section>
@if (error()) {
<div class="banner banner--error">{{ error() }}</div>
}
@if (loading()) {
<div class="banner">Loading regions and environments...</div>
} @else {
@if (viewMode() === 'region-first') {
<section class="split">
<article class="card">
<h2>Regions</h2>
<ul class="region-list">
@for (region of filteredRegions(); track region.regionId) {
<li>
<button type="button" [class.active]="selectedRegionId() === region.regionId" (click)="selectRegion(region.regionId)">
<strong>{{ region.displayName }}</strong>
<small>env {{ region.environmentCount }} · targets {{ region.targetCount }}</small>
</button>
</li>
} @empty {
<li class="muted">No regions in current scope.</li>
}
</ul>
</article>
<article class="card">
<h2>Environments · {{ selectedRegionLabel() }}</h2>
<table>
<thead>
<tr>
<th>Environment</th>
<th>Type</th>
<th>Health</th>
<th>Targets</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (env of selectedRegionEnvironments(); track env.environmentId) {
<tr>
<td>{{ env.displayName }}</td>
<td>{{ env.environmentType }}</td>
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
<td>{{ env.targetCount }}</td>
<td>
<a [routerLink]="['/topology/environments', env.environmentId, 'posture']">Open</a>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="muted">No environments for this region.</td>
</tr>
}
</tbody>
</table>
</article>
</section>
} @else if (viewMode() === 'flat') {
<article class="card">
<h2>Environment Inventory</h2>
<table>
<thead>
<tr>
<th>Environment</th>
<th>Region</th>
<th>Type</th>
<th>Health</th>
<th>Targets</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (env of filteredEnvironments(); track env.environmentId) {
<tr>
<td>{{ env.displayName }}</td>
<td>{{ env.regionId }}</td>
<td>{{ env.environmentType }}</td>
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
<td>{{ env.targetCount }}</td>
<td><a [routerLink]="['/topology/environments', env.environmentId, 'posture']">Open</a></td>
</tr>
} @empty {
<tr>
<td colspan="6" class="muted">No environments for current filters.</td>
</tr>
}
</tbody>
</table>
</article>
} @else {
<article class="card">
<h2>Promotion Graph (by region)</h2>
<ul class="graph-list">
@for (edge of graphEdges(); track edge.pathId) {
<li>{{ edge.regionId }} · {{ edge.sourceEnvironmentId }} -> {{ edge.targetEnvironmentId }} · {{ edge.status }}</li>
} @empty {
<li class="muted">No promotion edges in current scope.</li>
}
</ul>
</article>
}
<article class="card">
<h2>Environment Signals</h2>
<p>
Selected:
<strong>{{ selectedEnvironmentLabel() }}</strong>
· {{ selectedEnvironmentHealth() }}
· targets {{ selectedEnvironmentTargetCount() }}
</p>
<div class="actions">
<a [routerLink]="['/topology/environments', selectedEnvironmentId(), 'posture']">Open Environment</a>
<a [routerLink]="['/topology/targets']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Targets</a>
<a [routerLink]="['/topology/agents']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Agents</a>
<a [routerLink]="['/releases/activity']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Deployments</a>
</div>
</article>
}
</section>
`,
styles: [`
.regions-env {
display: grid;
gap: 0.75rem;
}
.regions-env__header {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.8rem;
}
.regions-env__header h1 {
margin: 0;
font-size: 1.3rem;
}
.regions-env__header p {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.regions-env__scope {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.regions-env__scope span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
white-space: nowrap;
}
.filters {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.45rem;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.filters__item {
display: grid;
gap: 0.2rem;
}
.filters__item--wide {
grid-column: span 2;
}
.filters label {
font-size: 0.7rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.filters select,
.filters input {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
font-size: 0.78rem;
padding: 0.32rem 0.42rem;
}
.banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.7rem;
font-size: 0.78rem;
}
.banner--error {
color: var(--color-status-error-text);
}
.split {
display: grid;
gap: 0.6rem;
grid-template-columns: minmax(220px, 320px) 1fr;
align-items: start;
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.45rem;
}
.card h2 {
margin: 0;
font-size: 0.95rem;
}
.region-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3rem;
}
.region-list li button {
width: 100%;
text-align: left;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
padding: 0.4rem 0.45rem;
display: grid;
gap: 0.1rem;
cursor: pointer;
}
.region-list li button.active {
border-color: var(--color-brand-primary);
background: var(--color-brand-primary-10);
}
.region-list small {
color: var(--color-text-secondary);
font-size: 0.7rem;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-secondary);
}
th,
td {
text-align: left;
font-size: 0.75rem;
padding: 0.38rem 0.42rem;
border-bottom: 1px solid var(--color-border-primary);
vertical-align: middle;
}
th {
text-transform: uppercase;
color: var(--color-text-secondary);
font-size: 0.68rem;
letter-spacing: 0.03em;
}
tr:last-child td {
border-bottom: none;
}
.muted {
color: var(--color-text-secondary);
font-size: 0.74rem;
}
.graph-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.25rem;
}
.graph-list li {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
padding: 0.32rem 0.42rem;
font-size: 0.74rem;
background: var(--color-surface-secondary);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.actions a {
color: var(--color-brand-primary);
font-size: 0.74rem;
text-decoration: none;
}
@media (max-width: 960px) {
.filters__item--wide {
grid-column: span 1;
}
.split {
grid-template-columns: 1fr;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TopologyRegionsEnvironmentsPageComponent {
private readonly topologyApi = inject(TopologyDataService);
private readonly route = inject(ActivatedRoute);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly searchQuery = signal('');
readonly viewMode = signal<RegionsView>('region-first');
readonly selectedRegionId = signal('');
readonly selectedEnvironmentId = signal('');
readonly regions = signal<TopologyRegion[]>([]);
readonly environments = signal<TopologyEnvironment[]>([]);
readonly targets = signal<TopologyTarget[]>([]);
readonly paths = signal<TopologyPromotionPath[]>([]);
readonly filteredRegions = computed(() => {
const query = this.searchQuery().trim().toLowerCase();
if (!query) {
return this.regions();
}
return this.regions().filter((item) => this.match(query, [item.displayName, item.regionId]));
});
readonly filteredEnvironments = computed(() => {
const query = this.searchQuery().trim().toLowerCase();
if (!query) {
return this.environments();
}
return this.environments().filter((item) =>
this.match(query, [item.displayName, item.environmentId, item.regionId, item.environmentType]),
);
});
readonly selectedRegionLabel = computed(() => {
const selected = this.regions().find((item) => item.regionId === this.selectedRegionId());
return (selected?.displayName ?? this.selectedRegionId()) || 'All Regions';
});
readonly selectedRegionEnvironments = computed(() => {
const selectedRegion = this.selectedRegionId();
if (!selectedRegion) {
return this.filteredEnvironments();
}
return this.filteredEnvironments().filter((item) => item.regionId === selectedRegion);
});
readonly selectedEnvironmentLabel = computed(() => {
const selected = this.environments().find((item) => item.environmentId === this.selectedEnvironmentId());
return (selected?.displayName ?? this.selectedEnvironmentId()) || 'No environment selected';
});
readonly selectedEnvironmentHealth = computed(() => {
const environmentId = this.selectedEnvironmentId();
if (!environmentId) {
return 'No environment selected';
}
return this.environmentHealthLabel(environmentId);
});
readonly selectedEnvironmentTargetCount = computed(() => {
const environmentId = this.selectedEnvironmentId();
if (!environmentId) {
return 0;
}
return this.targets().filter((item) => item.environmentId === environmentId).length;
});
readonly graphEdges = computed(() => {
const selectedRegion = this.selectedRegionId();
if (!selectedRegion) {
return this.paths();
}
return this.paths().filter((item) => item.regionId === selectedRegion);
});
constructor() {
this.context.initialize();
this.route.data.subscribe((data) => {
const defaultView = (data['defaultView'] as RegionsView | undefined) ?? 'region-first';
this.viewMode.set(defaultView);
});
effect(() => {
this.context.contextVersion();
this.load();
});
}
selectRegion(regionId: string): void {
this.selectedRegionId.set(regionId);
const firstEnv = this.environments().find((item) => item.regionId === regionId);
this.selectedEnvironmentId.set(firstEnv?.environmentId ?? '');
}
environmentHealthLabel(environmentId: string): string {
const statuses = this.targets()
.filter((item) => item.environmentId === environmentId)
.map((item) => item.healthStatus.trim().toLowerCase());
if (statuses.length === 0) {
return 'No target data';
}
if (statuses.includes('unhealthy') || statuses.includes('offline')) {
return 'Unhealthy';
}
if (statuses.includes('degraded') || statuses.includes('unknown')) {
return 'Degraded';
}
return 'Healthy';
}
private load(): void {
this.loading.set(true);
this.error.set(null);
forkJoin({
regions: this.topologyApi.list<TopologyRegion>('/api/v2/topology/regions', this.context).pipe(catchError(() => of([]))),
environments: this.topologyApi
.list<TopologyEnvironment>('/api/v2/topology/environments', this.context)
.pipe(catchError(() => of([]))),
targets: this.topologyApi.list<TopologyTarget>('/api/v2/topology/targets', this.context).pipe(catchError(() => of([]))),
paths: this.topologyApi
.list<TopologyPromotionPath>('/api/v2/topology/promotion-paths', this.context)
.pipe(catchError(() => of([]))),
})
.pipe(take(1))
.subscribe({
next: ({ regions, environments, targets, paths }) => {
this.regions.set(regions);
this.environments.set(environments);
this.targets.set(targets);
this.paths.set(paths);
const selectedRegion = this.selectedRegionId();
const hasRegion = selectedRegion && regions.some((item) => item.regionId === selectedRegion);
if (!hasRegion) {
this.selectedRegionId.set(regions[0]?.regionId ?? '');
}
const selectedEnv = this.selectedEnvironmentId();
const hasEnvironment = selectedEnv && environments.some((item) => item.environmentId === selectedEnv);
if (!hasEnvironment) {
const firstInRegion = environments.find((item) => item.regionId === this.selectedRegionId());
this.selectedEnvironmentId.set(firstInRegion?.environmentId ?? environments[0]?.environmentId ?? '');
}
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load region and environment inventory.');
this.regions.set([]);
this.environments.set([]);
this.targets.set([]);
this.paths.set([]);
this.selectedRegionId.set('');
this.selectedEnvironmentId.set('');
this.loading.set(false);
},
});
}
private match(query: string, values: string[]): boolean {
return values.some((value) => value.toLowerCase().includes(query));
}
}

View File

@@ -0,0 +1,411 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyDataService } from './topology-data.service';
import { TopologyAgent, TopologyHost, TopologyTarget } from './topology.models';
@Component({
selector: 'app-topology-targets-page',
standalone: true,
imports: [FormsModule, RouterLink],
template: `
<section class="targets">
<header class="targets__header">
<div>
<h1>Targets</h1>
<p>Operational target inventory with host and agent mapping.</p>
</div>
<div class="targets__scope">
<span>{{ context.regionSummary() }}</span>
<span>{{ context.environmentSummary() }}</span>
</div>
</header>
<section class="filters">
<div class="filters__item filters__item--wide">
<label for="targets-search">Search</label>
<input id="targets-search" type="text" [ngModel]="searchQuery()" (ngModelChange)="searchQuery.set($event)" />
</div>
<div class="filters__item">
<label for="targets-runtime">Runtime</label>
<select id="targets-runtime" [ngModel]="runtimeFilter()" (ngModelChange)="runtimeFilter.set($event)">
<option value="all">All</option>
@for (runtime of runtimeOptions(); track runtime) {
<option [value]="runtime">{{ runtime }}</option>
}
</select>
</div>
<div class="filters__item">
<label for="targets-status">Status</label>
<select id="targets-status" [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)">
<option value="all">All</option>
<option value="healthy">Healthy</option>
<option value="degraded">Degraded</option>
<option value="unhealthy">Unhealthy</option>
<option value="offline">Offline</option>
<option value="unknown">Unknown</option>
</select>
</div>
</section>
@if (error()) {
<div class="banner banner--error">{{ error() }}</div>
}
@if (loading()) {
<div class="banner">Loading targets...</div>
} @else {
<section class="split">
<article class="card">
<h2>Targets</h2>
<table>
<thead>
<tr>
<th>Target</th>
<th>Region</th>
<th>Environment</th>
<th>Runtime</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (target of filteredTargets(); track target.targetId) {
<tr [class.active]="selectedTargetId() === target.targetId" (click)="selectedTargetId.set(target.targetId)">
<td>{{ target.name }}</td>
<td>{{ target.regionId }}</td>
<td>{{ target.environmentId }}</td>
<td>{{ target.targetType }}</td>
<td>{{ target.healthStatus }}</td>
</tr>
} @empty {
<tr><td colspan="5" class="muted">No targets for current filters.</td></tr>
}
</tbody>
</table>
</article>
<article class="card detail">
<h2>Selected Target</h2>
@if (selectedTarget()) {
<p><strong>{{ selectedTarget()!.name }}</strong></p>
<p>Runtime: {{ selectedTarget()!.targetType }}</p>
<p>Health: {{ selectedTarget()!.healthStatus }}</p>
<p>Region/Env: {{ selectedTarget()!.regionId }} / {{ selectedTarget()!.environmentId }}</p>
<p>Component: {{ selectedTarget()!.componentVersionId }}</p>
<p>Host: {{ selectedHostName() }}</p>
<p>Agent: {{ selectedAgentName() }}</p>
<div class="actions">
<a [routerLink]="['/topology/hosts']" [queryParams]="{ hostId: selectedTarget()!.hostId }">Open Host</a>
<a [routerLink]="['/topology/agents']" [queryParams]="{ agentId: selectedTarget()!.agentId }">Open Agent</a>
<a [routerLink]="['/platform/integrations']">Go to Integrations</a>
</div>
} @else {
<p class="muted">Select a target row to view its topology mapping details.</p>
}
</article>
</section>
}
</section>
`,
styles: [`
.targets {
display: grid;
gap: 0.75rem;
}
.targets__header {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.8rem;
}
.targets__header h1 {
margin: 0;
font-size: 1.3rem;
}
.targets__header p {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.targets__scope {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.targets__scope span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
}
.filters {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.45rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.filters__item {
display: grid;
gap: 0.2rem;
}
.filters__item--wide {
grid-column: span 2;
}
.filters label {
font-size: 0.7rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.filters select,
.filters input {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
font-size: 0.78rem;
padding: 0.32rem 0.42rem;
}
.banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.7rem;
font-size: 0.78rem;
}
.banner--error {
color: var(--color-status-error-text);
}
.split {
display: grid;
gap: 0.6rem;
grid-template-columns: 1.45fr 1fr;
align-items: start;
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.4rem;
}
.card h2 {
margin: 0;
font-size: 0.95rem;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-secondary);
}
th,
td {
text-align: left;
font-size: 0.74rem;
padding: 0.36rem 0.42rem;
border-bottom: 1px solid var(--color-border-primary);
vertical-align: middle;
}
th {
text-transform: uppercase;
color: var(--color-text-secondary);
font-size: 0.67rem;
letter-spacing: 0.03em;
}
tr:last-child td {
border-bottom: none;
}
tbody tr {
cursor: pointer;
}
tbody tr.active {
background: var(--color-brand-primary-10);
}
.detail p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.actions a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.74rem;
}
.muted {
color: var(--color-text-secondary);
font-size: 0.74rem;
}
@media (max-width: 960px) {
.filters__item--wide {
grid-column: span 1;
}
.split {
grid-template-columns: 1fr;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TopologyTargetsPageComponent {
private readonly topologyApi = inject(TopologyDataService);
private readonly route = inject(ActivatedRoute);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly searchQuery = signal('');
readonly runtimeFilter = signal('all');
readonly statusFilter = signal('all');
readonly selectedTargetId = signal('');
readonly targets = signal<TopologyTarget[]>([]);
readonly hosts = signal<TopologyHost[]>([]);
readonly agents = signal<TopologyAgent[]>([]);
readonly runtimeOptions = computed(() => {
return [...new Set(this.targets().map((item) => item.targetType))].sort((a, b) =>
a.localeCompare(b, 'en', { sensitivity: 'base' }),
);
});
readonly filteredTargets = computed(() => {
const query = this.searchQuery().trim().toLowerCase();
const runtime = this.runtimeFilter();
const status = this.statusFilter();
return this.targets().filter((item) => {
const matchesQuery =
!query ||
[item.name, item.regionId, item.environmentId, item.targetType, item.healthStatus]
.some((value) => value.toLowerCase().includes(query));
const matchesRuntime = runtime === 'all' || item.targetType === runtime;
const normalizedStatus = item.healthStatus.trim().toLowerCase();
const matchesStatus = status === 'all' || normalizedStatus === status;
return matchesQuery && matchesRuntime && matchesStatus;
});
});
readonly selectedTarget = computed(() => {
const selectedId = this.selectedTargetId();
if (!selectedId) {
return this.filteredTargets()[0] ?? null;
}
return this.targets().find((item) => item.targetId === selectedId) ?? null;
});
readonly selectedHostName = computed(() => {
const target = this.selectedTarget();
if (!target) {
return '-';
}
const host = this.hosts().find((item) => item.hostId === target.hostId);
return host?.hostName ?? target.hostId;
});
readonly selectedAgentName = computed(() => {
const target = this.selectedTarget();
if (!target) {
return '-';
}
const agent = this.agents().find((item) => item.agentId === target.agentId);
return agent?.agentName ?? target.agentId;
});
constructor() {
this.context.initialize();
this.route.queryParamMap.subscribe((params) => {
const targetId = params.get('targetId');
if (targetId) {
this.selectedTargetId.set(targetId);
}
const environment = params.get('environment');
if (environment) {
this.searchQuery.set(environment);
}
});
effect(() => {
this.context.contextVersion();
this.load();
});
}
private load(): void {
this.loading.set(true);
this.error.set(null);
forkJoin({
targets: this.topologyApi.list<TopologyTarget>('/api/v2/topology/targets', this.context).pipe(catchError(() => of([]))),
hosts: this.topologyApi.list<TopologyHost>('/api/v2/topology/hosts', this.context).pipe(catchError(() => of([]))),
agents: this.topologyApi.list<TopologyAgent>('/api/v2/topology/agents', this.context).pipe(catchError(() => of([]))),
})
.pipe(take(1))
.subscribe({
next: ({ targets, hosts, agents }) => {
this.targets.set(targets);
this.hosts.set(hosts);
this.agents.set(agents);
if (!this.selectedTargetId() && targets.length > 0) {
this.selectedTargetId.set(targets[0].targetId);
}
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load topology targets.');
this.targets.set([]);
this.hosts.set([]);
this.agents.set([]);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,106 @@
export interface PlatformListResponse<T> {
items: T[];
total?: number;
count?: number;
limit?: number;
offset?: number;
}
export interface TopologyRegion {
regionId: string;
displayName: string;
sortOrder: number;
environmentCount: number;
targetCount: number;
hostCount: number;
agentCount: number;
lastSyncAt: string | null;
}
export interface TopologyEnvironment {
environmentId: string;
regionId: string;
environmentType: string;
displayName: string;
sortOrder: number;
targetCount: number;
hostCount: number;
agentCount: number;
promotionPathCount: number;
workflowCount: number;
lastSyncAt: string | null;
}
export interface TopologyTarget {
targetId: string;
name: string;
regionId: string;
environmentId: string;
hostId: string;
agentId: string;
targetType: string;
healthStatus: string;
componentVersionId: string;
imageDigest: string;
releaseId: string;
releaseVersionId: string;
lastSyncAt: string | null;
}
export interface TopologyHost {
hostId: string;
hostName: string;
regionId: string;
environmentId: string;
runtimeType: string;
status: string;
agentId: string;
targetCount: number;
lastSeenAt: string | null;
}
export interface TopologyAgent {
agentId: string;
agentName: string;
regionId: string;
environmentId: string;
status: string;
capabilities: string[];
assignedTargetCount: number;
lastHeartbeatAt: string | null;
}
export interface TopologyPromotionPath {
pathId: string;
regionId: string;
sourceEnvironmentId: string;
targetEnvironmentId: string;
pathMode: string;
status: string;
requiredApprovals: number;
workflowId: string;
gateProfileId: string;
lastPromotedAt: string | null;
}
export interface ReleaseActivityRow {
activityId: string;
releaseId: string;
releaseName: string;
status: string;
correlationKey: string;
occurredAt: string;
}
export interface SecurityFindingRow {
findingId: string;
cveId: string;
severity: string;
effectiveDisposition: string;
}
export interface EvidenceCapsuleRow {
capsuleId: string;
status: string;
updatedAt: string;
}

View File

@@ -68,16 +68,6 @@ describe('AppShellComponent', () => {
expect(skipLink.textContent).toContain('Skip to main content');
});
it('should toggle sidebar collapsed state', () => {
expect(component.sidebarCollapsed()).toBe(false);
component.onToggleSidebar();
expect(component.sidebarCollapsed()).toBe(true);
component.onToggleSidebar();
expect(component.sidebarCollapsed()).toBe(false);
});
it('should toggle mobile menu state', () => {
expect(component.mobileMenuOpen()).toBe(false);

View File

@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core';
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@@ -8,11 +8,11 @@ import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component';
import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
/**
* AppShellComponent - Main application shell with left rail navigation.
* AppShellComponent - Main application shell with permanent left rail navigation.
*
* Layout structure:
* - Left sidebar (200px desktop, 56px collapsed, hidden mobile with overlay)
* - Top bar (fixed height ~56px)
* - Left sidebar (fixed 240px, never collapses)
* - Top bar (fixed height 48px)
* - Main content area with breadcrumb and router outlet
* - Overlay host for drawers/modals
*
@@ -29,15 +29,13 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
OverlayHostComponent
],
template: `
<div class="shell" [class.shell--sidebar-collapsed]="sidebarCollapsed()">
<div class="shell">
<!-- Skip link for accessibility -->
<a class="shell__skip-link" href="#main-content">Skip to main content</a>
<!-- Sidebar -->
<!-- Sidebar (permanent, never collapses) -->
<app-sidebar
class="shell__sidebar"
[collapsed]="sidebarCollapsed()"
(toggleCollapse)="onToggleSidebar()"
(mobileClose)="onMobileSidebarClose()"
></app-sidebar>
@@ -78,14 +76,9 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
styles: [`
.shell {
display: grid;
grid-template-columns: var(--sidebar-width, 240px) 1fr;
grid-template-columns: 240px 1fr;
grid-template-rows: 1fr;
min-height: 100vh;
background: var(--color-surface-tertiary);
}
.shell--sidebar-collapsed {
--sidebar-width: 56px;
}
.shell__skip-link {
@@ -117,7 +110,8 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
grid-row: 1;
display: flex;
flex-direction: column;
min-width: 0; /* Prevent content overflow */
min-width: 0;
background: var(--color-surface-tertiary);
}
.shell__topbar {
@@ -168,7 +162,7 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
left: 0;
top: 0;
transform: translateX(-100%);
transition: transform 0.3s ease;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
width: 280px;
z-index: 200;
}
@@ -181,7 +175,8 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
z-index: 150;
}
@@ -214,16 +209,9 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
},
})
export class AppShellComponent {
/** Whether sidebar is collapsed to icon-only mode (desktop) */
readonly sidebarCollapsed = signal(false);
/** Whether mobile menu is open */
readonly mobileMenuOpen = signal(false);
onToggleSidebar(): void {
this.sidebarCollapsed.update((v) => !v);
}
onMobileMenuToggle(): void {
this.mobileMenuOpen.update((v) => !v);
}

View File

@@ -1,14 +1,11 @@
import {
Component,
ChangeDetectionStrategy,
Input,
Output,
EventEmitter,
inject,
signal,
computed,
NgZone,
OnDestroy,
DestroyRef,
} from '@angular/core';
@@ -23,7 +20,7 @@ import { SidebarNavGroupComponent } from './sidebar-nav-group.component';
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
/**
* Navigation structure for the new shell.
* Navigation structure for the shell.
* Each section maps to a top-level route.
*/
export interface NavSection {
@@ -38,24 +35,10 @@ export interface NavSection {
}
/**
* AppSidebarComponent - Left navigation rail.
* AppSidebarComponent - Permanent dark left navigation rail.
*
* Navigation structure (v2 canonical IA — SPRINT_20260218_006):
* - DASHBOARD
* - RELEASE CONTROL (group)
* - Releases [direct shortcut]
* - Approvals [direct shortcut]
* - Bundles [nested]
* - Deployments [nested]
* - Regions & Environments [nested]
* - SECURITY AND RISK
* - EVIDENCE AND AUDIT
* - INTEGRATIONS
* - PLATFORM OPS
* - ADMINISTRATION
*
* Canonical domain ownership per docs/modules/ui/v2-rewire/source-of-truth.md.
* Nav rendering policy per docs/modules/ui/v2-rewire/S00_nav_rendering_policy.md.
* Design: Always-visible 240px dark sidebar. Never collapses.
* Dark charcoal background with amber/gold accents.
*/
@Component({
selector: 'app-sidebar',
@@ -68,54 +51,36 @@ export interface NavSection {
template: `
<aside
class="sidebar"
[class.sidebar--collapsed]="collapsed && !hoverExpanded()"
[class.sidebar--hover-expanded]="hoverExpanded()"
role="navigation"
aria-label="Main navigation"
(mouseenter)="onSidebarMouseEnter()"
(mouseleave)="onSidebarMouseLeave()"
>
<!-- Brand/Logo -->
<!-- Brand -->
<div class="sidebar__brand">
<a routerLink="/" class="sidebar__logo">
<img src="assets/img/site.png" alt="" class="sidebar__logo-img" width="28" height="28" />
@if (!collapsed || hoverExpanded()) {
<span class="sidebar__logo-text">Stella Ops</span>
}
<div class="sidebar__logo-mark">
<img src="assets/img/site.png" alt="" width="26" height="26" />
</div>
<div class="sidebar__logo-wordmark">
<span class="sidebar__logo-name">Stella Ops</span>
<span class="sidebar__logo-tagline">Release Control</span>
</div>
</a>
</div>
<!-- Collapse toggle (desktop) -->
<button
type="button"
class="sidebar__toggle"
(click)="toggleCollapse.emit()"
[attr.aria-label]="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
[attr.aria-pressed]="collapsed"
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
@if (collapsed) {
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2"/>
} @else {
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2"/>
}
</svg>
</button>
<!-- Close button (mobile) -->
<!-- Close button (mobile only) -->
<button
type="button"
class="sidebar__close"
(click)="mobileClose.emit()"
aria-label="Close navigation menu"
>
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2"/>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<!-- Navigation items -->
<!-- Navigation -->
<nav class="sidebar__nav">
@for (section of visibleSections(); track section.id) {
@if (section.children && section.children.length > 0) {
@@ -124,7 +89,6 @@ export interface NavSection {
[icon]="section.icon"
[route]="section.route"
[children]="section.children"
[collapsed]="collapsed && !hoverExpanded()"
[expanded]="expandedGroups().has(section.id)"
(expandedChange)="onGroupToggle(section.id, $event)"
></app-sidebar-nav-group>
@@ -134,17 +98,15 @@ export interface NavSection {
[icon]="section.icon"
[route]="section.route"
[badge]="section.badge$ ? section.badge$() : null"
[collapsed]="collapsed && !hoverExpanded()"
></app-sidebar-nav-item>
}
}
</nav>
<!-- Bottom section (optional version info) -->
<!-- Footer -->
<div class="sidebar__footer">
@if (!collapsed || hoverExpanded()) {
<span class="sidebar__version">v1.0.0</span>
}
<div class="sidebar__footer-divider"></div>
<span class="sidebar__version">v1.0.0</span>
</div>
</aside>
`,
@@ -155,119 +117,100 @@ export interface NavSection {
width: 240px;
height: 100%;
background: var(--color-sidebar-bg);
color: var(--color-sidebar-text, var(--color-text-primary));
border-right: 1px solid var(--color-border-primary);
transition: width 0.2s ease;
color: var(--color-sidebar-text);
overflow: hidden;
position: relative;
}
.sidebar--collapsed {
width: 56px;
overflow: visible;
}
.sidebar--collapsed .sidebar__nav {
overflow: visible;
}
/* Hover auto-expand: slides out over content */
.sidebar--hover-expanded {
width: 240px;
/* Subtle inner glow along right edge */
.sidebar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.12), 1px 0 4px rgba(0, 0, 0, 0.06);
animation: sidebar-slide-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes sidebar-slide-in {
from {
width: 56px;
opacity: 0.8;
}
to {
width: 240px;
opacity: 1;
}
width: 1px;
background: var(--color-sidebar-border);
}
/* ---- Brand ---- */
.sidebar__brand {
display: flex;
align-items: center;
height: 48px;
height: 56px;
padding: 0 1rem;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border-primary);
background: transparent;
border-bottom: 1px solid var(--color-sidebar-divider);
}
.sidebar__logo {
display: flex;
align-items: center;
gap: 0.625rem;
gap: 0.75rem;
color: inherit;
text-decoration: none;
font-weight: 700;
font-size: 0.9375rem;
letter-spacing: -0.02em;
white-space: nowrap;
}
.sidebar__logo-img {
flex-shrink: 0;
border-radius: 5px;
filter: drop-shadow(0 1px 2px rgba(212,146,10,0.12));
}
.sidebar__logo-text {
color: var(--color-text-heading);
letter-spacing: -0.025em;
}
.sidebar__toggle {
display: none;
position: absolute;
right: -12px;
top: 72px;
width: 24px;
height: 24px;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
cursor: pointer;
z-index: 10;
transition: opacity 0.15s;
&:hover {
background: var(--color-nav-hover);
opacity: 0.85;
}
}
@media (min-width: 992px) {
.sidebar__toggle {
display: flex;
align-items: center;
justify-content: center;
.sidebar__logo-mark {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(245, 166, 35, 0.1);
border: 1px solid rgba(245, 166, 35, 0.15);
img {
border-radius: 4px;
}
}
.sidebar__logo-wordmark {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.sidebar__logo-name {
color: var(--color-sidebar-brand-text);
font-weight: 700;
font-size: 0.875rem;
letter-spacing: -0.02em;
}
.sidebar__logo-tagline {
color: var(--color-sidebar-text-muted);
font-size: 0.5625rem;
font-family: var(--font-family-mono);
text-transform: uppercase;
letter-spacing: 0.1em;
}
/* ---- Mobile close ---- */
.sidebar__close {
display: none;
position: absolute;
right: 0.75rem;
top: 0.75rem;
width: 40px;
height: 40px;
top: 0.875rem;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-lg);
border-radius: 6px;
background: transparent;
color: var(--color-text-secondary);
color: var(--color-sidebar-text-muted);
cursor: pointer;
&:hover {
background: var(--color-nav-hover);
background: var(--color-sidebar-hover);
color: var(--color-sidebar-text);
}
}
@@ -279,242 +222,145 @@ export interface NavSection {
}
}
/* ---- Nav ---- */
.sidebar__nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0.5rem;
padding: 0.75rem 0.5rem;
scrollbar-width: thin;
scrollbar-color: var(--color-border-primary) transparent;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
&::-webkit-scrollbar {
width: 4px;
width: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border-primary);
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
}
}
/* ---- Footer ---- */
.sidebar__footer {
flex-shrink: 0;
padding: 0.5rem 1rem;
text-align: center;
padding: 0.75rem 1rem;
}
.sidebar__footer-divider {
height: 1px;
background: var(--color-sidebar-divider);
margin-bottom: 0.75rem;
}
.sidebar__version {
display: block;
font-size: 0.5625rem;
font-family: var(--font-family-mono);
letter-spacing: 0.08em;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-text-muted);
opacity: 0.4;
color: var(--color-sidebar-version);
text-align: center;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppSidebarComponent implements OnDestroy {
export class AppSidebarComponent {
private readonly router = inject(Router);
private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly ngZone = inject(NgZone);
private readonly destroyRef = inject(DestroyRef);
private readonly approvalApi = inject(APPROVAL_API, { optional: true }) as ApprovalApi | null;
@Input() collapsed = false;
@Output() toggleCollapse = new EventEmitter<void>();
@Output() mobileClose = new EventEmitter<void>();
/** Whether sidebar is temporarily expanded due to hover */
readonly hoverExpanded = signal(false);
private hoverTimer: ReturnType<typeof setTimeout> | null = null;
private readonly pendingApprovalsCount = signal(0);
/** Track which groups are expanded — default open: Release Control, Security & Risk */
readonly expandedGroups = signal<Set<string>>(new Set(['release-control', 'security-risk']));
/** Track which groups are expanded — default open: Releases, Security, Platform. */
readonly expandedGroups = signal<Set<string>>(new Set(['releases', 'security', 'platform']));
/**
* Navigation sections — canonical v2 IA (SPRINT_20260218_006).
* Seven root domains per docs/modules/ui/v2-rewire/source-of-truth.md.
* All routes point to canonical /release-control/*, /security-risk/*, etc.
* v1 alias routes (/releases, /approvals, etc.) remain active for backward compat
* and are removed at SPRINT_20260218_016 cutover.
* Navigation sections — Pack 22 consolidated IA.
* Root modules: Dashboard, Releases, Security, Evidence, Topology, Platform.
*/
readonly navSections: NavSection[] = [
// 1. Dashboard
{
id: 'dashboard',
label: 'Dashboard',
icon: 'dashboard',
route: '/dashboard',
},
// 2. Release Control — Releases and Approvals as direct nav shortcuts per S00_nav_rendering_policy.md.
// Bundles, Deployments, and Regions & Environments stay grouped under Release Control ownership.
{
id: 'release-control',
label: 'Release Control',
id: 'releases',
label: 'Releases',
icon: 'package',
route: '/release-control',
route: '/releases',
children: [
{
id: 'rc-releases',
label: 'Releases',
route: '/release-control/releases',
icon: 'package',
},
{
id: 'rc-approvals',
label: 'Approvals',
route: '/release-control/approvals',
icon: 'check-circle',
badge: 0,
},
{
id: 'rc-promotions',
label: 'Promotions',
route: '/release-control/promotions',
icon: 'rocket',
},
{
id: 'rc-runs',
label: 'Run Timeline',
route: '/release-control/runs',
icon: 'clock',
},
{
id: 'rc-bundles',
label: 'Bundles',
route: '/release-control/bundles',
icon: 'archive',
},
{
id: 'rc-deployments',
label: 'Deployments',
route: '/release-control/deployments',
icon: 'play',
},
{
id: 'rc-environments',
label: 'Regions & Environments',
route: '/release-control/regions',
icon: 'server',
},
{
id: 'rc-governance',
label: 'Governance',
route: '/release-control/governance',
icon: 'shield',
},
{
id: 'rc-hotfixes',
label: 'Hotfixes',
route: '/release-control/hotfixes',
icon: 'zap',
},
{
id: 'rc-setup',
label: 'Setup',
route: '/release-control/setup',
icon: 'settings',
},
{ id: 'rel-versions', label: 'Release Versions', route: '/releases/versions', icon: 'package' },
{ id: 'rel-runs', label: 'Release Runs', route: '/releases/runs', icon: 'clock' },
{ id: 'rel-approvals', label: 'Approvals Queue', route: '/releases/approvals', icon: 'check-circle', badge: 0 },
{ id: 'rel-hotfix', label: 'Hotfix Lane', route: '/releases/hotfix', icon: 'zap' },
{ id: 'rel-create', label: 'Create Version', route: '/releases/versions/new', icon: 'settings' },
],
},
// 3. Security & Risk
{
id: 'security-risk',
label: 'Security & Risk',
id: 'security',
label: 'Security',
icon: 'shield',
route: '/security-risk',
route: '/security',
children: [
{ id: 'sr-overview', label: 'Risk Overview', route: '/security-risk', icon: 'chart' },
{ id: 'sr-advisory-sources', label: 'Advisory Sources', route: '/security-risk/advisory-sources', icon: 'radio' },
{ id: 'sr-findings', label: 'Findings Explorer', route: '/security-risk/findings', icon: 'list' },
{ id: 'sr-vulnerabilities', label: 'Vulnerabilities Explorer', route: '/security-risk/vulnerabilities', icon: 'alert' },
{ id: 'sr-reachability', label: 'Reachability', route: '/security-risk/reachability', icon: 'git-branch' },
{ id: 'sr-sbom', label: 'SBOM Data: Graph', route: '/security-risk/sbom', icon: 'graph' },
{ id: 'sr-sbom-lake', label: 'SBOM Data: Lake', route: '/security-risk/sbom-lake', icon: 'database' },
{ id: 'sr-vex', label: 'VEX & Exceptions: VEX Hub', route: '/security-risk/vex', icon: 'file-check' },
{ id: 'sr-exceptions', label: 'VEX & Exceptions: Exceptions', route: '/security-risk/exceptions', icon: 'shield-off' },
{ id: 'sr-symbol-sources', label: 'Symbol Sources', route: '/security-risk/symbol-sources', icon: 'package' },
{ id: 'sr-symbol-marketplace', label: 'Symbol Marketplace', route: '/security-risk/symbol-marketplace', icon: 'shopping-bag' },
{ id: 'sr-remediation', label: 'Remediation', route: '/security-risk/remediation', icon: 'tool' },
{ id: 'sec-overview', label: 'Overview', route: '/security/overview', icon: 'chart' },
{ id: 'sec-triage', label: 'Triage', route: '/security/triage', icon: 'list' },
{ id: 'sec-advisories', label: 'Advisories & VEX', route: '/security/advisories-vex', icon: 'shield-off' },
{ id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data/lake', icon: 'graph' },
{ id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' },
],
},
// 4. Evidence and Audit
{
id: 'evidence-audit',
label: 'Evidence & Audit',
id: 'evidence',
label: 'Evidence',
icon: 'file-text',
route: '/evidence-audit',
route: '/evidence',
children: [
{ id: 'ea-home', label: 'Home', route: '/evidence-audit', icon: 'home' },
{ id: 'ea-packs', label: 'Evidence Packs', route: '/evidence-audit/packs', icon: 'archive' },
{ id: 'ea-bundles', label: 'Evidence Bundles', route: '/evidence-audit/bundles', icon: 'package' },
{ id: 'ea-export', label: 'Export Center', route: '/evidence-audit/evidence', icon: 'download' },
{ id: 'ea-proof-chains', label: 'Proof Chains', route: '/evidence-audit/proofs', icon: 'link' },
{ id: 'ea-audit', label: 'Audit Log', route: '/evidence-audit/audit-log', icon: 'book-open' },
{ id: 'ea-change-trace', label: 'Change Trace', route: '/evidence-audit/change-trace', icon: 'git-commit' },
{ id: 'ea-timeline', label: 'Timeline', route: '/evidence-audit/timeline', icon: 'clock' },
{ id: 'ea-replay', label: 'Replay & Verify', route: '/evidence-audit/replay', icon: 'refresh' },
{ id: 'ea-trust-signing', label: 'Trust & Signing', route: '/evidence-audit/trust-signing', icon: 'key' },
{ id: 'ev-overview', label: 'Overview', route: '/evidence/overview', icon: 'home' },
{ id: 'ev-search', label: 'Search', route: '/evidence/search', icon: 'search' },
{ id: 'ev-capsules', label: 'Capsules', route: '/evidence/capsules', icon: 'archive' },
{ id: 'ev-verify', label: 'Verify & Replay', route: '/evidence/verify-replay', icon: 'refresh' },
{ id: 'ev-exports', label: 'Exports', route: '/evidence/exports', icon: 'download' },
{ id: 'ev-audit', label: 'Audit Log', route: '/evidence/audit-log', icon: 'book-open' },
],
},
// 5. Integrations (already canonical root domain — no rename needed)
{
id: 'integrations',
label: 'Integrations',
icon: 'plug',
route: '/integrations',
id: 'topology',
label: 'Topology',
icon: 'server',
route: '/topology',
children: [
{ id: 'int-hub', label: 'Hub', route: '/integrations', icon: 'grid' },
{ id: 'int-scm', label: 'SCM', route: '/integrations/scm', icon: 'git-branch' },
{ id: 'int-ci', label: 'CI/CD', route: '/integrations/ci-cd', icon: 'play' },
{ id: 'int-registries', label: 'Registries', route: '/integrations/registries', icon: 'box' },
{ id: 'int-secrets', label: 'Secrets', route: '/integrations/secrets', icon: 'key' },
{ id: 'int-targets', label: 'Targets / Runtimes', route: '/integrations/targets', icon: 'package' },
{ id: 'int-feeds', label: 'Feeds', route: '/integrations/feeds', icon: 'rss' },
{ id: 'top-overview', label: 'Overview', route: '/topology/overview', icon: 'chart' },
{ id: 'top-regions', label: 'Regions & Environments', route: '/topology/regions', icon: 'globe' },
{ id: 'top-targets', label: 'Targets', route: '/topology/targets', icon: 'package' },
{ id: 'top-hosts', label: 'Hosts', route: '/topology/hosts', icon: 'hard-drive' },
{ id: 'top-agents', label: 'Agents', route: '/topology/agents', icon: 'cpu' },
{ id: 'top-paths', label: 'Promotion Paths', route: '/topology/promotion-paths', icon: 'git-merge' },
],
},
// 6. Platform Ops (formerly Operations + transition label during alias window)
{
id: 'platform-ops',
label: 'Platform Ops',
id: 'platform',
label: 'Platform',
icon: 'settings',
route: '/platform-ops',
route: '/platform',
children: [
{ id: 'ops-data-integrity', label: 'Data Integrity', route: '/platform-ops/data-integrity', icon: 'activity' },
{ id: 'ops-orchestrator', label: 'Orchestrator', route: '/platform-ops/orchestrator', icon: 'play' },
{ id: 'ops-health', label: 'Platform Health', route: '/platform-ops/health', icon: 'heart' },
{ id: 'ops-quotas', label: 'Quotas', route: '/platform-ops/quotas', icon: 'bar-chart' },
{ id: 'ops-feeds', label: 'Feeds & Mirrors', route: '/platform-ops/feeds', icon: 'rss' },
{ id: 'ops-doctor', label: 'Doctor', route: '/platform-ops/doctor', icon: 'activity' },
{ id: 'ops-agents', label: 'Agents', route: '/platform-ops/agents', icon: 'cpu' },
{ id: 'ops-offline', label: 'Offline Kit', route: '/platform-ops/offline-kit', icon: 'download-cloud' },
{ id: 'ops-federation', label: 'Federation', route: '/platform-ops/federation-telemetry', icon: 'globe' },
],
},
// 7. Administration (formerly Settings + Policy + Trust)
{
id: 'administration',
label: 'Administration',
icon: 'cog',
route: '/administration',
children: [
{ id: 'adm-identity', label: 'Identity & Access', route: '/administration/identity-access', icon: 'users' },
{ id: 'adm-tenant', label: 'Tenant & Branding', route: '/administration/tenant-branding', icon: 'palette' },
{ id: 'adm-notifications', label: 'Notifications', route: '/administration/notifications', icon: 'bell' },
{ id: 'adm-usage', label: 'Usage & Limits', route: '/administration/usage', icon: 'bar-chart' },
{ id: 'adm-policy', label: 'Policy Governance', route: '/administration/policy-governance', icon: 'book' },
{ id: 'adm-offline', label: 'Offline Settings', route: '/administration/offline', icon: 'download-cloud' },
{ id: 'adm-system', label: 'System', route: '/administration/system', icon: 'terminal' },
{ id: 'plat-home', label: 'Overview', route: '/platform', icon: 'home' },
{ id: 'plat-ops', label: 'Ops', route: '/platform/ops', icon: 'activity' },
{ id: 'plat-jobs', label: 'Jobs & Queues', route: '/platform/ops/jobs-queues', icon: 'play' },
{ id: 'plat-integrity', label: 'Data Integrity', route: '/platform/ops/data-integrity', icon: 'shield' },
{ id: 'plat-health', label: 'Health & SLO', route: '/platform/ops/health-slo', icon: 'heart' },
{ id: 'plat-feeds', label: 'Feeds & Airgap', route: '/platform/ops/feeds-airgap', icon: 'rss' },
{ id: 'plat-quotas', label: 'Quotas & Limits', route: '/platform/ops/quotas', icon: 'bar-chart' },
{ id: 'plat-diagnostics', label: 'Diagnostics', route: '/platform/ops/doctor', icon: 'alert' },
{ id: 'plat-integrations', label: 'Integrations', route: '/platform/integrations', icon: 'plug' },
{ id: 'plat-setup', label: 'Setup', route: '/platform/setup', icon: 'cog' },
],
},
];
@@ -567,7 +413,7 @@ export class AppSidebarComponent implements OnDestroy {
}
private withDynamicChildState(item: NavItem): NavItem {
if (item.id !== 'rc-approvals') {
if (item.id !== 'rel-approvals') {
return item;
}
@@ -610,29 +456,6 @@ export class AppSidebarComponent implements OnDestroy {
});
}
onSidebarMouseEnter(): void {
if (!this.collapsed || this.hoverExpanded()) return;
this.ngZone.runOutsideAngular(() => {
this.hoverTimer = setTimeout(() => {
this.ngZone.run(() => this.hoverExpanded.set(true));
}, 2000);
});
}
onSidebarMouseLeave(): void {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
this.hoverTimer = null;
}
this.hoverExpanded.set(false);
}
ngOnDestroy(): void {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
}
}
onGroupToggle(groupId: string, expanded: boolean): void {
this.expandedGroups.update((groups) => {
const newGroups = new Set(groups);

View File

@@ -5,11 +5,9 @@ import {
Output,
EventEmitter,
OnInit,
OnDestroy,
inject,
signal,
DestroyRef,
NgZone,
} from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';
@@ -18,37 +16,34 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
/**
* SidebarNavGroupComponent - Collapsible navigation group container.
* SidebarNavGroupComponent - Collapsible navigation group for dark sidebar.
*
* Renders a parent item that can expand/collapse to show children.
* When collapsed, hovering shows a flyout submenu.
* Always renders inline (no flyout). Groups expand/collapse on click.
*/
@Component({
selector: 'app-sidebar-nav-group',
standalone: true,
imports: [SidebarNavItemComponent],
template: `
<div class="nav-group" [class.nav-group--expanded]="expanded" [class.nav-group--collapsed]="collapsed">
<div class="nav-group" [class.nav-group--expanded]="expanded">
<!-- Group header -->
<button
type="button"
class="nav-group__header"
[class.nav-group__header--active]="isGroupActive()"
(click)="onToggle()"
(mouseenter)="onHeaderMouseEnter()"
(mouseleave)="onHeaderMouseLeave()"
[attr.aria-expanded]="expanded"
[attr.aria-controls]="'nav-group-' + label"
>
<span class="nav-group__icon" [attr.aria-hidden]="true">
@switch (icon) {
@case ('shield') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('file-text') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="14 2 14 8 20 8" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2"/>
@@ -56,67 +51,34 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
</svg>
}
@case ('settings') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('cog') {
<svg viewBox="0 0 24 24" width="20" height="20">
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('bar-chart') {
<svg viewBox="0 0 24 24" width="20" height="20">
<line x1="12" y1="20" x2="12" y2="10" stroke="currentColor" stroke-width="2"/>
<line x1="18" y1="20" x2="18" y2="4" stroke="currentColor" stroke-width="2"/>
<line x1="6" y1="20" x2="6" y2="16" stroke="currentColor" stroke-width="2"/>
</svg>
}
@default {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
}
</span>
@if (!collapsed) {
<span class="nav-group__label">{{ label }}</span>
<svg class="nav-group__chevron" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
<span class="nav-group__label">{{ label }}</span>
<svg class="nav-group__chevron" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<!-- Children (expanded, not collapsed) -->
@if (expanded && !collapsed && children && children.length > 0) {
<div class="nav-group__children" [class.nav-group__children--auto-opened]="autoExpanded()" [id]="'nav-group-' + label" role="group">
<!-- Children -->
@if (expanded && children && children.length > 0) {
<div class="nav-group__children" [id]="'nav-group-' + label" role="group">
@for (child of children; track child.id) {
<app-sidebar-nav-item
[label]="child.label"
[icon]="child.icon"
[route]="child.route"
[badge]="child.badge ?? null"
[collapsed]="false"
[isChild]="true"
></app-sidebar-nav-item>
}
</div>
}
<!-- Flyout submenu (collapsed only, appears on hover) -->
@if (collapsed && children && children.length > 0) {
<div class="nav-group__flyout">
<div class="nav-group__flyout-label">{{ label }}</div>
@for (child of children; track child.id) {
<app-sidebar-nav-item
[label]="child.label"
[icon]="child.icon"
[route]="child.route"
[badge]="child.badge ?? null"
[collapsed]="false"
[isChild]="true"
></app-sidebar-nav-item>
}
@@ -131,12 +93,8 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
.nav-group {
display: block;
position: static;
padding: 0;
margin-bottom: 0.125rem;
margin-top: 0.75rem;
width: 100%;
box-sizing: border-box;
margin-top: 0.875rem;
&:first-child {
margin-top: 0;
@@ -146,35 +104,35 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
.nav-group__header {
display: flex;
align-items: center;
gap: 0.625rem;
gap: 0.5rem;
width: 100%;
padding: 0.375rem 0.75rem;
padding: 0.375rem 0.625rem;
margin: 0;
border: none;
border-radius: 0;
background: transparent;
color: var(--color-text-muted);
color: var(--color-sidebar-group-text);
cursor: pointer;
text-align: left;
font-family: var(--font-family-mono);
font-size: 0.625rem;
font-size: 0.5625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
letter-spacing: 0.12em;
transition: color 0.15s;
&:hover {
color: var(--color-text-heading);
color: var(--color-sidebar-text);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline: 2px solid var(--color-sidebar-active-border);
outline-offset: -2px;
}
}
.nav-group__header--active {
color: var(--color-brand-primary);
color: var(--color-sidebar-active-text);
}
.nav-group__icon {
@@ -182,12 +140,13 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
width: 14px;
height: 14px;
opacity: 0.6;
svg {
width: 14px;
height: 14px;
width: 12px;
height: 12px;
}
}
@@ -200,7 +159,7 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
.nav-group__chevron {
flex-shrink: 0;
opacity: 0.4;
opacity: 0.3;
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
@@ -210,101 +169,38 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
.nav-group__children {
padding-left: 0;
margin-left: 0.5rem;
margin-left: 0.625rem;
margin-top: 0.125rem;
border-left: 1px solid var(--color-border-primary);
transition: border-color 0.2s ease;
border-left: 1px solid var(--color-sidebar-divider);
overflow: hidden;
animation: nav-children-reveal 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.nav-group__children--auto-opened {
animation: nav-children-slide-down 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes nav-children-slide-down {
@keyframes nav-children-reveal {
from {
max-height: 0;
opacity: 0;
transform: translateY(-4px);
}
to {
max-height: 500px;
max-height: 600px;
opacity: 1;
transform: translateY(0);
}
}
.nav-group--expanded .nav-group__children {
border-left-color: var(--color-brand-primary-20);
}
/* Collapsed state */
.nav-group--collapsed {
position: relative;
}
.nav-group--collapsed .nav-group__header {
justify-content: center;
padding: 0.75rem;
border-radius: var(--radius-lg);
&:hover {
background: var(--color-nav-hover);
}
}
/* Flyout submenu */
.nav-group__flyout {
display: none;
position: absolute;
left: 100%;
top: 0;
min-width: 200px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
box-shadow: 4px 4px 16px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.06);
border-radius: var(--radius-lg);
z-index: 1000;
padding: 0.375rem;
}
/* Invisible bridge to prevent hover gap */
.nav-group__flyout::before {
content: '';
position: absolute;
left: -10px;
top: 0;
bottom: 0;
width: 10px;
}
.nav-group--collapsed:hover .nav-group__flyout {
display: block;
}
.nav-group__flyout-label {
padding: 0.5rem 0.75rem 0.375rem;
font-size: 0.6875rem;
font-weight: var(--font-weight-bold);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border-primary);
margin-bottom: 0.25rem;
border-left-color: rgba(245, 166, 35, 0.15);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SidebarNavGroupComponent implements OnInit, OnDestroy {
export class SidebarNavGroupComponent implements OnInit {
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly ngZone = inject(NgZone);
@Input({ required: true }) label!: string;
@Input({ required: true }) icon!: string;
@Input({ required: true }) route!: string;
@Input() children: NavItem[] = [];
@Input() collapsed = false;
@Input() expanded = false;
@Output() expandedChange = new EventEmitter<boolean>();
@@ -312,10 +208,6 @@ export class SidebarNavGroupComponent implements OnInit, OnDestroy {
/** Reactive active state wired to Router navigation events */
readonly isGroupActive = signal(false);
/** Whether the group was auto-expanded by hover (for animation class) */
readonly autoExpanded = signal(false);
private hoverTimer: ReturnType<typeof setTimeout> | null = null;
ngOnInit(): void {
this.checkActive(this.router.url);
@@ -334,32 +226,6 @@ export class SidebarNavGroupComponent implements OnInit, OnDestroy {
}
onToggle(): void {
this.autoExpanded.set(false);
this.expandedChange.emit(!this.expanded);
}
onHeaderMouseEnter(): void {
if (this.expanded || this.collapsed) return;
this.ngZone.runOutsideAngular(() => {
this.hoverTimer = setTimeout(() => {
this.ngZone.run(() => {
this.autoExpanded.set(true);
this.expandedChange.emit(true);
});
}, 1000);
});
}
onHeaderMouseLeave(): void {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
this.hoverTimer = null;
}
}
ngOnDestroy(): void {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
}
}
}

View File

@@ -18,7 +18,8 @@ export interface NavItem {
}
/**
* SidebarNavItemComponent - Individual navigation item with icon, label, badge.
* SidebarNavItemComponent - Individual navigation item for dark sidebar.
* Renders icon + label + optional badge with amber active state.
*/
@Component({
selector: 'app-sidebar-nav-item',
@@ -27,79 +28,69 @@ export interface NavItem {
template: `
<a
class="nav-item"
[class.nav-item--collapsed]="collapsed"
[class.nav-item--child]="isChild"
[routerLink]="route"
routerLinkActive="nav-item--active"
[routerLinkActiveOptions]="{ exact: !isChild }"
[attr.title]="collapsed ? label : null"
>
<span class="nav-item__icon" [attr.aria-hidden]="true">
@switch (icon) {
<!-- Dashboard / Control Plane -->
@case ('dashboard') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<rect x="3" y="3" width="7" height="7" fill="none" stroke="currentColor" stroke-width="2"/>
<rect x="14" y="3" width="7" height="7" fill="none" stroke="currentColor" stroke-width="2"/>
<rect x="14" y="14" width="7" height="7" fill="none" stroke="currentColor" stroke-width="2"/>
<rect x="3" y="14" width="7" height="7" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
<!-- Package / Releases -->
@case ('package') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<line x1="16.5" y1="9.4" x2="7.5" y2="4.21" stroke="currentColor" stroke-width="2"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="22.08" x2="12" y2="12" stroke="currentColor" stroke-width="2"/>
</svg>
}
<!-- Check Circle / Approvals -->
@case ('check-circle') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="22 4 12 14.01 9 11.01" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
<!-- Shield / Security -->
@case ('shield') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
<!-- File Text / Evidence -->
@case ('file-text') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="14 2 14 8 20 8" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2"/>
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2"/>
</svg>
}
<!-- Settings / Operations -->
@case ('settings') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
<!-- Cog / Settings section -->
@case ('cog') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
<!-- Sub-item icons -->
@case ('chart') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<line x1="18" y1="20" x2="18" y2="10" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="20" x2="12" y2="4" stroke="currentColor" stroke-width="2"/>
<line x1="6" y1="20" x2="6" y2="14" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('list') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<line x1="8" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="18" x2="21" y2="18" stroke="currentColor" stroke-width="2"/>
@@ -109,14 +100,14 @@ export interface NavItem {
</svg>
}
@case ('alert') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="9" x2="12" y2="13" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="17" x2="12.01" y2="17" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('graph') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="18" cy="19" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
@@ -125,139 +116,151 @@ export interface NavItem {
</svg>
}
@case ('archive') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<polyline points="21 8 21 21 3 21 3 8" fill="none" stroke="currentColor" stroke-width="2"/>
<rect x="1" y="3" width="22" height="5" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="10" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('link') {
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('refresh') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<polyline points="23 4 23 10 17 10" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="1 20 1 14 7 14" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('download') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="7 10 12 15 17 10" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('play') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<polygon points="5 3 19 12 5 21 5 3" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('clock') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="12 6 12 12 16 14" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('bar-chart') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<line x1="12" y1="20" x2="12" y2="10" stroke="currentColor" stroke-width="2"/>
<line x1="18" y1="20" x2="18" y2="4" stroke="currentColor" stroke-width="2"/>
<line x1="6" y1="20" x2="6" y2="16" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('inbox') {
<svg viewBox="0 0 24 24" width="20" height="20">
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('activity') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('rss') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M4 11a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M4 4a16 16 0 0 1 16 16" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="5" cy="19" r="1" fill="currentColor"/>
</svg>
}
@case ('plug') {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="2" x2="12" y2="12" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('key') {
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" fill="none" stroke="currentColor" stroke-width="2"/>
@case ('home') {
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="9 22 9 12 15 12 15 22" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('book') {
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" fill="none" stroke="currentColor" stroke-width="2"/>
@case ('search') {
<svg viewBox="0 0 24 24" width="18" height="18">
<circle cx="11" cy="11" r="8" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="21" y1="21" x2="16.65" y2="16.65" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('bell') {
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0" fill="none" stroke="currentColor" stroke-width="2"/>
@case ('server') {
<svg viewBox="0 0 24 24" width="18" height="18">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="6" y1="6" x2="6.01" y2="6" stroke="currentColor" stroke-width="2"/>
<line x1="6" y1="18" x2="6.01" y2="18" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('users') {
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="9" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('file-check') {
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="14 2 14 8 20 8" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="9 15 11 17 15 13" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('x-circle') {
<svg viewBox="0 0 24 24" width="20" height="20">
@case ('globe') {
<svg viewBox="0 0 24 24" width="18" height="18">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" stroke-width="2"/>
<line x1="9" y1="9" x2="15" y2="15" stroke="currentColor" stroke-width="2"/>
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('rocket') {
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" fill="none" stroke="currentColor" stroke-width="2"/>
@case ('hard-drive') {
<svg viewBox="0 0 24 24" width="18" height="18">
<line x1="22" y1="12" x2="2" y2="12" stroke="currentColor" stroke-width="2"/>
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="6" y1="16" x2="6.01" y2="16" stroke="currentColor" stroke-width="2"/>
<line x1="10" y1="16" x2="10.01" y2="16" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('palette') {
<svg viewBox="0 0 24 24" width="20" height="20">
<circle cx="13.5" cy="6.5" r="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="17.5" cy="10.5" r="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="8.5" cy="7.5" r="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="6.5" cy="12.5" r="2" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" fill="none" stroke="currentColor" stroke-width="2"/>
@case ('cpu') {
<svg viewBox="0 0 24 24" width="18" height="18">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2"/>
<rect x="9" y="9" width="6" height="6" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="9" y1="1" x2="9" y2="4" stroke="currentColor" stroke-width="2"/>
<line x1="15" y1="1" x2="15" y2="4" stroke="currentColor" stroke-width="2"/>
<line x1="9" y1="20" x2="9" y2="23" stroke="currentColor" stroke-width="2"/>
<line x1="15" y1="20" x2="15" y2="23" stroke="currentColor" stroke-width="2"/>
<line x1="20" y1="9" x2="23" y2="9" stroke="currentColor" stroke-width="2"/>
<line x1="20" y1="14" x2="23" y2="14" stroke="currentColor" stroke-width="2"/>
<line x1="1" y1="9" x2="4" y2="9" stroke="currentColor" stroke-width="2"/>
<line x1="1" y1="14" x2="4" y2="14" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('git-merge') {
<svg viewBox="0 0 24 24" width="18" height="18">
<circle cx="18" cy="18" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="6" cy="6" r="3" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M6 21V9a9 9 0 0 0 9 9" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('heart') {
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('zap') {
<svg viewBox="0 0 24 24" width="18" height="18">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('book-open') {
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('shield-off') {
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M19.69 14a6.9 6.9 0 0 0 .31-2V5l-8-3-3.16 1.18" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M4.73 4.73L4 5v7c0 6 8 10 8 10a20.29 20.29 0 0 0 5.62-4.38" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="1" y1="1" x2="23" y2="23" stroke="currentColor" stroke-width="2"/>
</svg>
}
<!-- Default circle icon -->
@default {
<svg viewBox="0 0 24 24" width="20" height="20">
<svg viewBox="0 0 24 24" width="18" height="18">
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
}
</span>
@if (!collapsed) {
<span class="nav-item__label">{{ label }}</span>
}
<span class="nav-item__label">{{ label }}</span>
@if (badge !== null && badge > 0) {
<span class="nav-item__badge" [attr.aria-label]="badge + ' pending'">
{{ badge > 99 ? '99+' : badge }}
@@ -273,56 +276,51 @@ export interface NavItem {
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
gap: 0.625rem;
padding: 0.4375rem 0.75rem;
margin: 0 0.25rem 1px;
color: var(--color-text-secondary);
color: var(--color-sidebar-text);
text-decoration: none;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
font-weight: 450;
transition: all 0.12s;
border-radius: var(--radius-sm);
border-radius: 6px;
cursor: pointer;
position: relative;
min-width: 0;
border-left: 2px solid transparent;
&:hover {
background: var(--color-nav-hover);
color: var(--color-text-primary);
background: var(--color-sidebar-hover);
color: var(--color-sidebar-text-heading);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline: 2px solid var(--color-sidebar-active-border);
outline-offset: -2px;
}
}
.nav-item--active {
background: var(--color-brand-soft);
color: var(--color-brand-primary);
font-weight: var(--font-weight-semibold);
border-left-color: var(--color-brand-primary);
background: var(--color-sidebar-active-bg);
color: var(--color-sidebar-active-text);
font-weight: 600;
border-left-color: var(--color-sidebar-active-border);
.nav-item__icon {
color: var(--color-brand-primary);
color: var(--color-sidebar-active-text);
}
&:hover {
background: var(--color-brand-primary-10);
background: var(--color-sidebar-active-bg);
}
}
.nav-item--collapsed {
justify-content: center;
padding: 0.75rem;
}
.nav-item--child {
font-size: 0.8125rem;
border-radius: 0;
margin: 0;
padding: 0.4375rem 0.75rem 0.4375rem 0.5rem;
padding: 0.375rem 0.75rem 0.375rem 0.5rem;
border-left: none;
.nav-item__icon {
@@ -338,8 +336,8 @@ export interface NavItem {
.nav-item--child.nav-item--active {
background: transparent;
color: var(--color-brand-primary);
font-weight: var(--font-weight-semibold);
color: var(--color-sidebar-active-text);
font-weight: 600;
border-left: none;
}
@@ -348,8 +346,13 @@ export interface NavItem {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
width: 18px;
height: 18px;
opacity: 0.7;
}
.nav-item--active .nav-item__icon {
opacity: 1;
}
.nav-item__label {
@@ -365,24 +368,15 @@ export interface NavItem {
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--color-brand-primary);
color: #fff;
font-size: 0.6875rem;
font-weight: var(--font-weight-bold);
border-radius: var(--radius-full);
background: var(--color-sidebar-badge-bg);
color: var(--color-sidebar-badge-text);
font-size: 0.625rem;
font-weight: 700;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-item--collapsed .nav-item__badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 14px;
height: 14px;
font-size: 0.5625rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -391,6 +385,5 @@ export class SidebarNavItemComponent {
@Input({ required: true }) icon!: string;
@Input({ required: true }) route!: string;
@Input() badge: number | null = null;
@Input() collapsed = false;
@Input() isChild = false;
}

View File

@@ -1,10 +1,11 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { OfflineStatusChipComponent } from './offline-status-chip.component';
import { FeedSnapshotChipComponent } from './feed-snapshot-chip.component';
import { PolicyBaselineChipComponent } from './policy-baseline-chip.component';
import { EvidenceModeChipComponent } from './evidence-mode-chip.component';
import { PlatformContextStore } from '../../core/context/platform-context.store';
/**
* ContextChipsComponent - Container for global context chips in the topbar.
@@ -19,27 +20,177 @@ import { EvidenceModeChipComponent } from './evidence-mode-chip.component';
selector: 'app-context-chips',
standalone: true,
imports: [
FormsModule,
OfflineStatusChipComponent,
FeedSnapshotChipComponent,
PolicyBaselineChipComponent,
EvidenceModeChipComponent
],
template: `
<div class="context-chips" role="status" aria-label="System status indicators">
<app-offline-status-chip></app-offline-status-chip>
<app-feed-snapshot-chip></app-feed-snapshot-chip>
<app-policy-baseline-chip></app-policy-baseline-chip>
<app-evidence-mode-chip></app-evidence-mode-chip>
<div class="context-chips" role="status" aria-label="Global context controls and system status indicators">
<div class="context-chips__selectors">
<div class="context-chips__select-wrap">
<label class="context-chips__label" for="global-region-select">Region</label>
<select
id="global-region-select"
class="context-chips__select context-chips__select--multi"
multiple
size="2"
[disabled]="context.loading()"
(change)="onRegionsChange($event)"
>
@for (region of context.regions(); track region.regionId) {
<option [value]="region.regionId" [selected]="context.selectedRegions().includes(region.regionId)">
{{ region.displayName }}
</option>
}
</select>
</div>
<div class="context-chips__select-wrap">
<label class="context-chips__label" for="global-environment-select">Environment</label>
<select
id="global-environment-select"
class="context-chips__select context-chips__select--multi"
multiple
size="2"
[disabled]="context.loading()"
(change)="onEnvironmentsChange($event)"
>
@for (environment of context.environments(); track environment.environmentId) {
<option
[value]="environment.environmentId"
[selected]="context.selectedEnvironments().includes(environment.environmentId)"
>
{{ environment.displayName }}
</option>
}
</select>
</div>
<div class="context-chips__select-wrap">
<label class="context-chips__label" for="global-time-window-select">Time Window</label>
<select
id="global-time-window-select"
class="context-chips__select"
[ngModel]="context.timeWindow()"
(ngModelChange)="context.setTimeWindow($event)"
>
<option value="1h">Last 1 hour</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
</select>
</div>
</div>
<div class="context-chips__summary">
<span class="context-chips__summary-item">{{ context.regionSummary() }}</span>
<span class="context-chips__summary-item">{{ context.environmentSummary() }}</span>
</div>
<div class="context-chips__status">
<app-offline-status-chip></app-offline-status-chip>
<app-feed-snapshot-chip></app-feed-snapshot-chip>
<app-policy-baseline-chip></app-policy-baseline-chip>
<app-evidence-mode-chip></app-evidence-mode-chip>
</div>
@if (context.error()) {
<span class="context-chips__error">{{ context.error() }}</span>
}
</div>
`,
styles: [`
.context-chips {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex-wrap: nowrap;
}
.context-chips__selectors {
display: flex;
align-items: end;
gap: 0.5rem;
}
.context-chips__select-wrap {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.context-chips__label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
font-weight: 600;
}
.context-chips__select {
min-width: 130px;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-size: 0.75rem;
padding: 0.2rem 0.35rem;
}
.context-chips__select--multi {
min-height: 2.5rem;
}
.context-chips__summary {
display: flex;
align-items: center;
gap: 0.25rem;
}
.context-chips__summary-item {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.45rem;
border-radius: var(--radius-full);
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
font-size: 0.6875rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.context-chips__status {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: nowrap;
}
.context-chips__error {
font-size: 0.6875rem;
color: var(--color-status-error-text);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContextChipsComponent {}
export class ContextChipsComponent {
readonly context = inject(PlatformContextStore);
constructor() {
this.context.initialize();
}
onRegionsChange(event: Event): void {
const target = event.target as HTMLSelectElement;
const selected = [...target.selectedOptions].map((option) => option.value);
this.context.setRegions(selected);
}
onEnvironmentsChange(event: Event): void {
const target = event.target as HTMLSelectElement;
const selected = [...target.selectedOptions].map((option) => option.value);
this.context.setEnvironments(selected);
}
}

View File

@@ -464,7 +464,7 @@ export class GlobalSearchComponent {
type: 'environment',
label: 'Production',
sublabel: '5 targets',
route: '/environments/prod',
route: '/topology/regions',
});
}

View File

@@ -0,0 +1,155 @@
import { Routes } from '@angular/router';
export const EVIDENCE_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'overview',
},
{
path: 'overview',
title: 'Evidence Overview',
data: { breadcrumb: 'Overview' },
loadComponent: () =>
import('../features/evidence-audit/evidence-audit-overview.component').then(
(m) => m.EvidenceAuditOverviewComponent,
),
},
{
path: 'search',
title: 'Evidence Search',
data: { breadcrumb: 'Search' },
loadComponent: () =>
import('../features/evidence-pack/evidence-pack-list.component').then(
(m) => m.EvidencePackListComponent,
),
},
{
path: 'capsules',
title: 'Decision Capsules',
data: { breadcrumb: 'Capsules' },
loadComponent: () =>
import('../features/evidence-pack/evidence-pack-list.component').then(
(m) => m.EvidencePackListComponent,
),
},
{
path: 'capsules/:capsuleId',
title: 'Decision Capsule',
data: { breadcrumb: 'Capsule' },
loadComponent: () =>
import('../features/evidence-pack/evidence-pack-viewer.component').then(
(m) => m.EvidencePackViewerComponent,
),
},
{
path: 'exports',
title: 'Evidence Exports',
data: { breadcrumb: 'Exports' },
loadChildren: () =>
import('../features/evidence-export/evidence-export.routes').then((m) => m.evidenceExportRoutes),
},
{
path: 'verification',
pathMatch: 'full',
redirectTo: 'verification/replay',
},
{
path: 'verification/replay',
title: 'Replay & Determinism',
data: { breadcrumb: 'Verify & Replay' },
loadComponent: () =>
import('../features/evidence-export/replay-controls.component').then(
(m) => m.ReplayControlsComponent,
),
},
{
path: 'verification/proofs',
title: 'Proof Explorer',
data: { breadcrumb: 'Proof Explorer' },
loadComponent: () =>
import('../features/proof-chain/proof-chain.component').then((m) => m.ProofChainComponent),
},
{
path: 'verification/offline',
title: 'Offline Verify',
data: { breadcrumb: 'Offline Verify' },
loadComponent: () =>
import('../features/settings/trust/trust-settings-page.component').then(
(m) => m.TrustSettingsPageComponent,
),
},
{
path: 'verify-replay',
pathMatch: 'full',
redirectTo: 'verification/replay',
},
{
path: 'audit-log',
title: 'Audit Log',
data: { breadcrumb: 'Audit Log' },
loadChildren: () =>
import('../features/audit-log/audit-log.routes').then((m) => m.auditLogRoutes),
},
{
path: 'trust-status',
title: 'Trust Status',
data: { breadcrumb: 'Trust Status' },
loadComponent: () =>
import('../features/settings/trust/trust-settings-page.component').then(
(m) => m.TrustSettingsPageComponent,
),
},
// Legacy aliases.
{
path: 'packs',
pathMatch: 'full',
redirectTo: 'capsules',
},
{
path: 'packs/:packId',
pathMatch: 'full',
redirectTo: 'capsules/:packId',
},
{
path: 'bundles',
pathMatch: 'full',
redirectTo: 'exports',
},
{
path: 'evidence',
pathMatch: 'full',
redirectTo: 'exports',
},
{
path: 'proofs',
pathMatch: 'full',
redirectTo: 'verification/proofs',
},
{
path: 'proofs/:subjectDigest',
pathMatch: 'full',
redirectTo: 'verification/proofs',
},
{
path: 'replay',
pathMatch: 'full',
redirectTo: 'verification/replay',
},
{
path: 'timeline',
pathMatch: 'full',
redirectTo: 'audit-log',
},
{
path: 'change-trace',
pathMatch: 'full',
redirectTo: 'audit-log',
},
{
path: 'trust-signing',
pathMatch: 'full',
redirectTo: '/platform/setup/trust-signing',
},
];

View File

@@ -52,47 +52,99 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// ===========================================
// Home & Dashboard
// ===========================================
{ path: 'dashboard/sources', redirectTo: '/platform-ops/feeds', pathMatch: 'full' },
{ path: 'dashboard/sources', redirectTo: '/platform/ops/feeds-airgap', pathMatch: 'full' },
{ path: 'home', redirectTo: '/', pathMatch: 'full' },
// ===========================================
// Pack 22 root migration aliases
// ===========================================
{ path: 'release-control', redirectTo: '/releases', pathMatch: 'full' },
{ path: 'release-control/releases', redirectTo: '/releases', pathMatch: 'full' },
{ path: 'release-control/releases/:id', redirectTo: '/releases/:id', pathMatch: 'full' },
{ path: 'release-control/approvals', redirectTo: '/releases/approvals', pathMatch: 'full' },
{ path: 'release-control/approvals/:id', redirectTo: '/releases/approvals/:id', pathMatch: 'full' },
{ path: 'release-control/runs', redirectTo: '/releases/runs', pathMatch: 'full' },
{ path: 'release-control/deployments', redirectTo: '/releases/runs', pathMatch: 'full' },
{ path: 'release-control/promotions', redirectTo: '/releases/runs', pathMatch: 'full' },
{ path: 'release-control/hotfixes', redirectTo: '/releases/hotfix', pathMatch: 'full' },
{ path: 'release-control/regions', redirectTo: '/topology/regions', pathMatch: 'full' },
{ path: 'release-control/regions/:region', redirectTo: '/topology/regions', pathMatch: 'full' },
{ path: 'release-control/regions/:region/environments/:env', redirectTo: '/topology/environments', pathMatch: 'full' },
{ path: 'release-control/setup', redirectTo: '/platform/setup', pathMatch: 'full' },
{ path: 'release-control/setup/environments-paths', redirectTo: '/topology/promotion-paths', pathMatch: 'full' },
{ path: 'release-control/setup/targets-agents', redirectTo: '/topology/targets', pathMatch: 'full' },
{ path: 'release-control/setup/workflows', redirectTo: '/platform/setup/workflows-gates', pathMatch: 'full' },
{ path: 'release-control/governance', redirectTo: '/platform/setup/workflows-gates', pathMatch: 'full' },
{ path: 'security-risk', redirectTo: '/security', pathMatch: 'full' },
{ path: 'security-risk/findings', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'security-risk/findings/:findingId', redirectTo: '/security/triage/:findingId', pathMatch: 'full' },
{ path: 'security-risk/vulnerabilities', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'security-risk/vulnerabilities/:vulnId', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'security-risk/sbom', redirectTo: '/security/supply-chain-data/graph', pathMatch: 'full' },
{ path: 'security-risk/sbom-lake', redirectTo: '/security/supply-chain-data/lake', pathMatch: 'full' },
{ path: 'security-risk/vex', redirectTo: '/security/advisories-vex', pathMatch: 'full' },
{ path: 'security-risk/exceptions', redirectTo: '/security/advisories-vex', pathMatch: 'full' },
{ path: 'security-risk/advisory-sources', redirectTo: '/platform/integrations/feeds', pathMatch: 'full' },
{ path: 'evidence-audit', redirectTo: '/evidence/overview', pathMatch: 'full' },
{ path: 'evidence-audit/packs', redirectTo: '/evidence/capsules', pathMatch: 'full' },
{ path: 'evidence-audit/packs/:packId', redirectTo: '/evidence/capsules/:packId', pathMatch: 'full' },
{ path: 'evidence-audit/bundles', redirectTo: '/evidence/exports', pathMatch: 'full' },
{ path: 'evidence-audit/evidence', redirectTo: '/evidence/exports', pathMatch: 'full' },
{ path: 'evidence-audit/proofs', redirectTo: '/evidence/verification/proofs', pathMatch: 'full' },
{ path: 'evidence-audit/replay', redirectTo: '/evidence/verification/replay', pathMatch: 'full' },
{ path: 'evidence-audit/audit-log', redirectTo: '/evidence/audit-log', pathMatch: 'full' },
{ path: 'evidence-audit/change-trace', redirectTo: '/evidence/audit-log', pathMatch: 'full' },
{ path: 'evidence-audit/timeline', redirectTo: '/evidence/audit-log', pathMatch: 'full' },
{ path: 'evidence-audit/trust-signing', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' },
{ path: 'platform-ops', redirectTo: '/platform/ops', pathMatch: 'full' },
{ path: 'platform-ops/data-integrity', redirectTo: '/platform/ops/data-integrity', pathMatch: 'full' },
{ path: 'platform-ops/orchestrator', redirectTo: '/platform/ops/orchestrator', pathMatch: 'full' },
{ path: 'platform-ops/orchestrator/jobs', redirectTo: '/platform/ops/jobs-queues', pathMatch: 'full' },
{ path: 'platform-ops/health', redirectTo: '/platform/ops/health-slo', pathMatch: 'full' },
{ path: 'platform-ops/quotas', redirectTo: '/platform/ops/quotas', pathMatch: 'full' },
{ path: 'platform-ops/feeds', redirectTo: '/platform/ops/feeds-airgap', pathMatch: 'full' },
{ path: 'platform-ops/offline-kit', redirectTo: '/platform/ops/offline-kit', pathMatch: 'full' },
{ path: 'platform-ops/doctor', redirectTo: '/platform/ops/doctor', pathMatch: 'full' },
{ path: 'platform-ops/aoc', redirectTo: '/platform/ops/aoc', pathMatch: 'full' },
{ path: 'platform-ops/agents', redirectTo: '/topology/agents', pathMatch: 'full' },
// ===========================================
// Analyze -> Security & Risk
// ===========================================
{ path: 'findings', redirectTo: '/security-risk/findings', pathMatch: 'full' },
{ path: 'findings/:scanId', redirectTo: '/security-risk/scans/:scanId', pathMatch: 'full' },
{ path: 'security/findings', redirectTo: '/security-risk/findings', pathMatch: 'full' },
{ path: 'security/findings/:findingId', redirectTo: '/security-risk/findings/:findingId', pathMatch: 'full' },
{ path: 'security/vulnerabilities', redirectTo: '/security-risk/vulnerabilities', pathMatch: 'full' },
{ path: 'security/vulnerabilities/:vulnId', redirectTo: '/security-risk/vulnerabilities/:vulnId', pathMatch: 'full' },
{ path: 'security/sbom', redirectTo: '/security-risk/sbom', pathMatch: 'full' },
{ path: 'security/vex', redirectTo: '/security-risk/vex', pathMatch: 'full' },
{ path: 'security/exceptions', redirectTo: '/security-risk/exceptions', pathMatch: 'full' },
{ path: 'security/advisory-sources', redirectTo: '/security-risk/advisory-sources', pathMatch: 'full' },
{ path: 'scans/:scanId', redirectTo: '/security-risk/scans/:scanId', pathMatch: 'full' },
{ path: 'vulnerabilities', redirectTo: '/security-risk/vulnerabilities', pathMatch: 'full' },
{ path: 'vulnerabilities/:vulnId', redirectTo: '/security-risk/vulnerabilities/:vulnId', pathMatch: 'full' },
{ path: 'graph', redirectTo: '/security-risk/sbom/graph', pathMatch: 'full' },
{ path: 'lineage', redirectTo: '/security-risk/lineage', pathMatch: 'full' },
{ path: 'lineage/:artifact/compare', redirectTo: '/security-risk/lineage/:artifact/compare', pathMatch: 'full' },
{ path: 'lineage/compare', redirectTo: '/security-risk/lineage/compare', pathMatch: 'full' },
{ path: 'compare/:currentId', redirectTo: '/security-risk/lineage/compare/:currentId', pathMatch: 'full' },
{ path: 'reachability', redirectTo: '/security-risk/reachability', pathMatch: 'full' },
{ path: 'analyze/unknowns', redirectTo: '/security-risk/unknowns', pathMatch: 'full' },
{ path: 'analyze/patch-map', redirectTo: '/security-risk/patch-map', pathMatch: 'full' },
{ path: 'analytics', redirectTo: '/security-risk/sbom-lake', pathMatch: 'full' },
{ path: 'analytics/sbom-lake', redirectTo: '/security-risk/sbom-lake', pathMatch: 'full' },
{ path: 'cvss/receipts/:receiptId', redirectTo: '/evidence-audit/receipts/cvss/:receiptId', pathMatch: 'full' },
{ path: 'findings', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'findings/:scanId', redirectTo: '/security/scans/:scanId', pathMatch: 'full' },
{ path: 'security/sbom', redirectTo: '/security/supply-chain-data/graph', pathMatch: 'full' },
{ path: 'security/vex', redirectTo: '/security/advisories-vex', pathMatch: 'full' },
{ path: 'security/exceptions', redirectTo: '/security/advisories-vex', pathMatch: 'full' },
{ path: 'security/advisory-sources', redirectTo: '/platform/integrations/feeds', pathMatch: 'full' },
{ path: 'scans/:scanId', redirectTo: '/security/scans/:scanId', pathMatch: 'full' },
{ path: 'vulnerabilities', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'vulnerabilities/:vulnId', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'graph', redirectTo: '/security/supply-chain-data/graph', pathMatch: 'full' },
{ path: 'lineage', redirectTo: '/security/lineage', pathMatch: 'full' },
{ path: 'lineage/:artifact/compare', redirectTo: '/security/lineage/:artifact/compare', pathMatch: 'full' },
{ path: 'lineage/compare', redirectTo: '/security/lineage/compare', pathMatch: 'full' },
{ path: 'compare/:currentId', redirectTo: '/security/lineage/compare/:currentId', pathMatch: 'full' },
{ path: 'reachability', redirectTo: '/security/findings', pathMatch: 'full' },
{ path: 'analyze/unknowns', redirectTo: '/security/unknowns', pathMatch: 'full' },
{ path: 'analyze/patch-map', redirectTo: '/security/patch-map', pathMatch: 'full' },
{ path: 'analytics', redirectTo: '/security/supply-chain-data/lake', pathMatch: 'full' },
{ path: 'analytics/sbom-lake', redirectTo: '/security/supply-chain-data/lake', pathMatch: 'full' },
{ path: 'cvss/receipts/:receiptId', redirectTo: '/evidence/receipts/cvss/:receiptId', pathMatch: 'full' },
// ===========================================
// Triage -> Security & Risk + Administration
// ===========================================
{ path: 'triage/artifacts', redirectTo: '/security-risk/artifacts', pathMatch: 'full' },
{ path: 'triage/artifacts/:artifactId', redirectTo: '/security-risk/artifacts/:artifactId', pathMatch: 'full' },
{ path: 'triage/audit-bundles', redirectTo: '/evidence-audit', pathMatch: 'full' },
{ path: 'triage/audit-bundles/new', redirectTo: '/evidence-audit', pathMatch: 'full' },
{ path: 'triage/artifacts', redirectTo: '/security/artifacts', pathMatch: 'full' },
{ path: 'triage/artifacts/:artifactId', redirectTo: '/security/artifacts/:artifactId', pathMatch: 'full' },
{ path: 'triage/audit-bundles', redirectTo: '/evidence/overview', pathMatch: 'full' },
{ path: 'triage/audit-bundles/new', redirectTo: '/evidence/overview', pathMatch: 'full' },
{ path: 'exceptions', redirectTo: '/administration/policy/exceptions', pathMatch: 'full' },
{ path: 'exceptions/:id', redirectTo: '/administration/policy/exceptions/:id', pathMatch: 'full' },
{ path: 'risk', redirectTo: '/security-risk/risk', pathMatch: 'full' },
{ path: 'risk', redirectTo: '/security/risk', pathMatch: 'full' },
// ===========================================
// Policy Studio -> Administration
@@ -104,40 +156,40 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// ===========================================
// VEX Hub -> Security & Risk
// ===========================================
{ path: 'admin/vex-hub', redirectTo: '/security-risk/vex', pathMatch: 'full' },
{ path: 'admin/vex-hub/search', redirectTo: '/security-risk/vex/search', pathMatch: 'full' },
{ path: 'admin/vex-hub/search/detail/:id', redirectTo: '/security-risk/vex/search/detail/:id', pathMatch: 'full' },
{ path: 'admin/vex-hub/stats', redirectTo: '/security-risk/vex/stats', pathMatch: 'full' },
{ path: 'admin/vex-hub/consensus', redirectTo: '/security-risk/vex/consensus', pathMatch: 'full' },
{ path: 'admin/vex-hub/explorer', redirectTo: '/security-risk/vex/explorer', pathMatch: 'full' },
{ path: 'admin/vex-hub/:page', redirectTo: '/security-risk/vex/:page', pathMatch: 'full' },
{ path: 'admin/vex-hub', redirectTo: '/security/vex', pathMatch: 'full' },
{ path: 'admin/vex-hub/search', redirectTo: '/security/vex/search', pathMatch: 'full' },
{ path: 'admin/vex-hub/search/detail/:id', redirectTo: '/security/vex/search/detail/:id', pathMatch: 'full' },
{ path: 'admin/vex-hub/stats', redirectTo: '/security/vex/stats', pathMatch: 'full' },
{ path: 'admin/vex-hub/consensus', redirectTo: '/security/vex/consensus', pathMatch: 'full' },
{ path: 'admin/vex-hub/explorer', redirectTo: '/security/vex/explorer', pathMatch: 'full' },
{ path: 'admin/vex-hub/:page', redirectTo: '/security/vex/:page', pathMatch: 'full' },
// ===========================================
// Orchestrator -> Platform Ops
// ===========================================
{ path: 'orchestrator', redirectTo: '/platform-ops/orchestrator', pathMatch: 'full' },
{ path: 'orchestrator/:page', redirectTo: '/platform-ops/orchestrator/:page', pathMatch: 'full' },
{ path: 'scheduler/:page', redirectTo: '/platform-ops/scheduler/:page', pathMatch: 'full' },
{ path: 'orchestrator', redirectTo: '/platform/ops/orchestrator', pathMatch: 'full' },
{ path: 'orchestrator/:page', redirectTo: '/platform/ops/orchestrator', pathMatch: 'full' },
{ path: 'scheduler/:page', redirectTo: '/platform/ops/scheduler/:page', pathMatch: 'full' },
// ===========================================
// Ops -> Platform Ops
// ===========================================
{ path: 'ops/quotas', redirectTo: '/platform-ops/quotas', pathMatch: 'full' },
{ path: 'ops/quotas/:page', redirectTo: '/platform-ops/quotas/:page', pathMatch: 'full' },
{ path: 'ops/orchestrator/dead-letter', redirectTo: '/platform-ops/dead-letter', pathMatch: 'full' },
{ path: 'ops/orchestrator/slo', redirectTo: '/platform-ops/slo', pathMatch: 'full' },
{ path: 'ops/health', redirectTo: '/platform-ops/health', pathMatch: 'full' },
{ path: 'ops/feeds', redirectTo: '/platform-ops/feeds', pathMatch: 'full' },
{ path: 'ops/feeds/:page', redirectTo: '/platform-ops/feeds/:page', pathMatch: 'full' },
{ path: 'ops/offline-kit', redirectTo: '/platform-ops/offline-kit', pathMatch: 'full' },
{ path: 'ops/aoc', redirectTo: '/platform-ops/aoc', pathMatch: 'full' },
{ path: 'ops/doctor', redirectTo: '/platform-ops/doctor', pathMatch: 'full' },
{ path: 'ops/quotas', redirectTo: '/platform/ops/quotas', pathMatch: 'full' },
{ path: 'ops/quotas/:page', redirectTo: '/platform/ops/quotas', pathMatch: 'full' },
{ path: 'ops/orchestrator/dead-letter', redirectTo: '/platform/ops/dead-letter', pathMatch: 'full' },
{ path: 'ops/orchestrator/slo', redirectTo: '/platform/ops/health-slo', pathMatch: 'full' },
{ path: 'ops/health', redirectTo: '/platform/ops/health-slo', pathMatch: 'full' },
{ path: 'ops/feeds', redirectTo: '/platform/ops/feeds-airgap', pathMatch: 'full' },
{ path: 'ops/feeds/:page', redirectTo: '/platform/ops/feeds-airgap', pathMatch: 'full' },
{ path: 'ops/offline-kit', redirectTo: '/platform/ops/offline-kit', pathMatch: 'full' },
{ path: 'ops/aoc', redirectTo: '/platform/ops/aoc', pathMatch: 'full' },
{ path: 'ops/doctor', redirectTo: '/platform/ops/doctor', pathMatch: 'full' },
// ===========================================
// Console -> Administration
// ===========================================
{ path: 'console/profile', redirectTo: '/administration/profile', pathMatch: 'full' },
{ path: 'console/status', redirectTo: '/platform-ops/status', pathMatch: 'full' },
{ path: 'console/status', redirectTo: '/platform/ops/status', pathMatch: 'full' },
{ path: 'console/configuration', redirectTo: '/administration/configuration-pane', pathMatch: 'full' },
{ path: 'console/admin/tenants', redirectTo: '/administration/admin/tenants', pathMatch: 'full' },
{ path: 'console/admin/users', redirectTo: '/administration/admin/users', pathMatch: 'full' },
@@ -150,14 +202,14 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// ===========================================
// Admin -> Administration
// ===========================================
{ path: 'admin/trust', redirectTo: '/administration/trust-signing', pathMatch: 'full' },
{ path: 'admin/trust/:page', redirectTo: '/administration/trust-signing/:page', pathMatch: 'full' },
{ path: 'admin/registries', redirectTo: '/integrations/registries', pathMatch: 'full' },
{ path: 'admin/issuers', redirectTo: '/administration/trust-signing/issuers', pathMatch: 'full' },
{ path: 'admin/trust', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' },
{ path: 'admin/trust/:page', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' },
{ path: 'admin/registries', redirectTo: '/platform/integrations/registries', pathMatch: 'full' },
{ path: 'admin/issuers', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' },
{ path: 'admin/notifications', redirectTo: '/administration/notifications', pathMatch: 'full' },
{ path: 'admin/audit', redirectTo: '/evidence-audit/audit', pathMatch: 'full' },
{ path: 'admin/audit', redirectTo: '/evidence/audit-log', pathMatch: 'full' },
{ path: 'admin/policy/governance', redirectTo: '/administration/policy/governance', pathMatch: 'full' },
{ path: 'concelier/trivy-db-settings', redirectTo: '/administration/security-data/trivy', pathMatch: 'full' },
{ path: 'concelier/trivy-db-settings', redirectTo: '/platform/setup/feed-policy', pathMatch: 'full' },
// ===========================================
// Integrations -> Integrations
@@ -167,52 +219,48 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// ===========================================
// Settings -> canonical v2 domains
// ===========================================
{ path: 'settings/integrations', redirectTo: '/integrations', pathMatch: 'full' },
{ path: 'settings/integrations/:id', redirectTo: '/integrations/:id', pathMatch: 'full' },
{ path: 'settings/release-control', redirectTo: '/release-control/setup', pathMatch: 'full' },
{ path: 'settings/trust', redirectTo: '/evidence-audit/trust-signing', pathMatch: 'full' },
{ path: 'settings/trust/:page', redirectTo: '/evidence-audit/trust-signing/:page', pathMatch: 'full' },
{ path: 'settings/policy', redirectTo: '/release-control/governance', pathMatch: 'full' },
{ path: 'settings/integrations', redirectTo: '/platform/integrations', pathMatch: 'full' },
{ path: 'settings/integrations/:id', redirectTo: '/platform/integrations/:id', pathMatch: 'full' },
{ path: 'settings/release-control', redirectTo: '/platform/setup', pathMatch: 'full' },
{ path: 'settings/trust', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' },
{ path: 'settings/trust/:page', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' },
{ path: 'settings/policy', redirectTo: '/administration/policy-governance', pathMatch: 'full' },
{ path: 'settings/admin', redirectTo: '/administration/identity-access', pathMatch: 'full' },
{ path: 'settings/branding', redirectTo: '/administration/tenant-branding', pathMatch: 'full' },
{ path: 'settings/notifications', redirectTo: '/administration/notifications', pathMatch: 'full' },
{ path: 'settings/usage', redirectTo: '/administration/usage', pathMatch: 'full' },
{ path: 'settings/system', redirectTo: '/administration/system', pathMatch: 'full' },
{ path: 'settings/offline', redirectTo: '/administration/offline', pathMatch: 'full' },
{ path: 'settings/security-data', redirectTo: '/integrations/feeds', pathMatch: 'full' },
{ path: 'settings/security-data', redirectTo: '/platform/integrations/feeds', pathMatch: 'full' },
// ===========================================
// Release Orchestrator -> Release Control
// ===========================================
{ path: 'release-orchestrator', redirectTo: '/', pathMatch: 'full' },
{ path: 'release-orchestrator/environments', redirectTo: '/release-control/regions', pathMatch: 'full' },
{ path: 'release-orchestrator/releases', redirectTo: '/release-control/releases', pathMatch: 'full' },
{ path: 'release-orchestrator/approvals', redirectTo: '/release-control/approvals', pathMatch: 'full' },
{ path: 'release-orchestrator/deployments', redirectTo: '/release-control/deployments', pathMatch: 'full' },
{ path: 'release-orchestrator/workflows', redirectTo: '/release-control/setup/workflows', pathMatch: 'full' },
{ path: 'release-orchestrator/evidence', redirectTo: '/evidence-audit', pathMatch: 'full' },
{ path: 'release-orchestrator/environments', redirectTo: '/topology/regions', pathMatch: 'full' },
{ path: 'release-orchestrator/releases', redirectTo: '/releases', pathMatch: 'full' },
{ path: 'release-orchestrator/approvals', redirectTo: '/releases/approvals', pathMatch: 'full' },
{ path: 'release-orchestrator/deployments', redirectTo: '/releases/runs', pathMatch: 'full' },
{ path: 'release-orchestrator/workflows', redirectTo: '/platform/setup/workflows-gates', pathMatch: 'full' },
{ path: 'release-orchestrator/evidence', redirectTo: '/evidence/overview', pathMatch: 'full' },
// ===========================================
// Evidence -> Evidence & Audit
// ===========================================
{ path: 'evidence/packs', redirectTo: '/evidence-audit/packs', pathMatch: 'full' },
{ path: 'evidence/bundles', redirectTo: '/evidence-audit/bundles', pathMatch: 'full' },
{ path: 'evidence/export', redirectTo: '/evidence-audit/evidence/export', pathMatch: 'full' },
{ path: 'evidence/replay', redirectTo: '/evidence-audit/replay', pathMatch: 'full' },
{ path: 'evidence/proof-chains', redirectTo: '/evidence-audit/proofs', pathMatch: 'full' },
{ path: 'evidence/audit-log', redirectTo: '/evidence-audit/audit-log', pathMatch: 'full' },
{ path: 'evidence-packs', redirectTo: '/evidence-audit/packs', pathMatch: 'full' },
{ path: 'evidence-packs/:packId', redirectTo: '/evidence-audit/packs/:packId', pathMatch: 'full' },
{ path: 'evidence/export', redirectTo: '/evidence/exports', pathMatch: 'full' },
{ path: 'evidence/proof-chains', redirectTo: '/evidence/verification/proofs', pathMatch: 'full' },
{ path: 'evidence-packs', redirectTo: '/evidence/capsules', pathMatch: 'full' },
{ path: 'evidence-packs/:packId', redirectTo: '/evidence/capsules/:packId', pathMatch: 'full' },
// Keep /proofs/* as permanent short alias for convenience
{ path: 'proofs/:subjectDigest', redirectTo: '/evidence-audit/proofs/:subjectDigest', pathMatch: 'full' },
{ path: 'proofs/:subjectDigest', redirectTo: '/evidence/verification/proofs', pathMatch: 'full' },
// ===========================================
// Other
// ===========================================
{ path: 'ai-runs', redirectTo: '/platform-ops/ai-runs', pathMatch: 'full' },
{ path: 'ai-runs/:runId', redirectTo: '/platform-ops/ai-runs/:runId', pathMatch: 'full' },
{ path: 'change-trace', redirectTo: '/evidence-audit/change-trace', pathMatch: 'full' },
{ path: 'notify', redirectTo: '/platform-ops/notifications', pathMatch: 'full' },
{ path: 'ai-runs', redirectTo: '/platform/ops/ai-runs', pathMatch: 'full' },
{ path: 'ai-runs/:runId', redirectTo: '/platform/ops/ai-runs/:runId', pathMatch: 'full' },
{ path: 'change-trace', redirectTo: '/evidence/audit-log', pathMatch: 'full' },
{ path: 'notify', redirectTo: '/platform/ops/notifications', pathMatch: 'full' },
];
export const LEGACY_REDIRECT_ROUTES: Routes = LEGACY_REDIRECT_ROUTE_TEMPLATES.map((route) => ({

View File

@@ -0,0 +1,228 @@
import { Routes } from '@angular/router';
import { requireOrchOperatorGuard, requireOrchViewerGuard } from '../core/auth';
export const OPERATIONS_ROUTES: Routes = [
{
path: '',
title: 'Platform Ops',
data: { breadcrumb: 'Ops' },
loadComponent: () =>
import('../features/platform/ops/platform-ops-overview-page.component').then(
(m) => m.PlatformOpsOverviewPageComponent,
),
},
{
path: 'jobs-queues',
title: 'Jobs & Queues',
data: { breadcrumb: 'Jobs & Queues' },
loadComponent: () =>
import('../features/platform/ops/platform-jobs-queues-page.component').then(
(m) => m.PlatformJobsQueuesPageComponent,
),
},
{
path: 'feeds-airgap',
title: 'Feeds & Airgap',
data: { breadcrumb: 'Feeds & Airgap' },
loadComponent: () =>
import('../features/platform/ops/platform-feeds-airgap-page.component').then(
(m) => m.PlatformFeedsAirgapPageComponent,
),
},
{
path: 'data-integrity',
title: 'Data Integrity',
data: { breadcrumb: 'Data Integrity' },
loadChildren: () =>
import('../features/platform-ops/data-integrity/data-integrity.routes').then(
(m) => m.dataIntegrityRoutes,
),
},
{
path: 'health-slo',
title: 'Health & SLO',
data: { breadcrumb: 'Health & SLO' },
loadChildren: () =>
import('../features/platform-health/platform-health.routes').then(
(m) => m.platformHealthRoutes,
),
},
{
path: 'orchestrator',
title: 'Orchestrator',
data: { breadcrumb: 'Orchestrator' },
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('../features/orchestrator/orchestrator-dashboard.component').then(
(m) => m.OrchestratorDashboardComponent,
),
},
{
path: 'orchestrator/jobs',
title: 'Jobs',
data: { breadcrumb: 'Jobs' },
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('../features/orchestrator/orchestrator-jobs.component').then(
(m) => m.OrchestratorJobsComponent,
),
},
{
path: 'orchestrator/jobs/:jobId',
title: 'Job Detail',
data: { breadcrumb: 'Job Detail' },
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('../features/orchestrator/orchestrator-job-detail.component').then(
(m) => m.OrchestratorJobDetailComponent,
),
},
{
path: 'orchestrator/quotas',
title: 'Orchestrator Quotas',
data: { breadcrumb: 'Orchestrator Quotas' },
canMatch: [requireOrchOperatorGuard],
loadComponent: () =>
import('../features/orchestrator/orchestrator-quotas.component').then(
(m) => m.OrchestratorQuotasComponent,
),
},
{
path: 'scheduler',
title: 'Scheduler',
data: { breadcrumb: 'Scheduler' },
loadChildren: () =>
import('../features/scheduler-ops/scheduler-ops.routes').then((m) => m.schedulerOpsRoutes),
},
{
path: 'quotas',
title: 'Quotas & Limits',
data: { breadcrumb: 'Quotas & Limits' },
loadChildren: () =>
import('../features/quota-dashboard/quota.routes').then((m) => m.quotaRoutes),
},
{
path: 'offline-kit',
title: 'Offline Kit',
data: { breadcrumb: 'Offline Kit' },
loadChildren: () =>
import('../features/offline-kit/offline-kit.routes').then((m) => m.offlineKitRoutes),
},
{
path: 'dead-letter',
title: 'Dead-Letter Queue',
data: { breadcrumb: 'Dead-Letter Queue' },
loadChildren: () =>
import('../features/deadletter/deadletter.routes').then((m) => m.deadletterRoutes),
},
{
path: 'aoc',
title: 'AOC Compliance',
data: { breadcrumb: 'AOC Compliance' },
loadChildren: () =>
import('../features/aoc-compliance/aoc-compliance.routes').then((m) => m.AOC_COMPLIANCE_ROUTES),
},
{
path: 'doctor',
title: 'Diagnostics',
data: { breadcrumb: 'Diagnostics' },
loadChildren: () =>
import('../features/doctor/doctor.routes').then((m) => m.DOCTOR_ROUTES),
},
{
path: 'diagnostics',
pathMatch: 'full',
redirectTo: 'doctor',
},
{
path: 'signals',
title: 'Signals',
data: { breadcrumb: 'Signals' },
loadChildren: () =>
import('../features/signals/signals.routes').then((m) => m.SIGNALS_ROUTES),
},
{
path: 'packs',
title: 'Pack Registry',
data: { breadcrumb: 'Pack Registry' },
loadChildren: () =>
import('../features/pack-registry/pack-registry.routes').then((m) => m.PACK_REGISTRY_ROUTES),
},
{
path: 'ai-runs',
title: 'AI Runs',
data: { breadcrumb: 'AI Runs' },
loadComponent: () =>
import('../features/ai-runs/ai-runs-list.component').then((m) => m.AiRunsListComponent),
},
{
path: 'ai-runs/:runId',
title: 'AI Run Detail',
data: { breadcrumb: 'AI Run Detail' },
loadComponent: () =>
import('../features/ai-runs/ai-run-viewer.component').then((m) => m.AiRunViewerComponent),
},
{
path: 'notifications',
title: 'Notifications',
data: { breadcrumb: 'Notifications' },
loadComponent: () =>
import('../features/notify/notify-panel.component').then((m) => m.NotifyPanelComponent),
},
{
path: 'status',
title: 'System Status',
data: { breadcrumb: 'System Status' },
loadComponent: () =>
import('../features/console/console-status.component').then((m) => m.ConsoleStatusComponent),
},
// Ownership move in Pack 22: agents belong to Topology.
{
path: 'agents',
pathMatch: 'full',
redirectTo: '/topology/agents',
},
// Legacy aliases within ops surface.
{
path: 'jobs',
pathMatch: 'full',
redirectTo: 'jobs-queues',
},
{
path: 'runs',
pathMatch: 'full',
redirectTo: 'jobs-queues',
},
{
path: 'schedules',
pathMatch: 'full',
redirectTo: 'jobs-queues',
},
{
path: 'workers',
pathMatch: 'full',
redirectTo: 'jobs-queues',
},
{
path: 'feeds',
pathMatch: 'full',
redirectTo: 'feeds-airgap',
},
{
path: 'feeds-offline',
pathMatch: 'full',
redirectTo: 'feeds-airgap',
},
{
path: 'health',
pathMatch: 'full',
redirectTo: 'health-slo',
},
{
path: 'slo',
pathMatch: 'full',
redirectTo: 'health-slo',
},
];

View File

@@ -0,0 +1,55 @@
import { Routes } from '@angular/router';
export const PLATFORM_ROUTES: Routes = [
{
path: '',
title: 'Platform',
data: { breadcrumb: 'Platform' },
loadComponent: () =>
import('../features/platform/platform-home-page.component').then(
(m) => m.PlatformHomePageComponent,
),
},
{
path: 'home',
pathMatch: 'full',
redirectTo: '',
},
{
path: 'ops',
title: 'Platform Ops',
data: { breadcrumb: 'Ops' },
loadChildren: () =>
import('./operations.routes').then((m) => m.OPERATIONS_ROUTES),
},
{
path: 'integrations',
title: 'Platform Integrations',
data: { breadcrumb: 'Integrations' },
loadChildren: () =>
import('../features/integration-hub/integration-hub.routes').then(
(m) => m.integrationHubRoutes,
),
},
{
path: 'setup',
title: 'Platform Setup',
data: { breadcrumb: 'Setup' },
loadChildren: () =>
import('../features/platform/setup/platform-setup.routes').then(
(m) => m.PLATFORM_SETUP_ROUTES,
),
},
// Interim aliases used by sprint 021 before setup naming finalized.
{
path: 'administration',
pathMatch: 'full',
redirectTo: 'setup',
},
{
path: 'administration/:page',
pathMatch: 'full',
redirectTo: 'setup/:page',
},
];

View File

@@ -0,0 +1,134 @@
import { Routes } from '@angular/router';
export const RELEASES_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'runs',
},
{
path: 'versions',
title: 'Release Versions',
data: { breadcrumb: 'Release Versions', semanticObject: 'version' },
loadComponent: () =>
import('../features/release-orchestrator/releases/release-list/release-list.component').then(
(m) => m.ReleaseListComponent,
),
},
{
path: 'versions/new',
title: 'Create Release Version',
data: { breadcrumb: 'Create Release Version', semanticObject: 'version' },
loadComponent: () =>
import('../features/release-orchestrator/releases/create-release/create-release.component').then(
(m) => m.CreateReleaseComponent,
),
},
{
path: 'versions/:versionId',
pathMatch: 'full',
redirectTo: 'versions/:versionId/overview',
},
{
path: 'versions/:versionId/:tab',
title: 'Release Version Detail',
data: { breadcrumb: 'Release Version', semanticObject: 'version' },
loadComponent: () =>
import('../features/release-orchestrator/releases/release-detail/release-detail.component').then(
(m) => m.ReleaseDetailComponent,
),
},
{
path: 'runs',
title: 'Release Runs',
data: { breadcrumb: 'Release Runs', semanticObject: 'run' },
loadComponent: () =>
import('../features/releases/releases-activity.component').then((m) => m.ReleasesActivityComponent),
},
{
path: 'runs/:runId',
pathMatch: 'full',
redirectTo: 'runs/:runId/timeline',
},
{
path: 'runs/:runId/:tab',
title: 'Release Run Detail',
data: { breadcrumb: 'Release Run', semanticObject: 'run' },
loadComponent: () =>
import('../features/release-orchestrator/releases/release-detail/release-detail.component').then(
(m) => m.ReleaseDetailComponent,
),
},
{
path: 'hotfix',
title: 'Hotfix Run Lane',
data: { breadcrumb: 'Hotfix Lane', semanticObject: 'run', defaultLane: 'hotfix' },
loadComponent: () =>
import('../features/releases/releases-activity.component').then((m) => m.ReleasesActivityComponent),
},
{
path: 'approvals',
title: 'Run Approvals Queue',
data: { breadcrumb: 'Run Approvals', semanticObject: 'run' },
loadChildren: () =>
import('../features/approvals/approvals.routes').then((m) => m.APPROVALS_ROUTES),
},
// Legacy aliases.
{
path: 'new',
pathMatch: 'full',
redirectTo: 'versions/new',
},
{
path: 'create',
pathMatch: 'full',
redirectTo: 'versions/new',
},
{
path: 'activity',
pathMatch: 'full',
redirectTo: 'runs',
},
{
path: 'deployments',
pathMatch: 'full',
redirectTo: 'runs',
},
{
path: 'promotions',
pathMatch: 'full',
redirectTo: 'runs',
},
{
path: 'hotfixes',
pathMatch: 'full',
redirectTo: 'hotfix',
},
{
path: ':releaseId/runs',
pathMatch: 'full',
redirectTo: 'runs/:releaseId/timeline',
},
{
path: ':releaseId/promotions',
pathMatch: 'full',
redirectTo: 'runs/:releaseId/gate-decision',
},
{
path: ':releaseId/deployments',
pathMatch: 'full',
redirectTo: 'runs/:releaseId/deployments',
},
{
path: ':releaseId',
pathMatch: 'full',
redirectTo: 'runs/:releaseId/timeline',
},
{
path: ':releaseId/:tab',
pathMatch: 'full',
redirectTo: 'runs/:releaseId/:tab',
},
];

View File

@@ -104,14 +104,23 @@ export const SECURITY_RISK_ROUTES: Routes = [
title: 'Exceptions',
data: { breadcrumb: 'Exceptions' },
loadComponent: () =>
import('../features/triage/triage-artifacts.component').then((m) => m.TriageArtifactsComponent),
import('../features/exceptions/exception-dashboard.component').then((m) => m.ExceptionDashboardComponent),
},
{
path: 'exceptions/:id',
path: 'exceptions/approvals',
title: 'Exception Approvals',
data: { breadcrumb: 'Exception Approvals' },
loadComponent: () =>
import('../features/exceptions/exception-approval-queue.component').then(
(m) => m.ExceptionApprovalQueueComponent
),
},
{
path: 'exceptions/:exceptionId',
title: 'Exception Detail',
data: { breadcrumb: 'Exception Detail' },
loadComponent: () =>
import('../features/triage/triage-workspace.component').then((m) => m.TriageWorkspaceComponent),
import('../features/exceptions/exception-dashboard.component').then((m) => m.ExceptionDashboardComponent),
},
{
path: 'lineage',

View File

@@ -0,0 +1,162 @@
import { Routes } from '@angular/router';
export const SECURITY_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'overview',
},
{
path: 'overview',
title: 'Security Overview',
data: { breadcrumb: 'Overview' },
loadComponent: () =>
import('../features/security-risk/security-risk-overview.component').then(
(m) => m.SecurityRiskOverviewComponent,
),
},
{
path: 'triage',
title: 'Security Triage',
data: { breadcrumb: 'Triage' },
loadComponent: () =>
import('../features/security/security-findings-page.component').then(
(m) => m.SecurityFindingsPageComponent,
),
},
{
path: 'triage/:findingId',
title: 'Security Finding',
data: { breadcrumb: 'Finding' },
loadComponent: () =>
import('../features/security-risk/finding-detail-page.component').then(
(m) => m.FindingDetailPageComponent,
),
},
{
path: 'advisories-vex',
title: 'Advisories & VEX',
data: { breadcrumb: 'Advisories & VEX' },
loadComponent: () =>
import('../features/security/security-disposition-page.component').then(
(m) => m.SecurityDispositionPageComponent,
),
},
{
path: 'supply-chain-data',
pathMatch: 'full',
redirectTo: 'supply-chain-data/lake',
},
{
path: 'supply-chain-data/:mode',
title: 'Supply-Chain Data',
data: { breadcrumb: 'Supply-Chain Data' },
loadComponent: () =>
import('../features/security/security-sbom-explorer-page.component').then(
(m) => m.SecuritySbomExplorerPageComponent,
),
},
{
path: 'reports',
title: 'Security Reports',
data: { breadcrumb: 'Reports' },
loadComponent: () =>
import('../features/security/security-disposition-page.component').then(
(m) => m.SecurityDispositionPageComponent,
),
},
// Canonical compatibility aliases.
{
path: 'posture',
pathMatch: 'full',
redirectTo: 'overview',
},
{
path: 'findings',
pathMatch: 'full',
redirectTo: 'triage',
},
{
path: 'findings/:findingId',
pathMatch: 'full',
redirectTo: 'triage/:findingId',
},
{
path: 'disposition',
pathMatch: 'full',
redirectTo: 'advisories-vex',
},
{
path: 'sbom',
pathMatch: 'full',
redirectTo: 'supply-chain-data/lake',
},
{
path: 'sbom/:mode',
pathMatch: 'full',
redirectTo: 'supply-chain-data/:mode',
},
{
path: 'reachability',
pathMatch: 'full',
redirectTo: 'triage',
},
{
path: 'vex',
pathMatch: 'full',
redirectTo: 'advisories-vex',
},
{
path: 'exceptions',
pathMatch: 'full',
redirectTo: 'advisories-vex',
},
{
path: 'advisory-sources',
pathMatch: 'full',
redirectTo: '/platform/integrations/feeds',
},
// Legacy deep links.
{
path: 'vulnerabilities',
pathMatch: 'full',
redirectTo: 'triage',
},
{
path: 'vulnerabilities/:cveId',
pathMatch: 'full',
redirectTo: 'triage',
},
{
path: 'sbom-explorer',
pathMatch: 'full',
redirectTo: 'supply-chain-data/lake',
},
{
path: 'sbom-explorer/:mode',
pathMatch: 'full',
redirectTo: 'supply-chain-data/:mode',
},
{
path: 'scans/:scanId',
title: 'Scan Detail',
data: { breadcrumb: 'Scan Detail' },
loadComponent: () =>
import('../features/scans/scan-detail-page.component').then((m) => m.ScanDetailPageComponent),
},
{
path: 'lineage',
title: 'Lineage',
data: { breadcrumb: 'Lineage' },
loadChildren: () =>
import('../features/lineage/lineage.routes').then((m) => m.lineageRoutes),
},
{
path: 'risk',
pathMatch: 'full',
redirectTo: 'overview',
},
];

View File

@@ -0,0 +1,155 @@
import { Routes } from '@angular/router';
export const TOPOLOGY_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'overview',
},
{
path: 'overview',
title: 'Topology Overview',
data: {
breadcrumb: 'Overview',
title: 'Topology Overview',
description: 'Operator mission map for region posture, targets, agents, and promotion flow health.',
},
loadComponent: () =>
import('../features/topology/topology-overview-page.component').then(
(m) => m.TopologyOverviewPageComponent,
),
},
{
path: 'regions',
title: 'Topology Regions & Environments',
data: {
breadcrumb: 'Regions & Environments',
title: 'Regions & Environments',
description: 'Region-first topology inventory with environment posture and drilldowns.',
defaultView: 'region-first',
},
loadComponent: () =>
import('../features/topology/topology-regions-environments-page.component').then(
(m) => m.TopologyRegionsEnvironmentsPageComponent,
),
},
{
path: 'environments',
title: 'Topology Environments',
data: {
breadcrumb: 'Environments',
title: 'Environments',
description: 'Environment inventory scoped by region and topology metadata.',
defaultView: 'flat',
},
loadComponent: () =>
import('../features/topology/topology-regions-environments-page.component').then(
(m) => m.TopologyRegionsEnvironmentsPageComponent,
),
},
{
path: 'environments/:environmentId',
pathMatch: 'full',
redirectTo: 'environments/:environmentId/posture',
},
{
path: 'environments/:environmentId/posture',
title: 'Topology Environment Detail',
data: {
breadcrumb: 'Environment Detail',
title: 'Environment Detail',
description: 'Topology-first tabs for targets, deployments, agents, security, and evidence.',
},
loadComponent: () =>
import('../features/topology/topology-environment-detail-page.component').then(
(m) => m.TopologyEnvironmentDetailPageComponent,
),
},
{
path: 'targets',
title: 'Topology Targets',
data: {
breadcrumb: 'Targets',
title: 'Targets',
description: 'Target runtime inventory with deployment and health context.',
},
loadComponent: () =>
import('../features/topology/topology-targets-page.component').then(
(m) => m.TopologyTargetsPageComponent,
),
},
{
path: 'hosts',
title: 'Topology Hosts',
data: {
breadcrumb: 'Hosts',
title: 'Hosts',
description: 'Host runtime inventory and topology placement.',
},
loadComponent: () =>
import('../features/topology/topology-hosts-page.component').then(
(m) => m.TopologyHostsPageComponent,
),
},
{
path: 'agents',
title: 'Topology Agents',
data: {
breadcrumb: 'Agents',
title: 'Agents',
description: 'Agent fleet status and assignments by region and environment.',
},
loadComponent: () =>
import('../features/topology/topology-agents-page.component').then(
(m) => m.TopologyAgentsPageComponent,
),
},
{
path: 'promotion-paths',
title: 'Topology Promotion Paths',
data: {
breadcrumb: 'Promotion Paths',
title: 'Promotion Paths',
description: 'Promotion path configurations and gate ownership.',
},
loadComponent: () =>
import('../features/topology/topology-promotion-paths-page.component').then(
(m) => m.TopologyPromotionPathsPageComponent,
),
},
{
path: 'workflows',
title: 'Topology Workflows',
data: {
breadcrumb: 'Workflows',
title: 'Workflows',
description: 'Release workflow inventory for configured topology stages.',
endpoint: '/api/v2/topology/workflows',
},
loadComponent: () =>
import('../features/topology/topology-inventory-page.component').then(
(m) => m.TopologyInventoryPageComponent,
),
},
{
path: 'gate-profiles',
title: 'Topology Gate Profiles',
data: {
breadcrumb: 'Gate Profiles',
title: 'Gate Profiles',
description: 'Gate profile inventory and required approval policies.',
endpoint: '/api/v2/topology/gate-profiles',
},
loadComponent: () =>
import('../features/topology/topology-inventory-page.component').then(
(m) => m.TopologyInventoryPageComponent,
),
},
// Legacy placement aliases.
{
path: 'targets-hosts',
pathMatch: 'full',
redirectTo: 'targets',
},
];

View File

@@ -121,9 +121,22 @@
--color-nav-border: #D4C9A8;
--color-nav-hover: #FEF3E2;
// Sidebar (warm cream tint)
--color-sidebar-bg: #FFF9ED;
--color-sidebar-text: #3D2E0A;
// Sidebar (dark charcoal — permanent dark rail)
--color-sidebar-bg: #12151F;
--color-sidebar-text: #C8C2B4;
--color-sidebar-text-heading: #F0EDE4;
--color-sidebar-text-muted: #6B6560;
--color-sidebar-border: rgba(255, 255, 255, 0.06);
--color-sidebar-hover: rgba(255, 255, 255, 0.05);
--color-sidebar-active-bg: rgba(245, 166, 35, 0.12);
--color-sidebar-active-text: #F5B84A;
--color-sidebar-active-border: #F5A623;
--color-sidebar-group-text: rgba(255, 255, 255, 0.35);
--color-sidebar-divider: rgba(255, 255, 255, 0.06);
--color-sidebar-badge-bg: #F5A623;
--color-sidebar-badge-text: #12151F;
--color-sidebar-brand-text: #F0EDE4;
--color-sidebar-version: rgba(255, 255, 255, 0.2);
--color-dropdown-bg: #FFFFFF;
--color-dropdown-border: #D4C9A8;
@@ -348,8 +361,22 @@
--color-nav-border: #1E2A42;
--color-nav-hover: #141C2E;
--color-sidebar-bg: #0A0F1A;
--color-sidebar-text: #F0EDE4;
// Sidebar (dark rail — stays dark in both themes)
--color-sidebar-bg: #0A0E16;
--color-sidebar-text: #B8B2A4;
--color-sidebar-text-heading: #F0EDE4;
--color-sidebar-text-muted: #5A5550;
--color-sidebar-border: rgba(255, 255, 255, 0.05);
--color-sidebar-hover: rgba(255, 255, 255, 0.06);
--color-sidebar-active-bg: rgba(245, 184, 74, 0.14);
--color-sidebar-active-text: #F5B84A;
--color-sidebar-active-border: #F5B84A;
--color-sidebar-group-text: rgba(255, 255, 255, 0.3);
--color-sidebar-divider: rgba(255, 255, 255, 0.05);
--color-sidebar-badge-bg: #F5B84A;
--color-sidebar-badge-text: #0A0E16;
--color-sidebar-brand-text: #F0EDE4;
--color-sidebar-version: rgba(255, 255, 255, 0.18);
--color-dropdown-bg: #0C1220;
--color-dropdown-border: #1E2A42;
@@ -517,8 +544,21 @@
// Header (deep navy matching stella-ops.org)
--color-header-bg: linear-gradient(90deg, #070B14 0%, #0C1220 45%, #141C2E 100%);
--color-nav-bg: #0A0F1A;
--color-sidebar-bg: #0A0F1A;
--color-sidebar-text: #F0EDE4;
--color-sidebar-bg: #0A0E16;
--color-sidebar-text: #B8B2A4;
--color-sidebar-text-heading: #F0EDE4;
--color-sidebar-text-muted: #5A5550;
--color-sidebar-border: rgba(255, 255, 255, 0.05);
--color-sidebar-hover: rgba(255, 255, 255, 0.06);
--color-sidebar-active-bg: rgba(245, 184, 74, 0.14);
--color-sidebar-active-text: #F5B84A;
--color-sidebar-active-border: #F5B84A;
--color-sidebar-group-text: rgba(255, 255, 255, 0.3);
--color-sidebar-divider: rgba(255, 255, 255, 0.05);
--color-sidebar-badge-bg: #F5B84A;
--color-sidebar-badge-text: #0A0E16;
--color-sidebar-brand-text: #F0EDE4;
--color-sidebar-version: rgba(255, 255, 255, 0.18);
--color-dropdown-bg: #0C1220;
// Shadows

View File

@@ -0,0 +1,133 @@
import '@angular/compiler';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ApprovalDetailStore } from '../../app/features/approvals/state/approval-detail.store';
describe('ApprovalDetailStore', () => {
let store: ApprovalDetailStore;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ApprovalDetailStore,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
});
store = TestBed.inject(ApprovalDetailStore);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify({ ignoreCancelled: true });
});
it('loads approval detail packet from v2 endpoints', () => {
store.load('apr-001');
httpMock.expectOne('/api/v1/approvals/apr-001').flush({
id: 'apr-001',
releaseId: 'rel-001',
releaseVersion: '1.2.3',
sourceEnvironment: 'staging',
targetEnvironment: 'production',
status: 'pending',
requestedBy: 'release-bot',
requestedAt: '2026-02-20T08:00:00Z',
releaseComponents: [{ name: 'api-gateway', version: '1.2.3', digest: 'sha256:abc' }],
actions: [],
gateResults: [],
});
httpMock.expectOne('/api/v1/approvals/apr-001/gates').flush({
gates: [{ gateId: 'g1', gateName: 'Policy', status: 'passed', message: 'ok' }],
});
httpMock.expectOne('/api/v1/approvals/apr-001/security-snapshot').flush({
topFindings: [{ cve: 'CVE-2026-1234', component: 'api-gateway', severity: 'critical', reachability: 'reachable' }],
});
httpMock.expectOne('/api/v1/approvals/apr-001/evidence').flush({ decisionDigest: 'sha256:decision' });
httpMock.expectOne('/api/v1/approvals/apr-001/ops-health').flush({ opsConfidence: { status: 'warning' } });
expect(store.approval()?.id).toBe('apr-001');
expect(store.gateResults()[0]?.status).toBe('PASS');
expect(store.securityDiff()[0]?.cveId).toBe('CVE-2026-1234');
expect(store.error()).toBeNull();
expect(store.loading()).toBe(false);
});
it('posts approve decision to v2 mutation endpoint', () => {
store.load('apr-001');
httpMock.expectOne('/api/v1/approvals/apr-001').flush({
id: 'apr-001',
releaseId: 'rel-001',
releaseVersion: '1.2.3',
sourceEnvironment: 'staging',
targetEnvironment: 'production',
status: 'pending',
requestedBy: 'release-bot',
requestedAt: '2026-02-20T08:00:00Z',
releaseComponents: [{ name: 'api-gateway', version: '1.2.3', digest: 'sha256:abc' }],
actions: [],
gateResults: [{ gateId: 'g1', gateName: 'Policy', status: 'passed', message: 'ok' }],
});
httpMock.expectOne('/api/v1/approvals/apr-001/gates').flush({
gates: [{ gateId: 'g1', gateName: 'Policy', status: 'passed', message: 'ok' }],
});
httpMock.expectOne('/api/v1/approvals/apr-001/security-snapshot').flush({ topFindings: [] });
httpMock.expectOne('/api/v1/approvals/apr-001/evidence').flush({});
httpMock.expectOne('/api/v1/approvals/apr-001/ops-health').flush({});
store.approve('ship it');
const decisionReq = httpMock.expectOne('/api/v1/approvals/apr-001/decision');
expect(decisionReq.request.method).toBe('POST');
expect(decisionReq.request.body.action).toBe('approve');
decisionReq.flush({
id: 'apr-001',
releaseId: 'rel-001',
releaseVersion: '1.2.3',
sourceEnvironment: 'staging',
targetEnvironment: 'production',
status: 'approved',
requestedBy: 'release-bot',
requestedAt: '2026-02-20T08:00:00Z',
releaseComponents: [{ name: 'api-gateway', version: '1.2.3', digest: 'sha256:abc' }],
gateResults: [{ gateId: 'g1', gateName: 'Policy', status: 'passed', message: 'ok' }],
actions: [{
id: 'act-1',
approvalId: 'apr-001',
action: 'approve',
actor: 'ui-operator',
comment: 'ship it',
timestamp: '2026-02-20T09:00:00Z',
}],
});
expect(store.approval()?.status).toBe('approved');
expect(store.comments()).toHaveLength(1);
expect(store.submitting()).toBe(false);
});
it('sets explicit failure state when detail load fails', () => {
store.load('apr-missing');
const req = httpMock.expectOne('/api/v1/approvals/apr-missing');
req.flush({ error: 'not found' }, { status: 404, statusText: 'Not Found' });
const pending = httpMock.match((request) =>
request.url.startsWith('/api/v1/approvals/apr-missing/')
&& request.url !== '/api/v1/approvals/apr-missing'
);
for (const subRequest of pending) {
if (!subRequest.cancelled) {
subRequest.flush({ error: 'not found' }, { status: 404, statusText: 'Not Found' });
}
}
expect(store.approval()).toBeNull();
expect(store.error()).toContain('/api/v1/approvals/apr-missing');
expect(store.loading()).toBe(false);
});
});

View File

@@ -28,10 +28,10 @@ const approvalsFixture: ApprovalRequest[] = [
{
id: 'apr-2',
releaseId: 'rel-2',
releaseName: 'Scanner',
releaseName: 'Scanner Hotfix',
releaseVersion: '3.0.0',
sourceEnvironment: 'dev',
targetEnvironment: 'stage',
targetEnvironment: 'staging',
requestedBy: 'security-user',
requestedAt: '2026-02-18T11:00:00Z',
urgency: 'high',
@@ -96,14 +96,10 @@ function createApprovalApiMock(): ApprovalApi {
};
}
describe('ApprovalsInboxComponent (approvals)', () => {
describe('ApprovalsInboxComponent (approvals queue)', () => {
let fixture: ComponentFixture<ApprovalsInboxComponent>;
let component: ApprovalsInboxComponent;
afterEach(() => {
sessionStorage.removeItem('approvals.data-integrity-banner-dismissed');
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ApprovalsInboxComponent],
@@ -118,72 +114,44 @@ describe('ApprovalsInboxComponent (approvals)', () => {
fixture.detectChanges();
});
it('loads approvals via API and renders result count', () => {
const text = fixture.nativeElement.textContent as string;
it('loads approvals and defaults to the pending queue tab', () => {
expect(component.approvals().length).toBe(3);
expect(text).toContain('Approvals');
expect(text).toContain('Results (3)');
});
expect(component.filtered().map((item) => item.id)).toEqual(['apr-1', 'apr-2']);
it('renders gate states and detail actions for approval cards', () => {
const cardElements = fixture.nativeElement.querySelectorAll('.approval-card');
const detailLinks = fixture.nativeElement.querySelectorAll('a.btn.btn--secondary');
const text = fixture.nativeElement.textContent as string;
expect(cardElements.length).toBe(3);
expect(detailLinks.length).toBeGreaterThanOrEqual(3);
expect(text).toContain('PASS');
expect(text).toContain('BLOCK');
expect(text).toContain('View Details');
expect(text).toContain('Release Run Approvals Queue');
expect(text).toContain('API Gateway');
expect(text).toContain('Scanner Hotfix');
});
it('shows justification and release identifiers in cards', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('JUSTIFICATION');
expect(text).toContain('API Gateway v1.2.5');
expect(text).toContain('Scanner v3.0.0');
});
it('renders gate type and action links for queue rows', () => {
const text = (fixture.nativeElement.textContent as string).toLowerCase();
expect(text).toContain('policy');
expect(text).toContain('ops');
it('does not render a dead Docs link in the page header', () => {
const links = Array.from(
fixture.nativeElement.querySelectorAll('a') as NodeListOf<HTMLAnchorElement>
fixture.nativeElement.querySelectorAll('a') as NodeListOf<HTMLAnchorElement>,
);
const docsLink = links.find((link) =>
(link.textContent ?? '').replace(/\s+/g, ' ').trim().startsWith('Docs')
);
expect(docsLink).toBeUndefined();
const openLinks = links.filter((link) => {
const href = link.getAttribute('href') ?? '';
return href.includes('/releases/runs/') && href.includes('/approvals');
});
expect(openLinks.length).toBeGreaterThanOrEqual(2);
});
it('renders data integrity warning banner with deep link when status is WARN', () => {
const banner = fixture.nativeElement.querySelector('.data-integrity-banner') as HTMLElement | null;
expect(banner).toBeTruthy();
it('applies tab and queue filters deterministically', () => {
component.activeTab.set('approved');
component.applyFilters();
expect(component.filtered().map((item) => item.id)).toEqual(['apr-3']);
const text = banner?.textContent ?? '';
expect(text).toContain('Data Integrity WARN');
expect(text).toContain('Open Data Integrity');
});
component.activeTab.set('pending');
component.envFilter = 'prod';
component.applyFilters();
expect(component.filtered().map((item) => item.id)).toEqual(['apr-1']);
it('hides data integrity banner when status is OK', () => {
component.dataIntegrityStatus.set('OK');
fixture.detectChanges();
const banner = fixture.nativeElement.querySelector('.data-integrity-banner');
expect(banner).toBeFalsy();
});
it('dismisses data integrity banner for current session', () => {
const dismiss = fixture.nativeElement.querySelector(
'.data-integrity-banner__actions button'
) as HTMLButtonElement | null;
expect(dismiss).toBeTruthy();
dismiss?.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.data-integrity-banner')).toBeFalsy();
expect(sessionStorage.getItem('approvals.data-integrity-banner-dismissed')).toBe('1');
component.envFilter = 'all';
component.hotfixFilter = 'true';
component.applyFilters();
expect(component.filtered().map((item) => item.id)).toEqual(['apr-2']);
});
});

View File

@@ -0,0 +1,116 @@
import '@angular/compiler';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RELEASE_DASHBOARD_API } from '../../app/core/api/release-dashboard.client';
import { type DashboardData } from '../../app/core/api/release-dashboard.models';
import { ControlPlaneStore } from '../../app/features/control-plane/control-plane.store';
describe('ControlPlaneStore', () => {
let store: ControlPlaneStore;
let httpMock: HttpTestingController;
let dashboardApi: { getDashboardData: ReturnType<typeof vi.fn> };
const dashboardPayload: DashboardData = {
pipelineData: {
environments: [
{
id: 'staging',
name: 'staging',
displayName: 'Staging',
order: 1,
releaseCount: 2,
pendingCount: 1,
healthStatus: 'healthy',
},
],
connections: [{ from: 'staging', to: 'production' }],
},
pendingApprovals: [
{
id: 'apr-001',
releaseId: 'rel-001',
releaseName: 'API Gateway',
releaseVersion: '1.2.3',
sourceEnvironment: 'staging',
targetEnvironment: 'production',
requestedBy: 'release-bot',
requestedAt: '2026-02-20T09:00:00Z',
urgency: 'high',
},
],
activeDeployments: [],
recentReleases: [
{
id: 'rel-001',
name: 'API Gateway',
version: '1.2.3',
status: 'ready',
currentEnvironment: 'staging',
createdAt: '2026-02-20T08:00:00Z',
createdBy: 'release-bot',
componentCount: 2,
},
],
};
beforeEach(() => {
dashboardApi = {
getDashboardData: vi.fn().mockReturnValue(of(dashboardPayload)),
};
TestBed.configureTestingModule({
providers: [
ControlPlaneStore,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
{ provide: RELEASE_DASHBOARD_API, useValue: dashboardApi },
],
});
store = TestBed.inject(ControlPlaneStore);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('loads mapped pipeline, inbox, and promotions from release dashboard api', async () => {
await store.load();
expect(dashboardApi.getDashboardData).toHaveBeenCalledTimes(1);
expect(store.pipeline()?.environments).toHaveLength(1);
expect(store.inbox()?.items).toHaveLength(1);
expect(store.promotions()[0]?.id).toBe('apr-001');
expect(store.error()).toBeNull();
});
it('calls live deploy endpoint and refreshes store state', async () => {
await store.load();
const deployPromise = store.deployPromotion('apr-001');
const req = httpMock.expectOne('/api/release-orchestrator/releases/rel-001/deploy');
expect(req.request.method).toBe('POST');
req.flush({});
await deployPromise;
expect(dashboardApi.getDashboardData).toHaveBeenCalledTimes(2);
});
it('sets error and clears data when dashboard api fails', async () => {
dashboardApi.getDashboardData.mockReturnValueOnce(
throwError(() => new Error('dashboard offline'))
);
await store.load();
expect(store.error()).toContain('dashboard offline');
expect(store.pipeline()).toBeNull();
expect(store.promotions()).toEqual([]);
expect(store.loading()).toBe(false);
});
});

View File

@@ -48,7 +48,8 @@ describe('Integration Hub UI (integration_hub)', () => {
[IntegrationType.Registry, 5],
[IntegrationType.Scm, 3],
[IntegrationType.CiCd, 2],
[IntegrationType.RuntimeHost, 4],
[IntegrationType.RuntimeHost, 6],
[IntegrationType.RepoSource, 4],
[IntegrationType.FeedMirror, 1],
]);
@@ -79,9 +80,11 @@ describe('Integration Hub UI (integration_hub)', () => {
expect(component.stats.registries).toBe(5);
expect(component.stats.scm).toBe(3);
expect(component.stats.ci).toBe(2);
expect(component.stats.hosts).toBe(4);
expect(component.stats.feeds).toBe(1);
expect(service.list).toHaveBeenCalledTimes(5);
expect(component.stats.runtimeHosts).toBe(6);
expect(component.stats.secrets).toBe(4);
expect(component.stats.advisorySources).toBe(1);
expect(component.stats.vexSources).toBe(1);
expect(service.list).toHaveBeenCalledTimes(6);
});
it('routes add action to registries onboarding flow', () => {
@@ -89,7 +92,7 @@ describe('Integration Hub UI (integration_hub)', () => {
component.addIntegration();
expect(navigateSpy).toHaveBeenCalledWith(['/integrations/onboarding/registry']);
expect(navigateSpy).toHaveBeenCalledWith(['/platform/integrations/onboarding/registry']);
});
it('renders a defined coming-soon state for recent activity', () => {
@@ -180,7 +183,7 @@ describe('Integration Hub UI (integration_hub)', () => {
component.addIntegration();
expect(navigateSpy).toHaveBeenCalledWith(['/integrations/onboarding', 'registry']);
expect(navigateSpy).toHaveBeenCalledWith(['/platform/integrations/onboarding', 'registry']);
});
});
@@ -257,12 +260,21 @@ describe('Integration Hub UI (integration_hub)', () => {
component.editIntegration();
component.deleteIntegration();
expect(navigateSpy).toHaveBeenCalledWith(['/integrations', 'integration-1'], {
expect(navigateSpy).toHaveBeenCalledWith(['/platform/integrations', 'integration-1'], {
queryParams: { edit: '1' },
queryParamsHandling: 'merge',
});
expect(service.delete).toHaveBeenCalledWith('integration-1');
expect(navigateSpy).toHaveBeenCalledWith(['/integrations']);
expect(navigateSpy).toHaveBeenCalledWith(['/platform/integrations']);
});
it('renders advisory-aligned tab model', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Overview');
expect(text).toContain('Credentials');
expect(text).toContain('Scopes & Rules');
expect(text).toContain('Events');
expect(text).toContain('Health');
});
});
});

View File

@@ -18,22 +18,33 @@ import {
const V2_CANONICAL_PREFIXES = [
'/dashboard',
'/release-control/',
'/security-risk/',
'/evidence-audit/',
'/releases',
'/releases/',
'/security',
'/security/',
'/evidence',
'/evidence/',
'/topology',
'/topology/',
'/platform',
'/platform/',
'/operations',
'/operations/',
'/integrations',
'/platform-ops/',
'/administration',
'/administration/',
'/', // root redirect target is valid
];
const V2_CANONICAL_ROOTS = [
'dashboard',
'release-control',
'security-risk',
'evidence-audit',
'releases',
'security',
'evidence',
'topology',
'platform',
'operations',
'integrations',
'platform-ops',
'administration',
];
@@ -135,7 +146,7 @@ describe('preserveQueryAndFragment behavior (navigation)', () => {
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, {}, {}, null);
expect(result).toBe('/security-risk/findings');
expect(result).toBe('/security/triage');
});
it('appends query string to redirect target', () => {
@@ -143,7 +154,7 @@ describe('preserveQueryAndFragment behavior (navigation)', () => {
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, {}, { filter: 'critical', sort: 'severity' }, null);
expect(result).toContain('/security-risk/findings');
expect(result).toContain('/security/triage');
expect(result).toContain('filter=critical');
expect(result).toContain('sort=severity');
});
@@ -153,7 +164,7 @@ describe('preserveQueryAndFragment behavior (navigation)', () => {
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, {}, {}, 'recent');
expect(result).toContain('/evidence-audit/audit');
expect(result).toContain('/evidence/audit-log');
expect(result).toContain('#recent');
});
@@ -162,17 +173,17 @@ describe('preserveQueryAndFragment behavior (navigation)', () => {
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, {}, { status: 'open' }, 'top');
expect(result).toContain('/security-risk/findings');
expect(result).toContain('/security/triage');
expect(result).toContain('status=open');
expect(result).toContain('#top');
});
it('interpolates :param segments for parameterized redirects', () => {
it('resolves parameterized vulnerability redirects to unified findings route', () => {
const idx = templateIndexFor('vulnerabilities/:vulnId');
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, { vulnId: 'CVE-2025-9999' }, {}, null);
expect(result).toBe('/security-risk/vulnerabilities/CVE-2025-9999');
expect(result).toBe('/security/triage');
});
it('interpolates multiple param segments', () => {
@@ -180,7 +191,7 @@ describe('preserveQueryAndFragment behavior (navigation)', () => {
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, { artifact: 'myapp' }, {}, null);
expect(result).toBe('/security-risk/lineage/myapp/compare');
expect(result).toBe('/security/lineage/myapp/compare');
});
it('handles multi-value query parameters as repeated keys', () => {
@@ -188,7 +199,7 @@ describe('preserveQueryAndFragment behavior (navigation)', () => {
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, {}, { tag: ['v1', 'v2'] as any }, null);
expect(result).toContain('/platform-ops/orchestrator');
expect(result).toContain('/platform/ops/orchestrator');
expect(result).toContain('tag=v1');
expect(result).toContain('tag=v2');
});
@@ -198,6 +209,6 @@ describe('preserveQueryAndFragment behavior (navigation)', () => {
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, {}, {}, null);
expect(result).toBe('/platform-ops/orchestrator');
expect(result).toBe('/platform/ops/orchestrator');
});
});

View File

@@ -1,14 +1,6 @@
/**
* Navigation model unit tests
* Sprint: SPRINT_20260218_006_FE_ui_v2_rewire_navigation_shell_route_migration (N1-03)
*
* Verifies:
* - Seven canonical root domains are defined in the correct order.
* - All canonical routes point to v2 paths (no legacy /security, /operations, etc.).
* - Release Control shortcut policy: Releases and Approvals are direct children.
* - Release Control nested policy: Bundles, Deployments, Environments are nested.
* - Section labels use clean canonical names (no parenthetical transition text).
* - No nav item links to a deprecated v1 root path as its primary route.
* Sprint: SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope (FE21-01, FE21-11)
*/
import { TestBed } from '@angular/core/testing';
@@ -21,32 +13,29 @@ import { APPROVAL_API } from '../../app/core/api/approval.client';
const CANONICAL_DOMAIN_IDS = [
'dashboard',
'release-control',
'security-risk',
'evidence-audit',
'integrations',
'platform-ops',
'administration',
'releases',
'security',
'evidence',
'topology',
'platform',
] as const;
const CANONICAL_DOMAIN_ROUTES = [
'/dashboard',
'/release-control',
'/security-risk',
'/evidence-audit',
'/integrations',
'/platform-ops',
'/administration',
'/releases',
'/security',
'/evidence',
'/topology',
'/platform',
] as const;
const EXPECTED_SECTION_LABELS: Record<string, string> = {
'dashboard': 'Dashboard',
'release-control': 'Release Control',
'security-risk': 'Security & Risk',
'evidence-audit': 'Evidence & Audit',
'integrations': 'Integrations',
'platform-ops': 'Platform Ops',
'administration': 'Administration',
dashboard: 'Dashboard',
releases: 'Releases',
security: 'Security',
evidence: 'Evidence',
topology: 'Topology',
platform: 'Platform',
};
describe('AppSidebarComponent nav model (navigation)', () => {
@@ -57,44 +46,28 @@ describe('AppSidebarComponent nav model (navigation)', () => {
authSpy.hasAllScopes.and.returnValue(true);
authSpy.hasAnyScope.and.returnValue(true);
const approvalApiSpy = jasmine.createSpyObj('ApprovalApi', ['listApprovals']);
approvalApiSpy.listApprovals.and.returnValue(of([
{
id: 'apr-001',
releaseId: 'rel-001',
releaseName: 'API Gateway',
releaseVersion: '2.1.0',
sourceEnvironment: 'staging',
targetEnvironment: 'production',
requestedBy: 'alice',
requestedAt: '2026-02-19T10:00:00Z',
urgency: 'normal',
justification: 'normal release',
status: 'pending',
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-02-20T10:00:00Z',
},
{
id: 'apr-002',
releaseId: 'rel-002',
releaseName: 'User Service',
releaseVersion: '3.0.0',
sourceEnvironment: 'qa',
targetEnvironment: 'staging',
requestedBy: 'bob',
requestedAt: '2026-02-19T09:00:00Z',
urgency: 'high',
justification: 'urgent patch',
status: 'pending',
currentApprovals: 1,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-02-20T09:00:00Z',
},
]));
approvalApiSpy.listApprovals.and.returnValue(
of([
{
id: 'apr-001',
releaseId: 'run-001',
releaseName: 'Checkout Hotfix',
releaseVersion: '2.1.0',
sourceEnvironment: 'staging',
targetEnvironment: 'production',
requestedBy: 'alice',
requestedAt: '2026-02-19T10:00:00Z',
urgency: 'normal',
justification: 'normal release',
status: 'pending',
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-02-20T10:00:00Z',
},
]),
);
await TestBed.configureTestingModule({
imports: [AppSidebarComponent],
@@ -109,15 +82,15 @@ describe('AppSidebarComponent nav model (navigation)', () => {
component = fixture.componentInstance;
});
it('defines exactly 7 canonical root domains', () => {
expect(component.navSections.length).toBe(7);
it('defines exactly 6 canonical root domains', () => {
expect(component.navSections.length).toBe(6);
});
it('root domain IDs match canonical IA order', () => {
expect(component.navSections.map((s) => s.id)).toEqual([...CANONICAL_DOMAIN_IDS]);
});
it('root domain routes all point to v2 canonical paths', () => {
it('root domain routes all point to canonical paths', () => {
expect(component.navSections.map((s) => s.route)).toEqual([...CANONICAL_DOMAIN_ROUTES]);
});
@@ -127,92 +100,50 @@ describe('AppSidebarComponent nav model (navigation)', () => {
}
});
it('Release Control has Releases as a direct child shortcut', () => {
const rc = component.navSections.find((s) => s.id === 'release-control')!;
expect(rc.children?.map((c) => c.id)).toContain('rc-releases');
it('Releases uses run/version-first navigation shortcuts', () => {
const releases = component.navSections.find((s) => s.id === 'releases')!;
const childIds = releases.children?.map((child) => child.id) ?? [];
expect(childIds).toContain('rel-versions');
expect(childIds).toContain('rel-runs');
expect(childIds).toContain('rel-approvals');
expect(childIds).toContain('rel-hotfix');
});
it('Release Control has Approvals as a direct child shortcut', () => {
const rc = component.navSections.find((s) => s.id === 'release-control')!;
expect(rc.children?.map((c) => c.id)).toContain('rc-approvals');
it('Releases create route uses canonical version-creation path', () => {
const releases = component.navSections.find((s) => s.id === 'releases')!;
const create = releases.children!.find((child) => child.id === 'rel-create')!;
expect(create.route).toBe('/releases/versions/new');
});
it('Release Control Releases route is /release-control/releases', () => {
const rc = component.navSections.find((s) => s.id === 'release-control')!;
const releases = rc.children!.find((c) => c.id === 'rc-releases')!;
expect(releases.route).toBe('/release-control/releases');
it('derives approvals queue badge from pending approvals', () => {
const releases = component.visibleSections().find((s) => s.id === 'releases')!;
const approvals = releases.children!.find((child) => child.id === 'rel-approvals')!;
expect(approvals.badge).toBe(1);
});
it('Release Control Approvals route is /release-control/approvals', () => {
const rc = component.navSections.find((s) => s.id === 'release-control')!;
const approvals = rc.children!.find((c) => c.id === 'rc-approvals')!;
expect(approvals.route).toBe('/release-control/approvals');
it('Evidence uses capsule-first workflow labels', () => {
const evidence = component.navSections.find((s) => s.id === 'evidence')!;
const capsules = evidence.children?.find((child) => child.id === 'ev-capsules');
const verify = evidence.children?.find((child) => child.id === 'ev-verify');
expect(capsules?.route).toBe('/evidence/capsules');
expect(verify?.route).toBe('/evidence/verify-replay');
});
it('derives the Approvals badge from pending approvals count', () => {
const rc = component.visibleSections().find((s) => s.id === 'release-control')!;
const approvals = rc.children!.find((c) => c.id === 'rc-approvals')!;
expect(approvals.badge).toBe(2);
it('Platform group owns ops/integrations/setup shortcuts', () => {
const platform = component.navSections.find((s) => s.id === 'platform')!;
const routes = platform.children?.map((child) => child.route) ?? [];
expect(routes).toContain('/platform/ops');
expect(routes).toContain('/platform/integrations');
expect(routes).toContain('/platform/setup');
});
it('Release Control includes Setup route under canonical /release-control/setup', () => {
const rc = component.navSections.find((s) => s.id === 'release-control')!;
const setup = rc.children!.find((c) => c.id === 'rc-setup')!;
expect(setup.route).toBe('/release-control/setup');
});
it('all Release Control child routes are under /release-control/', () => {
const rc = component.navSections.find((s) => s.id === 'release-control')!;
for (const child of rc.children!) {
expect(child.route).toMatch(/^\/release-control\//);
}
});
it('Policy Governance child label is the clean canonical name', () => {
const admin = component.navSections.find((s) => s.id === 'administration')!;
const policyItem = admin.children!.find((c) => c.id === 'adm-policy')!;
expect(policyItem.label).toBe('Policy Governance');
});
it('Administration includes Offline Settings shortcut', () => {
const admin = component.navSections.find((s) => s.id === 'administration')!;
const offlineItem = admin.children!.find((c) => c.id === 'adm-offline')!;
expect(offlineItem.label).toBe('Offline Settings');
expect(offlineItem.route).toBe('/administration/offline');
});
it('Platform Ops includes Data Integrity as the first submenu shortcut', () => {
const platformOps = component.navSections.find((s) => s.id === 'platform-ops')!;
expect(platformOps.children?.[0].id).toBe('ops-data-integrity');
expect(platformOps.children?.[0].route).toBe('/platform-ops/data-integrity');
});
it('Evidence & Audit keeps Evidence Packs and Evidence Bundles as distinct nav entries', () => {
const evidence = component.navSections.find((s) => s.id === 'evidence-audit')!;
const packs = evidence.children?.find((child) => child.id === 'ea-packs');
const bundles = evidence.children?.find((child) => child.id === 'ea-bundles');
expect(packs?.label).toBe('Evidence Packs');
expect(packs?.route).toBe('/evidence-audit/packs');
expect(bundles?.label).toBe('Evidence Bundles');
expect(bundles?.route).toBe('/evidence-audit/bundles');
});
it('no section root route uses a deprecated v1 prefix', () => {
const legacyRootSegments = ['security', 'operations', 'settings', 'evidence', 'policy'];
it('no section root route uses deprecated root prefixes', () => {
const legacyRootSegments = ['release-control', 'security-risk', 'evidence-audit', 'platform-ops'];
for (const section of component.navSections) {
const rootSegment = section.route.replace(/^\/+/, '').split('/')[0] ?? '';
expect(legacyRootSegments).not.toContain(rootSegment);
}
});
it('no child route uses a deprecated v1 prefix', () => {
const legacyRootSegments = ['security', 'operations', 'settings', 'evidence', 'policy'];
for (const section of component.navSections) {
for (const child of section.children ?? []) {
const rootSegment = child.route.replace(/^\/+/, '').split('/')[0] ?? '';
expect(legacyRootSegments).not.toContain(rootSegment);
}
}
});
});

View File

@@ -3,12 +3,14 @@ import { provideRouter, type Route } from '@angular/router';
import { AUTH_SERVICE } from '../../app/core/auth';
import { AppSidebarComponent } from '../../app/layout/app-sidebar/app-sidebar.component';
import { ADMINISTRATION_ROUTES } from '../../app/routes/administration.routes';
import { EVIDENCE_AUDIT_ROUTES } from '../../app/routes/evidence-audit.routes';
import { PLATFORM_OPS_ROUTES } from '../../app/routes/platform-ops.routes';
import { RELEASE_CONTROL_ROUTES } from '../../app/routes/release-control.routes';
import { SECURITY_RISK_ROUTES } from '../../app/routes/security-risk.routes';
import { EVIDENCE_ROUTES } from '../../app/routes/evidence.routes';
import { OPERATIONS_ROUTES } from '../../app/routes/operations.routes';
import { PLATFORM_ROUTES } from '../../app/routes/platform.routes';
import { RELEASES_ROUTES } from '../../app/routes/releases.routes';
import { SECURITY_ROUTES } from '../../app/routes/security.routes';
import { TOPOLOGY_ROUTES } from '../../app/routes/topology.routes';
import { integrationHubRoutes } from '../../app/features/integration-hub/integration-hub.routes';
import { PLATFORM_SETUP_ROUTES } from '../../app/features/platform/setup/platform-setup.routes';
function joinPath(prefix: string, path: string | undefined): string | null {
if (path === undefined) return null;
@@ -57,49 +59,59 @@ describe('AppSidebarComponent route integrity (navigation)', () => {
it('every sidebar route resolves to a concrete canonical route', () => {
const allowed = new Set<string>([
'/dashboard',
'/release-control',
'/security-risk',
'/evidence-audit',
'/integrations',
'/platform-ops',
'/administration',
'/releases',
'/security',
'/evidence',
'/topology',
'/platform',
]);
for (const path of collectConcretePaths('/release-control', RELEASE_CONTROL_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/security-risk', SECURITY_RISK_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/evidence-audit', EVIDENCE_AUDIT_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/integrations', integrationHubRoutes)) allowed.add(path);
for (const path of collectConcretePaths('/platform-ops', PLATFORM_OPS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/administration', ADMINISTRATION_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/releases', RELEASES_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/security', SECURITY_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/evidence', EVIDENCE_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/topology', TOPOLOGY_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform', PLATFORM_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path);
for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path);
allowed.add('/security/supply-chain-data/lake');
for (const section of component.navSections) {
expect(allowed.has(section.route)).toBeTrue();
for (const child of section.children ?? []) {
expect(allowed.has(child.route)).toBeTrue();
}
}
});
it('includes required canonical shell routes from active UI v2 sprints', () => {
it('includes required canonical shell routes from active Pack22 sprints', () => {
const allowed = new Set<string>();
for (const path of collectConcretePaths('/release-control', RELEASE_CONTROL_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/security-risk', SECURITY_RISK_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/evidence-audit', EVIDENCE_AUDIT_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/integrations', integrationHubRoutes)) allowed.add(path);
for (const path of collectConcretePaths('/platform-ops', PLATFORM_OPS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/releases', RELEASES_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/security', SECURITY_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/evidence', EVIDENCE_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/topology', TOPOLOGY_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path);
for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path);
allowed.add('/security/supply-chain-data/lake');
const required = [
'/release-control/setup',
'/release-control/setup/environments-paths',
'/release-control/setup/targets-agents',
'/release-control/setup/workflows',
'/release-control/setup/bundle-templates',
'/security-risk/advisory-sources',
'/evidence-audit/replay',
'/evidence-audit/timeline',
'/platform-ops/feeds',
'/integrations/targets',
'/releases/versions',
'/releases/runs',
'/security/triage',
'/security/advisories-vex',
'/security/supply-chain-data/lake',
'/evidence/capsules',
'/evidence/verify-replay',
'/topology/agents',
'/platform/ops/jobs-queues',
'/platform/ops/feeds-airgap',
'/platform/integrations/runtime-hosts',
'/platform/integrations/vex-sources',
'/platform/setup/feed-policy',
'/platform/setup/gate-profiles',
'/platform/setup/defaults-guardrails',
'/platform/setup/trust-signing',
];
for (const path of required) {
@@ -109,12 +121,14 @@ describe('AppSidebarComponent route integrity (navigation)', () => {
it('has no duplicate concrete route declarations inside canonical route families', () => {
const routeGroups: Array<{ name: string; paths: string[] }> = [
{ name: 'release-control', paths: collectConcretePathsArray('/release-control', RELEASE_CONTROL_ROUTES) },
{ name: 'security-risk', paths: collectConcretePathsArray('/security-risk', SECURITY_RISK_ROUTES) },
{ name: 'evidence-audit', paths: collectConcretePathsArray('/evidence-audit', EVIDENCE_AUDIT_ROUTES) },
{ name: 'integrations', paths: collectConcretePathsArray('/integrations', integrationHubRoutes) },
{ name: 'platform-ops', paths: collectConcretePathsArray('/platform-ops', PLATFORM_OPS_ROUTES) },
{ name: 'administration', paths: collectConcretePathsArray('/administration', ADMINISTRATION_ROUTES) },
{ name: 'releases', paths: collectConcretePathsArray('/releases', RELEASES_ROUTES) },
{ name: 'security', paths: collectConcretePathsArray('/security', SECURITY_ROUTES) },
{ name: 'evidence', paths: collectConcretePathsArray('/evidence', EVIDENCE_ROUTES) },
{ name: 'topology', paths: collectConcretePathsArray('/topology', TOPOLOGY_ROUTES) },
{ name: 'platform', paths: collectConcretePathsArray('/platform', PLATFORM_ROUTES) },
{ name: 'platform-ops', paths: collectConcretePathsArray('/platform/ops', OPERATIONS_ROUTES) },
{ name: 'platform-integrations', paths: collectConcretePathsArray('/platform/integrations', integrationHubRoutes) },
{ name: 'platform-setup', paths: collectConcretePathsArray('/platform/setup', PLATFORM_SETUP_ROUTES) },
];
for (const group of routeGroups) {

View File

@@ -40,16 +40,20 @@ describe('DataIntegrityOverviewComponent (platform-ops)', () => {
it('provides deep-link routes for all trust signals and drilldowns', () => {
expect(component.trustSignals.map((signal) => signal.route)).toEqual([
'/platform-ops/data-integrity/feeds-freshness',
'/platform-ops/data-integrity/scan-pipeline',
'/platform-ops/data-integrity/reachability-ingest',
'/platform-ops/data-integrity/integration-connectivity',
'/platform-ops/data-integrity/dlq',
'/platform/ops/data-integrity/feeds-freshness',
'/platform/ops/data-integrity/scan-pipeline',
'/platform/ops/data-integrity/reachability-ingest',
'/platform/ops/data-integrity/integration-connectivity',
'/platform/ops/data-integrity/dlq',
]);
const drilldownLinks = fixture.nativeElement.querySelectorAll('.drilldowns a');
expect(drilldownLinks.length).toBe(7);
});
it('uses canonical platform ops routes for top failures', () => {
expect(component.topFailures.every((item) => item.route.startsWith('/platform/ops/'))).toBeTrue();
});
});
describe('NightlyOpsReportPageComponent (platform-ops)', () => {
@@ -115,10 +119,12 @@ describe('FeedsFreshnessPageComponent (platform-ops)', () => {
expect(component.rows.every((row) => row.gateImpact.trim().length > 0)).toBeTrue();
});
it('links to canonical feeds operations page and stays read-only', () => {
it('links to canonical feeds and offline page and stays read-only', () => {
const links = fixture.nativeElement.querySelectorAll('footer.links a');
expect(links.length).toBe(3);
expect((links[0] as HTMLAnchorElement).getAttribute('href')).toContain('/platform-ops/feeds');
expect((links[0] as HTMLAnchorElement).getAttribute('href')).toContain('/platform/ops/feeds-airgap');
expect((links[1] as HTMLAnchorElement).getAttribute('href')).toContain('tab=version-locks');
expect((links[2] as HTMLAnchorElement).getAttribute('href')).toContain('tab=feed-mirrors');
expect(fixture.nativeElement.querySelectorAll('input, textarea').length).toBe(0);
});
});
@@ -228,7 +234,7 @@ describe('DlqReplaysPageComponent (platform-ops)', () => {
it('links to canonical dead-letter page', () => {
const link = fixture.nativeElement.querySelector('footer.links a') as HTMLAnchorElement | null;
expect(link).toBeTruthy();
expect(link?.getAttribute('href')).toContain('/platform-ops/dead-letter');
expect(link?.getAttribute('href')).toContain('/platform/ops/dead-letter');
});
});

View File

@@ -0,0 +1,43 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, ParamMap, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { PlatformFeedsAirgapPageComponent } from '../../app/features/platform/ops/platform-feeds-airgap-page.component';
describe('PlatformFeedsAirgapPageComponent (platform-ops)', () => {
let fixture: ComponentFixture<PlatformFeedsAirgapPageComponent>;
let component: PlatformFeedsAirgapPageComponent;
let queryParamMap$: BehaviorSubject<ParamMap>;
beforeEach(async () => {
queryParamMap$ = new BehaviorSubject(convertToParamMap({ tab: 'version-locks' }));
await TestBed.configureTestingModule({
imports: [PlatformFeedsAirgapPageComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
queryParamMap: queryParamMap$.asObservable(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PlatformFeedsAirgapPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('selects tab from query param when value is valid', () => {
expect(component.tab()).toBe('version-locks');
});
it('ignores unknown tab query values and keeps current tab', () => {
queryParamMap$.next(convertToParamMap({ tab: 'unknown-tab' }));
fixture.detectChanges();
expect(component.tab()).toBe('version-locks');
});
});

View File

@@ -149,7 +149,8 @@ const CANONICAL_INTEGRATION_CATEGORIES = [
'registries',
'scm',
'ci',
'hosts',
'runtime-hosts',
'vex-sources',
'secrets',
'feeds',
'notifications',
@@ -183,9 +184,9 @@ describe('integrationHubRoutes (platform-ops)', () => {
expect(notif).toBeDefined();
});
it('hosts category uses canonical Targets / Runtimes breadcrumb', () => {
it('hosts path is a topology ownership redirect', () => {
const hosts = integrationHubRoutes.find((r) => r.path === 'hosts');
expect(hosts?.data?.['breadcrumb']).toBe('Targets / Runtimes');
expect(hosts?.redirectTo).toBe('/topology/hosts');
});
it('activity route is present', () => {

View File

@@ -0,0 +1,23 @@
import { PLATFORM_SETUP_ROUTES } from '../../app/features/platform/setup/platform-setup.routes';
describe('PLATFORM_SETUP_ROUTES (platform)', () => {
it('renders feed policy as setup-owned page instead of ops redirect', () => {
const route = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'feed-policy');
expect(route).toBeDefined();
expect(route?.redirectTo).toBeUndefined();
expect(route?.loadComponent).toBeDefined();
});
it('includes dedicated gate profiles and defaults guardrails routes', () => {
const gateProfiles = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'gate-profiles');
const defaults = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'defaults-guardrails');
expect(gateProfiles?.loadComponent).toBeDefined();
expect(defaults?.loadComponent).toBeDefined();
});
it('keeps defaults alias redirect for compatibility', () => {
const alias = PLATFORM_SETUP_ROUTES.find((item) => item.path === 'defaults');
expect(alias?.redirectTo).toBe('defaults-guardrails');
});
});

View File

@@ -30,6 +30,44 @@ class ReleaseManagementStoreStub {
readonly deleteRelease = jasmine.createSpy('deleteRelease');
}
function buildRelease(overrides: Partial<ManagedRelease>): ManagedRelease {
return {
id: 'rel-default',
name: 'Default Release',
version: '1.0.0',
description: 'default',
status: 'ready',
releaseType: 'standard',
slug: 'default-release-1-0-0',
digest: 'sha256:default',
currentStage: 'pending_approval',
currentEnvironment: 'dev',
targetEnvironment: 'staging',
targetRegion: 'us-east',
componentCount: 1,
gateStatus: 'pending',
gateBlockingCount: 0,
gatePendingApprovals: 0,
gateBlockingReasons: [],
riskCriticalReachable: 0,
riskHighReachable: 0,
riskTrend: 'stable',
riskTier: 'none',
evidencePosture: 'partial',
needsApproval: false,
blocked: false,
hotfixLane: false,
replayMismatch: false,
createdAt: '2026-02-19T07:00:00Z',
createdBy: 'release-bot',
updatedAt: '2026-02-19T07:00:00Z',
lastActor: 'release-bot',
deployedAt: null,
deploymentStrategy: 'rolling',
...overrides,
};
}
describe('ReleaseListComponent loading behavior', () => {
let fixture: ComponentFixture<ReleaseListComponent>;
let store: ReleaseManagementStoreStub;
@@ -37,36 +75,68 @@ describe('ReleaseListComponent loading behavior', () => {
beforeEach(async () => {
store = new ReleaseManagementStoreStub();
store.releases.set([
{
buildRelease({
id: 'rel-hotfix-124',
name: 'Hotfix',
version: '1.2.4',
description: 'Critical reachable CVE hotfix',
status: 'ready',
releaseType: 'hotfix',
slug: 'hotfix-1-2-4',
digest: 'sha256:hotfix124',
currentStage: 'pending_approval',
currentEnvironment: 'staging',
targetEnvironment: 'production',
targetRegion: 'us-east',
componentCount: 8,
gateStatus: 'pending',
gateBlockingCount: 1,
gatePendingApprovals: 1,
gateBlockingReasons: ['approval_pending'],
riskCriticalReachable: 1,
riskHighReachable: 2,
riskTier: 'critical',
evidencePosture: 'partial',
needsApproval: true,
blocked: true,
hotfixLane: true,
createdAt: '2026-02-19T07:00:00Z',
createdBy: 'release-bot',
updatedAt: '2026-02-19T07:00:00Z',
lastActor: 'release-bot',
deployedAt: null,
deploymentStrategy: 'rolling',
},
{
}),
buildRelease({
id: 'rel-platform-130-rc1',
name: 'Platform Release',
version: '1.3.0-rc1',
description: 'Quarterly platform baseline update',
status: 'ready',
releaseType: 'standard',
slug: 'platform-release-1-3-0-rc1',
digest: 'sha256:platform130',
currentStage: 'pending_approval',
currentEnvironment: 'dev',
targetEnvironment: 'staging',
targetRegion: 'eu-west',
componentCount: 24,
gateStatus: 'pass',
gateBlockingCount: 0,
gatePendingApprovals: 0,
gateBlockingReasons: [],
riskCriticalReachable: 0,
riskHighReachable: 1,
riskTier: 'high',
evidencePosture: 'verified',
needsApproval: false,
blocked: false,
hotfixLane: false,
createdAt: '2026-02-18T14:30:00Z',
createdBy: 'release-bot',
updatedAt: '2026-02-18T14:30:00Z',
lastActor: 'release-bot',
deployedAt: null,
deploymentStrategy: 'rolling',
},
}),
]);
store.totalCount.set(2);
store.statusCounts.set({
@@ -95,7 +165,7 @@ describe('ReleaseListComponent loading behavior', () => {
expect(text).toContain('Hotfix');
expect(text).toContain('Platform Release');
expect(text).not.toContain('Loading releases...');
expect(store.loadReleases).toHaveBeenCalled();
expect(store.setFilter).toHaveBeenCalled();
});
it('shows explicit error banner when backend loading fails', () => {

View File

@@ -1,5 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { FindingDetailPageComponent } from '../../app/features/security-risk/finding-detail-page.component';
import { VulnerabilityDetailPageComponent } from '../../app/features/security-risk/vulnerability-detail-page.component';
@@ -16,6 +18,47 @@ describe('Security & Risk detail stubs', () => {
snapshot: {
paramMap: convertToParamMap({ findingId: 'FND-001' }),
},
paramMap: of(convertToParamMap({ findingId: 'FND-001' })),
queryParamMap: of(convertToParamMap({ tab: 'summary' })),
},
},
{
provide: HttpClient,
useValue: {
get: (url: string) => {
if (url.startsWith('/api/v2/security/disposition/')) {
return of({
item: {
findingId: 'FND-001',
cveId: 'CVE-2026-0001',
releaseId: 'rel-1',
releaseName: 'Release A',
packageName: 'openssl',
componentName: 'gateway',
environment: 'prod',
region: 'us-east',
effectiveDisposition: 'action_required',
policyAction: 'block',
updatedAt: '2026-02-20T10:00:00Z',
vex: { status: 'under_review', justification: 'pending analysis', statementId: null },
exception: { status: 'none', reason: 'not requested', approvalState: 'not_required', expiresAt: null },
},
});
}
return of({
items: [
{
findingId: 'FND-001',
cveId: 'CVE-2026-0001',
severity: 'high',
reachable: true,
reachabilityScore: 78,
updatedAt: '2026-02-20T10:00:00Z',
},
],
});
},
},
},
],
@@ -25,13 +68,13 @@ describe('Security & Risk detail stubs', () => {
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Finding FND-001');
expect(text).toContain('Reachability');
expect(text).toContain('Impact');
expect(text).toContain('Disposition');
expect(text).toContain('Create Exception Request');
expect(text).toContain('CVE-2026-0001');
expect(text).toContain('Effective VEX');
expect(text).toContain('Waivers/Exceptions');
expect(text).toContain('Policy Gate Trace');
expect(text).toContain('Back to triage');
expect(text).toContain('Search/Import VEX');
expect(text).toContain('Export as Evidence');
expect(text).toContain('Create waiver request');
});
it('renders vulnerability detail sections and actions', async () => {

View File

@@ -0,0 +1,178 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
ActivatedRoute,
ParamMap,
Router,
convertToParamMap,
provideRouter,
} from '@angular/router';
import { BehaviorSubject, EMPTY, of } from 'rxjs';
import { EXCEPTION_API, ExceptionApi } from '../../app/core/api/exception.client';
import {
EXCEPTION_EVENTS_API,
ExceptionEventsApi,
} from '../../app/core/api/exception-events.client';
import { Exception } from '../../app/core/api/exception.contract.models';
import { AuthSession } from '../../app/core/auth/auth-session.model';
import { AuthSessionStore } from '../../app/core/auth/auth-session.store';
import { StellaOpsScopes } from '../../app/core/auth/scopes';
import { ExceptionDashboardComponent } from '../../app/features/exceptions/exception-dashboard.component';
describe('Security-risk Exceptions dashboard route behavior', () => {
let paramMapSubject: BehaviorSubject<ParamMap>;
let exceptionApi: jasmine.SpyObj<ExceptionApi>;
let eventsApi: jasmine.SpyObj<ExceptionEventsApi>;
let navigateSpy: jasmine.Spy;
const mockException: Exception = {
schemaVersion: '1.0',
tenantId: 'tenant-001',
exceptionId: 'exc-001',
name: 'openssl-risk-acceptance',
displayName: 'OpenSSL risk acceptance',
description: 'Temporary acceptance while patch rollout completes.',
type: 'vulnerability',
severity: 'high',
status: 'approved',
scope: {
type: 'component',
tenantId: 'tenant-001',
componentPurls: ['pkg:deb/openssl@3.0.2'],
vulnIds: ['CVE-2026-1234'],
},
justification: {
text: 'Mitigating controls are in place; awaiting upstream release.',
},
timebox: {
startDate: '2026-02-01T00:00:00Z',
endDate: '2026-03-01T00:00:00Z',
},
approvals: [
{
approvalId: 'apr-001',
approvedBy: 'sec.lead@example.com',
approvedAt: '2026-02-02T10:00:00Z',
comment: 'Approved for 30 days with weekly review.',
},
],
auditTrail: [
{
auditId: 'audit-001',
action: 'approved',
actor: 'sec.lead@example.com',
timestamp: '2026-02-02T10:00:00Z',
previousStatus: 'pending_review',
newStatus: 'approved',
},
],
labels: {
evidenceHash: 'sha256:abcdef1234567890',
evidenceLink: 'https://evidence.local/exceptions/exc-001',
},
createdBy: 'analyst@example.com',
createdAt: '2026-02-01T00:00:00Z',
updatedAt: '2026-02-02T10:00:00Z',
};
beforeEach(async () => {
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({}));
exceptionApi = jasmine.createSpyObj('ExceptionApi', [
'listExceptions',
'createException',
'updateException',
'transitionStatus',
]) as unknown as jasmine.SpyObj<ExceptionApi>;
eventsApi = jasmine.createSpyObj('ExceptionEventsApi', [
'streamEvents',
]) as unknown as jasmine.SpyObj<ExceptionEventsApi>;
exceptionApi.listExceptions.and.returnValue(
of({ items: [mockException], count: 1, continuationToken: null })
);
exceptionApi.createException.and.returnValue(of(mockException));
exceptionApi.updateException.and.returnValue(of(mockException));
exceptionApi.transitionStatus.and.returnValue(of(mockException));
eventsApi.streamEvents.and.returnValue(EMPTY);
await TestBed.configureTestingModule({
imports: [ExceptionDashboardComponent],
providers: [
provideRouter([]),
{ provide: EXCEPTION_API, useValue: exceptionApi },
{ provide: EXCEPTION_EVENTS_API, useValue: eventsApi },
{
provide: AuthSessionStore,
useValue: {
session: signal<AuthSession | null>({
scopes: [StellaOpsScopes.EXCEPTION_WRITE, StellaOpsScopes.EXCEPTION_APPROVE],
} as unknown as AuthSession),
},
},
{
provide: ActivatedRoute,
useValue: {
paramMap: paramMapSubject.asObservable(),
snapshot: { paramMap: convertToParamMap({}) },
},
},
],
}).compileComponents();
const router = TestBed.inject(Router);
navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
});
it('renders exceptions list semantics for /security-risk/exceptions', async () => {
const fixture = TestBed.createComponent(ExceptionDashboardComponent);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Exception Center');
expect(text).toContain('Manage policy exceptions with auditable workflows.');
expect(text).toContain('Approval Queue');
expect(text).toContain('OpenSSL risk acceptance');
expect(text).toContain('Approved');
});
it('shows detail semantics for /security-risk/exceptions/:exceptionId and supports list back-nav', async () => {
paramMapSubject.next(convertToParamMap({ exceptionId: 'exc-001' }));
const fixture = TestBed.createComponent(ExceptionDashboardComponent);
const component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Status');
expect(text).toContain('Scope');
expect(text).toContain('Expires');
expect(text).toContain('Evidence');
expect(text).toContain('Approvals');
expect(text).toContain('sec.lead@example.com');
expect(text).toContain('sha256:abcdef1234567890');
component.closeDetail();
expect(navigateSpy).toHaveBeenCalledWith(['../'], {
relativeTo: jasmine.anything(),
});
});
it('navigates deterministically from list row to detail route', async () => {
const fixture = TestBed.createComponent(ExceptionDashboardComponent);
const component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
component.selectException(component.viewExceptions()[0]);
expect(navigateSpy).toHaveBeenCalledWith(['exc-001'], {
relativeTo: jasmine.anything(),
});
});
});

View File

@@ -10,6 +10,13 @@ describe('SECURITY_RISK_ROUTES', () => {
const getRouteByPath = (path: string): Route | undefined =>
SECURITY_RISK_ROUTES.find((r) => r.path === path);
const loadComponentByPath = async (path: string): Promise<unknown> => {
const route = getRouteByPath(path);
expect(route).toBeDefined();
expect(route?.loadComponent).toBeDefined();
return await route!.loadComponent!();
};
const allPaths = SECURITY_RISK_ROUTES.map((r) => r.path);
// ──────────────────────────────────────────
@@ -64,6 +71,15 @@ describe('SECURITY_RISK_ROUTES', () => {
expect(allPaths).toContain('exceptions');
});
it('contains the exceptions detail route with canonical param name', () => {
expect(allPaths).toContain('exceptions/:exceptionId');
expect(allPaths).not.toContain('exceptions/:id');
});
it('contains the exception approvals route', () => {
expect(allPaths).toContain('exceptions/approvals');
});
it('contains the lineage route', () => {
expect(allPaths).toContain('lineage');
});
@@ -137,6 +153,14 @@ describe('SECURITY_RISK_ROUTES', () => {
expect(getRouteByPath('vex')?.data?.['breadcrumb']).toBe('VEX Hub');
});
it('exceptions route has "Exceptions" breadcrumb', () => {
expect(getRouteByPath('exceptions')?.data?.['breadcrumb']).toBe('Exceptions');
});
it('exceptions detail route has "Exception Detail" breadcrumb', () => {
expect(getRouteByPath('exceptions/:exceptionId')?.data?.['breadcrumb']).toBe('Exception Detail');
});
it('sbom route has "SBOM Graph" breadcrumb', () => {
expect(getRouteByPath('sbom')?.data?.['breadcrumb']).toBe('SBOM Graph');
});
@@ -149,6 +173,21 @@ describe('SECURITY_RISK_ROUTES', () => {
expect(getRouteByPath('lineage')?.data?.['breadcrumb']).toBe('Lineage');
});
it('exceptions route loads ExceptionDashboardComponent', async () => {
const component = await loadComponentByPath('exceptions');
expect((component as { name?: string }).name).toContain('ExceptionDashboardComponent');
});
it('exceptions detail route loads ExceptionDashboardComponent', async () => {
const component = await loadComponentByPath('exceptions/:exceptionId');
expect((component as { name?: string }).name).toContain('ExceptionDashboardComponent');
});
it('exception approvals route loads ExceptionApprovalQueueComponent', async () => {
const component = await loadComponentByPath('exceptions/approvals');
expect((component as { name?: string }).name).toContain('ExceptionApprovalQueueComponent');
});
// ──────────────────────────────────────────
// Route count sanity check
// ──────────────────────────────────────────

View File

@@ -1,76 +1,117 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
import { SecurityFindingsPageComponent } from '../../app/features/security/security-findings-page.component';
import { SECURITY_FINDINGS_API, type SecurityFindingsApi } from '../../app/core/api/security-findings.client';
interface FindingProjectionFixture {
findingId: string;
cveId: string;
severity: string;
packageName: string;
componentName: string;
releaseId: string;
releaseName: string;
environment: string;
region: string;
reachable: boolean;
reachabilityScore: number;
effectiveDisposition: string;
vexStatus: string;
exceptionStatus: string;
updatedAt: string;
}
class PlatformContextStoreStub {
readonly selectedRegions = signal<string[]>([]);
readonly selectedEnvironments = signal<string[]>([]);
readonly contextVersion = signal(0);
readonly initialize = jasmine.createSpy('initialize');
}
const findingsFixture: FindingProjectionFixture[] = [
{
findingId: 'finding-1',
cveId: 'CVE-2026-1234',
severity: 'critical',
packageName: 'log4j-core',
componentName: 'checkout-api',
releaseId: 'rel-1',
releaseName: 'checkout@4.1.0',
environment: 'prod',
region: 'us-east',
reachable: true,
reachabilityScore: 92,
effectiveDisposition: 'action_required',
vexStatus: 'affected',
exceptionStatus: 'none',
updatedAt: '2026-02-20T10:00:00Z',
},
{
findingId: 'finding-2',
cveId: 'CVE-2026-5678',
severity: 'high',
packageName: 'openssl',
componentName: 'worker',
releaseId: 'rel-2',
releaseName: 'worker@4.1.1',
environment: 'staging',
region: 'eu-west',
reachable: false,
reachabilityScore: 64,
effectiveDisposition: 'monitor',
vexStatus: 'under_investigation',
exceptionStatus: 'pending',
updatedAt: '2026-02-20T09:00:00Z',
},
{
findingId: 'finding-3',
cveId: 'CVE-2026-9999',
severity: 'high',
packageName: 'nginx',
componentName: 'edge',
releaseId: 'rel-3',
releaseName: 'edge@4.0.9',
environment: 'prod',
region: 'us-east',
reachable: true,
reachabilityScore: 88,
effectiveDisposition: 'action_required',
vexStatus: 'affected',
exceptionStatus: 'approved',
updatedAt: '2026-02-20T08:00:00Z',
},
];
describe('Release-Aware Security Findings (B29-002)', () => {
let fixture: ComponentFixture<SecurityFindingsPageComponent>;
let component: SecurityFindingsPageComponent;
let httpClientMock: { get: jasmine.Spy };
beforeEach(async () => {
const findingsApiMock: SecurityFindingsApi = {
listFindings: () =>
of([
{
id: 'CVE-2026-1234',
package: 'log4j-core',
version: '2.17.0',
severity: 'CRITICAL',
cvss: 9.8,
reachable: true,
reachabilityConfidence: 92,
vexStatus: 'affected',
releaseId: 'rel-1',
releaseVersion: 'prod@4.1.0',
delta: 'new',
environments: ['Prod'],
firstSeen: '1h ago',
},
{
id: 'CVE-2026-5678',
package: 'openssl',
version: '3.0.8',
severity: 'HIGH',
cvss: 8.1,
reachable: false,
reachabilityConfidence: 64,
vexStatus: 'under_investigation',
releaseId: 'rel-2',
releaseVersion: 'staging@4.1.1',
delta: 'carried',
environments: ['Staging'],
firstSeen: '3h ago',
},
]),
getFinding: () =>
of({
id: 'CVE-2026-1234',
package: 'log4j-core',
version: '2.17.0',
severity: 'CRITICAL',
cvss: 9.8,
reachable: true,
reachabilityConfidence: 92,
vexStatus: 'affected',
releaseId: 'rel-1',
releaseVersion: 'prod@4.1.0',
delta: 'new',
environments: ['Prod'],
firstSeen: '1h ago',
description: 'placeholder',
references: [],
affectedVersions: [],
fixedVersions: [],
}),
};
httpClientMock = jasmine.createSpyObj('HttpClient', ['get']);
httpClientMock.get.and.callFake((url: string, options?: { params?: HttpParams }) => {
if (url === '/api/v2/security/findings') {
return of({
items: findingsFixture,
total: findingsFixture.length,
pivot: options?.params?.get('pivot') ?? 'cve',
facets: [],
});
}
return of([]);
});
await TestBed.configureTestingModule({
imports: [SecurityFindingsPageComponent],
providers: [
provideRouter([]),
{ provide: SECURITY_FINDINGS_API, useValue: findingsApiMock },
{ provide: HttpClient, useValue: httpClientMock },
{ provide: PlatformContextStore, useClass: PlatformContextStoreStub },
],
}).compileComponents();
@@ -79,95 +120,49 @@ describe('Release-Aware Security Findings (B29-002)', () => {
fixture.detectChanges();
});
it('updates the findings list when the search query changes', () => {
component.searchQuery.set('log4j');
const ids = component.filteredFindings().map((finding) => finding.id);
expect(ids).toEqual(['CVE-2026-1234']);
});
it('renders explicit release impact and delta columns for each finding row', () => {
it('renders the unified findings explorer columns and rows', () => {
const headers = Array.from(
fixture.nativeElement.querySelectorAll('th') as NodeListOf<HTMLElement>,
).map((header) => header.textContent?.trim());
).map((header) => (header.textContent ?? '').trim());
expect(headers).toContain('Release Impact');
expect(headers).toContain('Delta');
expect(headers).toEqual([
'Verdict',
'CVE',
'Component',
'Artifact',
'Env',
'Reachability',
'VEX',
'Waiver',
'Updated',
]);
const deltaBadges = fixture.nativeElement.querySelectorAll('.delta-badge') as NodeListOf<HTMLElement>;
expect(deltaBadges.length).toBeGreaterThan(0);
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('CVE-2026-1234');
expect(text).toContain('CVE-2026-5678');
expect(text).toContain('CVE-2026-9999');
});
it('sorts findings deterministically by severity rank, then CVSS, then CVE ID', () => {
component.findings.set([
{
id: 'CVE-2026-3000',
package: 'pkg-z',
version: '1.0.0',
severity: 'HIGH',
cvss: 7.5,
reachable: true,
reachabilityConfidence: 80,
vexStatus: 'affected',
releaseId: 'rel-z',
releaseVersion: 'prod@3.0.0',
delta: 'carried',
environments: ['Prod'],
firstSeen: '1h ago',
},
{
id: 'CVE-2026-1000',
package: 'pkg-a',
version: '1.0.0',
severity: 'CRITICAL',
cvss: 9.8,
reachable: true,
reachabilityConfidence: 90,
vexStatus: 'affected',
releaseId: 'rel-a',
releaseVersion: 'prod@3.1.0',
delta: 'new',
environments: ['Prod'],
firstSeen: '1h ago',
},
{
id: 'CVE-2026-2000',
package: 'pkg-b',
version: '1.0.0',
severity: 'HIGH',
cvss: 8.2,
reachable: false,
reachabilityConfidence: 60,
vexStatus: 'under_investigation',
releaseId: 'rel-b',
releaseVersion: 'staging@3.0.1',
delta: 'regressed',
environments: ['Staging'],
firstSeen: '2h ago',
},
{
id: 'CVE-2026-1999',
package: 'pkg-c',
version: '1.0.0',
severity: 'HIGH',
cvss: 8.2,
reachable: false,
reachabilityConfidence: 60,
vexStatus: 'under_investigation',
releaseId: 'rel-c',
releaseVersion: 'staging@3.0.2',
delta: 'resolved',
environments: ['Staging'],
firstSeen: '2h ago',
},
]);
it('filters rows by reachability facet', () => {
spyOn(component, 'reloadFromFilters').and.stub();
const ids = component.filteredFindings().map((finding) => finding.id);
expect(ids).toEqual([
'CVE-2026-1000',
'CVE-2026-1999',
'CVE-2026-2000',
'CVE-2026-3000',
]);
component.rows.set(findingsFixture);
component.filtered.set(findingsFixture);
component.reachabilityFacet = 'reachable';
component.applyLocalFacets();
expect(component.filtered().map((item) => item.findingId)).toEqual(['finding-1', 'finding-3']);
});
it('filters rows by promotion blocking facet', () => {
spyOn(component, 'reloadFromFilters').and.stub();
component.rows.set(findingsFixture);
component.filtered.set(findingsFixture);
component.blocksFacet = 'true';
component.applyLocalFacets();
expect(component.filtered().map((item) => item.findingId)).toEqual(['finding-1', 'finding-3']);
expect(component.filtered().every((item) => item.effectiveDisposition === 'action_required')).toBeTrue();
});
});

View File

@@ -0,0 +1,33 @@
import { TOPOLOGY_ROUTES } from '../../app/routes/topology.routes';
describe('TOPOLOGY_ROUTES dedicated pages', () => {
async function loadComponentName(path: string): Promise<string | null> {
const route = TOPOLOGY_ROUTES.find((item) => item.path === path);
expect(route).toBeDefined();
expect(route?.loadComponent).toBeDefined();
const component = await route!.loadComponent!();
return (component as { name?: string } | null)?.name ?? null;
}
it('overview uses dedicated topology overview page', async () => {
expect(await loadComponentName('overview')).toContain('TopologyOverviewPageComponent');
});
it('regions and environments routes use dedicated regions-environments page', async () => {
expect(await loadComponentName('regions')).toContain('TopologyRegionsEnvironmentsPageComponent');
expect(await loadComponentName('environments')).toContain('TopologyRegionsEnvironmentsPageComponent');
});
it('environment posture route uses topology detail tabs page', async () => {
expect(await loadComponentName('environments/:environmentId/posture')).toContain(
'TopologyEnvironmentDetailPageComponent',
);
});
it('targets, hosts, agents, and promotion paths use dedicated pages', async () => {
expect(await loadComponentName('targets')).toContain('TopologyTargetsPageComponent');
expect(await loadComponentName('hosts')).toContain('TopologyHostsPageComponent');
expect(await loadComponentName('agents')).toContain('TopologyAgentsPageComponent');
expect(await loadComponentName('promotion-paths')).toContain('TopologyPromotionPathsPageComponent');
});
});

View File

@@ -76,75 +76,50 @@ interface PackExpectation {
const conformanceFilter = process.env.PACK_CONFORMANCE_FILTER?.trim();
const screenshotDir = process.env.PACK_SCREENSHOT_DIR?.trim();
const screenshotAbsDir = screenshotDir ? path.resolve(process.cwd(), screenshotDir) : null;
const endpointMatrixFile = process.env.PACK_ENDPOINT_MATRIX_FILE?.trim();
const endpointMatrixAbsFile = endpointMatrixFile ? path.resolve(process.cwd(), endpointMatrixFile) : null;
const EXPECTATIONS: PackExpectation[] = [
{ pack: '16', path: '/dashboard', text: /Dashboard/i, canonical: /\/dashboard$/ },
{ pack: '22', path: '/dashboard', text: /Dashboard/i, canonical: /\/dashboard$/ },
{ pack: '8', path: '/release-control/control-plane', text: /Control Plane|Release Control Home|Dashboard/i, canonical: /\/release-control\/control-plane$/ },
{ pack: '12', path: '/release-control/bundles', text: /Bundle/i, canonical: /\/release-control\/bundles$/ },
{ pack: '12', path: '/release-control/bundles/create', text: /Create Bundle|Bundle Builder|Bundle Organizer/i, canonical: /\/release-control\/bundles\/create$/ },
{ pack: '12', path: '/release-control/bundles/platform-release/organizer', text: /Bundle Organizer|Create Bundle Version|Select Components/i, canonical: /\/release-control\/bundles\/platform-release\/organizer$/ },
{ pack: '12', path: '/release-control/bundles/platform-release', text: /Bundle Detail|Latest manifest digest|Version timeline/i, canonical: /\/release-control\/bundles\/platform-release$/ },
{ pack: '12', path: '/release-control/bundles/platform-release/versions/version-3', text: /Bundle manifest digest|Version|Materialization/i, canonical: /\/release-control\/bundles\/platform-release\/versions\/version-3$/ },
{ pack: '13', path: '/release-control/releases', text: /Release/i, canonical: /\/release-control\/releases$/ },
{ pack: '13', path: '/release-control/approvals', text: /Approval/i, canonical: /\/release-control\/approvals$/ },
{ pack: '14', path: '/release-control/runs', text: /Run Timeline|Pipeline Runs|Runs/i, canonical: /\/release-control\/runs$/ },
{ pack: '11', path: '/release-control/regions', text: /Region|Environment/i, canonical: /\/release-control\/regions$/ },
{ pack: '11', path: '/release-control/regions/us-east', text: /Region|us-east|Environment/i, canonical: /\/release-control\/regions\/us-east$/ },
{ pack: '18', path: '/release-control/regions/us-east/environments/staging', text: /Environment|staging|Deploy|SBOM/i, canonical: /\/release-control\/regions\/us-east\/environments\/staging$/ },
{ pack: '11', path: '/release-control/governance', text: /Governance|Policy/i, canonical: /\/release-control\/governance/ },
{ pack: '8', path: '/release-control/hotfixes', text: /Hotfix/i, canonical: /\/release-control\/hotfixes$/ },
{ pack: '21', path: '/release-control/setup', text: /Setup/i, canonical: /\/release-control\/setup$/ },
{ pack: '21', path: '/release-control/setup/environments-paths', text: /Environment|Promotion Path/i, canonical: /\/release-control\/setup\/environments-paths$/ },
{ pack: '21', path: '/release-control/setup/targets-agents', text: /Targets|Agents/i, canonical: /\/release-control\/setup\/targets-agents$/ },
{ pack: '21', path: '/release-control/setup/workflows', text: /Workflow/i, canonical: /\/release-control\/setup\/workflows$/ },
{ pack: '22', path: '/releases', text: /Release|Run/i, canonical: /\/releases\/runs$/ },
{ pack: '22', path: '/releases/new', text: /Create Release|Version|Release/i, canonical: /\/releases\/versions\/new$/ },
{ pack: '22', path: '/releases/activity', text: /Activity|Release|Run/i, canonical: /\/releases\/runs$/ },
{ pack: '22', path: '/releases/approvals', text: /Approval/i, canonical: /\/releases\/approvals$/ },
{ pack: '19', path: '/security-risk', text: /Risk Overview|Security/i, canonical: /\/security-risk$/ },
{ pack: '19', path: '/security-risk/findings', text: /Findings/i, canonical: /\/security-risk\/findings$/ },
{ pack: '19', path: '/security-risk/findings/fnd-001', text: /Finding Detail|Finding/i, canonical: /\/security-risk\/findings\/fnd-001$/ },
{ pack: '19', path: '/security-risk/vulnerabilities', text: /Vulnerab/i, canonical: /\/security-risk\/vulnerabilities$/ },
{ pack: '19', path: '/security-risk/vulnerabilities/CVE-2024-1234', text: /Vulnerability|CVE/i, canonical: /\/security-risk\/vulnerabilities\/CVE-2024-1234$/ },
{ pack: '19', path: '/security-risk/sbom-lake', text: /SBOM Lake|SBOM/i, canonical: /\/security-risk\/sbom-lake$/ },
{ pack: '19', path: '/security-risk/sbom', text: /SBOM Graph|SBOM/i, canonical: /\/security-risk\/sbom$/ },
{ pack: '19', path: '/security-risk/vex', text: /VEX/i, canonical: /\/security-risk\/vex/ },
{ pack: '19', path: '/security-risk/exceptions', text: /Exceptions?/i, canonical: /\/security-risk\/exceptions$/ },
{ pack: '19', path: '/security-risk/advisory-sources', text: /Advisory Sources/i, canonical: /\/security-risk\/advisory-sources$/ },
{ pack: '22', path: '/security', text: /Risk Overview|Security/i, canonical: /\/security\/overview$/ },
{ pack: '22', path: '/security/findings', text: /Findings|Triage/i, canonical: /\/security\/triage$/ },
{ pack: '22', path: '/security/disposition', text: /Disposition|VEX|Exception/i, canonical: /\/security\/advisories-vex$/ },
{ pack: '22', path: '/security/sbom/lake', text: /SBOM|Supply-Chain|Component/i, canonical: /\/security\/supply-chain-data\/lake$/ },
{ pack: '20', path: '/evidence-audit', text: /Find Evidence|Evidence & Audit/i, canonical: /\/evidence-audit$/ },
{ pack: '20', path: '/evidence-audit/packs', text: /Evidence Pack/i, canonical: /\/evidence-audit\/packs$/ },
{ pack: '20', path: '/evidence-audit/bundles', text: /Evidence Bundle/i, canonical: /\/evidence-audit\/bundles$/ },
{ pack: '20', path: '/evidence-audit/evidence/export', text: /Export Center|Profile|Export Runs/i, canonical: /\/evidence-audit\/evidence\/export$/ },
{ pack: '20', path: '/evidence-audit/proofs', text: /Proof Chain/i, canonical: /\/evidence-audit\/proofs/ },
{ pack: '20', path: '/evidence-audit/replay', text: /Replay|Verify/i, canonical: /\/evidence-audit\/replay$/ },
{ pack: '20', path: '/evidence-audit/trust-signing', text: /Trust|Signing/i, canonical: /\/evidence-audit\/trust-signing/ },
{ pack: '20', path: '/evidence-audit/audit-log', text: /Audit Log|Events?/i, canonical: /\/evidence-audit\/audit-log/ },
{ pack: '22', path: '/evidence', text: /Evidence/i, canonical: /\/evidence\/overview$/ },
{ pack: '22', path: '/evidence/packs', text: /Decision Capsule|Capsule|Evidence/i, canonical: /\/evidence\/capsules$/ },
{ pack: '22', path: '/evidence/exports', text: /Export Center|Export/i, canonical: /\/evidence\/exports\/export$/ },
{ pack: '22', path: '/evidence/audit-log', text: /Audit Log|Events?/i, canonical: /\/evidence\/audit-log/ },
{ pack: '21', path: '/integrations', text: /Integration Hub|Integrations/i, canonical: /\/integrations$/ },
{ pack: '21', path: '/integrations/registries', text: /Registries|Registry/i, canonical: /\/integrations\/registries$/ },
{ pack: '21', path: '/integrations/scm', text: /Source Control|SCM/i, canonical: /\/integrations\/scm$/ },
{ pack: '21', path: '/integrations/ci-cd', text: /CI\/CD|CI/i, canonical: /\/integrations\/ci$/ },
{ pack: '21', path: '/integrations/targets', text: /Target|Runtime|Host/i, canonical: /\/integrations\/hosts$/ },
{ pack: '21', path: '/integrations/secrets', text: /Secrets/i, canonical: /\/integrations\/secrets$/ },
{ pack: '21', path: '/integrations/feeds', text: /Feed|Advisory/i, canonical: /\/integrations\/feeds$/ },
{ pack: '22', path: '/topology/regions', text: /Region|Topology/i, canonical: /\/topology\/regions$/ },
{ pack: '22', path: '/topology/environments', text: /Environment|Topology/i, canonical: /\/topology\/environments$/ },
{ pack: '22', path: '/topology/agents', text: /Agent|Topology/i, canonical: /\/topology\/agents$/ },
{ pack: '22', path: '/topology/promotion-paths', text: /Promotion|Path|Topology/i, canonical: /\/topology\/promotion-paths$/ },
{ pack: '15', path: '/platform-ops', text: /Platform Ops|Operations/i, canonical: /\/platform-ops$/ },
{ pack: '15', path: '/platform-ops/data-integrity', text: /Data Integrity|Nightly Ops/i, canonical: /\/platform-ops\/data-integrity$/ },
{ pack: '6', path: '/platform-ops/scheduler', text: /Scheduler/i, canonical: /\/platform-ops\/scheduler/ },
{ pack: '6', path: '/platform-ops/orchestrator', text: /Orchestrator/i, canonical: /\/platform-ops\/orchestrator$/ },
{ pack: '6', path: '/platform-ops/orchestrator/jobs', text: /Jobs|Orchestrator/i, canonical: /\/platform-ops\/orchestrator\/jobs$/ },
{ pack: '6', path: '/platform-ops/health', text: /Platform Health|Health/i, canonical: /\/platform-ops\/health$/ },
{ pack: '6', path: '/platform-ops/quotas', text: /Quota|Limits/i, canonical: /\/platform-ops\/quotas/ },
{ pack: '6', path: '/platform-ops/dead-letter', text: /Dead Letter|Queue/i, canonical: /\/platform-ops\/dead-letter/ },
{ pack: '6', path: '/platform-ops/feeds', text: /Feeds|Mirror|AirGap/i, canonical: /\/platform-ops\/feeds/ },
{ pack: '22', path: '/operations', text: /Operations|Platform Ops/i, canonical: /\/operations$/ },
{ pack: '22', path: '/operations/data-integrity', text: /Data Integrity|Ops/i, canonical: /\/operations\/data-integrity$/ },
{ pack: '22', path: '/operations/orchestrator', text: /Orchestrator/i, canonical: /\/operations\/orchestrator$/ },
{ pack: '22', path: '/operations/feeds', text: /Feeds|Mirror/i, canonical: /\/operations\/feeds/ },
{ pack: '21', path: '/administration', text: /Administration/i, canonical: /\/administration$/ },
{ pack: '21', path: '/administration/identity-access', text: /Identity|Access|Users|Roles/i, canonical: /\/administration\/identity-access/ },
{ pack: '21', path: '/administration/tenant-branding', text: /Tenant|Branding/i, canonical: /\/administration\/tenant-branding/ },
{ pack: '21', path: '/administration/notifications', text: /Notification/i, canonical: /\/administration\/notifications/ },
{ pack: '21', path: '/administration/usage', text: /Usage|Limits/i, canonical: /\/administration\/usage/ },
{ pack: '21', path: '/administration/policy-governance', text: /Policy|Governance/i, canonical: /\/administration\/policy-governance/ },
{ pack: '21', path: '/administration/system', text: /System/i, canonical: /\/administration\/system/ },
{ pack: '21', path: '/administration/offline', text: /Offline/i, canonical: /\/administration\/offline/ },
{ pack: '22', path: '/integrations', text: /Integration Hub|Integrations/i, canonical: /\/integrations$/ },
{ pack: '22', path: '/integrations/feeds', text: /Feed|Advisory/i, canonical: /\/integrations\/feeds$/ },
{ pack: '22', path: '/integrations/vex-sources', text: /VEX|Source/i, canonical: /\/integrations\/vex-sources$/ },
{ pack: '22', path: '/administration', text: /Platform Setup|Administration/i, canonical: /\/platform\/setup/ },
{ pack: '22', path: '/administration/policy-governance', text: /Policy|Governance/i, canonical: /\/administration\/policy-governance/ },
// Legacy roots must continue to resolve into canonical Pack 22 routes.
{ pack: '22', path: '/release-control/releases', text: /Release|Run/i, canonical: /\/releases\/runs$/ },
{ pack: '22', path: '/release-control/setup/environments-paths', text: /Promotion|Path|Topology/i, canonical: /\/topology\/promotion-paths$/ },
{ pack: '22', path: '/security-risk/findings', text: /Findings|Triage/i, canonical: /\/security\/triage$/ },
{ pack: '22', path: '/evidence-audit/packs', text: /Decision Capsule|Capsule|Evidence/i, canonical: /\/evidence\/capsules$/ },
{ pack: '22', path: '/platform-ops/agents', text: /Agent|Topology/i, canonical: /\/topology\/agents$/ },
];
const RUN_EXPECTATIONS = (() => {
@@ -158,6 +133,9 @@ const RUN_EXPECTATIONS = (() => {
if (screenshotAbsDir) {
fs.mkdirSync(screenshotAbsDir, { recursive: true });
}
if (endpointMatrixAbsFile) {
fs.mkdirSync(path.dirname(endpointMatrixAbsFile), { recursive: true });
}
function slugifyRoute(routePath: string): string {
return routePath.replace(/^\/+/, '').replace(/[\/:]+/g, '-').replace(/[^a-zA-Z0-9._-]+/g, '-');
@@ -222,6 +200,19 @@ async function go(page: Page, path: string): Promise<void> {
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
async function goClientSide(page: Page, routePath: string): Promise<void> {
await go(page, '/dashboard');
await page.evaluate((targetPath) => {
window.history.pushState({}, '', targetPath);
window.dispatchEvent(new PopStateEvent('popstate'));
}, routePath);
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
function isProxyCapturedDevRoute(routePath: string): boolean {
return routePath.startsWith('/integrations') || routePath.startsWith('/platform-ops');
}
async function ensureShell(page: Page): Promise<void> {
await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 15000 });
}
@@ -244,64 +235,112 @@ test.describe('Pack conformance from docs/modules/ui/v2-rewire/pack-01..21', ()
test.setTimeout(6 * 60_000);
const failures: string[] = [];
const screenshotIndex: Array<{ pack: string; route: string; file: string }> = [];
const endpointRecords: Array<{
pack: string;
route: string;
method: string;
status: number;
path: string;
resourceType: string;
}> = [];
let activeRecord: { pack: string; route: string } | null = null;
const scoped = RUN_EXPECTATIONS;
page.on('response', (response) => {
if (!activeRecord) {
return;
}
const request = response.request();
const resourceType = request.resourceType();
if (resourceType !== 'xhr' && resourceType !== 'fetch') {
return;
}
let responsePath = response.url();
try {
responsePath = new URL(response.url()).pathname;
} catch {
// Keep raw URL if parsing fails.
}
endpointRecords.push({
pack: activeRecord.pack,
route: activeRecord.route,
method: request.method(),
status: response.status(),
path: responsePath,
resourceType,
});
});
for (const item of scoped) {
await test.step(`pack-${item.pack} ${item.path}`, async () => {
activeRecord = { pack: item.pack, route: item.path };
try {
await go(page, item.path);
} catch (error) {
failures.push(`[pack-${item.pack}] ${item.path} -> navigation failed: ${String(error)}`);
return;
}
try {
if (isProxyCapturedDevRoute(item.path)) {
// Dev proxy maps "/integrations" and "/platform*" to backend services.
// Reach these routes via client-side routing to validate SPA shell behavior.
await goClientSide(page, item.path);
} else {
await go(page, item.path);
}
} catch (error) {
failures.push(`[pack-${item.pack}] ${item.path} -> navigation failed: ${String(error)}`);
return;
}
const shellCount = await page.locator('aside.sidebar').count();
if (shellCount !== 1) {
failures.push(`[pack-${item.pack}] ${item.path} -> shell sidebar count=${shellCount}`);
}
const shellCount = await page.locator('aside.sidebar').count();
if (shellCount !== 1) {
failures.push(`[pack-${item.pack}] ${item.path} -> shell sidebar count=${shellCount}`);
}
const main = page.locator('main.shell__outlet').first();
const mainCount = await page.locator('main.shell__outlet').count();
if (mainCount !== 1) {
failures.push(`[pack-${item.pack}] ${item.path} -> shell main count=${mainCount}`);
return;
}
const main = page.locator('main.shell__outlet').first();
const mainCount = await page.locator('main.shell__outlet').count();
if (mainCount !== 1) {
failures.push(`[pack-${item.pack}] ${item.path} -> shell main count=${mainCount}`);
return;
}
const isVisible = await main.isVisible().catch(() => false);
if (!isVisible) {
failures.push(`[pack-${item.pack}] ${item.path} -> main is not visible`);
}
const isVisible = await main.isVisible().catch(() => false);
if (!isVisible) {
failures.push(`[pack-${item.pack}] ${item.path} -> main is not visible`);
}
const mainText = (await main.textContent().catch(() => '')) ?? '';
const compactText = mainText.replace(/\s+/g, '');
const childNodes = await main.locator('*').count().catch(() => 0);
if (!(compactText.length > 12 || childNodes > 4)) {
failures.push(
`[pack-${item.pack}] ${item.path} -> main appears empty (text=${compactText.length}, children=${childNodes})`
);
}
if (item.canonical) {
const currentUrl = page.url();
if (!item.canonical.test(currentUrl)) {
const mainText = (await main.textContent().catch(() => '')) ?? '';
const compactText = mainText.replace(/\s+/g, '');
const childNodes = await main.locator('*').count().catch(() => 0);
if (!(compactText.length > 12 || childNodes > 4)) {
failures.push(
`[pack-${item.pack}] ${item.path} -> canonical mismatch, expected ${item.canonical}, got ${currentUrl}`
`[pack-${item.pack}] ${item.path} -> main appears empty (text=${compactText.length}, children=${childNodes})`
);
}
}
if (!item.text.test(mainText)) {
const preview = mainText.replace(/\s+/g, ' ').trim().slice(0, 220);
failures.push(
`[pack-${item.pack}] ${item.path} -> missing text ${item.text}, preview="${preview}"`
);
}
if (item.canonical) {
const currentUrl = page.url();
if (!item.canonical.test(currentUrl)) {
failures.push(
`[pack-${item.pack}] ${item.path} -> canonical mismatch, expected ${item.canonical}, got ${currentUrl}`
);
}
}
if (screenshotAbsDir) {
const fileName = `pack-${item.pack}_${slugifyRoute(item.path)}.png`;
const absFile = path.join(screenshotAbsDir, fileName);
await page.screenshot({ path: absFile, fullPage: true });
screenshotIndex.push({ pack: item.pack, route: item.path, file: fileName });
if (!item.text.test(mainText)) {
const preview = mainText.replace(/\s+/g, ' ').trim().slice(0, 220);
failures.push(
`[pack-${item.pack}] ${item.path} -> missing text ${item.text}, preview="${preview}"`
);
}
if (screenshotAbsDir) {
const fileName = `pack-${item.pack}_${slugifyRoute(item.path)}.png`;
const absFile = path.join(screenshotAbsDir, fileName);
await page.screenshot({ path: absFile, fullPage: true });
screenshotIndex.push({ pack: item.pack, route: item.path, file: fileName });
}
} finally {
activeRecord = null;
}
});
}
@@ -311,6 +350,33 @@ test.describe('Pack conformance from docs/modules/ui/v2-rewire/pack-01..21', ()
fs.writeFileSync(path.join(screenshotAbsDir, 'index.csv'), `${rows.join('\n')}\n`, 'utf8');
}
if (endpointMatrixAbsFile) {
const dedup = new Map<string, { count: number; row: typeof endpointRecords[number] }>();
for (const row of endpointRecords) {
const key = `${row.pack}|${row.route}|${row.method}|${row.status}|${row.path}|${row.resourceType}`;
const current = dedup.get(key);
if (current) {
current.count += 1;
} else {
dedup.set(key, { count: 1, row });
}
}
const csvRows = [
'pack,route,method,status,path,resourceType,count',
...Array.from(dedup.values())
.sort((a, b) =>
`${a.row.pack} ${a.row.route} ${a.row.path}`.localeCompare(
`${b.row.pack} ${b.row.route} ${b.row.path}`
)
)
.map(({ row, count }) =>
`${row.pack},${row.route},${row.method},${row.status},${row.path},${row.resourceType},${count}`
),
];
fs.writeFileSync(endpointMatrixAbsFile, `${csvRows.join('\n')}\n`, 'utf8');
}
const ledger = failures.length > 0 ? failures.join('\n') : 'All pack routes matched current expectations.';
await testInfo.attach('pack-conformance-ledger', {
body: ledger,