partly or unimplemented features - now implemented
This commit is contained in:
@@ -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)],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
src/Web/StellaOps.Web/src/app/features/vex_gate/index.ts
Normal file
4
src/Web/StellaOps.Web/src/app/features/vex_gate/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './models/vex-gate.models';
|
||||
export * from './vex-gate-button.directive';
|
||||
export * from './vex-evidence-sheet.component';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '?';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user