feat: Add PathViewer and RiskDriftCard components with templates and styles

- Implemented PathViewerComponent for visualizing reachability call paths.
- Added RiskDriftCardComponent to display reachability drift results.
- Created corresponding HTML templates and SCSS styles for both components.
- Introduced test fixtures for reachability analysis in JSON format.
- Enhanced user interaction with collapsible and expandable features in PathViewer.
- Included risk trend visualization and summary metrics in RiskDriftCard.
This commit is contained in:
master
2025-12-18 18:35:30 +02:00
parent 811f35cba7
commit 0dc71e760a
70 changed files with 8904 additions and 163 deletions

View File

@@ -10,14 +10,30 @@ import {
EvidencePanelMetricsService,
EvidencePanelAction,
} from './evidence-panel-metrics.service';
import { APP_CONFIG } from '../config/app.config';
import { APP_CONFIG, AppConfig } from '../config/app-config.model';
describe('EvidencePanelMetricsService', () => {
let service: EvidencePanelMetricsService;
let httpMock: HttpTestingController;
const mockConfig = {
apiBaseUrl: 'http://localhost:5000/api',
const mockConfig: AppConfig = {
authority: {
issuer: 'https://auth.stellaops.test/',
clientId: 'ui-client',
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
redirectUri: 'https://ui.stellaops.test/auth/callback',
scope: 'openid profile email ui.read',
audience: 'https://scanner.stellaops.test',
},
apiBaseUrls: {
gateway: 'http://localhost:5000/api',
authority: 'https://auth.stellaops.test',
scanner: 'https://scanner.stellaops.test',
policy: 'https://policy.stellaops.test',
concelier: 'https://concelier.stellaops.test',
attestor: 'https://attestor.stellaops.test',
},
};
beforeEach(() => {
@@ -197,7 +213,7 @@ describe('EvidencePanelMetricsService', () => {
}
// Expect POST to metrics endpoint
const req = httpMock.expectOne(`${mockConfig.apiBaseUrl}/metrics/evidence-panel`);
const req = httpMock.expectOne(`${mockConfig.apiBaseUrls.gateway}/metrics/evidence-panel`);
expect(req.request.method).toBe('POST');
expect(req.request.body.sessions.length).toBe(10);
@@ -213,7 +229,7 @@ describe('EvidencePanelMetricsService', () => {
service.endSession();
}
const req = httpMock.expectOne(`${mockConfig.apiBaseUrl}/metrics/evidence-panel`);
const req = httpMock.expectOne(`${mockConfig.apiBaseUrls.gateway}/metrics/evidence-panel`);
const sessions = req.request.body.sessions;
expect(sessions[0]).toEqual(jasmine.objectContaining({

View File

@@ -11,7 +11,7 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { APP_CONFIG, AppConfig } from '../config/app.config';
import { APP_CONFIG, AppConfig } from '../config/app-config.model';
/**
* Types of actions tracked in the Evidence Panel
@@ -243,7 +243,7 @@ export class EvidencePanelMetricsService {
// Fire-and-forget POST to metrics endpoint
this.http.post(
`${this.config.apiBaseUrl}/metrics/evidence-panel`,
`${this.resolveMetricsBaseUrl()}/metrics/evidence-panel`,
{
sessions: sessions.map(s => ({
sessionId: s.sessionId,
@@ -264,6 +264,10 @@ export class EvidencePanelMetricsService {
});
}
private resolveMetricsBaseUrl(): string {
return this.config.apiBaseUrls.gateway ?? this.config.apiBaseUrls.scanner;
}
/**
* Get current metrics summary for debugging/display
*/

View File

@@ -194,11 +194,11 @@ export class TriageEvidenceHttpClient implements TriageEvidenceApi {
}
}
private buildParams(options?: Record<string, unknown>): HttpParams {
private buildParams(options?: object): HttpParams {
let params = new HttpParams();
if (options) {
for (const [key, value] of Object.entries(options)) {
for (const [key, value] of Object.entries(options as Record<string, unknown>)) {
if (value !== undefined && value !== null && key !== 'tenantId' && key !== 'traceId') {
params = params.set(key, String(value));
}

View File

@@ -83,13 +83,15 @@ export class TelemetrySamplerService {
}
private createSessionId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
const cryptoApi = this.getCryptoApi();
if (cryptoApi?.randomUUID) {
return cryptoApi.randomUUID();
}
if (typeof crypto !== 'undefined' && 'getRandomValues' in crypto) {
if (cryptoApi?.getRandomValues) {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
cryptoApi.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
@@ -97,13 +99,21 @@ export class TelemetrySamplerService {
}
private createSampleValue(): number {
if (typeof crypto !== 'undefined' && 'getRandomValues' in crypto) {
const cryptoApi = this.getCryptoApi();
if (cryptoApi?.getRandomValues) {
const bytes = new Uint32Array(1);
crypto.getRandomValues(bytes);
cryptoApi.getRandomValues(bytes);
return bytes[0] / 0x1_0000_0000;
}
return Math.random();
}
}
private getCryptoApi(): Crypto | null {
if (typeof globalThis === 'undefined') return null;
const value = (globalThis as unknown as { crypto?: Crypto }).crypto;
return value ?? null;
}
}

View File

@@ -1,8 +1,10 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import type { EvidenceApi } from '../../core/api/evidence.client';
import { EVIDENCE_API } from '../../core/api/evidence.client';
import type { EvidenceData, VexDecision, VexStatus } from '../../core/api/evidence.models';
import { APP_CONFIG, type AppConfig } from '../../core/config/app-config.model';
import { EvidencePanelComponent } from './evidence-panel.component';
function createVexDecision(status: VexStatus, id: string): VexDecision {
@@ -32,8 +34,31 @@ describe('EvidencePanelComponent', () => {
]);
await TestBed.configureTestingModule({
imports: [EvidencePanelComponent],
providers: [{ provide: EVIDENCE_API, useValue: api }],
imports: [HttpClientTestingModule, EvidencePanelComponent],
providers: [
{ provide: EVIDENCE_API, useValue: api },
{
provide: APP_CONFIG,
useValue: {
authority: {
issuer: 'https://auth.stellaops.test/',
clientId: 'ui-client',
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
redirectUri: 'https://ui.stellaops.test/auth/callback',
scope: 'openid profile email ui.read',
audience: 'https://scanner.stellaops.test',
},
apiBaseUrls: {
authority: 'https://auth.stellaops.test',
scanner: 'https://scanner.stellaops.test',
policy: 'https://policy.stellaops.test',
concelier: 'https://concelier.stellaops.test',
attestor: 'https://attestor.stellaops.test',
},
} satisfies AppConfig,
},
],
}).compileComponents();
fixture = TestBed.createComponent(EvidencePanelComponent);

View File

@@ -0,0 +1,4 @@
/**
* PathViewerComponent barrel export
*/
export * from './path-viewer.component';

View File

@@ -0,0 +1,110 @@
<!--
PathViewerComponent Template
Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
Task: UI-003
-->
<div class="path-viewer" [class.path-viewer--collapsed]="collapsed()">
<!-- Header -->
<div class="path-viewer__header">
<span class="path-viewer__title">{{ title() }}</span>
<div class="path-viewer__actions">
@if (hiddenNodeCount() > 0) {
<button
type="button"
class="path-viewer__btn path-viewer__btn--expand"
(click)="toggleExpand()"
[attr.aria-expanded]="isExpanded()">
@if (isExpanded()) {
Collapse ({{ hiddenNodeCount() }} hidden)
} @else {
Expand (+{{ hiddenNodeCount() }} nodes)
}
</button>
}
@if (collapsible()) {
<button
type="button"
class="path-viewer__btn path-viewer__btn--collapse"
(click)="toggleCollapse()"
[attr.aria-expanded]="!collapsed()">
{{ collapsed() ? 'Show' : 'Hide' }}
</button>
}
</div>
</div>
<!-- Content -->
@if (!collapsed()) {
<div class="path-viewer__content">
<!-- Path visualization -->
<ol class="path-viewer__nodes" role="list">
@for (node of displayNodes(); track node.nodeId; let i = $index; let last = $last) {
<!-- Node -->
<li
[class]="getNodeClass(node)"
(click)="onNodeClick(node)"
(keydown.enter)="onNodeClick(node)"
tabindex="0"
role="listitem"
[attr.aria-label]="node.symbol">
<span class="path-node__icon" [attr.aria-hidden]="true">
{{ getNodeIcon(node) }}
</span>
<div class="path-node__details">
<span class="path-node__symbol">{{ node.symbol }}</span>
@if (node.file) {
<span class="path-node__location">
{{ node.file }}@if (node.line) {:{{ node.line }}}
</span>
}
@if (node.package) {
<span class="path-node__package">{{ node.package }}</span>
}
@if (showConfidence() && node.confidence !== undefined) {
<span class="path-node__confidence">
{{ formatConfidence(node.confidence) }}
</span>
}
@if (highlightChanges() && node.isChanged && node.changeKind) {
<span class="path-node__change-badge" [class]="'path-node__change-badge--' + node.changeKind">
{{ formatChangeKind(node.changeKind) }}
</span>
}
@if (node.nodeType === 'entrypoint') {
<span class="path-node__type-badge path-node__type-badge--entrypoint">
ENTRYPOINT
</span>
}
@if (node.nodeType === 'sink') {
<span class="path-node__type-badge path-node__type-badge--sink">
SINK
</span>
}
@if (node.nodeType === 'gate') {
<span class="path-node__type-badge path-node__type-badge--gate">
GATE
</span>
}
</div>
</li>
<!-- Connector -->
@if (!last) {
<li class="path-viewer__connector" role="presentation" aria-hidden="true">
<span class="path-viewer__connector-line"></span>
</li>
}
}
</ol>
<!-- Hidden nodes indicator -->
@if (hiddenNodeCount() > 0 && !isExpanded()) {
<div class="path-viewer__hidden-indicator">
<span class="path-viewer__hidden-text">
… {{ hiddenNodeCount() }} intermediate node(s) hidden …
</span>
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,296 @@
/**
* PathViewerComponent Styles
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
* Task: UI-004
*/
// Variables
$color-entrypoint: #10b981; // Green
$color-sink: #ef4444; // Red
$color-gate: #f59e0b; // Amber
$color-changed: #8b5cf6; // Purple
$color-added: #22c55e;
$color-removed: #ef4444;
$color-modified: #f59e0b;
$color-border: #e5e7eb;
$color-bg: #ffffff;
$color-bg-hover: #f9fafb;
$color-text: #111827;
$color-text-muted: #6b7280;
.path-viewer {
font-family: var(--font-family-sans, system-ui, sans-serif);
background: $color-bg;
border: 1px solid $color-border;
border-radius: 8px;
overflow: hidden;
&--collapsed {
.path-viewer__content {
display: none;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid $color-border;
background: #f9fafb;
}
&__title {
font-weight: 600;
font-size: 14px;
color: $color-text;
}
&__actions {
display: flex;
gap: 8px;
}
&__btn {
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid $color-border;
border-radius: 4px;
background: $color-bg;
color: $color-text-muted;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: $color-bg-hover;
color: $color-text;
}
&:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
&--expand {
color: #3b82f6;
border-color: #3b82f6;
&:hover {
background: #eff6ff;
}
}
}
&__content {
padding: 16px;
}
&__nodes {
list-style: none;
margin: 0;
padding: 0;
}
&__connector {
display: flex;
justify-content: center;
padding: 4px 0;
&-line {
width: 2px;
height: 16px;
background: $color-border;
}
}
&__hidden-indicator {
display: flex;
justify-content: center;
padding: 8px 0;
}
&__hidden-text {
font-size: 12px;
font-style: italic;
color: $color-text-muted;
}
}
.path-node {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid $color-border;
border-radius: 6px;
background: $color-bg;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: $color-bg-hover;
border-color: #d1d5db;
}
&:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
&__icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
border-radius: 50%;
background: #f3f4f6;
color: $color-text-muted;
}
&__details {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
&__symbol {
font-weight: 500;
font-size: 14px;
font-family: var(--font-family-mono, 'SF Mono', Consolas, monospace);
color: $color-text;
word-break: break-word;
}
&__location {
font-size: 12px;
color: $color-text-muted;
font-family: var(--font-family-mono, 'SF Mono', Consolas, monospace);
}
&__package {
font-size: 11px;
color: $color-text-muted;
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
width: fit-content;
}
&__confidence {
font-size: 11px;
color: $color-text-muted;
background: #e0e7ff;
padding: 2px 6px;
border-radius: 4px;
width: fit-content;
}
&__change-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 4px;
width: fit-content;
&--added {
background: #dcfce7;
color: #166534;
}
&--removed {
background: #fee2e2;
color: #991b1b;
}
&--modified {
background: #fef3c7;
color: #92400e;
}
}
&__type-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 4px;
width: fit-content;
&--entrypoint {
background: #d1fae5;
color: #065f46;
}
&--sink {
background: #fee2e2;
color: #991b1b;
}
&--gate {
background: #fef3c7;
color: #92400e;
}
}
// Node type variants
&--entrypoint {
border-color: $color-entrypoint;
.path-node__icon {
background: #d1fae5;
color: $color-entrypoint;
}
}
&--sink {
border-color: $color-sink;
.path-node__icon {
background: #fee2e2;
color: $color-sink;
}
}
&--gate {
border-color: $color-gate;
.path-node__icon {
background: #fef3c7;
color: $color-gate;
}
}
// Changed state
&--changed {
border-color: $color-changed;
background: #faf5ff;
.path-node__icon {
background: #ede9fe;
color: $color-changed;
}
}
&--added {
border-color: $color-added;
background: #f0fdf4;
}
&--removed {
border-color: $color-removed;
background: #fef2f2;
}
&--modified {
border-color: $color-modified;
background: #fffbeb;
}
}

View File

@@ -0,0 +1,155 @@
/**
* PathViewerComponent - Call Path Visualization
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
* Task: UI-003
*/
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CompressedPath, PathNode, ExpandedPath, PathEdge } from '../../models/path-viewer.models';
/**
* Visualizes reachability call paths from entrypoint to sink.
* Supports both compressed and expanded views.
*
* @example
* ```html
* <app-path-viewer
* [path]="compressedPath"
* [collapsible]="true"
* [showConfidence]="true"
* (nodeClick)="onNodeClick($event)"
* (expandRequest)="onExpandPath($event)">
* </app-path-viewer>
* ```
*/
@Component({
selector: 'app-path-viewer',
standalone: true,
imports: [CommonModule],
templateUrl: './path-viewer.component.html',
styleUrl: './path-viewer.component.scss'
})
export class PathViewerComponent {
/** The compressed path to display */
path = input.required<CompressedPath>();
/** Optional title for the path viewer */
title = input<string>('Reachability Path');
/** Whether the viewer can be collapsed */
collapsible = input<boolean>(true);
/** Whether to show confidence scores */
showConfidence = input<boolean>(false);
/** Whether to highlight changed nodes */
highlightChanges = input<boolean>(true);
/** Maximum depth to show before collapsing */
maxVisibleDepth = input<number>(5);
/** Emits when a node is clicked */
nodeClick = output<PathNode>();
/** Emits when path expansion is requested */
expandRequest = output<string>();
/** Internal collapsed state */
collapsed = signal<boolean>(false);
/** Whether the full path is expanded */
isExpanded = signal<boolean>(false);
/** Computed: effective nodes to display */
displayNodes = computed(() => {
const p = this.path();
if (this.isExpanded()) {
return this.buildFullNodeList(p);
}
return [p.entrypoint, ...p.keyNodes, p.sink];
});
/** Computed: count of hidden nodes */
hiddenNodeCount = computed(() => {
const p = this.path();
if (this.isExpanded()) {
return 0;
}
return Math.max(0, p.intermediateCount - p.keyNodes.length);
});
/** Toggle collapsed state */
toggleCollapse(): void {
this.collapsed.update(v => !v);
}
/** Toggle expanded state */
toggleExpand(): void {
const p = this.path();
if (!this.isExpanded() && p.fullPath && p.fullPath.length > 0) {
this.expandRequest.emit(p.fullPath[0]);
}
this.isExpanded.update(v => !v);
}
/** Handle node click */
onNodeClick(node: PathNode): void {
this.nodeClick.emit(node);
}
/** Get CSS class for node type */
getNodeClass(node: PathNode): string {
const classes: string[] = ['path-node'];
if (node.nodeType) {
classes.push(`path-node--${node.nodeType}`);
}
if (this.highlightChanges() && node.isChanged) {
classes.push('path-node--changed');
if (node.changeKind) {
classes.push(`path-node--${node.changeKind}`);
}
}
return classes.join(' ');
}
/** Get icon for node type */
getNodeIcon(node: PathNode): string {
if (node.isChanged) {
return '●';
}
switch (node.nodeType) {
case 'entrypoint':
return '▶';
case 'sink':
return '⚠';
case 'gate':
return '◆';
default:
return '○';
}
}
/** Format change kind for display */
formatChangeKind(kind?: string): string {
if (!kind) return '';
return kind.charAt(0).toUpperCase() + kind.slice(1);
}
/** Format confidence as percentage */
formatConfidence(confidence?: number): string {
if (confidence === undefined) return '';
return `${Math.round(confidence * 100)}%`;
}
/** Build full node list from path */
private buildFullNodeList(path: CompressedPath): PathNode[] {
// For now, return compressed representation
// Full expansion requires additional data
return [path.entrypoint, ...path.keyNodes, path.sink];
}
}

View File

@@ -0,0 +1,4 @@
/**
* RiskDriftCardComponent barrel export
*/
export * from './risk-drift-card.component';

View File

@@ -0,0 +1,136 @@
<!--
RiskDriftCardComponent Template
Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
Task: UI-007
-->
<article class="risk-drift-card" [class.risk-drift-card--compact]="compact()">
<!-- Header -->
<header class="risk-drift-card__header">
<div class="risk-drift-card__title">
<h3 class="risk-drift-card__heading">Reachability Drift</h3>
@if (showAttestation() && isSigned()) {
<span class="risk-drift-card__attestation-badge" title="Signed with DSSE">
✓ Attested
</span>
}
</div>
<time class="risk-drift-card__time" [attr.datetime]="drift().comparedAt">
{{ formatTime(drift().comparedAt) }}
</time>
</header>
<!-- Summary metrics -->
<div class="risk-drift-card__summary">
<!-- Risk trend -->
<div class="risk-drift-card__metric risk-drift-card__metric--trend">
<span class="risk-drift-card__trend" [class]="trendClass()">
<span class="risk-drift-card__trend-icon">{{ trendIcon() }}</span>
<span class="risk-drift-card__trend-label">
{{ summary().riskTrend | titlecase }}
</span>
</span>
<span class="risk-drift-card__delta" [class.positive]="summary().netRiskDelta > 0" [class.negative]="summary().netRiskDelta < 0">
{{ formatRiskDelta(summary().netRiskDelta) }}
</span>
</div>
<!-- Key stats -->
@if (!compact()) {
<div class="risk-drift-card__stats">
<div class="risk-drift-card__stat">
<span class="risk-drift-card__stat-value">{{ summary().increasedReachability }}</span>
<span class="risk-drift-card__stat-label">Increased</span>
</div>
<div class="risk-drift-card__stat">
<span class="risk-drift-card__stat-value">{{ summary().decreasedReachability }}</span>
<span class="risk-drift-card__stat-label">Decreased</span>
</div>
<div class="risk-drift-card__stat">
<span class="risk-drift-card__stat-value">{{ summary().newSinks }}</span>
<span class="risk-drift-card__stat-label">New</span>
</div>
<div class="risk-drift-card__stat">
<span class="risk-drift-card__stat-value">{{ summary().removedSinks }}</span>
<span class="risk-drift-card__stat-label">Removed</span>
</div>
</div>
}
<!-- Severity breakdown -->
<div class="risk-drift-card__severity-bar">
@if (summary().bySeverity.critical > 0) {
<span class="risk-drift-card__severity risk-drift-card__severity--critical" [title]="'Critical: ' + summary().bySeverity.critical">
{{ summary().bySeverity.critical }}
</span>
}
@if (summary().bySeverity.high > 0) {
<span class="risk-drift-card__severity risk-drift-card__severity--high" [title]="'High: ' + summary().bySeverity.high">
{{ summary().bySeverity.high }}
</span>
}
@if (summary().bySeverity.medium > 0) {
<span class="risk-drift-card__severity risk-drift-card__severity--medium" [title]="'Medium: ' + summary().bySeverity.medium">
{{ summary().bySeverity.medium }}
</span>
}
@if (summary().bySeverity.low > 0) {
<span class="risk-drift-card__severity risk-drift-card__severity--low" [title]="'Low: ' + summary().bySeverity.low">
{{ summary().bySeverity.low }}
</span>
}
</div>
</div>
<!-- Preview sinks -->
@if (!compact() && previewSinks().length > 0) {
<div class="risk-drift-card__preview">
<h4 class="risk-drift-card__preview-title">Top Drifted Sinks</h4>
<ul class="risk-drift-card__sink-list">
@for (sink of previewSinks(); track sink.sink.nodeId) {
<li
class="risk-drift-card__sink-item"
(click)="onSinkClick(sink)"
(keydown.enter)="onSinkClick(sink)"
tabindex="0"
role="button">
<span class="risk-drift-card__sink-icon" [class]="getSeverityClass(sink.severity)">
@if (sink.isRiskIncrease) { ↑ } @else { ↓ }
</span>
<div class="risk-drift-card__sink-details">
<span class="risk-drift-card__sink-name">{{ sink.sink.symbol }}</span>
@if (sink.cveId) {
<span class="risk-drift-card__sink-cve">{{ sink.cveId }}</span>
}
<span class="risk-drift-card__sink-bucket">
{{ getBucketLabel(sink.previousBucket) }} → {{ getBucketLabel(sink.currentBucket) }}
</span>
</div>
<span class="risk-drift-card__sink-delta" [class.positive]="sink.riskDelta > 0" [class.negative]="sink.riskDelta < 0">
{{ formatRiskDelta(sink.riskDelta) }}
</span>
</li>
}
</ul>
@if (additionalSinksCount() > 0) {
<p class="risk-drift-card__more">
+{{ additionalSinksCount() }} more sinks
</p>
}
</div>
}
<!-- Actions -->
<footer class="risk-drift-card__footer">
@if (drift().pullRequestNumber) {
<span class="risk-drift-card__pr">
PR #{{ drift().pullRequestNumber }}
</span>
}
<button
type="button"
class="risk-drift-card__btn"
(click)="onViewDetails()">
View Details
</button>
</footer>
</article>

View File

@@ -0,0 +1,348 @@
/**
* RiskDriftCardComponent Styles
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
* Task: UI-008
*/
// Variables
$color-critical: #dc2626;
$color-high: #ea580c;
$color-medium: #d97706;
$color-low: #ca8a04;
$color-info: #6b7280;
$color-positive: #dc2626; // risk increase is bad
$color-negative: #16a34a; // risk decrease is good
$color-border: #e5e7eb;
$color-bg: #ffffff;
$color-bg-hover: #f9fafb;
$color-text: #111827;
$color-text-muted: #6b7280;
.risk-drift-card {
font-family: var(--font-family-sans, system-ui, sans-serif);
background: $color-bg;
border: 1px solid $color-border;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&--compact {
.risk-drift-card__preview,
.risk-drift-card__stats {
display: none;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid $color-border;
background: #fafafa;
}
&__title {
display: flex;
align-items: center;
gap: 12px;
}
&__heading {
margin: 0;
font-size: 16px;
font-weight: 600;
color: $color-text;
}
&__attestation-badge {
font-size: 11px;
font-weight: 500;
color: #059669;
background: #d1fae5;
padding: 2px 8px;
border-radius: 9999px;
}
&__time {
font-size: 12px;
color: $color-text-muted;
}
&__summary {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
&__metric--trend {
display: flex;
justify-content: space-between;
align-items: center;
}
&__trend {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 18px;
&--increasing {
color: $color-positive;
}
&--decreasing {
color: $color-negative;
}
&--stable {
color: $color-text-muted;
}
}
&__trend-icon {
font-size: 24px;
}
&__delta {
font-size: 24px;
font-weight: 700;
font-family: var(--font-family-mono, 'SF Mono', Consolas, monospace);
&.positive {
color: $color-positive;
}
&.negative {
color: $color-negative;
}
}
&__stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
padding-top: 16px;
border-top: 1px solid $color-border;
}
&__stat {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
&__stat-value {
font-size: 20px;
font-weight: 600;
color: $color-text;
}
&__stat-label {
font-size: 11px;
color: $color-text-muted;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__severity-bar {
display: flex;
gap: 8px;
}
&__severity {
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 9999px;
color: white;
&--critical {
background: $color-critical;
}
&--high {
background: $color-high;
}
&--medium {
background: $color-medium;
}
&--low {
background: $color-low;
}
&--info {
background: $color-info;
}
}
&__preview {
padding: 0 20px 20px;
}
&__preview-title {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $color-text-muted;
}
&__sink-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
&__sink-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid $color-border;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: $color-bg-hover;
border-color: #d1d5db;
}
&:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
&__sink-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
border-radius: 50%;
background: #f3f4f6;
color: $color-text-muted;
&.risk-drift-card__severity--critical {
background: #fee2e2;
color: $color-critical;
}
&.risk-drift-card__severity--high {
background: #ffedd5;
color: $color-high;
}
&.risk-drift-card__severity--medium {
background: #fef3c7;
color: $color-medium;
}
&.risk-drift-card__severity--low {
background: #fef9c3;
color: $color-low;
}
}
&__sink-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
&__sink-name {
font-weight: 500;
font-size: 14px;
font-family: var(--font-family-mono, 'SF Mono', Consolas, monospace);
color: $color-text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__sink-cve {
font-size: 12px;
color: $color-critical;
font-weight: 500;
}
&__sink-bucket {
font-size: 11px;
color: $color-text-muted;
}
&__sink-delta {
font-size: 14px;
font-weight: 600;
font-family: var(--font-family-mono, 'SF Mono', Consolas, monospace);
&.positive {
color: $color-positive;
}
&.negative {
color: $color-negative;
}
}
&__more {
margin: 8px 0 0;
font-size: 12px;
color: $color-text-muted;
text-align: center;
}
&__footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-top: 1px solid $color-border;
background: #fafafa;
}
&__pr {
font-size: 12px;
color: $color-text-muted;
background: #f3f4f6;
padding: 4px 10px;
border-radius: 4px;
}
&__btn {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #3b82f6;
background: transparent;
border: 1px solid #3b82f6;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: #eff6ff;
}
&:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
}

View File

@@ -0,0 +1,137 @@
/**
* RiskDriftCardComponent - Drift Summary Card
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
* Task: UI-007
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DriftResult, DriftSummary, DriftedSink } from '../../models/drift.models';
/**
* Summary card showing reachability drift results.
* Displays risk trend, key metrics, and links to details.
*
* @example
* ```html
* <app-risk-drift-card
* [drift]="driftResult"
* [compact]="true"
* (viewDetails)="onViewDetails()"
* (sinkClick)="onSinkClick($event)">
* </app-risk-drift-card>
* ```
*/
@Component({
selector: 'app-risk-drift-card',
standalone: true,
imports: [CommonModule],
templateUrl: './risk-drift-card.component.html',
styleUrl: './risk-drift-card.component.scss'
})
export class RiskDriftCardComponent {
/** The drift result to display */
drift = input.required<DriftResult>();
/** Compact mode (less detail) */
compact = input<boolean>(false);
/** Whether to show attestation badge */
showAttestation = input<boolean>(true);
/** Maximum sinks to show in preview */
maxPreviewSinks = input<number>(3);
/** Emits when "View Details" is clicked */
viewDetails = output<void>();
/** Emits when a specific sink is clicked */
sinkClick = output<DriftedSink>();
/** Computed: summary from drift */
summary = computed<DriftSummary>(() => this.drift().summary);
/** Computed: is signed with DSSE */
isSigned = computed(() => !!this.drift().attestationDigest);
/** Computed: risk trend icon */
trendIcon = computed(() => {
const trend = this.summary().riskTrend;
switch (trend) {
case 'increasing':
return '↑';
case 'decreasing':
return '↓';
default:
return '→';
}
});
/** Computed: risk trend CSS class */
trendClass = computed(() => {
const trend = this.summary().riskTrend;
return `risk-drift-card__trend--${trend}`;
});
/** Computed: top drifted sinks to preview */
previewSinks = computed(() => {
const sinks = this.drift().driftedSinks;
const max = this.maxPreviewSinks();
// Sort by risk delta (highest first), then severity
return sinks
.slice()
.sort((a, b) => {
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
const aSev = severityOrder[a.severity ?? 'info'];
const bSev = severityOrder[b.severity ?? 'info'];
if (aSev !== bSev) return aSev - bSev;
return b.riskDelta - a.riskDelta;
})
.slice(0, max);
});
/** Computed: additional sinks count */
additionalSinksCount = computed(() => {
return Math.max(0, this.drift().driftedSinks.length - this.maxPreviewSinks());
});
/** Handle view details click */
onViewDetails(): void {
this.viewDetails.emit();
}
/** Handle sink click */
onSinkClick(sink: DriftedSink): void {
this.sinkClick.emit(sink);
}
/** Format risk delta */
formatRiskDelta(delta: number): string {
if (delta > 0) return `+${delta}`;
return delta.toString();
}
/** Get severity badge class */
getSeverityClass(severity?: string): string {
return severity ? `risk-drift-card__severity--${severity}` : '';
}
/** Format timestamp */
formatTime(iso: string): string {
const date = new Date(iso);
return date.toLocaleString();
}
/** Get bucket label */
getBucketLabel(bucket: string | null): string {
if (!bucket) return 'N/A';
const labels: Record<string, string> = {
entrypoint: 'Entry Point',
direct: 'Direct',
runtime: 'Runtime',
unknown: 'Unknown',
unreachable: 'Unreachable'
};
return labels[bucket] ?? bucket;
}
}