partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -418,6 +418,22 @@ export const routes: Routes = [
loadChildren: () =>
import('./features/feed-mirror/feed-mirror.routes').then((m) => m.feedMirrorRoutes),
},
// Ops - Signals Runtime Dashboard (SPRINT_20260208_072)
{
path: 'ops/signals',
title: 'Signals Runtime Dashboard',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/signals/signals.routes').then((m) => m.SIGNALS_ROUTES),
},
// Ops - Pack Registry Browser (SPRINT_20260208_068)
{
path: 'ops/packs',
title: 'Pack Registry Browser',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireOrchViewerGuard],
loadChildren: () =>
import('./features/pack-registry/pack-registry.routes').then((m) => m.PACK_REGISTRY_ROUTES),
},
{
path: 'sbom-sources',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],

View File

@@ -0,0 +1,84 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
export interface AuditReasonRecord {
verdictId: string;
policyName: string;
ruleId: string;
graphRevisionId: string;
inputsDigest: string;
evaluatedAt: string;
reasonLines: string[];
evidenceRefs: string[];
}
@Injectable({ providedIn: 'root' })
export class AuditReasonsClient {
private readonly http = inject(HttpClient);
getReason(verdictId: string): Observable<AuditReasonRecord> {
return this.http
.get<AuditReasonRecord>(`/api/audit/reasons/${encodeURIComponent(verdictId)}`)
.pipe(catchError(() => of(this.buildMockReason(verdictId))));
}
private buildMockReason(verdictId: string): AuditReasonRecord {
const hash = this.hash(verdictId);
const policyName = this.pickPolicyName(hash);
const ruleId = `RULE-${(100 + (hash % 900)).toString()}`;
const graphRevisionId = `graph-r${(1 + (hash % 250)).toString().padStart(3, '0')}`;
const digest = this.toDigest(hash, verdictId);
return {
verdictId,
policyName,
ruleId,
graphRevisionId,
inputsDigest: digest,
evaluatedAt: this.toEvaluatedAt(hash),
reasonLines: [
`Policy ${policyName} matched risk posture and release context.`,
`Rule ${ruleId} evaluated deterministic evidence for verdict scope.`,
`Graph revision ${graphRevisionId} confirmed path constraints for this decision.`,
],
evidenceRefs: [
`stella://policy/${encodeURIComponent(policyName)}/${ruleId}`,
`stella://graph/${graphRevisionId}`,
`stella://inputs/${digest}`,
],
};
}
private pickPolicyName(hash: number): string {
const names = [
'default-release-gate',
'runtime-assurance-pack',
'risk-threshold-policy',
'promotion-safety-policy',
];
return names[hash % names.length];
}
private toEvaluatedAt(hash: number): string {
const base = Date.UTC(2026, 0, 1, 0, 0, 0);
const offsetMinutes = hash % (60 * 24 * 90);
return new Date(base + offsetMinutes * 60000).toISOString();
}
private toDigest(hash: number, source: string): string {
const seed = `${source}:${hash.toString(16)}`;
const digestHex = this.hash(seed).toString(16).padStart(8, '0');
return `sha256:${digestHex}${digestHex}${digestHex}${digestHex}`;
}
private hash(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = ((hash << 5) - hash) + value.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
}

View File

@@ -254,7 +254,7 @@ export class GatewayMetricsService {
*/
log(entry: Omit<GatewayLogEntry, 'timestamp' | 'tenantId'>): void {
const tenantId = this.tenantService.activeTenantId() ?? 'unknown';
const projectId = this.tenantService.activeProjectId();
const projectId = this.tenantService.activeProjectId() ?? undefined;
const logEntry: GatewayLogEntry = {
...entry,

View File

@@ -216,6 +216,13 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
icon: 'database',
tooltip: 'Manage SBOM ingestion sources and run history',
},
{
id: 'pack-registry',
label: 'Pack Registry',
route: '/ops/packs',
icon: 'package',
tooltip: 'Browse TaskRunner packs, verify DSSE metadata, and run compatibility-checked installs/upgrades',
},
{
id: 'quotas',
label: 'Quota Dashboard',

View File

@@ -142,6 +142,7 @@
Severity {{ getSortIcon('severity') }}
</th>
<th class="col-status">Status</th>
<th class="col-why">Why</th>
</tr>
</thead>
<tbody>
@@ -214,10 +215,16 @@
{{ finding.status }}
</span>
</td>
<td class="col-why" (click)="$event.stopPropagation()">
<app-reason-capsule
[verdictId]="finding.verdictId ?? finding.id"
[findingId]="finding.id"
/>
</td>
</tr>
} @empty {
<tr class="empty-row">
<td colspan="8">
<td colspan="9">
@if (scoredFindings().length === 0) {
No findings to display.
} @else {

View File

@@ -312,6 +312,10 @@
width: 100px;
}
.col-why {
min-width: 180px;
}
// Cell content
.score-loading {
display: inline-block;
@@ -435,7 +439,8 @@
}
.col-flags,
.col-status {
.col-status,
.col-why {
display: none;
}
}
@@ -537,6 +542,10 @@
display: none;
}
.col-why {
display: none;
}
.advisory-id {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);

View File

@@ -25,6 +25,7 @@ import {
} from '../../shared/components/score';
import { ExportAuditPackButtonComponent } from '../../shared/components/audit-pack';
import { VexTrustChipComponent, VexTrustPopoverComponent, TrustChipPopoverEvent } from '../../shared/components';
import { ReasonCapsuleComponent } from '../triage/components/reason-capsule/reason-capsule.component';
/**
* Finding model for display in the list.
@@ -46,6 +47,8 @@ export interface Finding {
publishedAt?: string;
/** Gating status with VEX trust info (SPRINT_1227_0004_0002) */
gatingStatus?: { vexTrustStatus?: import('../triage/models/gating.model').VexTrustStatus };
/** Optional verdict identifier for audit reason capsules. */
verdictId?: string;
}
/**
@@ -105,7 +108,8 @@ export interface FindingsFilter {
ScoreBreakdownPopoverComponent,
ExportAuditPackButtonComponent,
VexTrustChipComponent,
VexTrustPopoverComponent
VexTrustPopoverComponent,
ReasonCapsuleComponent
],
providers: [
{ provide: SCORING_API, useClass: MockScoringApi },

View File

@@ -250,12 +250,12 @@ const VIEWPORT_PADDING = 100;
[attr.height]="node.height + 12"
rx="12"
fill="none"
[attr.stroke]="getReachabilityHaloStroke(reach.status)"
[attr.stroke]="getReachabilityHaloStroke(reach.latticeState)"
stroke-width="3"
stroke-dasharray="5 4"
opacity="0.85"
>
<title>{{ reach.status }} ({{ (reach.confidence * 100).toFixed(0) }}%) · {{ reach.observedAt }}</title>
<title>{{ reach.latticeState }} {{ reach.status }} ({{ (reach.confidence * 100).toFixed(0) }}%) - {{ reach.observedAt }}</title>
</rect>
}
@@ -1170,10 +1170,10 @@ export class GraphCanvasComponent implements OnChanges, AfterViewInit, OnDestroy
getTypeIcon(type: string): string {
switch (type) {
case 'asset': return '\uD83D\uDCE6'; // 📦
case 'component': return '\uD83E\uDDE9'; // 🧩
case 'vulnerability': return '\u26A0\uFE0F'; // ⚠️
default: return '\u2022'; //
case 'asset': return '\uD83D\uDCE6'; // package icon
case 'component': return '\uD83E\uDDE9'; // component icon
case 'vulnerability': return '\u26A0\uFE0F'; // warning icon
default: return '\u2022'; // bullet icon
}
}
@@ -1182,12 +1182,22 @@ export class GraphCanvasComponent implements OnChanges, AfterViewInit, OnDestroy
return this.overlayState.reachability.get(nodeId) ?? null;
}
getReachabilityHaloStroke(status: ReachabilityOverlayData['status']): string {
switch (status) {
case 'reachable':
return '#22c55e';
case 'unreachable':
return '#9A8F78';
getReachabilityHaloStroke(latticeState: ReachabilityOverlayData['latticeState']): string {
switch (latticeState) {
case 'SR':
return '#16a34a';
case 'SU':
return '#65a30d';
case 'RO':
return '#0284c7';
case 'RU':
return '#0ea5e9';
case 'CR':
return '#f59e0b';
case 'CU':
return '#f97316';
case 'X':
return '#94a3b8';
default:
return '#f59e0b';
}

View File

@@ -55,6 +55,7 @@ export interface ExposureOverlayData {
export interface ReachabilityOverlayData {
nodeId: string;
latticeState: 'SR' | 'SU' | 'RO' | 'RU' | 'CR' | 'CU' | 'X';
status: 'reachable' | 'unreachable' | 'unknown';
confidence: number;
observedAt: string;
@@ -68,6 +69,13 @@ export interface GraphOverlayState {
reachability: Map<string, ReachabilityOverlayData>;
}
type SnapshotKey = 'current' | '1d' | '7d' | '30d';
interface SnapshotEvent {
label: string;
description: string;
}
// Mock overlay data generators
function stableHash(input: string): number {
let hash = 2166136261;
@@ -160,37 +168,28 @@ function generateMockExposureData(nodeIds: string[]): Map<string, ExposureOverla
return data;
}
function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<string, ReachabilityOverlayData> {
function generateMockReachabilityData(nodeIds: string[], snapshot: SnapshotKey): Map<string, ReachabilityOverlayData> {
const data = new Map<string, ReachabilityOverlayData>();
const snapshotDays: Record<string, number> = { current: 0, '1d': 1, '7d': 7, '30d': 30 };
const days = snapshotDays[snapshot] ?? 0;
const base = Date.parse('2025-12-12T00:00:00Z');
const observedAt = new Date(base - days * 24 * 60 * 60 * 1000).toISOString();
const latticeStates: ReachabilityOverlayData['latticeState'][] = ['SR', 'SU', 'RO', 'RU', 'CR', 'CU', 'X'];
for (const nodeId of nodeIds) {
const normalized = nodeId.toLowerCase();
let status: ReachabilityOverlayData['status'] = 'unknown';
let confidence = 0.0;
if (normalized.includes('log4j') || normalized.includes('log4shell')) {
status = 'unreachable';
confidence = 0.95;
} else if (
normalized.includes('curl') ||
normalized.includes('nghttp2') ||
normalized.includes('golang') ||
normalized.includes('jwt') ||
normalized.includes('jsonwebtoken')
) {
status = 'reachable';
confidence = 0.88;
} else if (normalized.includes('spring')) {
status = 'reachable';
confidence = 0.6;
}
const hash = stableHash(`reach:${nodeId}:${snapshot}`);
const latticeState = latticeStates[hash % latticeStates.length];
const status: ReachabilityOverlayData['status'] =
latticeState === 'X'
? 'unknown'
: latticeState === 'SR' || latticeState === 'RO' || latticeState === 'CR'
? 'reachable'
: 'unreachable';
const confidence = Number((0.45 + fraction(hash) * 0.5).toFixed(2));
data.set(nodeId, {
nodeId,
latticeState,
status,
confidence,
observedAt,
@@ -330,19 +329,35 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
@if (isOverlayEnabled('reachability')) {
<div class="legend-section">
<h4 class="legend-section__title">Reachability</h4>
<h4 class="legend-section__title">Reachability Lattice</h4>
<div class="legend-items">
<div class="legend-item">
<span class="legend-dot legend-dot--reachability-reachable"></span>
<span>Reachable</span>
<span class="legend-dot legend-dot--lattice-sr"></span>
<span>SR - Strong reachable</span>
</div>
<div class="legend-item">
<span class="legend-dot legend-dot--reachability-unreachable"></span>
<span>Unreachable</span>
<span class="legend-dot legend-dot--lattice-su"></span>
<span>SU - Strong unreachable</span>
</div>
<div class="legend-item">
<span class="legend-dot legend-dot--reachability-unknown"></span>
<span>Unknown</span>
<span class="legend-dot legend-dot--lattice-ro"></span>
<span>RO - Reachable observed</span>
</div>
<div class="legend-item">
<span class="legend-dot legend-dot--lattice-ru"></span>
<span>RU - Unreachable observed</span>
</div>
<div class="legend-item">
<span class="legend-dot legend-dot--lattice-cr"></span>
<span>CR - Conditionally reachable</span>
</div>
<div class="legend-item">
<span class="legend-dot legend-dot--lattice-cu"></span>
<span>CU - Conditionally unreachable</span>
</div>
<div class="legend-item">
<span class="legend-dot legend-dot--lattice-x"></span>
<span>X - Unknown</span>
</div>
</div>
</div>
@@ -462,6 +477,9 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
{{ getReachabilityData(selectedNodeId)!.status }}
</span>
</div>
<div class="overlay-detail-card__info">
Lattice state: {{ getReachabilityData(selectedNodeId)!.latticeState }}
</div>
<div class="overlay-detail-card__info">
Confidence: {{ (getReachabilityData(selectedNodeId)!.confidence * 100).toFixed(0) }}%
</div>
@@ -547,6 +565,10 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
/>
<span class="time-slider__label">{{ snapshotLabel() }}</span>
</div>
<div class="time-timeline" role="note">
<strong>{{ activeSnapshotEvent().label }}</strong>
<span>{{ activeSnapshotEvent().description }}</span>
</div>
<select
class="time-travel-select"
[value]="selectedSnapshot()"
@@ -736,9 +758,13 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
&--exposure-internal { background: #f59e0b; }
&--exposure-isolated { background: #22c55e; }
&--reachability-reachable { background: #22c55e; }
&--reachability-unreachable { background: var(--color-text-muted); }
&--reachability-unknown { background: #f59e0b; }
&--lattice-sr { background: #16a34a; }
&--lattice-su { background: #65a30d; }
&--lattice-ro { background: #0284c7; }
&--lattice-ru { background: #0ea5e9; }
&--lattice-cr { background: #f59e0b; }
&--lattice-cu { background: #f97316; }
&--lattice-x { background: #94a3b8; }
}
/* Overlay details */
@@ -961,6 +987,23 @@ function generateMockReachabilityData(nodeIds: string[], snapshot: string): Map<
white-space: nowrap;
}
.time-timeline {
display: grid;
gap: 0.125rem;
min-width: 180px;
font-size: 0.72rem;
color: var(--color-text-secondary);
padding: 0.25rem 0.5rem;
border: 1px dashed rgba(212, 201, 168, 0.35);
border-radius: 0.375rem;
background: rgba(255, 255, 255, 0.7);
}
.time-timeline strong {
font-size: 0.74rem;
color: var(--color-text-primary);
}
.time-travel-select {
padding: 0.375rem 0.5rem;
border: 1px solid rgba(212, 201, 168, 0.3);
@@ -1043,11 +1086,29 @@ export class GraphOverlaysComponent implements OnChanges {
readonly pathViewEnabled = signal(false);
readonly pathType = signal<'shortest' | 'attack' | 'dependency'>('shortest');
readonly timeTravelEnabled = signal(false);
readonly selectedSnapshot = signal<string>('current');
private readonly snapshotOrder = ['current', '1d', '7d', '30d'] as const;
readonly selectedSnapshot = signal<SnapshotKey>('current');
private readonly snapshotOrder: readonly SnapshotKey[] = ['current', '1d', '7d', '30d'];
private readonly snapshotEvents: Record<SnapshotKey, SnapshotEvent> = {
current: {
label: 'Current snapshot',
description: 'Latest deterministic reachability lattice from the active scan window.',
},
'1d': {
label: '1 day ago',
description: 'Short-term regression check after recent package and policy updates.',
},
'7d': {
label: '7 days ago',
description: 'Weekly operational baseline used for release gate drift comparison.',
},
'30d': {
label: '30 days ago',
description: 'Long-window historical baseline for replay and audit evidence review.',
},
};
readonly snapshotIndex = computed(() => {
const idx = this.snapshotOrder.indexOf(this.selectedSnapshot() as (typeof this.snapshotOrder)[number]);
const idx = this.snapshotOrder.indexOf(this.selectedSnapshot());
return idx === -1 ? 0 : idx;
});
@@ -1064,6 +1125,8 @@ export class GraphOverlaysComponent implements OnChanges {
}
});
readonly activeSnapshotEvent = computed<SnapshotEvent>(() => this.snapshotEvents[this.selectedSnapshot()]);
// Computed
readonly hasActiveOverlays = computed(() =>
this.overlayConfigs().some(c => c.enabled)
@@ -1127,10 +1190,11 @@ export class GraphOverlaysComponent implements OnChanges {
}
setSnapshot(snapshot: string): void {
this.selectedSnapshot.set(snapshot);
const normalizedSnapshot = this.normalizeSnapshot(snapshot);
this.selectedSnapshot.set(normalizedSnapshot);
this.timeTravelChange.emit({
enabled: this.timeTravelEnabled(),
snapshot,
snapshot: normalizedSnapshot,
});
if (this.isOverlayEnabled('reachability')) {
@@ -1228,4 +1292,8 @@ export class GraphOverlaysComponent implements OnChanges {
break;
}
}
private normalizeSnapshot(snapshot: string): SnapshotKey {
return this.snapshotOrder.includes(snapshot as SnapshotKey) ? (snapshot as SnapshotKey) : 'current';
}
}

View File

@@ -0,0 +1,49 @@
import { CompatibilityResult, PackStatus } from '../../../core/api/pack-registry.models';
export type PackSignatureState = 'verified' | 'unverified' | 'unsigned';
export type PackPrimaryAction = 'install' | 'upgrade';
export interface PackRegistryRow {
id: string;
name: string;
description: string;
author: string;
capabilities: string[];
platformCompatibility: string;
status: PackStatus;
installedVersion?: string;
latestVersion: string;
updatedAt: string;
signedBy?: string;
signatureState: PackSignatureState;
primaryAction: PackPrimaryAction;
primaryActionLabel: string;
actionEnabled: boolean;
}
export interface PackVersionRow {
version: string;
releaseDate: string;
changelog: string;
downloads: number;
isBreaking: boolean;
signedBy?: string;
signatureState: PackSignatureState;
}
export interface PackRegistryBrowserViewModel {
generatedAt: string;
packs: PackRegistryRow[];
capabilities: string[];
installedCount: number;
upgradeAvailableCount: number;
totalCount: number;
}
export interface PackRegistryActionResult {
packId: string;
action: PackPrimaryAction;
success: boolean;
message: string;
compatibility: CompatibilityResult;
}

View File

@@ -0,0 +1,741 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CompatibilityResult } from '../../core/api/pack-registry.models';
import {
PackRegistryBrowserViewModel,
PackRegistryRow,
PackSignatureState,
PackVersionRow,
} from './models/pack-registry-browser.models';
import { PackRegistryBrowserService } from './services/pack-registry-browser.service';
@Component({
selector: 'app-pack-registry-browser',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="pack-registry-page">
<header class="page-header">
<div>
<h1>Pack Registry Browser</h1>
<p>Browse TaskRunner packs, inspect DSSE signature state, and run compatibility-checked installs and upgrades.</p>
</div>
<button type="button" class="refresh-btn" (click)="refresh()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</header>
<section class="kpi-grid" aria-label="Pack registry summary">
<article class="kpi-card">
<h2>Total packs</h2>
<p>{{ vm()?.totalCount ?? 0 }}</p>
</article>
<article class="kpi-card">
<h2>Installed</h2>
<p>{{ vm()?.installedCount ?? 0 }}</p>
</article>
<article class="kpi-card">
<h2>Upgrade available</h2>
<p>{{ vm()?.upgradeAvailableCount ?? 0 }}</p>
</article>
</section>
<section class="filters">
<label class="field">
<span>Search</span>
<input
type="search"
[value]="query()"
(input)="setQuery($any($event.target).value ?? '')"
placeholder="Search packs by name, id, author, or capability"
data-testid="pack-search"
/>
</label>
<label class="field">
<span>Capability</span>
<select [value]="capabilityFilter()" (change)="setCapabilityFilter($any($event.target).value)" data-testid="capability-filter">
<option value="">All capabilities</option>
@for (capability of vm()?.capabilities ?? []; track capability) {
<option [value]="capability">{{ capability }}</option>
}
</select>
</label>
</section>
@if (loadError()) {
<p class="error-banner" role="alert">{{ loadError() }}</p>
}
@if (actionNotice()) {
<p class="notice-banner" [class.notice-banner--error]="actionNoticeKind() === 'error'" role="status">
{{ actionNotice() }}
</p>
}
<section class="table-card">
@if (filteredPacks().length === 0) {
<p class="empty-state">No packs match the selected filter criteria.</p>
} @else {
<table>
<thead>
<tr>
<th>Pack</th>
<th>Status</th>
<th>Installed / Latest</th>
<th>DSSE</th>
<th>Capabilities</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (pack of filteredPacks(); track pack.id) {
<tr [attr.data-testid]="'pack-row-' + pack.id">
<td>
<strong>{{ pack.name }}</strong>
<small class="subtle">{{ pack.id }}</small>
<small>{{ pack.description }}</small>
<small class="subtle">Author: {{ pack.author }}</small>
</td>
<td>
<span class="badge" [class]="statusClass(pack.status)">{{ pack.status }}</span>
@if (compatibilityFor(pack.id); as compatibility) {
<small [class.compatibility-ok]="compatibility.compatible" [class.compatibility-fail]="!compatibility.compatible">
{{ compatibility.compatible ? 'Compatible' : 'Incompatible' }}
</small>
}
</td>
<td>
<small>Installed: {{ pack.installedVersion ?? 'not installed' }}</small>
<small>Latest: {{ pack.latestVersion }}</small>
<small class="subtle">Updated: {{ pack.updatedAt | date:'short' }}</small>
</td>
<td>
<span class="badge" [class]="signatureClass(pack.signatureState)">
{{ signatureLabel(pack.signatureState) }}
</span>
<small>{{ pack.signedBy ?? 'No signer metadata' }}</small>
</td>
<td>
<div class="capability-list">
@for (capability of pack.capabilities; track capability) {
<span class="chip">{{ capability }}</span>
}
</div>
</td>
<td class="actions-cell">
<button
type="button"
class="secondary-btn"
(click)="runCompatibilityCheck(pack)"
[disabled]="busyPackId() === pack.id"
[attr.data-testid]="'check-compatibility-' + pack.id">
Check compatibility
</button>
<button
type="button"
class="primary-btn"
(click)="runPrimaryAction(pack)"
[disabled]="busyPackId() === pack.id || !pack.actionEnabled"
[attr.data-testid]="'primary-action-' + pack.id">
{{ busyPackId() === pack.id ? 'Working...' : pack.primaryActionLabel }}
</button>
<button
type="button"
class="link-btn"
(click)="toggleVersionHistory(pack)"
[attr.data-testid]="'toggle-versions-' + pack.id">
{{ selectedPackId() === pack.id ? 'Hide versions' : 'View versions' }}
</button>
</td>
</tr>
}
</tbody>
</table>
}
</section>
@if (selectedPack(); as pack) {
<section class="versions-card" [attr.data-testid]="'versions-panel-' + pack.id">
<header>
<h2>{{ pack.name }} version history</h2>
<small>{{ pack.id }}</small>
</header>
@if (loadingVersionsForPackId() === pack.id) {
<p class="subtle">Loading version history...</p>
} @else if (versionsFor(pack.id).length === 0) {
<p class="subtle">No version history returned for this pack.</p>
} @else {
<ul>
@for (version of versionsFor(pack.id); track version.version) {
<li>
<div>
<strong>{{ version.version }}</strong>
@if (version.isBreaking) {
<span class="breaking-pill">Breaking</span>
}
<small>Released {{ version.releaseDate | date:'mediumDate' }}</small>
<small>Downloads: {{ version.downloads }}</small>
</div>
<div class="version-signature">
<span class="badge" [class]="signatureClass(version.signatureState)">
{{ signatureLabel(version.signatureState) }}
</span>
<small>{{ version.signedBy ?? 'No signer metadata' }}</small>
</div>
</li>
}
</ul>
}
</section>
}
</section>
`,
styles: [`
:host {
display: block;
min-height: 100vh;
background: #f6f8fb;
color: #0f172a;
padding: 1.5rem;
}
.pack-registry-page {
max-width: 1280px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
h1 {
margin: 0;
font-size: 1.6rem;
line-height: 1.2;
}
.page-header p {
margin: 0.4rem 0 0;
color: #475569;
}
.refresh-btn {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
background: #ffffff;
color: #0f172a;
padding: 0.55rem 1rem;
font-weight: 600;
cursor: pointer;
}
.refresh-btn[disabled] {
opacity: 0.7;
cursor: not-allowed;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
}
.kpi-card {
border-radius: 0.75rem;
border: 1px solid #dbe4ef;
background: #ffffff;
padding: 0.9rem;
}
.kpi-card h2 {
margin: 0;
color: #64748b;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.kpi-card p {
margin: 0.35rem 0 0;
font-size: 1.8rem;
font-weight: 700;
}
.filters {
border-radius: 0.75rem;
border: 1px solid #dbe4ef;
background: #ffffff;
padding: 0.9rem;
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.field {
display: grid;
gap: 0.35rem;
font-size: 0.9rem;
}
.field span {
color: #475569;
font-weight: 600;
}
input,
select {
width: 100%;
box-sizing: border-box;
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 0.5rem 0.65rem;
font: inherit;
background: #ffffff;
color: inherit;
}
.error-banner,
.notice-banner {
margin: 0;
border-radius: 0.5rem;
border: 1px solid #bbf7d0;
background: #dcfce7;
color: #166534;
padding: 0.75rem;
font-weight: 600;
}
.error-banner,
.notice-banner--error {
border-color: #fecaca;
background: #fee2e2;
color: #991b1b;
}
.table-card,
.versions-card {
border-radius: 0.75rem;
border: 1px solid #dbe4ef;
background: #ffffff;
padding: 0.9rem;
overflow-x: auto;
}
table {
border-collapse: collapse;
width: 100%;
min-width: 980px;
}
th,
td {
text-align: left;
vertical-align: top;
border-top: 1px solid #eef2f7;
padding: 0.65rem 0.5rem;
font-size: 0.88rem;
}
th {
border-top: 0;
color: #64748b;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
td strong {
display: block;
font-size: 0.95rem;
}
td small {
display: block;
margin-top: 0.2rem;
}
.subtle {
color: #64748b;
}
.compatibility-ok {
color: #166534;
font-weight: 600;
}
.compatibility-fail {
color: #991b1b;
font-weight: 600;
}
.capability-list {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.chip {
display: inline-flex;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
padding: 0.2rem 0.55rem;
font-size: 0.76rem;
font-weight: 600;
}
.actions-cell {
display: grid;
gap: 0.35rem;
min-width: 180px;
}
.primary-btn,
.secondary-btn,
.link-btn {
border-radius: 0.45rem;
border: 1px solid #cbd5e1;
background: #ffffff;
color: inherit;
font: inherit;
padding: 0.4rem 0.65rem;
cursor: pointer;
text-align: left;
}
.primary-btn {
border-color: #1d4ed8;
background: #1d4ed8;
color: #ffffff;
font-weight: 600;
}
.link-btn {
border-style: dashed;
color: #334155;
}
button[disabled] {
opacity: 0.7;
cursor: not-allowed;
}
.badge {
display: inline-flex;
border-radius: 999px;
padding: 0.12rem 0.55rem;
font-size: 0.76rem;
font-weight: 700;
border: 1px solid transparent;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.status--available {
background: #dbeafe;
color: #1d4ed8;
border-color: #93c5fd;
}
.status--installed {
background: #dcfce7;
color: #166534;
border-color: #86efac;
}
.status--outdated {
background: #fef9c3;
color: #854d0e;
border-color: #fde047;
}
.status--deprecated {
background: #ffedd5;
color: #9a3412;
border-color: #fdba74;
}
.status--incompatible {
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
}
.signature--verified {
background: #dcfce7;
color: #166534;
border-color: #86efac;
}
.signature--unverified {
background: #fef3c7;
color: #92400e;
border-color: #fcd34d;
}
.signature--unsigned {
background: #e2e8f0;
color: #334155;
border-color: #cbd5e1;
}
.versions-card header h2 {
margin: 0;
font-size: 1rem;
}
.versions-card header small {
color: #64748b;
display: block;
margin-top: 0.2rem;
}
.versions-card ul {
list-style: none;
margin: 0.8rem 0 0;
padding: 0;
display: grid;
gap: 0.5rem;
}
.versions-card li {
border: 1px solid #e2e8f0;
border-radius: 0.55rem;
padding: 0.55rem 0.65rem;
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.version-signature {
display: grid;
justify-items: end;
gap: 0.2rem;
}
.breaking-pill {
display: inline-flex;
margin-left: 0.5rem;
border-radius: 999px;
padding: 0.1rem 0.45rem;
background: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
font-size: 0.72rem;
font-weight: 700;
}
.empty-state {
margin: 0;
color: #64748b;
font-style: italic;
}
@media (max-width: 900px) {
:host {
padding: 1rem;
}
.versions-card li {
align-items: flex-start;
flex-direction: column;
}
.version-signature {
justify-items: start;
}
}
`],
})
export class PackRegistryBrowserComponent {
private readonly service = inject(PackRegistryBrowserService);
readonly vm = signal<PackRegistryBrowserViewModel | null>(null);
readonly loading = signal(false);
readonly loadError = signal<string | null>(null);
readonly query = signal('');
readonly capabilityFilter = signal('');
readonly busyPackId = signal<string | null>(null);
readonly actionNotice = signal<string | null>(null);
readonly actionNoticeKind = signal<'success' | 'error'>('success');
readonly compatibilityByPack = signal<Record<string, CompatibilityResult>>({});
readonly selectedPackId = signal<string | null>(null);
readonly loadingVersionsForPackId = signal<string | null>(null);
readonly versionsByPack = signal<Record<string, PackVersionRow[]>>({});
readonly filteredPacks = computed(() => {
const dashboard = this.vm();
if (!dashboard) {
return [] as PackRegistryRow[];
}
const query = this.query().trim().toLowerCase();
const capabilityFilter = this.capabilityFilter();
return dashboard.packs.filter((pack) => {
if (capabilityFilter && !pack.capabilities.includes(capabilityFilter)) {
return false;
}
if (!query) {
return true;
}
return (
pack.id.toLowerCase().includes(query) ||
pack.name.toLowerCase().includes(query) ||
pack.author.toLowerCase().includes(query) ||
pack.capabilities.some((capability) => capability.toLowerCase().includes(query))
);
});
});
readonly selectedPack = computed(() => {
const dashboard = this.vm();
const packId = this.selectedPackId();
if (!dashboard || !packId) {
return null;
}
return dashboard.packs.find((pack) => pack.id === packId) ?? null;
});
constructor() {
this.refresh();
}
refresh(): void {
this.loading.set(true);
this.loadError.set(null);
this.service.loadDashboard().subscribe({
next: (vm) => {
this.vm.set(vm);
this.loading.set(false);
},
error: () => {
this.loadError.set('Pack registry data is currently unavailable.');
this.loading.set(false);
},
});
}
setQuery(value: string): void {
this.query.set(value);
}
setCapabilityFilter(value: string): void {
this.capabilityFilter.set(value);
}
toggleVersionHistory(pack: PackRegistryRow): void {
if (this.selectedPackId() === pack.id) {
this.selectedPackId.set(null);
return;
}
this.selectedPackId.set(pack.id);
if (this.versionsByPack()[pack.id]) {
return;
}
this.loadingVersionsForPackId.set(pack.id);
this.service.loadVersions(pack.id).subscribe({
next: (versions) => {
this.versionsByPack.update((rows) => ({ ...rows, [pack.id]: versions }));
this.loadingVersionsForPackId.set(null);
},
error: () => {
this.loadingVersionsForPackId.set(null);
this.actionNoticeKind.set('error');
this.actionNotice.set(`Version history for ${pack.name} is unavailable.`);
},
});
}
versionsFor(packId: string): PackVersionRow[] {
return this.versionsByPack()[packId] ?? [];
}
compatibilityFor(packId: string): CompatibilityResult | undefined {
return this.compatibilityByPack()[packId];
}
runCompatibilityCheck(pack: PackRegistryRow): void {
this.busyPackId.set(pack.id);
this.actionNotice.set(null);
this.service.checkCompatibility(pack.id).subscribe({
next: (compatibility) => {
this.compatibilityByPack.update((state) => ({ ...state, [pack.id]: compatibility }));
this.actionNoticeKind.set(compatibility.compatible ? 'success' : 'error');
this.actionNotice.set(
compatibility.compatible
? `${pack.name} is compatible with this environment.`
: `Compatibility check failed for ${pack.name}.`
);
this.busyPackId.set(null);
},
error: () => {
this.actionNoticeKind.set('error');
this.actionNotice.set(`Compatibility check failed for ${pack.name}.`);
this.busyPackId.set(null);
},
});
}
runPrimaryAction(pack: PackRegistryRow): void {
if (!pack.actionEnabled) {
return;
}
this.busyPackId.set(pack.id);
this.actionNotice.set(null);
this.service.executePrimaryAction(pack).subscribe({
next: (result) => {
this.compatibilityByPack.update((state) => ({ ...state, [pack.id]: result.compatibility }));
this.actionNoticeKind.set(result.success ? 'success' : 'error');
this.actionNotice.set(result.message);
this.busyPackId.set(null);
if (result.success) {
this.refresh();
}
},
error: () => {
this.actionNoticeKind.set('error');
this.actionNotice.set(`Unable to complete action for ${pack.name}.`);
this.busyPackId.set(null);
},
});
}
statusClass(status: string): string {
return `status--${status}`;
}
signatureClass(state: PackSignatureState): string {
return `signature--${state}`;
}
signatureLabel(state: PackSignatureState): string {
if (state === 'verified') {
return 'DSSE verified';
}
if (state === 'unverified') {
return 'DSSE present';
}
return 'Unsigned';
}
}

View File

@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const PACK_REGISTRY_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./pack-registry-browser.component').then((m) => m.PackRegistryBrowserComponent),
},
];

View File

@@ -0,0 +1,204 @@
import { Injectable, inject } from '@angular/core';
import { Observable, catchError, forkJoin, map, of, switchMap } from 'rxjs';
import { CompatibilityResult, Pack, PackStatus, PackVersion } from '../../../core/api/pack-registry.models';
import { PackRegistryClient } from '../../../core/api/pack-registry.client';
import {
PackPrimaryAction,
PackRegistryActionResult,
PackRegistryBrowserViewModel,
PackRegistryRow,
PackSignatureState,
PackVersionRow,
} from '../models/pack-registry-browser.models';
@Injectable({ providedIn: 'root' })
export class PackRegistryBrowserService {
private readonly packRegistryClient = inject(PackRegistryClient);
loadDashboard(): Observable<PackRegistryBrowserViewModel> {
return forkJoin({
listed: this.packRegistryClient.list(undefined, 200),
installed: this.packRegistryClient.getInstalled().pipe(catchError(() => of([] as Pack[]))),
}).pipe(
map(({ listed, installed }) => {
const installedById = new Map(installed.map((pack) => [pack.id, pack] as const));
const rows = listed.items
.map((pack) => this.toRow(pack, installedById.get(pack.id)))
.sort((left, right) => this.compareRows(left, right));
const capabilitySet = new Set<string>();
for (const row of rows) {
for (const capability of row.capabilities) {
capabilitySet.add(capability);
}
}
const capabilities = Array.from(capabilitySet).sort((left, right) => left.localeCompare(right));
const installedCount = rows.filter((row) => !!row.installedVersion).length;
const upgradeAvailableCount = rows.filter((row) => row.status === 'outdated').length;
return {
generatedAt: new Date().toISOString(),
packs: rows,
capabilities,
installedCount,
upgradeAvailableCount,
totalCount: listed.total,
};
})
);
}
loadVersions(packId: string): Observable<PackVersionRow[]> {
return this.packRegistryClient.getVersions(packId).pipe(
map((versions) =>
versions
.slice()
.sort((left, right) => this.compareVersions(left, right))
.map((version) => this.toVersionRow(version))
)
);
}
checkCompatibility(packId: string, version?: string): Observable<CompatibilityResult> {
return this.packRegistryClient.checkCompatibility(packId, version);
}
executePrimaryAction(pack: PackRegistryRow, version?: string): Observable<PackRegistryActionResult> {
const action = pack.primaryAction;
return this.packRegistryClient.checkCompatibility(pack.id, version).pipe(
switchMap((compatibility) => {
if (!compatibility.compatible) {
return of({
packId: pack.id,
action,
success: false,
message: this.buildCompatibilityMessage(compatibility),
compatibility,
});
}
const request$ = action === 'install'
? this.packRegistryClient.install(pack.id, version)
: this.packRegistryClient.upgrade(pack.id, version);
return request$.pipe(
map(() => ({
packId: pack.id,
action,
success: true,
message: action === 'install'
? `Installed ${pack.name} successfully.`
: `Upgraded ${pack.name} successfully.`,
compatibility,
}))
);
}),
catchError((error) =>
of({
packId: pack.id,
action,
success: false,
message: this.describeError(error, action),
compatibility: {
compatible: false,
platformVersionOk: false,
dependenciesSatisfied: false,
conflicts: ['Compatibility verification could not complete.'],
warnings: [],
},
})
)
);
}
private toRow(pack: Pack, installedPack?: Pack): PackRegistryRow {
const installedVersion = installedPack?.version ?? pack.installedVersion;
const status = this.resolveStatus(pack.status, installedVersion, pack.latestVersion);
const primaryAction: PackPrimaryAction = installedVersion ? 'upgrade' : 'install';
return {
id: pack.id,
name: pack.name,
description: pack.description,
author: pack.author,
capabilities: pack.capabilities.slice().sort((left, right) => left.localeCompare(right)),
platformCompatibility: pack.platformCompatibility,
status,
installedVersion,
latestVersion: pack.latestVersion,
updatedAt: pack.updatedAt,
signedBy: pack.signedBy,
signatureState: this.resolveSignatureState(pack.signature, pack.signedBy),
primaryAction,
primaryActionLabel: primaryAction === 'install' ? 'Install' : 'Upgrade',
actionEnabled: primaryAction === 'install' || status === 'outdated',
};
}
private toVersionRow(version: PackVersion): PackVersionRow {
return {
version: version.version,
releaseDate: version.releaseDate,
changelog: version.changelog,
downloads: version.downloads,
isBreaking: version.isBreaking,
signedBy: version.signedBy,
signatureState: this.resolveSignatureState(version.signature, version.signedBy),
};
}
private resolveStatus(sourceStatus: PackStatus, installedVersion: string | undefined, latestVersion: string): PackStatus {
if (sourceStatus === 'deprecated' || sourceStatus === 'incompatible') {
return sourceStatus;
}
if (!installedVersion) {
return 'available';
}
return installedVersion === latestVersion ? 'installed' : 'outdated';
}
private resolveSignatureState(signature: string | undefined, signedBy: string | undefined): PackSignatureState {
if (!signature) {
return 'unsigned';
}
return signedBy ? 'verified' : 'unverified';
}
private buildCompatibilityMessage(result: CompatibilityResult): string {
if (result.conflicts.length > 0) {
return `Pack action blocked: ${result.conflicts.join('; ')}`;
}
if (result.warnings.length > 0) {
return `Pack action blocked: ${result.warnings.join('; ')}`;
}
return 'Pack action blocked by compatibility policy.';
}
private describeError(error: unknown, action: PackPrimaryAction): string {
const fallback = action === 'install' ? 'Install failed.' : 'Upgrade failed.';
if (!error || typeof error !== 'object') {
return fallback;
}
const candidate = error as { error?: { message?: string }; message?: string };
return candidate.error?.message ?? candidate.message ?? fallback;
}
private compareRows(left: PackRegistryRow, right: PackRegistryRow): number {
const byName = left.name.localeCompare(right.name);
if (byName !== 0) {
return byName;
}
return left.id.localeCompare(right.id);
}
private compareVersions(left: PackVersion, right: PackVersion): number {
const byDate = right.releaseDate.localeCompare(left.releaseDate);
if (byDate !== 0) {
return byDate;
}
return right.version.localeCompare(left.version);
}
}

View File

@@ -19,11 +19,22 @@ describe('ReachabilityCenterComponent', () => {
expect(component.okCount()).toBe(1);
expect(component.staleCount()).toBe(1);
expect(component.missingCount()).toBe(1);
expect(component.fleetCoveragePercent()).toBe(69);
expect(component.sensorCoveragePercent()).toBe(63);
expect(component.assetsMissingSensors().map((a) => a.assetId)).toEqual([
'asset-api-prod',
'asset-worker-prod',
]);
});
it('filters rows by status', () => {
component.setStatusFilter('stale');
expect(component.filteredRows().map((r) => r.assetId)).toEqual(['asset-api-prod']);
});
});
it('switches to missing sensor filter from indicator action', () => {
component.goToMissingSensors();
expect(component.statusFilter()).toBe('missing');
expect(component.filteredRows().map((r) => r.assetId)).toEqual(['asset-worker-prod']);
});
});

View File

@@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
type CoverageStatus = 'ok' | 'stale' | 'missing';
@@ -12,6 +11,14 @@ interface ReachabilityCoverageRow {
readonly status: CoverageStatus;
}
interface MissingSensorAsset {
readonly assetId: string;
readonly missingSensors: number;
readonly sensorsExpected: number;
}
const FIXTURE_BUNDLE_ID = 'reachability-fixture-local-v1';
const FIXTURE_ROWS: ReachabilityCoverageRow[] = [
{
assetId: 'asset-api-prod',
@@ -40,14 +47,14 @@ const FIXTURE_ROWS: ReachabilityCoverageRow[] = [
];
@Component({
selector: 'app-reachability-center',
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
selector: 'app-reachability-center',
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="reachability">
<header class="reachability__header">
<div>
<p class="reachability__eyebrow">Signals · Reachability</p>
<p class="reachability__eyebrow">Signals / Reachability</p>
<h1>Reachability Center</h1>
<p class="reachability__subtitle">
Coverage-first view: what we observe, what is missing, and what is stale.
@@ -69,8 +76,39 @@ const FIXTURE_ROWS: ReachabilityCoverageRow[] = [
<div class="summary-card__value">{{ missingCount() }}</div>
<div class="summary-card__label">Missing sensors</div>
</div>
<div class="summary-card summary-card--info">
<div class="summary-card__value">{{ fleetCoveragePercent() }}%</div>
<div class="summary-card__label">Asset coverage</div>
</div>
<div class="summary-card summary-card--info">
<div class="summary-card__value">{{ sensorCoveragePercent() }}%</div>
<div class="summary-card__label">Sensor coverage</div>
</div>
</div>
<aside class="reachability__fixture-note" aria-label="Fixture source">
Fixture source: <code>{{ fixtureBundleId() }}</code>
</aside>
@if (assetsMissingSensors().length > 0) {
<section class="reachability__missing-sensors" role="status" aria-live="polite">
<div>
<strong>Missing sensors detected:</strong>
{{ assetsMissingSensors().length }} asset(s) have missing runtime sensors.
</div>
<button type="button" class="btn btn--small" (click)="goToMissingSensors()">
Show missing
</button>
<div class="missing-sensor-list">
@for (asset of assetsMissingSensors(); track asset.assetId) {
<span class="missing-chip" [attr.data-testid]="'missing-sensor-' + asset.assetId">
{{ asset.assetId }} (missing {{ asset.missingSensors }}/{{ asset.sensorsExpected }})
</span>
}
</div>
</section>
}
<div class="reachability__filters" role="group" aria-label="Filters">
<button
type="button"
@@ -122,8 +160,16 @@ const FIXTURE_ROWS: ReachabilityCoverageRow[] = [
<tr>
<td><code>{{ row.assetId }}</code></td>
<td>{{ row.coveragePercent }}%</td>
<td>{{ row.sensorsOnline }}/{{ row.sensorsExpected }}</td>
<td>{{ row.lastFactAt ?? '—' }}</td>
<td>
<span>{{ row.sensorsOnline }}/{{ row.sensorsExpected }}</span>
<small
class="sensor-indicator"
[class.sensor-indicator--missing]="row.sensorsOnline < row.sensorsExpected"
[class.sensor-indicator--ok]="row.sensorsOnline >= row.sensorsExpected">
{{ sensorGapLabel(row) }}
</small>
</td>
<td>{{ row.lastFactAt ?? '--' }}</td>
<td>
<span class="status" [class]="'status--' + row.status">
{{ row.status }}
@@ -136,8 +182,8 @@ const FIXTURE_ROWS: ReachabilityCoverageRow[] = [
</div>
</section>
`,
styles: [
`
styles: [
`
:host {
display: block;
min-height: 100vh;
@@ -187,6 +233,12 @@ const FIXTURE_ROWS: ReachabilityCoverageRow[] = [
cursor: pointer;
}
.btn--small {
font-size: 0.78rem;
padding: 0.32rem 0.65rem;
border-radius: 999px;
}
.reachability__summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@@ -220,6 +272,48 @@ const FIXTURE_ROWS: ReachabilityCoverageRow[] = [
color: var(--color-status-error);
}
.summary-card--info .summary-card__value {
color: var(--color-accent-cyan);
}
.reachability__fixture-note {
border: 1px dashed var(--color-border-secondary);
border-radius: 12px;
background: var(--color-surface-secondary);
padding: 0.6rem 0.8rem;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.reachability__fixture-note code {
font-family: ui-monospace, monospace;
}
.reachability__missing-sensors {
border: 1px solid var(--color-severity-medium-border);
border-radius: 12px;
background: color-mix(in srgb, var(--color-severity-medium) 14%, transparent);
padding: 0.7rem 0.8rem;
display: grid;
gap: 0.5rem;
}
.missing-sensor-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.missing-chip {
display: inline-flex;
border-radius: 999px;
padding: 0.18rem 0.58rem;
border: 1px solid var(--color-severity-medium-border);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-size: 0.75rem;
}
.reachability__filters {
display: flex;
flex-wrap: wrap;
@@ -296,10 +390,25 @@ const FIXTURE_ROWS: ReachabilityCoverageRow[] = [
border-color: var(--color-severity-critical-border);
color: var(--color-status-error);
}
.sensor-indicator {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
}
.sensor-indicator--ok {
color: var(--color-severity-low);
}
.sensor-indicator--missing {
color: var(--color-status-error);
}
`,
]
],
})
export class ReachabilityCenterComponent {
readonly fixtureBundleId = signal(FIXTURE_BUNDLE_ID);
readonly statusFilter = signal<CoverageStatus | 'all'>('all');
readonly rows = signal<ReachabilityCoverageRow[]>(
@@ -316,6 +425,29 @@ export class ReachabilityCenterComponent {
readonly okCount = computed(() => this.rows().filter((r) => r.status === 'ok').length);
readonly staleCount = computed(() => this.rows().filter((r) => r.status === 'stale').length);
readonly missingCount = computed(() => this.rows().filter((r) => r.status === 'missing').length);
readonly assetsMissingSensors = computed<MissingSensorAsset[]>(() =>
this.rows()
.filter((row) => row.sensorsOnline < row.sensorsExpected)
.map((row) => ({
assetId: row.assetId,
missingSensors: row.sensorsExpected - row.sensorsOnline,
sensorsExpected: row.sensorsExpected,
}))
.sort((left, right) => left.assetId.localeCompare(right.assetId))
);
readonly fleetCoveragePercent = computed(() => {
const rows = this.rows();
if (rows.length === 0) return 0;
const total = rows.reduce((sum, row) => sum + row.coveragePercent, 0);
return Math.round(total / rows.length);
});
readonly sensorCoveragePercent = computed(() => {
const rows = this.rows();
const totalExpected = rows.reduce((sum, row) => sum + row.sensorsExpected, 0);
if (totalExpected === 0) return 0;
const totalOnline = rows.reduce((sum, row) => sum + row.sensorsOnline, 0);
return Math.round((totalOnline / totalExpected) * 100);
});
setStatusFilter(status: CoverageStatus | 'all'): void {
this.statusFilter.set(status);
@@ -324,5 +456,17 @@ export class ReachabilityCenterComponent {
reset(): void {
this.statusFilter.set('all');
}
}
goToMissingSensors(): void {
this.statusFilter.set('missing');
}
sensorGapLabel(row: ReachabilityCoverageRow): string {
if (row.sensorsOnline >= row.sensorsExpected) {
return 'all sensors online';
}
const missing = row.sensorsExpected - row.sensorsOnline;
return missing === 1 ? 'missing 1 sensor' : `missing ${missing} sensors`;
}
}

View File

@@ -5,6 +5,7 @@
<p class="release-dashboard__subtitle">Pipeline overview and release management</p>
</div>
<div class="release-dashboard__actions">
<a routerLink="/release-orchestrator/runs" class="release-dashboard__runs-link">Pipeline Runs</a>
@if (store.lastUpdated(); as lastUpdated) {
<span class="release-dashboard__last-updated">
Last updated: {{ lastUpdated | date:'medium' }}

View File

@@ -43,6 +43,27 @@
color: var(--color-text-muted);
}
.release-dashboard__runs-link {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 var(--space-4);
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-md);
color: var(--color-brand-primary);
text-decoration: none;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
background: var(--color-surface-primary);
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--color-brand-primary);
color: var(--color-text-inverse);
}
}
.release-dashboard__refresh-btn {
display: flex;
align-items: center;

View File

@@ -1,5 +1,6 @@
import { Component, OnInit, OnDestroy, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ReleaseDashboardStore } from './dashboard.store';
import { PipelineOverviewComponent } from './components/pipeline-overview/pipeline-overview.component';
import { PendingApprovalsComponent } from './components/pending-approvals/pending-approvals.component';
@@ -17,6 +18,7 @@ import { RecentReleasesComponent } from './components/recent-releases/recent-rel
imports: [
CommonModule,
DatePipe,
RouterLink,
PipelineOverviewComponent,
PendingApprovalsComponent,
ActiveDeploymentsComponent,

View File

@@ -37,6 +37,12 @@ export const DASHBOARD_ROUTES: Routes = [
import('../deployments/deployments.routes').then((m) => m.DEPLOYMENT_ROUTES),
title: 'Deployments',
},
{
path: 'runs',
loadChildren: () =>
import('../runs/runs.routes').then((m) => m.PIPELINE_RUN_ROUTES),
title: 'Pipeline Runs',
},
{
path: 'evidence',
loadChildren: () =>

View File

@@ -0,0 +1,41 @@
export type PipelineRunOutcomeStatus = 'pending' | 'running' | 'passed' | 'failed';
export type PipelineRunStageStatus = 'pending' | 'running' | 'passed' | 'failed';
export type PipelineRunEvidenceStatus = 'pending' | 'collecting' | 'collected' | 'failed';
export interface PipelineRunSummary {
runId: string;
releaseId: string;
releaseName: string;
releaseVersion: string;
createdAt: string;
currentEnvironment: string | null;
currentStage: 'scan' | 'gate' | 'approval' | 'evidence' | 'deployment';
outcomeStatus: PipelineRunOutcomeStatus;
pendingApprovalCount: number;
activeDeploymentId?: string;
deploymentProgress?: number;
evidenceStatus: PipelineRunEvidenceStatus;
}
export interface PipelineRunStage {
key: 'scan' | 'gate' | 'approval' | 'evidence' | 'deployment';
label: string;
status: PipelineRunStageStatus;
detail: string;
}
export interface PipelineRunDetail extends PipelineRunSummary {
generatedAt: string;
stages: PipelineRunStage[];
gateSummary: string;
evidenceSummary: string;
}
export interface PipelineRunListViewModel {
generatedAt: string;
totalRuns: number;
activeRuns: number;
failedRuns: number;
completedRuns: number;
runs: PipelineRunSummary[];
}

View File

@@ -0,0 +1,300 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FirstSignalCardComponent } from '../../runs/components/first-signal-card/first-signal-card.component';
import { PipelineRunDetail, PipelineRunStageStatus } from './models/pipeline-runs.models';
import { PipelineRunsService } from './services/pipeline-runs.service';
@Component({
selector: 'app-pipeline-run-detail',
standalone: true,
imports: [CommonModule, RouterLink, FirstSignalCardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="pipeline-run-detail">
<header class="detail-header">
<a routerLink="../" class="back-link">Back to pipeline runs</a>
</header>
@if (loading()) {
<p class="loading">Loading run detail...</p>
} @else if (error()) {
<p class="error" role="alert">{{ error() }}</p>
} @else if (detail(); as detail) {
<header class="run-summary">
<h1>{{ detail.releaseName }} {{ detail.releaseVersion }}</h1>
<p>{{ detail.runId }} · {{ detail.createdAt | date:'medium' }}</p>
<div class="summary-pills">
<span class="badge" [class]="'outcome--' + detail.outcomeStatus">{{ detail.outcomeStatus }}</span>
<span class="badge" [class]="'stage--' + detail.currentStage">{{ detail.currentStage }}</span>
<span class="badge" [class]="'evidence--' + detail.evidenceStatus">{{ detail.evidenceStatus }}</span>
</div>
</header>
<section class="detail-grid">
<article class="card">
<h2>Stage progression</h2>
<ol class="stage-list">
@for (stage of detail.stages; track stage.key) {
<li [class]="'stage-item stage-item--' + stage.status">
<div class="stage-head">
<strong>{{ stage.label }}</strong>
<span class="badge stage-status" [class]="stageClass(stage.status)">{{ stage.status }}</span>
</div>
<p>{{ stage.detail }}</p>
</li>
}
</ol>
</article>
<article class="card">
<h2>Gate and evidence summary</h2>
<p><strong>Gates:</strong> {{ detail.gateSummary }}</p>
<p><strong>Evidence:</strong> {{ detail.evidenceSummary }}</p>
<p><strong>Pending approvals:</strong> {{ detail.pendingApprovalCount }}</p>
<p><strong>Current environment:</strong> {{ detail.currentEnvironment ?? 'n/a' }}</p>
<p>
<strong>Active deployment:</strong>
@if (detail.activeDeploymentId) {
{{ detail.activeDeploymentId }} ({{ detail.deploymentProgress ?? 0 }}%)
} @else {
none
}
</p>
</article>
</section>
<section class="card first-signal">
<h2>First signal</h2>
<app-first-signal-card [runId]="detail.runId"></app-first-signal-card>
</section>
} @else {
<p class="error" role="alert">Pipeline run was not found.</p>
}
</section>
`,
styles: [`
:host {
display: block;
min-height: 100vh;
background: #f6f8fb;
color: #0f172a;
padding: 1.25rem;
}
.pipeline-run-detail {
max-width: 1120px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.back-link {
color: #1d4ed8;
text-decoration: none;
font-weight: 600;
}
.back-link:hover {
text-decoration: underline;
}
.run-summary {
border: 1px solid #dbe4ef;
border-radius: 0.75rem;
background: #ffffff;
padding: 0.95rem;
}
.run-summary h1 {
margin: 0;
font-size: 1.5rem;
line-height: 1.2;
}
.run-summary p {
margin: 0.35rem 0 0;
color: #64748b;
}
.summary-pills {
margin-top: 0.7rem;
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.detail-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 0.85rem;
}
.card {
border: 1px solid #dbe4ef;
border-radius: 0.75rem;
background: #ffffff;
padding: 0.95rem;
}
.card h2 {
margin: 0 0 0.7rem;
font-size: 1rem;
}
.card p {
margin: 0.4rem 0;
line-height: 1.45;
}
.stage-list {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.55rem;
}
.stage-item {
border: 1px solid #e2e8f0;
border-radius: 0.55rem;
padding: 0.6rem 0.7rem;
background: #f8fafc;
list-style: decimal;
}
.stage-item p {
margin: 0.4rem 0 0;
color: #475569;
font-size: 0.86rem;
}
.stage-item--passed {
border-left: 4px solid #16a34a;
}
.stage-item--running {
border-left: 4px solid #2563eb;
}
.stage-item--pending {
border-left: 4px solid #a16207;
}
.stage-item--failed {
border-left: 4px solid #dc2626;
}
.stage-head {
display: flex;
justify-content: space-between;
gap: 0.6rem;
align-items: center;
}
.badge {
display: inline-flex;
border-radius: 999px;
padding: 0.1rem 0.5rem;
border: 1px solid transparent;
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.stage--scan { background: #e2e8f0; border-color: #cbd5e1; color: #334155; }
.stage--gate { background: #fef9c3; border-color: #fde047; color: #854d0e; }
.stage--approval { background: #ffedd5; border-color: #fdba74; color: #9a3412; }
.stage--evidence { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
.stage--deployment { background: #dcfce7; border-color: #86efac; color: #166534; }
.evidence--pending { background: #e2e8f0; border-color: #cbd5e1; color: #334155; }
.evidence--collecting { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
.evidence--collected { background: #dcfce7; border-color: #86efac; color: #166534; }
.evidence--failed { background: #fee2e2; border-color: #fca5a5; color: #991b1b; }
.outcome--pending { background: #e2e8f0; border-color: #cbd5e1; color: #334155; }
.outcome--running { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
.outcome--passed { background: #dcfce7; border-color: #86efac; color: #166534; }
.outcome--failed { background: #fee2e2; border-color: #fca5a5; color: #991b1b; }
.status--passed { background: #dcfce7; border-color: #86efac; color: #166534; }
.status--running { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
.status--pending { background: #fef9c3; border-color: #fde047; color: #854d0e; }
.status--failed { background: #fee2e2; border-color: #fca5a5; color: #991b1b; }
.first-signal {
display: grid;
gap: 0.7rem;
}
.loading,
.error {
margin: 0;
border-radius: 0.5rem;
padding: 0.72rem;
font-weight: 600;
}
.loading {
border: 1px solid #dbeafe;
background: #eff6ff;
color: #1e3a8a;
}
.error {
border: 1px solid #fecaca;
background: #fee2e2;
color: #991b1b;
}
@media (max-width: 920px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
`],
})
export class PipelineRunDetailComponent {
private readonly route = inject(ActivatedRoute);
private readonly pipelineRunsService = inject(PipelineRunsService);
readonly detail = signal<PipelineRunDetail | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly runId = computed(() => this.route.snapshot.paramMap.get('runId'));
constructor() {
this.refresh();
}
refresh(): void {
const runId = this.runId();
if (!runId) {
this.error.set('Pipeline run id is required.');
return;
}
this.loading.set(true);
this.error.set(null);
this.pipelineRunsService.loadRunDetail(runId).subscribe({
next: (detail) => {
this.detail.set(detail);
if (!detail) {
this.error.set('Pipeline run was not found.');
}
this.loading.set(false);
},
error: () => {
this.error.set('Pipeline run detail is currently unavailable.');
this.loading.set(false);
},
});
}
stageClass(status: PipelineRunStageStatus): string {
return `status--${status}`;
}
}

View File

@@ -0,0 +1,391 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { PipelineRunListViewModel, PipelineRunSummary } from './models/pipeline-runs.models';
import { PipelineRunsService } from './services/pipeline-runs.service';
@Component({
selector: 'app-pipeline-runs-list',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="pipeline-runs">
<header class="pipeline-runs__header">
<div>
<h1>Pipeline Runs</h1>
<p>Unified run-centric view linking release, gates, approvals, evidence, and deployment outcomes.</p>
</div>
<button type="button" class="refresh-btn" (click)="refresh()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</header>
<section class="stats" aria-label="Pipeline run counters">
<article>
<h2>Total</h2>
<p>{{ vm()?.totalRuns ?? 0 }}</p>
</article>
<article>
<h2>Active</h2>
<p>{{ vm()?.activeRuns ?? 0 }}</p>
</article>
<article>
<h2>Completed</h2>
<p>{{ vm()?.completedRuns ?? 0 }}</p>
</article>
<article>
<h2>Failed</h2>
<p>{{ vm()?.failedRuns ?? 0 }}</p>
</article>
</section>
<section class="filters">
<label>
<span>Search</span>
<input
type="search"
[value]="query()"
(input)="setQuery($any($event.target).value ?? '')"
placeholder="Filter by release, environment, or run ID"
data-testid="run-search" />
</label>
<label>
<span>Outcome</span>
<select [value]="statusFilter()" (change)="setStatusFilter($any($event.target).value)" data-testid="run-status-filter">
<option value="">All outcomes</option>
<option value="pending">Pending</option>
<option value="running">Running</option>
<option value="passed">Passed</option>
<option value="failed">Failed</option>
</select>
</label>
</section>
@if (error()) {
<p class="error" role="alert">{{ error() }}</p>
}
<section class="table-card">
@if (filteredRuns().length === 0) {
<p class="empty">No pipeline runs match the selected criteria.</p>
} @else {
<table>
<thead>
<tr>
<th>Run</th>
<th>Current stage</th>
<th>Approvals</th>
<th>Evidence</th>
<th>Deployment</th>
<th>Outcome</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (run of filteredRuns(); track run.runId) {
<tr [attr.data-testid]="'run-row-' + run.runId">
<td>
<strong>{{ run.releaseName }} {{ run.releaseVersion }}</strong>
<small>{{ run.runId }}</small>
<small>Env: {{ run.currentEnvironment ?? 'n/a' }}</small>
<small>Created {{ run.createdAt | date:'short' }}</small>
</td>
<td>
<span class="badge stage" [class]="'stage--' + run.currentStage">{{ run.currentStage }}</span>
</td>
<td>
<small>{{ run.pendingApprovalCount }} pending</small>
</td>
<td>
<span class="badge evidence" [class]="'evidence--' + run.evidenceStatus">{{ run.evidenceStatus }}</span>
</td>
<td>
<small>
@if (run.activeDeploymentId) {
{{ run.activeDeploymentId }} ({{ run.deploymentProgress ?? 0 }}%)
} @else {
no active deployment
}
</small>
</td>
<td>
<span class="badge outcome" [class]="'outcome--' + run.outcomeStatus">{{ run.outcomeStatus }}</span>
</td>
<td>
<a [routerLink]="[run.runId]" class="detail-link">View detail</a>
</td>
</tr>
}
</tbody>
</table>
}
</section>
</section>
`,
styles: [`
:host {
display: block;
min-height: 100vh;
background: #f6f8fb;
color: #0f172a;
padding: 1.25rem;
}
.pipeline-runs {
max-width: 1200px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.pipeline-runs__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.pipeline-runs__header h1 {
margin: 0;
font-size: 1.6rem;
}
.pipeline-runs__header p {
margin: 0.4rem 0 0;
color: #475569;
}
.refresh-btn {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
background: #ffffff;
color: #0f172a;
padding: 0.5rem 0.9rem;
font-weight: 600;
cursor: pointer;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 0.75rem;
}
.stats article {
border: 1px solid #dbe4ef;
border-radius: 0.7rem;
background: #ffffff;
padding: 0.8rem;
}
.stats h2 {
margin: 0;
color: #64748b;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.stats p {
margin: 0.35rem 0 0;
font-size: 1.7rem;
font-weight: 700;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.75rem;
border: 1px solid #dbe4ef;
border-radius: 0.7rem;
background: #ffffff;
padding: 0.8rem;
}
label {
display: grid;
gap: 0.3rem;
}
label span {
font-size: 0.86rem;
color: #475569;
font-weight: 600;
}
input,
select {
border: 1px solid #cbd5e1;
border-radius: 0.45rem;
padding: 0.48rem 0.6rem;
font: inherit;
width: 100%;
box-sizing: border-box;
}
.error {
margin: 0;
border: 1px solid #fecaca;
background: #fee2e2;
color: #991b1b;
border-radius: 0.5rem;
padding: 0.7rem;
font-weight: 600;
}
.table-card {
border: 1px solid #dbe4ef;
border-radius: 0.7rem;
background: #ffffff;
padding: 0.8rem;
overflow-x: auto;
}
.empty {
margin: 0;
color: #64748b;
font-style: italic;
}
table {
width: 100%;
min-width: 920px;
border-collapse: collapse;
}
th,
td {
text-align: left;
border-top: 1px solid #eef2f7;
padding: 0.58rem 0.45rem;
vertical-align: top;
font-size: 0.86rem;
}
th {
border-top: 0;
color: #64748b;
font-size: 0.77rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
td strong {
display: block;
font-size: 0.93rem;
}
td small {
display: block;
margin-top: 0.18rem;
color: #64748b;
}
.badge {
display: inline-flex;
border-radius: 999px;
padding: 0.11rem 0.52rem;
border: 1px solid transparent;
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.stage--scan { background: #e2e8f0; border-color: #cbd5e1; color: #334155; }
.stage--gate { background: #fef9c3; border-color: #fde047; color: #854d0e; }
.stage--approval { background: #ffedd5; border-color: #fdba74; color: #9a3412; }
.stage--evidence { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
.stage--deployment { background: #dcfce7; border-color: #86efac; color: #166534; }
.evidence--pending { background: #e2e8f0; border-color: #cbd5e1; color: #334155; }
.evidence--collecting { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
.evidence--collected { background: #dcfce7; border-color: #86efac; color: #166534; }
.evidence--failed { background: #fee2e2; border-color: #fca5a5; color: #991b1b; }
.outcome--pending { background: #e2e8f0; border-color: #cbd5e1; color: #334155; }
.outcome--running { background: #dbeafe; border-color: #93c5fd; color: #1d4ed8; }
.outcome--passed { background: #dcfce7; border-color: #86efac; color: #166534; }
.outcome--failed { background: #fee2e2; border-color: #fca5a5; color: #991b1b; }
.detail-link {
color: #1d4ed8;
text-decoration: none;
font-weight: 600;
}
.detail-link:hover {
text-decoration: underline;
}
`],
})
export class PipelineRunsListComponent {
private readonly pipelineRunsService = inject(PipelineRunsService);
readonly vm = signal<PipelineRunListViewModel | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly query = signal('');
readonly statusFilter = signal<'' | 'pending' | 'running' | 'passed' | 'failed'>('');
readonly filteredRuns = computed(() => {
const viewModel = this.vm();
if (!viewModel) {
return [] as PipelineRunSummary[];
}
const query = this.query().trim().toLowerCase();
const statusFilter = this.statusFilter();
return viewModel.runs.filter((run) => {
if (statusFilter && run.outcomeStatus !== statusFilter) {
return false;
}
if (!query) {
return true;
}
return (
run.runId.toLowerCase().includes(query) ||
run.releaseName.toLowerCase().includes(query) ||
run.releaseVersion.toLowerCase().includes(query) ||
(run.currentEnvironment ?? '').toLowerCase().includes(query)
);
});
});
constructor() {
this.refresh();
}
refresh(): void {
this.loading.set(true);
this.error.set(null);
this.pipelineRunsService.loadRuns().subscribe({
next: (vm) => {
this.vm.set(vm);
this.loading.set(false);
},
error: () => {
this.error.set('Pipeline runs are currently unavailable.');
this.loading.set(false);
},
});
}
setQuery(value: string): void {
this.query.set(value);
}
setStatusFilter(value: string): void {
if (value === 'pending' || value === 'running' || value === 'passed' || value === 'failed') {
this.statusFilter.set(value);
return;
}
this.statusFilter.set('');
}
}

View File

@@ -0,0 +1,16 @@
import { Routes } from '@angular/router';
export const PIPELINE_RUN_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./pipeline-runs-list.component').then((m) => m.PipelineRunsListComponent),
title: 'Pipeline Runs',
},
{
path: ':runId',
loadComponent: () =>
import('./pipeline-run-detail.component').then((m) => m.PipelineRunDetailComponent),
title: 'Pipeline Run Detail',
},
];

View File

@@ -0,0 +1,285 @@
import { Injectable, inject } from '@angular/core';
import { Observable, map } from 'rxjs';
import { RELEASE_DASHBOARD_API } from '../../../../core/api/release-dashboard.client';
import { ActiveDeployment, DashboardData, PendingApproval, RecentRelease } from '../../../../core/api/release-dashboard.models';
import {
PipelineRunDetail,
PipelineRunEvidenceStatus,
PipelineRunListViewModel,
PipelineRunOutcomeStatus,
PipelineRunStage,
PipelineRunStageStatus,
PipelineRunSummary,
} from '../models/pipeline-runs.models';
@Injectable({ providedIn: 'root' })
export class PipelineRunsService {
private readonly dashboardApi = inject(RELEASE_DASHBOARD_API);
loadRuns(): Observable<PipelineRunListViewModel> {
return this.dashboardApi.getDashboardData().pipe(
map((data) => {
const runs = this.mapRuns(data);
return {
generatedAt: new Date().toISOString(),
totalRuns: runs.length,
activeRuns: runs.filter((run) => run.outcomeStatus === 'running').length,
failedRuns: runs.filter((run) => run.outcomeStatus === 'failed').length,
completedRuns: runs.filter((run) => run.outcomeStatus === 'passed').length,
runs,
};
})
);
}
loadRunDetail(runId: string): Observable<PipelineRunDetail | null> {
return this.dashboardApi.getDashboardData().pipe(
map((data) => {
const runs = this.mapRuns(data);
const selected = runs.find((run) => run.runId === runId);
if (!selected) {
return null;
}
const release = data.recentReleases.find((item) => item.id === selected.releaseId);
const approvals = data.pendingApprovals.filter((item) => item.releaseId === selected.releaseId);
const deployment = data.activeDeployments.find((item) => item.releaseId === selected.releaseId);
const stages = this.buildStages(selected, approvals, deployment);
const gateSummary = approvals.length > 0
? `${approvals.length} pending promotion gate approval(s).`
: selected.outcomeStatus === 'failed'
? 'One or more gates failed for this run.'
: 'Policy and quality gates are satisfied.';
const evidenceSummary = this.buildEvidenceSummary(selected.evidenceStatus, selected.releaseName);
return {
...selected,
generatedAt: new Date().toISOString(),
stages,
gateSummary,
evidenceSummary,
};
})
);
}
private mapRuns(data: DashboardData): PipelineRunSummary[] {
return data.recentReleases
.map((release) => this.mapRun(release, data.pendingApprovals, data.activeDeployments))
.sort((left, right) => this.compareRuns(left, right));
}
private mapRun(
release: RecentRelease,
approvals: PendingApproval[],
deployments: ActiveDeployment[]
): PipelineRunSummary {
const releaseApprovals = approvals.filter((item) => item.releaseId === release.id);
const deployment = deployments.find((item) => item.releaseId === release.id);
const outcomeStatus = this.resolveOutcomeStatus(release.status);
const evidenceStatus = this.resolveEvidenceStatus(release.status, deployment);
return {
runId: this.toRunId(release.id),
releaseId: release.id,
releaseName: release.name,
releaseVersion: release.version,
createdAt: release.createdAt,
currentEnvironment: release.currentEnvironment,
currentStage: this.resolveCurrentStage(release, deployment, releaseApprovals),
outcomeStatus,
pendingApprovalCount: releaseApprovals.length,
activeDeploymentId: deployment?.id,
deploymentProgress: deployment?.progress,
evidenceStatus,
};
}
private buildStages(
run: PipelineRunSummary,
approvals: PendingApproval[],
deployment: ActiveDeployment | undefined
): PipelineRunStage[] {
const scanStatus: PipelineRunStageStatus =
run.outcomeStatus === 'pending' && run.currentStage === 'scan'
? 'running'
: 'passed';
const gateStatus: PipelineRunStageStatus =
run.outcomeStatus === 'failed' && !deployment
? 'failed'
: approvals.length > 0
? 'pending'
: 'passed';
const approvalStatus: PipelineRunStageStatus =
approvals.length > 0
? 'pending'
: run.outcomeStatus === 'pending'
? 'pending'
: 'passed';
const evidenceStatus = this.evidenceToStageStatus(run.evidenceStatus);
const deploymentStatus: PipelineRunStageStatus =
deployment
? deployment.status === 'running' || deployment.status === 'waiting'
? 'running'
: deployment.status === 'paused'
? 'pending'
: 'pending'
: run.outcomeStatus === 'passed'
? 'passed'
: run.outcomeStatus === 'failed'
? 'failed'
: 'pending';
return [
{
key: 'scan',
label: 'Scan and ingestion',
status: scanStatus,
detail: scanStatus === 'running'
? 'Run is ingesting scan artifacts and signal payloads.'
: 'Scanner and signal ingestion stage completed deterministically.',
},
{
key: 'gate',
label: 'Policy gates',
status: gateStatus,
detail: gateStatus === 'failed'
? 'One or more policy or quality gates failed.'
: gateStatus === 'pending'
? 'Gate evaluation is waiting for approval queue processing.'
: 'Policy, quality, and security gates are satisfied.',
},
{
key: 'approval',
label: 'Promotion approval',
status: approvalStatus,
detail: approvalStatus === 'pending'
? `${approvals.length} approval request(s) are still pending.`
: 'Promotion approvals are complete for this run.',
},
{
key: 'evidence',
label: 'Evidence collection',
status: evidenceStatus,
detail: this.buildEvidenceSummary(run.evidenceStatus, run.releaseName),
},
{
key: 'deployment',
label: 'Deployment',
status: deploymentStatus,
detail: deployment
? `Deployment ${deployment.id} is ${deployment.status} (${deployment.progress}% complete).`
: deploymentStatus === 'passed'
? 'Deployment completed successfully across configured targets.'
: deploymentStatus === 'failed'
? 'Deployment did not complete because the run failed.'
: 'Deployment has not started yet.',
},
];
}
private resolveCurrentStage(
release: RecentRelease,
deployment: ActiveDeployment | undefined,
approvals: PendingApproval[]
): PipelineRunSummary['currentStage'] {
if (deployment && (deployment.status === 'running' || deployment.status === 'waiting')) {
return 'deployment';
}
if (approvals.length > 0) {
return 'approval';
}
if (release.status === 'ready') {
return 'gate';
}
if (release.status === 'promoting') {
return 'deployment';
}
if (release.status === 'deployed') {
return 'evidence';
}
if (release.status === 'failed' || release.status === 'rolled_back' || release.status === 'deprecated') {
return deployment ? 'deployment' : 'gate';
}
return 'scan';
}
private resolveOutcomeStatus(status: RecentRelease['status']): PipelineRunOutcomeStatus {
if (status === 'deployed') {
return 'passed';
}
if (status === 'promoting') {
return 'running';
}
if (status === 'failed' || status === 'rolled_back' || status === 'deprecated') {
return 'failed';
}
return 'pending';
}
private resolveEvidenceStatus(
status: RecentRelease['status'],
deployment: ActiveDeployment | undefined
): PipelineRunEvidenceStatus {
if (status === 'deployed') {
return 'collected';
}
if (status === 'failed' || status === 'rolled_back') {
return 'failed';
}
if (deployment && (deployment.status === 'running' || deployment.status === 'waiting')) {
return 'collecting';
}
return 'pending';
}
private evidenceToStageStatus(status: PipelineRunEvidenceStatus): PipelineRunStageStatus {
if (status === 'collected') return 'passed';
if (status === 'failed') return 'failed';
if (status === 'collecting') return 'running';
return 'pending';
}
private buildEvidenceSummary(status: PipelineRunEvidenceStatus, releaseName: string): string {
if (status === 'collected') {
return `${releaseName} has collected evidence artifacts and signatures.`;
}
if (status === 'collecting') {
return 'Evidence packets are being assembled while deployment progresses.';
}
if (status === 'failed') {
return 'Evidence collection stopped because the run failed.';
}
return 'Evidence collection is pending later pipeline stages.';
}
private compareRuns(left: PipelineRunSummary, right: PipelineRunSummary): number {
const byDate = right.createdAt.localeCompare(left.createdAt);
if (byDate !== 0) {
return byDate;
}
return left.runId.localeCompare(right.runId);
}
private toRunId(releaseId: string): string {
return `pipeline-${releaseId}`;
}
}

View File

@@ -0,0 +1,39 @@
import { SignalProvider, SignalStatus } from '../../../core/api/signals.models';
export type ProbeRuntime = 'ebpf' | 'etw' | 'dyld' | 'unknown';
export type ProbeHealthState = 'healthy' | 'degraded' | 'failed' | 'unknown';
export interface SignalsRuntimeMetricSnapshot {
signalsPerSecond: number;
errorRatePercent: number;
averageLatencyMs: number;
lastHourCount: number;
totalSignals: number;
}
export interface SignalsProviderSummary {
provider: SignalProvider;
total: number;
}
export interface SignalsStatusSummary {
status: SignalStatus;
total: number;
}
export interface HostProbeHealth {
host: string;
runtime: ProbeRuntime;
status: ProbeHealthState;
lastSeenAt: string;
sampleCount: number;
averageLatencyMs: number | null;
}
export interface SignalsRuntimeDashboardViewModel {
generatedAt: string;
metrics: SignalsRuntimeMetricSnapshot;
providerSummary: SignalsProviderSummary[];
statusSummary: SignalsStatusSummary[];
hostProbes: HostProbeHealth[];
}

View File

@@ -0,0 +1,180 @@
import { Injectable, inject } from '@angular/core';
import { Observable, forkJoin, map } from 'rxjs';
import { GatewayMetricsService } from '../../../core/api/gateway-metrics.service';
import { Signal, SignalStats, SignalStatus } from '../../../core/api/signals.models';
import { SignalsClient } from '../../../core/api/signals.client';
import {
HostProbeHealth,
ProbeHealthState,
ProbeRuntime,
SignalsRuntimeDashboardViewModel,
} from '../models/signals-runtime-dashboard.models';
interface ProbeAccumulator {
host: string;
runtime: ProbeRuntime;
lastSeenAt: string;
samples: number;
healthyCount: number;
failedCount: number;
degradedCount: number;
latencyTotal: number;
latencySamples: number;
}
@Injectable({ providedIn: 'root' })
export class SignalsRuntimeDashboardService {
private readonly signalsClient = inject(SignalsClient);
private readonly gatewayMetrics = inject(GatewayMetricsService);
loadDashboard(): Observable<SignalsRuntimeDashboardViewModel> {
return forkJoin({
stats: this.signalsClient.getStats(),
list: this.signalsClient.list(undefined, 200),
}).pipe(
map(({ stats, list }) => this.toViewModel(stats, list.items))
);
}
private toViewModel(stats: SignalStats, signals: Signal[]): SignalsRuntimeDashboardViewModel {
const requestMetrics = this.gatewayMetrics.requestMetrics();
const successRate = this.normalizeSuccessRate(stats.successRate);
const fallbackErrorRate = (1 - successRate) * 100;
const gatewayErrorRate = requestMetrics.errorRate > 0 ? requestMetrics.errorRate * 100 : 0;
const gatewayLatency = requestMetrics.averageLatencyMs > 0 ? requestMetrics.averageLatencyMs : 0;
const providerSummary = Object.entries(stats.byProvider)
.map(([provider, total]) => ({ provider: provider as SignalsRuntimeDashboardViewModel['providerSummary'][number]['provider'], total }))
.sort((a, b) => b.total - a.total || a.provider.localeCompare(b.provider));
const statusSummary = Object.entries(stats.byStatus)
.map(([status, total]) => ({ status: status as SignalStatus, total }))
.sort((a, b) => b.total - a.total || a.status.localeCompare(b.status));
return {
generatedAt: new Date().toISOString(),
metrics: {
signalsPerSecond: Number((stats.lastHourCount / 3600).toFixed(2)),
errorRatePercent: Number((gatewayErrorRate > 0 ? gatewayErrorRate : fallbackErrorRate).toFixed(2)),
averageLatencyMs: Number((gatewayLatency > 0 ? gatewayLatency : stats.avgProcessingMs).toFixed(2)),
lastHourCount: stats.lastHourCount,
totalSignals: stats.total,
},
providerSummary,
statusSummary,
hostProbes: this.extractHostProbes(signals),
};
}
private extractHostProbes(signals: Signal[]): HostProbeHealth[] {
const byHostProbe = new Map<string, ProbeAccumulator>();
for (const signal of signals) {
const payload = signal.payload ?? {};
const host = this.readString(payload, ['host', 'hostname', 'node']) ?? `unknown-${signal.provider}`;
const runtime = this.resolveRuntime(payload);
const state = this.resolveState(signal.status, payload);
const latencyMs = this.readNumber(payload, ['latencyMs', 'processingLatencyMs', 'probeLatencyMs']);
const key = `${host}|${runtime}`;
const existing = byHostProbe.get(key) ?? {
host,
runtime,
lastSeenAt: signal.processedAt ?? signal.receivedAt,
samples: 0,
healthyCount: 0,
failedCount: 0,
degradedCount: 0,
latencyTotal: 0,
latencySamples: 0,
};
existing.samples += 1;
if (state === 'healthy') existing.healthyCount += 1;
else if (state === 'failed') existing.failedCount += 1;
else if (state === 'degraded') existing.degradedCount += 1;
const seen = signal.processedAt ?? signal.receivedAt;
if (seen > existing.lastSeenAt) {
existing.lastSeenAt = seen;
}
if (typeof latencyMs === 'number' && Number.isFinite(latencyMs) && latencyMs >= 0) {
existing.latencyTotal += latencyMs;
existing.latencySamples += 1;
}
byHostProbe.set(key, existing);
}
return Array.from(byHostProbe.values())
.map((entry) => ({
host: entry.host,
runtime: entry.runtime,
status: this.rankProbeState(entry),
lastSeenAt: entry.lastSeenAt,
sampleCount: entry.samples,
averageLatencyMs: entry.latencySamples > 0
? Number((entry.latencyTotal / entry.latencySamples).toFixed(2))
: null,
}))
.sort((a, b) => a.host.localeCompare(b.host) || a.runtime.localeCompare(b.runtime));
}
private normalizeSuccessRate(value: number): number {
if (value <= 0) return 0;
if (value >= 100) return 1;
if (value > 1) return value / 100;
return value;
}
private resolveRuntime(payload: Record<string, unknown>): ProbeRuntime {
const raw = (this.readString(payload, ['probeRuntime', 'probeType', 'runtime']) ?? 'unknown').toLowerCase();
if (raw.includes('ebpf')) return 'ebpf';
if (raw.includes('etw')) return 'etw';
if (raw.includes('dyld')) return 'dyld';
return 'unknown';
}
private resolveState(status: SignalStatus, payload: Record<string, unknown>): ProbeHealthState {
const probeState = (this.readString(payload, ['probeStatus', 'health']) ?? '').toLowerCase();
if (probeState === 'healthy' || probeState === 'ok') return 'healthy';
if (probeState === 'degraded' || probeState === 'warning') return 'degraded';
if (probeState === 'failed' || probeState === 'error') return 'failed';
if (status === 'failed') return 'failed';
if (status === 'processing' || status === 'received') return 'degraded';
if (status === 'completed') return 'healthy';
return 'unknown';
}
private rankProbeState(entry: ProbeAccumulator): ProbeHealthState {
if (entry.failedCount > 0) return 'failed';
if (entry.degradedCount > 0) return 'degraded';
if (entry.healthyCount > 0) return 'healthy';
return 'unknown';
}
private readString(source: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = source[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
private readNumber(source: Record<string, unknown>, keys: string[]): number | null {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
}
return null;
}
}

View File

@@ -0,0 +1,365 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HostProbeHealth, ProbeHealthState, SignalsRuntimeDashboardViewModel } from './models/signals-runtime-dashboard.models';
import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashboard.service';
@Component({
selector: 'app-signals-runtime-dashboard',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="signals-page">
<header class="signals-header">
<div>
<h1>Signals Runtime Dashboard</h1>
<p>Per-host probe health and signal ingestion runtime metrics.</p>
</div>
<button type="button" class="refresh-btn" (click)="refresh()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</header>
@if (error()) {
<div class="error-banner" role="alert">{{ error() }}</div>
}
@if (vm(); as dashboard) {
<section class="metrics-grid" aria-label="Signal runtime metrics">
<article class="metric-card">
<h2>Signals / sec</h2>
<p>{{ dashboard.metrics.signalsPerSecond | number:'1.0-2' }}</p>
<small>Last hour events: {{ dashboard.metrics.lastHourCount }}</small>
</article>
<article class="metric-card">
<h2>Error rate</h2>
<p>{{ dashboard.metrics.errorRatePercent | number:'1.0-2' }}%</p>
<small>Total signals: {{ dashboard.metrics.totalSignals }}</small>
</article>
<article class="metric-card">
<h2>Avg latency</h2>
<p>{{ dashboard.metrics.averageLatencyMs | number:'1.0-0' }} ms</p>
<small>Gateway-backed when available</small>
</article>
</section>
<section class="summary-grid">
<article class="summary-card">
<h2>By provider</h2>
<ul>
@for (item of dashboard.providerSummary; track item.provider) {
<li>
<span>{{ item.provider }}</span>
<strong>{{ item.total }}</strong>
</li>
}
</ul>
</article>
<article class="summary-card">
<h2>By status</h2>
<ul>
@for (item of dashboard.statusSummary; track item.status) {
<li>
<span>{{ item.status }}</span>
<strong>{{ item.total }}</strong>
</li>
}
</ul>
</article>
</section>
<section class="probes-card">
<header>
<h2>Probe health by host</h2>
<small>Snapshot generated {{ dashboard.generatedAt | date:'medium' }}</small>
</header>
@if (dashboard.hostProbes.length === 0) {
<p class="empty-state">No probe telemetry available in the current signal window.</p>
} @else {
<table>
<thead>
<tr>
<th>Host</th>
<th>Runtime</th>
<th>Status</th>
<th>Latency</th>
<th>Samples</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
@for (probe of dashboard.hostProbes; track probe.host + '-' + probe.runtime) {
<tr>
<td>{{ probe.host }}</td>
<td>{{ probe.runtime }}</td>
<td>
<span class="badge" [class]="probeStateClass(probe)">{{ probe.status }}</span>
</td>
<td>{{ formatLatency(probe) }}</td>
<td>{{ probe.sampleCount }}</td>
<td>{{ probe.lastSeenAt | date:'short' }}</td>
</tr>
}
</tbody>
</table>
}
</section>
}
</section>
`,
styles: [`
:host {
display: block;
padding: 1.5rem;
background: #f6f8fb;
min-height: 100vh;
color: #0f172a;
}
.signals-page {
max-width: 1200px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.signals-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.signals-header h1 {
margin: 0;
font-size: 1.6rem;
font-weight: 700;
line-height: 1.2;
}
.signals-header p {
margin: 0.35rem 0 0;
color: #475569;
}
.refresh-btn {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
background: #ffffff;
color: #0f172a;
padding: 0.55rem 1rem;
cursor: pointer;
font-weight: 600;
}
.refresh-btn[disabled] {
opacity: 0.65;
cursor: not-allowed;
}
.error-banner {
border-radius: 0.5rem;
border: 1px solid #fecaca;
background: #fee2e2;
color: #991b1b;
padding: 0.75rem;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
}
.metric-card {
border-radius: 0.75rem;
border: 1px solid #dbe4ef;
background: #ffffff;
padding: 0.9rem;
}
.metric-card h2 {
margin: 0;
font-size: 0.95rem;
color: #475569;
font-weight: 600;
}
.metric-card p {
margin: 0.4rem 0 0.2rem;
font-size: 1.8rem;
font-weight: 700;
color: #0f172a;
}
.metric-card small {
color: #64748b;
font-size: 0.78rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.75rem;
}
.summary-card {
border-radius: 0.75rem;
border: 1px solid #dbe4ef;
background: #ffffff;
padding: 0.9rem;
}
.summary-card h2 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.summary-card ul {
list-style: none;
margin: 0;
padding: 0;
}
.summary-card li {
display: flex;
justify-content: space-between;
border-top: 1px solid #eef2f7;
padding: 0.45rem 0;
font-size: 0.92rem;
}
.summary-card li:first-child {
border-top: 0;
}
.probes-card {
border-radius: 0.75rem;
border: 1px solid #dbe4ef;
background: #ffffff;
padding: 0.9rem;
overflow-x: auto;
}
.probes-card header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: baseline;
margin-bottom: 0.7rem;
}
.probes-card header h2 {
margin: 0;
font-size: 1rem;
}
.probes-card header small {
color: #64748b;
}
table {
border-collapse: collapse;
width: 100%;
min-width: 720px;
}
th,
td {
text-align: left;
border-top: 1px solid #eef2f7;
padding: 0.6rem 0.35rem;
font-size: 0.88rem;
}
th {
border-top: 0;
color: #64748b;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge {
display: inline-flex;
border-radius: 999px;
padding: 0.15rem 0.55rem;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
border: 1px solid transparent;
}
.badge--healthy {
background: #dcfce7;
border-color: #86efac;
color: #166534;
}
.badge--degraded {
background: #fef9c3;
border-color: #fde047;
color: #854d0e;
}
.badge--failed {
background: #fee2e2;
border-color: #fca5a5;
color: #991b1b;
}
.badge--unknown {
background: #e2e8f0;
border-color: #cbd5e1;
color: #334155;
}
.empty-state {
margin: 0;
color: #64748b;
font-style: italic;
}
`],
})
export class SignalsRuntimeDashboardComponent {
private readonly dashboardService = inject(SignalsRuntimeDashboardService);
readonly vm = signal<SignalsRuntimeDashboardViewModel | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly hasProbes = computed(() => (this.vm()?.hostProbes.length ?? 0) > 0);
constructor() {
this.refresh();
}
refresh(): void {
this.loading.set(true);
this.error.set(null);
this.dashboardService.loadDashboard().subscribe({
next: (vm) => {
this.vm.set(vm);
this.loading.set(false);
},
error: () => {
this.error.set('Signals runtime data is currently unavailable.');
this.loading.set(false);
},
});
}
probeStateClass(probe: HostProbeHealth): string {
return `badge--${probe.status}`;
}
formatLatency(probe: HostProbeHealth): string {
if (probe.averageLatencyMs == null) return 'n/a';
return `${Math.round(probe.averageLatencyMs)} ms`;
}
}

View File

@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const SIGNALS_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./signals-runtime-dashboard.component').then((m) => m.SignalsRuntimeDashboardComponent),
},
];

View File

@@ -15,6 +15,9 @@ import {
} from '@angular/core';
import { TtlCountdownChipComponent } from './ttl-countdown-chip.component';
import { VexEvidenceSheetComponent } from '../../../vex_gate/vex-evidence-sheet.component';
import { VexGateButtonDirective } from '../../../vex_gate/vex-gate-button.directive';
import { VexEvidenceLine, VexGateButtonState } from '../../../vex_gate/models/vex-gate.models';
/** Reason badges for why item is parked */
export type ParkedReason =
@@ -50,7 +53,7 @@ const REASON_LABELS: Record<ParkedReason, string> = {
@Component({
selector: 'app-parked-item-card',
standalone: true,
imports: [TtlCountdownChipComponent],
imports: [TtlCountdownChipComponent, VexGateButtonDirective, VexEvidenceSheetComponent],
template: `
<article
class="parked-card"
@@ -124,6 +127,8 @@ const REASON_LABELS: Record<ParkedReason, string> = {
</button>
<button
class="action-btn primary"
[appVexGateButton]="promoteGateState()"
(gateBlocked)="onPromoteGateBlocked()"
(click)="onPromote($event)"
[disabled]="actionLoading()"
type="button"
@@ -142,6 +147,16 @@ const REASON_LABELS: Record<ParkedReason, string> = {
Extend 30d
</button>
</div>
<app-vex-evidence-sheet
[open]="promoteEvidenceOpen()"
[title]="'Promote Gate Evidence · ' + finding.id"
[tier]="promoteGateState().tier"
[verdict]="promoteGateState().verdict"
[reason]="promoteGateState().reason"
[evidence]="promoteEvidenceLines()"
(closed)="closePromoteEvidence()"
/>
</article>
`,
styles: [`
@@ -338,6 +353,21 @@ const REASON_LABELS: Record<ParkedReason, string> = {
background: var(--primary-hover);
}
.action-btn.primary.vex-gate-btn--green {
border-color: #65a30d;
background: #65a30d;
}
.action-btn.primary.vex-gate-btn--amber {
border-color: #d97706;
background: #d97706;
}
.action-btn.primary.vex-gate-btn--red {
border-color: #dc2626;
background: #dc2626;
}
.action-btn.secondary {
color: var(--text-link);
border-color: var(--text-link);
@@ -389,10 +419,63 @@ export class ParkedItemCardComponent {
private _expanded = signal(false);
private _actionLoading = signal(false);
private _currentAction = signal<'recheck' | 'promote' | 'extend' | null>(null);
private _promoteEvidenceOpen = signal(false);
readonly expanded = computed(() => this._expanded());
readonly actionLoading = computed(() => this._actionLoading());
readonly currentAction = computed(() => this._currentAction());
readonly promoteEvidenceOpen = computed(() => this._promoteEvidenceOpen());
readonly promoteGateState = computed<VexGateButtonState>(() => {
const finding = this.finding;
if (!finding) {
return {
tier: 'tier2',
verdict: 'review',
reason: 'Promotion requires review: evidence is partial and should be operator-approved.',
actionLabel: 'Promote to active',
};
}
const reasons = finding.reasons;
if (reasons.includes('low_evidence') || reasons.includes('unverified')) {
return {
tier: 'tier3',
verdict: 'block',
reason: 'Promotion blocked: finding does not yet have sufficient verified evidence.',
actionLabel: 'Promote to active',
};
}
if (reasons.includes('vendor_only') || reasons.includes('low_confidence')) {
return {
tier: 'tier2',
verdict: 'review',
reason: 'Promotion requires review: evidence is partial and should be operator-approved.',
actionLabel: 'Promote to active',
};
}
return {
tier: 'tier1',
verdict: 'allow',
reason: 'Promotion allowed: evidence is sufficient for active triage.',
actionLabel: 'Promote to active',
};
});
readonly promoteEvidenceLines = computed<VexEvidenceLine[]>(() => [
{ label: 'finding id', value: this.finding?.id ?? 'unknown', source: 'quiet-lane' },
{ label: 'severity', value: this.finding?.severity ?? 'unknown', source: 'quiet-lane' },
{ label: 'parked reasons', value: this.finding?.reasons.join(', ') || 'none', source: 'quiet-lane' },
{
label: 'gate verdict',
value: this.promoteGateState().verdict,
source: 'vex-gate',
dsseVerified: this.promoteGateState().tier === 'tier1',
},
]);
toggleExpanded(): void {
this._expanded.update(v => !v);
@@ -423,6 +506,12 @@ export class ParkedItemCardComponent {
onPromote(event: Event): void {
event.stopPropagation();
if (this.promoteGateState().tier === 'tier3') {
this.onPromoteGateBlocked();
return;
}
this._currentAction.set('promote');
this._actionLoading.set(true);
this.promoteRequested.emit(this.finding.id);
@@ -440,4 +529,12 @@ export class ParkedItemCardComponent {
this._actionLoading.set(false);
this._currentAction.set(null);
}
onPromoteGateBlocked(): void {
this._promoteEvidenceOpen.set(true);
}
closePromoteEvidence(): void {
this._promoteEvidenceOpen.set(false);
}
}

View File

@@ -15,6 +15,9 @@ import {
} from '@angular/core';
import { ParkedItemCardComponent, ParkedFinding } from './parked-item-card.component';
import { VexEvidenceSheetComponent } from '../../../vex_gate/vex-evidence-sheet.component';
import { VexGateButtonDirective } from '../../../vex_gate/vex-gate-button.directive';
import { VexEvidenceLine, VexGateButtonState } from '../../../vex_gate/models/vex-gate.models';
/** Lane selection state */
export type TriageLaneType = 'active' | 'parked' | 'review';
@@ -22,7 +25,7 @@ export type TriageLaneType = 'active' | 'parked' | 'review';
@Component({
selector: 'app-quiet-lane-container',
standalone: true,
imports: [ParkedItemCardComponent],
imports: [ParkedItemCardComponent, VexGateButtonDirective, VexEvidenceSheetComponent],
template: `
<div class="quiet-lane">
<!-- Header -->
@@ -40,6 +43,8 @@ export type TriageLaneType = 'active' | 'parked' | 'review';
<div class="bulk-actions">
<button
class="bulk-btn"
[appVexGateButton]="bulkPromoteGateState()"
(gateBlocked)="openBulkEvidenceSheet()"
(click)="onPromoteAll()"
[disabled]="bulkLoading()"
type="button"
@@ -55,6 +60,16 @@ export type TriageLaneType = 'active' | 'parked' | 'review';
Clear Expired ({{ expiredCount() }})
</button>
</div>
<app-vex-evidence-sheet
[open]="bulkEvidenceOpen()"
[title]="'Promote All Gate Evidence'"
[tier]="bulkPromoteGateState().tier"
[verdict]="bulkPromoteGateState().verdict"
[reason]="bulkPromoteGateState().reason"
[evidence]="bulkPromoteEvidence()"
(closed)="closeBulkEvidenceSheet()"
/>
}
</header>
@@ -176,6 +191,21 @@ export type TriageLaneType = 'active' | 'parked' | 'review';
color: var(--text-primary);
}
.bulk-btn.vex-gate-btn--green {
border-color: #65a30d;
box-shadow: inset 0 0 0 1px rgba(101, 163, 13, 0.2);
}
.bulk-btn.vex-gate-btn--amber {
border-color: #d97706;
box-shadow: inset 0 0 0 1px rgba(217, 119, 6, 0.2);
}
.bulk-btn.vex-gate-btn--red {
border-color: #dc2626;
box-shadow: inset 0 0 0 1px rgba(220, 38, 38, 0.2);
}
.bulk-btn:hover:not(:disabled) {
background: var(--surface-hover);
}
@@ -337,20 +367,20 @@ export class QuietLaneContainerComponent {
private _error = signal<string | null>(null);
private _bulkLoading = signal(false);
@Input()
set findings(value: ParkedFinding[]) {
@Input({ alias: 'findings' })
set findingsInput(value: ParkedFinding[]) {
this._findings.set(value);
}
@Input() defaultTtlDays = 30;
@Input()
set loading(value: boolean) {
@Input({ alias: 'loading' })
set loadingInput(value: boolean) {
this._loading.set(value);
}
@Input()
set error(value: string | null) {
@Input({ alias: 'error' })
set errorInput(value: string | null) {
this._error.set(value);
}
@@ -364,12 +394,62 @@ export class QuietLaneContainerComponent {
readonly loading = computed(() => this._loading());
readonly error = computed(() => this._error());
readonly bulkLoading = computed(() => this._bulkLoading());
readonly bulkEvidenceOpen = signal(false);
readonly expiredCount = computed(() => {
const now = new Date();
return this._findings().filter(f => new Date(f.expiresAt) <= now).length;
});
readonly bulkPromoteGateState = computed<VexGateButtonState>(() => {
const findings = this._findings();
const lowEvidenceCount = findings.filter((finding) =>
finding.reasons.includes('low_evidence') || finding.reasons.includes('unverified')).length;
if (findings.length > 0 && lowEvidenceCount === findings.length) {
return {
tier: 'tier3',
verdict: 'block',
reason: 'All parked findings are low-evidence or unverified; promotion is blocked until evidence improves.',
actionLabel: 'Promote all',
};
}
if (lowEvidenceCount > 0 || this.expiredCount() > 0) {
return {
tier: 'tier2',
verdict: 'review',
reason: 'Some parked findings have partial evidence or are expired and require operator review.',
actionLabel: 'Promote all',
};
}
return {
tier: 'tier1',
verdict: 'allow',
reason: 'All parked findings have complete evidence coverage for promotion.',
actionLabel: 'Promote all',
};
});
readonly bulkPromoteEvidence = computed<VexEvidenceLine[]>(() => {
const findings = this._findings();
const lowEvidenceCount = findings.filter((finding) =>
finding.reasons.includes('low_evidence') || finding.reasons.includes('unverified')).length;
return [
{ label: 'parked findings', value: findings.length.toString(), source: 'quiet-lane' },
{ label: 'low evidence findings', value: lowEvidenceCount.toString(), source: 'quiet-lane' },
{ label: 'expired findings', value: this.expiredCount().toString(), source: 'quiet-lane' },
{
label: 'gate verdict',
value: this.bulkPromoteGateState().verdict,
source: 'vex-gate',
dsseVerified: this.bulkPromoteGateState().tier === 'tier1',
},
];
});
onRecheckItem(findingId: string): void {
this.recheckRequested.emit([findingId]);
}
@@ -383,6 +463,11 @@ export class QuietLaneContainerComponent {
}
onPromoteAll(): void {
if (this.bulkPromoteGateState().tier === 'tier3') {
this.openBulkEvidenceSheet();
return;
}
this._bulkLoading.set(true);
const ids = this._findings().map(f => f.id);
this.promoteRequested.emit(ids);
@@ -397,4 +482,12 @@ export class QuietLaneContainerComponent {
resetBulkLoading(): void {
this._bulkLoading.set(false);
}
openBulkEvidenceSheet(): void {
this.bulkEvidenceOpen.set(true);
}
closeBulkEvidenceSheet(): void {
this.bulkEvidenceOpen.set(false);
}
}

View File

@@ -0,0 +1,205 @@
import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuditReasonRecord, AuditReasonsClient } from '../../../../core/api/audit-reasons.client';
@Component({
selector: 'app-reason-capsule',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="reason-capsule">
<button
type="button"
class="reason-toggle"
[attr.aria-expanded]="expanded()"
[attr.aria-label]="'Toggle reason details for ' + verdictId()"
(click)="toggle($event)"
>
{{ expanded() ? 'Hide why' : 'Why am I seeing this?' }}
</button>
@if (expanded()) {
<section class="reason-panel" (click)="$event.stopPropagation()">
@if (loading()) {
<p class="state state-loading">Loading reason capsule...</p>
} @else if (error()) {
<div class="state state-error">
<p>{{ error() }}</p>
<button type="button" class="retry-btn" (click)="reload($event)">Retry</button>
</div>
} @else if (reason(); as record) {
<div class="summary-grid">
<div>
<span class="label">Policy</span>
<code>{{ record.policyName }}</code>
</div>
<div>
<span class="label">Rule ID</span>
<code>{{ record.ruleId }}</code>
</div>
<div>
<span class="label">Graph Revision</span>
<code>{{ record.graphRevisionId }}</code>
</div>
<div>
<span class="label">Inputs Digest</span>
<code>{{ record.inputsDigest }}</code>
</div>
</div>
<ul class="reason-lines">
@for (line of record.reasonLines; track line) {
<li>{{ line }}</li>
}
</ul>
}
</section>
}
</div>
`,
styles: [`
.reason-capsule {
display: inline-flex;
flex-direction: column;
gap: 0.35rem;
width: 100%;
}
.reason-toggle {
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #ffffff;
color: #1e293b;
font-size: 0.74rem;
line-height: 1;
font-weight: 600;
padding: 0.3rem 0.6rem;
cursor: pointer;
white-space: nowrap;
}
.reason-toggle:hover {
border-color: #94a3b8;
background: #f8fafc;
}
.reason-panel {
border: 1px solid #dbe4ef;
border-radius: 0.6rem;
background: #f8fafc;
padding: 0.6rem;
display: grid;
gap: 0.55rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.45rem;
}
.summary-grid .label {
display: block;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-bottom: 0.15rem;
}
.summary-grid code {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 0.35rem;
background: #e2e8f0;
padding: 0.2rem 0.35rem;
font-size: 0.72rem;
color: #0f172a;
}
.reason-lines {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.22rem;
font-size: 0.76rem;
color: #334155;
}
.state {
margin: 0;
font-size: 0.76rem;
color: #475569;
}
.state-error {
display: flex;
align-items: center;
gap: 0.5rem;
color: #991b1b;
}
.state-error p {
margin: 0;
}
.retry-btn {
border: 1px solid #fca5a5;
border-radius: 0.35rem;
background: #ffffff;
color: #b91c1c;
font-size: 0.7rem;
font-weight: 600;
padding: 0.22rem 0.45rem;
cursor: pointer;
}
`],
})
export class ReasonCapsuleComponent {
private readonly auditReasonsClient = inject(AuditReasonsClient);
readonly verdictId = input.required<string>();
readonly findingId = input<string | null>(null);
readonly expanded = signal(false);
readonly loading = signal(false);
readonly reason = signal<AuditReasonRecord | null>(null);
readonly error = signal<string | null>(null);
toggle(event: Event): void {
event.stopPropagation();
const open = !this.expanded();
this.expanded.set(open);
if (open && !this.reason()) {
this.fetchReason();
}
}
reload(event: Event): void {
event.stopPropagation();
this.fetchReason();
}
private fetchReason(): void {
this.loading.set(true);
this.error.set(null);
const verdictId = this.verdictId();
this.auditReasonsClient.getReason(verdictId).subscribe({
next: (reason) => {
this.reason.set(reason);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
this.error.set('Reason details are unavailable for this verdict.');
},
});
}
}

View File

@@ -19,6 +19,7 @@ import {
import { VulnerabilityListService, type Vulnerability, type VulnerabilityFilter } from '../../services/vulnerability-list.service';
import { VexTrustChipComponent } from '../../../../shared/components/vex-trust-chip/vex-trust-chip.component';
import { ReasonCapsuleComponent } from '../reason-capsule/reason-capsule.component';
export interface QuickAction {
type: 'mark_not_affected' | 'request_analysis' | 'create_vex';
@@ -32,7 +33,7 @@ export interface FilterChange {
@Component({
selector: 'app-triage-list',
standalone: true,
imports: [CommonModule, VexTrustChipComponent],
imports: [CommonModule, VexTrustChipComponent, ReasonCapsuleComponent],
template: `
<div class="triage-list">
<!-- Filter Section -->
@@ -268,6 +269,14 @@ export interface FilterChange {
</span>
}
</div>
@if (expandedReasonId() === vuln.id) {
<div class="vuln-item__reason" (click)="$event.stopPropagation()">
<app-reason-capsule
[verdictId]="vuln.id"
[findingId]="vuln.id"
/>
</div>
}
</div>
<!-- Quick Actions -->
@@ -293,6 +302,13 @@ export interface FilterChange {
>
📝
</button>
<button
class="quick-action quick-action--reason"
title="Why am I seeing this?"
(click)="toggleReasonCapsule(vuln.id, $event)"
>
?
</button>
</div>
</article>
}
@@ -676,6 +692,15 @@ export interface FilterChange {
background: var(--primary-50);
}
.quick-action--reason {
font-weight: 700;
font-size: 0.75rem;
}
.vuln-item__reason {
margin-top: 0.5rem;
}
/* States */
.loading-state,
.error-state,
@@ -846,6 +871,7 @@ export class TriageListComponent {
// Local state
readonly selectedIds = signal<string[]>([]);
readonly focusedId = signal<string | null>(null);
readonly expandedReasonId = signal<string | null>(null);
readonly searchText = signal('');
readonly sortBy = signal<'severity' | 'cvss' | 'epss' | 'date' | 'reachability'>('severity');
@@ -1033,6 +1059,11 @@ export class TriageListComponent {
this.quickAction.emit({ type, vulnId });
}
toggleReasonCapsule(vulnId: string, event: Event): void {
event.stopPropagation();
this.expandedReasonId.set(this.expandedReasonId() === vulnId ? null : vulnId);
}
onBulkAction(type: QuickAction['type']): void {
this.bulkActionTriggered.emit({ type, vulnIds: [...this.selectedIds()] });
}
@@ -1079,3 +1110,4 @@ export class TriageListComponent {
element?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}

View File

@@ -0,0 +1,4 @@
export * from './models/vex-gate.models';
export * from './vex-gate-button.directive';
export * from './vex-evidence-sheet.component';

View File

@@ -0,0 +1,31 @@
export type VexEvidenceTier = 'tier1' | 'tier2' | 'tier3';
export type VexGateVerdict = 'allow' | 'review' | 'block';
export interface VexGateButtonState {
tier: VexEvidenceTier;
verdict: VexGateVerdict;
reason: string;
actionLabel?: string;
}
export interface VexEvidenceLine {
label: string;
value: string;
source?: string;
dsseVerified?: boolean;
}
export function toGateColorClass(tier: VexEvidenceTier): string {
switch (tier) {
case 'tier1':
return 'green';
case 'tier2':
return 'amber';
case 'tier3':
return 'red';
default:
return 'amber';
}
}

View File

@@ -0,0 +1,193 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { VexEvidenceLine, VexEvidenceTier, VexGateVerdict } from './models/vex-gate.models';
@Component({
selector: 'app-vex-evidence-sheet',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (open()) {
<section
class="vex-evidence-sheet"
[class.vex-evidence-sheet--tier1]="tier() === 'tier1'"
[class.vex-evidence-sheet--tier2]="tier() === 'tier2'"
[class.vex-evidence-sheet--tier3]="tier() === 'tier3'"
role="status"
aria-live="polite"
>
<header class="sheet-header">
<div class="title-wrap">
<h4 class="sheet-title">{{ title() }}</h4>
<span class="tier-chip">Tier {{ tierLabel() }}</span>
<span class="verdict-chip">Verdict: {{ verdict() }}</span>
</div>
<button
class="close-btn"
type="button"
aria-label="Close VEX evidence sheet"
(click)="closed.emit()"
>
×
</button>
</header>
<p class="sheet-reason">{{ reason() }}</p>
@if (evidence().length > 0) {
<ul class="evidence-list">
@for (line of evidence(); track line.label + line.value) {
<li class="evidence-item">
<span class="line-label">{{ line.label }}</span>
<span class="line-value">{{ line.value }}</span>
@if (line.source) {
<span class="line-source">source: {{ line.source }}</span>
}
@if (line.dsseVerified !== undefined) {
<span class="line-proof">
DSSE: {{ line.dsseVerified ? 'verified' : 'not verified' }}
</span>
}
</li>
}
</ul>
}
</section>
}
`,
styles: [`
.vex-evidence-sheet {
margin-top: 8px;
border-radius: 8px;
border: 1px solid var(--border-color, #d4d4d8);
background: var(--surface-primary, #ffffff);
padding: 10px 12px;
}
.vex-evidence-sheet--tier1 {
border-color: #65a30d;
background: #f7fee7;
}
.vex-evidence-sheet--tier2 {
border-color: #d97706;
background: #fffbeb;
}
.vex-evidence-sheet--tier3 {
border-color: #dc2626;
background: #fef2f2;
}
.sheet-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.title-wrap {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sheet-title {
margin: 0;
font-size: 13px;
color: var(--text-primary, #111827);
font-weight: 600;
}
.tier-chip,
.verdict-chip {
font-size: 11px;
border-radius: 999px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.08);
color: var(--text-secondary, #4b5563);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.close-btn {
border: none;
background: transparent;
color: var(--text-secondary, #4b5563);
font-size: 16px;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
line-height: 1;
}
.close-btn:hover {
background: rgba(0, 0, 0, 0.08);
}
.sheet-reason {
margin: 8px 0 6px;
font-size: 12px;
color: var(--text-secondary, #374151);
}
.evidence-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 6px;
}
.evidence-item {
display: grid;
gap: 2px;
padding: 6px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.7);
font-size: 11px;
}
.line-label {
font-weight: 600;
color: var(--text-primary, #111827);
}
.line-value {
color: var(--text-secondary, #374151);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-word;
}
.line-source,
.line-proof {
color: var(--text-muted, #6b7280);
}
`],
})
export class VexEvidenceSheetComponent {
readonly open = input(false);
readonly title = input('VEX Gate Evidence');
readonly tier = input<VexEvidenceTier>('tier2');
readonly verdict = input<VexGateVerdict>('review');
readonly reason = input('Evidence gate details are unavailable.');
readonly evidence = input<VexEvidenceLine[]>([]);
readonly closed = output<void>();
tierLabel(): string {
switch (this.tier()) {
case 'tier1':
return '1';
case 'tier2':
return '2';
case 'tier3':
return '3';
default:
return '?';
}
}
}

View File

@@ -0,0 +1,71 @@
import { Directive, EventEmitter, HostBinding, HostListener, Input, Output } from '@angular/core';
import { VexGateButtonState, toGateColorClass } from './models/vex-gate.models';
@Directive({
selector: 'button[appVexGateButton]',
standalone: true,
})
export class VexGateButtonDirective {
@Input('appVexGateButton') state: VexGateButtonState | null = null;
@Input() vexGateBlockOnTier3 = true;
@Output() gateBlocked = new EventEmitter<VexGateButtonState>();
@HostBinding('class.vex-gate-btn') readonly baseClass = true;
@HostBinding('class.vex-gate-btn--green')
get greenClass(): boolean {
return this.colorClass === 'green';
}
@HostBinding('class.vex-gate-btn--amber')
get amberClass(): boolean {
return this.colorClass === 'amber';
}
@HostBinding('class.vex-gate-btn--red')
get redClass(): boolean {
return this.colorClass === 'red';
}
@HostBinding('attr.data-vex-tier')
get dataTier(): string | null {
return this.state?.tier ?? null;
}
@HostBinding('attr.aria-disabled')
get ariaDisabled(): 'true' | null {
return this.shouldBlockAction ? 'true' : null;
}
@HostBinding('attr.aria-label')
get ariaLabel(): string | null {
if (!this.state) {
return null;
}
const label = this.state.actionLabel ?? 'Action';
return `${label} gated as ${this.state.tier.toUpperCase()}: ${this.state.reason}`;
}
@HostListener('click', ['$event'])
onClick(event: MouseEvent): void {
if (!this.shouldBlockAction || !this.state) {
return;
}
event.preventDefault();
event.stopPropagation();
this.gateBlocked.emit(this.state);
}
private get shouldBlockAction(): boolean {
return this.vexGateBlockOnTier3 && this.state?.tier === 'tier3';
}
private get colorClass(): 'green' | 'amber' | 'red' {
return toGateColorClass(this.state?.tier ?? 'tier2') as 'green' | 'amber' | 'red';
}
}

View File

@@ -0,0 +1,63 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { firstValueFrom } from 'rxjs';
import { AuditReasonRecord, AuditReasonsClient } from '../../app/core/api/audit-reasons.client';
describe('AuditReasonsClient', () => {
let client: AuditReasonsClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [AuditReasonsClient, provideHttpClient(), provideHttpClientTesting()],
});
client = TestBed.inject(AuditReasonsClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('loads reason capsule from /api/audit/reasons/:verdictId', async () => {
const response: AuditReasonRecord = {
verdictId: 'verdict-123',
policyName: 'runtime-assurance-pack',
ruleId: 'RULE-210',
graphRevisionId: 'graph-r042',
inputsDigest: 'sha256:abc',
evaluatedAt: '2026-02-08T12:00:00Z',
reasonLines: ['line-a', 'line-b'],
evidenceRefs: ['stella://policy/runtime-assurance-pack/RULE-210'],
};
const promise = firstValueFrom(client.getReason('verdict-123'));
const req = httpMock.expectOne('/api/audit/reasons/verdict-123');
expect(req.request.method).toBe('GET');
req.flush(response);
const result = await promise;
expect(result.policyName).toBe('runtime-assurance-pack');
expect(result.ruleId).toBe('RULE-210');
});
it('returns deterministic fallback data when endpoint is unavailable', async () => {
const firstPromise = firstValueFrom(client.getReason('verdict-fallback'));
const firstReq = httpMock.expectOne('/api/audit/reasons/verdict-fallback');
firstReq.flush({ error: 'down' }, { status: 503, statusText: 'Service Unavailable' });
const first = await firstPromise;
const secondPromise = firstValueFrom(client.getReason('verdict-fallback'));
const secondReq = httpMock.expectOne('/api/audit/reasons/verdict-fallback');
secondReq.flush({ error: 'down' }, { status: 503, statusText: 'Service Unavailable' });
const second = await secondPromise;
expect(first.policyName).toBe(second.policyName);
expect(first.ruleId).toBe(second.ruleId);
expect(first.graphRevisionId).toBe(second.graphRevisionId);
expect(first.inputsDigest).toBe(second.inputsDigest);
});
});

View File

@@ -0,0 +1,74 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { AuditReasonsClient } from '../../app/core/api/audit-reasons.client';
import { FindingsListComponent, Finding } from '../../app/features/findings/findings-list.component';
describe('FindingsListComponent reason capsule integration', () => {
let fixture: ComponentFixture<FindingsListComponent>;
let component: FindingsListComponent;
let auditReasonsClient: { getReason: jasmine.Spy };
const findings: Finding[] = [
{
id: 'finding-001',
verdictId: 'verdict-001',
advisoryId: 'CVE-2026-0001',
packageName: 'openssl',
packageVersion: '3.0.0',
severity: 'high',
status: 'open',
publishedAt: '2026-01-01T00:00:00Z',
},
{
id: 'finding-002',
advisoryId: 'CVE-2026-0002',
packageName: 'glibc',
packageVersion: '2.39',
severity: 'medium',
status: 'in_progress',
publishedAt: '2026-01-02T00:00:00Z',
},
];
beforeEach(async () => {
auditReasonsClient = {
getReason: jasmine.createSpy('getReason').and.returnValue(of({
verdictId: 'verdict-001',
policyName: 'default-release-gate',
ruleId: 'RULE-101',
graphRevisionId: 'graph-r001',
inputsDigest: 'sha256:1111',
evaluatedAt: '2026-02-08T10:00:00Z',
reasonLines: ['line-1'],
evidenceRefs: [],
})),
};
await TestBed.configureTestingModule({
imports: [FindingsListComponent],
providers: [{ provide: AuditReasonsClient, useValue: auditReasonsClient }],
}).compileComponents();
fixture = TestBed.createComponent(FindingsListComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('autoLoadScores', false);
fixture.componentRef.setInput('findings', findings);
fixture.detectChanges();
});
it('renders reason capsule column for each finding row', () => {
const capsules = fixture.nativeElement.querySelectorAll('app-reason-capsule');
expect(capsules.length).toBe(2);
});
it('uses verdictId when present, otherwise falls back to finding id', () => {
const toggles = fixture.nativeElement.querySelectorAll('.reason-toggle') as NodeListOf<HTMLButtonElement>;
toggles[0].click();
toggles[1].click();
fixture.detectChanges();
expect(auditReasonsClient.getReason).toHaveBeenCalledWith('verdict-001');
expect(auditReasonsClient.getReason).toHaveBeenCalledWith('finding-002');
});
});

View File

@@ -0,0 +1,67 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { AuditReasonRecord, AuditReasonsClient } from '../../app/core/api/audit-reasons.client';
import { ReasonCapsuleComponent } from '../../app/features/triage/components/reason-capsule/reason-capsule.component';
const mockReason: AuditReasonRecord = {
verdictId: 'verdict-001',
policyName: 'default-release-gate',
ruleId: 'RULE-101',
graphRevisionId: 'graph-r001',
inputsDigest: 'sha256:1111',
evaluatedAt: '2026-02-08T10:00:00Z',
reasonLines: [
'Policy default-release-gate matched risk posture and release context.',
'Rule RULE-101 evaluated deterministic evidence for verdict scope.',
],
evidenceRefs: ['stella://policy/default-release-gate/RULE-101'],
};
describe('ReasonCapsuleComponent', () => {
let fixture: ComponentFixture<ReasonCapsuleComponent>;
let component: ReasonCapsuleComponent;
let client: { getReason: jasmine.Spy };
beforeEach(async () => {
client = {
getReason: jasmine.createSpy('getReason').and.returnValue(of(mockReason)),
};
await TestBed.configureTestingModule({
imports: [ReasonCapsuleComponent],
providers: [{ provide: AuditReasonsClient, useValue: client }],
}).compileComponents();
fixture = TestBed.createComponent(ReasonCapsuleComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('verdictId', 'verdict-001');
fixture.detectChanges();
});
it('loads and renders reason details when expanded', () => {
const toggle = fixture.nativeElement.querySelector('.reason-toggle') as HTMLButtonElement;
toggle.click();
fixture.detectChanges();
expect(client.getReason).toHaveBeenCalledWith('verdict-001');
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('default-release-gate');
expect(text).toContain('RULE-101');
expect(text).toContain('graph-r001');
});
it('shows error state when loading fails', () => {
client.getReason.and.returnValue(throwError(() => new Error('boom')));
const failedFixture = TestBed.createComponent(ReasonCapsuleComponent);
failedFixture.componentRef.setInput('verdictId', 'verdict-error');
failedFixture.detectChanges();
const toggle = failedFixture.nativeElement.querySelector('.reason-toggle') as HTMLButtonElement;
toggle.click();
failedFixture.detectChanges();
const text = failedFixture.nativeElement.textContent as string;
expect(text).toContain('unavailable');
});
});

View File

@@ -0,0 +1,74 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GraphCanvasComponent } from '../../app/features/graph/graph-canvas.component';
import { GraphOverlayState } from '../../app/features/graph/graph-overlays.component';
describe('GraphCanvasComponent (graph_reachability_overlay)', () => {
let fixture: ComponentFixture<GraphCanvasComponent>;
let component: GraphCanvasComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GraphCanvasComponent],
}).compileComponents();
fixture = TestBed.createComponent(GraphCanvasComponent);
component = fixture.componentInstance;
});
it('renders reachability halo using lattice-state color mapping', () => {
component.nodes = [
{
id: 'comp-log4j',
type: 'component',
name: 'log4j-core',
version: '2.14.1',
},
];
component.edges = [];
component.overlayState = createOverlayState('comp-log4j', 'SR');
fixture.detectChanges();
const halo = fixture.nativeElement.querySelector('.reachability-halo') as SVGRectElement | null;
expect(halo).not.toBeNull();
expect(halo?.getAttribute('stroke')).toBe('#16a34a');
const title = halo?.querySelector('title');
expect(title?.textContent ?? '').toContain('SR');
});
it('exposes deterministic halo colors for each lattice state', () => {
expect(component.getReachabilityHaloStroke('SR')).toBe('#16a34a');
expect(component.getReachabilityHaloStroke('SU')).toBe('#65a30d');
expect(component.getReachabilityHaloStroke('RO')).toBe('#0284c7');
expect(component.getReachabilityHaloStroke('RU')).toBe('#0ea5e9');
expect(component.getReachabilityHaloStroke('CR')).toBe('#f59e0b');
expect(component.getReachabilityHaloStroke('CU')).toBe('#f97316');
expect(component.getReachabilityHaloStroke('X')).toBe('#94a3b8');
});
});
function createOverlayState(
nodeId: string,
latticeState: 'SR' | 'SU' | 'RO' | 'RU' | 'CR' | 'CU' | 'X'
): GraphOverlayState {
return {
policy: new Map(),
evidence: new Map(),
license: new Map(),
exposure: new Map(),
reachability: new Map([
[
nodeId,
{
nodeId,
latticeState,
status: latticeState === 'X' ? 'unknown' : 'reachable',
confidence: 0.9,
observedAt: '2025-12-12T00:00:00.000Z',
},
],
]),
};
}

View File

@@ -0,0 +1,78 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
GraphOverlaysComponent,
ReachabilityOverlayData,
} from '../../app/features/graph/graph-overlays.component';
const ALLOWED_LATTICE_STATES: ReachabilityOverlayData['latticeState'][] = [
'SR',
'SU',
'RO',
'RU',
'CR',
'CU',
'X',
];
describe('GraphOverlaysComponent (graph_reachability_overlay)', () => {
let fixture: ComponentFixture<GraphOverlaysComponent>;
let component: GraphOverlaysComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GraphOverlaysComponent],
}).compileComponents();
fixture = TestBed.createComponent(GraphOverlaysComponent);
component = fixture.componentInstance;
});
it('generates deterministic reachability lattice data per selected snapshot', () => {
component.nodeIds = ['asset-web-prod', 'comp-log4j'];
fixture.detectChanges();
component.toggleOverlay('reachability');
const current = component.getReachabilityData('asset-web-prod');
expect(current).toBeDefined();
expect(ALLOWED_LATTICE_STATES).toContain(current!.latticeState);
component.setSnapshot('7d');
const weekOld = component.getReachabilityData('asset-web-prod');
expect(weekOld).toBeDefined();
expect(weekOld!.observedAt).not.toEqual(current!.observedAt);
component.setSnapshot('7d');
const weekOldAgain = component.getReachabilityData('asset-web-prod');
expect(weekOldAgain).toEqual(weekOld);
});
it('maps snapshot slider index to snapshot label and timeline event', () => {
fixture.detectChanges();
expect(component.selectedSnapshot()).toBe('current');
expect(component.snapshotLabel()).toBe('Current');
expect(component.activeSnapshotEvent().label).toBe('Current snapshot');
component.setSnapshotByIndex(2);
expect(component.selectedSnapshot()).toBe('7d');
expect(component.snapshotLabel()).toBe('7 days ago');
expect(component.activeSnapshotEvent().label).toBe('7 days ago');
});
it('renders lattice legend and timeline content when reachability overlay is enabled', () => {
component.nodeIds = ['asset-web-prod'];
fixture.detectChanges();
component.toggleOverlay('reachability');
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Reachability Lattice');
expect(text).toContain('SR - Strong reachable');
expect(text).toContain('RU - Unreachable observed');
expect(text).toContain('Time Travel');
});
});

View File

@@ -0,0 +1,151 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { CompatibilityResult } from '../../app/core/api/pack-registry.models';
import { PackRegistryBrowserViewModel, PackRegistryRow } from '../../app/features/pack-registry/models/pack-registry-browser.models';
import { PackRegistryBrowserComponent } from '../../app/features/pack-registry/pack-registry-browser.component';
import { PackRegistryBrowserService } from '../../app/features/pack-registry/services/pack-registry-browser.service';
const compatible: CompatibilityResult = {
compatible: true,
platformVersionOk: true,
dependenciesSatisfied: true,
conflicts: [],
warnings: [],
};
const incompatible: CompatibilityResult = {
compatible: false,
platformVersionOk: true,
dependenciesSatisfied: false,
conflicts: ['Dependency mismatch'],
warnings: [],
};
const samplePacks: PackRegistryRow[] = [
{
id: 'pack-a',
name: 'Alpha Pack',
description: 'Alpha policy support',
author: 'stella',
capabilities: ['policy'],
platformCompatibility: '>=1.0.0',
status: 'available',
latestVersion: '1.2.0',
updatedAt: '2026-02-08T08:00:00Z',
signatureState: 'verified',
signedBy: 'fulcio://alpha',
primaryAction: 'install',
primaryActionLabel: 'Install',
actionEnabled: true,
},
{
id: 'pack-b',
name: 'Beta Pack',
description: 'Runtime scanner',
author: 'stella',
capabilities: ['runtime'],
platformCompatibility: '>=1.0.0',
status: 'outdated',
installedVersion: '1.0.0',
latestVersion: '1.4.0',
updatedAt: '2026-02-08T09:00:00Z',
signatureState: 'unsigned',
primaryAction: 'upgrade',
primaryActionLabel: 'Upgrade',
actionEnabled: true,
},
];
const vm: PackRegistryBrowserViewModel = {
generatedAt: '2026-02-08T10:00:00Z',
packs: samplePacks,
capabilities: ['policy', 'runtime'],
installedCount: 1,
upgradeAvailableCount: 1,
totalCount: 2,
};
describe('PackRegistryBrowserComponent', () => {
let fixture: ComponentFixture<PackRegistryBrowserComponent>;
let service: {
loadDashboard: jasmine.Spy;
loadVersions: jasmine.Spy;
checkCompatibility: jasmine.Spy;
executePrimaryAction: jasmine.Spy;
};
beforeEach(async () => {
service = {
loadDashboard: jasmine.createSpy('loadDashboard').and.returnValue(of(vm)),
loadVersions: jasmine.createSpy('loadVersions').and.returnValue(of([])),
checkCompatibility: jasmine.createSpy('checkCompatibility').and.returnValue(of(compatible)),
executePrimaryAction: jasmine.createSpy('executePrimaryAction').and.returnValue(of({
packId: 'pack-a',
action: 'install',
success: true,
message: 'Installed Alpha Pack successfully.',
compatibility: compatible,
})),
};
await TestBed.configureTestingModule({
imports: [PackRegistryBrowserComponent],
providers: [{ provide: PackRegistryBrowserService, useValue: service as unknown as PackRegistryBrowserService }],
}).compileComponents();
fixture = TestBed.createComponent(PackRegistryBrowserComponent);
fixture.detectChanges();
});
it('renders the pack list and DSSE signature state', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Pack Registry Browser');
expect(text).toContain('Alpha Pack');
expect(text).toContain('Beta Pack');
expect(text).toContain('DSSE verified');
expect(text).toContain('Unsigned');
});
it('runs primary action and refreshes dashboard data on success', () => {
const actionButton = fixture.nativeElement.querySelector('[data-testid="primary-action-pack-a"]') as HTMLButtonElement;
actionButton.click();
fixture.detectChanges();
expect(service.executePrimaryAction).toHaveBeenCalledTimes(1);
expect(service.loadDashboard).toHaveBeenCalledTimes(2);
});
it('records incompatible result from explicit compatibility check', () => {
service.checkCompatibility.and.returnValue(of(incompatible));
const checkButton = fixture.nativeElement.querySelector('[data-testid="check-compatibility-pack-b"]') as HTMLButtonElement;
checkButton.click();
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Compatibility check failed for Beta Pack.');
expect(text).toContain('Incompatible');
});
it('loads version history when user opens versions panel', () => {
service.loadVersions.and.returnValue(of([
{
version: '1.4.0',
releaseDate: '2026-02-01T00:00:00Z',
changelog: 'improvements',
downloads: 12,
isBreaking: false,
signatureState: 'verified',
signedBy: 'fulcio://beta',
},
]));
const toggleButton = fixture.nativeElement.querySelector('[data-testid="toggle-versions-pack-b"]') as HTMLButtonElement;
toggleButton.click();
fixture.detectChanges();
expect(service.loadVersions).toHaveBeenCalledWith('pack-b');
expect(fixture.nativeElement.textContent).toContain('1.4.0');
});
});

View File

@@ -0,0 +1,170 @@
import { TestBed } from '@angular/core/testing';
import { firstValueFrom, of } from 'rxjs';
import { PackRegistryClient } from '../../app/core/api/pack-registry.client';
import { Pack, PackStatus } from '../../app/core/api/pack-registry.models';
import { PackRegistryRow } from '../../app/features/pack-registry/models/pack-registry-browser.models';
import { PackRegistryBrowserService } from '../../app/features/pack-registry/services/pack-registry-browser.service';
const createPack = (overrides: Partial<Pack> = {}): Pack => ({
id: 'pack-default',
name: 'Default Pack',
version: '1.0.0',
description: 'Default pack description',
author: 'stella',
isOfficial: true,
platformCompatibility: '>=1.0.0',
capabilities: ['scan'],
status: 'available',
latestVersion: '1.0.0',
updatedAt: '2026-02-08T00:00:00Z',
...overrides,
});
describe('PackRegistryBrowserService', () => {
let service: PackRegistryBrowserService;
let client: {
list: jasmine.Spy;
getInstalled: jasmine.Spy;
getVersions: jasmine.Spy;
checkCompatibility: jasmine.Spy;
install: jasmine.Spy;
upgrade: jasmine.Spy;
};
beforeEach(() => {
client = {
list: jasmine.createSpy('list'),
getInstalled: jasmine.createSpy('getInstalled'),
getVersions: jasmine.createSpy('getVersions'),
checkCompatibility: jasmine.createSpy('checkCompatibility'),
install: jasmine.createSpy('install'),
upgrade: jasmine.createSpy('upgrade'),
};
TestBed.configureTestingModule({
providers: [
PackRegistryBrowserService,
{ provide: PackRegistryClient, useValue: client as unknown as PackRegistryClient },
],
});
service = TestBed.inject(PackRegistryBrowserService);
});
it('builds deterministic pack rows with installed/outdated and signature states', async () => {
client.list.and.returnValue(of({
items: [
createPack({
id: 'pack-b',
name: 'B Pack',
capabilities: ['runtime', 'policy'],
status: 'available',
}),
createPack({
id: 'pack-a',
name: 'A Pack',
latestVersion: '2.0.0',
signature: 'dsse-envelope',
signedBy: 'fulcio://stella',
capabilities: ['policy'],
}),
],
total: 2,
}));
client.getInstalled.and.returnValue(of([
createPack({
id: 'pack-a',
name: 'A Pack',
version: '1.0.0',
latestVersion: '2.0.0',
status: 'installed' as PackStatus,
}),
]));
const vm = await firstValueFrom(service.loadDashboard());
expect(vm.totalCount).toBe(2);
expect(vm.installedCount).toBe(1);
expect(vm.upgradeAvailableCount).toBe(1);
expect(vm.packs.map((pack) => pack.id)).toEqual(['pack-a', 'pack-b']);
expect(vm.packs[0].status).toBe('outdated');
expect(vm.packs[0].signatureState).toBe('verified');
expect(vm.packs[1].signatureState).toBe('unsigned');
expect(vm.capabilities).toEqual(['policy', 'runtime']);
});
it('blocks install or upgrade when compatibility is false', async () => {
client.checkCompatibility.and.returnValue(of({
compatible: false,
platformVersionOk: true,
dependenciesSatisfied: false,
conflicts: ['Missing dependency: scanner-core >= 2.0.0'],
warnings: [],
}));
const row: PackRegistryRow = {
id: 'pack-a',
name: 'A Pack',
description: 'desc',
author: 'stella',
capabilities: ['policy'],
platformCompatibility: '>=1.0.0',
status: 'available',
latestVersion: '2.0.0',
updatedAt: '2026-02-08T00:00:00Z',
signatureState: 'unsigned',
primaryAction: 'install',
primaryActionLabel: 'Install',
actionEnabled: true,
};
const result = await firstValueFrom(service.executePrimaryAction(row));
expect(result.success).toBeFalse();
expect(result.action).toBe('install');
expect(result.message).toContain('Pack action blocked');
expect(client.install).not.toHaveBeenCalled();
expect(client.upgrade).not.toHaveBeenCalled();
});
it('uses upgrade action for installed packs once compatibility succeeds', async () => {
client.checkCompatibility.and.returnValue(of({
compatible: true,
platformVersionOk: true,
dependenciesSatisfied: true,
conflicts: [],
warnings: [],
}));
client.upgrade.and.returnValue(of(createPack({
id: 'pack-upgrade',
name: 'Upgrade Pack',
status: 'installed',
version: '2.0.0',
latestVersion: '2.0.0',
})));
const row: PackRegistryRow = {
id: 'pack-upgrade',
name: 'Upgrade Pack',
description: 'desc',
author: 'stella',
capabilities: ['runtime'],
platformCompatibility: '>=1.0.0',
status: 'outdated',
installedVersion: '1.0.0',
latestVersion: '2.0.0',
updatedAt: '2026-02-08T00:00:00Z',
signatureState: 'verified',
primaryAction: 'upgrade',
primaryActionLabel: 'Upgrade',
actionEnabled: true,
};
const result = await firstValueFrom(service.executePrimaryAction(row));
expect(result.success).toBeTrue();
expect(result.action).toBe('upgrade');
expect(client.upgrade).toHaveBeenCalledWith('pack-upgrade', undefined);
expect(client.install).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,101 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { PipelineRunListViewModel } from '../../app/features/release-orchestrator/runs/models/pipeline-runs.models';
import { PipelineRunsListComponent } from '../../app/features/release-orchestrator/runs/pipeline-runs-list.component';
import { PipelineRunsService } from '../../app/features/release-orchestrator/runs/services/pipeline-runs.service';
const vm: PipelineRunListViewModel = {
generatedAt: '2026-02-08T11:00:00Z',
totalRuns: 2,
activeRuns: 1,
failedRuns: 1,
completedRuns: 0,
runs: [
{
runId: 'pipeline-rel-2',
releaseId: 'rel-2',
releaseName: 'payments',
releaseVersion: '2.0.0',
createdAt: '2026-02-08T10:00:00Z',
currentEnvironment: 'staging',
currentStage: 'deployment',
outcomeStatus: 'running',
pendingApprovalCount: 1,
activeDeploymentId: 'dep-2',
deploymentProgress: 60,
evidenceStatus: 'collecting',
},
{
runId: 'pipeline-rel-3',
releaseId: 'rel-3',
releaseName: 'billing',
releaseVersion: '1.4.1',
createdAt: '2026-02-08T09:00:00Z',
currentEnvironment: 'qa',
currentStage: 'gate',
outcomeStatus: 'failed',
pendingApprovalCount: 0,
evidenceStatus: 'failed',
},
],
};
describe('PipelineRunsListComponent', () => {
let fixture: ComponentFixture<PipelineRunsListComponent>;
let service: { loadRuns: jasmine.Spy };
beforeEach(async () => {
service = {
loadRuns: jasmine.createSpy('loadRuns').and.returnValue(of(vm)),
};
await TestBed.configureTestingModule({
imports: [PipelineRunsListComponent],
providers: [
provideRouter([]),
{ provide: PipelineRunsService, useValue: service as unknown as PipelineRunsService },
],
}).compileComponents();
fixture = TestBed.createComponent(PipelineRunsListComponent);
fixture.detectChanges();
});
it('renders pipeline run rows with status and stage labels', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Pipeline Runs');
expect(text).toContain('payments');
expect(text).toContain('billing');
expect(text).toContain('running');
expect(text).toContain('failed');
const rows = fixture.nativeElement.querySelectorAll('tbody tr');
expect(rows.length).toBe(2);
});
it('filters rows by selected outcome status', () => {
const select = fixture.nativeElement.querySelector('[data-testid="run-status-filter"]') as HTMLSelectElement;
select.value = 'failed';
select.dispatchEvent(new Event('change'));
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('tbody tr');
expect(rows.length).toBe(1);
expect((rows[0] as HTMLElement).textContent).toContain('billing');
expect((rows[0] as HTMLElement).textContent).not.toContain('payments');
});
it('filters rows by search query', () => {
const search = fixture.nativeElement.querySelector('[data-testid="run-search"]') as HTMLInputElement;
search.value = 'pipeline-rel-2';
search.dispatchEvent(new Event('input'));
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('tbody tr');
expect(rows.length).toBe(1);
expect((rows[0] as HTMLElement).textContent).toContain('payments');
});
});

View File

@@ -0,0 +1,116 @@
import { TestBed } from '@angular/core/testing';
import { firstValueFrom, of } from 'rxjs';
import { RELEASE_DASHBOARD_API, ReleaseDashboardApi } from '../../app/core/api/release-dashboard.client';
import { DashboardData } from '../../app/core/api/release-dashboard.models';
import { PipelineRunsService } from '../../app/features/release-orchestrator/runs/services/pipeline-runs.service';
const dashboardData: DashboardData = {
pipelineData: {
environments: [],
connections: [],
},
pendingApprovals: [
{
id: 'apr-1',
releaseId: 'rel-2',
releaseName: 'payments',
releaseVersion: '2.0.0',
sourceEnvironment: 'staging',
targetEnvironment: 'prod',
requestedBy: 'approver@example.com',
requestedAt: '2026-02-08T10:03:00Z',
urgency: 'high',
},
],
activeDeployments: [
{
id: 'dep-2',
releaseId: 'rel-2',
releaseName: 'payments',
releaseVersion: '2.0.0',
environment: 'prod',
progress: 60,
status: 'running',
startedAt: '2026-02-08T10:05:00Z',
completedTargets: 3,
totalTargets: 5,
},
],
recentReleases: [
{
id: 'rel-1',
name: 'gateway',
version: '1.2.0',
status: 'deployed',
currentEnvironment: 'prod',
createdAt: '2026-02-08T09:00:00Z',
createdBy: 'ci',
componentCount: 2,
},
{
id: 'rel-2',
name: 'payments',
version: '2.0.0',
status: 'promoting',
currentEnvironment: 'staging',
createdAt: '2026-02-08T10:00:00Z',
createdBy: 'ci',
componentCount: 4,
},
],
};
describe('PipelineRunsService', () => {
let service: PipelineRunsService;
let api: { getDashboardData: jasmine.Spy };
beforeEach(() => {
api = {
getDashboardData: jasmine.createSpy('getDashboardData').and.returnValue(of(dashboardData)),
};
TestBed.configureTestingModule({
providers: [
PipelineRunsService,
{ provide: RELEASE_DASHBOARD_API, useValue: api as unknown as ReleaseDashboardApi },
],
});
service = TestBed.inject(PipelineRunsService);
});
it('maps release, approval, and deployment data into deterministic pipeline runs', async () => {
const vm = await firstValueFrom(service.loadRuns());
expect(vm.totalRuns).toBe(2);
expect(vm.activeRuns).toBe(1);
expect(vm.completedRuns).toBe(1);
expect(vm.failedRuns).toBe(0);
expect(vm.runs[0].runId).toBe('pipeline-rel-2');
expect(vm.runs[0].outcomeStatus).toBe('running');
expect(vm.runs[0].currentStage).toBe('deployment');
expect(vm.runs[0].pendingApprovalCount).toBe(1);
expect(vm.runs[0].activeDeploymentId).toBe('dep-2');
expect(vm.runs[0].evidenceStatus).toBe('collecting');
expect(vm.runs[1].runId).toBe('pipeline-rel-1');
expect(vm.runs[1].outcomeStatus).toBe('passed');
expect(vm.runs[1].evidenceStatus).toBe('collected');
});
it('builds run detail stages and returns null for unknown run ids', async () => {
const detail = await firstValueFrom(service.loadRunDetail('pipeline-rel-2'));
expect(detail).toBeTruthy();
expect(detail!.stages.length).toBe(5);
expect(detail!.stages[0].key).toBe('scan');
expect(detail!.stages[4].key).toBe('deployment');
expect(detail!.gateSummary).toContain('pending');
expect(detail!.evidenceSummary).toContain('assembled');
const missing = await firstValueFrom(service.loadRunDetail('pipeline-unknown'));
expect(missing).toBeNull();
});
});

View File

@@ -0,0 +1,47 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReachabilityCenterComponent } from '../../app/features/reachability/reachability-center.component';
describe('ReachabilityCenterComponent (reachability_center)', () => {
let fixture: ComponentFixture<ReachabilityCenterComponent>;
let component: ReachabilityCenterComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReachabilityCenterComponent],
}).compileComponents();
fixture = TestBed.createComponent(ReachabilityCenterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('computes deterministic coverage and missing-sensor summaries', () => {
expect(component.okCount()).toBe(1);
expect(component.staleCount()).toBe(1);
expect(component.missingCount()).toBe(1);
expect(component.fleetCoveragePercent()).toBe(69);
expect(component.sensorCoveragePercent()).toBe(63);
expect(component.assetsMissingSensors().map((a) => a.assetId)).toEqual([
'asset-api-prod',
'asset-worker-prod',
]);
});
it('supports missing-sensor quick filter action', () => {
component.goToMissingSensors();
fixture.detectChanges();
expect(component.statusFilter()).toBe('missing');
expect(component.filteredRows().map((r) => r.assetId)).toEqual(['asset-worker-prod']);
});
it('renders missing sensor chips and per-row sensor gap text', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Missing sensors detected');
expect(text).toContain('asset-api-prod');
expect(text).toContain('missing 1 sensor');
expect(text).toContain('missing 2 sensors');
expect(text).toContain('all sensors online');
});
});

View File

@@ -0,0 +1,84 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { SignalsRuntimeDashboardComponent } from '../../app/features/signals/signals-runtime-dashboard.component';
import { SignalsRuntimeDashboardViewModel } from '../../app/features/signals/models/signals-runtime-dashboard.models';
import { SignalsRuntimeDashboardService } from '../../app/features/signals/services/signals-runtime-dashboard.service';
const dashboardVm: SignalsRuntimeDashboardViewModel = {
generatedAt: '2026-02-08T12:00:00Z',
metrics: {
signalsPerSecond: 1.5,
errorRatePercent: 4.2,
averageLatencyMs: 72,
lastHourCount: 5400,
totalSignals: 15200,
},
providerSummary: [
{ provider: 'github', total: 4500 },
{ provider: 'gitlab', total: 900 },
],
statusSummary: [
{ status: 'completed', total: 5200 },
{ status: 'failed', total: 200 },
],
hostProbes: [
{
host: 'host-a',
runtime: 'ebpf',
status: 'healthy',
lastSeenAt: '2026-02-08T11:59:00Z',
sampleCount: 40,
averageLatencyMs: 55,
},
],
};
describe('SignalsRuntimeDashboardComponent', () => {
let fixture: ComponentFixture<SignalsRuntimeDashboardComponent>;
let service: { loadDashboard: jasmine.Spy };
beforeEach(async () => {
service = {
loadDashboard: jasmine.createSpy('loadDashboard'),
};
service.loadDashboard.and.returnValue(of(dashboardVm));
await TestBed.configureTestingModule({
imports: [SignalsRuntimeDashboardComponent],
providers: [{ provide: SignalsRuntimeDashboardService, useValue: service as unknown as SignalsRuntimeDashboardService }],
}).compileComponents();
fixture = TestBed.createComponent(SignalsRuntimeDashboardComponent);
fixture.detectChanges();
});
it('renders summary metrics and probe rows from dashboard data', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Signals Runtime Dashboard');
expect(text).toContain('1.5');
expect(text).toContain('4.2%');
expect(text).toContain('72 ms');
expect(text).toContain('host-a');
expect(text).toContain('healthy');
});
it('refreshes when refresh button is clicked', () => {
const refreshButton = fixture.nativeElement.querySelector('.refresh-btn') as HTMLButtonElement;
refreshButton.click();
expect(service.loadDashboard).toHaveBeenCalledTimes(2);
});
it('shows error banner when service load fails', async () => {
service.loadDashboard.and.returnValue(throwError(() => new Error('boom')));
const failedFixture = TestBed.createComponent(SignalsRuntimeDashboardComponent);
failedFixture.detectChanges();
await failedFixture.whenStable();
failedFixture.detectChanges();
const errorBanner = failedFixture.nativeElement.querySelector('.error-banner') as HTMLElement;
expect(errorBanner).toBeTruthy();
expect(errorBanner.textContent).toContain('currently unavailable');
});
});

View File

@@ -0,0 +1,150 @@
import { TestBed } from '@angular/core/testing';
import { firstValueFrom, of } from 'rxjs';
import { GatewayMetricsService, RequestMetricsSummary } from '../../app/core/api/gateway-metrics.service';
import { SignalsClient } from '../../app/core/api/signals.client';
import { Signal, SignalStats } from '../../app/core/api/signals.models';
import { SignalsRuntimeDashboardService } from '../../app/features/signals/services/signals-runtime-dashboard.service';
const emptyRequestMetrics = (): RequestMetricsSummary => ({
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
averageLatencyMs: 0,
p50LatencyMs: 0,
p95LatencyMs: 0,
p99LatencyMs: 0,
errorRate: 0,
requestsPerMinute: 0,
});
const createStats = (overrides: Partial<SignalStats> = {}): SignalStats => ({
total: 120,
byType: {
scm_push: 12,
scm_pr: 8,
ci_build: 40,
ci_deploy: 6,
registry_push: 18,
scan_complete: 26,
policy_eval: 10,
},
byStatus: {
received: 15,
processing: 8,
completed: 88,
failed: 9,
ignored: 0,
},
byProvider: {
github: 65,
gitlab: 22,
gitea: 5,
jenkins: 10,
tekton: 7,
harbor: 8,
internal: 3,
},
lastHourCount: 360,
successRate: 0.9,
avgProcessingMs: 210,
...overrides,
});
const createSignal = (overrides: Partial<Signal> = {}): Signal => ({
id: 'sig-1',
type: 'ci_build',
provider: 'github',
status: 'completed',
payload: {
host: 'host-a',
probeRuntime: 'eBPF',
probeStatus: 'healthy',
latencyMs: 42,
},
triggeredActions: [],
receivedAt: '2026-02-08T10:00:00Z',
processedAt: '2026-02-08T10:00:01Z',
...overrides,
});
describe('SignalsRuntimeDashboardService', () => {
let service: SignalsRuntimeDashboardService;
let signalsClient: {
getStats: jasmine.Spy;
list: jasmine.Spy;
};
let gatewayMetrics: { requestMetrics: jasmine.Spy };
beforeEach(() => {
signalsClient = {
getStats: jasmine.createSpy('getStats'),
list: jasmine.createSpy('list'),
};
gatewayMetrics = {
requestMetrics: jasmine.createSpy('requestMetrics').and.returnValue(emptyRequestMetrics()),
};
TestBed.configureTestingModule({
providers: [
SignalsRuntimeDashboardService,
{ provide: SignalsClient, useValue: signalsClient as unknown as SignalsClient },
{ provide: GatewayMetricsService, useValue: gatewayMetrics },
],
});
service = TestBed.inject(SignalsRuntimeDashboardService);
});
it('builds dashboard metrics and per-host probe status from signals payloads', async () => {
signalsClient.getStats.and.returnValue(of(createStats()));
signalsClient.list.and.returnValue(of({
items: [
createSignal({
id: 'sig-a',
payload: { host: 'host-a', probeRuntime: 'eBPF', probeStatus: 'healthy', latencyMs: 40 },
status: 'completed',
}),
createSignal({
id: 'sig-b',
payload: { host: 'host-a', probeRuntime: 'eBPF', probeStatus: 'failed', latencyMs: 60 },
status: 'failed',
processedAt: '2026-02-08T10:03:00Z',
}),
createSignal({
id: 'sig-c',
payload: { hostname: 'host-b', probeType: 'etw', latencyMs: 30 },
status: 'processing',
provider: 'gitlab',
}),
],
total: 3,
}));
const vm = await firstValueFrom(service.loadDashboard());
expect(vm.metrics.signalsPerSecond).toBe(0.1);
expect(vm.metrics.errorRatePercent).toBe(10);
expect(vm.metrics.averageLatencyMs).toBe(210);
expect(vm.hostProbes.length).toBe(2);
expect(vm.hostProbes[0].host).toBe('host-a');
expect(vm.hostProbes[0].runtime).toBe('ebpf');
expect(vm.hostProbes[0].status).toBe('failed');
expect(vm.hostProbes[0].averageLatencyMs).toBe(50);
expect(vm.hostProbes[1].host).toBe('host-b');
expect(vm.hostProbes[1].status).toBe('degraded');
});
it('prefers gateway runtime error-rate and latency snapshots when present', async () => {
signalsClient.getStats.and.returnValue(of(createStats({ successRate: 0.99, avgProcessingMs: 333 })));
signalsClient.list.and.returnValue(of({ items: [], total: 0 }));
gatewayMetrics.requestMetrics.and.returnValue({
...emptyRequestMetrics(),
errorRate: 0.25,
averageLatencyMs: 88,
} as RequestMetricsSummary);
const vm = await firstValueFrom(service.loadDashboard());
expect(vm.metrics.errorRatePercent).toBe(25);
expect(vm.metrics.averageLatencyMs).toBe(88);
});
});

View File

@@ -0,0 +1,65 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ParkedFinding, ParkedItemCardComponent } from '../../app/features/triage/components/quiet-lane/parked-item-card.component';
const baseFinding: ParkedFinding = {
id: 'finding-001',
title: 'Prototype pollution in parser dependency',
component: 'pkg:npm/parser-lib@1.2.0',
version: '1.2.0',
severity: 'high',
reasons: ['vendor_only'],
parkedAt: '2026-02-07T10:00:00Z',
expiresAt: '2026-03-01T10:00:00Z',
parkedBy: 'operator@stellaops.local',
};
describe('ParkedItemCardComponent', () => {
let fixture: ComponentFixture<ParkedItemCardComponent>;
let component: ParkedItemCardComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ParkedItemCardComponent],
}).compileComponents();
fixture = TestBed.createComponent(ParkedItemCardComponent);
component = fixture.componentInstance;
});
it('maps vendor-only finding to tier2/amber gate state', () => {
component.finding = { ...baseFinding, reasons: ['vendor_only'] };
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.action-btn.primary') as HTMLButtonElement;
expect(component.promoteGateState().tier).toBe('tier2');
expect(button.classList.contains('vex-gate-btn--amber')).toBeTrue();
});
it('blocks promote action for tier3 findings and opens evidence sheet', () => {
component.finding = { ...baseFinding, reasons: ['low_evidence'] };
const emitSpy = spyOn(component.promoteRequested, 'emit');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.action-btn.primary') as HTMLButtonElement;
button.click();
fixture.detectChanges();
expect(component.promoteGateState().tier).toBe('tier3');
expect(emitSpy).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('.vex-evidence-sheet')).toBeTruthy();
});
it('allows promote action for tier1 findings', () => {
component.finding = { ...baseFinding, reasons: ['no_fix_available'] };
const emitSpy = spyOn(component.promoteRequested, 'emit');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.action-btn.primary') as HTMLButtonElement;
button.click();
expect(component.promoteGateState().tier).toBe('tier1');
expect(emitSpy).toHaveBeenCalledWith('finding-001');
});
});

View File

@@ -0,0 +1,75 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ParkedFinding } from '../../app/features/triage/components/quiet-lane/parked-item-card.component';
import { QuietLaneContainerComponent } from '../../app/features/triage/components/quiet-lane/quiet-lane-container.component';
const finding = (id: string, reasons: ParkedFinding['reasons']): ParkedFinding => ({
id,
title: `Finding ${id}`,
component: 'pkg:npm/example@1.0.0',
version: '1.0.0',
severity: 'medium',
reasons,
parkedAt: '2026-02-07T10:00:00Z',
expiresAt: '2026-03-07T10:00:00Z',
});
describe('QuietLaneContainerComponent', () => {
let fixture: ComponentFixture<QuietLaneContainerComponent>;
let component: QuietLaneContainerComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [QuietLaneContainerComponent],
}).compileComponents();
fixture = TestBed.createComponent(QuietLaneContainerComponent);
component = fixture.componentInstance;
component.loadingInput = false;
component.errorInput = null;
});
it('uses amber gate state when mixed evidence quality is present', () => {
component.findingsInput = [
finding('f-1', ['low_evidence']),
finding('f-2', ['no_fix_available']),
];
fixture.detectChanges();
const promoteButton = fixture.nativeElement.querySelector('.bulk-btn') as HTMLButtonElement;
expect(component.bulkPromoteGateState().tier).toBe('tier2');
expect(promoteButton.classList.contains('vex-gate-btn--amber')).toBeTrue();
});
it('blocks bulk promote and opens evidence when all findings are low evidence', () => {
component.findingsInput = [
finding('f-1', ['low_evidence']),
finding('f-2', ['unverified']),
];
const emitSpy = spyOn(component.promoteRequested, 'emit');
fixture.detectChanges();
const promoteButton = fixture.nativeElement.querySelector('.bulk-btn') as HTMLButtonElement;
promoteButton.click();
fixture.detectChanges();
expect(component.bulkPromoteGateState().tier).toBe('tier3');
expect(emitSpy).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('.vex-evidence-sheet')).toBeTruthy();
});
it('emits ids for bulk promote when gate tier is allow/review', () => {
component.findingsInput = [
finding('f-1', ['no_fix_available']),
finding('f-2', ['disputed']),
];
const emitSpy = spyOn(component.promoteRequested, 'emit');
fixture.detectChanges();
const promoteButton = fixture.nativeElement.querySelector('.bulk-btn') as HTMLButtonElement;
promoteButton.click();
expect(component.bulkPromoteGateState().tier).toBe('tier1');
expect(emitSpy).toHaveBeenCalledWith(['f-1', 'f-2']);
});
});

View File

@@ -0,0 +1,88 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VexEvidenceSheetComponent } from '../../app/features/vex_gate/vex-evidence-sheet.component';
import { VexEvidenceLine, VexEvidenceTier, VexGateVerdict } from '../../app/features/vex_gate/models/vex-gate.models';
@Component({
standalone: true,
imports: [VexEvidenceSheetComponent],
template: `
<app-vex-evidence-sheet
[open]="open"
[title]="title"
[tier]="tier"
[verdict]="verdict"
[reason]="reason"
[evidence]="evidence"
(closed)="onClosed()"
/>
`,
})
class TestHostComponent {
open = true;
title = 'Promote Action Gate';
tier: VexEvidenceTier = 'tier2';
verdict: VexGateVerdict = 'review';
reason = 'Runtime proof is missing; operator review required.';
evidence: VexEvidenceLine[] = [
{ label: 'DSSE envelope', value: 'sha256:abc123', source: 'attestor', dsseVerified: true },
{ label: 'Runtime witness', value: 'missing', source: 'reachability', dsseVerified: false },
];
closedCount = 0;
onClosed(): void {
this.closedCount++;
}
}
describe('VexEvidenceSheetComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('renders evidence content when open is true', () => {
const title = fixture.nativeElement.querySelector('.sheet-title') as HTMLElement;
const reason = fixture.nativeElement.querySelector('.sheet-reason') as HTMLElement;
const items = fixture.nativeElement.querySelectorAll('.evidence-item') as NodeListOf<HTMLElement>;
expect(title.textContent).toContain('Promote Action Gate');
expect(reason.textContent).toContain('Runtime proof is missing');
expect(items.length).toBe(2);
});
it('hides content when open is false', () => {
host.open = false;
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.vex-evidence-sheet')).toBeNull();
});
it('emits closed when close button is clicked', () => {
const closeButton = fixture.nativeElement.querySelector('.close-btn') as HTMLButtonElement;
closeButton.click();
expect(host.closedCount).toBe(1);
});
it('uses tier classes deterministically', () => {
host.tier = 'tier3';
host.verdict = 'block';
fixture.detectChanges();
const sheet = fixture.nativeElement.querySelector('.vex-evidence-sheet') as HTMLElement;
expect(sheet.classList.contains('vex-evidence-sheet--tier3')).toBeTrue();
expect(sheet.classList.contains('vex-evidence-sheet--tier1')).toBeFalse();
});
});

View File

@@ -0,0 +1,101 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VexGateButtonDirective } from '../../app/features/vex_gate/vex-gate-button.directive';
import { VexGateButtonState } from '../../app/features/vex_gate/models/vex-gate.models';
@Component({
standalone: true,
imports: [VexGateButtonDirective],
template: `
<button
type="button"
[appVexGateButton]="state"
(gateBlocked)="onGateBlocked($event)"
>
Promote
</button>
`,
})
class TestHostComponent {
state: VexGateButtonState = {
tier: 'tier1',
verdict: 'allow',
reason: 'Full DSSE + runtime + policy evidence available.',
actionLabel: 'Promote',
};
blocked: VexGateButtonState | null = null;
onGateBlocked(state: VexGateButtonState): void {
this.blocked = state;
}
}
describe('VexGateButtonDirective', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
let button: HTMLButtonElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
});
it('applies green class for tier1 state', () => {
expect(button.classList.contains('vex-gate-btn')).toBeTrue();
expect(button.classList.contains('vex-gate-btn--green')).toBeTrue();
expect(button.getAttribute('data-vex-tier')).toBe('tier1');
});
it('applies amber class for tier2 state', () => {
host.state = {
tier: 'tier2',
verdict: 'review',
reason: 'Partial evidence; static proof available but runtime missing.',
actionLabel: 'Promote',
};
fixture.detectChanges();
expect(button.classList.contains('vex-gate-btn--amber')).toBeTrue();
expect(button.getAttribute('data-vex-tier')).toBe('tier2');
});
it('applies red class and blocks click for tier3 state', () => {
host.state = {
tier: 'tier3',
verdict: 'block',
reason: 'No evidence chain is present for this action.',
actionLabel: 'Promote',
};
fixture.detectChanges();
button.click();
expect(button.classList.contains('vex-gate-btn--red')).toBeTrue();
expect(button.getAttribute('aria-disabled')).toBe('true');
expect(host.blocked).not.toBeNull();
expect(host.blocked?.tier).toBe('tier3');
});
it('sets deterministic aria-label with tier and reason', () => {
host.state = {
tier: 'tier2',
verdict: 'review',
reason: 'Evidence is partial and requires operator review.',
actionLabel: 'Release',
};
fixture.detectChanges();
const ariaLabel = button.getAttribute('aria-label');
expect(ariaLabel).toContain('Release gated as TIER2');
expect(ariaLabel).toContain('Evidence is partial and requires operator review.');
});
});