Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -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: '**',
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 & 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 & 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: '⚙️',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 & 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));
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
@@ -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 > 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">▶</span>
|
||||
Release Control
|
||||
</a>
|
||||
<a routerLink="/security-risk" class="domain-nav-item">
|
||||
<span class="domain-icon">■</span>
|
||||
Security & Risk
|
||||
</a>
|
||||
<a routerLink="/platform-ops" class="domain-nav-item">
|
||||
<span class="domain-icon">◆</span>
|
||||
Platform Ops
|
||||
</a>
|
||||
<a routerLink="/evidence-audit" class="domain-nav-item">
|
||||
<span class="domain-icon">●</span>
|
||||
Evidence & Audit
|
||||
</a>
|
||||
<a routerLink="/administration" class="domain-nav-item">
|
||||
<span class="domain-icon">⚙</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);
|
||||
}
|
||||
}
|
||||
@@ -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 & 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 }} →</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">▶</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">■</span>
|
||||
<div class="cross-link-body">
|
||||
<div class="cross-link-title">Administration > Trust & 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">◆</span>
|
||||
<div class="cross-link-body">
|
||||
<div class="cross-link-title">Administration > 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">●</span>
|
||||
<div class="cross-link-body">
|
||||
<div class="cross-link-title">Security & Risk > 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 > Trust & Signing</a>.
|
||||
Evidence & 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: '📦',
|
||||
status: 'info',
|
||||
},
|
||||
{
|
||||
title: 'Proof Chains',
|
||||
description: 'Cryptographic proof chain traversal from subject digest to attestation.',
|
||||
link: '/evidence-audit/proofs',
|
||||
linkLabel: 'View proofs',
|
||||
icon: '🔒',
|
||||
status: 'info',
|
||||
},
|
||||
{
|
||||
title: 'Replay and Verify',
|
||||
description: 'Replay historical verdict decisions and verify deterministic evidence outcomes.',
|
||||
link: '/evidence-audit/replay',
|
||||
linkLabel: 'Open replay',
|
||||
icon: '↻',
|
||||
status: 'warning',
|
||||
},
|
||||
{
|
||||
title: 'Timeline',
|
||||
description: 'Timeline and checkpoint history for release evidence progression.',
|
||||
link: '/evidence-audit/timeline',
|
||||
linkLabel: 'Open timeline',
|
||||
icon: '⏰',
|
||||
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: '📄',
|
||||
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: '📊',
|
||||
status: 'info',
|
||||
},
|
||||
{
|
||||
title: 'Evidence Export',
|
||||
description: 'Export center: bundle exports, replay/verify, and scoped export jobs.',
|
||||
link: '/evidence-audit/evidence',
|
||||
linkLabel: 'Export center',
|
||||
icon: '📢',
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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 & 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',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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: () => {},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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`, {});
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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: '🤖',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 & 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">→</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">→</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">→</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">→</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">→</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">→</div>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- Contextual navigation links -->
|
||||
<section class="context-links" aria-label="Related surfaces">
|
||||
<h2 class="context-links-title">More in Security & 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 > Data Integrity</a>.
|
||||
Security & 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',
|
||||
});
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
@@ -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)}%`;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
287
src/Web/StellaOps.Web/src/app/routes/administration.routes.ts
Normal file
287
src/Web/StellaOps.Web/src/app/routes/administration.routes.ts
Normal 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
|
||||
),
|
||||
},
|
||||
];
|
||||
17
src/Web/StellaOps.Web/src/app/routes/dashboard.routes.ts
Normal file
17
src/Web/StellaOps.Web/src/app/routes/dashboard.routes.ts
Normal 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
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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) },
|
||||
];
|
||||
@@ -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) => ({
|
||||
|
||||
309
src/Web/StellaOps.Web/src/app/routes/platform-ops.routes.ts
Normal file
309
src/Web/StellaOps.Web/src/app/routes/platform-ops.routes.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
161
src/Web/StellaOps.Web/src/app/routes/release-control.routes.ts
Normal file
161
src/Web/StellaOps.Web/src/app/routes/release-control.routes.ts
Normal 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
|
||||
),
|
||||
},
|
||||
];
|
||||
62
src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts
Normal file
62
src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts
Normal 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) },
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
148
src/Web/StellaOps.Web/src/tests/navigation/nav-model.spec.ts
Normal file
148
src/Web/StellaOps.Web/src/tests/navigation/nav-model.spec.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
360
src/Web/StellaOps.Web/tests/e2e/critical-path.spec.ts
Normal file
360
src/Web/StellaOps.Web/tests/e2e/critical-path.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
244
src/Web/StellaOps.Web/tests/e2e/ia-v2-a11y-regression.spec.ts
Normal file
244
src/Web/StellaOps.Web/tests/e2e/ia-v2-a11y-regression.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
316
src/Web/StellaOps.Web/tests/e2e/nav-shell.spec.ts
Normal file
316
src/Web/StellaOps.Web/tests/e2e/nav-shell.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user