Gaps fill up, fixes, ui restructuring

This commit is contained in:
master
2026-02-19 22:10:54 +02:00
parent b5829dce5c
commit 04cacdca8a
331 changed files with 42859 additions and 2174 deletions

View File

@@ -19,117 +19,206 @@ import { LEGACY_REDIRECT_ROUTES } from './routes/legacy-redirects.routes';
export const routes: Routes = [
// ========================================================================
// NEW SHELL NAVIGATION ROUTES (SPRINT_20260118_001_FE)
// Control Plane is the new default landing page
// V2 CANONICAL DOMAIN ROUTES (SPRINT_20260218_006)
// Seven root domains per S00 spec freeze (docs/modules/ui/v2-rewire/source-of-truth.md).
// Old v1 routes redirect to these canonical paths via V1_ALIAS_REDIRECT_ROUTES below.
// ========================================================================
// Control Plane - new default landing page
// Domain 1: Dashboard (formerly Control Plane)
{
path: '',
pathMatch: 'full',
title: 'Control Plane',
title: 'Dashboard',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Dashboard' },
loadChildren: () =>
import('./features/control-plane/control-plane.routes').then(
(m) => m.CONTROL_PLANE_ROUTES
import('./routes/dashboard.routes').then(
(m) => m.DASHBOARD_ROUTES
),
},
{
path: 'dashboard',
title: 'Dashboard',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Dashboard' },
loadChildren: () =>
import('./routes/dashboard.routes').then(
(m) => m.DASHBOARD_ROUTES
),
},
// Approvals - promotion decision cockpit
// Domain 2: Release Control
{
path: 'release-control',
title: 'Release Control',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Release Control' },
loadChildren: () =>
import('./routes/release-control.routes').then(
(m) => m.RELEASE_CONTROL_ROUTES
),
},
// Domain 3: Security and Risk (formerly /security)
{
path: 'security-risk',
title: 'Security and Risk',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Security and Risk' },
loadChildren: () =>
import('./routes/security-risk.routes').then(
(m) => m.SECURITY_RISK_ROUTES
),
},
// Domain 4: Evidence and Audit (formerly /evidence)
{
path: 'evidence-audit',
title: 'Evidence and Audit',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Evidence and Audit' },
loadChildren: () =>
import('./routes/evidence-audit.routes').then(
(m) => m.EVIDENCE_AUDIT_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)
{
path: 'platform-ops',
title: 'Platform Ops',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Platform Ops' },
loadChildren: () =>
import('./routes/platform-ops.routes').then(
(m) => m.PLATFORM_OPS_ROUTES
),
},
// Domain 7: Administration (canonical A0-A7 surface — SPRINT_20260218_007)
{
path: 'administration',
title: 'Administration',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Administration' },
loadChildren: () =>
import('./routes/administration.routes').then(
(m) => m.ADMINISTRATION_ROUTES
),
},
// ========================================================================
// V1 ALIAS ROUTES (SPRINT_20260218_006)
// These serve v1 canonical paths during the migration alias window defined in
// docs/modules/ui/v2-rewire/S00_route_deprecation_map.md.
// They load the same content as canonical routes to maintain backward compatibility.
// Convert to redirects and remove at SPRINT_20260218_016 after confirming traffic.
// ========================================================================
// Release Control domain aliases
{
path: 'approvals',
title: 'Approvals',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
loadChildren: () =>
import('./features/approvals/approvals.routes').then(
(m) => m.APPROVALS_ROUTES
),
pathMatch: 'full',
redirectTo: '/release-control/approvals',
},
// Release aliases used by legacy redirects and consolidated nav links.
{
path: 'environments',
title: 'Environments',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
loadChildren: () =>
import('./features/release-orchestrator/environments/environments.routes').then(
(m) => m.ENVIRONMENT_ROUTES
),
pathMatch: 'full',
redirectTo: '/release-control/environments',
},
{
path: 'releases',
title: 'Releases',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
loadChildren: () =>
import('./features/release-orchestrator/releases/releases.routes').then(
(m) => m.RELEASE_ROUTES
),
pathMatch: 'full',
redirectTo: '/release-control/releases',
},
{
path: 'deployments',
title: 'Deployments',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
loadChildren: () =>
import('./features/release-orchestrator/deployments/deployments.routes').then(
(m) => m.DEPLOYMENT_ROUTES
),
pathMatch: 'full',
redirectTo: '/release-control/deployments',
},
// Operations alias tree used by legacy redirects and consolidated sidebar.
{
path: 'operations',
title: 'Operations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
loadChildren: () =>
import('./features/operations/operations.routes').then(
(m) => m.OPERATIONS_ROUTES
),
},
// Security - consolidated security analysis (SEC-005, SEC-006)
// Security and Risk domain alias
{
path: 'security',
title: 'Security Overview',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
loadChildren: () =>
import('./features/security/security.routes').then(
(m) => m.SECURITY_ROUTES
),
pathMatch: 'full',
redirectTo: '/security-risk',
},
// Analytics - SBOM and attestation insights (SPRINT_20260120_031)
// Analytics alias (served under security-risk in v2)
{
path: 'analytics',
title: 'Analytics',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAnalyticsViewerGuard],
loadChildren: () =>
import('./features/analytics/analytics.routes').then(
(m) => m.ANALYTICS_ROUTES
),
import('./features/analytics/analytics.routes').then((m) => m.ANALYTICS_ROUTES),
},
// Policy - governance and exceptions (SEC-007)
// Evidence and Audit domain alias
{
path: 'evidence',
pathMatch: 'full',
redirectTo: '/evidence-audit',
},
// Platform Ops domain alias
{
path: 'operations',
pathMatch: 'full',
redirectTo: '/platform-ops',
},
// Administration domain alias — policy
{
path: 'policy',
title: 'Policy',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
loadChildren: () =>
import('./features/policy/policy.routes').then(
(m) => m.POLICY_ROUTES
),
pathMatch: 'full',
redirectTo: '/administration/policy-governance',
},
// Settings - consolidated configuration (SPRINT_20260118_002)
// Legacy setup aliases moved under Release Control -> Setup.
{
path: 'settings/release-control',
pathMatch: 'full',
redirectTo: '/release-control/setup',
},
{
path: 'settings/release-control/environments',
pathMatch: 'full',
redirectTo: '/release-control/setup/environments-paths',
},
{
path: 'settings/release-control/targets',
pathMatch: 'full',
redirectTo: '/release-control/setup/targets-agents',
},
{
path: 'settings/release-control/agents',
pathMatch: 'full',
redirectTo: '/release-control/setup/targets-agents',
},
{
path: 'settings/release-control/workflows',
pathMatch: 'full',
redirectTo: '/release-control/setup/workflows',
},
// Administration domain alias — settings
{
path: 'settings',
title: 'Settings',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
loadChildren: () =>
import('./features/settings/settings.routes').then(
(m) => m.SETTINGS_ROUTES
),
import('./features/settings/settings.routes').then((m) => m.SETTINGS_ROUTES),
},
// ==========================================================================
// LEGACY REDIRECT ROUTES
// Redirects for renamed/consolidated paths before legacy aliases/components.
// ==========================================================================
...LEGACY_REDIRECT_ROUTES,
// ========================================================================
// LEGACY ROUTES (to be migrated/removed in future sprints)
// ========================================================================
@@ -167,6 +256,7 @@ export const routes: Routes = [
(m) => m.ConsoleStatusComponent
),
},
// Console Admin routes - gated by ui.admin scope
{
path: 'console/admin',
@@ -587,13 +677,6 @@ export const routes: Routes = [
loadChildren: () =>
import('./features/policy-simulation/policy-simulation.routes').then((m) => m.policySimulationRoutes),
},
// Evidence/Export/Replay (SPRINT_20251229_016)
{
path: 'evidence',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
loadChildren: () =>
import('./features/evidence-export/evidence-export.routes').then((m) => m.evidenceExportRoutes),
},
// Scheduler Ops (SPRINT_20251229_017)
{
path: 'scheduler',
@@ -857,12 +940,6 @@ export const routes: Routes = [
(m) => m.AUDITOR_WORKSPACE_ROUTES
),
},
// ==========================================================================
// LEGACY REDIRECT ROUTES
// Redirects for renamed/consolidated routes to prevent bookmark breakage
// ==========================================================================
...LEGACY_REDIRECT_ROUTES,
// Fallback for unknown routes
{
path: '**',

View File

@@ -228,7 +228,8 @@ export function formatUptime(uptime: number): string {
return `${uptime.toFixed(2)}%`;
}
export function formatLatency(ms: number): string {
export function formatLatency(ms: number | null | undefined): string {
if (ms == null || isNaN(ms as number)) return '—';
if (ms < 1) return '<1ms';
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
return `${Math.round(ms)}ms`;

View File

@@ -0,0 +1,207 @@
/**
* Administration Overview (A0)
* Sprint: SPRINT_20260218_007_FE_ui_v2_rewire_administration_foundation (A2-01)
*
* Root overview for the Administration domain.
* Provides summary cards for all A1-A7 capability areas with direct navigation links.
* Ownership labels explicitly reference canonical IA (docs/modules/ui/v2-rewire/source-of-truth.md).
*/
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterLink } from '@angular/router';
interface AdminCard {
id: string;
title: string;
description: string;
route: string;
icon: string;
}
@Component({
selector: 'app-administration-overview',
standalone: true,
imports: [RouterLink],
template: `
<div class="admin-overview">
<header class="admin-overview__header">
<h1 class="admin-overview__title">Administration</h1>
<p class="admin-overview__subtitle">
Manage identity, tenants, notifications, policy, trust, and system controls.
</p>
</header>
<div class="admin-overview__grid">
@for (card of cards; track card.id) {
<a class="admin-card" [routerLink]="card.route">
<div class="admin-card__icon" aria-hidden="true">{{ card.icon }}</div>
<div class="admin-card__body">
<h2 class="admin-card__title">{{ card.title }}</h2>
<p class="admin-card__description">{{ card.description }}</p>
</div>
</a>
}
</div>
<section class="admin-overview__drilldowns">
<h2 class="admin-overview__section-heading">Operational Drilldowns</h2>
<ul class="admin-overview__links">
<li><a routerLink="/platform-ops/quotas">Quotas &amp; Limits</a> — Platform Ops</li>
<li><a routerLink="/platform-ops/health">System Health</a> — Platform Ops</li>
<li><a routerLink="/evidence-audit/audit">Audit Log</a> — Evidence &amp; Audit</li>
</ul>
</section>
</div>
`,
styles: [`
.admin-overview {
padding: 1.5rem;
max-width: 1200px;
}
.admin-overview__header {
margin-bottom: 2rem;
}
.admin-overview__title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.admin-overview__subtitle {
color: var(--color-text-secondary, #666);
margin: 0;
}
.admin-overview__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.admin-card {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.25rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.admin-card:hover {
border-color: var(--color-brand-primary, #4f46e5);
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.admin-card__icon {
font-size: 1.5rem;
flex-shrink: 0;
width: 2.5rem;
text-align: center;
}
.admin-card__title {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.admin-card__description {
font-size: 0.8125rem;
color: var(--color-text-secondary, #666);
margin: 0;
}
.admin-overview__section-heading {
font-size: 0.875rem;
font-weight: 600;
margin: 0 0 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary, #666);
}
.admin-overview__links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.admin-overview__links li {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
}
.admin-overview__links a {
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
}
.admin-overview__links a:hover {
text-decoration: underline;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdministrationOverviewComponent {
readonly cards: AdminCard[] = [
{
id: 'identity-access',
title: 'Identity & Access',
description: 'Users, roles, clients, tokens, and scope management.',
route: '/administration/identity-access',
icon: '👤',
},
{
id: 'tenant-branding',
title: 'Tenant & Branding',
description: 'Tenant configuration, logo, color scheme, and white-label settings.',
route: '/administration/tenant-branding',
icon: '🎨',
},
{
id: 'notifications',
title: 'Notifications',
description: 'Notification rules, channels, and delivery templates.',
route: '/administration/notifications',
icon: '🔔',
},
{
id: 'usage',
title: 'Usage & Limits',
description: 'Subscription usage, quota policies, and resource ceilings.',
route: '/administration/usage',
icon: '📊',
},
{
id: 'policy-governance',
title: 'Policy Governance',
description: 'Policy packs, baselines, simulation, exceptions, and approval workflows.',
route: '/administration/policy-governance',
icon: '📋',
},
{
id: 'trust-signing',
title: 'Trust & Signing',
description: 'Keys, issuers, certificates, transparency log, and trust scoring.',
route: '/administration/trust-signing',
icon: '🔐',
},
{
id: 'system',
title: 'System',
description: 'System configuration, diagnostics, offline settings, and security data.',
route: '/administration/system',
icon: '⚙️',
},
];
}

View File

@@ -1,7 +1,7 @@
import { Component, ChangeDetectionStrategy, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { 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';
@@ -441,6 +441,7 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
})
export class ApprovalsInboxComponent implements OnInit {
private readonly api = inject(APPROVAL_API);
private readonly router = inject(Router);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
@@ -489,21 +490,14 @@ export class ApprovalsInboxComponent implements OnInit {
}
approveRequest(id: string): void {
this.api.approve(id, '').pipe(
catchError(() => {
this.error.set('Failed to approve request');
return of(null);
})
).subscribe(() => this.loadApprovals());
// 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(['/approvals', id]);
}
rejectRequest(id: string): void {
this.api.reject(id, '').pipe(
catchError(() => {
this.error.set('Failed to reject request');
return of(null);
})
).subscribe(() => this.loadApprovals());
// Route to the detail page so the user can provide a rejection reason.
this.router.navigate(['/approvals', id]);
}
timeAgo(dateStr: string): string {

View File

@@ -1,16 +1,40 @@
/**
* 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:
* '' — 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
*
* Decision actions (A6-05): Approve, Reject, Defer available from the cockpit.
* The approvals surface is self-sufficient — all context needed for a decision is shown here
* without requiring navigation to Security & Risk or Evidence & Audit domains.
*/
import { Routes } from '@angular/router';
export const APPROVALS_ROUTES: Routes = [
// A6-01 — Approvals queue
{
path: '',
title: 'Approvals',
data: { breadcrumb: 'Approvals' },
loadComponent: () =>
import('./approvals-inbox.component').then((m) => m.ApprovalsInboxComponent),
data: { breadcrumb: 'Approvals' },
},
// A6-02 through A6-05 — Decision cockpit
{
path: ':id',
title: 'Approval Decision',
data: {
breadcrumb: 'Approval Decision',
// Available tabs in the decision cockpit:
// overview | gates | security | reachability | ops-data | evidence | replay | history
decisionTabs: ['overview', 'gates', 'security', 'reachability', 'ops-data', 'evidence', 'replay', 'history'],
},
loadComponent: () =>
import('./approval-detail-page.component').then((m) => m.ApprovalDetailPageComponent),
data: { breadcrumb: 'Approval Detail' },
},
];

View File

@@ -0,0 +1,575 @@
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { finalize, map, of, switchMap } from 'rxjs';
import {
BundleOrganizerApi,
ReleaseControlBundleComponentInputDto,
ReleaseControlBundleVersionDetailDto,
} from './bundle-organizer.api';
type BuilderStep = 1 | 2 | 3 | 4;
interface ComponentDraft {
componentVersionId: string;
componentName: string;
imageDigest: string;
deployOrder: number;
metadataJson: string;
}
@Component({
selector: 'app-bundle-builder',
standalone: true,
imports: [RouterLink],
template: `
<div class="bundle-builder">
<nav class="bundle-builder__back">
<a routerLink=".." class="back-link">Back to Bundles</a>
</nav>
<header class="bundle-builder__header">
<h1 class="bundle-builder__title">Create Bundle Version</h1>
<p class="bundle-builder__subtitle">
Define an immutable versioned artifact set for release promotion.
</p>
@if (existingBundleId(); as existingBundleId) {
<p class="bundle-builder__context">Publishing into existing bundle: {{ existingBundleId }}</p>
}
</header>
<div class="bundle-builder__steps" role="list" aria-label="Builder steps">
@for (step of steps; track step.number) {
<div
class="step-item"
[class.step-item--active]="activeStep() === step.number"
[class.step-item--done]="activeStep() > step.number"
role="listitem"
>
<span class="step-item__num" aria-hidden="true">{{ step.number }}</span>
<span class="step-item__label">{{ step.label }}</span>
</div>
}
</div>
<div class="bundle-builder__content">
@switch (activeStep()) {
@case (1) {
<section aria-label="Step 1: Basic info">
<h2 class="bundle-builder__step-title">Basic Info</h2>
<div class="form-field">
<label for="bundle-name">Bundle name</label>
<input
id="bundle-name"
type="text"
placeholder="e.g. platform-release"
[value]="bundleName()"
(input)="bundleName.set($any($event.target).value)"
/>
</div>
<div class="form-field">
<label for="bundle-slug">Bundle slug</label>
<input
id="bundle-slug"
type="text"
placeholder="e.g. platform-release"
[value]="bundleSlug()"
(input)="bundleSlug.set($any($event.target).value)"
/>
</div>
<div class="form-field">
<label for="bundle-desc">Description</label>
<textarea
id="bundle-desc"
rows="3"
placeholder="What does this bundle represent?"
[value]="bundleDescription()"
(input)="bundleDescription.set($any($event.target).value)"
></textarea>
</div>
</section>
}
@case (2) {
<section aria-label="Step 2: Component selector">
<h2 class="bundle-builder__step-title">Select Components</h2>
<p class="bundle-builder__hint">Add artifact versions to include in this bundle.</p>
<table class="bundle-builder__table" aria-label="Selected component versions">
<thead>
<tr>
<th>Component</th>
<th>Version Id</th>
<th>Digest</th>
<th>Deploy Order</th>
</tr>
</thead>
<tbody>
@for (component of components(); track component.componentVersionId + component.componentName) {
<tr>
<td>
<input
[value]="component.componentName"
(input)="updateComponentField($index, 'componentName', $any($event.target).value)"
/>
</td>
<td>
<input
[value]="component.componentVersionId"
(input)="updateComponentField($index, 'componentVersionId', $any($event.target).value)"
/>
</td>
<td>
<input
class="mono-input"
[value]="component.imageDigest"
(input)="updateComponentField($index, 'imageDigest', $any($event.target).value)"
/>
</td>
<td>
<input
type="number"
min="0"
[value]="component.deployOrder"
(input)="updateComponentField($index, 'deployOrder', Number($any($event.target).value))"
/>
</td>
</tr>
}
</tbody>
</table>
<p class="bundle-builder__hint">
Digest is authoritative identity. Display naming is maintained for operator readability.
</p>
</section>
}
@case (3) {
<section aria-label="Step 3: Config contract">
<h2 class="bundle-builder__step-title">Config Contract</h2>
<p class="bundle-builder__hint">
Define environment-specific configuration overrides and required parameters.
</p>
<p class="bundle-builder__empty">
Required bindings: DB_PASSWORD, JWT_PUBLIC_KEYS. Missing bindings block materialization.
</p>
<div class="form-field">
<label for="materialize-env">Optional materialize target environment</label>
<input
id="materialize-env"
type="text"
placeholder="e.g. prod-us-east"
[value]="materializeTargetEnvironment()"
(input)="materializeTargetEnvironment.set($any($event.target).value)"
/>
</div>
</section>
}
@case (4) {
<section aria-label="Step 4: Review">
<h2 class="bundle-builder__step-title">Review &amp; Finalize</h2>
<p class="bundle-builder__hint">Validate and publish immutable bundle version.</p>
<div class="bundle-builder__validation">
<span class="validation-badge validation-badge--ready">Ready to create version</span>
</div>
<p class="bundle-builder__hint">
Finalization captures manifest digest and optional materialization run trigger.
</p>
@if (submitError(); as submitError) {
<p class="bundle-builder__error">{{ submitError }}</p>
}
@if (submitMessage(); as submitMessage) {
<p class="bundle-builder__message">{{ submitMessage }}</p>
}
</section>
}
}
</div>
<div class="bundle-builder__nav">
@if (activeStep() > 1) {
<button class="btn-secondary" (click)="prevStep()" [disabled]="submitting()">Back</button>
}
@if (activeStep() < 4) {
<button class="btn-primary" (click)="nextStep()" [disabled]="submitting()">Next</button>
} @else {
<button class="btn-primary btn-primary--success" (click)="createBundleVersion()" [disabled]="submitting()">
@if (submitting()) { Creating... } @else { Create Bundle Version }
</button>
}
</div>
</div>
`,
styles: [`
.bundle-builder {
padding: 1.5rem;
max-width: 900px;
}
.bundle-builder__back {
margin-bottom: 1rem;
}
.back-link {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
text-decoration: none;
}
.back-link:hover {
color: var(--color-brand-primary, #4f46e5);
}
.bundle-builder__title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.bundle-builder__subtitle {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
margin: 0;
}
.bundle-builder__context {
margin: 0.4rem 0 0;
color: var(--color-text-secondary, #666);
font-size: 0.78rem;
font-family: var(--font-mono, monospace);
}
.bundle-builder__steps {
display: flex;
gap: 0;
margin: 2rem 0;
border-bottom: 2px solid var(--color-border, #e5e7eb);
}
.step-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.step-item--active {
color: var(--color-brand-primary, #4f46e5);
border-bottom-color: var(--color-brand-primary, #4f46e5);
font-weight: 600;
}
.step-item__num {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--color-border, #e5e7eb);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
}
.step-item--active .step-item__num {
background: var(--color-brand-primary, #4f46e5);
color: #fff;
}
.bundle-builder__step-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 1rem;
}
.bundle-builder__content {
min-height: 260px;
margin-bottom: 2rem;
}
.form-field {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field label {
font-size: 0.875rem;
font-weight: 500;
}
.form-field input,
.form-field textarea {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-sm, 4px);
font-size: 0.875rem;
}
.bundle-builder__hint {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
margin-bottom: 1rem;
}
.bundle-builder__empty {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
padding: 1.5rem;
background: var(--color-surface-alt, #f9fafb);
border: 1px dashed var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
text-align: center;
}
.bundle-builder__validation {
margin-top: 1rem;
}
.bundle-builder__table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
margin-bottom: 0.85rem;
}
.bundle-builder__table th,
.bundle-builder__table td {
text-align: left;
border-top: 1px solid var(--color-border, #e5e7eb);
padding: 0.45rem 0.4rem;
}
.bundle-builder__table th {
border-top: 0;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #666);
}
.bundle-builder__table input {
width: 100%;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.35rem 0.45rem;
font-size: 0.78rem;
}
.mono-input {
font-family: var(--font-mono, monospace);
}
.bundle-builder__nav {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.25rem;
border-radius: var(--radius-sm, 4px);
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
}
.btn-primary {
background: var(--color-brand-primary, #4f46e5);
color: #fff;
}
.btn-primary--success {
background: #059669;
}
.btn-primary[disabled],
.btn-secondary[disabled] {
opacity: 0.6;
cursor: default;
}
.btn-secondary {
background: transparent;
color: var(--color-text-primary, #111);
border: 1px solid var(--color-border, #e5e7eb);
}
.validation-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
}
.validation-badge--ready {
background: #d1fae5;
color: #065f46;
}
.bundle-builder__error {
margin: 0.65rem 0 0;
color: #991b1b;
font-size: 0.82rem;
}
.bundle-builder__message {
margin: 0.65rem 0 0;
color: #065f46;
font-size: 0.82rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BundleBuilderComponent implements OnInit {
private readonly bundleApi = inject(BundleOrganizerApi);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly activeStep = signal<BuilderStep>(1);
readonly existingBundleId = signal<string | null>(null);
readonly bundleName = signal('');
readonly bundleSlug = signal('');
readonly bundleDescription = signal('');
readonly materializeTargetEnvironment = signal('');
readonly submitError = signal<string | null>(null);
readonly submitMessage = signal<string | null>(null);
readonly submitting = signal(false);
readonly components = signal<ComponentDraft[]>([
{
componentVersionId: 'api-gateway@2.3.1',
componentName: 'api-gateway',
imageDigest: 'sha256:pending',
deployOrder: 1,
metadataJson: '{}',
},
]);
readonly steps: Array<{ number: BuilderStep; label: string }> = [
{ number: 1, label: 'Basic Info' },
{ number: 2, label: 'Components' },
{ number: 3, label: 'Config Contract' },
{ number: 4, label: 'Review' },
];
ngOnInit(): void {
const existingBundleId = this.route.snapshot.queryParamMap.get('bundleId');
if (existingBundleId) {
this.existingBundleId.set(existingBundleId);
}
}
nextStep(): void {
if (this.activeStep() < 4) {
this.activeStep.set((this.activeStep() + 1) as BuilderStep);
}
}
prevStep(): void {
if (this.activeStep() > 1) {
this.activeStep.set((this.activeStep() - 1) as BuilderStep);
}
}
updateComponentField(index: number, field: keyof ComponentDraft, value: string | number): void {
this.components.update((components) => {
const copy = [...components];
const target = copy[index];
if (!target) {
return components;
}
copy[index] = {
...target,
[field]: value,
};
return copy;
});
}
createBundleVersion(): void {
this.submitError.set(null);
this.submitMessage.set(null);
const existingBundleId = this.existingBundleId();
if (!existingBundleId && (!this.bundleName().trim() || !this.bundleSlug().trim())) {
this.submitError.set('Bundle name and slug are required when creating a new bundle.');
return;
}
const publishRequest = {
changelog: this.bundleDescription().trim() || null,
components: this.toComponentInputs(),
};
this.submitting.set(true);
const publishFlow = existingBundleId
? this.bundleApi.publishBundleVersion(existingBundleId, publishRequest)
: this.bundleApi
.createBundle({
name: this.bundleName().trim(),
slug: this.bundleSlug().trim(),
description: this.bundleDescription().trim() || null,
})
.pipe(
switchMap((bundle) =>
this.bundleApi.publishBundleVersion(bundle.id, publishRequest)
)
);
publishFlow
.pipe(
switchMap((version) => this.materializeIfRequested(version)),
finalize(() => this.submitting.set(false))
)
.subscribe({
next: (version) => {
const bundleId = version.bundleId;
this.submitMessage.set(`Bundle version v${version.versionNumber} created.`);
this.router.navigate(['/release-control/bundles', bundleId, version.id]);
},
error: () => {
this.submitError.set('Failed to create bundle version via release-control endpoints.');
},
});
}
private toComponentInputs(): ReleaseControlBundleComponentInputDto[] {
return this.components()
.filter(
(component) =>
component.componentName.trim() &&
component.componentVersionId.trim() &&
component.imageDigest.trim()
)
.map((component) => ({
componentName: component.componentName.trim(),
componentVersionId: component.componentVersionId.trim(),
imageDigest: component.imageDigest.trim(),
deployOrder: Number.isFinite(component.deployOrder) ? component.deployOrder : 0,
metadataJson: component.metadataJson.trim() || '{}',
}));
}
private materializeIfRequested(
version: ReleaseControlBundleVersionDetailDto
) {
const targetEnvironment = this.materializeTargetEnvironment().trim();
if (!targetEnvironment) {
return of(version);
}
return this.bundleApi
.materializeBundleVersion(version.bundleId, version.id, {
targetEnvironment,
reason: 'bundle_builder_finalize',
})
.pipe(map(() => version));
}
}

View File

@@ -0,0 +1,443 @@
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { BundleOrganizerApi, ReleaseControlBundleSummaryDto } from './bundle-organizer.api';
type BundleStatus = 'ready' | 'draft';
type BundleStatusFilter = 'all' | BundleStatus;
interface BundleRow {
id: string;
name: string;
latestVersionLabel: string;
latestVersionId: string | null;
latestDigest: string;
versionCount: number;
status: BundleStatus;
updatedAtLabel: string;
}
@Component({
selector: 'app-bundle-catalog',
standalone: true,
imports: [RouterLink],
template: `
<div class="bundle-catalog">
<header class="bundle-catalog__header">
<div>
<h1 class="bundle-catalog__title">Bundles</h1>
<p class="bundle-catalog__subtitle">
Immutable versioned artifact sets used as release promotion inputs.
</p>
</div>
<a class="btn-primary" routerLink="create">Create Bundle</a>
</header>
<div class="bundle-catalog__toolbar">
<input
class="bundle-catalog__search"
type="search"
placeholder="Search bundles..."
aria-label="Search bundles"
[value]="searchTerm()"
(input)="setSearchTerm($any($event.target).value)"
/>
<div class="bundle-catalog__filters" role="group" aria-label="Filter by status">
<button
type="button"
class="filter-chip"
[class.filter-chip--active]="statusFilter() === 'all'"
(click)="setStatusFilter('all')"
>
All
</button>
<button
type="button"
class="filter-chip"
[class.filter-chip--active]="statusFilter() === 'ready'"
(click)="setStatusFilter('ready')"
>
Ready
</button>
<button
type="button"
class="filter-chip"
[class.filter-chip--active]="statusFilter() === 'draft'"
(click)="setStatusFilter('draft')"
>
Draft
</button>
</div>
</div>
<section class="bundle-catalog__summary" aria-label="Bundle organizer structure">
<article>
<h2>Digest-first identity</h2>
<p>Bundle versions are identified by manifest digest and version label.</p>
</article>
<article>
<h2>Validation gates</h2>
<p>Validation status controls materialization and promotion entry.</p>
</article>
<article>
<h2>Materialization hooks</h2>
<p>Bundle versions can be materialized to environments and exported as evidence.</p>
</article>
</section>
@if (loading()) {
<p class="bundle-catalog__state">Loading bundles...</p>
} @else if (errorMessage(); as errorMessage) {
<p class="bundle-catalog__state bundle-catalog__state--error">
{{ errorMessage }}
</p>
} @else {
<table class="bundle-catalog__table" aria-label="Bundle catalog">
<thead>
<tr>
<th>Bundle</th>
<th>Latest Version</th>
<th>Latest Manifest Digest</th>
<th>Versions</th>
<th>Status</th>
<th>Updated</th>
<th><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (bundle of filteredBundles(); track bundle.id) {
<tr>
<td>
<a [routerLink]="bundle.id" class="bundle-catalog__name-link">
{{ bundle.name }}
</a>
</td>
<td class="bundle-catalog__version">{{ bundle.latestVersionLabel }}</td>
<td class="bundle-catalog__version">{{ bundle.latestDigest }}</td>
<td>{{ bundle.versionCount }}</td>
<td>
<span class="status-badge status-badge--{{ bundle.status }}">
{{ bundle.status }}
</span>
</td>
<td class="bundle-catalog__date">{{ bundle.updatedAtLabel }}</td>
<td>
@if (bundle.latestVersionId) {
<a [routerLink]="[bundle.id, bundle.latestVersionId]" class="link-secondary">
View version
</a>
} @else {
<span class="bundle-catalog__muted">No versions yet</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="bundle-catalog__empty">
No bundles found. <a routerLink="create">Create the first bundle.</a>
</td>
</tr>
}
</tbody>
</table>
}
</div>
`,
styles: [`
.bundle-catalog {
padding: 1.5rem;
max-width: 1200px;
}
.bundle-catalog__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.bundle-catalog__title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.bundle-catalog__subtitle {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
margin: 0;
}
.bundle-catalog__toolbar {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
}
.bundle-catalog__search {
flex: 1;
max-width: 320px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-sm, 4px);
font-size: 0.875rem;
}
.bundle-catalog__filters {
display: flex;
gap: 0.5rem;
}
.filter-chip {
padding: 0.25rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 9999px;
font-size: 0.8125rem;
background: transparent;
cursor: pointer;
}
.filter-chip--active {
background: var(--color-brand-primary, #4f46e5);
color: #fff;
border-color: var(--color-brand-primary, #4f46e5);
}
.bundle-catalog__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.bundle-catalog__summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 0.7rem;
margin-bottom: 1rem;
}
.bundle-catalog__summary article {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
padding: 0.62rem 0.72rem;
background: var(--color-surface-alt, #f9fafb);
}
.bundle-catalog__summary h2 {
margin: 0 0 0.2rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #666);
}
.bundle-catalog__summary p {
margin: 0;
font-size: 0.82rem;
color: var(--color-text-secondary, #666);
}
.bundle-catalog__table th {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 2px solid var(--color-border, #e5e7eb);
font-weight: 600;
font-size: 0.8125rem;
color: var(--color-text-secondary, #666);
}
.bundle-catalog__table td {
padding: 0.75rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.bundle-catalog__name-link {
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
font-weight: 500;
}
.bundle-catalog__name-link:hover {
text-decoration: underline;
}
.bundle-catalog__version {
font-family: var(--font-mono, monospace);
font-size: 0.8125rem;
}
.bundle-catalog__date {
color: var(--color-text-secondary, #666);
}
.bundle-catalog__state {
color: var(--color-text-secondary, #666);
font-size: 0.85rem;
}
.bundle-catalog__state--error {
color: #991b1b;
}
.bundle-catalog__empty {
text-align: center;
padding: 2rem;
color: var(--color-text-secondary, #666);
}
.status-badge {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
text-transform: capitalize;
}
.status-badge--ready {
background: #d1fae5;
color: #065f46;
}
.status-badge--draft {
background: #fef3c7;
color: #92400e;
}
.btn-primary {
padding: 0.5rem 1rem;
background: var(--color-brand-primary, #4f46e5);
color: #fff;
border-radius: var(--radius-sm, 4px);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.link-secondary {
font-size: 0.8125rem;
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
}
.link-secondary:hover {
text-decoration: underline;
}
.bundle-catalog__muted {
font-size: 0.8rem;
color: var(--color-text-secondary, #666);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0,0,0,0);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BundleCatalogComponent implements OnInit {
private readonly bundleApi = inject(BundleOrganizerApi);
readonly bundles = signal<BundleRow[]>([]);
readonly loading = signal(true);
readonly errorMessage = signal<string | null>(null);
readonly searchTerm = signal('');
readonly statusFilter = signal<BundleStatusFilter>('all');
readonly filteredBundles = computed(() => {
const normalizedSearch = this.searchTerm().trim().toLowerCase();
return this.bundles().filter((bundle) => {
if (this.statusFilter() !== 'all' && bundle.status !== this.statusFilter()) {
return false;
}
if (!normalizedSearch) {
return true;
}
return (
bundle.name.toLowerCase().includes(normalizedSearch) ||
bundle.latestDigest.toLowerCase().includes(normalizedSearch)
);
});
});
ngOnInit(): void {
this.loadBundles();
}
setSearchTerm(value: string): void {
this.searchTerm.set(value ?? '');
}
setStatusFilter(filter: BundleStatusFilter): void {
this.statusFilter.set(filter);
}
private loadBundles(): void {
this.loading.set(true);
this.errorMessage.set(null);
this.bundleApi.listBundles().subscribe({
next: (bundles) => {
this.bundles.set(bundles.map((bundle) => this.mapBundleRow(bundle)));
this.loading.set(false);
},
error: () => {
this.errorMessage.set('Failed to load bundles from release-control endpoint.');
this.loading.set(false);
},
});
}
private mapBundleRow(bundle: ReleaseControlBundleSummaryDto): BundleRow {
const latestVersionLabel =
bundle.latestVersionNumber !== null && bundle.latestVersionNumber !== undefined
? `v${bundle.latestVersionNumber}`
: 'n/a';
const status: BundleStatus =
bundle.latestVersionNumber !== null && bundle.latestVersionNumber !== undefined
? 'ready'
: 'draft';
return {
id: bundle.id,
name: bundle.name,
latestVersionLabel,
latestVersionId: bundle.latestVersionId ?? null,
latestDigest: this.truncateDigest(bundle.latestVersionDigest),
versionCount: bundle.totalVersions,
status,
updatedAtLabel: this.formatDateTime(bundle.updatedAt),
};
}
private truncateDigest(digest?: string | null): string {
if (!digest) {
return 'n/a';
}
if (digest.length <= 24) {
return digest;
}
return `${digest.slice(0, 20)}...`;
}
private formatDateTime(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return `${parsed.getUTCFullYear()}-${String(parsed.getUTCMonth() + 1).padStart(2, '0')}-${String(
parsed.getUTCDate()
).padStart(2, '0')} ${String(parsed.getUTCHours()).padStart(2, '0')}:${String(
parsed.getUTCMinutes()
).padStart(2, '0')} UTC`;
}
}

View File

@@ -0,0 +1,432 @@
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { forkJoin } from 'rxjs';
import {
BundleOrganizerApi,
ReleaseControlBundleDetailDto,
ReleaseControlBundleVersionSummaryDto,
} from './bundle-organizer.api';
@Component({
selector: 'app-bundle-detail',
standalone: true,
imports: [RouterLink],
template: `
<div class="bundle-detail">
<nav class="bundle-detail__back">
<a routerLink=".." class="back-link">Back to Bundles</a>
</nav>
@if (loading()) {
<p class="bundle-detail__state">Loading bundle details...</p>
} @else if (errorMessage(); as errorMessage) {
<p class="bundle-detail__state bundle-detail__state--error">{{ errorMessage }}</p>
} @else if (bundle(); as bundleModel) {
<header class="bundle-detail__header">
<div>
<h1 class="bundle-detail__name">{{ bundleModel.name }}</h1>
<p class="bundle-detail__meta">
Immutable release input bundle with digest-first identity managed by Release Control.
</p>
<p class="bundle-detail__submeta">
Owner: {{ bundleModel.createdBy }} | Slug: {{ bundleModel.slug }}
</p>
</div>
<a class="btn-primary" [routerLink]="['..', 'create']" [queryParams]="{ bundleId: bundleModel.id }">
New Version
</a>
</header>
<section class="bundle-detail__identity" aria-label="Bundle identity">
<article>
<h2>Latest manifest digest</h2>
<p class="mono">{{ latestDigest() }}</p>
</article>
<article>
<h2>Total versions</h2>
<p>{{ bundleModel.totalVersions }}</p>
</article>
<article>
<h2>Materialization readiness</h2>
<p>{{ materializationReadiness() }}</p>
</article>
</section>
<div class="bundle-detail__tabs" role="tablist">
<button
role="tab"
[class.tab--active]="activeTab() === 'versions'"
(click)="setTab('versions')"
>
Versions
</button>
<button
role="tab"
[class.tab--active]="activeTab() === 'config'"
(click)="setTab('config')"
>
Config Contract
</button>
<button
role="tab"
[class.tab--active]="activeTab() === 'changelog'"
(click)="setTab('changelog')"
>
Changelog
</button>
</div>
@if (activeTab() === 'versions') {
<section class="bundle-detail__section" aria-label="Bundle versions">
<h2>Version timeline</h2>
@if (versions().length === 0) {
<p class="bundle-detail__empty-state">
No versions yet. Create the first immutable version to begin release promotions.
</p>
<a [routerLink]="['..', 'create']" [queryParams]="{ bundleId: bundleModel.id }" class="link-sm">
Create first bundle version
</a>
} @else {
<table class="bundle-detail__table">
<thead>
<tr>
<th>Version</th>
<th>Digest</th>
<th>Status</th>
<th>Components</th>
<th>Published</th>
<th><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (version of versions(); track version.id) {
<tr>
<td>v{{ version.versionNumber }}</td>
<td class="mono">{{ truncateDigest(version.digest) }}</td>
<td>{{ version.status }}</td>
<td>{{ version.componentsCount }}</td>
<td>{{ formatDateTime(version.publishedAt ?? version.createdAt) }}</td>
<td>
<a [routerLink]="[version.id]" class="link-sm">Open version</a>
</td>
</tr>
}
</tbody>
</table>
}
</section>
}
@if (activeTab() === 'config') {
<section class="bundle-detail__section" aria-label="Config contract">
<h2>Config contract</h2>
<p class="bundle-detail__empty-state">
Contract sections: required bindings, defaults, and policy-driven overrides.
</p>
<p class="bundle-detail__hint">
Missing bindings block materialization and promotion creation.
</p>
</section>
}
@if (activeTab() === 'changelog') {
<section class="bundle-detail__section" aria-label="Changelog">
<h2>Repository changelog</h2>
@if (changelogRows().length === 0) {
<p class="bundle-detail__empty-state">No changelog entries yet for this bundle.</p>
} @else {
<ul class="bundle-detail__changelog">
@for (entry of changelogRows(); track entry.id) {
<li>
<strong>v{{ entry.versionNumber }}:</strong>
<span>{{ entry.changelog }}</span>
</li>
}
</ul>
}
<p class="bundle-detail__hint">Per-repository changelog exports are attached to evidence packs.</p>
</section>
}
}
</div>
`,
styles: [`
.bundle-detail {
padding: 1.5rem;
max-width: 1000px;
}
.bundle-detail__back {
margin-bottom: 1rem;
}
.back-link {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
text-decoration: none;
}
.back-link:hover {
color: var(--color-brand-primary, #4f46e5);
}
.bundle-detail__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
gap: 0.8rem;
}
.bundle-detail__name {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem;
font-family: var(--font-mono, monospace);
}
.bundle-detail__meta {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
margin: 0;
}
.bundle-detail__submeta {
margin: 0.35rem 0 0;
font-size: 0.78rem;
color: var(--color-text-secondary, #666);
}
.bundle-detail__tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border, #e5e7eb);
margin-bottom: 1.5rem;
}
.bundle-detail__tabs button {
padding: 0.5rem 1rem;
border: none;
background: transparent;
font-size: 0.875rem;
cursor: pointer;
color: var(--color-text-secondary, #666);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tab--active {
color: var(--color-brand-primary, #4f46e5) !important;
border-bottom-color: var(--color-brand-primary, #4f46e5) !important;
font-weight: 600;
}
.bundle-detail__section {
padding: 1rem 0;
}
.bundle-detail__identity {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.bundle-detail__identity article {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
padding: 0.65rem 0.7rem;
background: var(--color-surface-alt, #f9fafb);
}
.bundle-detail__identity h2 {
margin: 0 0 0.25rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #666);
}
.bundle-detail__identity p {
margin: 0;
font-size: 0.84rem;
}
.bundle-detail__empty-state {
color: var(--color-text-secondary, #666);
font-size: 0.875rem;
}
.bundle-detail__hint {
margin: 0.3rem 0 0;
color: var(--color-text-secondary, #666);
font-size: 0.82rem;
}
.bundle-detail__changelog {
margin: 0.5rem 0;
padding-left: 1rem;
display: grid;
gap: 0.35rem;
}
.bundle-detail__changelog li {
font-size: 0.84rem;
color: var(--color-text-secondary, #666);
}
.bundle-detail__state {
font-size: 0.9rem;
color: var(--color-text-secondary, #666);
}
.bundle-detail__state--error {
color: #991b1b;
}
.bundle-detail__table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
.bundle-detail__table th,
.bundle-detail__table td {
text-align: left;
border-top: 1px solid var(--color-border, #e5e7eb);
padding: 0.45rem 0.4rem;
}
.bundle-detail__table th {
border-top: 0;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #666);
}
.mono {
font-family: var(--font-mono, monospace);
font-size: 0.8rem;
}
.link-sm {
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
font-size: 0.82rem;
}
.btn-primary {
padding: 0.5rem 1rem;
background: var(--color-brand-primary, #4f46e5);
color: #fff;
border-radius: var(--radius-sm, 4px);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BundleDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly bundleApi = inject(BundleOrganizerApi);
readonly bundleId = signal('');
readonly activeTab = signal<'versions' | 'config' | 'changelog'>('versions');
readonly loading = signal(true);
readonly errorMessage = signal<string | null>(null);
readonly bundle = signal<ReleaseControlBundleDetailDto | null>(null);
readonly versions = signal<ReleaseControlBundleVersionSummaryDto[]>([]);
readonly latestDigest = computed(() => {
const bundle = this.bundle();
if (!bundle?.latestVersionDigest) {
return 'n/a';
}
return this.truncateDigest(bundle.latestVersionDigest);
});
readonly materializationReadiness = computed(() => {
const latestVersion = this.versions()[0];
if (!latestVersion) {
return 'Blocked until first version is published.';
}
if (latestVersion.status.toLowerCase() === 'published') {
return 'Ready for materialization.';
}
return 'Needs version publication before materialization.';
});
readonly changelogRows = computed(() =>
this.versions().filter((version) => Boolean(version.changelog))
);
ngOnInit(): void {
this.bundleId.set(this.route.snapshot.params['bundleId'] ?? '');
if (!this.bundleId()) {
this.loading.set(false);
this.errorMessage.set('Bundle id is missing from route.');
return;
}
this.loadBundle(this.bundleId());
}
setTab(tab: 'versions' | 'config' | 'changelog'): void {
this.activeTab.set(tab);
}
formatDateTime(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return `${parsed.getUTCFullYear()}-${String(parsed.getUTCMonth() + 1).padStart(2, '0')}-${String(
parsed.getUTCDate()
).padStart(2, '0')} ${String(parsed.getUTCHours()).padStart(2, '0')}:${String(
parsed.getUTCMinutes()
).padStart(2, '0')} UTC`;
}
truncateDigest(digest: string): string {
if (digest.length <= 24) {
return digest;
}
return `${digest.slice(0, 20)}...`;
}
private loadBundle(bundleId: string): void {
this.loading.set(true);
this.errorMessage.set(null);
forkJoin({
bundle: this.bundleApi.getBundle(bundleId),
versions: this.bundleApi.listBundleVersions(bundleId),
}).subscribe({
next: ({ bundle, versions }) => {
this.bundle.set(bundle);
this.versions.set(versions);
this.loading.set(false);
},
error: () => {
this.errorMessage.set('Failed to load bundle details from release-control endpoints.');
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,181 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { map, Observable } from 'rxjs';
export interface PlatformListResponse<T> {
tenantId: string;
actorId: string;
dataAsOf: string;
cached: boolean;
cacheTtlSeconds: number;
items: T[];
count: number;
limit?: number;
offset?: number;
query?: string | null;
}
export interface PlatformItemResponse<T> {
tenantId: string;
actorId: string;
dataAsOf: string;
cached: boolean;
cacheTtlSeconds: number;
item: T;
}
export interface ReleaseControlBundleSummaryDto {
id: string;
slug: string;
name: string;
description?: string | null;
totalVersions: number;
latestVersionNumber?: number | null;
latestVersionId?: string | null;
latestVersionDigest?: string | null;
latestPublishedAt?: string | null;
createdAt: string;
updatedAt: string;
}
export interface ReleaseControlBundleDetailDto extends ReleaseControlBundleSummaryDto {
createdBy: string;
}
export interface ReleaseControlBundleVersionSummaryDto {
id: string;
bundleId: string;
versionNumber: number;
digest: string;
status: string;
componentsCount: number;
changelog?: string | null;
createdAt: string;
publishedAt?: string | null;
createdBy: string;
}
export interface ReleaseControlBundleComponentDto {
componentVersionId: string;
componentName: string;
imageDigest: string;
deployOrder: number;
metadataJson: string;
}
export interface ReleaseControlBundleVersionDetailDto extends ReleaseControlBundleVersionSummaryDto {
components: ReleaseControlBundleComponentDto[];
}
export interface ReleaseControlBundleMaterializationRunDto {
runId: string;
bundleId: string;
versionId: string;
status: string;
targetEnvironment?: string | null;
reason?: string | null;
requestedBy: string;
idempotencyKey?: string | null;
requestedAt: string;
updatedAt: string;
}
export interface CreateReleaseControlBundleRequestDto {
slug: string;
name: string;
description?: string | null;
}
export interface ReleaseControlBundleComponentInputDto {
componentVersionId: string;
componentName: string;
imageDigest: string;
deployOrder: number;
metadataJson?: string | null;
}
export interface PublishReleaseControlBundleVersionRequestDto {
changelog?: string | null;
components?: ReleaseControlBundleComponentInputDto[];
}
export interface MaterializeReleaseControlBundleVersionRequestDto {
targetEnvironment?: string | null;
reason?: string | null;
idempotencyKey?: string | null;
}
@Injectable({ providedIn: 'root' })
export class BundleOrganizerApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/release-control/bundles';
listBundles(limit = 100, offset = 0): Observable<ReleaseControlBundleSummaryDto[]> {
const params = new HttpParams()
.set('limit', String(limit))
.set('offset', String(offset));
return this.http
.get<PlatformListResponse<ReleaseControlBundleSummaryDto>>(this.baseUrl, { params })
.pipe(map((response) => response.items ?? []));
}
getBundle(bundleId: string): Observable<ReleaseControlBundleDetailDto> {
return this.http
.get<PlatformItemResponse<ReleaseControlBundleDetailDto>>(`${this.baseUrl}/${bundleId}`)
.pipe(map((response) => response.item));
}
createBundle(request: CreateReleaseControlBundleRequestDto): Observable<ReleaseControlBundleDetailDto> {
return this.http.post<ReleaseControlBundleDetailDto>(this.baseUrl, request);
}
listBundleVersions(
bundleId: string,
limit = 100,
offset = 0
): Observable<ReleaseControlBundleVersionSummaryDto[]> {
const params = new HttpParams()
.set('limit', String(limit))
.set('offset', String(offset));
return this.http
.get<PlatformListResponse<ReleaseControlBundleVersionSummaryDto>>(
`${this.baseUrl}/${bundleId}/versions`,
{ params }
)
.pipe(map((response) => response.items ?? []));
}
getBundleVersion(
bundleId: string,
versionId: string
): Observable<ReleaseControlBundleVersionDetailDto> {
return this.http
.get<PlatformItemResponse<ReleaseControlBundleVersionDetailDto>>(
`${this.baseUrl}/${bundleId}/versions/${versionId}`
)
.pipe(map((response) => response.item));
}
publishBundleVersion(
bundleId: string,
request: PublishReleaseControlBundleVersionRequestDto
): Observable<ReleaseControlBundleVersionDetailDto> {
return this.http.post<ReleaseControlBundleVersionDetailDto>(
`${this.baseUrl}/${bundleId}/versions`,
request
);
}
materializeBundleVersion(
bundleId: string,
versionId: string,
request: MaterializeReleaseControlBundleVersionRequestDto
): Observable<ReleaseControlBundleMaterializationRunDto> {
return this.http.post<ReleaseControlBundleMaterializationRunDto>(
`${this.baseUrl}/${bundleId}/versions/${versionId}/materialize`,
request
);
}
}

View File

@@ -0,0 +1,457 @@
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import {
BundleOrganizerApi,
ReleaseControlBundleVersionDetailDto,
} from './bundle-organizer.api';
@Component({
selector: 'app-bundle-version-detail',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="bvd">
<nav class="bvd__back">
<a [routerLink]="'..'">Back to Bundle</a>
</nav>
@if (loading()) {
<p class="bvd__state">Loading bundle version...</p>
} @else if (errorMessage(); as errorMessage) {
<p class="bvd__state bvd__state--error">{{ errorMessage }}</p>
} @else if (versionDetail(); as versionDetailModel) {
<header class="bvd__header">
<div>
<h1 class="bvd__title">
<span class="bvd__bundle-name">{{ bundleId() }}</span>
<span class="bvd__version-tag">v{{ versionDetailModel.versionNumber }}</span>
</h1>
<p class="bvd__meta">Immutable bundle version context with digest-first identity.</p>
</div>
<span class="status-badge" [class]="'status-badge status-badge--' + versionDetailModel.status.toLowerCase()">
{{ versionDetailModel.status }}
</span>
</header>
<section class="bvd__identity" aria-label="Immutable version identity">
<article>
<h2>Bundle manifest digest</h2>
<p class="mono">{{ truncateDigest(versionDetailModel.digest) }}</p>
</article>
<article>
<h2>Created by</h2>
<p>{{ versionDetailModel.createdBy }}</p>
</article>
<article>
<h2>Promotion readiness</h2>
<p>{{ promotionReadiness(versionDetailModel.status) }}</p>
</article>
</section>
<div class="bvd__tabs" role="tablist">
<button role="tab" [class.tab--active]="activeTab() === 'components'" (click)="setTab('components')">Components</button>
<button role="tab" [class.tab--active]="activeTab() === 'validation'" (click)="setTab('validation')">Validation</button>
<button role="tab" [class.tab--active]="activeTab() === 'releases'" (click)="setTab('releases')">Promotions</button>
</div>
@if (activeTab() === 'components') {
<section aria-label="Manifest components">
<h2>Manifest components (digest-first)</h2>
@if (versionDetailModel.components.length === 0) {
<p class="bvd__empty">No components listed for this version.</p>
} @else {
<table class="bvd__table">
<thead>
<tr>
<th>Component</th>
<th>Version Id</th>
<th>Image Digest</th>
<th>Order</th>
</tr>
</thead>
<tbody>
@for (component of versionDetailModel.components; track component.componentVersionId + component.componentName) {
<tr>
<td>{{ component.componentName }}</td>
<td>{{ component.componentVersionId }}</td>
<td class="mono">{{ truncateDigest(component.imageDigest) }}</td>
<td>{{ component.deployOrder }}</td>
</tr>
}
</tbody>
</table>
}
</section>
}
@if (activeTab() === 'validation') {
<section aria-label="Validation results">
<h2>Validation summary</h2>
<p class="bvd__empty">
Status: {{ versionDetailModel.status }} | Components: {{ versionDetailModel.componentsCount }}
</p>
<p class="bvd__empty">
Published: {{ formatDateTime(versionDetailModel.publishedAt ?? versionDetailModel.createdAt) }}
</p>
<a routerLink="/release-control/approvals" class="bvd__link">Open approvals queue</a>
</section>
}
@if (activeTab() === 'releases') {
<section aria-label="Promotions using this bundle version">
<h2>Materialization and promotion entry points</h2>
<div class="bvd__materialize">
<label for="target-env">Target environment</label>
<input
id="target-env"
type="text"
placeholder="e.g. prod-us-east"
[value]="targetEnvironment()"
(input)="targetEnvironment.set($any($event.target).value)"
/>
<button type="button" (click)="materialize()" [disabled]="materializing()">
@if (materializing()) { Materializing... } @else { Materialize Bundle }
</button>
</div>
@if (materializeMessage(); as materializeMessage) {
<p class="bvd__message">{{ materializeMessage }}</p>
}
@if (materializeError(); as materializeError) {
<p class="bvd__error">{{ materializeError }}</p>
}
<a routerLink="/release-control/releases" class="bvd__link">View all releases</a>
<a routerLink="/release-control/promotions/create" class="bvd__link">Create promotion from this version</a>
</section>
}
}
</div>
`,
styles: [`
.bvd {
padding: 1.5rem;
max-width: 1000px;
}
.bvd__back {
margin-bottom: 1rem;
font-size: 0.875rem;
}
.bvd__back a {
color: var(--color-text-secondary, #666);
text-decoration: none;
}
.bvd__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
gap: 0.8rem;
}
.bvd__title {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 0.25rem;
font-size: 1.375rem;
font-weight: 600;
}
.bvd__bundle-name {
font-family: var(--font-mono, monospace);
}
.bvd__version-tag {
font-family: var(--font-mono, monospace);
font-size: 1rem;
color: var(--color-text-secondary, #666);
background: var(--color-surface-alt, #f9fafb);
border: 1px solid var(--color-border, #e5e7eb);
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm, 4px);
}
.bvd__meta {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
margin: 0;
}
.bvd__identity {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.bvd__identity article {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
background: var(--color-surface-alt, #f9fafb);
padding: 0.65rem 0.7rem;
}
.bvd__identity h2 {
margin: 0 0 0.25rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #666);
}
.bvd__identity p {
margin: 0;
font-size: 0.84rem;
}
.bvd__tabs {
display: flex;
border-bottom: 2px solid var(--color-border, #e5e7eb);
margin-bottom: 1rem;
}
.bvd__tabs button {
padding: 0.5rem 1rem;
border: none;
background: transparent;
font-size: 0.875rem;
cursor: pointer;
color: var(--color-text-secondary, #666);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tab--active {
color: var(--color-brand-primary, #4f46e5) !important;
border-bottom-color: var(--color-brand-primary, #4f46e5) !important;
font-weight: 600;
}
section h2 {
margin: 0 0 0.35rem;
font-size: 0.9rem;
}
.bvd__empty {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
}
.bvd__link {
display: inline-block;
margin-right: 0.8rem;
font-size: 0.875rem;
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
}
.bvd__link:hover {
text-decoration: underline;
}
.bvd__table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
.bvd__table th,
.bvd__table td {
text-align: left;
border-top: 1px solid var(--color-border, #e5e7eb);
padding: 0.45rem 0.4rem;
}
.bvd__table th {
border-top: 0;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #666);
}
.bvd__materialize {
display: grid;
gap: 0.45rem;
max-width: 320px;
margin-bottom: 0.8rem;
}
.bvd__materialize label {
font-size: 0.78rem;
color: var(--color-text-secondary, #666);
}
.bvd__materialize input {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.4rem 0.5rem;
font-size: 0.82rem;
}
.bvd__materialize button {
width: fit-content;
border: 0;
border-radius: 4px;
background: var(--color-brand-primary, #4f46e5);
color: #fff;
padding: 0.4rem 0.7rem;
font-size: 0.78rem;
cursor: pointer;
}
.bvd__materialize button[disabled] {
opacity: 0.6;
cursor: default;
}
.bvd__state {
font-size: 0.9rem;
color: var(--color-text-secondary, #666);
}
.bvd__state--error {
color: #991b1b;
}
.bvd__message {
color: #065f46;
font-size: 0.82rem;
margin: 0 0 0.5rem;
}
.bvd__error {
color: #991b1b;
font-size: 0.82rem;
margin: 0 0 0.5rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
text-transform: capitalize;
}
.status-badge--published {
background: #d1fae5;
color: #065f46;
}
.status-badge--queued {
background: #fef3c7;
color: #92400e;
}
.status-badge--draft {
background: #e5e7eb;
color: #374151;
}
.mono {
font-family: var(--font-mono, monospace);
font-size: 0.84rem;
}
`],
})
export class BundleVersionDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly bundleApi = inject(BundleOrganizerApi);
readonly bundleId = signal('');
readonly versionId = signal('');
readonly activeTab = signal<'components' | 'validation' | 'releases'>('components');
readonly loading = signal(true);
readonly errorMessage = signal<string | null>(null);
readonly versionDetail = signal<ReleaseControlBundleVersionDetailDto | null>(null);
readonly materializing = signal(false);
readonly targetEnvironment = signal('');
readonly materializeMessage = signal<string | null>(null);
readonly materializeError = signal<string | null>(null);
ngOnInit(): void {
this.bundleId.set(this.route.snapshot.params['bundleId'] ?? '');
this.versionId.set(this.route.snapshot.params['version'] ?? '');
if (!this.bundleId() || !this.versionId()) {
this.loading.set(false);
this.errorMessage.set('Bundle or version id is missing from route.');
return;
}
this.loadVersion();
}
setTab(tab: 'components' | 'validation' | 'releases'): void {
this.activeTab.set(tab);
}
materialize(): void {
this.materializeMessage.set(null);
this.materializeError.set(null);
this.materializing.set(true);
this.bundleApi
.materializeBundleVersion(this.bundleId(), this.versionId(), {
targetEnvironment: this.targetEnvironment().trim() || null,
reason: 'bundle_version_detail_manual_trigger',
})
.subscribe({
next: (run) => {
this.materializeMessage.set(`Materialization run queued (${run.runId}).`);
this.materializing.set(false);
},
error: () => {
this.materializeError.set('Failed to enqueue materialization run.');
this.materializing.set(false);
},
});
}
formatDateTime(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return `${parsed.getUTCFullYear()}-${String(parsed.getUTCMonth() + 1).padStart(2, '0')}-${String(
parsed.getUTCDate()
).padStart(2, '0')} ${String(parsed.getUTCHours()).padStart(2, '0')}:${String(
parsed.getUTCMinutes()
).padStart(2, '0')} UTC`;
}
truncateDigest(digest: string): string {
if (digest.length <= 24) {
return digest;
}
return `${digest.slice(0, 20)}...`;
}
promotionReadiness(status: string): string {
return status.toLowerCase() === 'published'
? 'Ready after validation and materialization checks.'
: 'Pending publication before promotion entry.';
}
private loadVersion(): void {
this.loading.set(true);
this.errorMessage.set(null);
this.bundleApi.getBundleVersion(this.bundleId(), this.versionId()).subscribe({
next: (versionDetail) => {
this.versionDetail.set(versionDetail);
this.loading.set(false);
},
error: () => {
this.errorMessage.set('Failed to load bundle version detail from release-control endpoint.');
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,50 @@
/**
* Bundle Organizer Routes
* Sprint: SPRINT_20260218_009_FE_ui_v2_rewire_bundle_organizer_lifecycle (B4-01 through B4-06)
*
* Canonical bundle lifecycle surfaces under /release-control/bundles:
* '' — Bundle catalog (list + search + filter)
* create — Bundle builder wizard
* :bundleId — Bundle detail (version list, config, changelog)
* :bundleId/:version — Bundle version detail (components, materialization)
*/
import { Routes } from '@angular/router';
export const BUNDLE_ROUTES: Routes = [
// B4-01 — Bundle catalog
{
path: '',
title: 'Bundles',
data: { breadcrumb: 'Bundles' },
loadComponent: () =>
import('./bundle-catalog.component').then((m) => m.BundleCatalogComponent),
},
// B4-03/B4-04/B4-05 — Bundle builder wizard
{
path: 'create',
title: 'Create Bundle',
data: { breadcrumb: 'Create Bundle' },
loadComponent: () =>
import('./bundle-builder.component').then((m) => m.BundleBuilderComponent),
},
// B4-02 — Bundle detail (with version history, config-contract, changelog)
{
path: ':bundleId',
title: 'Bundle Detail',
data: { breadcrumb: 'Bundle Detail' },
loadComponent: () =>
import('./bundle-detail.component').then((m) => m.BundleDetailComponent),
},
// B4-04/B4-06 — Bundle version detail (component selector, materialization)
{
path: ':bundleId/:version',
title: 'Bundle Version',
data: { breadcrumb: 'Bundle Version' },
loadComponent: () =>
import('./bundle-version-detail.component').then((m) => m.BundleVersionDetailComponent),
},
];

View File

@@ -0,0 +1,882 @@
/**
* Dashboard V3 - Mission Board
* Sprint: SPRINT_20260218_012_FE_ui_v2_rewire_dashboard_v3_mission_board (D7-01 through D7-05)
*
* Release mission board: aggregates environment risk, SBOM state, reachability,
* and data-integrity signals. Summarises; does not duplicate domain ownership.
*/
import {
Component,
ChangeDetectionStrategy,
signal,
computed,
} from '@angular/core';
import { TitleCasePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
interface EnvironmentCard {
id: string;
name: string;
region: string;
deployStatus: 'healthy' | 'degraded' | 'blocked' | 'unknown';
sbomFreshness: 'fresh' | 'stale' | 'missing';
critRCount: number;
highRCount: number;
pendingApprovals: number;
lastDeployedAt: string;
}
interface MissionSummary {
activePromotions: number;
blockedPromotions: number;
highestRiskEnv: string;
dataIntegrityStatus: 'healthy' | 'degraded' | 'error';
}
@Component({
selector: 'app-dashboard-v3',
standalone: true,
imports: [RouterLink, TitleCasePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="mission-board">
<!-- Header: environment selector, date range filter, mission summary -->
<header class="board-header">
<div class="header-identity">
<h1 class="board-title">Mission Board</h1>
<p class="board-subtitle">Release pipeline health across all regions and environments</p>
</div>
<div class="header-controls">
<div class="control-group">
<label class="control-label" for="regionFilter">Region</label>
<select
id="regionFilter"
class="control-select"
[value]="selectedRegion()"
(change)="onRegionChange($event)"
>
<option value="all">All Regions</option>
<option value="eu-west">EU West</option>
<option value="us-east">US East</option>
<option value="ap-south">AP South</option>
</select>
</div>
<div class="control-group">
<label class="control-label" for="timeWindow">Time Window</label>
<select
id="timeWindow"
class="control-select"
[value]="selectedTimeWindow()"
(change)="onTimeWindowChange($event)"
>
<option value="1h">Last 1h</option>
<option value="24h">Last 24h</option>
<option value="7d">Last 7d</option>
<option value="30d">Last 30d</option>
</select>
</div>
</div>
</header>
<!-- Mission Summary Strip -->
<section class="mission-summary" aria-label="Mission summary">
<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>
</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>
</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>
</div>
<div class="summary-card" [class.warning]="summary().dataIntegrityStatus === 'degraded'"
[class.critical]="summary().dataIntegrityStatus === 'error'">
<div class="summary-value">
<span class="status-dot" [class]="summary().dataIntegrityStatus"></span>
{{ summary().dataIntegrityStatus | titlecase }}
</div>
<div class="summary-label">Data Integrity</div>
<a routerLink="/platform-ops/data-integrity" class="summary-link">Ops detail</a>
</div>
</section>
<!-- Regional Pipeline Board -->
<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>
</div>
<div class="env-grid">
@for (env of filteredEnvironments(); track env.id) {
<div class="env-card" [class]="env.deployStatus">
<div class="env-card-header">
<div class="env-identity">
<span class="env-name">{{ env.name }}</span>
<span class="env-region">{{ env.region }}</span>
</div>
<span class="status-badge" [class]="env.deployStatus">
{{ env.deployStatus }}
</span>
</div>
<div class="env-metrics">
<div class="metric">
<span class="metric-label">SBOM</span>
<span class="metric-value freshness" [class]="env.sbomFreshness">
{{ env.sbomFreshness }}
</span>
</div>
<div class="metric">
<span class="metric-label">CritR</span>
<span class="metric-value" [class.danger]="env.critRCount > 0">
{{ env.critRCount }}
</span>
</div>
<div class="metric">
<span class="metric-label">HighR</span>
<span class="metric-value" [class.warning]="env.highRCount > 0">
{{ env.highRCount }}
</span>
</div>
<div class="metric">
<span class="metric-label">Pending</span>
<span class="metric-value" [class.warning]="env.pendingApprovals > 0">
{{ env.pendingApprovals }}
</span>
</div>
</div>
<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">
Detail
</a>
<a [routerLink]="['/security-risk/findings']" [queryParams]="{ env: env.id }" class="env-link">
Findings
</a>
</div>
</div>
</div>
}
@if (filteredEnvironments().length === 0) {
<div class="env-grid-empty">
<p>No environments match the current filter.</p>
</div>
}
</div>
</section>
<!-- Summary Cards Row -->
<div class="cards-row">
<!-- SBOM Snapshot Card -->
<section class="domain-card" aria-label="SBOM snapshot">
<div class="card-header">
<h2 class="card-title">SBOM Snapshot</h2>
<a routerLink="/security-risk/sbom" class="card-link">View SBOM</a>
</div>
<div class="card-body">
<div class="snapshot-stat">
<span class="stat-value">{{ sbomStats().totalComponents.toLocaleString() }}</span>
<span class="stat-label">Total Components</span>
</div>
<div class="snapshot-stat">
<span class="stat-value danger">{{ sbomStats().criticalFindings }}</span>
<span class="stat-label">Critical Findings</span>
</div>
<div class="snapshot-stat">
<span class="stat-value" [class.warning]="sbomStats().staleCount > 0">
{{ sbomStats().staleCount }}
</span>
<span class="stat-label">Stale SBOMs</span>
</div>
<div class="snapshot-stat">
<span class="stat-value" [class.danger]="sbomStats().missingCount > 0">
{{ sbomStats().missingCount }}
</span>
<span class="stat-label">Missing SBOMs</span>
</div>
</div>
<div class="card-footer">
<a routerLink="/security-risk/findings" class="card-action">Explore findings</a>
<a routerLink="/release-control" class="card-action">Release Control</a>
</div>
</section>
<!-- Reachability Summary Card -->
<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>
</div>
<div class="card-body">
<div class="bir-matrix">
<div class="bir-item">
<span class="bir-label">B (Binary)</span>
<div class="bir-bar-track">
<div class="bir-bar" [style.width.%]="reachabilityStats().bCoverage"></div>
</div>
<span class="bir-value">{{ reachabilityStats().bCoverage }}%</span>
</div>
<div class="bir-item">
<span class="bir-label">I (Interpreted)</span>
<div class="bir-bar-track">
<div class="bir-bar" [style.width.%]="reachabilityStats().iCoverage"></div>
</div>
<span class="bir-value">{{ reachabilityStats().iCoverage }}%</span>
</div>
<div class="bir-item">
<span class="bir-label">R (Runtime)</span>
<div class="bir-bar-track">
<div class="bir-bar" [style.width.%]="reachabilityStats().rCoverage"></div>
</div>
<span class="bir-value">{{ reachabilityStats().rCoverage }}%</span>
</div>
</div>
<p class="card-note">
Hybrid B/I/R reachability coverage across production environments.
</p>
</div>
<div class="card-footer">
<a routerLink="/security-risk/reachability" class="card-action">Deep analysis</a>
</div>
</section>
<!-- Data Integrity Summary Card -->
<section class="domain-card" aria-label="Data integrity summary">
<div class="card-header">
<h2 class="card-title">Data Integrity</h2>
<a routerLink="/platform-ops/data-integrity" class="card-link">Platform Ops detail</a>
</div>
<div class="card-body">
<div class="integrity-stat" [class.warning]="dataIntegrityStats().staleFeedCount > 0">
<span class="stat-value">{{ dataIntegrityStats().staleFeedCount }}</span>
<span class="stat-label">Stale Feeds</span>
</div>
<div class="integrity-stat" [class.danger]="dataIntegrityStats().failedScans > 0">
<span class="stat-value">{{ dataIntegrityStats().failedScans }}</span>
<span class="stat-label">Failed Scans</span>
</div>
<div class="integrity-stat" [class.warning]="dataIntegrityStats().dlqDepth > 0">
<span class="stat-value">{{ dataIntegrityStats().dlqDepth }}</span>
<span class="stat-label">DLQ Depth</span>
</div>
<p class="card-note integrity-ownership-note">
Advisory source health is managed in
<a routerLink="/platform-ops/data-integrity">Platform Ops &gt; Data Integrity</a>.
</p>
</div>
<div class="card-footer">
<a routerLink="/platform-ops/data-integrity" class="card-action">Ops diagnostics</a>
</div>
</section>
</div>
<!-- Cross-domain navigation links -->
<nav class="domain-nav" aria-label="Domain navigation">
<a routerLink="/release-control" class="domain-nav-item">
<span class="domain-icon">&#9654;</span>
Release Control
</a>
<a routerLink="/security-risk" class="domain-nav-item">
<span class="domain-icon">&#9632;</span>
Security &amp; Risk
</a>
<a routerLink="/platform-ops" class="domain-nav-item">
<span class="domain-icon">&#9670;</span>
Platform Ops
</a>
<a routerLink="/evidence-audit" class="domain-nav-item">
<span class="domain-icon">&#9679;</span>
Evidence &amp; Audit
</a>
<a routerLink="/administration" class="domain-nav-item">
<span class="domain-icon">&#9881;</span>
Administration
</a>
</nav>
</div>
`,
styles: [`
.mission-board {
padding: 1.5rem;
max-width: 1600px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Header */
.board-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 1rem;
}
.board-title {
font-size: 1.75rem;
font-weight: var(--font-weight-bold);
margin: 0;
}
.board-subtitle {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 0.25rem 0 0;
}
.header-controls {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.control-label {
font-size: 0.75rem;
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
.control-select {
padding: 0.4rem 0.75rem;
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.9rem;
min-width: 140px;
}
/* Mission Summary Strip */
.mission-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.summary-card {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.summary-card.warning {
border-left: 4px solid var(--color-status-warning);
}
.summary-card.critical {
border-left: 4px solid var(--color-status-error);
}
.summary-value {
font-size: 1.75rem;
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.summary-value.env-name {
font-size: 1rem;
font-weight: var(--font-weight-semibold);
}
.summary-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.summary-link {
font-size: 0.8rem;
color: var(--color-brand-primary);
text-decoration: none;
margin-top: 0.25rem;
}
/* Section headers */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-title {
font-size: 1.1rem;
font-weight: var(--font-weight-semibold);
margin: 0;
}
.section-link {
font-size: 0.85rem;
color: var(--color-brand-primary);
text-decoration: none;
}
/* Pipeline Board */
.pipeline-board {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.env-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.env-grid-empty {
grid-column: 1 / -1;
padding: 2rem;
text-align: center;
color: var(--color-text-secondary);
}
.env-card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-surface-elevated);
}
.env-card.healthy { border-top: 3px solid var(--color-status-success); }
.env-card.degraded { border-top: 3px solid var(--color-status-warning); }
.env-card.blocked { border-top: 3px solid var(--color-status-error); }
.env-card.unknown { border-top: 3px solid var(--color-border-primary); }
.env-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border-primary);
}
.env-identity {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.env-name {
font-weight: var(--font-weight-semibold);
font-size: 0.95rem;
}
.env-region {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.status-badge {
font-size: 0.7rem;
font-weight: var(--font-weight-medium);
padding: 0.2rem 0.5rem;
border-radius: var(--radius-full);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.status-badge.healthy { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.status-badge.degraded { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.status-badge.blocked { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.status-badge.unknown { background: var(--color-surface-elevated); color: var(--color-text-secondary); }
.env-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
padding: 0.75rem 1rem;
gap: 0.5rem;
}
.metric {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.metric-label {
font-size: 0.65rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric-value {
font-size: 1rem;
font-weight: var(--font-weight-semibold);
}
.metric-value.danger { color: var(--color-status-error); }
.metric-value.warning { color: var(--color-status-warning); }
.metric-value.freshness.fresh { color: var(--color-status-success); }
.metric-value.freshness.stale { color: var(--color-status-warning); }
.metric-value.freshness.missing { color: var(--color-status-error); }
.env-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
border-top: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
}
.last-deployed {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.env-links {
display: flex;
gap: 0.75rem;
}
.env-link {
font-size: 0.8rem;
color: var(--color-brand-primary);
text-decoration: none;
}
/* Cards Row */
.cards-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.domain-card {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border-primary);
background: var(--color-surface-elevated);
}
.card-title {
font-size: 1rem;
font-weight: var(--font-weight-semibold);
margin: 0;
}
.card-link {
font-size: 0.8rem;
color: var(--color-brand-primary);
text-decoration: none;
}
.card-body {
padding: 1.25rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--color-border-primary);
display: flex;
gap: 1rem;
background: var(--color-surface-elevated);
}
.card-action {
font-size: 0.85rem;
color: var(--color-brand-primary);
text-decoration: none;
}
.card-note {
font-size: 0.8rem;
color: var(--color-text-muted);
margin: 0;
}
/* SBOM Snapshot */
.snapshot-stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
}
.stat-value {
font-size: 1.25rem;
font-weight: var(--font-weight-bold);
}
.stat-value.danger { color: var(--color-status-error); }
.stat-value.warning { color: var(--color-status-warning); }
.stat-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
/* B/I/R Matrix */
.bir-matrix {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.bir-item {
display: grid;
grid-template-columns: 90px 1fr 40px;
align-items: center;
gap: 0.5rem;
}
.bir-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.bir-bar-track {
background: var(--color-surface-elevated);
border-radius: var(--radius-full);
height: 8px;
overflow: hidden;
}
.bir-bar {
height: 100%;
background: var(--color-brand-primary);
border-radius: var(--radius-full);
transition: width 0.3s ease;
}
.bir-value {
font-size: 0.8rem;
font-weight: var(--font-weight-medium);
text-align: right;
}
/* Data Integrity */
.integrity-stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
}
.integrity-stat.warning .stat-value { color: var(--color-status-warning); }
.integrity-stat.danger .stat-value { color: var(--color-status-error); }
.integrity-ownership-note a {
color: var(--color-brand-primary);
text-decoration: none;
}
/* Status dot */
.status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: var(--radius-full);
margin-right: 0.4rem;
vertical-align: middle;
}
.status-dot.healthy { background: var(--color-status-success); }
.status-dot.degraded { background: var(--color-status-warning); }
.status-dot.error { background: var(--color-status-error); }
/* Domain Navigation */
.domain-nav {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
border-top: 1px solid var(--color-border-primary);
padding-top: 1.25rem;
}
.domain-nav-item {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.9rem;
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-elevated);
transition: background 0.15s;
}
.domain-nav-item:hover {
background: var(--color-surface-primary);
}
.domain-icon {
font-size: 0.7rem;
color: var(--color-brand-primary);
}
@media (max-width: 768px) {
.board-header {
flex-direction: column;
}
.mission-summary {
grid-template-columns: 1fr 1fr;
}
.cards-row {
grid-template-columns: 1fr;
}
}
`],
})
export class DashboardV3Component {
readonly selectedRegion = signal<string>('all');
readonly selectedTimeWindow = signal<string>('24h');
// Placeholder mission summary data
readonly summary = signal<MissionSummary>({
activePromotions: 3,
blockedPromotions: 1,
highestRiskEnv: 'prod-eu-west',
dataIntegrityStatus: 'healthy',
});
// Placeholder environments
private readonly allEnvironments = signal<EnvironmentCard[]>([
{
id: 'dev-eu-west',
name: 'dev',
region: 'EU West',
deployStatus: 'healthy',
sbomFreshness: 'fresh',
critRCount: 0,
highRCount: 2,
pendingApprovals: 0,
lastDeployedAt: '2h ago',
},
{
id: 'stage-eu-west',
name: 'stage',
region: 'EU West',
deployStatus: 'degraded',
sbomFreshness: 'stale',
critRCount: 1,
highRCount: 5,
pendingApprovals: 2,
lastDeployedAt: '6h ago',
},
{
id: 'prod-eu-west',
name: 'prod',
region: 'EU West',
deployStatus: 'healthy',
sbomFreshness: 'fresh',
critRCount: 3,
highRCount: 8,
pendingApprovals: 1,
lastDeployedAt: '1d ago',
},
{
id: 'dev-us-east',
name: 'dev',
region: 'US East',
deployStatus: 'healthy',
sbomFreshness: 'fresh',
critRCount: 0,
highRCount: 1,
pendingApprovals: 0,
lastDeployedAt: '3h ago',
},
{
id: 'prod-us-east',
name: 'prod',
region: 'US East',
deployStatus: 'blocked',
sbomFreshness: 'missing',
critRCount: 5,
highRCount: 12,
pendingApprovals: 3,
lastDeployedAt: '3d ago',
},
]);
readonly filteredEnvironments = computed(() => {
const region = this.selectedRegion();
if (region === 'all') return this.allEnvironments();
return this.allEnvironments().filter(
(e) => e.region.toLowerCase().replace(' ', '-') === region
);
});
// Placeholder SBOM stats
readonly sbomStats = signal({
totalComponents: 24_850,
criticalFindings: 8,
staleCount: 2,
missingCount: 1,
});
// Placeholder reachability stats
readonly reachabilityStats = signal({
bCoverage: 72,
iCoverage: 88,
rCoverage: 61,
});
// Placeholder data integrity stats
readonly dataIntegrityStats = signal({
staleFeedCount: 1,
failedScans: 0,
dlqDepth: 3,
});
onRegionChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedRegion.set(select.value);
}
onTimeWindowChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedTimeWindow.set(select.value);
}
}

View File

@@ -0,0 +1,524 @@
/**
* Evidence & Audit Overview Component
* Sprint: SPRINT_20260218_015_FE_ui_v2_rewire_evidence_audit_consolidation (V10-01 through V10-05)
*
* Domain overview page for Evidence & Audit (V0). Routes users to the evidence surface
* matching their need: promotion decision, bundle evidence, environment snapshot,
* proof verification, or audit trail.
* Trust & Signing ownership remains in Administration; Evidence consumes trust state.
*/
import {
Component,
ChangeDetectionStrategy,
computed,
signal,
} from '@angular/core';
import { RouterLink } from '@angular/router';
interface EvidenceEntryCard {
title: string;
description: string;
link: string;
linkLabel: string;
icon: string;
status?: 'ok' | 'warning' | 'info';
}
type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
@Component({
selector: 'app-evidence-audit-overview',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="evidence-audit-overview">
<header class="overview-header">
<div class="header-content">
<h1 class="overview-title">Evidence &amp; Audit</h1>
<p class="overview-subtitle">
Retrieve, verify, export, and audit evidence for every release, bundle, environment, and approval decision.
</p>
</div>
</header>
<section class="mode-toggle" aria-label="Evidence home state mode">
<span>State mode:</span>
<button type="button" [class.active]="mode() === 'normal'" (click)="setMode('normal')">Normal</button>
<button type="button" [class.active]="mode() === 'degraded'" (click)="setMode('degraded')">Degraded</button>
<button type="button" [class.active]="mode() === 'empty'" (click)="setMode('empty')">Empty</button>
</section>
@if (isDegraded()) {
<section class="state-banner" role="status">
Evidence index is degraded. Replay and export links remain available, but latest-pack metrics
may be stale.
</section>
}
<!-- Primary entry points -->
<section class="entry-section" aria-label="Evidence entry points">
<h2 class="section-title">Evidence Surfaces</h2>
@if (entryCards().length === 0) {
<div class="empty-state">
<p>No evidence records are available yet.</p>
<a routerLink="/release-control/promotions">Open Release Control Promotions</a>
</div>
} @else {
<div class="entry-grid">
@for (card of entryCards(); track card.link) {
<a [routerLink]="card.link" class="entry-card" [class]="card.status ?? 'info'">
<div class="entry-icon" aria-hidden="true">{{ card.icon }}</div>
<div class="entry-body">
<div class="entry-title">{{ card.title }}</div>
<div class="entry-description">{{ card.description }}</div>
</div>
<div class="entry-link-label">{{ card.linkLabel }} &#8594;</div>
</a>
}
</div>
}
</section>
<!-- Quick Stats -->
<section class="stats-section" aria-label="Evidence statistics">
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">{{ stats().totalPacks.toLocaleString() }}</span>
<span class="stat-label">Evidence Packs</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ stats().auditEventsToday.toLocaleString() }}</span>
<span class="stat-label">Audit Events Today</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ stats().proofChains.toLocaleString() }}</span>
<span class="stat-label">Proof Chains</span>
</div>
<div class="stat-item" [class.warning]="stats().pendingExports > 0">
<span class="stat-value">{{ stats().pendingExports }}</span>
<span class="stat-label">Pending Exports</span>
</div>
</div>
</section>
<!-- Cross-domain links -->
<section class="cross-links" aria-label="Related domain links">
<h2 class="section-title">Related Domains</h2>
<div class="cross-links-grid">
<a routerLink="/release-control" class="cross-link">
<span class="cross-link-icon" aria-hidden="true">&#9654;</span>
<div class="cross-link-body">
<div class="cross-link-title">Release Control</div>
<div class="cross-link-desc">Evidence attached to releases and promotions</div>
</div>
</a>
<a routerLink="/administration/trust-signing" class="cross-link">
<span class="cross-link-icon" aria-hidden="true">&#9632;</span>
<div class="cross-link-body">
<div class="cross-link-title">Administration &gt; Trust &amp; Signing</div>
<div class="cross-link-desc">Key management and signing policy (owned by Administration)</div>
</div>
</a>
<a routerLink="/administration/policy-governance" class="cross-link">
<span class="cross-link-icon" aria-hidden="true">&#9670;</span>
<div class="cross-link-body">
<div class="cross-link-title">Administration &gt; Policy Governance</div>
<div class="cross-link-desc">Policy packs driving evidence requirements</div>
</div>
</a>
<a routerLink="/security-risk/findings" class="cross-link">
<span class="cross-link-icon" aria-hidden="true">&#9679;</span>
<div class="cross-link-body">
<div class="cross-link-title">Security &amp; Risk &gt; Findings</div>
<div class="cross-link-desc">Findings linked to evidence records</div>
</div>
</a>
</div>
</section>
<!-- Trust 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>
Trust and signing operations are owned by
<a routerLink="/administration/trust-signing">Administration &gt; Trust &amp; Signing</a>.
Evidence &amp; Audit consumes trust state as a read-only consumer.
</aside>
</div>
`,
styles: [`
.evidence-audit-overview {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Header */
.overview-header {
border-bottom: 1px solid var(--color-border-primary);
padding-bottom: 1.25rem;
}
.overview-title {
font-size: 1.75rem;
font-weight: var(--font-weight-bold);
margin: 0;
}
.overview-subtitle {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 0.35rem 0 0;
}
/* Section titles */
.section-title {
font-size: 0.85rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.85rem;
}
.mode-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.mode-toggle button {
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
border-radius: 999px;
padding: 0.22rem 0.62rem;
cursor: pointer;
font-size: 0.76rem;
}
.mode-toggle button.active {
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
font-weight: var(--font-weight-semibold);
}
.state-banner {
border: 1px solid var(--color-status-warning-border, #f59e0b);
background: var(--color-status-warning-bg, #fffbeb);
color: var(--color-status-warning-text, #854d0e);
border-radius: var(--radius-md);
padding: 0.7rem 0.85rem;
font-size: 0.84rem;
}
/* Entry Cards */
.entry-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.empty-state {
border: 1px dashed var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 1rem;
}
.empty-state p {
margin: 0 0 0.4rem;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.empty-state a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.84rem;
}
.entry-card {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.1rem 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);
transition: box-shadow 0.15s, border-color 0.15s;
position: relative;
}
.entry-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-brand-primary);
}
.entry-card.ok { border-left: 4px solid var(--color-status-success); }
.entry-card.warning { border-left: 4px solid var(--color-status-warning); }
.entry-card.info { border-left: 4px solid var(--color-brand-primary); }
.entry-icon {
font-size: 1.5rem;
flex-shrink: 0;
width: 2rem;
text-align: center;
}
.entry-body {
flex: 1;
}
.entry-title {
font-weight: var(--font-weight-semibold);
font-size: 0.95rem;
margin-bottom: 0.2rem;
}
.entry-description {
font-size: 0.8rem;
color: var(--color-text-secondary);
line-height: 1.4;
}
.entry-link-label {
font-size: 0.8rem;
color: var(--color-brand-primary);
margin-top: 0.75rem;
font-weight: var(--font-weight-medium);
}
/* Stats Section */
.stats-section {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat-item.warning .stat-value { color: var(--color-status-warning); }
.stat-value {
font-size: 1.75rem;
font-weight: var(--font-weight-bold);
}
.stat-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
/* Cross Links */
.cross-links-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.85rem;
}
.cross-link {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-elevated);
text-decoration: none;
color: var(--color-text-primary);
transition: background 0.15s;
}
.cross-link:hover {
background: var(--color-surface-primary);
border-color: var(--color-brand-primary);
}
.cross-link-icon {
font-size: 0.85rem;
color: var(--color-brand-primary);
margin-top: 0.15rem;
flex-shrink: 0;
}
.cross-link-title {
font-size: 0.9rem;
font-weight: var(--font-weight-medium);
margin-bottom: 0.15rem;
}
.cross-link-desc {
font-size: 0.78rem;
color: var(--color-text-secondary);
line-height: 1.35;
}
/* 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) {
.entry-grid,
.cross-links-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
}
`],
})
export class EvidenceAuditOverviewComponent {
readonly mode = signal<EvidenceHomeMode>('normal');
private readonly normalEntryCards: EvidenceEntryCard[] = [
{
title: 'Evidence Packs',
description: 'Structured evidence collections for releases, bundles, and promotion decisions.',
link: '/evidence-audit/packs',
linkLabel: 'Browse packs',
icon: '&#128230;',
status: 'info',
},
{
title: 'Proof Chains',
description: 'Cryptographic proof chain traversal from subject digest to attestation.',
link: '/evidence-audit/proofs',
linkLabel: 'View proofs',
icon: '&#128274;',
status: 'info',
},
{
title: 'Replay and Verify',
description: 'Replay historical verdict decisions and verify deterministic evidence outcomes.',
link: '/evidence-audit/replay',
linkLabel: 'Open replay',
icon: '&#8635;',
status: 'warning',
},
{
title: 'Timeline',
description: 'Timeline and checkpoint history for release evidence progression.',
link: '/evidence-audit/timeline',
linkLabel: 'Open timeline',
icon: '&#9200;',
status: 'info',
},
{
title: 'Audit Log',
description: 'Comprehensive audit log filtered by actor, action, resource, and domain context.',
link: '/evidence-audit/audit',
linkLabel: 'Open audit log',
icon: '&#128196;',
status: 'ok',
},
{
title: 'Change Trace',
description: 'Byte-level change tracing between artifact versions with proof annotations.',
link: '/evidence-audit/change-trace',
linkLabel: 'Explore changes',
icon: '&#128202;',
status: 'info',
},
{
title: 'Evidence Export',
description: 'Export center: bundle exports, replay/verify, and scoped export jobs.',
link: '/evidence-audit/evidence',
linkLabel: 'Export center',
icon: '&#128226;',
status: 'info',
},
];
readonly entryCards = computed(() => {
if (this.mode() === 'empty') return [] as EvidenceEntryCard[];
return this.normalEntryCards;
});
readonly stats = computed(() => {
if (this.mode() === 'empty') {
return {
totalPacks: 0,
auditEventsToday: 0,
proofChains: 0,
pendingExports: 0,
};
}
if (this.mode() === 'degraded') {
return {
totalPacks: 1_842,
auditEventsToday: 12_340,
proofChains: 5_271,
pendingExports: 9,
};
}
return {
totalPacks: 1_842,
auditEventsToday: 12_340,
proofChains: 5_271,
pendingExports: 2,
};
});
readonly isDegraded = computed(() => this.mode() === 'degraded');
setMode(mode: EvidenceHomeMode): void {
this.mode.set(mode);
}
}

View File

@@ -13,7 +13,7 @@ describe('IntegrationDetailComponent', () => {
const mockIntegration: Integration = {
id: '1',
name: 'Harbor Registry',
type: IntegrationType.ContainerRegistry,
type: IntegrationType.Registry,
provider: 'harbor',
status: IntegrationStatus.Active,
description: 'Main container registry',

View File

@@ -28,11 +28,11 @@ describe('IntegrationHubComponent', () => {
return of(mockListResponse(5));
case IntegrationType.Scm:
return of(mockListResponse(3));
case IntegrationType.Ci:
case IntegrationType.CiCd:
return of(mockListResponse(2));
case IntegrationType.Host:
case IntegrationType.RuntimeHost:
return of(mockListResponse(8));
case IntegrationType.Feed:
case IntegrationType.FeedMirror:
return of(mockListResponse(4));
default:
return of(mockListResponse(0));

View File

@@ -190,13 +190,13 @@ export class IntegrationHubComponent {
this.integrationService.list({ type: IntegrationType.Scm, pageSize: 1 }).subscribe({
next: (res) => this.stats.scm = res.totalCount,
});
this.integrationService.list({ type: IntegrationType.Ci, pageSize: 1 }).subscribe({
this.integrationService.list({ type: IntegrationType.CiCd, pageSize: 1 }).subscribe({
next: (res) => this.stats.ci = res.totalCount,
});
this.integrationService.list({ type: IntegrationType.Host, pageSize: 1 }).subscribe({
this.integrationService.list({ type: IntegrationType.RuntimeHost, pageSize: 1 }).subscribe({
next: (res) => this.stats.hosts = res.totalCount,
});
this.integrationService.list({ type: IntegrationType.Feed, pageSize: 1 }).subscribe({
this.integrationService.list({ type: IntegrationType.FeedMirror, pageSize: 1 }).subscribe({
next: (res) => this.stats.feeds = res.totalCount,
});
}

View File

@@ -1,58 +1,141 @@
/**
* Integration Hub Routes
* Updated: SPRINT_20260218_008_FE_ui_v2_rewire_integrations_platform_ops_data_integrity (I3-01, I3-03)
*
* 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)
*
* Data Integrity cross-link: connectivity/freshness owned here;
* decision impact consumed by Security & Risk.
*/
import { Routes } from '@angular/router';
export const integrationHubRoutes: Routes = [
// Root — Integrations overview with health summary and category navigation
{
path: '',
title: 'Integrations',
data: { breadcrumb: 'Integrations' },
loadComponent: () =>
import('./integration-hub.component').then((m) => m.IntegrationHubComponent),
},
// Onboarding flow
{
path: 'onboarding',
title: 'Add Integration',
data: { breadcrumb: 'Add Integration' },
loadComponent: () =>
import('../integrations/integrations-hub.component').then((m) => m.IntegrationsHubComponent),
},
{
path: 'onboarding/:type',
title: 'Add Integration',
data: { breadcrumb: 'Add Integration' },
loadComponent: () =>
import('../integrations/integrations-hub.component').then((m) => m.IntegrationsHubComponent),
},
// Category: Container Registries
{
path: 'registries',
title: 'Registries',
data: { breadcrumb: 'Registries', type: 'Registry' },
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
data: { type: 'Registry' },
},
// Category: Source Control
{
path: 'scm',
title: 'Source Control',
data: { breadcrumb: 'Source Control', type: 'Scm' },
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
data: { type: 'Scm' },
},
// Category: CI/CD Pipelines
{
path: 'ci',
title: 'CI/CD',
data: { breadcrumb: 'CI/CD', type: 'Ci' },
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
data: { type: 'Ci' },
},
// Category: Targets / Runtimes
{
path: 'hosts',
title: 'Targets / Runtimes',
data: { breadcrumb: 'Targets / Runtimes', type: 'Host' },
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
data: { type: 'Host' },
},
{
path: 'targets-runtimes',
pathMatch: 'full',
redirectTo: 'hosts',
},
// Category: Secrets Managers
{
path: 'secrets',
title: 'Secrets',
data: { breadcrumb: 'Secrets', type: 'Secrets' },
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),
data: { type: 'Feed' },
},
// Category: Notification Providers
{
path: 'notifications',
title: 'Notification Providers',
data: { breadcrumb: 'Notification Providers', type: 'Notification' },
loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent),
},
// SBOM sources (canonical path under integrations)
{
path: 'sbom-sources',
title: 'SBOM Sources',
data: { breadcrumb: 'SBOM Sources' },
loadChildren: () =>
import('../sbom-sources/sbom-sources.routes').then((m) => m.SBOM_SOURCES_ROUTES),
},
// Activity log
{
path: 'activity',
title: 'Activity',
data: { breadcrumb: 'Activity' },
loadComponent: () =>
import('./integration-activity.component').then((m) => m.IntegrationActivityComponent),
},
// Integration detail — standard contract template (I3-03)
{
path: ':integrationId',
title: 'Integration Detail',
data: { breadcrumb: 'Integration Detail' },
loadComponent: () =>
import('./integration-detail.component').then((m) => m.IntegrationDetailComponent),
},

View File

@@ -14,7 +14,7 @@ describe('IntegrationListComponent', () => {
{
id: '1',
name: 'Harbor Registry',
type: IntegrationType.ContainerRegistry,
type: IntegrationType.Registry,
provider: 'harbor',
status: IntegrationStatus.Active,
description: 'Main container registry',
@@ -27,7 +27,7 @@ describe('IntegrationListComponent', () => {
{
id: '2',
name: 'GitHub App',
type: IntegrationType.SourceControl,
type: IntegrationType.Scm,
provider: 'github-app',
status: IntegrationStatus.Error,
description: 'Source control integration',
@@ -79,10 +79,10 @@ describe('IntegrationListComponent', () => {
const req = httpMock.expectOne('/api/v1/integrations');
req.flush(mockIntegrations);
component.filterByType(IntegrationType.ContainerRegistry);
component.filterByType(IntegrationType.Registry);
expect(component.filteredIntegrations.length).toBe(1);
expect(component.filteredIntegrations[0].type).toBe(IntegrationType.ContainerRegistry);
expect(component.filteredIntegrations[0].type).toBe(IntegrationType.Registry);
});
it('should filter integrations by status', () => {

View File

@@ -344,9 +344,12 @@ export class IntegrationListComponent implements OnInit {
switch (typeStr) {
case 'Registry': return IntegrationType.Registry;
case 'Scm': return IntegrationType.Scm;
case 'Ci': return IntegrationType.Ci;
case 'Host': return IntegrationType.Host;
case 'Feed': return IntegrationType.Feed;
case 'CiCd': case 'Ci': return IntegrationType.CiCd;
case 'RuntimeHost': case 'Host': return IntegrationType.RuntimeHost;
case 'FeedMirror': case 'Feed': return IntegrationType.FeedMirror;
case 'RepoSource': return IntegrationType.RepoSource;
case 'SymbolSource': return IntegrationType.SymbolSource;
case 'Marketplace': return IntegrationType.Marketplace;
default: return undefined;
}
}
@@ -355,11 +358,11 @@ export class IntegrationListComponent implements OnInit {
switch (type) {
case IntegrationType.Scm:
return 'scm';
case IntegrationType.Ci:
case IntegrationType.CiCd:
return 'ci';
case IntegrationType.Host:
case IntegrationType.RuntimeHost:
return 'host';
case IntegrationType.Feed:
case IntegrationType.FeedMirror:
return 'registry';
case IntegrationType.Registry:
default:

View File

@@ -4,12 +4,14 @@
*/
export enum IntegrationType {
Registry = 0,
Scm = 1,
Ci = 2,
Host = 3,
Feed = 4,
Artifact = 5,
Registry = 1,
Scm = 2,
CiCd = 3,
RepoSource = 4,
RuntimeHost = 5,
FeedMirror = 6,
SymbolSource = 7,
Marketplace = 8,
}
export enum IntegrationStatus {
@@ -157,14 +159,18 @@ export function getIntegrationTypeLabel(type: IntegrationType): string {
return 'Registry';
case IntegrationType.Scm:
return 'SCM';
case IntegrationType.Ci:
case IntegrationType.CiCd:
return 'CI/CD';
case IntegrationType.Host:
return 'Host';
case IntegrationType.Feed:
return 'Feed';
case IntegrationType.Artifact:
return 'Artifact';
case IntegrationType.RepoSource:
return 'Repo Source';
case IntegrationType.RuntimeHost:
return 'Runtime Host';
case IntegrationType.FeedMirror:
return 'Feed Mirror';
case IntegrationType.SymbolSource:
return 'Symbol Source';
case IntegrationType.Marketplace:
return 'Marketplace';
default:
return 'Unknown';
}

View File

@@ -11,7 +11,7 @@ describe('IntegrationService', () => {
const mockIntegration: Integration = {
id: '1',
name: 'Harbor Registry',
type: IntegrationType.ContainerRegistry,
type: IntegrationType.Registry,
provider: 'harbor',
status: IntegrationStatus.Active,
description: 'Test',
@@ -58,7 +58,7 @@ describe('IntegrationService', () => {
});
it('should filter by type', () => {
service.getIntegrations(IntegrationType.ContainerRegistry).subscribe();
service.getIntegrations(IntegrationType.Registry).subscribe();
const req = httpMock.expectOne('/api/v1/integrations?type=ContainerRegistry');
expect(req.request.method).toBe('GET');
@@ -90,7 +90,7 @@ describe('IntegrationService', () => {
it('should create a new integration', () => {
const createRequest = {
name: 'New Registry',
type: IntegrationType.ContainerRegistry,
type: IntegrationType.Registry,
provider: 'harbor',
configuration: { endpoint: 'https://new.example.com' }
};

View File

@@ -69,7 +69,11 @@ import { Subject, interval, takeUntil, switchMap, startWith, forkJoin } from 'rx
></span>
</div>
<p class="text-2xl font-bold text-gray-900">
{{ summary()!.healthyCount }}/{{ summary()!.totalServices }}
@if (summary()!.totalServices != null) {
{{ summary()!.healthyCount ?? 0 }}/{{ summary()!.totalServices }}
} @else {
}
</p>
<p class="text-xs text-gray-500">Healthy</p>
</div>

View File

@@ -0,0 +1,254 @@
/**
* Data Integrity Overview
* Sprint: SPRINT_20260218_008_FE_ui_v2_rewire_integrations_platform_ops_data_integrity (I3-02)
*
* Platform Ops-owned Data Integrity surface.
* Provides overview and drilldown links for:
* - Nightly data-quality reports
* - Feed freshness status
* - Scan pipeline health
* - Reachability ingest health
* - Integration connectivity status
* - Dead-Letter Queue management
* - SLO burn-rate monitoring
*
* Security Data ownership split:
* - THIS PAGE: connectivity health, feed freshness, pipeline operational state
* - Security & Risk: gating impact and decision context (consumer only)
*/
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterLink } from '@angular/router';
interface IntegritySection {
id: string;
title: string;
description: string;
route: string;
ownerNote?: string;
}
@Component({
selector: 'app-data-integrity-overview',
standalone: true,
imports: [RouterLink],
template: `
<div class="di-overview">
<header class="di-overview__header">
<h1 class="di-overview__title">Data Integrity</h1>
<p class="di-overview__subtitle">
Platform Ops source of truth for feed freshness, pipeline health, and data quality SLOs.
</p>
<p class="di-overview__ownership-note">
<strong>Ownership:</strong> Platform Ops manages connectivity and freshness.
Gating impact is consumed by
<a routerLink="/security-risk/advisory-sources">Security &amp; Risk</a>.
</p>
</header>
<div class="di-overview__grid">
@for (section of sections; track section.id) {
<a class="di-card" [routerLink]="section.route">
<div class="di-card__body">
<h2 class="di-card__title">{{ section.title }}</h2>
<p class="di-card__description">{{ section.description }}</p>
@if (section.ownerNote) {
<span class="di-card__owner">{{ section.ownerNote }}</span>
}
</div>
</a>
}
</div>
<section class="di-overview__related">
<h2 class="di-overview__section-heading">Related Operational Controls</h2>
<ul class="di-overview__links">
<li><a routerLink="/platform-ops/feeds">Feeds &amp; Mirrors</a> — feed source management</li>
<li><a routerLink="/platform-ops/dead-letter">Dead-Letter Queue</a> — failed message replay</li>
<li><a routerLink="/platform-ops/slo">SLO Monitoring</a> — burn-rate and error budgets</li>
<li><a routerLink="/platform-ops/doctor">Diagnostics</a> — registry connectivity checks</li>
</ul>
<h2 class="di-overview__section-heading">Security &amp; Risk Consumers</h2>
<ul class="di-overview__links">
<li>
<a routerLink="/security-risk/advisory-sources">Advisory Sources</a> — gating impact from fresh data
</li>
</ul>
</section>
</div>
`,
styles: [`
.di-overview {
padding: 1.5rem;
max-width: 1000px;
}
.di-overview__header {
margin-bottom: 2rem;
}
.di-overview__title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.di-overview__subtitle {
color: var(--color-text-secondary, #666);
margin: 0 0 0.75rem;
}
.di-overview__ownership-note {
font-size: 0.8125rem;
color: var(--color-text-secondary, #666);
background: var(--color-surface-alt, #f9fafb);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-sm, 4px);
padding: 0.5rem 0.75rem;
margin: 0;
}
.di-overview__ownership-note a {
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
}
.di-overview__ownership-note a:hover {
text-decoration: underline;
}
.di-overview__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.di-card {
display: block;
padding: 1.25rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.di-card:hover {
border-color: var(--color-brand-primary, #4f46e5);
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.di-card__title {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.di-card__description {
font-size: 0.8125rem;
color: var(--color-text-secondary, #666);
margin: 0 0 0.25rem;
}
.di-card__owner {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
font-style: italic;
}
.di-overview__section-heading {
font-size: 0.875rem;
font-weight: 600;
margin: 0 0 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary, #666);
}
.di-overview__related {
display: flex;
flex-direction: column;
gap: 1rem;
}
.di-overview__links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.di-overview__links li {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
}
.di-overview__links a {
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
}
.di-overview__links a:hover {
text-decoration: underline;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DataIntegrityOverviewComponent {
readonly sections: IntegritySection[] = [
{
id: 'nightly-report',
title: 'Nightly Data Quality Report',
description: 'Aggregated quality metrics, freshness scores, and anomaly flags from last run.',
route: '/platform-ops/health',
ownerNote: 'Platform Ops — source of truth',
},
{
id: 'feeds-freshness',
title: 'Feeds Freshness',
description: 'Advisory feed source staleness, last-sync timestamps, and delta alerts.',
route: '/platform-ops/feeds',
ownerNote: 'Platform Ops — connectivity ownership',
},
{
id: 'scan-pipeline',
title: 'Scan Pipeline Health',
description: 'Scanner queue depth, throughput rates, and error rates.',
route: '/platform-ops/health',
ownerNote: 'Platform Ops — pipeline operations',
},
{
id: 'reachability-ingest',
title: 'Reachability Ingest Health',
description: 'Reachability graph ingestion status and stale-graph detection.',
route: '/platform-ops/health',
ownerNote: 'Platform Ops — ingest operations',
},
{
id: 'integration-connectivity',
title: 'Integration Connectivity',
description: 'Live connectivity status for all registered integration connectors.',
route: '/integrations',
ownerNote: 'Integrations — connector ownership',
},
{
id: 'dlq-replays',
title: 'DLQ & Replays',
description: 'Dead-letter queue contents, replay operations, and failure investigation.',
route: '/platform-ops/dead-letter',
ownerNote: 'Platform Ops — operations',
},
{
id: 'slo-burn',
title: 'Data Quality SLOs',
description: 'Error-budget burn rates and latency targets for data pipeline SLOs.',
route: '/platform-ops/slo',
ownerNote: 'Platform Ops — SLO ownership',
},
];
}

View File

@@ -0,0 +1,159 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import {
FederationTelemetryApi,
FederationBundleSummary,
} from './federation-telemetry.api';
@Component({
selector: 'app-bundle-explorer',
standalone: true,
template: `
<div class="bundle-explorer">
<header class="bundle-explorer__header">
<h1>Bundle Explorer</h1>
<p>Federated telemetry bundles aggregated with differential privacy.</p>
</header>
<div class="bundle-explorer__table-wrap">
<table class="bundle-table">
<thead>
<tr>
<th>ID</th>
<th>Source Site</th>
<th>Buckets</th>
<th>Suppressed</th>
<th>Epsilon Spent</th>
<th>Verified</th>
<th>Created</th>
</tr>
</thead>
<tbody>
@for (bundle of bundles(); track bundle.id) {
<tr class="bundle-row">
<td class="cell-mono">{{ bundle.id | slice:0:8 }}...</td>
<td>{{ bundle.sourceSiteId }}</td>
<td>{{ bundle.bucketCount }}</td>
<td>{{ bundle.suppressedBuckets }}</td>
<td>{{ bundle.epsilonSpent.toFixed(4) }}</td>
<td>
<span class="verify-badge" [class.verify-badge--ok]="bundle.verified">
{{ bundle.verified ? 'OK' : 'FAIL' }}
</span>
</td>
<td>{{ bundle.createdAt }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="bundle-table__empty">No bundles yet</td>
</tr>
}
</tbody>
</table>
</div>
@if (error()) {
<div class="bundle-explorer__error">{{ error() }}</div>
}
</div>
`,
styles: [`
.bundle-explorer {
padding: 1.5rem;
max-width: 1200px;
}
.bundle-explorer__header {
margin-bottom: 1.5rem;
}
.bundle-explorer__header h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.bundle-explorer__header p {
color: var(--color-text-secondary, #666);
margin: 0;
}
.bundle-explorer__table-wrap {
overflow-x: auto;
}
.bundle-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.bundle-table th {
text-align: left;
padding: 0.75rem 1rem;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary, #666);
border-bottom: 2px solid var(--color-border, #e5e7eb);
}
.bundle-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
.bundle-row:hover {
background: var(--color-surface-hover, #f9fafb);
}
.cell-mono {
font-family: var(--font-family-mono);
font-size: 0.8125rem;
}
.verify-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-full, 9999px);
font-size: 0.6875rem;
font-weight: 600;
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #991b1b);
}
.verify-badge--ok {
background: var(--color-success-bg, #d1fae5);
color: var(--color-success, #065f46);
}
.bundle-table__empty {
text-align: center;
color: var(--color-text-secondary, #666);
padding: 2rem 1rem;
}
.bundle-explorer__error {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #991b1b);
border-radius: var(--radius-md, 8px);
font-size: 0.875rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BundleExplorerComponent implements OnInit {
private readonly api = inject(FederationTelemetryApi);
readonly bundles = signal<FederationBundleSummary[]>([]);
readonly error = signal<string | null>(null);
ngOnInit(): void {
this.api.listBundles().subscribe({
next: (b) => this.bundles.set(b),
error: () => this.error.set('Failed to load bundles'),
});
}
}

View File

@@ -0,0 +1,245 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import {
FederationTelemetryApi,
FederationConsentState,
} from './federation-telemetry.api';
@Component({
selector: 'app-consent-management',
standalone: true,
template: `
<div class="consent-management">
<header class="consent-management__header">
<h1>Consent Management</h1>
<p>Manage federation telemetry consent for your organization.</p>
</header>
<div class="consent-management__state">
<div class="consent-state-card">
<div class="consent-state-card__status" [class.consent-state-card__status--granted]="state()?.granted">
{{ state()?.granted ? 'Consent Granted' : 'Consent Not Granted' }}
</div>
@if (state()?.granted) {
<div class="consent-state-card__details">
<div class="detail-row">
<span class="detail-label">Granted By:</span>
<span class="detail-value">{{ state()!.grantedBy }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Granted At:</span>
<span class="detail-value">{{ state()!.grantedAt }}</span>
</div>
@if (state()!.expiresAt) {
<div class="detail-row">
<span class="detail-label">Expires At:</span>
<span class="detail-value">{{ state()!.expiresAt }}</span>
</div>
}
@if (state()!.dsseDigest) {
<div class="detail-row">
<span class="detail-label">DSSE Digest:</span>
<span class="detail-value detail-value--mono">{{ state()!.dsseDigest }}</span>
</div>
}
</div>
}
<div class="consent-state-card__actions">
@if (!state()?.granted) {
<button class="btn btn--primary" (click)="grantConsent()" [disabled]="loading()">
Grant Consent
</button>
} @else {
<button class="btn btn--danger" (click)="revokeConsent()" [disabled]="loading()">
Revoke Consent
</button>
}
</div>
</div>
</div>
@if (error()) {
<div class="consent-management__error">{{ error() }}</div>
}
@if (successMessage()) {
<div class="consent-management__success">{{ successMessage() }}</div>
}
</div>
`,
styles: [`
.consent-management {
padding: 1.5rem;
max-width: 800px;
}
.consent-management__header {
margin-bottom: 1.5rem;
}
.consent-management__header h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.consent-management__header p {
color: var(--color-text-secondary, #666);
margin: 0;
}
.consent-state-card {
padding: 1.5rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
}
.consent-state-card__status {
font-size: 1.125rem;
font-weight: 600;
padding: 0.5rem 1rem;
border-radius: var(--radius-md, 8px);
background: var(--color-surface-secondary, #f3f4f6);
display: inline-block;
margin-bottom: 1rem;
}
.consent-state-card__status--granted {
background: var(--color-success-bg, #d1fae5);
color: var(--color-success, #065f46);
}
.consent-state-card__details {
margin-bottom: 1.5rem;
}
.detail-row {
display: flex;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
.detail-label {
font-weight: 500;
min-width: 120px;
color: var(--color-text-secondary, #666);
font-size: 0.875rem;
}
.detail-value {
font-size: 0.875rem;
}
.detail-value--mono {
font-family: var(--font-family-mono);
font-size: 0.8125rem;
word-break: break-all;
}
.consent-state-card__actions {
margin-top: 1rem;
}
.btn {
padding: 0.5rem 1.25rem;
border: none;
border-radius: var(--radius-md, 8px);
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--primary {
background: var(--color-brand-primary, #4f46e5);
color: #fff;
}
.btn--danger {
background: var(--color-danger, #dc2626);
color: #fff;
}
.consent-management__error {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #991b1b);
border-radius: var(--radius-md, 8px);
font-size: 0.875rem;
}
.consent-management__success {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--color-success-bg, #d1fae5);
color: var(--color-success, #065f46);
border-radius: var(--radius-md, 8px);
font-size: 0.875rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConsentManagementComponent implements OnInit {
private readonly api = inject(FederationTelemetryApi);
readonly state = signal<FederationConsentState | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly successMessage = signal<string | null>(null);
ngOnInit(): void {
this.loadState();
}
grantConsent(): void {
this.loading.set(true);
this.error.set(null);
this.successMessage.set(null);
this.api.grantConsent('current-user').subscribe({
next: () => {
this.successMessage.set('Consent granted successfully');
this.loadState();
this.loading.set(false);
},
error: () => {
this.error.set('Failed to grant consent');
this.loading.set(false);
},
});
}
revokeConsent(): void {
this.loading.set(true);
this.error.set(null);
this.successMessage.set(null);
this.api.revokeConsent('current-user').subscribe({
next: () => {
this.successMessage.set('Consent revoked successfully');
this.loadState();
this.loading.set(false);
},
error: () => {
this.error.set('Failed to revoke consent');
this.loading.set(false);
},
});
}
private loadState(): void {
this.api.getConsentState().subscribe({
next: (s) => this.state.set(s),
error: () => this.error.set('Failed to load consent state'),
});
}
}

View File

@@ -0,0 +1,227 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import { RouterLink } from '@angular/router';
import {
FederationTelemetryApi,
FederationStatus,
FederationPrivacyBudget,
} from './federation-telemetry.api';
@Component({
selector: 'app-federation-overview',
standalone: true,
imports: [RouterLink],
template: `
<div class="federation-overview">
<header class="federation-overview__header">
<h1 class="federation-overview__title">Federated Telemetry</h1>
<p class="federation-overview__subtitle">
Privacy-preserving telemetry federation with differential privacy and k-anonymity.
</p>
</header>
@if (status()) {
<div class="federation-overview__status-bar">
<span class="status-indicator" [class.status-indicator--active]="status()!.enabled">
{{ status()!.enabled ? 'Enabled' : 'Disabled' }}
</span>
@if (status()!.sealedMode) {
<span class="status-indicator status-indicator--sealed">Sealed Mode</span>
}
<span class="status-site">Site: {{ status()!.siteId }}</span>
</div>
}
<div class="federation-overview__cards">
<a class="fed-card" routerLink="consent">
<div class="fed-card__header">Consent</div>
<div class="fed-card__value">
@if (status()) {
{{ status()!.consentGranted ? 'Granted' : 'Not Granted' }}
} @else {
--
}
</div>
<div class="fed-card__label">Federation consent state</div>
</a>
<a class="fed-card" routerLink="privacy">
<div class="fed-card__header">Privacy Budget</div>
<div class="fed-card__value">
@if (budget()) {
{{ budget()!.remaining.toFixed(3) }} / {{ budget()!.total.toFixed(1) }}
} @else {
--
}
</div>
<div class="fed-card__label">Epsilon remaining</div>
@if (budget()?.exhausted) {
<div class="fed-card__badge fed-card__badge--warning">Exhausted</div>
}
</a>
<a class="fed-card" routerLink="bundles">
<div class="fed-card__header">Bundles</div>
<div class="fed-card__value">
{{ status()?.bundleCount ?? '--' }}
</div>
<div class="fed-card__label">Aggregated bundles</div>
</a>
<a class="fed-card" routerLink="intelligence">
<div class="fed-card__header">Intelligence</div>
<div class="fed-card__value">Shared Corpus</div>
<div class="fed-card__label">Federated exploit intelligence</div>
</a>
</div>
@if (error()) {
<div class="federation-overview__error">{{ error() }}</div>
}
</div>
`,
styles: [`
.federation-overview {
padding: 1.5rem;
max-width: 1200px;
}
.federation-overview__header {
margin-bottom: 1.5rem;
}
.federation-overview__title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.federation-overview__subtitle {
color: var(--color-text-secondary, #666);
margin: 0;
}
.federation-overview__status-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--color-surface, #f9fafb);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
margin-bottom: 1.5rem;
font-size: 0.875rem;
}
.status-indicator {
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full, 9999px);
font-weight: 500;
font-size: 0.75rem;
background: var(--color-surface-secondary, #e5e7eb);
color: var(--color-text-secondary, #666);
}
.status-indicator--active {
background: var(--color-success-bg, #d1fae5);
color: var(--color-success, #065f46);
}
.status-indicator--sealed {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #92400e);
}
.status-site {
color: var(--color-text-secondary, #666);
font-family: var(--font-family-mono);
font-size: 0.8125rem;
}
.federation-overview__cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
.fed-card {
display: block;
padding: 1.25rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
position: relative;
}
.fed-card:hover {
border-color: var(--color-brand-primary, #4f46e5);
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.fed-card__header {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary, #666);
margin-bottom: 0.5rem;
}
.fed-card__value {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.fed-card__label {
font-size: 0.8125rem;
color: var(--color-text-secondary, #666);
}
.fed-card__badge {
position: absolute;
top: 0.75rem;
right: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-full, 9999px);
font-size: 0.6875rem;
font-weight: 600;
}
.fed-card__badge--warning {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #92400e);
}
.federation-overview__error {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #991b1b);
border-radius: var(--radius-md, 8px);
font-size: 0.875rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FederationOverviewComponent implements OnInit {
private readonly api = inject(FederationTelemetryApi);
readonly status = signal<FederationStatus | null>(null);
readonly budget = signal<FederationPrivacyBudget | null>(null);
readonly error = signal<string | null>(null);
ngOnInit(): void {
this.api.getStatus().subscribe({
next: (s) => this.status.set(s),
error: (e) => this.error.set('Failed to load federation status'),
});
this.api.getPrivacyBudget().subscribe({
next: (b) => this.budget.set(b),
error: () => {},
});
}
}

View File

@@ -0,0 +1,143 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface FederationConsentState {
granted: boolean;
grantedBy: string | null;
grantedAt: string | null;
expiresAt: string | null;
dsseDigest: string | null;
}
export interface FederationConsentProof {
tenantId: string;
grantedBy: string;
grantedAt: string;
expiresAt: string | null;
dsseDigest: string;
}
export interface FederationStatus {
enabled: boolean;
sealedMode: boolean;
siteId: string;
consentGranted: boolean;
epsilonRemaining: number;
epsilonTotal: number;
budgetExhausted: boolean;
nextBudgetReset: string;
bundleCount: number;
}
export interface FederationBundleSummary {
id: string;
sourceSiteId: string;
bucketCount: number;
suppressedBuckets: number;
epsilonSpent: number;
verified: boolean;
createdAt: string;
}
export interface FederationBundleDetail {
id: string;
sourceSiteId: string;
totalFacts: number;
bucketCount: number;
suppressedBuckets: number;
epsilonSpent: number;
consentDsseDigest: string;
bundleDsseDigest: string;
verified: boolean;
aggregatedAt: string;
createdAt: string;
buckets: FederationBucketDetail[];
}
export interface FederationBucketDetail {
cveId: string;
observationCount: number;
artifactCount: number;
noisyCount: number;
suppressed: boolean;
}
export interface FederationIntelligenceResponse {
entries: FederationIntelligenceEntry[];
totalEntries: number;
uniqueCves: number;
contributingSites: number;
lastUpdated: string;
}
export interface FederationIntelligenceEntry {
cveId: string;
sourceSiteId: string;
observationCount: number;
noisyCount: number;
artifactCount: number;
observedAt: string;
}
export interface FederationPrivacyBudget {
remaining: number;
total: number;
exhausted: boolean;
periodStart: string;
nextReset: string;
queriesThisPeriod: number;
suppressedThisPeriod: number;
}
export interface FederationTriggerResponse {
triggered: boolean;
reason: string | null;
}
@Injectable({ providedIn: 'root' })
export class FederationTelemetryApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/telemetry/federation';
getConsentState(): Observable<FederationConsentState> {
return this.http.get<FederationConsentState>(`${this.baseUrl}/consent`);
}
grantConsent(grantedBy: string, ttlHours?: number): Observable<FederationConsentProof> {
return this.http.post<FederationConsentProof>(`${this.baseUrl}/consent/grant`, {
grantedBy,
ttlHours: ttlHours ?? null,
});
}
revokeConsent(revokedBy: string): Observable<{ revoked: boolean }> {
return this.http.post<{ revoked: boolean }>(`${this.baseUrl}/consent/revoke`, {
revokedBy,
});
}
getStatus(): Observable<FederationStatus> {
return this.http.get<FederationStatus>(`${this.baseUrl}/status`);
}
listBundles(): Observable<FederationBundleSummary[]> {
return this.http.get<FederationBundleSummary[]>(`${this.baseUrl}/bundles`);
}
getBundle(id: string): Observable<FederationBundleDetail> {
return this.http.get<FederationBundleDetail>(`${this.baseUrl}/bundles/${id}`);
}
getIntelligence(): Observable<FederationIntelligenceResponse> {
return this.http.get<FederationIntelligenceResponse>(`${this.baseUrl}/intelligence`);
}
getPrivacyBudget(): Observable<FederationPrivacyBudget> {
return this.http.get<FederationPrivacyBudget>(`${this.baseUrl}/privacy-budget`);
}
triggerAggregation(): Observable<FederationTriggerResponse> {
return this.http.post<FederationTriggerResponse>(`${this.baseUrl}/trigger`, {});
}
}

View File

@@ -0,0 +1,179 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import {
FederationTelemetryApi,
FederationIntelligenceResponse,
} from './federation-telemetry.api';
@Component({
selector: 'app-intelligence-viewer',
standalone: true,
template: `
<div class="intelligence-viewer">
<header class="intelligence-viewer__header">
<h1>Exploit Intelligence</h1>
<p>Shared exploit corpus from federated telemetry peers.</p>
</header>
@if (corpus()) {
<div class="intelligence-viewer__stats">
<div class="stat-card">
<div class="stat-card__value">{{ corpus()!.uniqueCves }}</div>
<div class="stat-card__label">Unique CVEs</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ corpus()!.contributingSites }}</div>
<div class="stat-card__label">Contributing Sites</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ corpus()!.totalEntries }}</div>
<div class="stat-card__label">Total Entries</div>
</div>
</div>
}
<div class="intelligence-viewer__table-wrap">
<table class="intel-table">
<thead>
<tr>
<th>CVE ID</th>
<th>Source Site</th>
<th>Observations</th>
<th>Noisy Count</th>
<th>Artifacts</th>
<th>Observed At</th>
</tr>
</thead>
<tbody>
@if (corpus()) {
@for (entry of corpus()!.entries; track entry.cveId + entry.sourceSiteId) {
<tr>
<td class="cell-mono">{{ entry.cveId }}</td>
<td>{{ entry.sourceSiteId }}</td>
<td>{{ entry.observationCount }}</td>
<td>{{ entry.noisyCount.toFixed(2) }}</td>
<td>{{ entry.artifactCount }}</td>
<td>{{ entry.observedAt }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="intel-table__empty">No intelligence data available</td>
</tr>
}
}
</tbody>
</table>
</div>
@if (error()) {
<div class="intelligence-viewer__error">{{ error() }}</div>
}
</div>
`,
styles: [`
.intelligence-viewer {
padding: 1.5rem;
max-width: 1200px;
}
.intelligence-viewer__header {
margin-bottom: 1.5rem;
}
.intelligence-viewer__header h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.intelligence-viewer__header p {
color: var(--color-text-secondary, #666);
margin: 0;
}
.intelligence-viewer__stats {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
padding: 1rem 1.5rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
text-align: center;
min-width: 120px;
}
.stat-card__value {
font-size: 1.5rem;
font-weight: 700;
}
.stat-card__label {
font-size: 0.75rem;
color: var(--color-text-secondary, #666);
margin-top: 0.25rem;
}
.intelligence-viewer__table-wrap {
overflow-x: auto;
}
.intel-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.intel-table th {
text-align: left;
padding: 0.75rem 1rem;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary, #666);
border-bottom: 2px solid var(--color-border, #e5e7eb);
}
.intel-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
.cell-mono {
font-family: var(--font-family-mono);
font-size: 0.8125rem;
}
.intel-table__empty {
text-align: center;
color: var(--color-text-secondary, #666);
padding: 2rem 1rem;
}
.intelligence-viewer__error {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #991b1b);
border-radius: var(--radius-md, 8px);
font-size: 0.875rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class IntelligenceViewerComponent implements OnInit {
private readonly api = inject(FederationTelemetryApi);
readonly corpus = signal<FederationIntelligenceResponse | null>(null);
readonly error = signal<string | null>(null);
ngOnInit(): void {
this.api.getIntelligence().subscribe({
next: (c) => this.corpus.set(c),
error: () => this.error.set('Failed to load intelligence data'),
});
}
}

View File

@@ -0,0 +1,242 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core';
import {
FederationTelemetryApi,
FederationPrivacyBudget,
} from './federation-telemetry.api';
@Component({
selector: 'app-privacy-budget-monitor',
standalone: true,
template: `
<div class="privacy-monitor">
<header class="privacy-monitor__header">
<h1>Privacy Budget</h1>
<p>Differential privacy epsilon budget tracking and suppression statistics.</p>
</header>
@if (budget()) {
<div class="privacy-monitor__gauge-section">
<div class="gauge-card">
<div class="gauge-card__label">Epsilon Budget</div>
<div class="gauge-bar">
<div
class="gauge-bar__fill"
[class.gauge-bar__fill--warning]="budgetPercentage() < 30"
[class.gauge-bar__fill--danger]="budget()!.exhausted"
[style.width.%]="budgetPercentage()"
></div>
</div>
<div class="gauge-card__values">
<span>{{ budget()!.remaining.toFixed(4) }} remaining</span>
<span>{{ budget()!.total.toFixed(1) }} total</span>
</div>
@if (budget()!.exhausted) {
<div class="gauge-card__alert">Budget Exhausted</div>
}
</div>
</div>
<div class="privacy-monitor__stats">
<div class="stat-card">
<div class="stat-card__value">{{ budget()!.queriesThisPeriod }}</div>
<div class="stat-card__label">Queries This Period</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ budget()!.suppressedThisPeriod }}</div>
<div class="stat-card__label">Suppressed This Period</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ budgetPercentage().toFixed(1) }}%</div>
<div class="stat-card__label">Budget Remaining</div>
</div>
</div>
<div class="privacy-monitor__details">
<div class="detail-card">
<h3>Period Information</h3>
<div class="detail-row">
<span class="detail-label">Period Start:</span>
<span class="detail-value">{{ budget()!.periodStart }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Next Reset:</span>
<span class="detail-value">{{ budget()!.nextReset }}</span>
</div>
</div>
</div>
}
@if (error()) {
<div class="privacy-monitor__error">{{ error() }}</div>
}
</div>
`,
styles: [`
.privacy-monitor {
padding: 1.5rem;
max-width: 1000px;
}
.privacy-monitor__header {
margin-bottom: 1.5rem;
}
.privacy-monitor__header h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.privacy-monitor__header p {
color: var(--color-text-secondary, #666);
margin: 0;
}
.privacy-monitor__gauge-section {
margin-bottom: 1.5rem;
}
.gauge-card {
padding: 1.5rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
}
.gauge-card__label {
font-weight: 600;
margin-bottom: 0.75rem;
}
.gauge-bar {
height: 24px;
background: var(--color-surface-secondary, #f3f4f6);
border-radius: var(--radius-md, 8px);
overflow: hidden;
}
.gauge-bar__fill {
height: 100%;
background: var(--color-success, #059669);
border-radius: var(--radius-md, 8px);
transition: width 0.3s ease;
min-width: 2px;
}
.gauge-bar__fill--warning {
background: var(--color-warning, #d97706);
}
.gauge-bar__fill--danger {
background: var(--color-danger, #dc2626);
}
.gauge-card__values {
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
font-size: 0.8125rem;
color: var(--color-text-secondary, #666);
}
.gauge-card__alert {
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #991b1b);
border-radius: var(--radius-md, 8px);
font-weight: 600;
font-size: 0.875rem;
text-align: center;
}
.privacy-monitor__stats {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
flex: 1;
padding: 1rem 1.5rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
text-align: center;
}
.stat-card__value {
font-size: 1.5rem;
font-weight: 700;
}
.stat-card__label {
font-size: 0.75rem;
color: var(--color-text-secondary, #666);
margin-top: 0.25rem;
}
.privacy-monitor__details {
margin-bottom: 1.5rem;
}
.detail-card {
padding: 1.25rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
}
.detail-card h3 {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.75rem;
}
.detail-row {
display: flex;
gap: 0.75rem;
padding: 0.375rem 0;
}
.detail-label {
font-weight: 500;
min-width: 120px;
color: var(--color-text-secondary, #666);
font-size: 0.875rem;
}
.detail-value {
font-size: 0.875rem;
}
.privacy-monitor__error {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #991b1b);
border-radius: var(--radius-md, 8px);
font-size: 0.875rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PrivacyBudgetMonitorComponent implements OnInit {
private readonly api = inject(FederationTelemetryApi);
readonly budget = signal<FederationPrivacyBudget | null>(null);
readonly error = signal<string | null>(null);
readonly budgetPercentage = computed(() => {
const b = this.budget();
if (!b || b.total === 0) return 0;
return (b.remaining / b.total) * 100;
});
ngOnInit(): void {
this.api.getPrivacyBudget().subscribe({
next: (b) => this.budget.set(b),
error: () => this.error.set('Failed to load privacy budget'),
});
}
}

View File

@@ -0,0 +1,179 @@
/**
* Platform Ops Overview (P0)
* Sprint: SPRINT_20260218_008_FE_ui_v2_rewire_integrations_platform_ops_data_integrity (I3-02)
*
* Root overview for the Platform Ops domain.
* Provides summary cards for all operational capability areas.
* Security Data: connectivity/freshness is owned here (gating impact consumed by Security & Risk).
*/
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterLink } from '@angular/router';
interface OpsCard {
id: string;
title: string;
description: string;
route: string;
icon: string;
}
@Component({
selector: 'app-platform-ops-overview',
standalone: true,
imports: [RouterLink],
template: `
<div class="ops-overview">
<header class="ops-overview__header">
<h1 class="ops-overview__title">Platform Ops</h1>
<p class="ops-overview__subtitle">
Operational controls for orchestration, data feeds, health, and compliance.
</p>
</header>
<div class="ops-overview__grid">
@for (card of cards; track card.id) {
<a class="ops-card" [routerLink]="card.route">
<div class="ops-card__icon" aria-hidden="true">{{ card.icon }}</div>
<div class="ops-card__body">
<h2 class="ops-card__title">{{ card.title }}</h2>
<p class="ops-card__description">{{ card.description }}</p>
</div>
</a>
}
</div>
</div>
`,
styles: [`
.ops-overview {
padding: 1.5rem;
max-width: 1200px;
}
.ops-overview__header {
margin-bottom: 2rem;
}
.ops-overview__title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.ops-overview__subtitle {
color: var(--color-text-secondary, #666);
margin: 0;
}
.ops-overview__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.ops-card {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.25rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.ops-card:hover {
border-color: var(--color-brand-primary, #4f46e5);
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.ops-card__icon {
font-size: 1.5rem;
flex-shrink: 0;
width: 2.5rem;
text-align: center;
}
.ops-card__title {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.ops-card__description {
font-size: 0.8125rem;
color: var(--color-text-secondary, #666);
margin: 0;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlatformOpsOverviewComponent {
readonly cards: OpsCard[] = [
{
id: 'orchestrator',
title: 'Orchestrator',
description: 'Job execution, queue management, and operational controls.',
route: '/platform-ops/orchestrator',
icon: '⚡',
},
{
id: 'data-integrity',
title: 'Data Integrity',
description: 'Feeds freshness, scan pipeline health, DLQ, replays, and SLOs.',
route: '/platform-ops/data-integrity',
icon: '🔍',
},
{
id: 'feeds',
title: 'Feeds & Mirrors',
description: 'Advisory feed sources, mirror state, and freshness tracking.',
route: '/platform-ops/feeds',
icon: '📡',
},
{
id: 'offline-kit',
title: 'Offline Kit',
description: 'AirGap bundle management and offline deployment packages.',
route: '/platform-ops/offline-kit',
icon: '📦',
},
{
id: 'health',
title: 'Platform Health',
description: 'Service health dashboard and live readiness signals.',
route: '/platform-ops/health',
icon: '🏥',
},
{
id: 'doctor',
title: 'Diagnostics',
description: 'Registry connectivity, configuration, and self-test diagnostics.',
route: '/platform-ops/doctor',
icon: '🩺',
},
{
id: 'quotas',
title: 'Quotas & Limits',
description: 'Resource quotas, burst limits, and capacity planning views.',
route: '/platform-ops/quotas',
icon: '📏',
},
{
id: 'aoc',
title: 'AOC Compliance',
description: 'Continuous compliance verification and control attestation.',
route: '/platform-ops/aoc',
icon: '✅',
},
{
id: 'agents',
title: 'Agent Fleet',
description: 'Scanner agent registration, status, and workload assignment.',
route: '/platform-ops/agents',
icon: '🤖',
},
];
}

View File

@@ -0,0 +1,688 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { catchError, of } from 'rxjs';
import { APPROVAL_API } from '../../core/api/approval.client';
import type {
ApprovalUrgency,
PromotionPreview,
TargetEnvironment,
} from '../../core/api/approval.models';
type Step = 1 | 2 | 3 | 4 | 5 | 6;
@Component({
selector: 'app-create-promotion',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="create-promotion">
<nav class="create-promotion__back">
<a routerLink=".." class="back-link"><- Back to Promotions</a>
</nav>
<header class="create-promotion__header">
<h1 class="create-promotion__title">Create Promotion</h1>
<p class="create-promotion__subtitle">
Pack-13 flow: bundle version identity, target path, input materialization, gate preview, approval context, launch.
</p>
</header>
<div class="create-promotion__steps" role="list">
@for (step of steps; track step.number) {
<div
class="step-item"
[class.step-item--active]="activeStep() === step.number"
[class.step-item--done]="activeStep() > step.number"
role="listitem"
>
<span class="step-item__num" aria-hidden="true">{{ step.number }}</span>
<span class="step-item__label">{{ step.label }}</span>
</div>
}
</div>
@if (error()) {
<div class="state-block state-block--error">{{ error() }}</div>
}
<div class="create-promotion__content">
@switch (activeStep()) {
@case (1) {
<section aria-label="Select bundle version identity">
<h2>Select Bundle Version Identity</h2>
<div class="form-field">
<label for="release-id">Release/Bundle identity</label>
<input
id="release-id"
type="text"
placeholder="rel-001"
[ngModel]="releaseId()"
(ngModelChange)="releaseId.set($event)"
/>
<p class="form-hint">
Current contract uses release id. Bundle manifest digest appears after detail retrieval.
</p>
</div>
<button type="button" class="btn-secondary" (click)="loadEnvironments()" [disabled]="loadingEnvironments() || !releaseId().trim()">
{{ loadingEnvironments() ? 'Loading environments...' : 'Load Target Environments' }}
</button>
</section>
}
@case (2) {
<section aria-label="Select target environment">
<h2>Select Region and Environment Path</h2>
<div class="form-field">
<label for="target-env">Target environment</label>
<select
id="target-env"
[ngModel]="targetEnvironmentId()"
(ngModelChange)="onTargetEnvironmentChange($event)"
>
<option value="">- Select environment -</option>
@for (env of environments(); track env.id) {
<option [value]="env.id">{{ env.name }} ({{ env.tier }})</option>
}
</select>
</div>
<p class="form-hint">Source environment is inferred by backend promotion policy.</p>
</section>
}
@case (3) {
<section aria-label="Inputs materialization preflight">
<h2>Inputs Materialization Preflight</h2>
<p class="form-hint">
Vault/Consul binding details are not fully exposed by this contract. This step surfaces deterministic preflight state.
</p>
<div class="materialization-state" [class]="'materialization-state--' + materializationState().level">
<strong>{{ materializationState().title }}</strong>
<p>{{ materializationState().message }}</p>
</div>
<a routerLink="/release-control/setup/environments-paths" class="link-sm">
Open Release Control setup for inputs and paths ->
</a>
</section>
}
@case (4) {
<section aria-label="Gate preview">
<h2>Gate Preview</h2>
<button type="button" class="btn-secondary" (click)="loadPreview()" [disabled]="loadingPreview() || !targetEnvironmentId() || !releaseId().trim()">
{{ loadingPreview() ? 'Loading gate preview...' : 'Refresh Gate Preview' }}
</button>
@if (preview(); as currentPreview) {
<div class="preview-summary" [class.preview-summary--warn]="!currentPreview.allGatesPassed">
<span>
{{ currentPreview.allGatesPassed ? 'All gates passed' : 'One or more gates are not passing' }}
</span>
<span>Required approvers: {{ currentPreview.requiredApprovers }}</span>
</div>
<ul class="gate-list">
@for (gate of currentPreview.gateResults; track gate.gateId) {
<li>
<span class="signal signal--{{ gate.status }}">{{ gate.status }}</span>
<span>{{ gate.gateName }}</span>
<span>{{ gate.message }}</span>
</li>
}
</ul>
} @else {
<p class="state-inline">No preview loaded yet.</p>
}
</section>
}
@case (5) {
<section aria-label="Approval context">
<h2>Approval Context</h2>
<div class="form-field">
<label for="urgency">Urgency</label>
<select id="urgency" [ngModel]="urgency()" (ngModelChange)="urgency.set($event)">
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="form-field">
<label for="justification">Justification</label>
<textarea
id="justification"
rows="4"
[ngModel]="justification()"
(ngModelChange)="justification.set($event)"
placeholder="Explain why this promotion is needed"
></textarea>
</div>
<div class="form-field">
<label for="schedule">Schedule (optional)</label>
<input
id="schedule"
type="datetime-local"
[ngModel]="scheduledTime()"
(ngModelChange)="scheduledTime.set($event)"
/>
</div>
<label class="checkbox-row">
<input
type="checkbox"
[ngModel]="notifyApprovers()"
(ngModelChange)="notifyApprovers.set($event)"
/>
Notify approvers
</label>
</section>
}
@case (6) {
<section aria-label="Launch review">
<h2>Launch Promotion</h2>
<div class="review-block">
<div class="review-row"><span>Release identity</span><span>{{ releaseId() || '-' }}</span></div>
<div class="review-row"><span>Target environment</span><span>{{ selectedEnvironmentLabel() }}</span></div>
<div class="review-row"><span>Gate preview</span><span>{{ preview() ? (preview()!.allGatesPassed ? 'PASS' : 'WARN/BLOCK') : 'not loaded' }}</span></div>
<div class="review-row"><span>Materialization</span><span>{{ materializationState().title }}</span></div>
<div class="review-row"><span>Urgency</span><span>{{ urgency() }}</span></div>
</div>
<button type="button" class="btn-primary" (click)="submit()" [disabled]="!canSubmit() || submitting()">
{{ submitting() ? 'Submitting...' : 'Submit Promotion Request' }}
</button>
</section>
}
}
</div>
<div class="create-promotion__nav">
@if (activeStep() > 1) {
<button class="btn-secondary" (click)="prevStep()"><- Back</button>
}
@if (activeStep() < 6) {
<button class="btn-primary" (click)="nextStep()" [disabled]="!canAdvance(activeStep())">Next -></button>
}
</div>
</div>
`,
styles: [
`
.create-promotion {
padding: 1.5rem;
max-width: 760px;
}
.create-promotion__back {
margin-bottom: 1rem;
}
.back-link {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
text-decoration: none;
}
.create-promotion__title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.create-promotion__subtitle {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
margin: 0 0 1.4rem;
}
.create-promotion__steps {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 1.4rem;
}
.step-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.4rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
font-size: 0.73rem;
color: var(--color-text-secondary, #666);
}
.step-item--active {
border-color: var(--color-brand-primary, #4f46e5);
color: var(--color-brand-primary, #4f46e5);
font-weight: 600;
}
.step-item--done {
border-color: #34d399;
}
.step-item__num {
width: 1.2rem;
height: 1.2rem;
border-radius: 9999px;
border: 1px solid currentColor;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
}
.create-promotion__content {
min-height: 280px;
margin-bottom: 1.2rem;
}
.create-promotion__content section {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
padding: 0.9rem 1rem;
display: grid;
gap: 0.8rem;
}
.create-promotion__content h2 {
margin: 0;
font-size: 1.05rem;
}
.form-field {
display: grid;
gap: 0.25rem;
}
.form-field label {
font-size: 0.82rem;
font-weight: 600;
}
.form-field input,
.form-field select,
.form-field textarea {
padding: 0.45rem 0.65rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
}
.form-hint {
margin: 0;
font-size: 0.78rem;
color: var(--color-text-secondary, #666);
}
.state-block {
margin-bottom: 0.75rem;
padding: 0.7rem 0.8rem;
border-radius: 8px;
border: 1px solid #fecaca;
background: #fff5f5;
color: #991b1b;
font-size: 0.82rem;
}
.materialization-state {
border-radius: 8px;
padding: 0.65rem 0.75rem;
border: 1px solid #e5e7eb;
background: #f9fafb;
}
.materialization-state p {
margin: 0.2rem 0 0;
font-size: 0.82rem;
}
.materialization-state--blocked {
border-color: #fecaca;
background: #fff5f5;
}
.materialization-state--warning {
border-color: #fde68a;
background: #fffbeb;
}
.materialization-state--pass {
border-color: #bbf7d0;
background: #f0fdf4;
}
.preview-summary {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
border: 1px solid #bbf7d0;
background: #f0fdf4;
border-radius: 8px;
padding: 0.55rem 0.7rem;
font-size: 0.82rem;
}
.preview-summary--warn {
border-color: #fde68a;
background: #fffbeb;
}
.gate-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.gate-list li {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 0.45rem 0.55rem;
display: grid;
grid-template-columns: auto 1fr 2fr;
gap: 0.5rem;
align-items: center;
font-size: 0.8rem;
}
.signal {
padding: 0.1rem 0.45rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.signal--passed {
background: #dcfce7;
color: #166534;
}
.signal--warning {
background: #fef3c7;
color: #92400e;
}
.signal--failed {
background: #fee2e2;
color: #991b1b;
}
.signal--pending,
.signal--skipped {
background: #f3f4f6;
color: #4b5563;
}
.review-block {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.review-row {
display: flex;
justify-content: space-between;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
font-size: 0.82rem;
}
.review-row:last-child {
border-bottom: none;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.84rem;
}
.state-inline {
margin: 0;
color: var(--color-text-secondary, #666);
font-size: 0.82rem;
}
.create-promotion__nav {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn-primary,
.btn-secondary {
padding: 0.45rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
border: none;
cursor: pointer;
}
.btn-primary {
background: var(--color-brand-primary, #4f46e5);
color: #fff;
}
.btn-secondary {
background: #fff;
color: #111;
border: 1px solid var(--color-border, #e5e7eb);
}
.btn-primary:disabled,
.btn-secondary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.link-sm {
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
font-size: 0.82rem;
}
@media (max-width: 980px) {
.create-promotion__steps {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
`,
],
})
export class CreatePromotionComponent {
private readonly api = inject(APPROVAL_API);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
readonly activeStep = signal<Step>(1);
readonly releaseId = signal('');
readonly targetEnvironmentId = signal('');
readonly urgency = signal<ApprovalUrgency>('normal');
readonly justification = signal('');
readonly scheduledTime = signal('');
readonly notifyApprovers = signal(true);
readonly environments = signal<TargetEnvironment[]>([]);
readonly preview = signal<PromotionPreview | null>(null);
readonly loadingEnvironments = signal(false);
readonly loadingPreview = signal(false);
readonly submitting = signal(false);
readonly error = signal<string | null>(null);
readonly steps: ReadonlyArray<{ number: Step; label: string }> = [
{ number: 1, label: 'Identity' },
{ number: 2, label: 'Target' },
{ number: 3, label: 'Inputs' },
{ number: 4, label: 'Gates' },
{ number: 5, label: 'Approvals' },
{ number: 6, label: 'Launch' },
];
readonly selectedEnvironmentLabel = computed(() => {
const env = this.environments().find((item) => item.id === this.targetEnvironmentId());
return env ? `${env.name} (${env.tier})` : '-';
});
readonly materializationState = computed(() => {
if (!this.releaseId().trim() || !this.targetEnvironmentId()) {
return {
level: 'blocked',
title: 'Blocked',
message: 'Select release identity and target environment first.',
} as const;
}
if (!this.preview()) {
return {
level: 'warning',
title: 'Preflight pending',
message: 'Gate preview is not loaded yet. Binding evidence remains unconfirmed.',
} as const;
}
if (!this.preview()!.allGatesPassed) {
return {
level: 'warning',
title: 'Attention needed',
message: 'One or more gates are not passing. Validate inputs before launch.',
} as const;
}
return {
level: 'pass',
title: 'Ready',
message: 'No contract-reported input materialization blockers.',
} as const;
});
nextStep(): void {
const current = this.activeStep();
if (current < 6 && this.canAdvance(current)) {
this.activeStep.set((current + 1) as Step);
}
}
prevStep(): void {
const current = this.activeStep();
if (current > 1) {
this.activeStep.set((current - 1) as Step);
}
}
canAdvance(step: Step): boolean {
switch (step) {
case 1:
return this.releaseId().trim().length > 0;
case 2:
return this.targetEnvironmentId().length > 0;
case 3:
return this.releaseId().trim().length > 0 && this.targetEnvironmentId().length > 0;
case 4:
return this.preview() !== null;
case 5:
return this.justification().trim().length >= 10;
default:
return true;
}
}
canSubmit(): boolean {
return (
this.releaseId().trim().length > 0 &&
this.targetEnvironmentId().length > 0 &&
this.justification().trim().length >= 10
);
}
loadEnvironments(): void {
if (!this.releaseId().trim()) {
return;
}
this.loadingEnvironments.set(true);
this.error.set(null);
this.api
.getAvailableEnvironments(this.releaseId().trim())
.pipe(
catchError(() => {
this.error.set('Failed to load environments.');
return of([] as TargetEnvironment[]);
})
)
.subscribe((items) => {
this.environments.set(items);
this.loadingEnvironments.set(false);
});
}
onTargetEnvironmentChange(value: string): void {
this.targetEnvironmentId.set(value);
this.preview.set(null);
if (value) {
this.loadPreview();
}
}
loadPreview(): void {
if (!this.releaseId().trim() || !this.targetEnvironmentId()) {
return;
}
this.loadingPreview.set(true);
this.error.set(null);
this.api
.getPromotionPreview(this.releaseId().trim(), this.targetEnvironmentId())
.pipe(
catchError(() => {
this.error.set('Failed to load gate preview.');
return of(null);
})
)
.subscribe((preview) => {
this.preview.set(preview);
this.loadingPreview.set(false);
});
}
submit(): void {
if (!this.canSubmit()) {
return;
}
this.submitting.set(true);
this.error.set(null);
this.api
.submitPromotionRequest(this.releaseId().trim(), {
targetEnvironmentId: this.targetEnvironmentId(),
urgency: this.urgency(),
justification: this.justification().trim(),
notifyApprovers: this.notifyApprovers(),
scheduledTime: this.scheduledTime() || null,
})
.pipe(
catchError(() => {
this.error.set('Failed to submit promotion request.');
return of(null);
})
)
.subscribe((created) => {
this.submitting.set(false);
if (created) {
this.router.navigate(['../', created.id], { relativeTo: this.route });
}
});
}
}

View File

@@ -0,0 +1,684 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, of } from 'rxjs';
import { APPROVAL_API } from '../../core/api/approval.client';
import type { ApprovalDetail, GateStatus } from '../../core/api/approval.models';
type DetailTab =
| 'overview'
| 'gates'
| 'security'
| 'reachability'
| 'ops-data'
| 'evidence'
| 'replay'
| 'history';
@Component({
selector: 'app-promotion-detail',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="promotion-detail">
@if (loading()) {
<div class="state-block">Loading promotion detail...</div>
} @else if (error()) {
<div class="state-block state-block--error">
{{ error() }}
<button type="button" (click)="loadPromotion()">Retry</button>
</div>
} @else if (promotion()) {
<nav class="promotion-detail__back">
<a routerLink=".." class="back-link"><- Back to Promotions</a>
</nav>
<header class="promotion-detail__header">
<div>
<h1 class="promotion-detail__title">
{{ promotion()!.releaseName }}
<span class="mono">{{ promotion()!.releaseVersion }}</span>
</h1>
<p class="promotion-detail__meta">
{{ promotion()!.sourceEnvironment }} -> {{ promotion()!.targetEnvironment }}
| requested by {{ promotion()!.requestedBy }}
</p>
</div>
<span class="status-badge status-badge--{{ promotion()!.status }}">
{{ promotion()!.status }}
</span>
</header>
<section class="promotion-detail__identity" aria-label="Bundle version identity">
<article>
<h2>Bundle version identity</h2>
<p>{{ promotion()!.releaseName }} {{ promotion()!.releaseVersion }}</p>
</article>
<article>
<h2>Manifest digest</h2>
@if (manifestDigest()) {
<code class="mono">{{ manifestDigest() }}</code>
} @else {
<span class="contract-gap">Digest missing in current contract payload</span>
}
</article>
<article>
<h2>Approval progress</h2>
<p>{{ promotion()!.currentApprovals }}/{{ promotion()!.requiredApprovals }}</p>
</article>
</section>
<div class="promotion-detail__tabs" role="tablist" aria-label="Promotion detail tabs">
@for (tab of tabs; track tab.id) {
<button
role="tab"
type="button"
[class.tab--active]="activeTab() === tab.id"
(click)="setTab(tab.id)"
>
{{ tab.label }}
</button>
}
</div>
@switch (activeTab()) {
@case ('overview') {
<section class="panel" aria-label="Promotion overview">
<h2>Decision Overview</h2>
<p>{{ promotion()!.justification }}</p>
<div class="overview-grid">
<div><strong>Gate state:</strong> {{ promotion()!.gatesPassed ? 'PASS' : 'BLOCK' }}</div>
<div><strong>Failures:</strong> {{ gateStatusCounts().failed }}</div>
<div><strong>Warnings:</strong> {{ gateStatusCounts().warning }}</div>
<div><strong>Requested:</strong> {{ formatDate(promotion()!.requestedAt) }}</div>
</div>
@if (promotion()!.status === 'pending') {
<div class="decision-box">
<label for="decisionComment">Decision comment</label>
<textarea
id="decisionComment"
rows="3"
[ngModel]="decisionComment()"
(ngModelChange)="decisionComment.set($event)"
placeholder="Explain approval or rejection"
></textarea>
<div class="decision-actions">
<button type="button" class="btn btn--success" (click)="approve()" [disabled]="submitting()">
Approve
</button>
<button type="button" class="btn btn--danger" (click)="reject()" [disabled]="submitting() || !decisionComment().trim()">
Reject
</button>
</div>
</div>
}
</section>
}
@case ('gates') {
<section class="panel" aria-label="Gate results">
<h2>Gate Results and Trace</h2>
@if (promotion()!.gateResults.length === 0) {
<p class="state-inline">No gate trace returned by the current contract.</p>
} @else {
<table class="gate-table">
<thead>
<tr>
<th>Gate</th>
<th>Status</th>
<th>Message</th>
<th>Evaluated</th>
</tr>
</thead>
<tbody>
@for (gate of promotion()!.gateResults; track gate.gateId) {
<tr>
<td>{{ gate.gateName }}</td>
<td><span class="signal signal--{{ gate.status }}">{{ gate.status }}</span></td>
<td>{{ gate.message }}</td>
<td>{{ formatDate(gate.evaluatedAt) }}</td>
</tr>
}
</tbody>
</table>
}
</section>
}
@case ('security') {
<section class="panel" aria-label="Security snapshot">
<h2>Security Snapshot</h2>
<p>
Critical reachable, high reachable, and finding deltas are partially available in the current promotion contract.
</p>
<div class="overview-grid">
<div><strong>Failed gates:</strong> {{ gateStatusCounts().failed }}</div>
<div><strong>Warning gates:</strong> {{ gateStatusCounts().warning }}</div>
<div><strong>Passing gates:</strong> {{ gateStatusCounts().passed }}</div>
<div><strong>Target env:</strong> {{ promotion()!.targetEnvironment }}</div>
</div>
<a routerLink="/security-risk/findings" [queryParams]="{ env: promotion()!.targetEnvironment }" class="link-sm">
Open findings for target environment ->
</a>
</section>
}
@case ('reachability') {
<section class="panel" aria-label="Reachability snapshot">
<h2>Reachability Snapshot</h2>
<p>
Image/build/runtime coverage percentages are not currently included in approval detail payloads.
</p>
<div class="contract-gap-row">
<span class="contract-gap">Contract gap: hybrid B/I/R coverage fields are missing.</span>
</div>
<a routerLink="/security-risk/reachability" class="link-sm">Open reachability center -></a>
</section>
}
@case ('ops-data') {
<section class="panel" aria-label="Ops and data health snapshot">
<h2>Ops/Data Health</h2>
<p>
Data confidence is derived from gate trace where available. Detailed feed freshness and integration connectivity remain in Platform Ops.
</p>
<div class="overview-grid">
<div><strong>Derived ops signal:</strong> {{ opsSignal().text }}</div>
<div><strong>Ops gate status:</strong> {{ opsSignal().status }}</div>
</div>
<a routerLink="/platform-ops/data-integrity" class="link-sm">Open Platform Ops data integrity -></a>
</section>
}
@case ('evidence') {
<section class="panel" aria-label="Evidence snapshot">
<h2>Evidence Used for Decision</h2>
<p>
Evidence packet identifiers are not provided in this contract; use canonical Evidence and Audit surfaces for promotion-linked retrieval.
</p>
<a routerLink="/evidence-audit" class="link-sm">Open Evidence and Audit -></a>
</section>
}
@case ('replay') {
<section class="panel" aria-label="Replay and verify">
<h2>Replay / Verify Decision</h2>
<p>Replay and verification are delegated to Evidence and Audit.</p>
<a routerLink="/evidence-audit/replay" class="link-sm">Open replay and verify -></a>
</section>
}
@case ('history') {
<section class="panel" aria-label="Decision history">
<h2>Decision History</h2>
@if (promotion()!.actions.length === 0) {
<p class="state-inline">No decision actions recorded yet.</p>
} @else {
<ul class="history-list">
@for (action of promotion()!.actions; track action.id) {
<li>
<strong>{{ action.actor }}</strong>
<span>{{ action.action }}</span>
<span>{{ formatDate(action.timestamp) }}</span>
<p>{{ action.comment }}</p>
</li>
}
</ul>
}
</section>
}
}
} @else {
<div class="state-block state-block--error">Promotion detail not found.</div>
}
</div>
`,
styles: [
`
.promotion-detail {
padding: 1.5rem;
max-width: 980px;
}
.promotion-detail__back {
margin-bottom: 1rem;
}
.back-link {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
text-decoration: none;
}
.promotion-detail__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.promotion-detail__title {
font-size: 1.375rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.promotion-detail__meta {
margin: 0;
color: var(--color-text-secondary, #666);
font-size: 0.875rem;
}
.promotion-detail__identity {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.promotion-detail__identity article {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
background: var(--color-surface-alt, #f9fafb);
padding: 0.75rem;
}
.promotion-detail__identity h2 {
margin: 0 0 0.2rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #666);
}
.promotion-detail__identity p,
.promotion-detail__identity code {
margin: 0;
font-size: 0.84rem;
}
.promotion-detail__tabs {
display: flex;
flex-wrap: wrap;
border-bottom: 2px solid var(--color-border, #e5e7eb);
margin-bottom: 1rem;
}
.promotion-detail__tabs button {
padding: 0.45rem 0.85rem;
border: none;
background: transparent;
font-size: 0.82rem;
cursor: pointer;
color: var(--color-text-secondary, #666);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tab--active {
color: var(--color-brand-primary, #4f46e5) !important;
border-bottom-color: var(--color-brand-primary, #4f46e5) !important;
font-weight: 600;
}
.panel {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
padding: 0.9rem 1rem;
display: grid;
gap: 0.7rem;
}
.panel h2 {
margin: 0;
font-size: 1rem;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.4rem 0.75rem;
font-size: 0.85rem;
}
.decision-box {
display: grid;
gap: 0.5rem;
}
.decision-box textarea {
width: 100%;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
padding: 0.5rem 0.65rem;
font-size: 0.875rem;
}
.decision-actions {
display: flex;
gap: 0.6rem;
}
.btn {
padding: 0.45rem 0.85rem;
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.82rem;
}
.btn--success {
background: #166534;
}
.btn--danger {
background: #991b1b;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.gate-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.gate-table th,
.gate-table td {
text-align: left;
padding: 0.45rem 0.5rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.signal {
padding: 0.1rem 0.45rem;
border-radius: 9999px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
}
.signal--passed {
background: #dcfce7;
color: #166534;
}
.signal--warning {
background: #fef3c7;
color: #92400e;
}
.signal--failed {
background: #fee2e2;
color: #991b1b;
}
.signal--pending,
.signal--skipped {
background: #f3f4f6;
color: #4b5563;
}
.state-block {
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface-alt, #f9fafb);
font-size: 0.875rem;
}
.state-block--error {
background: #fff5f5;
border-color: #fecaca;
color: #991b1b;
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
.state-block--error button {
border: 1px solid #fecaca;
border-radius: 6px;
background: #fff;
color: #991b1b;
padding: 0.2rem 0.7rem;
}
.state-inline {
color: var(--color-text-secondary, #666);
margin: 0;
}
.contract-gap-row {
margin: 0.2rem 0;
}
.contract-gap {
font-size: 0.75rem;
color: #92400e;
background: #fef3c7;
border: 1px solid #fde68a;
border-radius: 9999px;
padding: 0.1rem 0.5rem;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.65rem;
}
.history-list li {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 0.6rem 0.7rem;
font-size: 0.82rem;
display: grid;
gap: 0.25rem;
}
.history-list p {
margin: 0;
color: var(--color-text-secondary, #666);
}
.status-badge {
padding: 0.25rem 0.7rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: capitalize;
}
.status-badge--pending {
background: #fef3c7;
color: #92400e;
}
.status-badge--approved {
background: #dbeafe;
color: #1e40af;
}
.status-badge--rejected,
.status-badge--expired {
background: #fee2e2;
color: #991b1b;
}
.mono {
font-family: var(--font-mono, monospace);
font-size: 0.84rem;
}
.link-sm {
display: inline-block;
font-size: 0.82rem;
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
}
`,
],
})
export class PromotionDetailComponent implements OnInit {
private readonly api = inject(APPROVAL_API);
private readonly route = inject(ActivatedRoute);
readonly promotionId = signal('');
readonly loading = signal(true);
readonly submitting = signal(false);
readonly error = signal<string | null>(null);
readonly promotion = signal<ApprovalDetail | null>(null);
readonly activeTab = signal<DetailTab>('overview');
readonly decisionComment = signal('');
readonly tabs: ReadonlyArray<{ id: DetailTab; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'gates', label: 'Gates' },
{ id: 'security', label: 'Security' },
{ id: 'reachability', label: 'Reachability' },
{ id: 'ops-data', label: 'Ops/Data' },
{ id: 'evidence', label: 'Evidence' },
{ id: 'replay', label: 'Replay/Verify' },
{ id: 'history', label: 'History' },
];
readonly manifestDigest = computed(() => {
return this.promotion()?.releaseComponents[0]?.digest ?? null;
});
readonly gateStatusCounts = computed(() => {
const gates = this.promotion()?.gateResults ?? [];
return {
passed: gates.filter((gate) => gate.status === 'passed').length,
warning: gates.filter((gate) => gate.status === 'warning').length,
failed: gates.filter((gate) => gate.status === 'failed').length,
pending: gates.filter((gate) => gate.status === 'pending').length,
skipped: gates.filter((gate) => gate.status === 'skipped').length,
};
});
readonly opsSignal = computed(() => {
const gates = this.promotion()?.gateResults ?? [];
const opsGate = gates.find((gate) => {
const name = gate.gateName.toLowerCase();
return name.includes('scan') || name.includes('feed') || name.includes('integr');
});
if (!opsGate) {
return { status: 'unknown', text: 'No ops gate in contract' };
}
if (opsGate.status === 'failed' || opsGate.status === 'warning') {
return { status: opsGate.status, text: 'Degraded' };
}
if (opsGate.status === 'passed' || opsGate.status === 'skipped') {
return { status: opsGate.status, text: 'Healthy' };
}
return { status: opsGate.status, text: 'Pending' };
});
ngOnInit(): void {
this.promotionId.set(this.route.snapshot.params['promotionId'] ?? '');
this.loadPromotion();
}
setTab(tab: DetailTab): void {
this.activeTab.set(tab);
}
loadPromotion(): void {
const id = this.promotionId();
if (!id) {
this.loading.set(false);
this.error.set('Missing promotion id in route.');
return;
}
this.loading.set(true);
this.error.set(null);
this.api
.getApproval(id)
.pipe(
catchError(() => {
this.error.set('Failed to load promotion detail.');
return of(null);
})
)
.subscribe((detail) => {
this.promotion.set(detail);
this.loading.set(false);
});
}
approve(): void {
const current = this.promotion();
if (!current) {
return;
}
this.submitting.set(true);
this.api
.approve(current.id, this.decisionComment().trim())
.pipe(
catchError(() => {
this.error.set('Failed to approve promotion.');
return of(null);
})
)
.subscribe((updated) => {
if (updated) {
this.promotion.set(updated);
this.decisionComment.set('');
}
this.submitting.set(false);
});
}
reject(): void {
const current = this.promotion();
if (!current || !this.decisionComment().trim()) {
return;
}
this.submitting.set(true);
this.api
.reject(current.id, this.decisionComment().trim())
.pipe(
catchError(() => {
this.error.set('Failed to reject promotion.');
return of(null);
})
)
.subscribe((updated) => {
if (updated) {
this.promotion.set(updated);
this.decisionComment.set('');
}
this.submitting.set(false);
});
}
formatDate(value: string): string {
return new Date(value).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}

View File

@@ -0,0 +1,569 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
import { APPROVAL_API } from '../../core/api/approval.client';
import type {
ApprovalDetail,
ApprovalRequest,
ApprovalStatus,
GateStatus,
} from '../../core/api/approval.models';
interface PromotionRow {
id: string;
bundleIdentity: string;
bundleVersion: string;
manifestDigest: string | null;
manifestDigestGap: boolean;
sourceEnvironment: string;
targetEnvironment: string;
status: ApprovalStatus;
riskSignal: {
level: 'clean' | 'warning' | 'blocked' | 'unknown';
text: string;
};
dataHealth: {
level: 'healthy' | 'warning' | 'unknown';
text: string;
};
requestedAt: string;
requestedBy: string;
}
@Component({
selector: 'app-promotions-list',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="promotions-list">
<header class="promotions-list__header">
<div>
<h1 class="promotions-list__title">Promotions</h1>
<p class="promotions-list__subtitle">
Bundle-version anchored release promotions with decision context.
</p>
</div>
<a class="btn-primary" routerLink="create">Create Promotion</a>
</header>
<section class="promotions-list__filters" aria-label="Promotion filters">
<label class="filter-field">
<span>Search</span>
<input
type="search"
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event)"
placeholder="Bundle, digest, requester"
aria-label="Search promotions"
/>
</label>
<label class="filter-field">
<span>Status</span>
<select
[ngModel]="statusFilter()"
(ngModelChange)="statusFilter.set($event)"
aria-label="Filter promotions by status"
>
<option value="all">All</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="expired">Expired</option>
</select>
</label>
<label class="filter-field">
<span>Environment</span>
<select
[ngModel]="environmentFilter()"
(ngModelChange)="environmentFilter.set($event)"
aria-label="Filter promotions by target environment"
>
<option value="all">All</option>
@for (env of environmentOptions(); track env) {
<option [value]="env">{{ env }}</option>
}
</select>
</label>
</section>
@if (loading()) {
<div class="state-block">Loading promotions...</div>
}
@if (error()) {
<div class="state-block state-block--error">
{{ error() }}
<button type="button" (click)="loadPromotions()">Retry</button>
</div>
}
@if (!loading() && !error()) {
@if (filteredPromotions().length === 0) {
<div class="state-block">No promotions match the current filters.</div>
} @else {
<table class="promotions-list__table" aria-label="Promotions">
<thead>
<tr>
<th>Promotion</th>
<th>Env Path</th>
<th>Status</th>
<th>Risk Signal</th>
<th>Data Health</th>
<th>Requested</th>
<th><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (promotion of filteredPromotions(); track promotion.id) {
<tr>
<td>
<div class="promotion-identity">
<div class="promotion-identity__title">{{ promotion.bundleIdentity }}</div>
<div class="promotion-identity__meta">Version {{ promotion.bundleVersion }}</div>
@if (promotion.manifestDigest) {
<code class="mono">{{ promotion.manifestDigest }}</code>
} @else {
<span class="contract-gap">Digest unavailable from current promotion contract</span>
}
</div>
</td>
<td>
<span class="env-path">
{{ promotion.sourceEnvironment }} -> {{ promotion.targetEnvironment }}
</span>
</td>
<td>
<span class="status-badge status-badge--{{ promotion.status }}">
{{ promotion.status }}
</span>
</td>
<td>
<span class="signal signal--{{ promotion.riskSignal.level }}">
{{ promotion.riskSignal.text }}
</span>
</td>
<td>
<span class="signal signal--{{ promotion.dataHealth.level }}">
{{ promotion.dataHealth.text }}
</span>
</td>
<td>
<div class="requested-cell">
<span>{{ formatRequestedAt(promotion.requestedAt) }}</span>
<span>by {{ promotion.requestedBy }}</span>
</div>
</td>
<td>
<a [routerLink]="promotion.id" class="link-sm">View -></a>
</td>
</tr>
}
</tbody>
</table>
}
}
</div>
`,
styles: [
`
.promotions-list {
padding: 1.5rem;
max-width: 1200px;
}
.promotions-list__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.promotions-list__title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.promotions-list__subtitle {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
margin: 0;
}
.promotions-list__filters {
display: grid;
grid-template-columns: 1fr 200px 220px;
gap: 0.75rem;
margin-bottom: 1rem;
}
.filter-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-secondary, #666);
}
.filter-field input,
.filter-field select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-sm, 4px);
font-size: 0.875rem;
}
.state-block {
padding: 1rem;
background: var(--color-surface-alt, #f9fafb);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
}
.state-block--error {
background: #fff5f5;
border-color: #fecaca;
color: #991b1b;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.state-block--error button {
padding: 0.25rem 0.75rem;
border-radius: 4px;
border: 1px solid #fecaca;
background: #fff;
color: #991b1b;
cursor: pointer;
}
.promotions-list__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.promotions-list__table th {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 2px solid var(--color-border, #e5e7eb);
font-weight: 600;
font-size: 0.75rem;
color: var(--color-text-secondary, #666);
text-transform: uppercase;
}
.promotions-list__table td {
padding: 0.75rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
vertical-align: top;
}
.promotion-identity {
display: grid;
gap: 0.15rem;
}
.promotion-identity__title {
font-weight: 600;
}
.promotion-identity__meta {
font-size: 0.75rem;
color: var(--color-text-secondary, #666);
}
.mono {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
max-width: 280px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contract-gap {
font-size: 0.75rem;
color: #92400e;
background: #fef3c7;
border: 1px solid #fde68a;
border-radius: 9999px;
padding: 0.1rem 0.5rem;
width: fit-content;
}
.env-path {
font-size: 0.8125rem;
color: var(--color-text-secondary, #666);
}
.status-badge {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: capitalize;
}
.status-badge--pending {
background: #fef3c7;
color: #92400e;
}
.status-badge--approved {
background: #dbeafe;
color: #1e40af;
}
.status-badge--rejected,
.status-badge--expired {
background: #fee2e2;
color: #991b1b;
}
.signal {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.signal--clean,
.signal--healthy {
background: #dcfce7;
color: #166534;
}
.signal--warning {
background: #fef3c7;
color: #92400e;
}
.signal--blocked {
background: #fee2e2;
color: #991b1b;
}
.signal--unknown {
background: #f3f4f6;
color: #4b5563;
}
.requested-cell {
display: grid;
gap: 0.2rem;
font-size: 0.75rem;
color: var(--color-text-secondary, #666);
}
.link-sm {
font-size: 0.8125rem;
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
}
.btn-primary {
padding: 0.5rem 1rem;
background: var(--color-brand-primary, #4f46e5);
color: #fff;
border-radius: var(--radius-sm, 4px);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
@media (max-width: 900px) {
.promotions-list__filters {
grid-template-columns: 1fr;
}
}
`,
],
})
export class PromotionsListComponent implements OnInit {
private readonly api = inject(APPROVAL_API);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly promotions = signal<PromotionRow[]>([]);
readonly searchQuery = signal('');
readonly statusFilter = signal<ApprovalStatus | 'all'>('all');
readonly environmentFilter = signal<string>('all');
readonly environmentOptions = computed(() => {
return Array.from(new Set(this.promotions().map((promotion) => promotion.targetEnvironment))).sort();
});
readonly filteredPromotions = computed(() => {
const search = this.searchQuery().trim().toLowerCase();
const status = this.statusFilter();
const environment = this.environmentFilter();
return this.promotions().filter((promotion) => {
if (status !== 'all' && promotion.status !== status) {
return false;
}
if (environment !== 'all' && promotion.targetEnvironment !== environment) {
return false;
}
if (!search) {
return true;
}
return (
promotion.bundleIdentity.toLowerCase().includes(search) ||
promotion.bundleVersion.toLowerCase().includes(search) ||
(promotion.manifestDigest?.toLowerCase().includes(search) ?? false) ||
promotion.requestedBy.toLowerCase().includes(search)
);
});
});
ngOnInit(): void {
this.loadPromotions();
}
loadPromotions(): void {
this.loading.set(true);
this.error.set(null);
this.api
.listApprovals()
.pipe(
switchMap((approvals) => {
if (approvals.length === 0) {
return of([] as PromotionRow[]);
}
const details = approvals.map((approval) =>
this.api.getApproval(approval.id).pipe(
map((detail) => this.toPromotionRow(approval, detail)),
catchError(() => of(this.toPromotionRow(approval, null)))
)
);
return forkJoin(details);
}),
catchError(() => {
this.error.set('Failed to load promotions.');
return of([] as PromotionRow[]);
})
)
.subscribe((rows) => {
this.promotions.set(rows);
this.loading.set(false);
});
}
formatRequestedAt(requestedAt: string): string {
return new Date(requestedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
private toPromotionRow(
approval: ApprovalRequest,
detail: ApprovalDetail | null
): PromotionRow {
const primaryDigest = detail?.releaseComponents[0]?.digest ?? null;
return {
id: approval.id,
bundleIdentity: approval.releaseName,
bundleVersion: approval.releaseVersion,
manifestDigest: primaryDigest,
manifestDigestGap: primaryDigest === null,
sourceEnvironment: approval.sourceEnvironment,
targetEnvironment: approval.targetEnvironment,
status: approval.status,
riskSignal: this.toRiskSignal(detail?.gateResults),
dataHealth: this.toDataHealth(detail?.gateResults),
requestedAt: approval.requestedAt,
requestedBy: approval.requestedBy,
};
}
private toRiskSignal(gates: readonly { status: GateStatus }[] | undefined): {
level: 'clean' | 'warning' | 'blocked' | 'unknown';
text: string;
} {
if (!gates || gates.length === 0) {
return { level: 'unknown', text: 'No gate trace' };
}
if (gates.some((gate) => gate.status === 'failed')) {
return { level: 'blocked', text: 'Gate block' };
}
if (gates.some((gate) => gate.status === 'warning')) {
return { level: 'warning', text: 'Gate warning' };
}
if (gates.every((gate) => gate.status === 'passed' || gate.status === 'skipped')) {
return { level: 'clean', text: 'Clean' };
}
return { level: 'unknown', text: 'Pending evaluation' };
}
private toDataHealth(gates: readonly { gateName: string; status: GateStatus }[] | undefined): {
level: 'healthy' | 'warning' | 'unknown';
text: string;
} {
if (!gates || gates.length === 0) {
return { level: 'unknown', text: 'No ops data' };
}
const opsGate = gates.find((gate) => {
const name = gate.gateName.toLowerCase();
return name.includes('scan') || name.includes('feed') || name.includes('integr');
});
if (!opsGate) {
return { level: 'unknown', text: 'No ops gate' };
}
if (opsGate.status === 'failed' || opsGate.status === 'warning') {
return { level: 'warning', text: 'Attention needed' };
}
if (opsGate.status === 'passed' || opsGate.status === 'skipped') {
return { level: 'healthy', text: 'Healthy' };
}
return { level: 'unknown', text: 'Pending' };
}
}

View File

@@ -0,0 +1,40 @@
/**
* Promotions Routes
* Sprint: SPRINT_20260218_010_FE_ui_v2_rewire_releases_promotions_run_timeline (R5-01 through R5-04)
*
* Bundle-version anchored promotions under /release-control/promotions:
* '' — Promotions list (filtered by bundle, environment, status)
* create — Create promotion wizard (selects bundle version + target environment)
* :promotionId — Promotion detail with release context and run timeline
*/
import { Routes } from '@angular/router';
export const PROMOTION_ROUTES: Routes = [
// R5-01 — Promotions list
{
path: '',
title: 'Promotions',
data: { breadcrumb: 'Promotions' },
loadComponent: () =>
import('./promotions-list.component').then((m) => m.PromotionsListComponent),
},
// R5-02 — Create promotion wizard
{
path: 'create',
title: 'Create Promotion',
data: { breadcrumb: 'Create Promotion' },
loadComponent: () =>
import('./create-promotion.component').then((m) => m.CreatePromotionComponent),
},
// R5-03/R5-04 — Promotion detail with release context and run timeline
{
path: ':promotionId',
title: 'Promotion Detail',
data: { breadcrumb: 'Promotion Detail' },
loadComponent: () =>
import('./promotion-detail.component').then((m) => m.PromotionDetailComponent),
},
];

View File

@@ -0,0 +1,168 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface SetupArea {
title: string;
description: string;
route: string;
}
@Component({
selector: 'app-release-control-setup-home',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="setup-home">
<header class="header">
<h1>Release Control Setup</h1>
<p>
Canonical setup hub for environments, promotion paths, targets, agents, workflows, and
bundle templates.
</p>
</header>
<p class="state-banner">
Read-only structural mode: setup contracts are shown with deterministic placeholders until
backend setup APIs are wired.
</p>
<section class="areas" aria-label="Setup areas">
@for (area of areas; track area.route) {
<a class="card" [routerLink]="area.route">
<h2>{{ area.title }}</h2>
<p>{{ area.description }}</p>
</a>
}
</section>
<section class="legacy-map" aria-label="Legacy setup aliases">
<h2>Legacy Setup Aliases</h2>
<ul>
<li><code>/settings/release-control</code> redirects to <code>/release-control/setup</code></li>
<li>
<code>/settings/release-control/environments</code> redirects to
<code>/release-control/setup/environments-paths</code>
</li>
<li>
<code>/settings/release-control/targets</code> and <code>/settings/release-control/agents</code>
redirect to <code>/release-control/setup/targets-agents</code>
</li>
<li>
<code>/settings/release-control/workflows</code> redirects to
<code>/release-control/setup/workflows</code>
</li>
</ul>
</section>
</section>
`,
styles: [`
.setup-home {
padding: 1.5rem;
max-width: 1100px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.header h1 {
margin: 0 0 0.25rem;
font-size: 1.6rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary, #667085);
font-size: 0.92rem;
}
.state-banner {
margin: 0;
border: 1px solid var(--color-status-warning-border, #facc15);
background: var(--color-status-warning-bg, #fffbeb);
color: var(--color-status-warning-text, #854d0e);
border-radius: var(--radius-md, 8px);
padding: 0.7rem 0.85rem;
font-size: 0.86rem;
}
.areas {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0.8rem;
}
.card {
display: block;
border: 1px solid var(--color-border-primary, #e4e7ec);
background: var(--color-surface-primary, #fff);
border-radius: var(--radius-md, 8px);
padding: 0.9rem;
text-decoration: none;
color: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.card:hover {
border-color: var(--color-brand-primary, #2563eb);
box-shadow: 0 3px 10px rgba(15, 23, 42, 0.08);
}
.card h2 {
margin: 0 0 0.25rem;
font-size: 1rem;
}
.card p {
margin: 0;
color: var(--color-text-secondary, #667085);
font-size: 0.84rem;
line-height: 1.4;
}
.legacy-map {
border: 1px solid var(--color-border-primary, #e4e7ec);
border-radius: var(--radius-md, 8px);
padding: 0.9rem;
background: var(--color-surface-primary, #fff);
}
.legacy-map h2 {
margin: 0 0 0.6rem;
font-size: 0.95rem;
}
.legacy-map ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
color: var(--color-text-secondary, #667085);
font-size: 0.83rem;
}
`],
})
export class ReleaseControlSetupHomeComponent {
readonly areas: SetupArea[] = [
{
title: 'Environments and Promotion Paths',
description: 'Define environment hierarchy and promotion routes (Dev -> Stage -> Prod).',
route: '/release-control/setup/environments-paths',
},
{
title: 'Targets and Agents',
description: 'Track runtime targets and execution agents used by release deployments.',
route: '/release-control/setup/targets-agents',
},
{
title: 'Workflows',
description: 'Review workflow templates and promotion execution steps before activation.',
route: '/release-control/setup/workflows',
},
{
title: 'Bundle Templates',
description: 'Manage default bundle composition templates and validation requirements.',
route: '/release-control/setup/bundle-templates',
},
];
}

View File

@@ -0,0 +1,133 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-setup-bundle-templates',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="page">
<header class="header">
<a routerLink="/release-control/setup" class="back-link">Back to Setup</a>
<h1>Bundle Templates</h1>
<p>Template presets for bundle composition, validation gates, and release metadata policy.</p>
</header>
<section class="panel">
<h2>Template Catalog</h2>
<table aria-label="Bundle templates">
<thead>
<tr>
<th>Template</th>
<th>Required Sections</th>
<th>Validation Profile</th>
<th>Default Use</th>
</tr>
</thead>
<tbody>
<tr><td>service-platform</td><td>digest, config, changelog, evidence</td><td>strict</td><td>platform releases</td></tr>
<tr><td>edge-hotfix</td><td>digest, changelog, evidence</td><td>fast-track</td><td>hotfix bundle</td></tr>
<tr><td>regional-rollout</td><td>digest, config, promotion path, evidence</td><td>risk-aware</td><td>multi-region rollout</td></tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>Template Rules</h2>
<ul>
<li>Template controls required builder sections before bundle version materialization.</li>
<li>Validation profile maps to policy and advisory confidence requirements.</li>
<li>Template changes apply only to newly created bundle versions (immutability preserved).</li>
</ul>
</section>
<section class="panel links">
<h2>Related Surfaces</h2>
<a routerLink="/release-control/bundles/create">Open Bundle Builder</a>
<a routerLink="/release-control/bundles">Open Bundle Catalog</a>
</section>
</section>
`,
styles: [`
.page {
padding: 1.5rem;
max-width: 980px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.back-link {
font-size: 0.84rem;
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
}
.header h1 {
margin: 0.25rem 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary, #667085);
}
.panel {
border: 1px solid var(--color-border-primary, #e4e7ec);
border-radius: var(--radius-md, 8px);
padding: 0.85rem;
background: var(--color-surface-primary, #fff);
}
.panel h2 {
margin: 0 0 0.6rem;
font-size: 0.96rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
th, td {
text-align: left;
padding: 0.45rem 0.35rem;
border-top: 1px solid var(--color-border-primary, #e4e7ec);
}
th {
border-top: 0;
font-size: 0.73rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
color: var(--color-text-secondary, #667085);
font-size: 0.85rem;
}
.links {
display: grid;
gap: 0.5rem;
}
.links a {
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
font-size: 0.86rem;
}
`],
})
export class SetupBundleTemplatesComponent {}

View File

@@ -0,0 +1,133 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-setup-environments-paths',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="page">
<header class="header">
<a routerLink="/release-control/setup" class="back-link">Back to Setup</a>
<h1>Environments and Promotion Paths</h1>
<p>Release Control-owned environment graph and allowed promotion flows.</p>
</header>
<section class="panel">
<h2>Environment Inventory</h2>
<table aria-label="Environment inventory">
<thead>
<tr>
<th>Environment</th>
<th>Region</th>
<th>Risk Tier</th>
<th>Promotion Entry</th>
</tr>
</thead>
<tbody>
<tr><td>dev-us-east</td><td>us-east</td><td>low</td><td>yes</td></tr>
<tr><td>stage-eu-west</td><td>eu-west</td><td>medium</td><td>yes</td></tr>
<tr><td>prod-eu-west</td><td>eu-west</td><td>high</td><td>yes</td></tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>Promotion Path Rules</h2>
<ul>
<li><code>dev-*</code> can promote to <code>stage-*</code> with approval gates.</li>
<li><code>stage-*</code> can promote to <code>prod-*</code> only with policy + ops gate pass.</li>
<li>Cross-region promotion requires an explicit path definition and target parity checks.</li>
</ul>
</section>
<section class="panel links">
<h2>Related Surfaces</h2>
<a routerLink="/release-control/environments">Open Regions and Environments</a>
<a routerLink="/release-control/promotions">Open Promotions</a>
</section>
</section>
`,
styles: [`
.page {
padding: 1.5rem;
max-width: 980px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.back-link {
font-size: 0.84rem;
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
}
.header h1 {
margin: 0.25rem 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary, #667085);
}
.panel {
border: 1px solid var(--color-border-primary, #e4e7ec);
border-radius: var(--radius-md, 8px);
padding: 0.85rem;
background: var(--color-surface-primary, #fff);
}
.panel h2 {
margin: 0 0 0.6rem;
font-size: 0.96rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
th, td {
text-align: left;
padding: 0.45rem 0.35rem;
border-top: 1px solid var(--color-border-primary, #e4e7ec);
}
th {
border-top: 0;
font-size: 0.73rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
color: var(--color-text-secondary, #667085);
font-size: 0.85rem;
}
.links {
display: grid;
gap: 0.5rem;
}
.links a {
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
font-size: 0.86rem;
}
`],
})
export class SetupEnvironmentsPathsComponent {}

View File

@@ -0,0 +1,136 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-setup-targets-agents',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="page">
<header class="header">
<a routerLink="/release-control/setup" class="back-link">Back to Setup</a>
<h1>Targets and Agents</h1>
<p>Release Control deployment execution topology with ownership split to Integrations.</p>
</header>
<section class="panel">
<h2>Deployment Targets</h2>
<table aria-label="Deployment targets">
<thead>
<tr>
<th>Target</th>
<th>Runtime</th>
<th>Region</th>
<th>Agent Group</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr><td>edge-gateway-prod</td><td>vm</td><td>eu-west</td><td>agent-eu</td><td>ready</td></tr>
<tr><td>payments-core-stage</td><td>nomad</td><td>us-east</td><td>agent-us</td><td>ready</td></tr>
<tr><td>billing-svc-prod</td><td>ecs</td><td>eu-west</td><td>agent-eu</td><td>degraded</td></tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>Agent Coverage</h2>
<ul>
<li><strong>agent-eu</strong>: 42 targets, heartbeat every 20s, upgrade window Fri 23:00 UTC.</li>
<li><strong>agent-us</strong>: 35 targets, heartbeat every 20s, upgrade window Sat 01:00 UTC.</li>
<li><strong>agent-apac</strong>: 18 targets, on-call watch enabled, runtime drift checks active.</li>
</ul>
</section>
<section class="panel links">
<h2>Ownership Links</h2>
<a routerLink="/integrations/hosts">
Connector connectivity and credentials are managed in Integrations > Targets / Runtimes
</a>
<a routerLink="/platform-ops/agents">Operational status and diagnostics are managed in Platform Ops > Agents</a>
</section>
</section>
`,
styles: [`
.page {
padding: 1.5rem;
max-width: 980px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.back-link {
font-size: 0.84rem;
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
}
.header h1 {
margin: 0.25rem 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary, #667085);
}
.panel {
border: 1px solid var(--color-border-primary, #e4e7ec);
border-radius: var(--radius-md, 8px);
padding: 0.85rem;
background: var(--color-surface-primary, #fff);
}
.panel h2 {
margin: 0 0 0.6rem;
font-size: 0.96rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
th, td {
text-align: left;
padding: 0.45rem 0.35rem;
border-top: 1px solid var(--color-border-primary, #e4e7ec);
}
th {
border-top: 0;
font-size: 0.73rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
color: var(--color-text-secondary, #667085);
font-size: 0.85rem;
}
.links {
display: grid;
gap: 0.5rem;
}
.links a {
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
font-size: 0.86rem;
}
`],
})
export class SetupTargetsAgentsComponent {}

View File

@@ -0,0 +1,133 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-setup-workflows',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="page">
<header class="header">
<a routerLink="/release-control/setup" class="back-link">Back to Setup</a>
<h1>Workflows</h1>
<p>Release Control workflow definitions for promotion orchestration and approval sequencing.</p>
</header>
<section class="panel">
<h2>Workflow Catalog</h2>
<table aria-label="Workflow catalog">
<thead>
<tr>
<th>Workflow</th>
<th>Path</th>
<th>Gate Profile</th>
<th>Rollback</th>
</tr>
</thead>
<tbody>
<tr><td>standard-blue-green</td><td>dev -> stage -> prod</td><td>strict-prod</td><td>auto</td></tr>
<tr><td>canary-regional</td><td>stage -> prod-canary -> prod</td><td>risk-aware</td><td>manual</td></tr>
<tr><td>hotfix-fast-track</td><td>stage -> prod</td><td>expedited</td><td>manual</td></tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>Execution Constraints</h2>
<ul>
<li>All workflows require a bundle version digest and resolved inputs before promotion launch.</li>
<li>Approval checkpoints inherit policy gates from Administration policy governance baseline.</li>
<li>Run timeline evidence checkpoints are mandatory for promotion completion.</li>
</ul>
</section>
<section class="panel links">
<h2>Related Surfaces</h2>
<a routerLink="/administration/workflows">Open legacy workflow editor surface</a>
<a routerLink="/release-control/runs">Open Run Timeline</a>
</section>
</section>
`,
styles: [`
.page {
padding: 1.5rem;
max-width: 980px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.back-link {
font-size: 0.84rem;
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
}
.header h1 {
margin: 0.25rem 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary, #667085);
}
.panel {
border: 1px solid var(--color-border-primary, #e4e7ec);
border-radius: var(--radius-md, 8px);
padding: 0.85rem;
background: var(--color-surface-primary, #fff);
}
.panel h2 {
margin: 0 0 0.6rem;
font-size: 0.96rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
th, td {
text-align: left;
padding: 0.45rem 0.35rem;
border-top: 1px solid var(--color-border-primary, #e4e7ec);
}
th {
border-top: 0;
font-size: 0.73rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
color: var(--color-text-secondary, #667085);
font-size: 0.85rem;
}
.links {
display: grid;
gap: 0.5rem;
}
.links a {
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
font-size: 0.86rem;
}
`],
})
export class SetupWorkflowsComponent {}

View File

@@ -3,10 +3,13 @@ import { Routes } from '@angular/router';
/**
* Environment management routes for Release Orchestrator.
* Sprint: SPRINT_20260110_111_002_FE_environment_management_ui
* Updated: SPRINT_20260218_013_FE_ui_v2_rewire_environment_detail_standardization (E8-01 through E8-05)
* — Added canonical breadcrumbs and tab data to list and detail routes.
*/
export const ENVIRONMENT_ROUTES: Routes = [
{
path: '',
data: { breadcrumb: 'Regions & Environments' },
loadComponent: () =>
import('./environment-list/environment-list.component').then(
(m) => m.EnvironmentListComponent
@@ -14,6 +17,19 @@ export const ENVIRONMENT_ROUTES: Routes = [
},
{
path: ':id',
data: {
breadcrumb: 'Environment Detail',
tabs: [
'overview',
'deployments',
'sbom',
'reachability',
'inputs',
'promotions',
'data-integrity',
'evidence',
],
},
loadComponent: () =>
import('./environment-detail/environment-detail.component').then(
(m) => m.EnvironmentDetailComponent
@@ -21,10 +37,10 @@ export const ENVIRONMENT_ROUTES: Routes = [
},
{
path: ':id/settings',
data: { breadcrumb: 'Environment Settings', tab: 'settings' },
loadComponent: () =>
import('./environment-detail/environment-detail.component').then(
(m) => m.EnvironmentDetailComponent
),
data: { tab: 'settings' },
},
];

View File

@@ -21,7 +21,7 @@ import {
<div class="create-release">
<header class="wizard-header">
<h1>Create Release</h1>
<button class="btn-text" routerLink="/release-orchestrator/releases">Cancel</button>
<button class="btn-text" routerLink="/releases">Cancel</button>
</header>
<div class="wizard-steps">
@@ -665,9 +665,7 @@ export class CreateReleaseComponent {
deploymentStrategy: this.formData.deploymentStrategy,
});
// After creation, add components
// Note: In a real app, we'd wait for the release to be created first
// For now, we'll just navigate back
this.router.navigate(['/release-orchestrator/releases']);
// Navigate to releases list after creation
this.router.navigate(['/releases']);
}
}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { Component, OnInit, inject, signal, WritableSignal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterModule } from '@angular/router';
@@ -26,14 +26,12 @@ import {
<div class="not-found">
<h2>Release Not Found</h2>
<p>The release you're looking for doesn't exist.</p>
<a routerLink="/release-orchestrator/releases" class="btn-primary">Back to Releases</a>
<a routerLink="/releases" class="btn-primary">Back to Releases</a>
</div>
} @else {
<header class="detail-header">
<nav class="breadcrumb">
<a routerLink="/release-orchestrator">Release Orchestrator</a>
<span>/</span>
<a routerLink="/release-orchestrator/releases">Releases</a>
<a routerLink="/releases">Releases</a>
<span>/</span>
<span>{{ release()!.name }} {{ release()!.version }}</span>
</nav>
@@ -51,19 +49,19 @@ import {
</div>
<div class="header-actions">
@if (store.canEdit()) {
<button class="btn-secondary" (click)="showEditDialog = true">Edit</button>
<button class="btn-secondary" (click)="showEditDialog.set(true)">Edit</button>
}
@if (release()!.status === 'draft') {
<button class="btn-primary" (click)="onMarkReady()">Mark Ready</button>
}
@if (store.canPromote()) {
<button class="btn-secondary" (click)="showPromoteDialog = true">Promote</button>
<button class="btn-secondary" (click)="showPromoteDialog.set(true)">Promote</button>
}
@if (store.canDeploy()) {
<button class="btn-primary" (click)="showDeployDialog = true">Deploy</button>
<button class="btn-primary" (click)="showDeployDialog.set(true)">Deploy</button>
}
@if (store.canRollback()) {
<button class="btn-danger" (click)="showRollbackDialog = true">Rollback</button>
<button class="btn-danger" (click)="showRollbackDialog.set(true)">Rollback</button>
}
</div>
</div>
@@ -113,7 +111,7 @@ import {
<div class="components-tab">
@if (store.canEdit()) {
<div class="component-actions">
<button class="btn-secondary" (click)="showAddComponent = true">+ Add Component</button>
<button class="btn-secondary" (click)="showAddComponent.set(true)">+ Add Component</button>
</div>
}
@@ -121,7 +119,7 @@ import {
<div class="empty-state">
<p>No components added yet.</p>
@if (store.canEdit()) {
<button class="btn-primary" (click)="showAddComponent = true">Add Component</button>
<button class="btn-primary" (click)="showAddComponent.set(true)">Add Component</button>
}
</div>
} @else {
@@ -197,7 +195,7 @@ import {
</div>
<!-- Add Component Dialog -->
@if (showAddComponent) {
@if (showAddComponent()) {
<div class="dialog-overlay" (click)="closeAddComponent()">
<div class="dialog dialog-lg" (click)="$event.stopPropagation()">
<h2>Add Component</h2>
@@ -256,8 +254,8 @@ import {
}
<!-- Promote Dialog -->
@if (showPromoteDialog) {
<div class="dialog-overlay" (click)="showPromoteDialog = false">
@if (showPromoteDialog()) {
<div class="dialog-overlay" (click)="showPromoteDialog.set(false)">
<div class="dialog" (click)="$event.stopPropagation()">
<h2>Promote Release</h2>
<p>Select target environment for promotion:</p>
@@ -270,7 +268,7 @@ import {
</select>
</div>
<div class="dialog-actions">
<button class="btn-secondary" (click)="showPromoteDialog = false">Cancel</button>
<button class="btn-secondary" (click)="showPromoteDialog.set(false)">Cancel</button>
<button class="btn-primary" (click)="confirmPromote()" [disabled]="!promoteTarget">
Promote
</button>
@@ -280,8 +278,8 @@ import {
}
<!-- Deploy Dialog -->
@if (showDeployDialog) {
<div class="dialog-overlay" (click)="showDeployDialog = false">
@if (showDeployDialog()) {
<div class="dialog-overlay" (click)="showDeployDialog.set(false)">
<div class="dialog" (click)="$event.stopPropagation()">
<h2>Deploy Release</h2>
<p>
@@ -290,7 +288,7 @@ import {
</p>
<p class="info">This will start the deployment process using {{ getStrategyLabel(release()!.deploymentStrategy) }} strategy.</p>
<div class="dialog-actions">
<button class="btn-secondary" (click)="showDeployDialog = false">Cancel</button>
<button class="btn-secondary" (click)="showDeployDialog.set(false)">Cancel</button>
<button class="btn-primary" (click)="confirmDeploy()">Deploy</button>
</div>
</div>
@@ -298,8 +296,8 @@ import {
}
<!-- Rollback Dialog -->
@if (showRollbackDialog) {
<div class="dialog-overlay" (click)="showRollbackDialog = false">
@if (showRollbackDialog()) {
<div class="dialog-overlay" (click)="showRollbackDialog.set(false)">
<div class="dialog" (click)="$event.stopPropagation()">
<h2>Rollback Release</h2>
<p class="warning">
@@ -308,7 +306,7 @@ import {
</p>
<p>This will restore the previous release.</p>
<div class="dialog-actions">
<button class="btn-secondary" (click)="showRollbackDialog = false">Cancel</button>
<button class="btn-secondary" (click)="showRollbackDialog.set(false)">Cancel</button>
<button class="btn-danger" (click)="confirmRollback()">Rollback</button>
</div>
</div>
@@ -316,8 +314,8 @@ import {
}
<!-- Edit Dialog -->
@if (showEditDialog) {
<div class="dialog-overlay" (click)="showEditDialog = false">
@if (showEditDialog()) {
<div class="dialog-overlay" (click)="showEditDialog.set(false)">
<div class="dialog" (click)="$event.stopPropagation()">
<h2>Edit Release</h2>
<div class="form-field">
@@ -333,7 +331,7 @@ import {
<textarea [(ngModel)]="editForm.description" rows="3"></textarea>
</div>
<div class="dialog-actions">
<button class="btn-secondary" (click)="showEditDialog = false">Cancel</button>
<button class="btn-secondary" (click)="showEditDialog.set(false)">Cancel</button>
<button class="btn-primary" (click)="confirmEdit()">Save</button>
</div>
</div>
@@ -813,11 +811,11 @@ export class ReleaseDetailComponent implements OnInit {
activeTab = signal<'components' | 'timeline'>('components');
showAddComponent = false;
showPromoteDialog = false;
showDeployDialog = false;
showRollbackDialog = false;
showEditDialog = false;
showAddComponent: WritableSignal<boolean> = signal(false);
showPromoteDialog: WritableSignal<boolean> = signal(false);
showDeployDialog: WritableSignal<boolean> = signal(false);
showRollbackDialog: WritableSignal<boolean> = signal(false);
showEditDialog: WritableSignal<boolean> = signal(false);
searchQuery = '';
selectedImage: { name: string; repository: string; digests: Array<{ tag: string; digest: string; pushedAt: string }> } | null = null;
@@ -898,7 +896,7 @@ export class ReleaseDetailComponent implements OnInit {
}
closeAddComponent(): void {
this.showAddComponent = false;
this.showAddComponent.set(false);
this.selectedImage = null;
this.selectedDigest = '';
this.selectedTag = '';
@@ -953,7 +951,7 @@ export class ReleaseDetailComponent implements OnInit {
const r = this.release();
if (r && this.promoteTarget) {
this.store.requestPromotion(r.id, this.promoteTarget);
this.showPromoteDialog = false;
this.showPromoteDialog.set(false);
this.promoteTarget = '';
}
}
@@ -962,7 +960,7 @@ export class ReleaseDetailComponent implements OnInit {
const r = this.release();
if (r) {
this.store.deploy(r.id);
this.showDeployDialog = false;
this.showDeployDialog.set(false);
}
}
@@ -970,7 +968,7 @@ export class ReleaseDetailComponent implements OnInit {
const r = this.release();
if (r) {
this.store.rollback(r.id);
this.showRollbackDialog = false;
this.showRollbackDialog.set(false);
}
}
@@ -978,7 +976,7 @@ export class ReleaseDetailComponent implements OnInit {
const r = this.release();
if (r) {
this.store.updateRelease(r.id, this.editForm);
this.showEditDialog = false;
this.showEditDialog.set(false);
}
}
}

View File

@@ -0,0 +1,185 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { map, Observable } from 'rxjs';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
export interface AdvisorySourceListResponseDto {
items: AdvisorySourceListItemDto[];
totalCount: number;
dataAsOf: string;
}
export interface AdvisorySourceListItemDto {
sourceId: string;
sourceKey: string;
sourceName: string;
sourceFamily: string;
sourceUrl?: string | null;
priority: number;
enabled: boolean;
lastSyncAt?: string | null;
lastSuccessAt?: string | null;
freshnessAgeSeconds: number;
freshnessSlaSeconds: number;
freshnessStatus: string;
signatureStatus: string;
lastError?: string | null;
syncCount: number;
errorCount: number;
totalAdvisories: number;
signedAdvisories: number;
unsignedAdvisories: number;
signatureFailureCount: number;
}
export interface AdvisorySourceSummaryResponseDto {
totalSources: number;
healthySources: number;
warningSources: number;
staleSources: number;
unavailableSources: number;
disabledSources: number;
conflictingSources: number;
dataAsOf: string;
}
export interface AdvisorySourceFreshnessResponseDto {
source: AdvisorySourceListItemDto;
lastSyncAt?: string | null;
lastSuccessAt?: string | null;
lastError?: string | null;
syncCount: number;
errorCount: number;
dataAsOf: string;
}
export interface AdvisorySourceImpactResponseDto {
sourceId: string;
sourceFamily: string;
region?: string | null;
environment?: string | null;
impactedDecisionsCount: number;
impactSeverity: string;
lastDecisionAt?: string | null;
decisionRefs: AdvisorySourceDecisionRefDto[];
dataAsOf: string;
}
export interface AdvisorySourceDecisionRefDto {
decisionId: string;
decisionType?: string | null;
label?: string | null;
route?: string | null;
}
export interface AdvisorySourceConflictListResponseDto {
sourceId: string;
status: string;
limit: number;
offset: number;
totalCount: number;
items: AdvisorySourceConflictResponseDto[];
dataAsOf: string;
}
export interface AdvisorySourceConflictResponseDto {
conflictId: string;
advisoryId: string;
pairedSourceKey?: string | null;
conflictType: string;
severity: string;
status: string;
description: string;
firstDetectedAt: string;
lastDetectedAt: string;
resolvedAt?: string | null;
}
export interface AdvisorySourceImpactFilter {
region?: string | null;
environment?: string | null;
sourceFamily?: string | null;
}
@Injectable({ providedIn: 'root' })
export class AdvisorySourcesApi {
private readonly http = inject(HttpClient);
private readonly authSession = inject(AuthSessionStore);
private readonly baseUrl = '/api/v1/advisory-sources';
listSources(includeDisabled = false): Observable<AdvisorySourceListItemDto[]> {
const params = new HttpParams().set('includeDisabled', String(includeDisabled));
return this.http
.get<AdvisorySourceListResponseDto>(this.baseUrl, {
params,
headers: this.buildConcelierHeaders(),
})
.pipe(map((response) => response.items ?? []));
}
getSummary(): Observable<AdvisorySourceSummaryResponseDto> {
return this.http.get<AdvisorySourceSummaryResponseDto>(`${this.baseUrl}/summary`, {
headers: this.buildConcelierHeaders(),
});
}
getFreshness(sourceIdOrKey: string): Observable<AdvisorySourceFreshnessResponseDto> {
return this.http.get<AdvisorySourceFreshnessResponseDto>(
`${this.baseUrl}/${encodeURIComponent(sourceIdOrKey)}/freshness`,
{
headers: this.buildConcelierHeaders(),
}
);
}
getImpact(
sourceKey: string,
filter?: AdvisorySourceImpactFilter
): Observable<AdvisorySourceImpactResponseDto> {
let params = new HttpParams();
if (filter?.region) {
params = params.set('region', filter.region);
}
if (filter?.environment) {
params = params.set('environment', filter.environment);
}
if (filter?.sourceFamily) {
params = params.set('sourceFamily', filter.sourceFamily);
}
return this.http.get<AdvisorySourceImpactResponseDto>(
`${this.baseUrl}/${encodeURIComponent(sourceKey)}/impact`,
{ params }
);
}
listConflicts(
sourceKey: string,
status = 'open',
limit = 50,
offset = 0
): Observable<AdvisorySourceConflictListResponseDto> {
const params = new HttpParams()
.set('status', status)
.set('limit', String(limit))
.set('offset', String(offset));
return this.http.get<AdvisorySourceConflictListResponseDto>(
`${this.baseUrl}/${encodeURIComponent(sourceKey)}/conflicts`,
{ params }
);
}
private buildConcelierHeaders(): HttpHeaders {
const tenantId = this.authSession.getActiveTenantId();
if (!tenantId) {
return new HttpHeaders();
}
return new HttpHeaders({
'X-Stella-Tenant': tenantId,
'X-Tenant-Id': tenantId,
'X-StellaOps-Tenant': tenantId,
});
}
}

View File

@@ -0,0 +1,310 @@
/**
* Remediation Marketplace Browse Component
* Sprint: SPRINT_20260220_014 (REM-22)
*/
import {
Component,
ChangeDetectionStrategy,
signal,
inject,
OnInit,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { RemediationApiService, FixTemplate } from './remediation.api';
@Component({
selector: 'app-remediation-browse',
standalone: true,
imports: [RouterLink, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="remediation-browse">
<header class="browse-header">
<h1 class="browse-title">Remediation Marketplace</h1>
<p class="browse-subtitle">
Browse verified fix templates, community contributions, and signed remediation PRs.
</p>
</header>
<section class="browse-filters">
<div class="filter-row">
<input
type="text"
class="filter-input"
placeholder="Search by CVE (e.g., CVE-2024-1234)"
[ngModel]="searchCve()"
(ngModelChange)="searchCve.set($event)"
(keyup.enter)="onSearch()"
/>
<input
type="text"
class="filter-input"
placeholder="Filter by PURL (e.g., pkg:npm/lodash)"
[ngModel]="searchPurl()"
(ngModelChange)="searchPurl.set($event)"
(keyup.enter)="onSearch()"
/>
<button class="btn btn-primary" (click)="onSearch()">Search</button>
</div>
<div class="filter-chips">
<button
class="chip"
[class.chip--active]="statusFilter() === 'all'"
(click)="statusFilter.set('all')"
>All</button>
<button
class="chip"
[class.chip--active]="statusFilter() === 'verified'"
(click)="statusFilter.set('verified')"
>Verified</button>
<button
class="chip"
[class.chip--active]="statusFilter() === 'pending'"
(click)="statusFilter.set('pending')"
>Pending</button>
</div>
</section>
<section class="fix-cards" aria-label="Fix templates">
@if (loading()) {
<div class="loading">Loading fix templates...</div>
} @else if (templates().length === 0) {
<div class="empty-state">
<p>No fix templates found. Try adjusting your search filters.</p>
</div>
} @else {
@for (fix of filteredTemplates(); track fix.id) {
<a [routerLink]="'/security-risk/remediation/' + fix.id" class="fix-card">
<div class="fix-card__header">
<span class="fix-card__cve">{{ fix.cveId }}</span>
<span class="fix-card__status" [class]="'status--' + fix.status">{{ fix.status }}</span>
</div>
<div class="fix-card__purl">{{ fix.purl }}</div>
<div class="fix-card__range">Version range: {{ fix.versionRange }}</div>
@if (fix.description) {
<div class="fix-card__desc">{{ fix.description }}</div>
}
<div class="fix-card__footer">
<span class="fix-card__trust" [attr.title]="'Trust score: ' + fix.trustScore">
Trust: {{ (fix.trustScore * 100).toFixed(0) }}%
</span>
<span class="fix-card__date">{{ fix.createdAt | date:'mediumDate' }}</span>
</div>
</a>
}
}
</section>
</div>
`,
styles: [`
.remediation-browse {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.browse-header {
border-bottom: 1px solid var(--color-border-primary);
padding-bottom: 1rem;
}
.browse-title {
font-size: 1.75rem;
font-weight: var(--font-weight-bold);
margin: 0;
}
.browse-subtitle {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 0.35rem 0 0;
}
.browse-filters {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.filter-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-input {
flex: 1;
min-width: 200px;
padding: 0.5rem 0.75rem;
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.875rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
cursor: pointer;
}
.btn-primary {
background: var(--color-brand-primary);
color: #fff;
}
.filter-chips {
display: flex;
gap: 0.5rem;
}
.chip {
padding: 0.25rem 0.75rem;
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.8rem;
cursor: pointer;
}
.chip--active {
background: var(--color-brand-primary);
color: #fff;
border-color: var(--color-brand-primary);
}
.fix-cards {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
}
.fix-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 1rem 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);
transition: box-shadow 0.15s, border-color 0.15s;
}
.fix-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-brand-primary);
}
.fix-card__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.fix-card__cve {
font-weight: var(--font-weight-semibold);
font-size: 1rem;
}
.fix-card__status {
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status--verified {
background: rgba(34, 197, 94, 0.15);
color: var(--color-status-success);
}
.status--pending {
background: rgba(234, 179, 8, 0.15);
color: var(--color-status-warning);
}
.status--rejected {
background: rgba(239, 68, 68, 0.15);
color: var(--color-status-error);
}
.fix-card__purl {
font-family: var(--font-family-mono);
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.fix-card__range {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.fix-card__desc {
font-size: 0.85rem;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fix-card__footer {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
}
.loading, .empty-state {
text-align: center;
padding: 2rem;
color: var(--color-text-secondary);
}
`],
})
export class RemediationBrowseComponent implements OnInit {
private readonly api = inject(RemediationApiService);
readonly searchCve = signal('');
readonly searchPurl = signal('');
readonly statusFilter = signal<'all' | 'verified' | 'pending'>('all');
readonly templates = signal<FixTemplate[]>([]);
readonly loading = signal(false);
readonly filteredTemplates = () => {
const status = this.statusFilter();
if (status === 'all') return this.templates();
return this.templates().filter(t => t.status === status);
};
ngOnInit(): void {
this.onSearch();
}
onSearch(): void {
this.loading.set(true);
const cve = this.searchCve() || undefined;
const purl = this.searchPurl() || undefined;
this.api.listTemplates(cve, purl).subscribe({
next: (res) => {
this.templates.set(res.items);
this.loading.set(false);
},
error: () => {
this.templates.set([]);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,269 @@
/**
* Remediation Fix Detail Component
* Sprint: SPRINT_20260220_014 (REM-23)
*/
import {
Component,
ChangeDetectionStrategy,
signal,
inject,
OnInit,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { RemediationApiService, FixTemplate } from './remediation.api';
@Component({
selector: 'app-remediation-fix-detail',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fix-detail">
@if (loading()) {
<div class="loading">Loading fix details...</div>
} @else if (fix()) {
<header class="detail-header">
<div class="header-top">
<a routerLink="/security-risk/remediation" class="back-link">Back to Marketplace</a>
</div>
<h1 class="detail-title">{{ fix()!.cveId }}</h1>
<div class="detail-meta">
<span class="meta-item status" [class]="'status--' + fix()!.status">{{ fix()!.status }}</span>
<span class="meta-item purl">{{ fix()!.purl }}</span>
<span class="meta-item range">{{ fix()!.versionRange }}</span>
</div>
</header>
<section class="detail-section">
<h2 class="section-title">Description</h2>
<p class="section-body">{{ fix()!.description || 'No description provided.' }}</p>
</section>
<section class="detail-section">
<h2 class="section-title">Trust Information</h2>
<div class="trust-grid">
<div class="trust-item">
<span class="trust-label">Trust Score</span>
<span class="trust-value">{{ (fix()!.trustScore * 100).toFixed(0) }}%</span>
</div>
@if (fix()!.dsseDigest) {
<div class="trust-item">
<span class="trust-label">DSSE Digest</span>
<span class="trust-value mono">{{ fix()!.dsseDigest }}</span>
</div>
}
@if (fix()!.verifiedAt) {
<div class="trust-item">
<span class="trust-label">Verified At</span>
<span class="trust-value">{{ fix()!.verifiedAt }}</span>
</div>
}
</div>
</section>
<section class="detail-section">
<h2 class="section-title">Patch Content</h2>
<pre class="patch-content"><code>{{ fix()!.patchContent }}</code></pre>
</section>
<section class="detail-section">
<h2 class="section-title">Attestation Chain</h2>
<div class="attestation-chain">
<div class="chain-step">
<span class="chain-dot"></span>
<span class="chain-label">Template Created</span>
<span class="chain-time">{{ fix()!.createdAt }}</span>
</div>
@if (fix()!.dsseDigest) {
<div class="chain-step">
<span class="chain-dot chain-dot--verified"></span>
<span class="chain-label">DSSE Envelope Signed</span>
<span class="chain-time mono">{{ fix()!.dsseDigest }}</span>
</div>
}
@if (fix()!.verifiedAt) {
<div class="chain-step">
<span class="chain-dot chain-dot--verified"></span>
<span class="chain-label">Verification Complete</span>
<span class="chain-time">{{ fix()!.verifiedAt }}</span>
</div>
}
</div>
</section>
} @else {
<div class="empty-state">Fix template not found.</div>
}
</div>
`,
styles: [`
.fix-detail {
padding: 1.5rem;
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.back-link {
font-size: 0.85rem;
color: var(--color-brand-primary);
text-decoration: none;
}
.back-link:hover { text-decoration: underline; }
.detail-title {
font-size: 1.75rem;
font-weight: var(--font-weight-bold);
margin: 0.5rem 0 0;
}
.detail-meta {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.meta-item {
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
border-radius: var(--radius-full);
border: 1px solid var(--color-border-primary);
}
.status--verified { background: rgba(34, 197, 94, 0.15); color: var(--color-status-success); }
.status--pending { background: rgba(234, 179, 8, 0.15); color: var(--color-status-warning); }
.status--rejected { background: rgba(239, 68, 68, 0.15); color: var(--color-status-error); }
.purl, .range {
font-family: var(--font-family-mono);
color: var(--color-text-secondary);
}
.detail-section {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.25rem;
background: var(--color-surface-primary);
}
.section-title {
font-size: 0.85rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
margin: 0 0 0.75rem;
}
.section-body {
font-size: 0.9rem;
margin: 0;
}
.trust-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
.trust-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.trust-label {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.trust-value {
font-size: 1rem;
font-weight: var(--font-weight-semibold);
}
.mono { font-family: var(--font-family-mono); font-size: 0.8rem; word-break: break-all; }
.patch-content {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: 1rem;
overflow-x: auto;
font-size: 0.8rem;
font-family: var(--font-family-mono);
white-space: pre-wrap;
margin: 0;
}
.attestation-chain {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-left: 1rem;
border-left: 2px solid var(--color-border-primary);
}
.chain-step {
display: flex;
align-items: center;
gap: 0.5rem;
position: relative;
}
.chain-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-border-primary);
position: absolute;
left: -1.35rem;
}
.chain-dot--verified { background: var(--color-status-success); }
.chain-label {
font-size: 0.85rem;
font-weight: var(--font-weight-medium);
}
.chain-time {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.loading, .empty-state {
text-align: center;
padding: 2rem;
color: var(--color-text-secondary);
}
`],
})
export class RemediationFixDetailComponent implements OnInit {
private readonly api = inject(RemediationApiService);
private readonly route = inject(ActivatedRoute);
readonly fix = signal<FixTemplate | null>(null);
readonly loading = signal(true);
ngOnInit(): void {
const fixId = this.route.snapshot.paramMap.get('fixId');
if (fixId) {
this.api.getTemplate(fixId).subscribe({
next: (template) => {
this.fix.set(template);
this.loading.set(false);
},
error: () => {
this.fix.set(null);
this.loading.set(false);
},
});
} else {
this.loading.set(false);
}
}
}

View File

@@ -0,0 +1,76 @@
/**
* Remediation Fixes Badge Component
* Sprint: SPRINT_20260220_014 (REM-25)
*
* Contextual "N Available Fixes" badge for use on vulnerability detail pages.
*/
import {
Component,
ChangeDetectionStrategy,
Input,
signal,
inject,
OnChanges,
SimpleChanges,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { RemediationApiService } from './remediation.api';
@Component({
selector: 'app-remediation-fixes-badge',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (fixCount() > 0) {
<a
routerLink="/security-risk/remediation"
[queryParams]="{ cve: cveId }"
class="fixes-badge"
[attr.title]="fixCount() + ' verified fix templates available'"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
{{ fixCount() }} Available Fix{{ fixCount() === 1 ? '' : 'es' }}
</a>
}
`,
styles: [`
.fixes-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem;
border-radius: var(--radius-full);
background: rgba(34, 197, 94, 0.12);
color: var(--color-status-success);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
text-decoration: none;
transition: background 0.15s;
}
.fixes-badge:hover {
background: rgba(34, 197, 94, 0.2);
}
`],
})
export class RemediationFixesBadgeComponent implements OnChanges {
private readonly api = inject(RemediationApiService);
@Input() cveId = '';
readonly fixCount = signal(0);
ngOnChanges(changes: SimpleChanges): void {
if (changes['cveId'] && this.cveId) {
this.api.findMatches(this.cveId).subscribe({
next: (res) => this.fixCount.set(res.count),
error: () => this.fixCount.set(0),
});
}
}
}

View File

@@ -0,0 +1,341 @@
/**
* Remediation PR Submit / Status Component
* Sprint: SPRINT_20260220_014 (REM-24)
*/
import {
Component,
ChangeDetectionStrategy,
signal,
inject,
OnInit,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { RemediationApiService, PrSubmission } from './remediation.api';
interface PipelineStep {
label: string;
status: 'pending' | 'active' | 'done' | 'failed';
}
@Component({
selector: 'app-remediation-submit',
standalone: true,
imports: [RouterLink, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="remediation-submit">
<header class="submit-header">
<a routerLink="/security-risk/remediation" class="back-link">Back to Marketplace</a>
<h1 class="submit-title">{{ submission() ? 'Verification Status' : 'Submit Remediation PR' }}</h1>
</header>
@if (!submission()) {
<!-- Submit form -->
<section class="submit-form">
<div class="form-group">
<label class="form-label" for="prUrl">Pull Request URL</label>
<input
id="prUrl"
type="url"
class="form-input"
placeholder="https://github.com/org/repo/pull/123"
[ngModel]="prUrl()"
(ngModelChange)="prUrl.set($event)"
/>
</div>
<div class="form-group">
<label class="form-label" for="cveId">CVE ID</label>
<input
id="cveId"
type="text"
class="form-input"
placeholder="CVE-2024-1234"
[ngModel]="cveId()"
(ngModelChange)="cveId.set($event)"
/>
</div>
<button
class="btn btn-primary"
[disabled]="submitting()"
(click)="onSubmit()"
>
{{ submitting() ? 'Submitting...' : 'Submit for Verification' }}
</button>
@if (error()) {
<div class="form-error">{{ error() }}</div>
}
</section>
} @else {
<!-- Verification status pipeline -->
<section class="pipeline-status">
<div class="submission-info">
<div class="info-row">
<span class="info-label">PR URL</span>
<a [href]="submission()!.prUrl" target="_blank" rel="noopener" class="info-value link">
{{ submission()!.prUrl }}
</a>
</div>
<div class="info-row">
<span class="info-label">CVE</span>
<span class="info-value">{{ submission()!.cveId }}</span>
</div>
<div class="info-row">
<span class="info-label">Status</span>
<span class="info-value status" [class]="'status--' + submission()!.status">{{ submission()!.status }}</span>
</div>
@if (submission()!.verdict) {
<div class="info-row">
<span class="info-label">Verdict</span>
<span class="info-value verdict" [class]="'verdict--' + submission()!.verdict">{{ submission()!.verdict }}</span>
</div>
}
</div>
<h2 class="pipeline-title">Verification Pipeline</h2>
<div class="pipeline-timeline">
@for (step of pipelineSteps(); track step.label) {
<div class="pipeline-step" [class]="'step--' + step.status">
<div class="step-dot"></div>
<div class="step-label">{{ step.label }}</div>
</div>
}
</div>
</section>
}
</div>
`,
styles: [`
.remediation-submit {
padding: 1.5rem;
max-width: 700px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.back-link {
font-size: 0.85rem;
color: var(--color-brand-primary);
text-decoration: none;
}
.back-link:hover { text-decoration: underline; }
.submit-title {
font-size: 1.5rem;
font-weight: var(--font-weight-bold);
margin: 0.5rem 0 0;
}
.submit-form {
display: flex;
flex-direction: column;
gap: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.5rem;
background: var(--color-surface-primary);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.form-label {
font-size: 0.8rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.form-input {
padding: 0.5rem 0.75rem;
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.875rem;
}
.btn {
padding: 0.6rem 1.25rem;
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
cursor: pointer;
align-self: flex-start;
}
.btn-primary {
background: var(--color-brand-primary);
color: #fff;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-error {
color: var(--color-status-error);
font-size: 0.85rem;
}
.pipeline-status {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.submission-info {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.25rem;
background: var(--color-surface-primary);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-row {
display: flex;
gap: 0.75rem;
align-items: center;
}
.info-label {
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
min-width: 80px;
}
.info-value { font-size: 0.875rem; }
.info-value.link { color: var(--color-brand-primary); text-decoration: none; }
.status--opened { color: var(--color-status-info); }
.status--scanning { color: var(--color-status-warning); }
.status--verified { color: var(--color-status-success); }
.status--failed { color: var(--color-status-error); }
.verdict--fixed { color: var(--color-status-success); font-weight: var(--font-weight-semibold); }
.verdict--partial { color: var(--color-status-warning); }
.verdict--not_fixed { color: var(--color-status-error); }
.verdict--inconclusive { color: var(--color-text-muted); }
.pipeline-title {
font-size: 0.85rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
margin: 0;
}
.pipeline-timeline {
display: flex;
flex-direction: column;
gap: 0;
padding-left: 1rem;
border-left: 2px solid var(--color-border-primary);
}
.pipeline-step {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
position: relative;
}
.step-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--color-border-primary);
background: var(--color-surface-primary);
position: absolute;
left: -1.4rem;
}
.step--done .step-dot { background: var(--color-status-success); border-color: var(--color-status-success); }
.step--active .step-dot { background: var(--color-brand-primary); border-color: var(--color-brand-primary); }
.step--failed .step-dot { background: var(--color-status-error); border-color: var(--color-status-error); }
.step-label { font-size: 0.85rem; }
.step--done .step-label { color: var(--color-text-muted); }
.step--active .step-label { font-weight: var(--font-weight-semibold); }
.step--failed .step-label { color: var(--color-status-error); }
`],
})
export class RemediationSubmitComponent implements OnInit {
private readonly api = inject(RemediationApiService);
private readonly route = inject(ActivatedRoute);
readonly prUrl = signal('');
readonly cveId = signal('');
readonly submitting = signal(false);
readonly error = signal('');
readonly submission = signal<PrSubmission | null>(null);
readonly pipelineSteps = signal<PipelineStep[]>([]);
ngOnInit(): void {
const submissionId = this.route.snapshot.paramMap.get('submissionId');
if (submissionId) {
this.loadSubmission(submissionId);
}
}
onSubmit(): void {
const url = this.prUrl();
const cve = this.cveId();
if (!url || !cve) {
this.error.set('PR URL and CVE ID are required.');
return;
}
this.submitting.set(true);
this.error.set('');
this.api.submitPr(url, cve).subscribe({
next: (sub) => {
this.submission.set(sub);
this.pipelineSteps.set(this.buildPipelineSteps(sub.status));
this.submitting.set(false);
},
error: () => {
this.error.set('Failed to submit PR. Please try again.');
this.submitting.set(false);
},
});
}
private loadSubmission(id: string): void {
this.api.getSubmission(id).subscribe({
next: (sub) => {
this.submission.set(sub);
this.pipelineSteps.set(this.buildPipelineSteps(sub.status));
},
error: () => {
this.submission.set(null);
},
});
}
private buildPipelineSteps(status: string): PipelineStep[] {
const stages = ['opened', 'scanning', 'merged', 'verified'];
const currentIndex = stages.indexOf(status);
const failed = status === 'failed' || status === 'inconclusive';
return [
{ label: 'PR Submitted', status: currentIndex >= 0 ? 'done' : 'pending' },
{ label: 'Pre-merge Scan', status: failed && currentIndex <= 1 ? 'failed' : currentIndex >= 1 ? 'done' : currentIndex === 0 ? 'active' : 'pending' },
{ label: 'PR Merged', status: currentIndex >= 2 ? 'done' : currentIndex === 1 ? 'active' : 'pending' },
{ label: 'Post-merge Verification', status: failed && currentIndex >= 2 ? 'failed' : currentIndex >= 3 ? 'done' : currentIndex === 2 ? 'active' : 'pending' },
{ label: 'Reachability Delta Check', status: currentIndex >= 3 ? 'done' : 'pending' },
{ label: 'Fix Chain DSSE Signed', status: currentIndex >= 3 ? 'done' : 'pending' },
];
}
}

View File

@@ -0,0 +1,118 @@
/**
* Remediation Marketplace API Service
* Sprint: SPRINT_20260220_014 (REM-21)
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface FixTemplate {
id: string;
cveId: string;
purl: string;
versionRange: string;
patchContent: string;
description?: string;
contributorId?: string;
sourceId?: string;
status: string;
trustScore: number;
dsseDigest?: string;
createdAt: string;
verifiedAt?: string;
}
export interface PrSubmission {
id: string;
fixTemplateId?: string;
prUrl: string;
repositoryUrl: string;
sourceBranch: string;
targetBranch: string;
cveId: string;
status: string;
preScanDigest?: string;
postScanDigest?: string;
reachabilityDeltaDigest?: string;
fixChainDsseDigest?: string;
verdict?: string;
contributorId?: string;
createdAt: string;
mergedAt?: string;
verifiedAt?: string;
}
export interface Contributor {
id: string;
username: string;
displayName?: string;
verifiedFixes: number;
totalSubmissions: number;
trustScore: number;
trustTier: string;
}
export interface FixTemplateListResponse {
items: FixTemplate[];
count: number;
limit: number;
offset: number;
}
export interface PrSubmissionListResponse {
items: PrSubmission[];
count: number;
limit: number;
offset: number;
}
export interface MatchResponse {
items: FixTemplate[];
count: number;
}
@Injectable({ providedIn: 'root' })
export class RemediationApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/remediation';
listTemplates(cveId?: string, purl?: string): Observable<FixTemplateListResponse> {
let params = new HttpParams();
if (cveId) params = params.set('cve', cveId);
if (purl) params = params.set('purl', purl);
return this.http.get<FixTemplateListResponse>(`${this.baseUrl}/templates`, { params });
}
getTemplate(id: string): Observable<FixTemplate> {
return this.http.get<FixTemplate>(`${this.baseUrl}/templates/${id}`);
}
listSubmissions(cveId?: string): Observable<PrSubmissionListResponse> {
let params = new HttpParams();
if (cveId) params = params.set('cve', cveId);
return this.http.get<PrSubmissionListResponse>(`${this.baseUrl}/submissions`, { params });
}
getSubmission(id: string): Observable<PrSubmission> {
return this.http.get<PrSubmission>(`${this.baseUrl}/submissions/${id}`);
}
submitPr(prUrl: string, cveId: string): Observable<PrSubmission> {
return this.http.post<PrSubmission>(`${this.baseUrl}/submissions`, {
prUrl,
repositoryUrl: '',
sourceBranch: '',
targetBranch: '',
cveId,
});
}
getContributor(username: string): Observable<Contributor> {
return this.http.get<Contributor>(`${this.baseUrl}/contributors/${username}`);
}
findMatches(cveId: string): Observable<MatchResponse> {
const params = new HttpParams().set('cve', cveId);
return this.http.get<MatchResponse>(`${this.baseUrl}/match`, { params });
}
}

View File

@@ -0,0 +1,362 @@
/**
* 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 { RouterLink } from '@angular/router';
interface RiskSummaryCard {
title: string;
value: string | number;
subtext: string;
severity: 'ok' | 'warning' | 'critical' | 'info';
link: string;
linkLabel: string;
}
@Component({
selector: 'app-security-risk-overview',
standalone: true,
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>
</div>
</header>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
`,
styles: [`
.security-risk-overview {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Header */
.overview-header {
border-bottom: 1px solid var(--color-border-primary);
padding-bottom: 1.25rem;
}
.overview-title {
font-size: 1.75rem;
font-weight: var(--font-weight-bold);
margin: 0;
}
.overview-subtitle {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 0.35rem 0 0;
}
/* Card Grids */
.cards-grid {
display: grid;
gap: 1rem;
}
.primary-cards {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.secondary-cards {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
/* 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;
}
}
`],
})
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',
});
readonly findingsCard = signal<RiskSummaryCard>({
title: 'Findings',
value: 284,
subtext: '8 critical reachable findings',
severity: 'critical',
link: '/security-risk/findings',
linkLabel: 'Explore findings',
});
readonly vulnsCard = signal<RiskSummaryCard>({
title: 'Vulnerabilities',
value: 1_204,
subtext: '51 affecting prod environments',
severity: 'warning',
link: '/security-risk/vulnerabilities',
linkLabel: 'Explore vulnerabilities',
});
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 vexCard = signal<RiskSummaryCard>({
title: 'VEX Coverage',
value: '61%',
subtext: '476 CVEs awaiting VEX statement',
severity: 'warning',
link: '/security-risk/vex',
linkLabel: 'VEX hub',
});
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',
});
}

View File

@@ -0,0 +1,318 @@
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { catchError, of } from 'rxjs';
import {
SymbolCatalogEntry,
SymbolSourcesApiService,
} from './symbol-sources.api';
@Component({
selector: 'app-symbol-marketplace-catalog',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="marketplace">
<header class="header">
<div>
<h1>Symbol Marketplace</h1>
<p>
Browse and install symbol/debug packs from configured sources.
Each pack is verified for DSSE signature integrity before installation.
</p>
</div>
<button type="button" class="refresh-btn" (click)="reload()">Refresh</button>
</header>
<section class="search-bar">
<input
type="text"
placeholder="Search by PURL, platform, or component..."
[value]="searchTerm()"
(input)="onSearchInput($any($event.target).value)"
/>
</section>
@if (loading()) {
<p class="state-msg">Loading marketplace catalog...</p>
} @else if (errorMessage()) {
<div class="banner error" role="alert">{{ errorMessage() }}</div>
} @else if (filteredEntries().length === 0) {
<section class="empty">
<h2>No packs found</h2>
<p>
@if (searchTerm()) {
No packs match your search. Try a different query.
} @else {
No symbol packs available. Configure sources in
<a routerLink="/security-risk/symbol-sources">Symbol Sources</a>.
}
</p>
</section>
} @else {
<section class="catalog-grid" aria-label="Symbol pack catalog">
@for (entry of filteredEntries(); track entry.id) {
<article class="pack-card">
<div class="pack-card-header">
<h3>{{ entry.packId }}</h3>
@if (entry.installed) {
<span class="badge badge-installed">Installed</span>
}
</div>
<dl>
<dt>Version</dt><dd>{{ entry.version }}</dd>
<dt>Platform</dt><dd>{{ entry.platform }}</dd>
<dt>Size</dt><dd>{{ formatSize(entry.sizeBytes) }}</dd>
<dt>Components</dt><dd>{{ entry.components.join(', ') || 'n/a' }}</dd>
<dt>Published</dt><dd>{{ formatDate(entry.publishedAt) }}</dd>
<dt>DSSE</dt><dd class="mono">{{ entry.dsseDigest || 'unsigned' }}</dd>
</dl>
<div class="pack-card-actions">
@if (entry.installed) {
<button
type="button"
class="btn-uninstall"
(click)="uninstall(entry.id)"
[disabled]="actionInProgress()"
>
Uninstall
</button>
} @else {
<button
type="button"
class="btn-install"
(click)="install(entry.id)"
[disabled]="actionInProgress()"
>
Install
</button>
}
</div>
</article>
}
</section>
}
</section>
`,
styles: [`
.marketplace {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
display: grid;
gap: 0.9rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.8rem;
}
.header h1 { margin: 0 0 0.3rem; font-size: 1.65rem; }
.header p { margin: 0; color: var(--color-text-secondary, #667085); font-size: 0.88rem; max-width: 880px; }
.refresh-btn {
border: 1px solid var(--color-border-primary, #d0d5dd);
background: var(--color-surface-primary, #fff);
border-radius: 6px;
padding: 0.42rem 0.7rem;
cursor: pointer;
font-size: 0.78rem;
white-space: nowrap;
}
.search-bar input {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border-primary, #d0d5dd);
border-radius: 6px;
font-size: 0.84rem;
background: var(--color-surface-primary, #fff);
}
.state-msg { font-size: 0.84rem; color: var(--color-text-secondary, #667085); }
.banner.error {
border-radius: 7px;
background: #fef2f2;
border: 1px solid #f87171;
color: #991b1b;
padding: 0.62rem 0.85rem;
font-size: 0.84rem;
}
.empty {
border: 1px dashed var(--color-border-primary, #d0d5dd);
border-radius: 8px;
padding: 1rem;
background: var(--color-surface-primary, #fff);
}
.empty h2 { margin: 0 0 0.25rem; font-size: 1rem; }
.empty p { margin: 0; color: var(--color-text-secondary, #667085); font-size: 0.84rem; }
.empty a { color: var(--color-brand-primary, #2563eb); text-decoration: none; }
.catalog-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
}
.pack-card {
border: 1px solid var(--color-border-primary, #d0d5dd);
border-radius: 8px;
background: var(--color-surface-primary, #fff);
padding: 0.85rem;
display: grid;
gap: 0.5rem;
}
.pack-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pack-card-header h3 {
margin: 0;
font-size: 0.88rem;
word-break: break-all;
}
.badge {
display: inline-flex;
border-radius: 999px;
padding: 0.08rem 0.46rem;
font-size: 0.68rem;
font-weight: 600;
}
.badge-installed { background: #dcfce7; color: #166534; }
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.15rem 0.6rem;
margin: 0;
font-size: 0.78rem;
}
dt { color: var(--color-text-secondary, #667085); font-weight: 500; }
dd { margin: 0; }
.mono { font-family: var(--font-family-mono, monospace); font-size: 0.72rem; word-break: break-all; }
.pack-card-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.btn-install, .btn-uninstall {
border: 1px solid var(--color-border-primary, #d0d5dd);
border-radius: 6px;
padding: 0.35rem 0.65rem;
cursor: pointer;
font-size: 0.78rem;
}
.btn-install {
background: var(--color-brand-primary, #2563eb);
color: #fff;
border-color: var(--color-brand-primary, #2563eb);
}
.btn-uninstall {
background: var(--color-surface-primary, #fff);
color: var(--color-text-primary, #111);
}
.btn-install:disabled, .btn-uninstall:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`],
})
export class SymbolMarketplaceCatalogComponent implements OnInit {
private readonly api = inject(SymbolSourcesApiService);
readonly loading = signal(true);
readonly errorMessage = signal<string | null>(null);
readonly allEntries = signal<SymbolCatalogEntry[]>([]);
readonly searchTerm = signal('');
readonly actionInProgress = signal(false);
readonly filteredEntries = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
if (!term) return this.allEntries();
return this.allEntries().filter((e) =>
e.packId.toLowerCase().includes(term) ||
e.platform.toLowerCase().includes(term) ||
e.components.some((c) => c.toLowerCase().includes(term))
);
});
ngOnInit(): void {
this.reload();
}
reload(): void {
this.loading.set(true);
this.errorMessage.set(null);
this.api.listCatalog()
.pipe(
catchError(() => {
this.errorMessage.set('Failed to load marketplace catalog.');
this.loading.set(false);
return of(null);
})
)
.subscribe((result) => {
if (!result) return;
this.allEntries.set(result.items ?? []);
this.loading.set(false);
});
}
onSearchInput(value: string): void {
this.searchTerm.set(value ?? '');
}
install(entryId: string): void {
this.actionInProgress.set(true);
this.api.installPack(entryId)
.pipe(catchError(() => of(null)))
.subscribe(() => {
this.actionInProgress.set(false);
this.reload();
});
}
uninstall(entryId: string): void {
this.actionInProgress.set(true);
this.api.uninstallPack(entryId)
.pipe(catchError(() => of(null)))
.subscribe(() => {
this.actionInProgress.set(false);
this.reload();
});
}
formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
formatDate(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return `${parsed.getUTCFullYear()}-${String(parsed.getUTCMonth() + 1).padStart(2, '0')}-${String(
parsed.getUTCDate()
).padStart(2, '0')}`;
}
}

View File

@@ -0,0 +1,225 @@
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, of } from 'rxjs';
import {
SymbolSourceDetailResponse,
SymbolSourcesApiService,
} from './symbol-sources.api';
@Component({
selector: 'app-symbol-source-detail',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="source-detail">
<nav class="breadcrumb">
<a routerLink="/security-risk/symbol-sources">Symbol Sources</a>
<span>/</span>
<span>{{ sourceName() }}</span>
</nav>
@if (loading()) {
<p class="state-msg">Loading source detail...</p>
} @else if (errorMessage()) {
<div class="banner error" role="alert">{{ errorMessage() }}</div>
} @else if (detail()) {
<header class="detail-header">
<h1>{{ detail()!.source.sourceName }}</h1>
<span class="badge badge-{{ detail()!.source.freshnessStatus }}">
{{ detail()!.source.freshnessStatus }}
</span>
</header>
<section class="info-grid">
<div class="info-card">
<h3>Source Info</h3>
<dl>
<dt>Key</dt><dd>{{ detail()!.source.sourceKey }}</dd>
<dt>Type</dt><dd>{{ detail()!.source.sourceType }}</dd>
<dt>Priority</dt><dd>{{ detail()!.source.priority }}</dd>
<dt>URL</dt><dd>{{ detail()!.source.sourceUrl ?? 'n/a' }}</dd>
</dl>
</div>
<div class="info-card">
<h3>Freshness</h3>
<dl>
<dt>Status</dt><dd>{{ detail()!.source.freshnessStatus }}</dd>
<dt>Age</dt><dd>{{ formatDuration(detail()!.source.freshnessAgeSeconds) }}</dd>
<dt>SLA</dt><dd>{{ formatDuration(detail()!.source.freshnessSlaSeconds) }}</dd>
<dt>Last Sync</dt><dd>{{ detail()!.source.lastSyncAt ?? 'never' }}</dd>
<dt>Last Success</dt><dd>{{ detail()!.source.lastSuccessAt ?? 'never' }}</dd>
<dt>Last Error</dt><dd>{{ detail()!.source.lastError ?? 'none' }}</dd>
<dt>Syncs</dt><dd>{{ detail()!.source.syncCount }}</dd>
<dt>Errors</dt><dd>{{ detail()!.source.errorCount }}</dd>
</dl>
</div>
<div class="info-card">
<h3>Pack Coverage</h3>
<dl>
<dt>Total Packs</dt><dd>{{ detail()!.source.totalPacks }}</dd>
<dt>Signed</dt><dd>{{ detail()!.source.signedPacks }}</dd>
<dt>Unsigned</dt><dd>{{ detail()!.source.unsignedPacks }}</dd>
<dt>Signature Failures</dt><dd>{{ detail()!.source.signatureFailureCount }}</dd>
</dl>
</div>
<div class="info-card">
<h3>Trust Score</h3>
<dl>
<dt>Overall</dt><dd>{{ formatPercent(detail()!.trust.overall) }}</dd>
<dt>Freshness</dt><dd>{{ formatPercent(detail()!.trust.freshness) }}</dd>
<dt>Signature</dt><dd>{{ formatPercent(detail()!.trust.signature) }}</dd>
<dt>Coverage</dt><dd>{{ formatPercent(detail()!.trust.coverage) }}</dd>
<dt>SLA Compliance</dt><dd>{{ formatPercent(detail()!.trust.slCompliance) }}</dd>
</dl>
</div>
</section>
}
</section>
`,
styles: [`
.source-detail {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
color: var(--color-text-secondary, #667085);
}
.breadcrumb a {
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
}
.state-msg {
font-size: 0.84rem;
color: var(--color-text-secondary, #667085);
}
.banner.error {
border-radius: 7px;
background: #fef2f2;
border: 1px solid #f87171;
color: #991b1b;
padding: 0.62rem 0.85rem;
font-size: 0.84rem;
}
.detail-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.detail-header h1 {
margin: 0;
font-size: 1.5rem;
}
.badge {
display: inline-flex;
border-radius: 999px;
padding: 0.12rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: capitalize;
}
.badge-healthy { background: #dcfce7; color: #166534; }
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-stale { background: #fee2e2; color: #991b1b; }
.badge-unavailable { background: #fee2e2; color: #991b1b; }
.info-grid {
display: grid;
gap: 0.9rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.info-card {
border: 1px solid var(--color-border-primary, #d0d5dd);
border-radius: 8px;
background: var(--color-surface-primary, #fff);
padding: 0.9rem;
}
.info-card h3 {
margin: 0 0 0.5rem;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
margin: 0;
font-size: 0.82rem;
}
dt {
color: var(--color-text-secondary, #667085);
font-weight: 500;
}
dd { margin: 0; }
`],
})
export class SymbolSourceDetailComponent implements OnInit {
private readonly api = inject(SymbolSourcesApiService);
private readonly route = inject(ActivatedRoute);
readonly loading = signal(true);
readonly errorMessage = signal<string | null>(null);
readonly detail = signal<SymbolSourceDetailResponse | null>(null);
readonly sourceName = signal('Source');
ngOnInit(): void {
const sourceId = this.route.snapshot.paramMap.get('sourceId');
if (!sourceId) {
this.errorMessage.set('No source ID provided.');
this.loading.set(false);
return;
}
this.api.getSourceDetail(sourceId)
.pipe(
catchError(() => {
this.errorMessage.set('Failed to load symbol source detail.');
this.loading.set(false);
return of(null);
})
)
.subscribe((result) => {
if (!result) return;
this.detail.set(result);
this.sourceName.set(result.source.sourceName);
this.loading.set(false);
});
}
formatDuration(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
}

View File

@@ -0,0 +1,361 @@
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { forkJoin, catchError, of } from 'rxjs';
import {
SymbolSourceListItem,
SymbolSourceSummaryResponse,
SymbolSourcesApiService,
} from './symbol-sources.api';
type FreshnessState = 'healthy' | 'warning' | 'stale' | 'unavailable';
interface SourceRow {
sourceId: string;
sourceKey: string;
name: string;
sourceType: string;
lastSync: string;
freshnessAge: string;
freshnessSla: string;
freshnessStatus: FreshnessState;
signatureStatus: string;
totalPacks: number;
signedPacks: number;
errorCount: number;
}
@Component({
selector: 'app-symbol-sources-list',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="symbol-sources">
<header class="header">
<div>
<h1>Symbol Sources</h1>
<p>
Symbol/debug pack source registry. Freshness, trust scoring, and DSSE signature
coverage for all configured symbol providers.
</p>
</div>
<button type="button" class="refresh-btn" (click)="reload()">Refresh</button>
</header>
@if (loading()) {
<p class="state-msg">Loading symbol sources...</p>
} @else if (errorMessage()) {
<div class="banner error" role="alert">
Symbol source API is unavailable. Check
<a routerLink="/platform-ops/data-integrity">Platform Ops Data Integrity</a>
for service status.
</div>
} @else {
<section class="summary" aria-label="Symbol source summary cards">
<article>
<span>Total Sources</span>
<strong>{{ summaryVm().total }}</strong>
</article>
<article>
<span>Healthy</span>
<strong>{{ summaryVm().healthy }}</strong>
</article>
<article>
<span>Stale</span>
<strong>{{ summaryVm().stale }}</strong>
</article>
<article>
<span>Unavailable</span>
<strong>{{ summaryVm().unavailable }}</strong>
</article>
<article>
<span>Avg Trust Score</span>
<strong>{{ summaryVm().avgTrust }}</strong>
</article>
</section>
@if (rows().length === 0) {
<section class="empty" aria-label="No symbol sources">
<h2>No symbol sources configured</h2>
<p>Configure a symbol source integration to populate the marketplace.</p>
<a routerLink="/integrations">Open Integrations</a>
</section>
} @else {
<section class="table-wrap" aria-label="Symbol source table">
<table>
<thead>
<tr>
<th>Source name</th>
<th>Type</th>
<th>Last sync</th>
<th>Freshness age</th>
<th>Freshness SLA</th>
<th>Status</th>
<th>Signature</th>
<th>Packs (signed/total)</th>
<th>Errors</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (row of rows(); track row.sourceKey) {
<tr>
<td>{{ row.name }}</td>
<td>{{ row.sourceType }}</td>
<td>{{ row.lastSync }}</td>
<td>{{ row.freshnessAge }}</td>
<td>{{ row.freshnessSla }}</td>
<td>
<span class="badge badge-{{ row.freshnessStatus }}">{{ row.freshnessStatus }}</span>
</td>
<td>
<span class="badge trust-{{ row.signatureStatus }}">{{ row.signatureStatus }}</span>
</td>
<td>{{ row.signedPacks }} / {{ row.totalPacks }}</td>
<td>{{ row.errorCount }}</td>
<td class="actions">
<a [routerLink]="['/security-risk/symbol-sources', row.sourceId]">Detail</a>
</td>
</tr>
}
</tbody>
</table>
</section>
}
}
</section>
`,
styles: [`
.symbol-sources {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
display: grid;
gap: 0.9rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.8rem;
}
.header h1 {
margin: 0 0 0.3rem;
font-size: 1.65rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary, #667085);
font-size: 0.88rem;
max-width: 880px;
}
.refresh-btn {
border: 1px solid var(--color-border-primary, #d0d5dd);
background: var(--color-surface-primary, #fff);
border-radius: 6px;
padding: 0.42rem 0.7rem;
cursor: pointer;
font-size: 0.78rem;
white-space: nowrap;
}
.state-msg {
font-size: 0.84rem;
color: var(--color-text-secondary, #667085);
}
.banner {
border-radius: 7px;
border: 1px solid transparent;
padding: 0.62rem 0.85rem;
font-size: 0.84rem;
}
.banner.error {
background: #fef2f2;
border-color: #f87171;
color: #991b1b;
}
.summary {
display: grid;
gap: 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.summary article {
border: 1px solid var(--color-border-primary, #d0d5dd);
border-radius: 8px;
background: var(--color-surface-primary, #fff);
padding: 0.72rem;
display: grid;
gap: 0.2rem;
}
.summary span {
font-size: 0.74rem;
text-transform: uppercase;
color: var(--color-text-secondary, #667085);
letter-spacing: 0.04em;
}
.summary strong {
font-size: 1.3rem;
line-height: 1;
}
.empty {
border: 1px dashed var(--color-border-primary, #d0d5dd);
border-radius: 8px;
padding: 1rem;
background: var(--color-surface-primary, #fff);
}
.empty h2 { margin: 0 0 0.25rem; font-size: 1rem; }
.empty p { margin: 0 0 0.4rem; color: var(--color-text-secondary, #667085); font-size: 0.84rem; }
.empty a { color: var(--color-brand-primary, #2563eb); text-decoration: none; font-size: 0.84rem; }
.table-wrap {
border: 1px solid var(--color-border-primary, #d0d5dd);
border-radius: 8px;
overflow: auto;
background: var(--color-surface-primary, #fff);
}
table { width: 100%; border-collapse: collapse; min-width: 1000px; }
th, td {
text-align: left;
padding: 0.58rem 0.62rem;
border-bottom: 1px solid var(--color-border-primary, #e4e7ec);
vertical-align: top;
font-size: 0.8rem;
}
th {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
background: var(--color-surface-elevated, #f8fafc);
}
.badge {
display: inline-flex;
border-radius: 999px;
padding: 0.08rem 0.46rem;
border: 1px solid transparent;
font-size: 0.72rem;
font-weight: 600;
text-transform: capitalize;
}
.badge-healthy { background: #dcfce7; color: #166534; }
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-stale { background: #fee2e2; color: #991b1b; }
.badge-unavailable { background: #fee2e2; color: #991b1b; }
.trust-signed { background: #dcfce7; color: #166534; }
.trust-unsigned { background: #fef3c7; color: #92400e; }
.actions a {
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
font-size: 0.78rem;
}
`],
})
export class SymbolSourcesListComponent implements OnInit {
private readonly api = inject(SymbolSourcesApiService);
readonly loading = signal(true);
readonly errorMessage = signal<string | null>(null);
readonly allRows = signal<SourceRow[]>([]);
readonly serverSummary = signal<SymbolSourceSummaryResponse | null>(null);
readonly rows = computed(() => this.allRows());
readonly summaryVm = computed(() => {
const s = this.serverSummary();
return {
total: s?.totalSources ?? 0,
healthy: s?.healthySources ?? 0,
stale: s?.staleSources ?? 0,
unavailable: s?.unavailableSources ?? 0,
avgTrust: s?.averageTrustScore != null ? `${(s.averageTrustScore * 100).toFixed(1)}%` : 'n/a',
};
});
ngOnInit(): void {
this.reload();
}
reload(): void {
this.loading.set(true);
this.errorMessage.set(null);
forkJoin({
list: this.api.listSources(false),
summary: this.api.getSourceSummary(),
})
.pipe(
catchError(() => {
this.errorMessage.set('Failed to load symbol sources.');
this.loading.set(false);
return of(null);
})
)
.subscribe((result) => {
if (!result) return;
this.serverSummary.set(result.summary);
this.allRows.set((result.list.items ?? []).map((s) => this.mapRow(s)));
this.loading.set(false);
});
}
private mapRow(source: SymbolSourceListItem): SourceRow {
return {
sourceId: source.sourceId,
sourceKey: source.sourceKey,
name: source.sourceName,
sourceType: source.sourceType,
lastSync: source.lastSyncAt ? this.formatDateTime(source.lastSyncAt) : 'never',
freshnessAge: this.formatDuration(source.freshnessAgeSeconds),
freshnessSla: this.formatDuration(source.freshnessSlaSeconds),
freshnessStatus: this.mapFreshnessStatus(source.freshnessStatus),
signatureStatus: source.signatureStatus,
totalPacks: source.totalPacks,
signedPacks: source.signedPacks,
errorCount: source.errorCount,
};
}
private mapFreshnessStatus(status: string): FreshnessState {
const normalized = (status ?? '').trim().toLowerCase();
if (normalized === 'healthy' || normalized === 'warning' || normalized === 'stale' || normalized === 'unavailable') {
return normalized;
}
return 'unavailable';
}
private formatDuration(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
private formatDateTime(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return `${parsed.getUTCFullYear()}-${String(parsed.getUTCMonth() + 1).padStart(2, '0')}-${String(
parsed.getUTCDate()
).padStart(2, '0')} ${String(parsed.getUTCHours()).padStart(2, '0')}:${String(
parsed.getUTCMinutes()
).padStart(2, '0')} UTC`;
}
}

View File

@@ -0,0 +1,153 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { map, Observable } from 'rxjs';
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
export interface SymbolSourceListItem {
sourceId: string;
sourceKey: string;
sourceName: string;
sourceType: string;
sourceUrl?: string | null;
priority: number;
enabled: boolean;
lastSyncAt?: string | null;
lastSuccessAt?: string | null;
lastError?: string | null;
syncCount: number;
errorCount: number;
freshnessSlaSeconds: number;
warningRatio: number;
freshnessAgeSeconds: number;
freshnessStatus: string;
signatureStatus: string;
totalPacks: number;
signedPacks: number;
unsignedPacks: number;
signatureFailureCount: number;
}
export interface SymbolSourceListResponse {
items: SymbolSourceListItem[];
totalCount: number;
dataAsOf: string;
}
export interface SymbolSourceSummaryResponse {
totalSources: number;
healthySources: number;
warningSources: number;
staleSources: number;
unavailableSources: number;
averageTrustScore: number;
dataAsOf: string;
}
export interface SymbolSourceTrustScore {
freshness: number;
signature: number;
coverage: number;
slCompliance: number;
overall: number;
}
export interface SymbolSourceDetailResponse {
source: SymbolSourceListItem;
trust: SymbolSourceTrustScore;
dataAsOf: string;
}
export interface SymbolCatalogEntry {
id: string;
sourceId: string;
packId: string;
platform: string;
components: string[];
dsseDigest: string;
version: string;
sizeBytes: number;
installed: boolean;
publishedAt: string;
installedAt?: string | null;
}
export interface SymbolCatalogListResponse {
items: SymbolCatalogEntry[];
totalCount: number;
dataAsOf: string;
}
@Injectable({ providedIn: 'root' })
export class SymbolSourcesApiService {
private readonly http = inject(HttpClient);
private readonly authSession = inject(AuthSessionStore);
private readonly sourcesUrl = '/api/v1/symbols/sources';
private readonly marketplaceUrl = '/api/v1/symbols/marketplace';
listSources(includeDisabled = false): Observable<SymbolSourceListResponse> {
const params = new HttpParams().set('includeDisabled', String(includeDisabled));
return this.http.get<SymbolSourceListResponse>(this.sourcesUrl, {
params,
headers: this.buildHeaders(),
});
}
getSourceSummary(): Observable<SymbolSourceSummaryResponse> {
return this.http.get<SymbolSourceSummaryResponse>(`${this.sourcesUrl}/summary`, {
headers: this.buildHeaders(),
});
}
getSourceDetail(sourceId: string): Observable<SymbolSourceDetailResponse> {
return this.http.get<SymbolSourceDetailResponse>(
`${this.sourcesUrl}/${encodeURIComponent(sourceId)}`,
{ headers: this.buildHeaders() }
);
}
listCatalog(search?: string, sourceId?: string): Observable<SymbolCatalogListResponse> {
let params = new HttpParams();
if (search) {
params = params.set('search', search);
}
if (sourceId) {
params = params.set('sourceId', sourceId);
}
return this.http.get<SymbolCatalogListResponse>(this.marketplaceUrl, {
params,
headers: this.buildHeaders(),
});
}
installPack(entryId: string): Observable<void> {
return this.http
.post<void>(`${this.marketplaceUrl}/${encodeURIComponent(entryId)}/install`, null, {
headers: this.buildHeaders(),
});
}
uninstallPack(entryId: string): Observable<void> {
return this.http
.post<void>(`${this.marketplaceUrl}/${encodeURIComponent(entryId)}/uninstall`, null, {
headers: this.buildHeaders(),
});
}
listInstalled(): Observable<SymbolCatalogListResponse> {
return this.http.get<SymbolCatalogListResponse>(`${this.marketplaceUrl}/installed`, {
headers: this.buildHeaders(),
});
}
private buildHeaders(): HttpHeaders {
const tenantId = this.authSession.getActiveTenantId();
if (!tenantId) {
return new HttpHeaders();
}
return new HttpHeaders({
'X-Stella-Tenant': tenantId,
'X-Tenant-Id': tenantId,
'X-StellaOps-Tenant': tenantId,
});
}
}

View File

@@ -36,14 +36,22 @@ export interface NavSection {
/**
* AppSidebarComponent - Left navigation rail.
*
* Navigation structure:
* - CONTROL PLANE (default landing)
* - RELEASES
* - APPROVALS
* - SECURITY
* - EVIDENCE
* - OPERATIONS
* - SETTINGS
* 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.
*/
@Component({
selector: 'app-sidebar',
@@ -317,96 +325,171 @@ export class AppSidebarComponent implements OnDestroy {
readonly hoverExpanded = signal(false);
private hoverTimer: ReturnType<typeof setTimeout> | null = null;
/** Track which groups are expanded */
readonly expandedGroups = signal<Set<string>>(new Set(['security']));
/** Track which groups are expanded — default open: Release Control, Security and Risk */
readonly expandedGroups = signal<Set<string>>(new Set(['release-control', 'security-risk']));
/** Navigation sections */
/**
* 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.
*/
readonly navSections: NavSection[] = [
// 1. Dashboard
{
id: 'control-plane',
label: 'Control Plane',
id: 'dashboard',
label: 'Dashboard',
icon: 'dashboard',
route: '/',
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: 'releases',
label: 'Releases',
id: 'release-control',
label: 'Release Control',
icon: 'package',
route: '/releases',
route: '/release-control',
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/environments',
icon: 'server',
},
{
id: 'rc-setup',
label: 'Setup',
route: '/release-control/setup',
icon: 'settings',
},
],
},
// 3. Security and Risk
{
id: 'approvals',
label: 'Approvals',
icon: 'check-circle',
route: '/approvals',
badge$: () => 3, // TODO: Wire to actual pending approvals count
},
{
id: 'security',
label: 'Security',
id: 'security-risk',
label: 'Security and Risk',
icon: 'shield',
route: '/security',
route: '/security-risk',
children: [
{ id: 'security-overview', label: 'Overview', route: '/security', icon: 'chart' },
{ id: 'security-findings', label: 'Findings', route: '/security/findings', icon: 'list' },
{ id: 'security-vulnerabilities', label: 'Vulnerabilities', route: '/security/vulnerabilities', icon: 'alert' },
{ id: 'security-sbom', label: 'SBOM Graph', route: '/security/sbom', icon: 'graph' },
{ id: 'security-vex', label: 'VEX Hub', route: '/security/vex', icon: 'file-check' },
{ id: 'security-exceptions', label: 'Exceptions', route: '/security/exceptions', icon: 'x-circle' },
{ id: 'sr-overview', label: 'Overview', route: '/security-risk', icon: 'chart' },
{ id: 'sr-findings', label: 'Findings', route: '/security-risk/findings', icon: 'list' },
{ id: 'sr-vulnerabilities', label: 'Vulnerabilities', route: '/security-risk/vulnerabilities', icon: 'alert' },
{ id: 'sr-reachability', label: 'Reachability', route: '/security-risk/reachability', icon: 'git-branch' },
{ id: 'sr-sbom', label: 'SBOM Graph', route: '/security-risk/sbom', icon: 'graph' },
{ id: 'sr-vex', label: 'VEX Hub', route: '/security-risk/vex', icon: 'file-check' },
{ id: 'sr-advisory-sources', label: 'Advisory Sources', route: '/security-risk/advisory-sources', icon: 'radio' },
{ 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' },
],
},
// 4. Evidence and Audit
{
id: 'analytics',
label: 'Analytics',
icon: 'bar-chart',
route: '/analytics',
requiredScopes: [StellaOpsScopes.UI_READ, StellaOpsScopes.ANALYTICS_READ],
children: [
{ id: 'analytics-sbom-lake', label: 'SBOM Lake', route: '/analytics/sbom-lake', icon: 'chart' },
],
},
{
id: 'evidence',
label: 'Evidence',
id: 'evidence-audit',
label: 'Evidence and Audit',
icon: 'file-text',
route: '/evidence',
route: '/evidence-audit',
children: [
{ id: 'evidence-packets', label: 'Packets', route: '/evidence', icon: 'archive' },
{ id: 'evidence-proof-chains', label: 'Proof Chains', route: '/evidence/proof-chains', icon: 'link' },
{ id: 'evidence-replay', label: 'Replay/Verify', route: '/evidence/replay', icon: 'refresh' },
{ id: 'evidence-export', label: 'Export', route: '/evidence/export', icon: 'download' },
{ id: 'ea-packets', label: 'Evidence Packets', route: '/evidence-audit', icon: 'archive' },
{ id: 'ea-proof-chains', label: 'Proof Chains', route: '/evidence-audit/proofs', icon: 'link' },
{ id: 'ea-audit', label: 'Audit Log', route: '/evidence-audit/audit', 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' },
],
},
// 5. Integrations (already canonical root domain — no rename needed)
{
id: 'operations',
label: 'Operations',
id: 'integrations',
label: 'Integrations',
icon: 'plug',
route: '/integrations',
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', 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/hosts', icon: 'package' },
{ id: 'int-feeds', label: 'Feeds', route: '/integrations/feeds', icon: 'rss' },
],
},
// 6. Platform Ops (formerly Operations + transition label during alias window)
{
id: 'platform-ops',
label: 'Platform Ops',
icon: 'settings',
route: '/operations',
route: '/platform-ops',
children: [
{ id: 'ops-orchestrator', label: 'Orchestrator', route: '/operations/orchestrator', icon: 'play' },
{ id: 'ops-scheduler', label: 'Scheduler', route: '/operations/scheduler', icon: 'clock' },
{ id: 'ops-quotas', label: 'Quotas', route: '/operations/quotas', icon: 'bar-chart' },
{ id: 'ops-deadletter', label: 'Dead Letter', route: '/operations/dead-letter', icon: 'inbox' },
{ id: 'ops-health', label: 'Platform Health', route: '/operations/health', icon: 'activity' },
{ id: 'ops-feeds', label: 'Feeds', route: '/operations/feeds', icon: 'rss' },
{ 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: 'settings',
label: 'Settings',
id: 'administration',
label: 'Administration',
icon: 'cog',
route: '/settings',
route: '/administration',
children: [
{ id: 'settings-integrations', label: 'Integrations', route: '/settings/integrations', icon: 'plug' },
{ id: 'settings-release-control', label: 'Release Control', route: '/settings/release-control', icon: 'rocket' },
{ id: 'settings-trust', label: 'Trust & Signing', route: '/settings/trust', icon: 'key' },
{ id: 'settings-security-data', label: 'Security Data', route: '/settings/security-data', icon: 'shield' },
{ id: 'settings-admin', label: 'Identity & Access', route: '/settings/admin', icon: 'users' },
{ id: 'settings-branding', label: 'Tenant / Branding', route: '/settings/branding', icon: 'palette' },
{ id: 'settings-usage', label: 'Usage & Limits', route: '/settings/usage', icon: 'chart' },
{ id: 'settings-notifications', label: 'Notifications', route: '/settings/notifications', icon: 'bell' },
{ id: 'settings-policy', label: 'Policy Governance', route: '/settings/policy', icon: 'book' },
{ id: 'settings-system', label: 'System', route: '/settings/system', icon: 'settings' },
{ 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-trust', label: 'Trust & Signing', route: '/administration/trust-signing', icon: 'key' },
{ id: 'adm-system', label: 'System', route: '/administration/system', icon: 'terminal' },
],
},
];

View File

@@ -0,0 +1,287 @@
/**
* Administration Domain Routes
* Sprint: SPRINT_20260218_007_FE_ui_v2_rewire_administration_foundation (A2-01 through A2-05)
*
* Canonical Administration IA v2 route tree.
* Sub-area ownership per docs/modules/ui/v2-rewire/source-of-truth.md:
* A0 Overview / shell
* A1 Identity & Access (IAM)
* A2 Tenant & Branding
* A3 Notifications
* A4 Usage & Limits
* A5 Policy Governance (Administration-owned; RC is a consumer)
* A6 Trust & Signing
* A7 System
*
* Legacy /settings/* paths continue to resolve via app.routes.ts V1 alias entries
* until SPRINT_20260218_016 cutover; this file owns the /administration/* canonical paths.
*/
import { Routes } from '@angular/router';
export const ADMINISTRATION_ROUTES: Routes = [
// A0 — Administration overview
{
path: '',
data: { breadcrumb: 'Administration' },
loadComponent: () =>
import('../features/administration/administration-overview.component').then(
(m) => m.AdministrationOverviewComponent
),
},
// A1 — Identity & Access (IAM)
{
path: 'identity-access',
title: 'Identity & Access',
data: { breadcrumb: 'Identity & Access' },
loadComponent: () =>
import('../features/settings/admin/admin-settings-page.component').then(
(m) => m.AdminSettingsPageComponent
),
},
{
path: 'identity-access/:page',
title: 'Identity & Access',
data: { breadcrumb: 'Identity & Access' },
loadComponent: () =>
import('../features/settings/admin/admin-settings-page.component').then(
(m) => m.AdminSettingsPageComponent
),
},
// Profile sub-path (formerly /console/profile)
{
path: 'profile',
title: 'Profile',
data: { breadcrumb: 'Profile' },
loadComponent: () =>
import('../features/console/console-profile.component').then(
(m) => m.ConsoleProfileComponent
),
},
// Admin sub-paths (formerly /admin/:page, /console/admin/:page)
{
path: 'admin',
title: 'Administration',
data: { breadcrumb: 'Administration' },
loadComponent: () =>
import('../features/settings/admin/admin-settings-page.component').then(
(m) => m.AdminSettingsPageComponent
),
},
{
path: 'admin/:page',
title: 'Administration',
data: { breadcrumb: 'Administration' },
loadComponent: () =>
import('../features/settings/admin/admin-settings-page.component').then(
(m) => m.AdminSettingsPageComponent
),
},
// A2 — Tenant & Branding
{
path: 'tenant-branding',
title: 'Tenant & Branding',
data: { breadcrumb: 'Tenant & Branding' },
loadComponent: () =>
import('../features/settings/branding/branding-settings-page.component').then(
(m) => m.BrandingSettingsPageComponent
),
},
// A3 — Notifications
{
path: 'notifications',
title: 'Notifications',
data: { breadcrumb: 'Notifications' },
loadChildren: () =>
import('../features/admin-notifications/admin-notifications.routes').then(
(m) => m.adminNotificationsRoutes
),
},
// A4 — Usage & Limits
{
path: 'usage',
title: 'Usage & Limits',
data: { breadcrumb: 'Usage & Limits' },
loadComponent: () =>
import('../features/settings/usage/usage-settings-page.component').then(
(m) => m.UsageSettingsPageComponent
),
},
// A5 — Policy Governance (Administration-owned)
{
path: 'policy-governance',
title: 'Policy Governance',
data: { breadcrumb: 'Policy Governance' },
loadChildren: () =>
import('../features/policy-governance/policy-governance.routes').then(
(m) => m.policyGovernanceRoutes
),
},
{
path: 'policy',
title: 'Policy Governance',
data: { breadcrumb: 'Policy Governance' },
loadComponent: () =>
import('../features/settings/policy/policy-governance-settings-page.component').then(
(m) => m.PolicyGovernanceSettingsPageComponent
),
},
{
path: 'policy/packs',
title: 'Policy Packs',
data: { breadcrumb: 'Policy Packs' },
loadComponent: () =>
import('../features/policy-studio/workspace/policy-workspace.component').then(
(m) => m.PolicyWorkspaceComponent
),
},
{
path: 'policy/exceptions',
title: 'Exceptions',
data: { breadcrumb: 'Exceptions' },
loadComponent: () =>
import('../features/triage/triage-artifacts.component').then(
(m) => m.TriageArtifactsComponent
),
},
{
path: 'policy/exceptions/:id',
title: 'Exception Detail',
data: { breadcrumb: 'Exception Detail' },
loadComponent: () =>
import('../features/triage/triage-workspace.component').then(
(m) => m.TriageWorkspaceComponent
),
},
{
path: 'policy/packs/:packId',
title: 'Policy Pack',
data: { breadcrumb: 'Policy Pack' },
loadComponent: () =>
import('../features/policy-studio/workspace/policy-workspace.component').then(
(m) => m.PolicyWorkspaceComponent
),
},
{
path: 'policy/packs/:packId/:page',
title: 'Policy Pack',
data: { breadcrumb: 'Policy Pack' },
loadComponent: () =>
import('../features/policy-studio/workspace/policy-workspace.component').then(
(m) => m.PolicyWorkspaceComponent
),
},
{
path: 'policy/governance',
title: 'Policy Governance',
data: { breadcrumb: 'Policy Governance' },
loadChildren: () =>
import('../features/policy-governance/policy-governance.routes').then(
(m) => m.policyGovernanceRoutes
),
},
// A6 — Trust & Signing
{
path: 'trust-signing',
title: 'Trust & Signing',
data: { breadcrumb: 'Trust & Signing' },
loadChildren: () =>
import('../features/trust-admin/trust-admin.routes').then(
(m) => m.trustAdminRoutes
),
},
// Legacy trust sub-paths (formerly /admin/trust/*)
{
path: 'trust',
title: 'Trust & Signing',
data: { breadcrumb: 'Trust & Signing' },
loadComponent: () =>
import('../features/settings/trust/trust-settings-page.component').then(
(m) => m.TrustSettingsPageComponent
),
},
{
path: 'trust/:page',
title: 'Trust & Signing',
data: { breadcrumb: 'Trust & Signing' },
loadComponent: () =>
import('../features/settings/trust/trust-settings-page.component').then(
(m) => m.TrustSettingsPageComponent
),
},
{
path: 'trust/issuers',
title: 'Issuers',
data: { breadcrumb: 'Issuers' },
loadChildren: () =>
import('../features/issuer-trust/issuer-trust.routes').then(
(m) => m.issuerTrustRoutes
),
},
// A7 — System
{
path: 'system',
title: 'System',
data: { breadcrumb: 'System' },
loadComponent: () =>
import('../features/settings/system/system-settings-page.component').then(
(m) => m.SystemSettingsPageComponent
),
},
// Configuration pane (formerly /console/configuration)
{
path: 'configuration-pane',
title: 'Configuration',
data: { breadcrumb: 'Configuration' },
loadChildren: () =>
import('../features/configuration-pane/configuration-pane.routes').then(
(m) => m.CONFIGURATION_PANE_ROUTES
),
},
// Security Data settings (A7 diagnostic sub-path)
{
path: 'security-data',
title: 'Security Data',
data: { breadcrumb: 'Security Data' },
loadComponent: () =>
import('../features/settings/security-data/security-data-settings-page.component').then(
(m) => m.SecurityDataSettingsPageComponent
),
},
{
path: 'security-data/trivy',
title: 'Trivy DB Settings',
data: { breadcrumb: 'Trivy DB' },
loadComponent: () =>
import('../features/trivy-db-settings/trivy-db-settings-page.component').then(
(m) => m.TrivyDbSettingsPageComponent
),
},
// Workflows (formerly /release-orchestrator/workflows)
{
path: 'workflows',
title: 'Workflows',
data: { breadcrumb: 'Workflows' },
loadChildren: () =>
import('../features/release-orchestrator/workflows/workflows.routes').then(
(m) => m.WORKFLOW_ROUTES
),
},
// AI Preferences
{
path: 'ai-preferences',
title: 'AI Preferences',
data: { breadcrumb: 'AI Preferences' },
loadComponent: () =>
import('../features/settings/ai-preferences-workbench.component').then(
(m) => m.AiPreferencesWorkbenchComponent
),
},
];

View File

@@ -0,0 +1,17 @@
/**
* Dashboard Domain Routes
* Sprint: SPRINT_20260218_012_FE_ui_v2_rewire_dashboard_v3_mission_board (D7-01 through D7-05)
*/
import { Routes } from '@angular/router';
export const DASHBOARD_ROUTES: Routes = [
{
path: '',
title: 'Dashboard',
data: { breadcrumb: 'Dashboard' },
loadComponent: () =>
import('../features/dashboard-v3/dashboard-v3.component').then(
(m) => m.DashboardV3Component
),
},
];

View File

@@ -0,0 +1,30 @@
/**
* Evidence & Audit Domain Routes
* Sprint: SPRINT_20260218_015_FE_ui_v2_rewire_evidence_audit_consolidation (V10-01 through V10-05)
*/
import { Routes } from '@angular/router';
export const EVIDENCE_AUDIT_ROUTES: Routes = [
{ path: '', title: 'Evidence and Audit', data: { breadcrumb: 'Evidence and Audit' },
loadComponent: () => import('../features/evidence-audit/evidence-audit-overview.component').then(m => m.EvidenceAuditOverviewComponent) },
{ path: 'packs', title: 'Evidence Packs', data: { breadcrumb: 'Evidence Packs' },
loadComponent: () => import('../features/evidence-pack/evidence-pack-list.component').then(m => m.EvidencePackListComponent) },
{ path: 'packs/:packId', title: 'Evidence Pack', data: { breadcrumb: 'Evidence Pack' },
loadComponent: () => import('../features/evidence-pack/evidence-pack-viewer.component').then(m => m.EvidencePackViewerComponent) },
{ path: 'proofs', title: 'Proof Chains', data: { breadcrumb: 'Proof Chains' },
loadComponent: () => import('../features/proof-chain/proof-chain.component').then(m => m.ProofChainComponent) },
{ path: 'proofs/:subjectDigest', title: 'Proof Chain', data: { breadcrumb: 'Proof Chain' },
loadComponent: () => import('../features/proof-chain/proof-chain.component').then(m => m.ProofChainComponent) },
{ path: 'timeline', title: 'Timeline', data: { breadcrumb: 'Timeline' },
loadChildren: () => import('../features/timeline/timeline.routes').then(m => m.TIMELINE_ROUTES) },
{ path: 'replay', title: 'Replay / Verify', data: { breadcrumb: 'Replay / Verify' },
loadComponent: () => import('../features/evidence-export/replay-controls.component').then(m => m.ReplayControlsComponent) },
{ path: 'receipts/cvss/:receiptId', title: 'CVSS Receipt', data: { breadcrumb: 'CVSS Receipt' },
loadComponent: () => import('../features/cvss/cvss-receipt.component').then(m => m.CvssReceiptComponent) },
{ path: 'audit', title: 'Audit Log', data: { breadcrumb: 'Audit Log' },
loadChildren: () => import('../features/audit-log/audit-log.routes').then(m => m.auditLogRoutes) },
{ path: 'change-trace', title: 'Change Trace', data: { breadcrumb: 'Change Trace' },
loadChildren: () => import('../features/change-trace/change-trace.routes').then(m => m.changeTraceRoutes) },
{ path: 'evidence', title: 'Evidence', data: { breadcrumb: 'Evidence' },
loadChildren: () => import('../features/evidence-export/evidence-export.routes').then(m => m.evidenceExportRoutes) },
];

View File

@@ -1,8 +1,14 @@
/**
* Legacy Route Redirects
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (ROUTE-001)
* Updated: SPRINT_20260218_006_FE_ui_v2_rewire_navigation_shell_route_migration (N1-04)
*
* Comprehensive redirect configuration for all legacy routes.
* Redirects pre-v1 paths to v2 canonical domain paths per:
* docs/modules/ui/v2-rewire/S00_route_deprecation_map.md
*
* v1 alias routes (/releases, /security, /operations, /settings, etc.) are kept
* as active loadChildren entries in app.routes.ts and are NOT redirected here.
* They will be converted to redirects at SPRINT_20260218_016 cutover.
*/
import { RedirectFunction, Routes } from '@angular/router';
@@ -46,137 +52,134 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// ===========================================
// Home & Dashboard
// ===========================================
{ path: 'dashboard/sources', redirectTo: '/operations/feeds', pathMatch: 'full' },
{ path: 'dashboard/sources', redirectTo: '/platform-ops/feeds', pathMatch: 'full' },
{ path: 'home', redirectTo: '/', pathMatch: 'full' },
// ===========================================
// Analyze -> Security
// Analyze -> Security & Risk
// ===========================================
{ path: 'findings', redirectTo: '/security/findings', pathMatch: 'full' },
{ path: 'findings/:scanId', redirectTo: '/security/scans/:scanId', pathMatch: 'full' },
{ path: 'scans/:scanId', redirectTo: '/security/scans/:scanId', pathMatch: 'full' },
{ path: 'vulnerabilities', redirectTo: '/security/vulnerabilities', pathMatch: 'full' },
{ path: 'vulnerabilities/:vulnId', redirectTo: '/security/vulnerabilities/:vulnId', pathMatch: 'full' },
{ path: 'graph', redirectTo: '/security/sbom/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/reachability', pathMatch: 'full' },
{ path: 'analyze/unknowns', redirectTo: '/security/unknowns', pathMatch: 'full' },
{ path: 'analyze/patch-map', redirectTo: '/security/patch-map', pathMatch: 'full' },
{ path: 'cvss/receipts/:receiptId', redirectTo: '/evidence/receipts/cvss/:receiptId', pathMatch: 'full' },
{ path: 'findings', redirectTo: '/security-risk/findings', pathMatch: 'full' },
{ path: 'findings/:scanId', redirectTo: '/security-risk/scans/:scanId', 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: 'cvss/receipts/:receiptId', redirectTo: '/evidence-audit/receipts/cvss/:receiptId', pathMatch: 'full' },
// ===========================================
// Triage -> Security + Policy
// Triage -> Security & Risk + Administration
// ===========================================
{ path: 'triage/artifacts', redirectTo: '/security/artifacts', pathMatch: 'full' },
{ path: 'triage/artifacts/:artifactId', redirectTo: '/security/artifacts/:artifactId', pathMatch: 'full' },
{ path: 'triage/audit-bundles', redirectTo: '/evidence', pathMatch: 'full' },
{ path: 'triage/audit-bundles/new', redirectTo: '/evidence', pathMatch: 'full' },
{ path: 'exceptions', redirectTo: '/policy/exceptions', pathMatch: 'full' },
{ path: 'exceptions/:id', redirectTo: '/policy/exceptions/:id', pathMatch: 'full' },
{ path: 'risk', redirectTo: '/security/risk', pathMatch: 'full' },
{ 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: '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' },
// ===========================================
// Policy Studio -> Policy
// Policy Studio -> Administration
// ===========================================
{ path: 'policy-studio/packs', redirectTo: '/policy/packs', pathMatch: 'full' },
{ path: 'policy-studio/packs/:packId', redirectTo: '/policy/packs/:packId', pathMatch: 'full' },
{ path: 'policy-studio/packs/:packId/:page', redirectTo: '/policy/packs/:packId/:page', pathMatch: 'full' },
{ path: 'policy-studio/packs', redirectTo: '/administration/policy/packs', pathMatch: 'full' },
{ path: 'policy-studio/packs/:packId', redirectTo: '/administration/policy/packs/:packId', pathMatch: 'full' },
{ path: 'policy-studio/packs/:packId/:page', redirectTo: '/administration/policy/packs/:packId/:page', pathMatch: 'full' },
// ===========================================
// VEX Hub -> Security
// VEX Hub -> Security & Risk
// ===========================================
{ 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' },
{ 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' },
// ===========================================
// Orchestrator -> Operations
// Orchestrator -> Platform Ops
// ===========================================
{ path: 'orchestrator', redirectTo: '/operations/orchestrator', pathMatch: 'full' },
{ path: 'orchestrator/:page', redirectTo: '/operations/orchestrator/:page', pathMatch: 'full' },
{ path: 'scheduler/:page', redirectTo: '/operations/scheduler/:page', pathMatch: 'full' },
{ 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' },
// ===========================================
// Ops -> Operations
// Ops -> Platform Ops
// ===========================================
{ path: 'ops/quotas', redirectTo: '/operations/quotas', pathMatch: 'full' },
{ path: 'ops/quotas/:page', redirectTo: '/operations/quotas/:page', pathMatch: 'full' },
{ path: 'ops/orchestrator/dead-letter', redirectTo: '/operations/dead-letter', pathMatch: 'full' },
{ path: 'ops/orchestrator/slo', redirectTo: '/operations/slo', pathMatch: 'full' },
{ path: 'ops/health', redirectTo: '/operations/health', pathMatch: 'full' },
{ path: 'ops/feeds', redirectTo: '/operations/feeds', pathMatch: 'full' },
{ path: 'ops/feeds/:page', redirectTo: '/operations/feeds/:page', pathMatch: 'full' },
{ path: 'ops/offline-kit', redirectTo: '/operations/offline-kit', pathMatch: 'full' },
{ path: 'ops/aoc', redirectTo: '/operations/aoc', pathMatch: 'full' },
{ path: 'ops/doctor', redirectTo: '/operations/doctor', pathMatch: 'full' },
{ 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' },
// ===========================================
// Console -> Settings
// Console -> Administration
// ===========================================
{ path: 'console/profile', redirectTo: '/settings/profile', pathMatch: 'full' },
{ path: 'console/status', redirectTo: '/operations/status', pathMatch: 'full' },
{ path: 'console/configuration', redirectTo: '/settings/configuration-pane', pathMatch: 'full' },
{ path: 'console/admin/tenants', redirectTo: '/settings/admin/tenants', pathMatch: 'full' },
{ path: 'console/admin/users', redirectTo: '/settings/admin/users', pathMatch: 'full' },
{ path: 'console/admin/roles', redirectTo: '/settings/admin/roles', pathMatch: 'full' },
{ path: 'console/admin/clients', redirectTo: '/settings/admin/clients', pathMatch: 'full' },
{ path: 'console/admin/tokens', redirectTo: '/settings/admin/tokens', pathMatch: 'full' },
{ path: 'console/admin/branding', redirectTo: '/settings/admin/branding', pathMatch: 'full' },
{ path: 'console/admin/:page', redirectTo: '/settings/admin/:page', pathMatch: 'full' },
{ path: 'console/profile', redirectTo: '/administration/profile', 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' },
{ path: 'console/admin/roles', redirectTo: '/administration/admin/roles', pathMatch: 'full' },
{ path: 'console/admin/clients', redirectTo: '/administration/admin/clients', pathMatch: 'full' },
{ path: 'console/admin/tokens', redirectTo: '/administration/admin/tokens', pathMatch: 'full' },
{ path: 'console/admin/branding', redirectTo: '/administration/admin/branding', pathMatch: 'full' },
{ path: 'console/admin/:page', redirectTo: '/administration/admin/:page', pathMatch: 'full' },
// ===========================================
// Admin -> Settings
// Admin -> Administration
// ===========================================
{ path: 'admin/trust', redirectTo: '/settings/trust', pathMatch: 'full' },
{ path: 'admin/trust/:page', redirectTo: '/settings/trust/:page', pathMatch: 'full' },
{ path: 'admin/registries', redirectTo: '/settings/integrations/registries', pathMatch: 'full' },
{ path: 'admin/issuers', redirectTo: '/settings/trust/issuers', pathMatch: 'full' },
{ path: 'admin/notifications', redirectTo: '/settings/notifications', pathMatch: 'full' },
{ path: 'admin/audit', redirectTo: '/evidence/audit', pathMatch: 'full' },
{ path: 'admin/policy/governance', redirectTo: '/policy/governance', pathMatch: 'full' },
{ path: 'concelier/trivy-db-settings', redirectTo: '/settings/security-data/trivy', pathMatch: 'full' },
{ 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/notifications', redirectTo: '/administration/notifications', pathMatch: 'full' },
{ path: 'admin/audit', redirectTo: '/evidence-audit/audit', pathMatch: 'full' },
{ path: 'admin/policy/governance', redirectTo: '/administration/policy/governance', pathMatch: 'full' },
{ path: 'concelier/trivy-db-settings', redirectTo: '/administration/security-data/trivy', pathMatch: 'full' },
// ===========================================
// Integrations -> Settings
// Integrations -> Integrations
// ===========================================
{ path: 'integrations', redirectTo: '/settings/integrations', pathMatch: 'full' },
{ path: 'integrations/activity', redirectTo: '/settings/integrations/activity', pathMatch: 'full' },
{ path: 'integrations/:id', redirectTo: '/settings/integrations/:id', pathMatch: 'full' },
{ path: 'sbom-sources', redirectTo: '/settings/sbom-sources', pathMatch: 'full' },
{ path: 'sbom-sources', redirectTo: '/integrations/sbom-sources', pathMatch: 'full' },
// ===========================================
// Release Orchestrator -> Root
// Release Orchestrator -> Release Control
// ===========================================
{ path: 'release-orchestrator', redirectTo: '/', pathMatch: 'full' },
{ path: 'release-orchestrator/environments', redirectTo: '/environments', pathMatch: 'full' },
{ path: 'release-orchestrator/releases', redirectTo: '/releases', pathMatch: 'full' },
{ path: 'release-orchestrator/approvals', redirectTo: '/approvals', pathMatch: 'full' },
{ path: 'release-orchestrator/deployments', redirectTo: '/deployments', pathMatch: 'full' },
{ path: 'release-orchestrator/workflows', redirectTo: '/settings/workflows', pathMatch: 'full' },
{ path: 'release-orchestrator/evidence', redirectTo: '/evidence', pathMatch: 'full' },
{ path: 'release-orchestrator/environments', redirectTo: '/release-control/environments', 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' },
// ===========================================
// Evidence
// Evidence -> Evidence & Audit
// ===========================================
{ path: 'evidence-packs', redirectTo: '/evidence/packs', pathMatch: 'full' },
{ path: 'evidence-packs/:packId', redirectTo: '/evidence/packs/:packId', pathMatch: 'full' },
{ path: 'evidence-packs', redirectTo: '/evidence-audit/packs', pathMatch: 'full' },
{ path: 'evidence-packs/:packId', redirectTo: '/evidence-audit/packs/:packId', pathMatch: 'full' },
// Keep /proofs/* as permanent short alias for convenience
{ path: 'proofs/:subjectDigest', redirectTo: '/evidence/proofs/:subjectDigest', pathMatch: 'full' },
{ path: 'proofs/:subjectDigest', redirectTo: '/evidence-audit/proofs/:subjectDigest', pathMatch: 'full' },
// ===========================================
// Other
// ===========================================
{ path: 'ai-runs', redirectTo: '/operations/ai-runs', pathMatch: 'full' },
{ path: 'ai-runs/:runId', redirectTo: '/operations/ai-runs/:runId', pathMatch: 'full' },
{ path: 'change-trace', redirectTo: '/evidence/change-trace', pathMatch: 'full' },
{ path: 'notify', redirectTo: '/operations/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/change-trace', 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,309 @@
/**
* Platform Ops Domain Routes
* Sprint: SPRINT_20260218_008_FE_ui_v2_rewire_integrations_platform_ops_data_integrity (I3-02, I3-04)
*
* Canonical Platform Ops IA v2 route tree.
* Sub-area ownership per docs/modules/ui/v2-rewire/source-of-truth.md:
* P0 Overview
* P1 Orchestrator & Jobs
* P2 Scheduler
* P3 Quotas & Limits
* P4 Feeds & Mirrors
* P5 Offline Kit & AirGap
* P6 Data Integrity (feeds freshness, scan health, DLQ, SLOs)
* P7 Health & Diagnostics
* P8 AOC Compliance
* P9 Agents & Signals
*
* Security Data: connectivity/freshness is owned here; decision impact consumed by Security & Risk.
*/
import { Routes } from '@angular/router';
import {
requireOrchViewerGuard,
requireOrchOperatorGuard,
} from '../core/auth';
export const PLATFORM_OPS_ROUTES: Routes = [
// P0 — Platform Ops overview
{
path: '',
title: 'Platform Ops',
data: { breadcrumb: 'Platform Ops' },
loadComponent: () =>
import('../features/platform-ops/platform-ops-overview.component').then(
(m) => m.PlatformOpsOverviewComponent
),
},
// P1 — Orchestrator & Jobs
{
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
),
},
// P2 — Scheduler
{
path: 'scheduler',
title: 'Scheduler',
data: { breadcrumb: 'Scheduler' },
loadChildren: () =>
import('../features/scheduler-ops/scheduler-ops.routes').then(
(m) => m.schedulerOpsRoutes
),
},
{
path: 'scheduler/:page',
title: 'Scheduler',
data: { breadcrumb: 'Scheduler' },
loadChildren: () =>
import('../features/scheduler-ops/scheduler-ops.routes').then(
(m) => m.schedulerOpsRoutes
),
},
// P3 — Quotas
{
path: 'quotas',
title: 'Quotas & Limits',
data: { breadcrumb: 'Quotas & Limits' },
loadChildren: () =>
import('../features/quota-dashboard/quota.routes').then((m) => m.quotaRoutes),
},
{
path: 'quotas/:page',
title: 'Quotas & Limits',
data: { breadcrumb: 'Quotas & Limits' },
loadChildren: () =>
import('../features/quota-dashboard/quota.routes').then((m) => m.quotaRoutes),
},
// P4 — Feeds & Mirrors
{
path: 'feeds',
title: 'Feeds & Mirrors',
data: { breadcrumb: 'Feeds & Mirrors' },
loadChildren: () =>
import('../features/feed-mirror/feed-mirror.routes').then((m) => m.feedMirrorRoutes),
},
{
path: 'feeds/:page',
title: 'Feeds & Mirrors',
data: { breadcrumb: 'Feeds & Mirrors' },
loadChildren: () =>
import('../features/feed-mirror/feed-mirror.routes').then((m) => m.feedMirrorRoutes),
},
// P5 — Offline Kit & AirGap
{
path: 'offline-kit',
title: 'Offline Kit',
data: { breadcrumb: 'Offline Kit' },
loadChildren: () =>
import('../features/offline-kit/offline-kit.routes').then((m) => m.offlineKitRoutes),
},
// P6 — Data Integrity (feeds freshness, scan pipeline health, DLQ, SLOs)
{
path: 'data-integrity',
title: 'Data Integrity',
data: { breadcrumb: 'Data Integrity' },
loadComponent: () =>
import('../features/platform-ops/data-integrity-overview.component').then(
(m) => m.DataIntegrityOverviewComponent
),
},
{
path: 'dead-letter',
title: 'Dead-Letter Queue',
data: { breadcrumb: 'Dead-Letter Queue' },
loadChildren: () =>
import('../features/deadletter/deadletter.routes').then((m) => m.deadletterRoutes),
},
{
path: 'slo',
title: 'SLO Monitoring',
data: { breadcrumb: 'SLO Monitoring' },
loadChildren: () =>
import('../features/slo-monitoring/slo.routes').then((m) => m.sloRoutes),
},
// P7 — Health & Diagnostics
{
path: 'health',
title: 'Platform Health',
data: { breadcrumb: 'Platform Health' },
loadChildren: () =>
import('../features/platform-health/platform-health.routes').then(
(m) => m.platformHealthRoutes
),
},
{
path: 'doctor',
title: 'Diagnostics',
data: { breadcrumb: 'Diagnostics' },
loadChildren: () =>
import('../features/doctor/doctor.routes').then((m) => m.DOCTOR_ROUTES),
},
{
path: 'status',
title: 'System Status',
data: { breadcrumb: 'System Status' },
loadComponent: () =>
import('../features/console/console-status.component').then(
(m) => m.ConsoleStatusComponent
),
},
// P8 — AOC Compliance
{
path: 'aoc',
title: 'AOC Compliance',
data: { breadcrumb: 'AOC Compliance' },
loadChildren: () =>
import('../features/aoc-compliance/aoc-compliance.routes').then(
(m) => m.AOC_COMPLIANCE_ROUTES
),
},
// P9 — Agents, Signals, AI Runs
{
path: 'agents',
title: 'Agent Fleet',
data: { breadcrumb: 'Agent Fleet' },
loadChildren: () =>
import('../features/agents/agents.routes').then((m) => m.AGENTS_ROUTES),
},
{
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
),
},
// P10 — Federated Telemetry
{
path: 'federation-telemetry',
title: 'Federation',
data: { breadcrumb: 'Federation' },
loadComponent: () =>
import('../features/platform-ops/federation-telemetry/federation-overview.component').then(
(m) => m.FederationOverviewComponent
),
},
{
path: 'federation-telemetry/consent',
title: 'Consent Management',
data: { breadcrumb: 'Consent' },
loadComponent: () =>
import('../features/platform-ops/federation-telemetry/consent-management.component').then(
(m) => m.ConsentManagementComponent
),
},
{
path: 'federation-telemetry/bundles',
title: 'Bundle Explorer',
data: { breadcrumb: 'Bundles' },
loadComponent: () =>
import('../features/platform-ops/federation-telemetry/bundle-explorer.component').then(
(m) => m.BundleExplorerComponent
),
},
{
path: 'federation-telemetry/intelligence',
title: 'Intelligence Viewer',
data: { breadcrumb: 'Intelligence' },
loadComponent: () =>
import('../features/platform-ops/federation-telemetry/intelligence-viewer.component').then(
(m) => m.IntelligenceViewerComponent
),
},
{
path: 'federation-telemetry/privacy',
title: 'Privacy Budget',
data: { breadcrumb: 'Privacy' },
loadComponent: () =>
import('../features/platform-ops/federation-telemetry/privacy-budget-monitor.component').then(
(m) => m.PrivacyBudgetMonitorComponent
),
},
// Alias for dead-letter alternative path format
{
path: 'deadletter',
redirectTo: 'dead-letter',
pathMatch: 'full',
},
];

View File

@@ -0,0 +1,161 @@
/**
* Release Control Canonical Domain Routes
* Sprint: SPRINT_20260218_006 (initial), SPRINT_20260218_009 (bundles), SPRINT_20260218_010 (promotions)
*
* Canonical path prefix: /release-control
* Domain owner: Release Control
*/
import { Routes } from '@angular/router';
export const RELEASE_CONTROL_ROUTES: Routes = [
{
path: '',
redirectTo: 'releases',
pathMatch: 'full',
},
// Setup hub and setup child pages (Pack 21 migration from Settings -> Release Control)
{
path: 'setup',
title: 'Setup',
data: { breadcrumb: 'Setup' },
loadComponent: () =>
import('../features/release-control/setup/release-control-setup-home.component').then(
(m) => m.ReleaseControlSetupHomeComponent
),
},
{
path: 'setup/environments-paths',
title: 'Environments and Promotion Paths',
data: { breadcrumb: 'Environments and Promotion Paths' },
loadComponent: () =>
import('../features/release-control/setup/setup-environments-paths.component').then(
(m) => m.SetupEnvironmentsPathsComponent
),
},
{
path: 'setup/targets-agents',
title: 'Targets and Agents',
data: { breadcrumb: 'Targets and Agents' },
loadComponent: () =>
import('../features/release-control/setup/setup-targets-agents.component').then(
(m) => m.SetupTargetsAgentsComponent
),
},
{
path: 'setup/workflows',
title: 'Workflows',
data: { breadcrumb: 'Workflows' },
loadComponent: () =>
import('../features/release-control/setup/setup-workflows.component').then(
(m) => m.SetupWorkflowsComponent
),
},
{
path: 'setup/bundle-templates',
title: 'Bundle Templates',
data: { breadcrumb: 'Bundle Templates' },
loadComponent: () =>
import('../features/release-control/setup/setup-bundle-templates.component').then(
(m) => m.SetupBundleTemplatesComponent
),
},
{
path: 'setup/environments',
redirectTo: 'setup/environments-paths',
pathMatch: 'full',
},
{
path: 'setup/targets',
redirectTo: 'setup/targets-agents',
pathMatch: 'full',
},
{
path: 'setup/agents',
redirectTo: 'setup/targets-agents',
pathMatch: 'full',
},
{
path: 'setup/templates',
redirectTo: 'setup/bundle-templates',
pathMatch: 'full',
},
// Releases (B5: list, create, detail, run timeline)
{
path: 'releases',
title: 'Releases',
data: { breadcrumb: 'Releases' },
loadChildren: () =>
import('../features/release-orchestrator/releases/releases.routes').then(
(m) => m.RELEASE_ROUTES
),
},
// Approvals — decision cockpit (SPRINT_20260218_011)
{
path: 'approvals',
title: 'Approvals',
data: { breadcrumb: 'Approvals' },
loadChildren: () =>
import('../features/approvals/approvals.routes').then(
(m) => m.APPROVALS_ROUTES
),
},
// Environments
{
path: 'environments',
title: 'Regions & Environments',
data: { breadcrumb: 'Regions & Environments' },
loadChildren: () =>
import('../features/release-orchestrator/environments/environments.routes').then(
(m) => m.ENVIRONMENT_ROUTES
),
},
// Deployments
{
path: 'deployments',
title: 'Deployments',
data: { breadcrumb: 'Deployments' },
loadChildren: () =>
import('../features/release-orchestrator/deployments/deployments.routes').then(
(m) => m.DEPLOYMENT_ROUTES
),
},
// Bundles — bundle organizer lifecycle (SPRINT_20260218_009)
{
path: 'bundles',
title: 'Bundles',
data: { breadcrumb: 'Bundles' },
loadChildren: () =>
import('../features/bundles/bundles.routes').then(
(m) => m.BUNDLE_ROUTES
),
},
// Promotions — bundle-version anchored promotions (SPRINT_20260218_010)
{
path: 'promotions',
title: 'Promotions',
data: { breadcrumb: 'Promotions' },
loadChildren: () =>
import('../features/promotions/promotions.routes').then(
(m) => m.PROMOTION_ROUTES
),
},
// Run timeline — pipeline run history
{
path: 'runs',
title: 'Run Timeline',
data: { breadcrumb: 'Run Timeline' },
loadChildren: () =>
import('../features/release-orchestrator/runs/runs.routes').then(
(m) => m.PIPELINE_RUN_ROUTES
),
},
];

View File

@@ -0,0 +1,62 @@
/**
* Security & Risk Domain Routes
* Sprint: SPRINT_20260218_014_FE_ui_v2_rewire_security_risk_consolidation (S9-01 through S9-05)
*/
import { Routes } from '@angular/router';
export const SECURITY_RISK_ROUTES: Routes = [
{ path: '', title: 'Security and Risk', data: { breadcrumb: 'Security and Risk' },
loadComponent: () => import('../features/security-risk/security-risk-overview.component').then(m => m.SecurityRiskOverviewComponent) },
{ path: 'findings', title: 'Findings', data: { breadcrumb: 'Findings' },
loadComponent: () => import('../features/findings/container/findings-container.component').then(m => m.FindingsContainerComponent) },
{ path: 'advisory-sources', title: 'Advisory Sources', data: { breadcrumb: 'Advisory Sources' },
loadComponent: () => import('../features/security-risk/advisory-sources.component').then(m => m.AdvisorySourcesComponent) },
{ path: 'vulnerabilities', title: 'Vulnerabilities', data: { breadcrumb: 'Vulnerabilities' },
loadComponent: () => import('../features/vulnerabilities/vulnerability-explorer.component').then(m => m.VulnerabilityExplorerComponent) },
{ path: 'vulnerabilities/:vulnId', title: 'Vulnerability Detail', data: { breadcrumb: 'Vulnerability Detail' },
loadComponent: () => import('../features/vulnerabilities/vulnerability-detail.component').then(m => m.VulnerabilityDetailComponent) },
{ path: 'scans/:scanId', title: 'Scan Detail', data: { breadcrumb: 'Scan Detail' },
loadComponent: () => import('../features/scans/scan-detail-page.component').then(m => m.ScanDetailPageComponent) },
{ path: 'sbom', title: 'SBOM', data: { breadcrumb: 'SBOM' },
loadComponent: () => import('../features/security/sbom-graph-page.component').then(m => m.SbomGraphPageComponent) },
{ path: 'sbom/graph', title: 'SBOM Graph', data: { breadcrumb: 'SBOM Graph' },
loadComponent: () => import('../features/graph/graph-explorer.component').then(m => m.GraphExplorerComponent) },
{ path: 'vex', title: 'VEX', data: { breadcrumb: 'VEX' },
loadChildren: () => import('../features/vex-hub/vex-hub.routes').then(m => m.vexHubRoutes) },
{ path: 'vex/:page', title: 'VEX', data: { breadcrumb: 'VEX' },
loadChildren: () => import('../features/vex-hub/vex-hub.routes').then(m => m.vexHubRoutes) },
{ path: 'lineage', title: 'Lineage', data: { breadcrumb: 'Lineage' },
loadChildren: () => import('../features/lineage/lineage.routes').then(m => m.lineageRoutes) },
{ path: 'lineage/:artifact/compare', title: 'Lineage Compare', data: { breadcrumb: 'Lineage Compare' },
loadChildren: () => import('../features/lineage/lineage.routes').then(m => m.lineageRoutes) },
{ path: 'lineage/compare', title: 'Lineage Compare', data: { breadcrumb: 'Lineage Compare' },
loadChildren: () => import('../features/lineage/lineage.routes').then(m => m.lineageRoutes) },
{ path: 'lineage/compare/:currentId', title: 'Compare', data: { breadcrumb: 'Compare' },
loadComponent: () => import('../features/compare/components/compare-view/compare-view.component').then(m => m.CompareViewComponent) },
{ path: 'reachability', title: 'Reachability', data: { breadcrumb: 'Reachability' },
loadComponent: () => import('../features/reachability/reachability-center.component').then(m => m.ReachabilityCenterComponent) },
{ path: 'risk', title: 'Risk', data: { breadcrumb: 'Risk Overview' },
loadComponent: () => import('../features/risk/risk-dashboard.component').then(m => m.RiskDashboardComponent) },
{ path: 'unknowns', title: 'Unknowns', data: { breadcrumb: 'Unknowns' },
loadChildren: () => import('../features/unknowns-tracking/unknowns.routes').then(m => m.unknownsRoutes) },
{ path: 'patch-map', title: 'Patch Map', data: { breadcrumb: 'Patch Map' },
loadComponent: () => import('../features/binary-index/patch-map.component').then(m => m.PatchMapComponent) },
{ path: 'artifacts', title: 'Artifacts', data: { breadcrumb: 'Artifacts' },
loadComponent: () => import('../features/triage/triage-artifacts.component').then(m => m.TriageArtifactsComponent) },
{ path: 'artifacts/:artifactId', title: 'Artifact Detail', data: { breadcrumb: 'Artifact Detail' },
loadComponent: () => import('../features/triage/triage-workspace.component').then(m => m.TriageWorkspaceComponent) },
{ path: 'symbol-sources', title: 'Symbol Sources', data: { breadcrumb: 'Symbol Sources' },
loadComponent: () => import('../features/security-risk/symbol-sources/symbol-sources-list.component').then(m => m.SymbolSourcesListComponent) },
{ path: 'symbol-sources/:sourceId', title: 'Symbol Source Detail', data: { breadcrumb: 'Symbol Source' },
loadComponent: () => import('../features/security-risk/symbol-sources/symbol-source-detail.component').then(m => m.SymbolSourceDetailComponent) },
{ path: 'symbol-marketplace', title: 'Symbol Marketplace', data: { breadcrumb: 'Symbol Marketplace' },
loadComponent: () => import('../features/security-risk/symbol-sources/symbol-marketplace-catalog.component').then(m => m.SymbolMarketplaceCatalogComponent) },
{ path: 'remediation', title: 'Remediation', data: { breadcrumb: 'Remediation' },
loadComponent: () => import('../features/security-risk/remediation/remediation-browse.component').then(m => m.RemediationBrowseComponent) },
{ path: 'remediation/submit', title: 'Submit Fix', data: { breadcrumb: 'Submit' },
loadComponent: () => import('../features/security-risk/remediation/remediation-submit.component').then(m => m.RemediationSubmitComponent) },
{ path: 'remediation/status/:submissionId', title: 'Verification Status', data: { breadcrumb: 'Status' },
loadComponent: () => import('../features/security-risk/remediation/remediation-submit.component').then(m => m.RemediationSubmitComponent) },
{ path: 'remediation/:fixId', title: 'Fix Detail', data: { breadcrumb: 'Fix Detail' },
loadComponent: () => import('../features/security-risk/remediation/remediation-fix-detail.component').then(m => m.RemediationFixDetailComponent) },
];

View File

@@ -0,0 +1,114 @@
/**
* Administration domain routes unit tests
* Sprint: SPRINT_20260218_007_FE_ui_v2_rewire_administration_foundation (A2-06)
*
* Verifies:
* - ADMINISTRATION_ROUTES covers all A0-A7 canonical paths.
* - All canonical sub-paths are present and non-empty.
* - No route uses a legacy v1 prefix as its canonical path.
* - Overview component route exists at '' path.
* - Policy Governance is under Administration ownership (not Release Control).
* - Trust and Signing routes are present under canonical paths.
* - Legacy alias paths (trust/:page, admin/:page) are preserved during migration window.
*/
import { ADMINISTRATION_ROUTES } from '../../app/routes/administration.routes';
const CANONICAL_PATHS = [
'', // A0 overview
'identity-access', // A1
'tenant-branding', // A2
'notifications', // A3
'usage', // A4
'policy-governance', // A5
'trust-signing', // A6
'system', // A7
];
describe('ADMINISTRATION_ROUTES (administration)', () => {
it('contains at least one route', () => {
expect(ADMINISTRATION_ROUTES.length).toBeGreaterThan(0);
});
it('all canonical A0-A7 paths are defined', () => {
const routePaths = ADMINISTRATION_ROUTES.map((r) => r.path);
for (const expected of CANONICAL_PATHS) {
expect(routePaths).toContain(expected);
}
});
it('overview route at "" loads AdministrationOverviewComponent', () => {
const overview = ADMINISTRATION_ROUTES.find((r) => r.path === '');
expect(overview).toBeDefined();
expect(overview?.loadComponent).toBeTruthy();
});
it('identity-access route uses canonical breadcrumb', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'identity-access');
expect(route?.data?.['breadcrumb']).toBe('Identity & Access');
});
it('tenant-branding route uses canonical breadcrumb', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'tenant-branding');
expect(route?.data?.['breadcrumb']).toBe('Tenant & Branding');
});
it('policy-governance route is under Administration (has loadChildren)', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'policy-governance');
expect(route).toBeDefined();
expect(route?.loadChildren).toBeTruthy();
});
it('policy-governance breadcrumb is canonical (no Release Control ownership)', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'policy-governance');
expect(route?.data?.['breadcrumb']).toBe('Policy Governance');
expect(route?.data?.['breadcrumb']).not.toContain('Release Control');
});
it('trust-signing route is present and loads trust admin routes', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'trust-signing');
expect(route).toBeDefined();
expect(route?.loadChildren).toBeTruthy();
});
it('system route is present and uses System breadcrumb', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'system');
expect(route?.data?.['breadcrumb']).toBe('System');
});
it('no canonical A0-A7 route uses a deprecated v1 root-level path as its path value', () => {
const legacyRoots = ['settings', 'operations', 'security', 'evidence', 'policy'];
const canonicalPaths = new Set(CANONICAL_PATHS.filter((path) => path.length > 0));
for (const legacy of legacyRoots) {
expect(canonicalPaths.has(legacy)).toBeFalse();
}
});
it('legacy migration alias trust/:page is present during migration window', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'trust/:page');
expect(route).toBeDefined();
});
it('legacy migration alias admin/:page is present during migration window', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'admin/:page');
expect(route).toBeDefined();
});
it('profile route is present (formerly /console/profile)', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'profile');
expect(route).toBeDefined();
});
it('workflows route is present (formerly /release-orchestrator/workflows)', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'workflows');
expect(route).toBeDefined();
});
it('all route paths are non-empty strings except the overview ""', () => {
const nonOverview = ADMINISTRATION_ROUTES.filter((r) => r.path !== '');
for (const route of nonOverview) {
expect(typeof route.path).toBe('string');
expect(route.path!.length).toBeGreaterThan(0);
}
});
});

View File

@@ -1,8 +1,101 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { APPROVAL_API, type ApprovalApi } from '../../app/core/api/approval.client';
import type { ApprovalRequest } from '../../app/core/api/approval.models';
import { ApprovalsInboxComponent } from '../../app/features/approvals/approvals-inbox.component';
const approvalsFixture: ApprovalRequest[] = [
{
id: 'apr-1',
releaseId: 'rel-1',
releaseName: 'API Gateway',
releaseVersion: '1.2.5',
sourceEnvironment: 'stage',
targetEnvironment: 'prod',
requestedBy: 'ops-user',
requestedAt: '2026-02-18T10:00:00Z',
urgency: 'normal',
justification: 'Promote stable release candidate',
status: 'pending',
currentApprovals: 1,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-02-21T10:00:00Z',
},
{
id: 'apr-2',
releaseId: 'rel-2',
releaseName: 'Scanner',
releaseVersion: '3.0.0',
sourceEnvironment: 'dev',
targetEnvironment: 'stage',
requestedBy: 'security-user',
requestedAt: '2026-02-18T11:00:00Z',
urgency: 'high',
justification: 'Hotfix for scanner backlog',
status: 'pending',
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: false,
scheduledTime: null,
expiresAt: '2026-02-21T11:00:00Z',
},
{
id: 'apr-3',
releaseId: 'rel-3',
releaseName: 'Evidence Locker',
releaseVersion: '2.4.1',
sourceEnvironment: 'stage',
targetEnvironment: 'prod',
requestedBy: 'ops-user',
requestedAt: '2026-02-18T12:00:00Z',
urgency: 'normal',
justification: 'Routine promotion',
status: 'approved',
currentApprovals: 2,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-02-21T12:00:00Z',
},
];
function createApprovalApiMock(): ApprovalApi {
const approvalDetail = {
...approvalsFixture[0],
gateResults: [],
actions: [],
approvers: [],
releaseComponents: [],
};
const promotionPreview = {
releaseId: approvalsFixture[0].releaseId,
releaseName: approvalsFixture[0].releaseName,
sourceEnvironment: approvalsFixture[0].sourceEnvironment,
targetEnvironment: approvalsFixture[0].targetEnvironment,
gateResults: [],
allGatesPassed: true,
requiredApprovers: 1,
estimatedDeployTime: 0,
warnings: [],
};
return {
listApprovals: () => of(approvalsFixture),
getApproval: () => of(approvalDetail),
getPromotionPreview: () => of(promotionPreview),
getAvailableEnvironments: () => of([]),
submitPromotionRequest: () => of(approvalsFixture[0]),
approve: () => of(approvalDetail),
reject: () => of(approvalDetail),
batchApprove: () => of(undefined),
batchReject: () => of(undefined),
};
}
describe('ApprovalsInboxComponent (approvals)', () => {
let fixture: ComponentFixture<ApprovalsInboxComponent>;
let component: ApprovalsInboxComponent;
@@ -10,7 +103,10 @@ describe('ApprovalsInboxComponent (approvals)', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ApprovalsInboxComponent],
providers: [provideRouter([])],
providers: [
provideRouter([]),
{ provide: APPROVAL_API, useValue: createApprovalApiMock() },
],
}).compileComponents();
fixture = TestBed.createComponent(ApprovalsInboxComponent);
@@ -18,16 +114,15 @@ describe('ApprovalsInboxComponent (approvals)', () => {
fixture.detectChanges();
});
it('renders pending approvals with diff-first change summaries', () => {
it('loads approvals via API and renders result count', () => {
const text = fixture.nativeElement.textContent as string;
expect(component.pendingApprovals.length).toBe(3);
expect(text).toContain('Pending (3)');
expect(text).toContain('WHAT CHANGED');
expect(text).toContain('v1.2.5');
expect(component.approvals().length).toBe(3);
expect(text).toContain('Approvals');
expect(text).toContain('Results (3)');
});
it('shows gate states and detail actions for each approval card', () => {
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;
@@ -35,13 +130,14 @@ describe('ApprovalsInboxComponent (approvals)', () => {
expect(cardElements.length).toBe(3);
expect(detailLinks.length).toBeGreaterThanOrEqual(3);
expect(text).toContain('PASS');
expect(text).toContain('WARN');
expect(text).toContain('BLOCK');
expect(text).toContain('View Details');
});
it('contains evidence action links for triage follow-up', () => {
it('shows justification and release identifiers in cards', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Open Evidence');
expect(text).toContain('JUSTIFICATION');
expect(text).toContain('API Gateway v1.2.5');
expect(text).toContain('Scanner v3.0.0');
});
});

View File

@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { EvidenceAuditOverviewComponent } from '../../app/features/evidence-audit/evidence-audit-overview.component';
describe('EvidenceAuditOverviewComponent (evidence-audit)', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EvidenceAuditOverviewComponent],
providers: [provideRouter([])],
}).compileComponents();
});
it('renders evidence home entry router sections', () => {
const fixture = TestBed.createComponent(EvidenceAuditOverviewComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Evidence Surfaces');
expect(text).toContain('Evidence Packs');
expect(text).toContain('Proof Chains');
expect(text).toContain('Replay and Verify');
expect(text).toContain('Timeline');
expect(text).toContain('Audit Log');
});
it('keeps trust ownership deep-link under administration', () => {
const fixture = TestBed.createComponent(EvidenceAuditOverviewComponent);
fixture.detectChanges();
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
const trustLink = links.find((link) => link.getAttribute('href')?.includes('/administration/trust-signing'));
expect(trustLink).toBeTruthy();
});
it('supports degraded state banner', () => {
const fixture = TestBed.createComponent(EvidenceAuditOverviewComponent);
fixture.detectChanges();
const degradedButton = (Array.from(
fixture.nativeElement.querySelectorAll('.mode-toggle button')
) as HTMLElement[]).find((button) => button.textContent?.includes('Degraded')) as
| HTMLButtonElement
| undefined;
degradedButton?.click();
fixture.detectChanges();
expect((fixture.nativeElement.textContent as string)).toContain('Evidence index is degraded');
});
it('supports deterministic empty state', () => {
const fixture = TestBed.createComponent(EvidenceAuditOverviewComponent);
fixture.detectChanges();
const emptyButton = (Array.from(
fixture.nativeElement.querySelectorAll('.mode-toggle button')
) as HTMLElement[]).find((button) => button.textContent?.includes('Empty')) as
| HTMLButtonElement
| undefined;
emptyButton?.click();
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('No evidence records are available yet');
expect(text).toContain('Open Release Control Promotions');
});
});

View File

@@ -0,0 +1,131 @@
/**
* Tests for EVIDENCE_AUDIT_ROUTES
* Sprint: SPRINT_20260218_015_FE_ui_v2_rewire_evidence_audit_consolidation (V10-05)
*/
import { EVIDENCE_AUDIT_ROUTES } from '../../app/routes/evidence-audit.routes';
import { Route } from '@angular/router';
describe('EVIDENCE_AUDIT_ROUTES', () => {
const getRouteByPath = (path: string): Route | undefined =>
EVIDENCE_AUDIT_ROUTES.find((r) => r.path === path);
const allPaths = EVIDENCE_AUDIT_ROUTES.map((r) => r.path);
// ──────────────────────────────────────────
// Path existence
// ──────────────────────────────────────────
it('contains the root overview route (empty path)', () => {
expect(allPaths).toContain('');
});
it('contains the packs list route', () => {
expect(allPaths).toContain('packs');
});
it('contains the pack detail route', () => {
expect(allPaths).toContain('packs/:packId');
});
it('contains the audit log route', () => {
expect(allPaths).toContain('audit');
});
it('contains the change-trace route', () => {
expect(allPaths).toContain('change-trace');
});
it('contains the proofs route', () => {
expect(allPaths).toContain('proofs');
});
it('contains the proofs detail route', () => {
expect(allPaths).toContain('proofs/:subjectDigest');
});
it('contains the timeline route', () => {
expect(allPaths).toContain('timeline');
});
it('contains the replay route', () => {
expect(allPaths).toContain('replay');
});
it('contains the cvss receipts route', () => {
expect(allPaths).toContain('receipts/cvss/:receiptId');
});
it('contains the evidence sub-domain route', () => {
expect(allPaths).toContain('evidence');
});
// ──────────────────────────────────────────
// Overview route breadcrumb
// ──────────────────────────────────────────
it('overview route has "Evidence and Audit" breadcrumb', () => {
const overviewRoute = getRouteByPath('');
expect(overviewRoute).toBeDefined();
expect(overviewRoute?.data?.['breadcrumb']).toBe('Evidence and Audit');
});
it('overview route has title "Evidence and Audit"', () => {
const overviewRoute = getRouteByPath('');
expect(overviewRoute?.title).toBe('Evidence and Audit');
});
// ──────────────────────────────────────────
// All routes must have breadcrumb data
// ──────────────────────────────────────────
it('every route has a breadcrumb in data', () => {
for (const route of EVIDENCE_AUDIT_ROUTES) {
expect(route.data?.['breadcrumb']).toBeTruthy();
}
});
// ──────────────────────────────────────────
// Specific breadcrumb values
// ──────────────────────────────────────────
it('packs route has "Evidence Packs" breadcrumb', () => {
expect(getRouteByPath('packs')?.data?.['breadcrumb']).toBe('Evidence Packs');
});
it('packs detail route has "Evidence Pack" breadcrumb', () => {
expect(getRouteByPath('packs/:packId')?.data?.['breadcrumb']).toBe('Evidence Pack');
});
it('audit route has "Audit Log" breadcrumb', () => {
expect(getRouteByPath('audit')?.data?.['breadcrumb']).toBe('Audit Log');
});
it('change-trace route has "Change Trace" breadcrumb', () => {
expect(getRouteByPath('change-trace')?.data?.['breadcrumb']).toBe('Change Trace');
});
it('proofs route has "Proof Chain" breadcrumb', () => {
expect(getRouteByPath('proofs/:subjectDigest')?.data?.['breadcrumb']).toBe('Proof Chain');
});
it('proofs list route has "Proof Chains" breadcrumb', () => {
expect(getRouteByPath('proofs')?.data?.['breadcrumb']).toBe('Proof Chains');
});
it('timeline route has "Timeline" breadcrumb', () => {
expect(getRouteByPath('timeline')?.data?.['breadcrumb']).toBe('Timeline');
});
it('replay route has "Replay / Verify" breadcrumb', () => {
expect(getRouteByPath('replay')?.data?.['breadcrumb']).toBe('Replay / Verify');
});
// ──────────────────────────────────────────
// Route count sanity check
// ──────────────────────────────────────────
it('has at least 8 routes defined', () => {
expect(EVIDENCE_AUDIT_ROUTES.length).toBeGreaterThanOrEqual(8);
});
});

View File

@@ -0,0 +1,191 @@
/**
* BreadcrumbComponent and BreadcrumbService unit tests
* Sprint: SPRINT_20260218_006_FE_ui_v2_rewire_navigation_shell_route_migration (N1-03)
*
* Verifies:
* - BreadcrumbService manages context crumbs correctly.
* - BreadcrumbComponent merges route crumbs and context crumbs in the right order.
* - Canonical domain crumbs appear without transition-label text (transition labels
* are sidebar-only per S00_nav_rendering_policy.md).
* - The last breadcrumb item carries aria-current="page".
* - Breadcrumb renders nothing when there are no crumbs.
*/
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, Routes } from '@angular/router';
import { Component } from '@angular/core';
import {
BreadcrumbComponent,
BreadcrumbService,
} from '../../app/layout/breadcrumb/breadcrumb.component';
@Component({ template: '' })
class StubPageComponent {}
const testRoutes: Routes = [
{
path: 'release-control',
data: { breadcrumb: 'Release Control' },
children: [
{
path: 'releases',
data: { breadcrumb: 'Releases' },
component: StubPageComponent,
},
],
},
{
path: 'security-risk',
data: { breadcrumb: 'Security and Risk' },
children: [
{
path: 'findings',
data: { breadcrumb: 'Findings' },
component: StubPageComponent,
},
],
},
{
path: 'evidence-audit',
data: { breadcrumb: 'Evidence and Audit' },
children: [
{
path: 'audit',
data: { breadcrumb: 'Audit Log' },
component: StubPageComponent,
},
],
},
{
path: '',
component: StubPageComponent,
},
];
describe('BreadcrumbService (navigation)', () => {
let service: BreadcrumbService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [BreadcrumbService],
});
service = TestBed.inject(BreadcrumbService);
});
it('starts with no context crumbs', () => {
expect(service.contextCrumbs()).toEqual([]);
});
it('setContextCrumbs replaces all crumbs', () => {
service.setContextCrumbs([{ label: 'Alpha' }, { label: 'Beta' }]);
expect(service.contextCrumbs().map((c) => c.label)).toEqual(['Alpha', 'Beta']);
});
it('clearContextCrumbs resets to empty array', () => {
service.setContextCrumbs([{ label: 'Alpha' }]);
service.clearContextCrumbs();
expect(service.contextCrumbs()).toEqual([]);
});
it('addContextCrumb appends to existing crumbs with isContext: true', () => {
service.setContextCrumbs([{ label: 'Findings' }]);
service.addContextCrumb({ label: 'CVE-2025-1234' });
const crumbs = service.contextCrumbs();
expect(crumbs.length).toBe(2);
expect(crumbs[1].label).toBe('CVE-2025-1234');
expect(crumbs[1].isContext).toBeTrue();
});
it('addContextCrumb on empty list produces a single isContext crumb', () => {
service.addContextCrumb({ label: 'Detail' });
const crumbs = service.contextCrumbs();
expect(crumbs.length).toBe(1);
expect(crumbs[0].isContext).toBeTrue();
});
});
describe('BreadcrumbComponent (navigation)', () => {
let breadcrumbService: BreadcrumbService;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BreadcrumbComponent, StubPageComponent],
providers: [provideRouter(testRoutes)],
}).compileComponents();
breadcrumbService = TestBed.inject(BreadcrumbService);
breadcrumbService.clearContextCrumbs();
});
async function navigateTo(url: string) {
const router = TestBed.inject(Router);
await router.navigateByUrl(url);
}
it('builds two-level breadcrumb trail from nested route data', async () => {
await navigateTo('/release-control/releases');
const fixture = TestBed.createComponent(BreadcrumbComponent);
fixture.detectChanges();
const labels = fixture.componentInstance.breadcrumbs().map((c) => c.label);
expect(labels).toEqual(['Release Control', 'Releases']);
});
it('appends context crumbs after route crumbs', async () => {
await navigateTo('/security-risk/findings');
breadcrumbService.setContextCrumbs([{ label: 'CVE-2025-1234', isContext: true }]);
const fixture = TestBed.createComponent(BreadcrumbComponent);
fixture.detectChanges();
const labels = fixture.componentInstance.breadcrumbs().map((c) => c.label);
expect(labels).toEqual(['Security and Risk', 'Findings', 'CVE-2025-1234']);
});
it('returns empty array on root route with no breadcrumb data', async () => {
await navigateTo('/');
const fixture = TestBed.createComponent(BreadcrumbComponent);
fixture.detectChanges();
expect(fixture.componentInstance.breadcrumbs().length).toBe(0);
});
it('canonical domain labels in breadcrumbs do not contain "formerly"', async () => {
const canonicalRoutes = [
'/release-control/releases',
'/security-risk/findings',
'/evidence-audit/audit',
];
for (const url of canonicalRoutes) {
await navigateTo(url);
const fixture = TestBed.createComponent(BreadcrumbComponent);
fixture.detectChanges();
const labels = fixture.componentInstance.breadcrumbs().map((c) => c.label);
for (const label of labels) {
expect(label).not.toContain('formerly');
}
}
});
it('renders nav element when breadcrumbs are present', async () => {
await navigateTo('/release-control/releases');
const fixture = TestBed.createComponent(BreadcrumbComponent);
fixture.detectChanges();
const nav = fixture.nativeElement.querySelector('nav.breadcrumb');
expect(nav).toBeTruthy();
});
it('does not render nav element when no breadcrumbs', async () => {
await navigateTo('/');
const fixture = TestBed.createComponent(BreadcrumbComponent);
fixture.detectChanges();
const nav = fixture.nativeElement.querySelector('nav.breadcrumb');
expect(nav).toBeFalsy();
});
it('last breadcrumb item has aria-current="page"', async () => {
await navigateTo('/release-control/releases');
const fixture = TestBed.createComponent(BreadcrumbComponent);
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.breadcrumb__item');
const lastItem = items[items.length - 1] as HTMLElement;
const currentEl = lastItem.querySelector('[aria-current="page"]');
expect(currentEl).toBeTruthy();
});
});

View File

@@ -0,0 +1,203 @@
/**
* Legacy redirect map unit tests
* Sprint: SPRINT_20260218_006_FE_ui_v2_rewire_navigation_shell_route_migration (N1-05)
*
* Verifies:
* - All redirect entries have non-empty source and target paths.
* - No redirect loop exists (source path never equals redirectTo target).
* - All redirectTo targets are under v2 canonical domain prefixes.
* - No source path is itself a v2 canonical root (would create an alias conflict).
* - Query parameter and fragment preservation function works correctly.
* - LEGACY_REDIRECT_ROUTES array length matches LEGACY_REDIRECT_ROUTE_TEMPLATES.
*/
import {
LEGACY_REDIRECT_ROUTE_TEMPLATES,
LEGACY_REDIRECT_ROUTES,
} from '../../app/routes/legacy-redirects.routes';
const V2_CANONICAL_PREFIXES = [
'/dashboard',
'/release-control/',
'/security-risk/',
'/evidence-audit/',
'/integrations',
'/platform-ops/',
'/administration/',
'/', // root redirect target is valid
];
const V2_CANONICAL_ROOTS = [
'dashboard',
'release-control',
'security-risk',
'evidence-audit',
'integrations',
'platform-ops',
'administration',
];
describe('LEGACY_REDIRECT_ROUTE_TEMPLATES (navigation)', () => {
it('has at least one redirect entry', () => {
expect(LEGACY_REDIRECT_ROUTE_TEMPLATES.length).toBeGreaterThan(0);
});
it('every entry has a non-empty path', () => {
for (const entry of LEGACY_REDIRECT_ROUTE_TEMPLATES) {
expect(entry.path.length).toBeGreaterThan(0);
}
});
it('every entry has a non-empty redirectTo', () => {
for (const entry of LEGACY_REDIRECT_ROUTE_TEMPLATES) {
expect(entry.redirectTo.length).toBeGreaterThan(0);
}
});
it('every entry uses pathMatch: full', () => {
for (const entry of LEGACY_REDIRECT_ROUTE_TEMPLATES) {
expect(entry.pathMatch).toBe('full');
}
});
it('no redirect loop — source path never equals the redirectTo target (ignoring leading slash)', () => {
for (const entry of LEGACY_REDIRECT_ROUTE_TEMPLATES) {
const normalizedSource = entry.path.startsWith('/') ? entry.path : `/${entry.path}`;
// A loop would mean source == target
expect(normalizedSource).not.toBe(entry.redirectTo);
}
});
it('all redirectTo targets are under v2 canonical domain prefixes', () => {
for (const entry of LEGACY_REDIRECT_ROUTE_TEMPLATES) {
const target = entry.redirectTo;
const matchesCanonical = V2_CANONICAL_PREFIXES.some(
(prefix) => target === prefix || target.startsWith(prefix)
);
expect(matchesCanonical).toBeTrue();
}
});
it('no source path is a bare v2 canonical root', () => {
for (const entry of LEGACY_REDIRECT_ROUTE_TEMPLATES) {
expect(V2_CANONICAL_ROOTS).not.toContain(entry.path);
}
});
it('source paths are all distinct (no duplicate entries)', () => {
const paths = LEGACY_REDIRECT_ROUTE_TEMPLATES.map((e) => e.path);
const uniquePaths = new Set(paths);
expect(uniquePaths.size).toBe(paths.length);
});
it('no source path is empty string', () => {
for (const entry of LEGACY_REDIRECT_ROUTE_TEMPLATES) {
expect(entry.path).not.toBe('');
}
});
});
describe('LEGACY_REDIRECT_ROUTES (navigation)', () => {
it('has the same length as LEGACY_REDIRECT_ROUTE_TEMPLATES', () => {
expect(LEGACY_REDIRECT_ROUTES.length).toBe(LEGACY_REDIRECT_ROUTE_TEMPLATES.length);
});
it('every route entry has a path matching its template', () => {
for (let i = 0; i < LEGACY_REDIRECT_ROUTES.length; i++) {
expect(LEGACY_REDIRECT_ROUTES[i].path).toBe(LEGACY_REDIRECT_ROUTE_TEMPLATES[i].path);
}
});
it('every route entry has a redirectTo function (preserveQueryAndFragment)', () => {
for (const route of LEGACY_REDIRECT_ROUTES) {
expect(typeof route.redirectTo).toBe('function');
}
});
});
describe('preserveQueryAndFragment behavior (navigation)', () => {
function resolveRedirect(
templateIndex: number,
params: Record<string, string>,
queryParams: Record<string, string>,
fragment: string | null
): string {
const fn = LEGACY_REDIRECT_ROUTES[templateIndex].redirectTo as Function;
return fn({ params, queryParams, fragment });
}
function templateIndexFor(path: string): number {
return LEGACY_REDIRECT_ROUTE_TEMPLATES.findIndex((t) => t.path === path);
}
it('resolves simple redirect without query or fragment', () => {
const idx = templateIndexFor('findings');
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, {}, {}, null);
expect(result).toBe('/security-risk/findings');
});
it('appends query string to redirect target', () => {
const idx = templateIndexFor('findings');
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('filter=critical');
expect(result).toContain('sort=severity');
});
it('appends fragment to redirect target', () => {
const idx = templateIndexFor('admin/audit');
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, {}, {}, 'recent');
expect(result).toContain('/evidence-audit/audit');
expect(result).toContain('#recent');
});
it('appends query and fragment together', () => {
const idx = templateIndexFor('findings');
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, {}, { status: 'open' }, 'top');
expect(result).toContain('/security-risk/findings');
expect(result).toContain('status=open');
expect(result).toContain('#top');
});
it('interpolates :param segments for parameterized redirects', () => {
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');
});
it('interpolates multiple param segments', () => {
const idx = templateIndexFor('lineage/:artifact/compare');
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, { artifact: 'myapp' }, {}, null);
expect(result).toBe('/security-risk/lineage/myapp/compare');
});
it('handles multi-value query parameters as repeated keys', () => {
const idx = templateIndexFor('orchestrator');
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('tag=v1');
expect(result).toContain('tag=v2');
});
it('returns target path unchanged when no query or fragment provided', () => {
const idx = templateIndexFor('orchestrator');
expect(idx).toBeGreaterThanOrEqual(0);
if (idx === -1) return;
const result = resolveRedirect(idx, {}, {}, null);
expect(result).toBe('/platform-ops/orchestrator');
});
});

View File

@@ -0,0 +1,148 @@
/**
* 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.
*/
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AppSidebarComponent } from '../../app/layout/app-sidebar/app-sidebar.component';
import { AUTH_SERVICE } from '../../app/core/auth';
const CANONICAL_DOMAIN_IDS = [
'dashboard',
'release-control',
'security-risk',
'evidence-audit',
'integrations',
'platform-ops',
'administration',
] as const;
const CANONICAL_DOMAIN_ROUTES = [
'/dashboard',
'/release-control',
'/security-risk',
'/evidence-audit',
'/integrations',
'/platform-ops',
'/administration',
] as const;
const EXPECTED_SECTION_LABELS: Record<string, string> = {
'dashboard': 'Dashboard',
'release-control': 'Release Control',
'security-risk': 'Security and Risk',
'evidence-audit': 'Evidence and Audit',
'integrations': 'Integrations',
'platform-ops': 'Platform Ops',
'administration': 'Administration',
};
describe('AppSidebarComponent nav model (navigation)', () => {
let component: AppSidebarComponent;
beforeEach(async () => {
const authSpy = jasmine.createSpyObj('AuthService', ['hasAllScopes', 'hasAnyScope']);
authSpy.hasAllScopes.and.returnValue(true);
authSpy.hasAnyScope.and.returnValue(true);
await TestBed.configureTestingModule({
imports: [AppSidebarComponent],
providers: [
provideRouter([]),
{ provide: AUTH_SERVICE, useValue: authSpy },
],
}).compileComponents();
const fixture = TestBed.createComponent(AppSidebarComponent);
component = fixture.componentInstance;
});
it('defines exactly 7 canonical root domains', () => {
expect(component.navSections.length).toBe(7);
});
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', () => {
expect(component.navSections.map((s) => s.route)).toEqual([...CANONICAL_DOMAIN_ROUTES]);
});
it('section labels use clean canonical names', () => {
for (const section of component.navSections) {
expect(section.label).toBe(EXPECTED_SECTION_LABELS[section.id]);
}
});
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('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('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('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('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('no section root route uses a deprecated v1 prefix', () => {
const legacyPrefixes = ['/security/', '/operations/', '/settings/', '/evidence/', '/policy/'];
for (const section of component.navSections) {
for (const prefix of legacyPrefixes) {
expect(section.route + '/').not.toContain(prefix);
}
}
});
it('no child route uses a deprecated v1 prefix', () => {
const legacyPrefixes = ['/security/', '/operations/', '/settings/', '/evidence/', '/policy/'];
for (const section of component.navSections) {
for (const child of section.children ?? []) {
for (const prefix of legacyPrefixes) {
expect(child.route + '/').not.toContain(prefix);
}
}
}
});
});

View File

@@ -0,0 +1,128 @@
import { TestBed } from '@angular/core/testing';
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 { integrationHubRoutes } from '../../app/features/integration-hub/integration-hub.routes';
function joinPath(prefix: string, path: string | undefined): string | null {
if (path === undefined) return null;
if (path.includes(':')) return null;
if (path === '') return prefix;
return `${prefix}/${path}`;
}
function collectConcretePaths(prefix: string, routes: Route[]): Set<string> {
const resolved = new Set<string>();
for (const route of routes) {
const full = joinPath(prefix, route.path);
if (full) resolved.add(full);
}
return resolved;
}
function collectConcretePathsArray(prefix: string, routes: Route[]): string[] {
const resolved: string[] = [];
for (const route of routes) {
const full = joinPath(prefix, route.path);
if (full) resolved.push(full);
}
return resolved;
}
describe('AppSidebarComponent route integrity (navigation)', () => {
let component: AppSidebarComponent;
beforeEach(async () => {
const authSpy = jasmine.createSpyObj('AuthService', ['hasAllScopes', 'hasAnyScope']);
authSpy.hasAllScopes.and.returnValue(true);
authSpy.hasAnyScope.and.returnValue(true);
await TestBed.configureTestingModule({
imports: [AppSidebarComponent],
providers: [
provideRouter([]),
{ provide: AUTH_SERVICE, useValue: authSpy },
],
}).compileComponents();
component = TestBed.createComponent(AppSidebarComponent).componentInstance;
});
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',
]);
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 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', () => {
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);
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/hosts',
];
for (const path of required) {
expect(allowed.has(path)).toBeTrue();
}
});
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) },
];
for (const group of routeGroups) {
const seen = new Set<string>();
for (const path of group.paths) {
expect(seen.has(path)).toBeFalse();
seen.add(path);
}
}
});
});

View File

@@ -0,0 +1,164 @@
/**
* Platform Ops and Integrations routes unit tests
* Sprint: SPRINT_20260218_008_FE_ui_v2_rewire_integrations_platform_ops_data_integrity (I3-06)
*
* Verifies:
* - PLATFORM_OPS_ROUTES covers all canonical P0-P9 paths.
* - data-integrity route is present and loads DataIntegrityOverviewComponent.
* - All category routes are under /integrations (Integrations ownership).
* - integrationHubRoutes covers canonical taxonomy categories.
* - No cross-ownership contamination (connectivity is Integrations/Platform Ops, not Security & Risk).
* - Canonical breadcrumbs are set on all routes.
*/
import { PLATFORM_OPS_ROUTES } from '../../app/routes/platform-ops.routes';
import { integrationHubRoutes } from '../../app/features/integration-hub/integration-hub.routes';
// ---------------------------------------------------------------------------
// Platform Ops routes
// ---------------------------------------------------------------------------
const EXPECTED_PLATFORM_OPS_PATHS = [
'', // P0 overview
'orchestrator', // P1
'scheduler', // P2
'quotas', // P3
'feeds', // P4
'offline-kit', // P5
'data-integrity', // P6
'dead-letter', // P6 DLQ
'slo', // P6 SLOs
'health', // P7
'doctor', // P7
'aoc', // P8
'agents', // P9
];
describe('PLATFORM_OPS_ROUTES (platform-ops)', () => {
it('contains at least 10 routes', () => {
expect(PLATFORM_OPS_ROUTES.length).toBeGreaterThanOrEqual(10);
});
it('includes all canonical domain paths', () => {
const paths = PLATFORM_OPS_ROUTES.map((r) => r.path);
for (const expected of EXPECTED_PLATFORM_OPS_PATHS) {
expect(paths).toContain(expected);
}
});
it('overview route "" loads PlatformOpsOverviewComponent', () => {
const overview = PLATFORM_OPS_ROUTES.find((r) => r.path === '');
expect(overview).toBeDefined();
expect(overview?.loadComponent).toBeTruthy();
});
it('data-integrity route loads DataIntegrityOverviewComponent', () => {
const di = PLATFORM_OPS_ROUTES.find((r) => r.path === 'data-integrity');
expect(di).toBeDefined();
expect(di?.loadComponent).toBeTruthy();
});
it('data-integrity breadcrumb is "Data Integrity"', () => {
const di = PLATFORM_OPS_ROUTES.find((r) => r.path === 'data-integrity');
expect(di?.data?.['breadcrumb']).toBe('Data Integrity');
});
it('dead-letter route is present for DLQ management', () => {
const dlq = PLATFORM_OPS_ROUTES.find((r) => r.path === 'dead-letter');
expect(dlq).toBeDefined();
});
it('slo route is present for SLO monitoring', () => {
const slo = PLATFORM_OPS_ROUTES.find((r) => r.path === 'slo');
expect(slo).toBeDefined();
});
it('health route is present for platform health', () => {
const health = PLATFORM_OPS_ROUTES.find((r) => r.path === 'health');
expect(health?.data?.['breadcrumb']).toBe('Platform Health');
});
it('no route path starts with /security-risk (no cross-ownership contamination)', () => {
for (const route of PLATFORM_OPS_ROUTES) {
expect(String(route.path)).not.toMatch(/^security-risk/);
}
});
it('feeds route has canonical breadcrumb', () => {
const feeds = PLATFORM_OPS_ROUTES.find((r) => r.path === 'feeds');
expect(feeds?.data?.['breadcrumb']).toBe('Feeds & Mirrors');
});
it('offline-kit route is present (AirGap support)', () => {
const ok = PLATFORM_OPS_ROUTES.find((r) => r.path === 'offline-kit');
expect(ok).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Integration Hub routes
// ---------------------------------------------------------------------------
const CANONICAL_INTEGRATION_CATEGORIES = [
'registries',
'scm',
'ci',
'hosts',
'secrets',
'feeds',
'notifications',
];
describe('integrationHubRoutes (platform-ops)', () => {
it('contains all canonical taxonomy categories', () => {
const paths = integrationHubRoutes.map((r) => r.path);
for (const cat of CANONICAL_INTEGRATION_CATEGORIES) {
expect(paths).toContain(cat);
}
});
it('root route "" has canonical breadcrumb "Integrations"', () => {
const root = integrationHubRoutes.find((r) => r.path === '');
expect(root?.data?.['breadcrumb']).toBe('Integrations');
});
it('registries category has correct breadcrumb', () => {
const reg = integrationHubRoutes.find((r) => r.path === 'registries');
expect(reg?.data?.['breadcrumb']).toBe('Registries');
});
it('secrets category is present', () => {
const sec = integrationHubRoutes.find((r) => r.path === 'secrets');
expect(sec).toBeDefined();
});
it('notifications category is present', () => {
const notif = integrationHubRoutes.find((r) => r.path === 'notifications');
expect(notif).toBeDefined();
});
it('hosts category uses canonical Targets / Runtimes breadcrumb', () => {
const hosts = integrationHubRoutes.find((r) => r.path === 'hosts');
expect(hosts?.data?.['breadcrumb']).toBe('Targets / Runtimes');
});
it('activity route is present', () => {
const activity = integrationHubRoutes.find((r) => r.path === 'activity');
expect(activity).toBeDefined();
});
it('detail route :integrationId uses canonical breadcrumb', () => {
const detail = integrationHubRoutes.find((r) => r.path === ':integrationId');
expect(detail?.data?.['breadcrumb']).toBe('Integration Detail');
});
it('no route incorrectly contains Security ownership labels', () => {
for (const route of integrationHubRoutes) {
const breadcrumb = route.data?.['breadcrumb'] as string | undefined;
if (breadcrumb) {
expect(breadcrumb).not.toContain('Security');
expect(breadcrumb).not.toContain('Vulnerability');
}
}
});
});

View File

@@ -0,0 +1,191 @@
/**
* Release Control domain routes unit tests
* Sprints: 009 (bundles), 010 (promotions/runs), 011 (approvals decision cockpit)
*
* Verifies:
* - RELEASE_CONTROL_ROUTES covers all canonical Release Control paths.
* - Bundle organizer routes are wired and use BUNDLE_ROUTES.
* - Promotions routes are present with correct breadcrumbs.
* - Run timeline route is present.
* - Approvals decision cockpit routes have tab metadata.
* - BUNDLE_ROUTES covers catalog, builder, detail, and version-detail paths.
* - PROMOTION_ROUTES covers list, create, and detail paths.
*/
import { RELEASE_CONTROL_ROUTES } from '../../app/routes/release-control.routes';
import { BUNDLE_ROUTES } from '../../app/features/bundles/bundles.routes';
import { PROMOTION_ROUTES } from '../../app/features/promotions/promotions.routes';
import { APPROVALS_ROUTES } from '../../app/features/approvals/approvals.routes';
// ---------------------------------------------------------------------------
// Release Control root routes
// ---------------------------------------------------------------------------
describe('RELEASE_CONTROL_ROUTES (release-control)', () => {
const paths = RELEASE_CONTROL_ROUTES.map((r) => r.path);
it('contains all canonical domain paths', () => {
const expected = [
'setup',
'setup/environments-paths',
'setup/targets-agents',
'setup/workflows',
'setup/bundle-templates',
'releases',
'approvals',
'environments',
'deployments',
'bundles',
'promotions',
'runs',
];
for (const p of expected) {
expect(paths).toContain(p);
}
});
it('setup path has canonical Setup breadcrumb', () => {
const setup = RELEASE_CONTROL_ROUTES.find((r) => r.path === 'setup');
expect(setup?.data?.['breadcrumb']).toBe('Setup');
});
it('bundles path uses loadChildren (BUNDLE_ROUTES)', () => {
const bundles = RELEASE_CONTROL_ROUTES.find((r) => r.path === 'bundles');
expect(bundles?.loadChildren).toBeTruthy();
});
it('promotions path uses loadChildren (PROMOTION_ROUTES)', () => {
const promotions = RELEASE_CONTROL_ROUTES.find((r) => r.path === 'promotions');
expect(promotions?.loadChildren).toBeTruthy();
});
it('runs path has run timeline breadcrumb', () => {
const runs = RELEASE_CONTROL_ROUTES.find((r) => r.path === 'runs');
expect(runs?.data?.['breadcrumb']).toBe('Run Timeline');
});
it('releases path has correct breadcrumb', () => {
const releases = RELEASE_CONTROL_ROUTES.find((r) => r.path === 'releases');
expect(releases?.data?.['breadcrumb']).toBe('Releases');
});
it('approvals path has correct breadcrumb', () => {
const approvals = RELEASE_CONTROL_ROUTES.find((r) => r.path === 'approvals');
expect(approvals?.data?.['breadcrumb']).toBe('Approvals');
});
});
// ---------------------------------------------------------------------------
// Bundle Organizer routes (Sprint 009)
// ---------------------------------------------------------------------------
describe('BUNDLE_ROUTES (release-control)', () => {
const paths = BUNDLE_ROUTES.map((r) => r.path);
it('has catalog route at ""', () => {
expect(paths).toContain('');
});
it('has create/builder route', () => {
expect(paths).toContain('create');
});
it('has bundle detail route :bundleId', () => {
expect(paths).toContain(':bundleId');
});
it('has version detail route :bundleId/:version', () => {
expect(paths).toContain(':bundleId/:version');
});
it('catalog route has "Bundles" breadcrumb', () => {
const catalog = BUNDLE_ROUTES.find((r) => r.path === '');
expect(catalog?.data?.['breadcrumb']).toBe('Bundles');
});
it('create route has "Create Bundle" breadcrumb', () => {
const create = BUNDLE_ROUTES.find((r) => r.path === 'create');
expect(create?.data?.['breadcrumb']).toBe('Create Bundle');
});
it('detail route has "Bundle Detail" breadcrumb', () => {
const detail = BUNDLE_ROUTES.find((r) => r.path === ':bundleId');
expect(detail?.data?.['breadcrumb']).toBe('Bundle Detail');
});
it('version detail route has "Bundle Version" breadcrumb', () => {
const vd = BUNDLE_ROUTES.find((r) => r.path === ':bundleId/:version');
expect(vd?.data?.['breadcrumb']).toBe('Bundle Version');
});
it('all routes use loadComponent (no module-based lazy loading)', () => {
for (const route of BUNDLE_ROUTES) {
expect(route.loadComponent).toBeTruthy();
}
});
});
// ---------------------------------------------------------------------------
// Promotions routes (Sprint 010)
// ---------------------------------------------------------------------------
describe('PROMOTION_ROUTES (release-control)', () => {
const paths = PROMOTION_ROUTES.map((r) => r.path);
it('has list route at ""', () => {
expect(paths).toContain('');
});
it('has create promotion route', () => {
expect(paths).toContain('create');
});
it('has promotion detail route :promotionId', () => {
expect(paths).toContain(':promotionId');
});
it('list route has "Promotions" breadcrumb', () => {
const list = PROMOTION_ROUTES.find((r) => r.path === '');
expect(list?.data?.['breadcrumb']).toBe('Promotions');
});
it('create route has "Create Promotion" breadcrumb', () => {
const create = PROMOTION_ROUTES.find((r) => r.path === 'create');
expect(create?.data?.['breadcrumb']).toBe('Create Promotion');
});
it('detail route has "Promotion Detail" breadcrumb', () => {
const detail = PROMOTION_ROUTES.find((r) => r.path === ':promotionId');
expect(detail?.data?.['breadcrumb']).toBe('Promotion Detail');
});
});
// ---------------------------------------------------------------------------
// Approvals decision cockpit routes (Sprint 011)
// ---------------------------------------------------------------------------
describe('APPROVALS_ROUTES (release-control)', () => {
it('queue route "" has "Approvals" breadcrumb', () => {
const queue = APPROVALS_ROUTES.find((r) => r.path === '');
expect(queue?.data?.['breadcrumb']).toBe('Approvals');
});
it('detail route :id has decision cockpit metadata', () => {
const detail = APPROVALS_ROUTES.find((r) => r.path === ':id');
expect(detail?.data?.['decisionTabs']).toBeTruthy();
});
it('decision cockpit tabs include all required context tabs', () => {
const detail = APPROVALS_ROUTES.find((r) => r.path === ':id');
const tabs = detail?.data?.['decisionTabs'] as string[];
const required = ['overview', 'gates', 'security', 'reachability', 'evidence'];
for (const tab of required) {
expect(tabs).toContain(tab);
}
});
it('detail route has "Approval Decision" breadcrumb', () => {
const detail = APPROVALS_ROUTES.find((r) => r.path === ':id');
expect(detail?.data?.['breadcrumb']).toBe('Approval Decision');
});
});

View File

@@ -0,0 +1,72 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { ReleaseControlSetupHomeComponent } from '../../app/features/release-control/setup/release-control-setup-home.component';
import { SetupBundleTemplatesComponent } from '../../app/features/release-control/setup/setup-bundle-templates.component';
import { SetupEnvironmentsPathsComponent } from '../../app/features/release-control/setup/setup-environments-paths.component';
import { SetupTargetsAgentsComponent } from '../../app/features/release-control/setup/setup-targets-agents.component';
import { SetupWorkflowsComponent } from '../../app/features/release-control/setup/setup-workflows.component';
describe('Release Control setup components (release-control)', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ReleaseControlSetupHomeComponent,
SetupEnvironmentsPathsComponent,
SetupTargetsAgentsComponent,
SetupWorkflowsComponent,
SetupBundleTemplatesComponent,
],
providers: [provideRouter([])],
}).compileComponents();
});
it('setup home renders required setup areas', () => {
const fixture = TestBed.createComponent(ReleaseControlSetupHomeComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Release Control Setup');
expect(text).toContain('Environments and Promotion Paths');
expect(text).toContain('Targets and Agents');
expect(text).toContain('Workflows');
expect(text).toContain('Bundle Templates');
});
it('environments and paths page renders inventory and path rules', () => {
const fixture = TestBed.createComponent(SetupEnvironmentsPathsComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Environment Inventory');
expect(text).toContain('Promotion Path Rules');
});
it('targets and agents page renders ownership links', () => {
const fixture = TestBed.createComponent(SetupTargetsAgentsComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Targets and Agents');
expect(text).toContain('Integrations > Targets / Runtimes');
expect(text).toContain('Platform Ops > Agents');
});
it('workflows page renders workflow catalog and run timeline link', () => {
const fixture = TestBed.createComponent(SetupWorkflowsComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Workflow Catalog');
expect(text).toContain('Open Run Timeline');
});
it('bundle templates page renders template catalog and builder link', () => {
const fixture = TestBed.createComponent(SetupBundleTemplatesComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Template Catalog');
expect(text).toContain('Open Bundle Builder');
});
});

View File

@@ -0,0 +1,368 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { BundleBuilderComponent } from '../../app/features/bundles/bundle-builder.component';
import { BundleCatalogComponent } from '../../app/features/bundles/bundle-catalog.component';
import { BundleDetailComponent } from '../../app/features/bundles/bundle-detail.component';
import { BundleOrganizerApi } from '../../app/features/bundles/bundle-organizer.api';
import { BundleVersionDetailComponent } from '../../app/features/bundles/bundle-version-detail.component';
import { APPROVAL_API } from '../../app/core/api/approval.client';
import { CreatePromotionComponent } from '../../app/features/promotions/create-promotion.component';
import { PromotionDetailComponent } from '../../app/features/promotions/promotion-detail.component';
import { PromotionsListComponent } from '../../app/features/promotions/promotions-list.component';
describe('Release Control structural surfaces (release-control)', () => {
const bundleApiStub: Partial<BundleOrganizerApi> = {
listBundles: () =>
of([
{
id: 'platform-release',
slug: 'platform-release',
name: 'Platform Release',
totalVersions: 3,
latestVersionNumber: 3,
latestVersionId: 'version-3',
latestVersionDigest: 'sha256:abc123abc123abc123abc123',
latestPublishedAt: '2026-02-19T08:00:00Z',
createdAt: '2026-02-18T08:00:00Z',
updatedAt: '2026-02-19T08:00:00Z',
},
]),
getBundle: () =>
of({
id: 'platform-release',
slug: 'platform-release',
name: 'Platform Release',
totalVersions: 3,
latestVersionNumber: 3,
latestVersionId: 'version-3',
latestVersionDigest: 'sha256:abc123abc123abc123abc123',
latestPublishedAt: '2026-02-19T08:00:00Z',
createdAt: '2026-02-18T08:00:00Z',
updatedAt: '2026-02-19T08:00:00Z',
createdBy: 'ci-pipeline',
}),
listBundleVersions: () =>
of([
{
id: 'version-3',
bundleId: 'platform-release',
versionNumber: 3,
digest: 'sha256:abc123abc123abc123abc123',
status: 'published',
componentsCount: 2,
changelog: 'API and worker updates.',
createdAt: '2026-02-19T07:55:00Z',
publishedAt: '2026-02-19T08:00:00Z',
createdBy: 'ci-pipeline',
},
]),
getBundleVersion: () =>
of({
id: 'version-3',
bundleId: 'platform-release',
versionNumber: 3,
digest: 'sha256:abc123abc123abc123abc123',
status: 'published',
componentsCount: 2,
changelog: 'API and worker updates.',
createdAt: '2026-02-19T07:55:00Z',
publishedAt: '2026-02-19T08:00:00Z',
createdBy: 'ci-pipeline',
components: [
{
componentVersionId: 'api-gateway@2.3.1',
componentName: 'api-gateway',
imageDigest: 'sha256:111111111111111111111111',
deployOrder: 1,
metadataJson: '{}',
},
],
}),
materializeBundleVersion: () =>
of({
runId: 'run-1',
bundleId: 'platform-release',
versionId: 'version-3',
status: 'queued',
requestedBy: 'tester',
requestedAt: '2026-02-19T09:00:00Z',
updatedAt: '2026-02-19T09:00:00Z',
}),
};
const approvalDetailStub = {
id: 'apr-001',
releaseId: 'rel-001',
releaseName: 'Platform Release',
releaseVersion: '1.3.0-rc1',
sourceEnvironment: 'stage-eu-west',
targetEnvironment: 'prod-eu-west',
requestedBy: 'alice.johnson',
requestedAt: '2026-02-19T08:00:00Z',
urgency: 'high' as const,
justification: 'Promotion required for regional rollout.',
status: 'pending' as const,
currentApprovals: 1,
requiredApprovals: 2,
gatesPassed: false,
scheduledTime: null,
expiresAt: '2026-02-20T08:00:00Z',
gateResults: [
{
gateId: 'gate-1',
gateName: 'Security Scan',
type: 'security' as const,
status: 'warning' as const,
message: 'One warning gate',
details: {},
evaluatedAt: '2026-02-19T08:02:00Z',
},
{
gateId: 'gate-2',
gateName: 'Policy Compliance',
type: 'policy' as const,
status: 'passed' as const,
message: 'Policy pass',
details: {},
evaluatedAt: '2026-02-19T08:03:00Z',
},
],
actions: [
{
id: 'action-1',
approvalId: 'apr-001',
action: 'approved' as const,
actor: 'bob.smith',
comment: 'Looks good.',
timestamp: '2026-02-19T08:10:00Z',
},
],
approvers: [
{
id: 'user-1',
name: 'Bob Smith',
email: 'bob@example.com',
hasApproved: true,
approvedAt: '2026-02-19T08:10:00Z',
},
{
id: 'user-2',
name: 'Carol Davis',
email: 'carol@example.com',
hasApproved: false,
approvedAt: null,
},
],
releaseComponents: [
{
name: 'api-gateway',
version: '1.3.0-rc1',
digest: 'sha256:111111111111111111111111',
},
],
};
const approvalApiStub = {
listApprovals: () =>
of([
{
id: 'apr-001',
releaseId: 'rel-001',
releaseName: 'Platform Release',
releaseVersion: '1.3.0-rc1',
sourceEnvironment: 'stage-eu-west',
targetEnvironment: 'prod-eu-west',
requestedBy: 'alice.johnson',
requestedAt: '2026-02-19T08:00:00Z',
urgency: 'high' as const,
justification: 'Promotion required for regional rollout.',
status: 'pending' as const,
currentApprovals: 1,
requiredApprovals: 2,
gatesPassed: false,
scheduledTime: null,
expiresAt: '2026-02-20T08:00:00Z',
},
]),
getApproval: () => of(approvalDetailStub),
getPromotionPreview: () =>
of({
releaseId: 'rel-001',
releaseName: 'Platform Release',
sourceEnvironment: 'stage-eu-west',
targetEnvironment: 'prod-eu-west',
gateResults: approvalDetailStub.gateResults,
allGatesPassed: false,
requiredApprovers: 2,
estimatedDeployTime: 600,
warnings: ['NVD feed stale'],
}),
getAvailableEnvironments: () =>
of([
{ id: 'env-production', name: 'Production', tier: 'production' },
{ id: 'env-staging', name: 'Staging', tier: 'staging' },
]),
submitPromotionRequest: () =>
of({
id: 'apr-100',
releaseId: 'rel-001',
releaseName: 'Platform Release',
releaseVersion: '1.3.0-rc1',
sourceEnvironment: 'stage-eu-west',
targetEnvironment: 'prod-eu-west',
requestedBy: 'alice.johnson',
requestedAt: '2026-02-19T08:00:00Z',
urgency: 'high' as const,
justification: 'Promotion required for regional rollout.',
status: 'pending' as const,
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: false,
scheduledTime: null,
expiresAt: '2026-02-20T08:00:00Z',
}),
approve: () => of({ ...approvalDetailStub, status: 'approved' as const, currentApprovals: 2 }),
reject: () => of({ ...approvalDetailStub, status: 'rejected' as const }),
batchApprove: () => of(undefined),
batchReject: () => of(undefined),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
BundleCatalogComponent,
BundleDetailComponent,
BundleBuilderComponent,
BundleVersionDetailComponent,
PromotionsListComponent,
CreatePromotionComponent,
PromotionDetailComponent,
],
providers: [
provideRouter([]),
{ provide: BundleOrganizerApi, useValue: bundleApiStub },
{ provide: APPROVAL_API, useValue: approvalApiStub },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
params: {
bundleId: 'platform-release',
version: '1.3.0-rc1',
promotionId: 'prm-1001',
},
queryParamMap: convertToParamMap({}),
},
},
},
],
}).compileComponents();
});
it('bundle catalog renders digest/validation/materialization structure', () => {
const fixture = TestBed.createComponent(BundleCatalogComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Digest-first identity');
expect(text).toContain('Validation gates');
expect(text).toContain('Materialization hooks');
});
it('bundle detail renders identity and config/changelog sections', () => {
const fixture = TestBed.createComponent(BundleDetailComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Latest manifest digest');
expect(text).toContain('Total versions');
expect(text).toContain('Materialization readiness');
expect(text).toContain('Version timeline');
});
it('bundle builder includes digest-first component selection and validation finalization', () => {
const fixture = TestBed.createComponent(BundleBuilderComponent);
fixture.detectChanges();
const component = fixture.componentInstance;
component.nextStep();
fixture.detectChanges();
expect(fixture.nativeElement.textContent as string).toContain('Select Components');
component.nextStep();
fixture.detectChanges();
expect(fixture.nativeElement.textContent as string).toContain('Missing bindings block materialization');
component.nextStep();
fixture.detectChanges();
expect(fixture.nativeElement.textContent as string).toContain('manifest digest');
});
it('bundle version detail renders immutable context and promotion entry points', () => {
const fixture = TestBed.createComponent(BundleVersionDetailComponent);
fixture.detectChanges();
const component = fixture.componentInstance;
component.setTab('releases');
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Bundle manifest digest');
expect(text).toContain('Materialization and promotion entry points');
});
it('promotions list renders API-backed identity and signal columns', () => {
const fixture = TestBed.createComponent(PromotionsListComponent);
fixture.detectChanges();
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Bundle-version anchored release promotions');
expect(text).toContain('sha256:111111111111111111111111');
expect(text).toContain('Risk Signal');
expect(text).toContain('Data Health');
});
it('create promotion review includes materialization and gate structure', () => {
const fixture = TestBed.createComponent(CreatePromotionComponent);
fixture.detectChanges();
const component = fixture.componentInstance;
component.releaseId.set('rel-001');
component.nextStep();
component.onTargetEnvironmentChange('env-production');
component.nextStep();
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Inputs Materialization Preflight');
component.nextStep();
fixture.detectChanges();
expect(fixture.nativeElement.textContent as string).toContain('Gate Preview');
});
it('promotion detail exposes pack-13 decision tabs with API-backed content', () => {
const fixture = TestBed.createComponent(PromotionDetailComponent);
fixture.detectChanges();
const component = fixture.componentInstance;
expect(fixture.nativeElement.textContent as string).toContain('Decision Overview');
expect(fixture.nativeElement.textContent as string).toContain('Manifest digest');
component.setTab('gates');
fixture.detectChanges();
expect(fixture.nativeElement.textContent as string).toContain('Gate Results and Trace');
component.setTab('reachability');
fixture.detectChanges();
expect(fixture.nativeElement.textContent as string).toContain('Contract gap');
component.setTab('history');
fixture.detectChanges();
expect(fixture.nativeElement.textContent as string).toContain('Decision History');
});
});

View File

@@ -0,0 +1,221 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { AdvisorySourcesApi } from '../../app/features/security-risk/advisory-sources.api';
import { AdvisorySourcesComponent } from '../../app/features/security-risk/advisory-sources.component';
describe('AdvisorySourcesComponent (security-risk)', () => {
const advisorySourcesApiStub: Partial<AdvisorySourcesApi> = {
listSources: () =>
of([
{
sourceId: 'source-1',
sourceKey: 'nvd',
sourceName: 'NVD',
sourceFamily: 'nvd',
sourceUrl: 'https://nvd.nist.gov',
priority: 100,
enabled: true,
lastSyncAt: '2026-02-19T08:11:00Z',
lastSuccessAt: '2026-02-19T08:10:00Z',
freshnessAgeSeconds: 3600,
freshnessSlaSeconds: 14400,
freshnessStatus: 'healthy',
signatureStatus: 'signed',
lastError: null,
syncCount: 220,
errorCount: 1,
totalAdvisories: 220,
signedAdvisories: 215,
unsignedAdvisories: 5,
signatureFailureCount: 1,
},
]),
getSummary: () =>
of({
totalSources: 1,
healthySources: 1,
warningSources: 0,
staleSources: 0,
unavailableSources: 0,
disabledSources: 0,
conflictingSources: 0,
dataAsOf: '2026-02-19T08:11:00Z',
}),
getImpact: () =>
of({
sourceId: 'nvd',
sourceFamily: 'nvd',
region: null,
environment: null,
impactedDecisionsCount: 4,
impactSeverity: 'high',
lastDecisionAt: '2026-02-19T08:12:00Z',
decisionRefs: [
{ decisionId: 'APR-2201', decisionType: 'approval', label: 'Approval APR-2201', route: '/release-control/approvals/apr-2201' },
],
dataAsOf: '2026-02-19T08:12:00Z',
}),
listConflicts: () =>
of({
sourceId: 'nvd',
status: 'open',
limit: 20,
offset: 0,
totalCount: 0,
items: [],
dataAsOf: '2026-02-19T08:12:00Z',
}),
getFreshness: () =>
of({
source: {
sourceId: 'source-1',
sourceKey: 'nvd',
sourceName: 'NVD',
sourceFamily: 'nvd',
sourceUrl: 'https://nvd.nist.gov',
priority: 100,
enabled: true,
lastSyncAt: '2026-02-19T08:11:00Z',
lastSuccessAt: '2026-02-19T08:10:00Z',
freshnessAgeSeconds: 3600,
freshnessSlaSeconds: 14400,
freshnessStatus: 'healthy',
signatureStatus: 'signed',
lastError: null,
syncCount: 220,
errorCount: 1,
totalAdvisories: 220,
signedAdvisories: 215,
unsignedAdvisories: 5,
signatureFailureCount: 1,
},
lastSyncAt: '2026-02-19T08:11:00Z',
lastSuccessAt: '2026-02-19T08:10:00Z',
lastError: null,
syncCount: 220,
errorCount: 1,
dataAsOf: '2026-02-19T08:12:00Z',
}),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdvisorySourcesComponent],
providers: [
provideRouter([]),
{ provide: AdvisorySourcesApi, useValue: advisorySourcesApiStub },
],
}).compileComponents();
});
it('renders required header, filters, and summary cards', () => {
const fixture = TestBed.createComponent(AdvisorySourcesComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Advisory Sources');
expect(text).toContain('Region');
expect(text).toContain('Environment');
expect(text).toContain('Source family');
expect(text).toContain('Freshness severity');
expect(text).toContain('Healthy Sources');
expect(text).toContain('Stale Sources');
expect(text).toContain('Unavailable Sources');
expect(text).toContain('Conflicting Sources');
});
it('renders required table columns and ownership split actions', () => {
const fixture = TestBed.createComponent(AdvisorySourcesComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Signature / trust status');
expect(text).toContain('Open connector status');
expect(text).toContain('Open mirror ops');
expect(text).toContain('View impacted findings');
});
it('opens detail panel with required diagnostics sections', () => {
const fixture = TestBed.createComponent(AdvisorySourcesComponent);
fixture.detectChanges();
const inspectButton = (Array.from(
fixture.nativeElement.querySelectorAll('button')
) as HTMLElement[]).find((button) => button.textContent?.includes('Inspect')) as
| HTMLButtonElement
| undefined;
expect(inspectButton).toBeTruthy();
inspectButton?.click();
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Source status timeline');
expect(text).toContain('Conflict diagnostics');
expect(text).toContain('Advisory statistics');
expect(text).toContain('Total advisories: 220');
expect(text).toContain('Signed: 215');
expect(text).toContain('Unsigned: 5');
expect(text).toContain('Signature failures: 1');
expect(text).toContain('Impacted release, approval, and environment references');
});
it('shows hard-fail state with platform-ops path when API fails', async () => {
const failingApiStub: Partial<AdvisorySourcesApi> = {
...advisorySourcesApiStub,
listSources: () => throwError(() => new Error('service down')),
};
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [AdvisorySourcesComponent],
providers: [
provideRouter([]),
{ provide: AdvisorySourcesApi, useValue: failingApiStub },
],
}).compileComponents();
const fixture = TestBed.createComponent(AdvisorySourcesComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Advisory source API is unavailable');
expect(text).toContain('Platform Ops Data Integrity');
});
it('shows empty state with integrations onboarding path', async () => {
const emptyApiStub: Partial<AdvisorySourcesApi> = {
...advisorySourcesApiStub,
listSources: () => of([]),
getSummary: () =>
of({
totalSources: 0,
healthySources: 0,
warningSources: 0,
staleSources: 0,
unavailableSources: 0,
disabledSources: 0,
conflictingSources: 0,
dataAsOf: '2026-02-19T08:11:00Z',
}),
};
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [AdvisorySourcesComponent],
providers: [
provideRouter([]),
{ provide: AdvisorySourcesApi, useValue: emptyApiStub },
],
}).compileComponents();
const fixture = TestBed.createComponent(AdvisorySourcesComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('No advisory sources configured');
expect(text).toContain('Open Integrations Feeds');
});
});

View File

@@ -0,0 +1,147 @@
/**
* Tests for SECURITY_RISK_ROUTES
* Sprint: SPRINT_20260218_014_FE_ui_v2_rewire_security_risk_consolidation (S9-05)
*/
import { SECURITY_RISK_ROUTES } from '../../app/routes/security-risk.routes';
import { Route } from '@angular/router';
describe('SECURITY_RISK_ROUTES', () => {
const getRouteByPath = (path: string): Route | undefined =>
SECURITY_RISK_ROUTES.find((r) => r.path === path);
const allPaths = SECURITY_RISK_ROUTES.map((r) => r.path);
// ──────────────────────────────────────────
// Path existence
// ──────────────────────────────────────────
it('contains the root overview route (empty path)', () => {
expect(allPaths).toContain('');
});
it('contains the findings route', () => {
expect(allPaths).toContain('findings');
});
it('contains the advisory-sources route', () => {
expect(allPaths).toContain('advisory-sources');
});
it('contains the vulnerabilities list route', () => {
expect(allPaths).toContain('vulnerabilities');
});
it('contains the vulnerability detail route', () => {
expect(allPaths).toContain('vulnerabilities/:vulnId');
});
it('contains the reachability route', () => {
expect(allPaths).toContain('reachability');
});
it('contains the risk route', () => {
expect(allPaths).toContain('risk');
});
it('contains the vex route', () => {
expect(allPaths).toContain('vex');
});
it('contains the sbom route', () => {
expect(allPaths).toContain('sbom');
});
it('contains the lineage route', () => {
expect(allPaths).toContain('lineage');
});
it('contains the unknowns route', () => {
expect(allPaths).toContain('unknowns');
});
it('contains the patch-map route', () => {
expect(allPaths).toContain('patch-map');
});
it('contains the artifacts route', () => {
expect(allPaths).toContain('artifacts');
});
it('contains the artifact detail route', () => {
expect(allPaths).toContain('artifacts/:artifactId');
});
it('contains the scan detail route', () => {
expect(allPaths).toContain('scans/:scanId');
});
// ──────────────────────────────────────────
// Overview route breadcrumb
// ──────────────────────────────────────────
it('overview route has "Security and Risk" breadcrumb', () => {
const overviewRoute = getRouteByPath('');
expect(overviewRoute).toBeDefined();
expect(overviewRoute?.data?.['breadcrumb']).toBe('Security and Risk');
});
it('overview route has title "Security and Risk"', () => {
const overviewRoute = getRouteByPath('');
expect(overviewRoute?.title).toBe('Security and Risk');
});
// ──────────────────────────────────────────
// All routes must have breadcrumb data
// ──────────────────────────────────────────
it('every route has a breadcrumb in data', () => {
for (const route of SECURITY_RISK_ROUTES) {
expect(route.data?.['breadcrumb']).toBeTruthy();
}
});
// ──────────────────────────────────────────
// Specific breadcrumb values
// ──────────────────────────────────────────
it('findings route has "Findings" breadcrumb', () => {
expect(getRouteByPath('findings')?.data?.['breadcrumb']).toBe('Findings');
});
it('advisory-sources route has "Advisory Sources" breadcrumb', () => {
expect(getRouteByPath('advisory-sources')?.data?.['breadcrumb']).toBe('Advisory Sources');
});
it('vulnerabilities route has "Vulnerabilities" breadcrumb', () => {
expect(getRouteByPath('vulnerabilities')?.data?.['breadcrumb']).toBe('Vulnerabilities');
});
it('risk route has "Risk Overview" breadcrumb', () => {
expect(getRouteByPath('risk')?.data?.['breadcrumb']).toBe('Risk Overview');
});
it('vex route has "VEX" breadcrumb', () => {
expect(getRouteByPath('vex')?.data?.['breadcrumb']).toBe('VEX');
});
it('sbom route has "SBOM" breadcrumb', () => {
expect(getRouteByPath('sbom')?.data?.['breadcrumb']).toBe('SBOM');
});
it('reachability route has "Reachability" breadcrumb', () => {
expect(getRouteByPath('reachability')?.data?.['breadcrumb']).toBe('Reachability');
});
it('lineage route has "Lineage" breadcrumb', () => {
expect(getRouteByPath('lineage')?.data?.['breadcrumb']).toBe('Lineage');
});
// ──────────────────────────────────────────
// Route count sanity check
// ──────────────────────────────────────────
it('has at least 15 routes defined', () => {
expect(SECURITY_RISK_ROUTES.length).toBeGreaterThanOrEqual(15);
});
});

View File

@@ -1,53 +1,14 @@
import { computed, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { routes } from '../../app/app.routes';
import {
AUTH_SERVICE,
AuthService,
StellaOpsScope,
StellaOpsScopes,
} from '../../app/core/auth';
import { ADVISORY_AI_API, type AdvisoryAiApi } from '../../app/core/api/advisory-ai.client';
import type { RemediationPrSettings } from '../../app/core/api/advisory-ai.models';
import { RemediationPrSettingsComponent } from '../../app/features/settings/remediation-pr-settings.component';
import { SettingsPageComponent } from '../../app/features/settings/settings-page.component';
import { SETTINGS_ROUTES } from '../../app/features/settings/settings.routes';
const createAuthService = (scopes: readonly StellaOpsScope[]): AuthService => {
const scopeSignal = signal<readonly StellaOpsScope[]>(scopes);
const hasScope = (scope: StellaOpsScope): boolean => scopeSignal().includes(scope);
return {
isAuthenticated: signal(true),
user: signal(null),
scopes: computed(() => scopeSignal()),
hasScope,
hasAllScopes: (required) => required.every((scope) => hasScope(scope)),
hasAnyScope: (required) => required.some((scope) => hasScope(scope)),
canViewGraph: () => hasScope(StellaOpsScopes.GRAPH_READ),
canEditGraph: () => hasScope(StellaOpsScopes.GRAPH_WRITE),
canExportGraph: () => hasScope(StellaOpsScopes.GRAPH_EXPORT),
canSimulate: () => hasScope(StellaOpsScopes.GRAPH_SIMULATE),
canViewOrchestrator: () => hasScope(StellaOpsScopes.ORCH_READ),
canOperateOrchestrator: () => hasScope(StellaOpsScopes.ORCH_OPERATE),
canManageOrchestratorQuotas: () => hasScope(StellaOpsScopes.ORCH_QUOTA),
canInitiateBackfill: () => hasScope(StellaOpsScopes.ORCH_BACKFILL),
canViewPolicies: () => hasScope(StellaOpsScopes.POLICY_READ),
canAuthorPolicies: () => hasScope(StellaOpsScopes.POLICY_AUTHOR),
canEditPolicies: () => hasScope(StellaOpsScopes.POLICY_EDIT),
canReviewPolicies: () => hasScope(StellaOpsScopes.POLICY_REVIEW),
canApprovePolicies: () => hasScope(StellaOpsScopes.POLICY_APPROVE),
canOperatePolicies: () => hasScope(StellaOpsScopes.POLICY_OPERATE),
canActivatePolicies: () => hasScope(StellaOpsScopes.POLICY_ACTIVATE),
canSimulatePolicies: () => hasScope(StellaOpsScopes.POLICY_SIMULATE),
canPublishPolicies: () => hasScope(StellaOpsScopes.POLICY_PUBLISH),
canAuditPolicies: () => hasScope(StellaOpsScopes.POLICY_AUDIT),
};
};
const serverSettingsFixture: RemediationPrSettings = {
enabled: true,
defaultAttachEvidenceCard: true,
@@ -87,60 +48,22 @@ describe('unified-settings-page behavior', () => {
'notifications',
'ai-preferences',
'policy',
'offline',
'system',
]);
});
it('hides admin-only categories for non-admin scopes and keeps deterministic base category order', async () => {
it('renders settings shell container', async () => {
await TestBed.configureTestingModule({
imports: [SettingsPageComponent],
providers: [
provideRouter([]),
{ provide: AUTH_SERVICE, useValue: createAuthService([]) },
],
providers: [provideRouter([])],
}).compileComponents();
const fixture: ComponentFixture<SettingsPageComponent> = TestBed.createComponent(SettingsPageComponent);
const component = fixture.componentInstance;
fixture.detectChanges();
expect(component.categories().map((category) => category.id)).toEqual([
'integrations',
'release-control',
'trust',
'security-data',
'branding',
'usage',
'notifications',
'policy',
]);
});
it('shows all 10 categories for admin scope with deterministic ordering', async () => {
await TestBed.configureTestingModule({
imports: [SettingsPageComponent],
providers: [
provideRouter([]),
{ provide: AUTH_SERVICE, useValue: createAuthService([StellaOpsScopes.ADMIN]) },
],
}).compileComponents();
const fixture: ComponentFixture<SettingsPageComponent> = TestBed.createComponent(SettingsPageComponent);
const component = fixture.componentInstance;
fixture.detectChanges();
expect(component.categories().map((category) => category.id)).toEqual([
'integrations',
'release-control',
'trust',
'security-data',
'admin',
'branding',
'usage',
'notifications',
'policy',
'system',
]);
const container = fixture.nativeElement.querySelector('.settings-content');
expect(container).toBeTruthy();
});
it('persists remediation preferences with deterministic key order and resets to deterministic defaults', async () => {

View File

@@ -0,0 +1,360 @@
import { expect, test, type Page } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const shellSession = {
...policyAuthorSession,
scopes: [
...new Set([
...policyAuthorSession.scopes,
'ui.read',
'admin',
'orch:read',
'orch:operate',
'orch:quota',
'findings:read',
'vuln:view',
'vuln:investigate',
'vuln:operate',
'vuln:audit',
'authority:tenants.read',
'advisory:read',
'vex:read',
'exceptions:read',
'exceptions:approve',
'aoc:verify',
]),
],
};
const mockConfig = {
authority: {
issuer: 'http://127.0.0.1:4400/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: 'http://127.0.0.1:4400/authority/connect/authorize',
tokenEndpoint: 'http://127.0.0.1:4400/authority/connect/token',
logoutEndpoint: 'http://127.0.0.1:4400/authority/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'http://127.0.0.1:4400/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
const oidcConfig = {
issuer: mockConfig.authority.issuer,
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
token_endpoint: mockConfig.authority.tokenEndpoint,
jwks_uri: 'http://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};
async function setupShell(page: Page): Promise<void> {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage access errors in restricted contexts
}
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, shellSession);
await page.route('**/platform/envsettings.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('**/authority/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
})
);
await page.route('**/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
})
);
await page.route('**/authority/.well-known/jwks.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ keys: [] }),
})
);
await page.route('**/authority/connect/**', (route) =>
route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'not-used-in-critical-path-e2e' }),
})
);
await page.route('**/api/v1/advisory-sources**', (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (path === '/api/v1/advisory-sources') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
sourceId: 'src-nvd',
sourceKey: 'nvd',
sourceName: 'NVD',
sourceFamily: 'nvd',
sourceUrl: 'https://nvd.nist.gov',
priority: 10,
enabled: true,
lastSyncAt: '2026-02-19T08:00:00Z',
lastSuccessAt: '2026-02-19T08:00:00Z',
freshnessAgeSeconds: 1200,
freshnessSlaSeconds: 7200,
freshnessStatus: 'warning',
signatureStatus: 'signed',
lastError: null,
syncCount: 14,
errorCount: 0,
totalAdvisories: 12345,
signedAdvisories: 12300,
unsignedAdvisories: 45,
signatureFailureCount: 0,
},
],
totalCount: 1,
dataAsOf: '2026-02-19T08:00:00Z',
}),
});
}
if (path === '/api/v1/advisory-sources/summary') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
totalSources: 1,
healthySources: 1,
warningSources: 0,
staleSources: 0,
unavailableSources: 0,
disabledSources: 0,
conflictingSources: 0,
dataAsOf: '2026-02-19T08:00:00Z',
}),
});
}
if (path.endsWith('/impact')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
sourceId: 'src-nvd',
sourceFamily: 'nvd',
region: null,
environment: null,
impactedDecisionsCount: 2,
impactSeverity: 'medium',
lastDecisionAt: '2026-02-19T08:05:00Z',
decisionRefs: [
{
decisionId: 'apr-001',
decisionType: 'approval',
label: 'Approval apr-001',
route: '/release-control/approvals/apr-001',
},
],
dataAsOf: '2026-02-19T08:00:00Z',
}),
});
}
if (path.endsWith('/conflicts')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
sourceId: 'src-nvd',
status: 'open',
limit: 50,
offset: 0,
totalCount: 0,
items: [],
dataAsOf: '2026-02-19T08:00:00Z',
}),
});
}
if (path.endsWith('/freshness')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
source: {
sourceId: 'src-nvd',
sourceKey: 'nvd',
sourceName: 'NVD',
sourceFamily: 'nvd',
sourceUrl: 'https://nvd.nist.gov',
priority: 10,
enabled: true,
lastSyncAt: '2026-02-19T08:00:00Z',
lastSuccessAt: '2026-02-19T08:00:00Z',
freshnessAgeSeconds: 1200,
freshnessSlaSeconds: 7200,
freshnessStatus: 'warning',
signatureStatus: 'signed',
lastError: null,
syncCount: 14,
errorCount: 0,
totalAdvisories: 12345,
signedAdvisories: 12300,
unsignedAdvisories: 45,
signatureFailureCount: 0,
},
lastSyncAt: '2026-02-19T08:00:00Z',
lastSuccessAt: '2026-02-19T08:00:00Z',
lastError: null,
syncCount: 14,
errorCount: 0,
dataAsOf: '2026-02-19T08:00:00Z',
}),
});
}
return route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'not mocked in critical-path e2e' }),
});
});
}
async function go(page: Page, path: string): Promise<void> {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
async function ensureShell(page: Page): Promise<void> {
await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 15000 });
}
async function openSidebarGroupRoute(
page: Page,
groupLabel: string,
targetHref: string
): Promise<void> {
const sidebar = page.locator('aside.sidebar');
const targetLink = sidebar.locator(`a[href="${targetHref}"]`).first();
const isVisible = await targetLink.isVisible().catch(() => false);
if (!isVisible) {
await sidebar.getByRole('button', { name: groupLabel, exact: true }).click();
}
await expect(targetLink).toBeVisible();
await targetLink.click();
}
test.describe.configure({ mode: 'serial' });
test.describe('Critical path shell verification', () => {
test.beforeEach(async ({ page }) => {
await setupShell(page);
});
test('dashboard to release-control setup/bundles/promotions/runs renders canonical flow', async ({
page,
}) => {
await go(page, '/dashboard');
await expect(page).toHaveURL(/\/dashboard$/);
await ensureShell(page);
await expect(page.locator('app-breadcrumb nav.breadcrumb')).toContainText('Dashboard');
await go(page, '/release-control/setup');
await expect(page).toHaveURL(/\/release-control\/setup$/);
await expect(page.locator('app-breadcrumb nav.breadcrumb')).toContainText('Setup');
await go(page, '/release-control/bundles');
await expect(page).toHaveURL(/\/release-control\/bundles$/);
await expect(page.locator('app-breadcrumb nav.breadcrumb')).toContainText('Bundles');
await go(page, '/release-control/promotions');
await expect(page).toHaveURL(/\/release-control\/promotions$/);
await expect(page.locator('app-breadcrumb nav.breadcrumb')).toContainText('Promotions');
await go(page, '/release-control/runs');
await expect(page).toHaveURL(/\/release-control\/runs$/);
await expect(page.locator('app-breadcrumb nav.breadcrumb')).toContainText('Run Timeline');
});
test('security advisory sources preserves ownership split links', async ({ page }) => {
await go(page, '/security-risk/advisory-sources');
await expect(page).toHaveURL(/\/security-risk\/advisory-sources$/);
await ensureShell(page);
await expect(page.locator('body')).toContainText('Advisory Sources');
await expect(page.locator('a[href*="/integrations/feeds"]').first()).toBeVisible();
await expect(page.locator('a[href*="/platform-ops/feeds"]').first()).toBeVisible();
await expect(page.locator('a[href*="/security-risk/findings"]').first()).toBeVisible();
});
test('evidence routes expose replay, timeline, proofs, and trust ownership link', async ({ page }) => {
await go(page, '/evidence-audit');
await expect(page).toHaveURL(/\/evidence-audit$/);
await ensureShell(page);
await expect(page.locator('body')).toContainText('Evidence Surfaces');
await expect(page.locator('a[href="/administration/trust-signing"]').first()).toBeVisible();
await go(page, '/evidence-audit/replay');
await expect(page).toHaveURL(/\/evidence-audit\/replay$/);
await expect(page.locator('app-breadcrumb nav.breadcrumb')).toContainText('Replay / Verify');
await go(page, '/evidence-audit/timeline');
await expect(page).toHaveURL(/\/evidence-audit\/timeline$/);
await expect(page.getByRole('heading', { name: /Timeline/i }).first()).toBeVisible();
await go(page, '/evidence-audit/proofs');
await expect(page).toHaveURL(/\/evidence-audit\/proofs$/);
await expect(page.locator('app-breadcrumb nav.breadcrumb')).toContainText('Proof Chains');
});
test('integrations and platform-ops split navigation remains intact', async ({ page }) => {
await go(page, '/dashboard');
await ensureShell(page);
await expect(page.locator('aside.sidebar')).toContainText('Integrations');
await openSidebarGroupRoute(page, 'Platform Ops', '/platform-ops/data-integrity');
await expect(page).toHaveURL(/\/platform-ops\/data-integrity$/);
await expect(page.locator('body')).toContainText('Data Integrity');
await expect(page.locator('a[href="/security-risk/advisory-sources"]').first()).toBeVisible();
});
});

View File

@@ -0,0 +1,244 @@
import { expect, test, type Page } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const shellSession = {
...policyAuthorSession,
scopes: [
...new Set([
...policyAuthorSession.scopes,
'ui.read',
'admin',
'orch:read',
'orch:operate',
'orch:quota',
'findings:read',
'vuln:view',
'vuln:investigate',
'vuln:operate',
'vuln:audit',
'authority:tenants.read',
'advisory:read',
'vex:read',
'exceptions:read',
'exceptions:approve',
'aoc:verify',
]),
],
};
const mockConfig = {
authority: {
issuer: 'http://127.0.0.1:4400/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: 'http://127.0.0.1:4400/authority/connect/authorize',
tokenEndpoint: 'http://127.0.0.1:4400/authority/connect/token',
logoutEndpoint: 'http://127.0.0.1:4400/authority/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'http://127.0.0.1:4400/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
const oidcConfig = {
issuer: mockConfig.authority.issuer,
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
token_endpoint: mockConfig.authority.tokenEndpoint,
jwks_uri: 'http://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};
async function setupShell(page: Page): Promise<void> {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage access errors in restricted contexts
}
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, shellSession);
await page.route('**/platform/envsettings.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('**/authority/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
})
);
await page.route('**/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
})
);
await page.route('**/authority/.well-known/jwks.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ keys: [] }),
})
);
await page.route('**/authority/connect/**', (route) =>
route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'not-used-in-a11y-e2e' }),
})
);
}
async function go(page: Page, path: string): Promise<void> {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
async function ensureShell(page: Page): Promise<void> {
await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 15000 });
}
async function openSidebarGroupRoute(
page: Page,
groupLabel: string,
targetHref: string
): Promise<void> {
const sidebar = page.locator('aside.sidebar');
const targetLink = sidebar.locator(`a[href="${targetHref}"]`).first();
const isVisible = await targetLink.isVisible().catch(() => false);
if (!isVisible) {
await sidebar.getByRole('button', { name: groupLabel, exact: true }).click();
}
await expect(targetLink).toBeVisible();
await targetLink.click();
}
test.describe.configure({ mode: 'serial' });
test.describe('IA v2 accessibility and regression', () => {
test.beforeEach(async ({ page }) => {
await setupShell(page);
});
test('canonical roots expose landmarks and navigation controls', async ({ page }) => {
const roots = [
'/dashboard',
'/release-control',
'/security-risk',
'/evidence-audit',
'/administration',
];
for (const path of roots) {
await go(page, path);
await ensureShell(page);
const landmarkCount = await page.locator('main, [role="main"], nav, [role="navigation"]').count();
expect(landmarkCount).toBeGreaterThan(1);
await expect(page.locator('aside.sidebar a, aside.sidebar button').first()).toBeVisible();
}
// /platform-ops and /integrations are proxy-captured in dev mode.
// Validate both via in-app navigation.
await go(page, '/dashboard');
await openSidebarGroupRoute(page, 'Platform Ops', '/platform-ops/data-integrity');
await expect(page).toHaveURL(/\/platform-ops\/data-integrity$/);
await go(page, '/dashboard');
await openSidebarGroupRoute(page, 'Integrations', '/integrations');
await expect(page).toHaveURL(/\/integrations$/);
});
test('keyboard navigation moves focus across shell controls', async ({ page }) => {
await go(page, '/dashboard');
await ensureShell(page);
const focusedElements: string[] = [];
for (let i = 0; i < 10; i += 1) {
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => {
const element = document.activeElement as HTMLElement | null;
if (!element) return 'none';
return `${element.tagName.toLowerCase()}::${element.className || element.id || 'no-id'}`;
});
focusedElements.push(focused);
}
expect(new Set(focusedElements).size).toBeGreaterThan(3);
});
test('deprecated root labels are absent from primary nav', async ({ page }) => {
await go(page, '/dashboard');
await ensureShell(page);
const navText = (await page.locator('aside.sidebar nav').textContent()) ?? '';
expect(navText).not.toContain('Operations');
expect(navText).not.toContain('Policy Studio');
expect(navText).not.toContain('\nSecurity\n');
expect(navText).not.toContain('\nEvidence\n');
});
test('breadcrumbs render canonical ownership on key shell routes', async ({ page }) => {
const checks: Array<{ path: string; expected: string }> = [
{ path: '/release-control/setup', expected: 'Setup' },
{ path: '/security-risk/advisory-sources', expected: 'Advisory Sources' },
{ path: '/evidence-audit/replay', expected: 'Replay / Verify' },
{ path: '/platform-ops/data-integrity', expected: 'Data Integrity' },
{ path: '/administration/trust-signing', expected: 'Trust & Signing' },
];
for (const check of checks) {
if (check.path === '/platform-ops/data-integrity') {
await go(page, '/dashboard');
await openSidebarGroupRoute(page, 'Platform Ops', '/platform-ops/data-integrity');
} else {
await go(page, check.path);
}
await ensureShell(page);
const breadcrumb = page.locator('app-breadcrumb nav.breadcrumb');
await expect(breadcrumb).toHaveCount(1);
await expect(breadcrumb).toContainText(check.expected);
}
});
test('mobile viewport keeps shell usable without horizontal overflow', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await go(page, '/dashboard');
await expect(page.locator('.topbar__menu-toggle')).toBeVisible();
const hasHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
);
expect(hasHorizontalScroll).toBe(false);
});
});

View File

@@ -0,0 +1,316 @@
import { expect, test, type Page } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const shellSession = {
...policyAuthorSession,
scopes: [
...new Set([
...policyAuthorSession.scopes,
'ui.read',
'admin',
'orch:read',
'orch:operate',
'orch:quota',
'findings:read',
'vuln:view',
'vuln:investigate',
'vuln:operate',
'vuln:audit',
'authority:tenants.read',
'advisory:read',
'vex:read',
'exceptions:read',
'exceptions:approve',
'aoc:verify',
]),
],
};
const mockConfig = {
authority: {
issuer: 'http://127.0.0.1:4400/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: 'http://127.0.0.1:4400/authority/connect/authorize',
tokenEndpoint: 'http://127.0.0.1:4400/authority/connect/token',
logoutEndpoint: 'http://127.0.0.1:4400/authority/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'http://127.0.0.1:4400/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
const oidcConfig = {
issuer: mockConfig.authority.issuer,
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
token_endpoint: mockConfig.authority.tokenEndpoint,
jwks_uri: 'http://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};
async function setupShell(page: Page): Promise<void> {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage access errors in restricted contexts
}
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, shellSession);
await page.route('**/platform/envsettings.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('**/authority/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
})
);
await page.route('**/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
})
);
await page.route('**/authority/.well-known/jwks.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ keys: [] }),
})
);
await page.route('**/authority/connect/**', (route) =>
route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'not-used-in-shell-e2e' }),
})
);
}
async function go(page: Page, path: string): Promise<void> {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
async function ensureShell(page: Page): Promise<void> {
await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 15000 });
}
async function openSidebarGroupRoute(
page: Page,
groupLabel: string,
targetHref: string
): Promise<void> {
const sidebar = page.locator('aside.sidebar');
const targetLink = sidebar.locator(`a[href="${targetHref}"]`).first();
const isVisible = await targetLink.isVisible().catch(() => false);
if (!isVisible) {
await sidebar.getByRole('button', { name: groupLabel, exact: true }).click();
}
await expect(targetLink).toBeVisible();
await targetLink.click();
}
function collectConsoleErrors(page: Page): string[] {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', (err) => errors.push(err.message));
return errors;
}
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ page }) => {
await setupShell(page);
});
test.describe('Nav shell canonical domains', () => {
test('sidebar renders all canonical root labels', async ({ page }) => {
await go(page, '/dashboard');
await ensureShell(page);
const navText = (await page.locator('aside.sidebar').textContent()) ?? '';
const labels = [
'Dashboard',
'Release Control',
'Security and Risk',
'Evidence and Audit',
'Integrations',
'Platform Ops',
'Administration',
];
for (const label of labels) {
expect(navText).toContain(label);
}
});
test('sidebar excludes deprecated v1 labels', async ({ page }) => {
await go(page, '/dashboard');
await ensureShell(page);
const navText = (await page.locator('aside.sidebar').textContent()) ?? '';
expect(navText).not.toContain('Operations');
expect(navText).not.toContain('Policy Studio');
});
});
test.describe('Nav shell critical legacy redirects', () => {
const redirects: Array<{ from: string; expectedPrefix: string }> = [
{ from: '/findings', expectedPrefix: '/security-risk/findings' },
{ from: '/vulnerabilities', expectedPrefix: '/security-risk/vulnerabilities' },
{ from: '/evidence-packs', expectedPrefix: '/evidence-audit/packs' },
{ from: '/admin/audit', expectedPrefix: '/evidence-audit/audit' },
{ from: '/ops/health', expectedPrefix: '/platform-ops/health' },
{ from: '/admin/notifications', expectedPrefix: '/administration/notifications' },
{ from: '/release-orchestrator/releases', expectedPrefix: '/release-control/releases' },
{ from: '/release-orchestrator/approvals', expectedPrefix: '/release-control/approvals' },
{ from: '/release-orchestrator/environments', expectedPrefix: '/release-control/environments' },
{ from: '/settings/release-control', expectedPrefix: '/release-control/setup' },
];
for (const redirect of redirects) {
test(`${redirect.from} redirects correctly`, async ({ page }) => {
await go(page, redirect.from);
const finalUrl = new URL(page.url());
expect(finalUrl.pathname.startsWith(redirect.expectedPrefix)).toBe(true);
});
}
test('redirect preserves query parameters', async ({ page }) => {
await go(page, '/findings?filter=critical&sort=severity');
const finalUrl = page.url();
expect(finalUrl).toContain('/security-risk/findings');
expect(finalUrl).toContain('filter=critical');
expect(finalUrl).toContain('sort=severity');
});
test('redirect preserves fragments', async ({ page }) => {
await go(page, '/admin/audit#recent');
const finalUrl = page.url();
expect(finalUrl).toContain('/evidence-audit/audit');
expect(finalUrl).toContain('#recent');
});
test('release-orchestrator root redirect does not loop', async ({ page }) => {
await go(page, '/release-orchestrator');
const finalUrl = new URL(page.url());
expect(finalUrl.pathname).not.toBe('/release-orchestrator');
});
});
test.describe('Nav shell breadcrumbs and stability', () => {
const breadcrumbRoutes: Array<{ path: string; expected: string }> = [
{ path: '/release-control/releases', expected: 'Release Control' },
{ path: '/release-control/setup', expected: 'Setup' },
{ path: '/security-risk/advisory-sources', expected: 'Advisory Sources' },
{ path: '/evidence-audit/replay', expected: 'Replay / Verify' },
{ path: '/platform-ops/data-integrity', expected: 'Data Integrity' },
{ path: '/administration/trust-signing', expected: 'Trust & Signing' },
];
for (const route of breadcrumbRoutes) {
test(`breadcrumb renders on ${route.path}`, async ({ page }) => {
if (route.path === '/platform-ops/data-integrity') {
await go(page, '/dashboard');
await openSidebarGroupRoute(page, 'Platform Ops', '/platform-ops/data-integrity');
} else {
await go(page, route.path);
}
await ensureShell(page);
const breadcrumb = page.locator('app-breadcrumb nav.breadcrumb');
await expect(breadcrumb).toHaveCount(1);
await expect(breadcrumb).toContainText(route.expected);
});
}
test('canonical roots produce no app runtime errors', async ({ page }) => {
const errors = collectConsoleErrors(page);
const routes = [
'/dashboard',
'/release-control',
'/security-risk',
'/evidence-audit',
'/administration',
];
for (const route of routes) {
await go(page, route);
await ensureShell(page);
}
// /platform-ops and /integrations are proxy-captured in dev mode.
// Validate them through client-side navigation instead of direct reload.
await go(page, '/dashboard');
await openSidebarGroupRoute(page, 'Platform Ops', '/platform-ops/data-integrity');
await expect(page).toHaveURL(/\/platform-ops\/data-integrity$/);
await go(page, '/dashboard');
await openSidebarGroupRoute(page, 'Integrations', '/integrations');
await expect(page).toHaveURL(/\/integrations$/);
const appErrors = errors.filter(
(error) =>
!error.includes('ERR_FAILED') &&
!error.includes('ERR_BLOCKED') &&
!error.includes('ERR_CONNECTION_REFUSED') &&
!error.includes('404') &&
error.length > 0
);
expect(appErrors).toEqual([]);
});
});
test.describe('Nav shell responsive layout', () => {
test('desktop viewport shows sidebar', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await go(page, '/dashboard');
await expect(page.locator('aside.sidebar')).toBeVisible();
});
test('mobile viewport remains usable without horizontal overflow', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await go(page, '/dashboard');
await expect(page.locator('.topbar__menu-toggle')).toBeVisible();
const hasHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
);
expect(hasHorizontalScroll).toBe(false);
});
});