Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit c8f3120174
349 changed files with 78558 additions and 1342 deletions

View File

@@ -0,0 +1,168 @@
/**
* Delta Verdict Models
*
* Models for policy verdict display, delta comparison,
* and verdict explanation.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-02
*/
/**
* Verdict level.
*/
export type VerdictLevel = 'routine' | 'review' | 'block';
/**
* Verdict driver category.
*/
export type VerdictDriverCategory =
| 'critical_vuln'
| 'high_vuln'
| 'budget_exceeded'
| 'unknown_risk'
| 'exception_expired'
| 'reachability'
| 'vex_source'
| 'sbom_drift'
| 'policy_rule';
/**
* Verdict driver (reason for verdict).
*/
export interface VerdictDriver {
/** Driver category. */
category: VerdictDriverCategory;
/** Human-readable summary. */
summary: string;
/** Detailed description. */
description: string;
/** Impact on verdict (points or boolean). */
impact: number | boolean;
/** Related entity IDs. */
relatedIds?: string[];
/** Evidence type for drill-down. */
evidenceType?: 'reachability' | 'vex' | 'sbom_diff' | 'exception';
}
/**
* Delta verdict for an artifact.
*/
export interface DeltaVerdict {
/** Verdict ID. */
id: string;
/** Artifact digest. */
artifactDigest: string;
/** Artifact name/tag. */
artifactName?: string;
/** Verdict level. */
level: VerdictLevel;
/** Verdict timestamp. */
timestamp: string;
/** Policy pack ID. */
policyPackId: string;
/** Policy version. */
policyVersion: string;
/** Drivers (reasons for verdict). */
drivers: VerdictDriver[];
/** Previous verdict for comparison (if available). */
previousVerdict?: {
level: VerdictLevel;
timestamp: string;
};
/** Risk delta from previous. */
riskDelta?: {
added: number;
removed: number;
net: number;
};
/** Trace ID. */
traceId: string;
}
/**
* Verdict comparison (before/after).
*/
export interface VerdictComparison {
/** Before state. */
before: DeltaVerdict;
/** After state. */
after: DeltaVerdict;
/** Changes between states. */
changes: VerdictChange[];
/** Overall risk delta. */
riskDelta: number;
/** Timestamp of comparison. */
comparedAt: string;
}
/**
* Individual change between verdicts.
*/
export interface VerdictChange {
/** Change type. */
type: 'added' | 'removed' | 'modified';
/** Category of change. */
category: VerdictDriverCategory;
/** Description of change. */
description: string;
/** Impact on risk score. */
riskImpact: number;
/** Related entity. */
entityId?: string;
/** Entity type. */
entityType?: 'vulnerability' | 'package' | 'exception' | 'policy_rule';
}
/**
* Verdict query options.
*/
export interface VerdictQueryOptions {
/** Tenant ID. */
tenantId: string;
/** Project ID (optional). */
projectId?: string;
/** Artifact digest (optional). */
artifactDigest?: string;
/** Include previous verdict for delta. */
includePrevious?: boolean;
/** Trace ID. */
traceId?: string;
}
/**
* Verdict history entry.
*/
export interface VerdictHistoryEntry {
/** Verdict ID. */
id: string;
/** Artifact digest. */
artifactDigest: string;
/** Verdict level. */
level: VerdictLevel;
/** Timestamp. */
timestamp: string;
/** Risk score at time. */
riskScore: number;
/** Key drivers (summary). */
keyDrivers: string[];
}
/**
* Verdict statistics.
*/
export interface VerdictStats {
/** Total verdicts in period. */
total: number;
/** Counts by level. */
byLevel: Record<VerdictLevel, number>;
/** Trend (compared to previous period). */
trend: {
direction: 'improving' | 'worsening' | 'stable';
changePercent: number;
};
/** Average risk score. */
averageRiskScore: number;
/** Trace ID. */
traceId: string;
}

View File

@@ -2,13 +2,13 @@
* Exception management models for the Exception Center.
*/
export type ExceptionStatus =
| 'draft'
| 'pending_review'
| 'approved'
| 'rejected'
| 'expired'
| 'revoked';
export type ExceptionStatus =
| 'draft'
| 'pending_review'
| 'approved'
| 'rejected'
| 'expired'
| 'revoked';
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
@@ -192,19 +192,61 @@ export interface ExceptionTransition {
allowedRoles: string[];
}
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
{ from: 'draft', to: 'pending_review', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
{ from: 'pending_review', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
{ from: 'pending_review', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'pending_review', to: 'rejected', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'approved', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
];
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
{ status: 'pending_review', label: 'Pending Review', color: '#f59e0b' },
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
{ status: 'rejected', label: 'Rejected', color: '#f472b6' },
{ status: 'expired', label: 'Expired', color: '#6b7280' },
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
];
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
{ from: 'draft', to: 'pending_review', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
{ from: 'pending_review', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
{ from: 'pending_review', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'pending_review', to: 'rejected', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'approved', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
];
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
{ status: 'pending_review', label: 'Pending Review', color: '#f59e0b' },
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
{ status: 'rejected', label: 'Rejected', color: '#f472b6' },
{ status: 'expired', label: 'Expired', color: '#6b7280' },
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
];
/**
* Exception ledger entry for timeline display.
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-12
*/
export interface ExceptionLedgerEntry {
/** Entry ID. */
id: string;
/** Exception ID. */
exceptionId: string;
/** Event type. */
eventType: 'created' | 'approved' | 'rejected' | 'expired' | 'revoked' | 'extended' | 'modified';
/** Event timestamp. */
timestamp: string;
/** Actor user ID. */
actorId: string;
/** Actor display name. */
actorName?: string;
/** Event details. */
details?: Record<string, unknown>;
/** Comment. */
comment?: string;
}
/**
* Exception summary for risk budget dashboard.
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-04
*/
export interface ExceptionSummary {
/** Total active exceptions. */
active: number;
/** Pending approval. */
pending: number;
/** Expiring within 7 days. */
expiringSoon: number;
/** Total risk points covered. */
riskPointsCovered: number;
/** Trace ID. */
traceId: string;
}

View File

@@ -0,0 +1,120 @@
/**
* Risk Budget Models
*
* Models for risk budget tracking, burn-up visualization,
* and budget enforcement.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-01
*/
/**
* Risk budget status.
*/
export type BudgetStatus = 'healthy' | 'warning' | 'critical' | 'exceeded';
/**
* Risk budget time series point.
*/
export interface BudgetTimePoint {
/** Timestamp (UTC ISO-8601). */
timestamp: string;
/** Actual risk points at this time. */
actual: number;
/** Budget limit at this time. */
budget: number;
/** Headroom (budget - actual). */
headroom: number;
}
/**
* Risk budget configuration.
*/
export interface BudgetConfig {
/** Budget ID. */
id: string;
/** Tenant ID. */
tenantId: string;
/** Project ID (optional). */
projectId?: string;
/** Budget name. */
name: string;
/** Total budget points. */
totalBudget: number;
/** Warning threshold (percentage). */
warningThreshold: number;
/** Critical threshold (percentage). */
criticalThreshold: number;
/** Budget period (e.g., 'quarterly', 'monthly'). */
period: 'weekly' | 'monthly' | 'quarterly' | 'yearly';
/** Period start date. */
periodStart: string;
/** Period end date. */
periodEnd: string;
/** Created timestamp. */
createdAt: string;
/** Updated timestamp. */
updatedAt: string;
}
/**
* Current risk budget status.
*/
export interface BudgetSnapshot {
/** Budget configuration. */
config: BudgetConfig;
/** Current risk points consumed. */
currentRiskPoints: number;
/** Remaining headroom. */
headroom: number;
/** Budget utilization percentage. */
utilizationPercent: number;
/** Budget status. */
status: BudgetStatus;
/** Time series data for chart. */
timeSeries: BudgetTimePoint[];
/** Last updated. */
updatedAt: string;
/** Trace ID. */
traceId: string;
}
/**
* Risk budget KPIs.
*/
export interface BudgetKpis {
/** Current headroom (points). */
headroom: number;
/** Headroom change from yesterday. */
headroomDelta24h: number;
/** Unknown risks added in last 24h. */
unknownsDelta24h: number;
/** Risk points retired in last 7 days. */
riskRetired7d: number;
/** Exceptions expiring soon (within 7 days). */
exceptionsExpiring: number;
/** Burn rate (points per day). */
burnRate: number;
/** Projected days until budget exceeded (null if not projected). */
projectedDaysToExceeded: number | null;
/** Trace ID. */
traceId: string;
}
/**
* Risk budget query options.
*/
export interface BudgetQueryOptions {
/** Tenant ID. */
tenantId: string;
/** Project ID (optional). */
projectId?: string;
/** Start date for time series. */
startDate?: string;
/** End date for time series. */
endDate?: string;
/** Time series granularity. */
granularity?: 'hour' | 'day' | 'week';
/** Trace ID. */
traceId?: string;
}

View File

@@ -0,0 +1,214 @@
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import {
DeltaVerdictStore,
DELTA_VERDICT_API,
HttpDeltaVerdictApi,
MockDeltaVerdictApi,
} from './delta-verdict.service';
import type { DeltaVerdict, VerdictLevel } from '../api/delta-verdict.models';
describe('DeltaVerdictStore', () => {
let store: DeltaVerdictStore;
let api: MockDeltaVerdictApi;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
DeltaVerdictStore,
{ provide: DELTA_VERDICT_API, useClass: MockDeltaVerdictApi },
],
});
store = TestBed.inject(DeltaVerdictStore);
api = TestBed.inject(DELTA_VERDICT_API) as MockDeltaVerdictApi;
});
it('should be created', () => {
expect(store).toBeTruthy();
});
it('should have initial null verdict', () => {
expect(store.verdict()).toBeNull();
});
it('should have initial null previousVerdict', () => {
expect(store.previousVerdict()).toBeNull();
});
it('should not be loading initially', () => {
expect(store.loading()).toBe(false);
});
it('should have no error initially', () => {
expect(store.error()).toBeNull();
});
describe('loadVerdict', () => {
it('should set loading to true while fetching', async () => {
const loadPromise = store.loadVerdict('sha256:abc123');
expect(store.loading()).toBe(true);
await loadPromise;
});
it('should set verdict after successful fetch', async () => {
await store.loadVerdict('sha256:abc123');
expect(store.verdict()).not.toBeNull();
expect(store.verdict()?.artifactDigest).toBe('sha256:abc123');
});
it('should set loading to false after fetch', async () => {
await store.loadVerdict('sha256:abc123');
expect(store.loading()).toBe(false);
});
it('should include drivers in verdict', async () => {
await store.loadVerdict('sha256:abc123');
expect(store.verdict()?.drivers).toBeDefined();
expect(store.verdict()?.drivers.length).toBeGreaterThan(0);
});
});
describe('loadHistory', () => {
it('should set history after successful fetch', async () => {
await store.loadHistory('sha256:abc123');
expect(store.history()).not.toBeNull();
expect(store.history()?.length).toBeGreaterThan(0);
});
});
describe('computed properties', () => {
beforeEach(async () => {
await store.loadVerdict('sha256:abc123');
});
it('should return correct verdict level', () => {
const verdict = store.verdict();
expect(['routine', 'review', 'block']).toContain(verdict?.level);
});
it('should have timestamp', () => {
const verdict = store.verdict();
expect(verdict?.timestamp).toBeDefined();
});
});
});
describe('HttpDeltaVerdictApi', () => {
let api: HttpDeltaVerdictApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [HttpDeltaVerdictApi],
});
api = TestBed.inject(HttpDeltaVerdictApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
describe('getVerdict', () => {
it('should make GET request to correct endpoint', () => {
const mockVerdict: DeltaVerdict = {
id: 'verdict-1',
artifactDigest: 'sha256:abc123',
level: 'routine' as VerdictLevel,
drivers: [],
timestamp: '2025-12-26T00:00:00Z',
traceId: 'trace-123',
};
api.getVerdict('sha256:abc123').subscribe(verdict => {
expect(verdict).toEqual(mockVerdict);
});
const req = httpMock.expectOne('/api/risk/gate/verdict?digest=sha256:abc123');
expect(req.request.method).toBe('GET');
req.flush(mockVerdict);
});
});
describe('getHistory', () => {
it('should make GET request to correct endpoint', () => {
const mockHistory: DeltaVerdict[] = [
{
id: 'verdict-1',
artifactDigest: 'sha256:abc123',
level: 'routine' as VerdictLevel,
drivers: [],
timestamp: '2025-12-26T00:00:00Z',
traceId: 'trace-123',
},
];
api.getHistory('sha256:abc123', 10).subscribe(history => {
expect(history).toEqual(mockHistory);
});
const req = httpMock.expectOne('/api/risk/gate/history?digest=sha256:abc123&limit=10');
expect(req.request.method).toBe('GET');
req.flush(mockHistory);
});
it('should default to limit of 10', () => {
api.getHistory('sha256:abc123').subscribe();
const req = httpMock.expectOne('/api/risk/gate/history?digest=sha256:abc123&limit=10');
expect(req.request.method).toBe('GET');
req.flush([]);
});
});
});
describe('MockDeltaVerdictApi', () => {
let api: MockDeltaVerdictApi;
beforeEach(() => {
api = new MockDeltaVerdictApi();
});
it('should return mock verdict', (done) => {
api.getVerdict('sha256:abc123').subscribe(verdict => {
expect(verdict).toBeDefined();
expect(verdict.id).toBeDefined();
expect(verdict.level).toBeDefined();
done();
});
});
it('should return verdict with drivers', (done) => {
api.getVerdict('sha256:abc123').subscribe(verdict => {
expect(verdict.drivers).toBeDefined();
expect(verdict.drivers.length).toBeGreaterThan(0);
done();
});
});
it('should return mock history', (done) => {
api.getHistory('sha256:abc123').subscribe(history => {
expect(history).toBeDefined();
expect(Array.isArray(history)).toBe(true);
done();
});
});
it('should include previous verdict in current verdict', (done) => {
api.getVerdict('sha256:abc123').subscribe(verdict => {
expect(verdict.previousVerdict).toBeDefined();
done();
});
});
it('should include risk delta in verdict', (done) => {
api.getVerdict('sha256:abc123').subscribe(verdict => {
expect(verdict.riskDelta).toBeDefined();
done();
});
});
});

View File

@@ -0,0 +1,282 @@
/**
* Delta Verdict Service
*
* Angular service for consuming gate/verdict API endpoints.
* Provides verdict data, delta comparisons, and verdict drivers.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-02
*/
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, delay, finalize } from 'rxjs';
import type {
DeltaVerdict,
VerdictComparison,
VerdictQueryOptions,
VerdictHistoryEntry,
VerdictStats,
VerdictLevel,
VerdictDriver,
VerdictDriverCategory,
} from '../api/delta-verdict.models';
const API_BASE = '/api/gate';
/**
* Delta Verdict API client interface.
*/
export interface DeltaVerdictApi {
/** Get current verdict for artifact. */
getVerdict(artifactDigest: string, options: VerdictQueryOptions): Observable<DeltaVerdict>;
/** Get verdict comparison (before/after). */
getComparison(beforeDigest: string, afterDigest: string, options: VerdictQueryOptions): Observable<VerdictComparison>;
/** Get verdict history for artifact. */
getHistory(artifactDigest: string, options: VerdictQueryOptions & { limit?: number }): Observable<VerdictHistoryEntry[]>;
/** Get verdict statistics. */
getStats(options: VerdictQueryOptions): Observable<VerdictStats>;
/** Get latest verdicts across artifacts. */
getLatestVerdicts(options: VerdictQueryOptions & { limit?: number }): Observable<DeltaVerdict[]>;
}
/**
* Mock Delta Verdict API for development.
*/
@Injectable({ providedIn: 'root' })
export class MockDeltaVerdictApi implements DeltaVerdictApi {
private readonly mockDrivers: VerdictDriver[] = [
{
category: 'critical_vuln',
summary: '2 critical vulnerabilities detected',
description: 'CVE-2025-1234 and CVE-2025-5678 are reachable from public endpoints',
impact: 150,
relatedIds: ['CVE-2025-1234', 'CVE-2025-5678'],
evidenceType: 'reachability',
},
{
category: 'budget_exceeded',
summary: 'Risk budget at 85% utilization',
description: 'Current risk points (680) approaching budget limit (800)',
impact: 50,
evidenceType: 'sbom_diff',
},
{
category: 'vex_source',
summary: 'Vendor VEX not available for 3 CVEs',
description: 'Missing vendor analysis may understate risk',
impact: 25,
evidenceType: 'vex',
},
];
getVerdict(artifactDigest: string, options: VerdictQueryOptions): Observable<DeltaVerdict> {
const level: VerdictLevel = Math.random() > 0.7 ? 'block' : Math.random() > 0.4 ? 'review' : 'routine';
const verdict: DeltaVerdict = {
id: `verdict-${Date.now()}`,
artifactDigest,
artifactName: 'myapp:v2.1.0',
level,
timestamp: new Date().toISOString(),
policyPackId: 'default',
policyVersion: '1.2.0',
drivers: this.mockDrivers.slice(0, level === 'block' ? 3 : level === 'review' ? 2 : 1),
previousVerdict: {
level: 'routine',
timestamp: new Date(Date.now() - 86400000).toISOString(),
},
riskDelta: {
added: 45,
removed: 12,
net: 33,
},
traceId: `trace-${Date.now()}`,
};
return of(verdict).pipe(delay(75));
}
getComparison(beforeDigest: string, afterDigest: string, options: VerdictQueryOptions): Observable<VerdictComparison> {
return this.getVerdict(beforeDigest, options).pipe(
delay(50),
) as unknown as Observable<VerdictComparison>;
}
getHistory(artifactDigest: string, options: VerdictQueryOptions & { limit?: number }): Observable<VerdictHistoryEntry[]> {
const limit = options.limit ?? 10;
const entries: VerdictHistoryEntry[] = [];
for (let i = 0; i < limit; i++) {
const date = new Date();
date.setDate(date.getDate() - i);
entries.push({
id: `verdict-${i}`,
artifactDigest,
level: i === 0 ? 'review' : 'routine',
timestamp: date.toISOString(),
riskScore: 680 - i * 15,
keyDrivers: ['2 critical vulns', 'Budget at 85%'],
});
}
return of(entries).pipe(delay(50));
}
getStats(options: VerdictQueryOptions): Observable<VerdictStats> {
return of({
total: 156,
byLevel: {
routine: 120,
review: 28,
block: 8,
},
trend: {
direction: 'improving' as const,
changePercent: -5,
},
averageRiskScore: 425,
traceId: `trace-${Date.now()}`,
}).pipe(delay(50));
}
getLatestVerdicts(options: VerdictQueryOptions & { limit?: number }): Observable<DeltaVerdict[]> {
const limit = options.limit ?? 5;
const verdicts: DeltaVerdict[] = [];
for (let i = 0; i < limit; i++) {
verdicts.push({
id: `verdict-${i}`,
artifactDigest: `sha256:abc${i}def`,
artifactName: `service-${i}:latest`,
level: i === 0 ? 'block' : i < 3 ? 'review' : 'routine',
timestamp: new Date(Date.now() - i * 3600000).toISOString(),
policyPackId: 'default',
policyVersion: '1.2.0',
drivers: this.mockDrivers.slice(0, 1),
traceId: `trace-${Date.now()}-${i}`,
});
}
return of(verdicts).pipe(delay(75));
}
}
/**
* HTTP-based Delta Verdict API client.
*/
@Injectable({ providedIn: 'root' })
export class HttpDeltaVerdictApi implements DeltaVerdictApi {
private readonly http = inject(HttpClient);
getVerdict(artifactDigest: string, options: VerdictQueryOptions): Observable<DeltaVerdict> {
let params = new HttpParams()
.set('tenantId', options.tenantId)
.set('artifact', artifactDigest);
if (options.projectId) params = params.set('projectId', options.projectId);
if (options.includePrevious) params = params.set('includePrevious', 'true');
return this.http.get<DeltaVerdict>(`${API_BASE}/verdict`, { params });
}
getComparison(beforeDigest: string, afterDigest: string, options: VerdictQueryOptions): Observable<VerdictComparison> {
let params = new HttpParams()
.set('tenantId', options.tenantId)
.set('before', beforeDigest)
.set('after', afterDigest);
if (options.projectId) params = params.set('projectId', options.projectId);
return this.http.get<VerdictComparison>(`${API_BASE}/compare`, { params });
}
getHistory(artifactDigest: string, options: VerdictQueryOptions & { limit?: number }): Observable<VerdictHistoryEntry[]> {
let params = new HttpParams()
.set('tenantId', options.tenantId)
.set('artifact', artifactDigest);
if (options.projectId) params = params.set('projectId', options.projectId);
if (options.limit) params = params.set('limit', options.limit.toString());
return this.http.get<VerdictHistoryEntry[]>(`${API_BASE}/history`, { params });
}
getStats(options: VerdictQueryOptions): Observable<VerdictStats> {
let params = new HttpParams().set('tenantId', options.tenantId);
if (options.projectId) params = params.set('projectId', options.projectId);
return this.http.get<VerdictStats>(`${API_BASE}/stats`, { params });
}
getLatestVerdicts(options: VerdictQueryOptions & { limit?: number }): Observable<DeltaVerdict[]> {
let params = new HttpParams().set('tenantId', options.tenantId);
if (options.projectId) params = params.set('projectId', options.projectId);
if (options.limit) params = params.set('limit', options.limit.toString());
return this.http.get<DeltaVerdict[]>(`${API_BASE}/latest`, { params });
}
}
/**
* Delta Verdict Store (reactive state management).
*/
@Injectable({ providedIn: 'root' })
export class DeltaVerdictStore {
private readonly api = inject(MockDeltaVerdictApi); // Switch to HttpDeltaVerdictApi for production
private readonly currentVerdictSignal = signal<DeltaVerdict | null>(null);
private readonly latestVerdictSignal = signal<DeltaVerdict[]>([]);
private readonly statsSignal = signal<VerdictStats | null>(null);
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
readonly currentVerdict = this.currentVerdictSignal.asReadonly();
readonly latestVerdicts = this.latestVerdictSignal.asReadonly();
readonly stats = this.statsSignal.asReadonly();
readonly loading = this.loadingSignal.asReadonly();
readonly error = this.errorSignal.asReadonly();
readonly currentLevel = computed(() => this.currentVerdictSignal()?.level ?? 'routine');
readonly drivers = computed(() => this.currentVerdictSignal()?.drivers ?? []);
readonly riskDelta = computed(() => this.currentVerdictSignal()?.riskDelta ?? null);
fetchVerdict(artifactDigest: string, options: VerdictQueryOptions): void {
this.loadingSignal.set(true);
this.errorSignal.set(null);
this.api.getVerdict(artifactDigest, options)
.pipe(finalize(() => this.loadingSignal.set(false)))
.subscribe({
next: (verdict) => this.currentVerdictSignal.set(verdict),
error: (err) => this.errorSignal.set(err.message ?? 'Failed to fetch verdict'),
});
}
fetchLatestVerdicts(options: VerdictQueryOptions & { limit?: number }): void {
this.api.getLatestVerdicts(options).subscribe({
next: (verdicts) => this.latestVerdictSignal.set(verdicts),
error: (err) => this.errorSignal.set(err.message ?? 'Failed to fetch latest verdicts'),
});
}
fetchStats(options: VerdictQueryOptions): void {
this.api.getStats(options).subscribe({
next: (stats) => this.statsSignal.set(stats),
error: (err) => this.errorSignal.set(err.message ?? 'Failed to fetch stats'),
});
}
clear(): void {
this.currentVerdictSignal.set(null);
this.latestVerdictSignal.set([]);
this.statsSignal.set(null);
this.errorSignal.set(null);
}
}

View File

@@ -0,0 +1,187 @@
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { RiskBudgetStore, RISK_BUDGET_API, HttpRiskBudgetApi, MockRiskBudgetApi } from './risk-budget.service';
import type { BudgetSnapshot, BudgetKpis } from '../api/risk-budget.models';
describe('RiskBudgetStore', () => {
let store: RiskBudgetStore;
let api: MockRiskBudgetApi;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
RiskBudgetStore,
{ provide: RISK_BUDGET_API, useClass: MockRiskBudgetApi },
],
});
store = TestBed.inject(RiskBudgetStore);
api = TestBed.inject(RISK_BUDGET_API) as MockRiskBudgetApi;
});
it('should be created', () => {
expect(store).toBeTruthy();
});
it('should have initial null snapshot', () => {
expect(store.snapshot()).toBeNull();
});
it('should have initial null kpis', () => {
expect(store.kpis()).toBeNull();
});
it('should not be loading initially', () => {
expect(store.loading()).toBe(false);
});
it('should have no error initially', () => {
expect(store.error()).toBeNull();
});
describe('loadSnapshot', () => {
it('should set loading to true while fetching', async () => {
const loadPromise = store.loadSnapshot('tenant-1');
expect(store.loading()).toBe(true);
await loadPromise;
});
it('should set snapshot after successful fetch', async () => {
await store.loadSnapshot('tenant-1');
expect(store.snapshot()).not.toBeNull();
expect(store.snapshot()?.config.tenantId).toBe('tenant-1');
});
it('should set loading to false after fetch', async () => {
await store.loadSnapshot('tenant-1');
expect(store.loading()).toBe(false);
});
it('should clear error on successful fetch', async () => {
await store.loadSnapshot('tenant-1');
expect(store.error()).toBeNull();
});
});
describe('loadKpis', () => {
it('should set kpis after successful fetch', async () => {
await store.loadKpis('tenant-1');
expect(store.kpis()).not.toBeNull();
});
it('should include headroom in kpis', async () => {
await store.loadKpis('tenant-1');
expect(store.kpis()?.headroom).toBeDefined();
});
});
});
describe('HttpRiskBudgetApi', () => {
let api: HttpRiskBudgetApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [HttpRiskBudgetApi],
});
api = TestBed.inject(HttpRiskBudgetApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
describe('getSnapshot', () => {
it('should make GET request to correct endpoint', () => {
const mockSnapshot: BudgetSnapshot = {
config: {
id: 'budget-1',
tenantId: 'tenant-1',
totalBudget: 1000,
warningThreshold: 70,
criticalThreshold: 90,
period: 'monthly',
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
},
currentRiskPoints: 500,
headroom: 500,
utilizationPercent: 50,
status: 'healthy',
timeSeries: [],
computedAt: '2025-12-26T00:00:00Z',
traceId: 'trace-123',
};
api.getSnapshot('tenant-1').subscribe(snapshot => {
expect(snapshot).toEqual(mockSnapshot);
});
const req = httpMock.expectOne('/api/risk/budgets/tenant-1/snapshot');
expect(req.request.method).toBe('GET');
req.flush(mockSnapshot);
});
});
describe('getKpis', () => {
it('should make GET request to correct endpoint', () => {
const mockKpis: BudgetKpis = {
headroom: 500,
headroomDelta24h: 10,
unknownsDelta24h: 2,
riskRetired7d: 50,
exceptionsExpiring: 1,
burnRate: 15,
projectedDaysToExceeded: null,
topContributors: [],
traceId: 'trace-456',
};
api.getKpis('tenant-1').subscribe(kpis => {
expect(kpis).toEqual(mockKpis);
});
const req = httpMock.expectOne('/api/risk/budgets/tenant-1/kpis');
expect(req.request.method).toBe('GET');
req.flush(mockKpis);
});
});
});
describe('MockRiskBudgetApi', () => {
let api: MockRiskBudgetApi;
beforeEach(() => {
api = new MockRiskBudgetApi();
});
it('should return mock snapshot', (done) => {
api.getSnapshot('tenant-1').subscribe(snapshot => {
expect(snapshot).toBeDefined();
expect(snapshot.config).toBeDefined();
expect(snapshot.status).toBeDefined();
done();
});
});
it('should return mock kpis', (done) => {
api.getKpis('tenant-1').subscribe(kpis => {
expect(kpis).toBeDefined();
expect(kpis.headroom).toBeDefined();
expect(kpis.burnRate).toBeDefined();
done();
});
});
it('should return mock time series in snapshot', (done) => {
api.getSnapshot('tenant-1').subscribe(snapshot => {
expect(snapshot.timeSeries).toBeDefined();
expect(snapshot.timeSeries.length).toBeGreaterThan(0);
done();
});
});
});

View File

@@ -0,0 +1,251 @@
/**
* Risk Budget Service
*
* Angular service for consuming risk budget API endpoints.
* Provides budget snapshots, KPIs, and time series data.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-01
*/
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, delay, finalize } from 'rxjs';
import type {
BudgetSnapshot,
BudgetKpis,
BudgetQueryOptions,
BudgetConfig,
BudgetTimePoint,
BudgetStatus,
} from '../api/risk-budget.models';
const API_BASE = '/api/risk-budget';
/**
* Risk Budget API client interface.
*/
export interface RiskBudgetApi {
/** Get current budget snapshot. */
getSnapshot(options: BudgetQueryOptions): Observable<BudgetSnapshot>;
/** Get budget KPIs. */
getKpis(options: BudgetQueryOptions): Observable<BudgetKpis>;
/** Get budget configuration. */
getConfig(tenantId: string, projectId?: string): Observable<BudgetConfig>;
/** Update budget configuration. */
updateConfig(config: Partial<BudgetConfig>): Observable<BudgetConfig>;
/** Get time series data. */
getTimeSeries(options: BudgetQueryOptions): Observable<BudgetTimePoint[]>;
}
/**
* Mock Risk Budget API for development.
*/
@Injectable({ providedIn: 'root' })
export class MockRiskBudgetApi implements RiskBudgetApi {
private generateMockTimeSeries(days: number): BudgetTimePoint[] {
const points: BudgetTimePoint[] = [];
const now = new Date();
const budget = 1000;
for (let i = days; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
// Simulate gradual increase with some variation
const baseActual = budget * 0.4 + (budget * 0.3 * (days - i) / days);
const variation = Math.random() * 50 - 25;
const actual = Math.round(baseActual + variation);
points.push({
timestamp: date.toISOString(),
actual,
budget,
headroom: budget - actual,
});
}
return points;
}
getSnapshot(options: BudgetQueryOptions): Observable<BudgetSnapshot> {
const timeSeries = this.generateMockTimeSeries(30);
const current = timeSeries[timeSeries.length - 1];
const utilizationPercent = (current.actual / current.budget) * 100;
let status: BudgetStatus = 'healthy';
if (utilizationPercent >= 90) status = 'exceeded';
else if (utilizationPercent >= 80) status = 'critical';
else if (utilizationPercent >= 60) status = 'warning';
const snapshot: BudgetSnapshot = {
config: {
id: 'budget-001',
tenantId: options.tenantId,
projectId: options.projectId,
name: 'Q4 2025 Risk Budget',
totalBudget: 1000,
warningThreshold: 60,
criticalThreshold: 80,
period: 'quarterly',
periodStart: '2025-10-01T00:00:00Z',
periodEnd: '2025-12-31T23:59:59Z',
createdAt: '2025-10-01T00:00:00Z',
updatedAt: new Date().toISOString(),
},
currentRiskPoints: current.actual,
headroom: current.headroom,
utilizationPercent,
status,
timeSeries,
updatedAt: new Date().toISOString(),
traceId: `trace-${Date.now()}`,
};
return of(snapshot).pipe(delay(100));
}
getKpis(options: BudgetQueryOptions): Observable<BudgetKpis> {
const kpis: BudgetKpis = {
headroom: 320,
headroomDelta24h: -15,
unknownsDelta24h: 3,
riskRetired7d: 45,
exceptionsExpiring: 2,
burnRate: 8.5,
projectedDaysToExceeded: 38,
traceId: `trace-${Date.now()}`,
};
return of(kpis).pipe(delay(50));
}
getConfig(tenantId: string, projectId?: string): Observable<BudgetConfig> {
return of({
id: 'budget-001',
tenantId,
projectId,
name: 'Q4 2025 Risk Budget',
totalBudget: 1000,
warningThreshold: 60,
criticalThreshold: 80,
period: 'quarterly' as const,
periodStart: '2025-10-01T00:00:00Z',
periodEnd: '2025-12-31T23:59:59Z',
createdAt: '2025-10-01T00:00:00Z',
updatedAt: new Date().toISOString(),
}).pipe(delay(50));
}
updateConfig(config: Partial<BudgetConfig>): Observable<BudgetConfig> {
return this.getConfig(config.tenantId ?? '', config.projectId);
}
getTimeSeries(options: BudgetQueryOptions): Observable<BudgetTimePoint[]> {
return of(this.generateMockTimeSeries(30)).pipe(delay(75));
}
}
/**
* HTTP-based Risk Budget API client.
*/
@Injectable({ providedIn: 'root' })
export class HttpRiskBudgetApi implements RiskBudgetApi {
private readonly http = inject(HttpClient);
getSnapshot(options: BudgetQueryOptions): Observable<BudgetSnapshot> {
let params = new HttpParams().set('tenantId', options.tenantId);
if (options.projectId) params = params.set('projectId', options.projectId);
if (options.startDate) params = params.set('startDate', options.startDate);
if (options.endDate) params = params.set('endDate', options.endDate);
if (options.granularity) params = params.set('granularity', options.granularity);
return this.http.get<BudgetSnapshot>(`${API_BASE}/snapshot`, { params });
}
getKpis(options: BudgetQueryOptions): Observable<BudgetKpis> {
let params = new HttpParams().set('tenantId', options.tenantId);
if (options.projectId) params = params.set('projectId', options.projectId);
return this.http.get<BudgetKpis>(`${API_BASE}/kpis`, { params });
}
getConfig(tenantId: string, projectId?: string): Observable<BudgetConfig> {
let params = new HttpParams().set('tenantId', tenantId);
if (projectId) params = params.set('projectId', projectId);
return this.http.get<BudgetConfig>(`${API_BASE}/config`, { params });
}
updateConfig(config: Partial<BudgetConfig>): Observable<BudgetConfig> {
return this.http.put<BudgetConfig>(`${API_BASE}/config/${config.id}`, config);
}
getTimeSeries(options: BudgetQueryOptions): Observable<BudgetTimePoint[]> {
let params = new HttpParams().set('tenantId', options.tenantId);
if (options.projectId) params = params.set('projectId', options.projectId);
if (options.startDate) params = params.set('startDate', options.startDate);
if (options.endDate) params = params.set('endDate', options.endDate);
if (options.granularity) params = params.set('granularity', options.granularity);
return this.http.get<BudgetTimePoint[]>(`${API_BASE}/timeseries`, { params });
}
}
/**
* Risk Budget Store (reactive state management).
*/
@Injectable({ providedIn: 'root' })
export class RiskBudgetStore {
private readonly api = inject(MockRiskBudgetApi); // Switch to HttpRiskBudgetApi for production
private readonly snapshotSignal = signal<BudgetSnapshot | null>(null);
private readonly kpisSignal = signal<BudgetKpis | null>(null);
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
readonly snapshot = this.snapshotSignal.asReadonly();
readonly kpis = this.kpisSignal.asReadonly();
readonly loading = this.loadingSignal.asReadonly();
readonly error = this.errorSignal.asReadonly();
readonly status = computed(() => this.snapshotSignal()?.status ?? 'healthy');
readonly headroom = computed(() => this.snapshotSignal()?.headroom ?? 0);
readonly utilizationPercent = computed(() => this.snapshotSignal()?.utilizationPercent ?? 0);
readonly timeSeries = computed(() => this.snapshotSignal()?.timeSeries ?? []);
fetchSnapshot(options: BudgetQueryOptions): void {
this.loadingSignal.set(true);
this.errorSignal.set(null);
this.api.getSnapshot(options)
.pipe(finalize(() => this.loadingSignal.set(false)))
.subscribe({
next: (snapshot) => this.snapshotSignal.set(snapshot),
error: (err) => this.errorSignal.set(err.message ?? 'Failed to fetch budget snapshot'),
});
}
fetchKpis(options: BudgetQueryOptions): void {
this.api.getKpis(options).subscribe({
next: (kpis) => this.kpisSignal.set(kpis),
error: (err) => this.errorSignal.set(err.message ?? 'Failed to fetch KPIs'),
});
}
refresh(options: BudgetQueryOptions): void {
this.fetchSnapshot(options);
this.fetchKpis(options);
}
clear(): void {
this.snapshotSignal.set(null);
this.kpisSignal.set(null);
this.errorSignal.set(null);
}
}

View File

@@ -0,0 +1,131 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BudgetBurnupChartComponent } from './budget-burnup-chart.component';
import type { BudgetTimePoint, BudgetStatus } from '../../../core/api/risk-budget.models';
describe('BudgetBurnupChartComponent', () => {
let component: BudgetBurnupChartComponent;
let fixture: ComponentFixture<BudgetBurnupChartComponent>;
const mockTimeSeriesData: BudgetTimePoint[] = [
{ timestamp: '2025-12-20T00:00:00Z', actual: 100, budget: 1000, headroom: 900 },
{ timestamp: '2025-12-21T00:00:00Z', actual: 150, budget: 1000, headroom: 850 },
{ timestamp: '2025-12-22T00:00:00Z', actual: 200, budget: 1000, headroom: 800 },
{ timestamp: '2025-12-23T00:00:00Z', actual: 250, budget: 1000, headroom: 750 },
{ timestamp: '2025-12-24T00:00:00Z', actual: 300, budget: 1000, headroom: 700 },
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BudgetBurnupChartComponent],
}).compileComponents();
fixture = TestBed.createComponent(BudgetBurnupChartComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render SVG chart', () => {
component.data = mockTimeSeriesData;
component.budget = 1000;
component.status = 'healthy';
fixture.detectChanges();
const svg = fixture.nativeElement.querySelector('.burnup-chart');
expect(svg).toBeTruthy();
});
it('should render grid lines', () => {
component.data = mockTimeSeriesData;
fixture.detectChanges();
const gridLines = fixture.nativeElement.querySelectorAll('.grid-line');
expect(gridLines.length).toBeGreaterThan(0);
});
it('should render actual data line', () => {
component.data = mockTimeSeriesData;
fixture.detectChanges();
const actualLine = fixture.nativeElement.querySelector('.actual-line');
expect(actualLine).toBeTruthy();
expect(actualLine.getAttribute('d')).toBeTruthy();
});
it('should render budget line', () => {
component.data = mockTimeSeriesData;
component.budget = 1000;
fixture.detectChanges();
const budgetLine = fixture.nativeElement.querySelector('.budget-line');
expect(budgetLine).toBeTruthy();
});
it('should render data points for each time point', () => {
component.data = mockTimeSeriesData;
fixture.detectChanges();
const dataPoints = fixture.nativeElement.querySelectorAll('.data-point');
expect(dataPoints.length).toBe(mockTimeSeriesData.length);
});
it('should apply healthy class to headroom area when status is healthy', () => {
component.data = mockTimeSeriesData;
component.status = 'healthy';
fixture.detectChanges();
const headroom = fixture.nativeElement.querySelector('.headroom-area');
expect(headroom.classList.contains('healthy')).toBe(true);
});
it('should apply warning class to headroom area when status is warning', () => {
component.data = mockTimeSeriesData;
component.status = 'warning';
fixture.detectChanges();
const headroom = fixture.nativeElement.querySelector('.headroom-area');
expect(headroom.classList.contains('warning')).toBe(true);
});
it('should apply critical class to headroom area when status is critical', () => {
component.data = mockTimeSeriesData;
component.status = 'critical';
fixture.detectChanges();
const headroom = fixture.nativeElement.querySelector('.headroom-area');
expect(headroom.classList.contains('critical')).toBe(true);
});
it('should render legend items', () => {
component.data = mockTimeSeriesData;
fixture.detectChanges();
const legendItems = fixture.nativeElement.querySelectorAll('.legend-item');
expect(legendItems.length).toBe(3); // Budget, Actual, Headroom
});
it('should handle empty data gracefully', () => {
component.data = [];
fixture.detectChanges();
const actualLine = fixture.nativeElement.querySelector('.actual-line');
expect(actualLine.getAttribute('d')).toBe('');
});
it('should use custom dimensions when provided', () => {
component.data = mockTimeSeriesData;
component.dimensions = {
width: 800,
height: 400,
padding: { top: 30, right: 80, bottom: 50, left: 60 },
};
fixture.detectChanges();
const svg = fixture.nativeElement.querySelector('.burnup-chart');
expect(svg.getAttribute('width')).toBe('800');
expect(svg.getAttribute('height')).toBe('400');
});
});

View File

@@ -0,0 +1,386 @@
/**
* Budget Burn-Up Chart Component
*
* Visualizes risk budget consumption over time.
* X-axis: calendar days, Y-axis: risk points
* Shows budget limit line, actual consumption, and headroom shading.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-03
*/
import { Component, Input, OnChanges, SimpleChanges, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { BudgetTimePoint, BudgetStatus } from '../../../core/api/risk-budget.models';
export interface ChartDimensions {
width: number;
height: number;
padding: { top: number; right: number; bottom: number; left: number };
}
@Component({
selector: 'st-budget-burnup-chart',
standalone: true,
imports: [CommonModule],
template: `
<div class="chart-container" [style.height.px]="dimensions.height">
<svg
[attr.width]="dimensions.width"
[attr.height]="dimensions.height"
[attr.viewBox]="viewBox()"
class="burnup-chart"
>
<!-- Grid lines -->
<g class="grid">
@for (line of gridLines(); track line.y) {
<line
[attr.x1]="dimensions.padding.left"
[attr.y1]="line.y"
[attr.x2]="chartWidth()"
[attr.y2]="line.y"
class="grid-line"
/>
<text
[attr.x]="dimensions.padding.left - 8"
[attr.y]="line.y + 4"
class="axis-label"
text-anchor="end"
>
{{ line.value }}
</text>
}
</g>
<!-- Headroom shading (area between budget and actual) -->
<path
[attr.d]="headroomPath()"
class="headroom-area"
[class.healthy]="status === 'healthy'"
[class.warning]="status === 'warning'"
[class.critical]="status === 'critical'"
[class.exceeded]="status === 'exceeded'"
/>
<!-- Budget limit line -->
<line
[attr.x1]="dimensions.padding.left"
[attr.y1]="budgetY()"
[attr.x2]="chartWidth()"
[attr.y2]="budgetY()"
class="budget-line"
/>
<text
[attr.x]="chartWidth() + 4"
[attr.y]="budgetY() + 4"
class="budget-label"
>
Budget: {{ budget }}
</text>
<!-- Actual consumption line -->
<path
[attr.d]="actualPath()"
class="actual-line"
fill="none"
/>
<!-- Data points -->
@for (point of chartPoints(); track point.x; let i = $index) {
<circle
[attr.cx]="point.x"
[attr.cy]="point.y"
r="3"
class="data-point"
[class.last]="i === chartPoints().length - 1"
/>
}
<!-- X-axis labels -->
@for (label of xAxisLabels(); track label.x) {
<text
[attr.x]="label.x"
[attr.y]="chartHeight() + 16"
class="axis-label x-label"
text-anchor="middle"
>
{{ label.text }}
</text>
}
</svg>
<!-- Legend -->
<div class="chart-legend">
<div class="legend-item">
<span class="legend-line budget"></span>
<span>Budget Limit</span>
</div>
<div class="legend-item">
<span class="legend-line actual"></span>
<span>Actual Risk Points</span>
</div>
<div class="legend-item">
<span class="legend-area" [class]="status"></span>
<span>Headroom</span>
</div>
</div>
</div>
`,
styles: [`
:host {
display: block;
width: 100%;
}
.chart-container {
position: relative;
width: 100%;
overflow-x: auto;
}
.burnup-chart {
font-family: var(--st-font-mono, monospace);
width: 100%;
height: auto;
min-width: 320px;
}
.grid-line {
stroke: var(--st-color-border-subtle, #e5e7eb);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.axis-label {
font-size: 11px;
fill: var(--st-color-text-secondary, #6b7280);
}
.x-label {
font-size: 10px;
}
.budget-line {
stroke: var(--st-color-warning, #f59e0b);
stroke-width: 2;
stroke-dasharray: 8 4;
}
.budget-label {
font-size: 11px;
fill: var(--st-color-warning, #f59e0b);
font-weight: 500;
}
.actual-line {
stroke: var(--st-color-primary, #3b82f6);
stroke-width: 2;
}
.data-point {
fill: var(--st-color-primary, #3b82f6);
stroke: white;
stroke-width: 1;
}
.data-point.last {
r: 5;
fill: var(--st-color-primary-dark, #2563eb);
}
.headroom-area {
opacity: 0.15;
}
.headroom-area.healthy { fill: var(--st-color-success, #22c55e); }
.headroom-area.warning { fill: var(--st-color-warning, #f59e0b); }
.headroom-area.critical { fill: var(--st-color-error, #ef4444); }
.headroom-area.exceeded { fill: var(--st-color-error, #ef4444); }
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
margin-top: 12px;
font-size: 12px;
color: var(--st-color-text-secondary, #6b7280);
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.legend-line {
display: inline-block;
width: 20px;
height: 2px;
}
.legend-line.budget {
background: var(--st-color-warning, #f59e0b);
border-style: dashed;
}
.legend-line.actual {
background: var(--st-color-primary, #3b82f6);
}
.legend-area {
display: inline-block;
width: 14px;
height: 14px;
opacity: 0.3;
border-radius: 2px;
}
.legend-area.healthy { background: var(--st-color-success, #22c55e); }
.legend-area.warning { background: var(--st-color-warning, #f59e0b); }
.legend-area.critical { background: var(--st-color-error, #ef4444); }
.legend-area.exceeded { background: var(--st-color-error, #ef4444); }
/* Tablet */
@media (min-width: 768px) {
.chart-legend {
gap: 16px;
}
}
/* Desktop */
@media (min-width: 1024px) {
.chart-legend {
gap: 24px;
}
}
`],
})
export class BudgetBurnupChartComponent implements OnChanges {
@Input() data: BudgetTimePoint[] = [];
@Input() budget = 1000;
@Input() status: BudgetStatus = 'healthy';
@Input() dimensions: ChartDimensions = {
width: 600,
height: 300,
padding: { top: 20, right: 60, bottom: 40, left: 50 },
};
private readonly dataSignal = signal<BudgetTimePoint[]>([]);
ngOnChanges(changes: SimpleChanges): void {
if (changes['data']) {
this.dataSignal.set(this.data);
}
}
protected viewBox = computed(() =>
`0 0 ${this.dimensions.width} ${this.dimensions.height}`
);
protected chartWidth = computed(() =>
this.dimensions.width - this.dimensions.padding.left - this.dimensions.padding.right
);
protected chartHeight = computed(() =>
this.dimensions.height - this.dimensions.padding.top - this.dimensions.padding.bottom
);
protected maxValue = computed(() => {
const data = this.dataSignal();
const maxActual = Math.max(...data.map(d => d.actual), 0);
return Math.max(maxActual * 1.1, this.budget * 1.1);
});
protected scaleY = computed(() => {
const max = this.maxValue();
return (value: number) => {
const chartH = this.chartHeight();
const top = this.dimensions.padding.top;
return top + chartH - (value / max) * chartH;
};
});
protected scaleX = computed(() => {
const data = this.dataSignal();
const len = data.length || 1;
return (index: number) => {
const chartW = this.chartWidth();
const left = this.dimensions.padding.left;
return left + (index / (len - 1)) * chartW;
};
});
protected budgetY = computed(() => this.scaleY()(this.budget));
protected chartPoints = computed(() => {
const data = this.dataSignal();
const scaleX = this.scaleX();
const scaleY = this.scaleY();
return data.map((point, i) => ({
x: scaleX(i),
y: scaleY(point.actual),
value: point.actual,
}));
});
protected actualPath = computed(() => {
const points = this.chartPoints();
if (points.length === 0) return '';
return points.reduce((path, point, i) => {
return path + (i === 0 ? `M ${point.x} ${point.y}` : ` L ${point.x} ${point.y}`);
}, '');
});
protected headroomPath = computed(() => {
const data = this.dataSignal();
const scaleX = this.scaleX();
const scaleY = this.scaleY();
if (data.length === 0) return '';
// Create path: actual line -> budget line (reversed) -> close
const actualPoints = data.map((d, i) => `${scaleX(i)},${scaleY(d.actual)}`);
const budgetY = this.budgetY();
const budgetPoints = data.map((_, i) => `${scaleX(data.length - 1 - i)},${budgetY}`).reverse();
return `M ${actualPoints.join(' L ')} L ${budgetPoints.join(' L ')} Z`;
});
protected gridLines = computed(() => {
const max = this.maxValue();
const scaleY = this.scaleY();
const lines: { y: number; value: number }[] = [];
const step = Math.ceil(max / 5 / 100) * 100; // Round to nearest 100
for (let v = 0; v <= max; v += step) {
lines.push({ y: scaleY(v), value: v });
}
return lines;
});
protected xAxisLabels = computed(() => {
const data = this.dataSignal();
const scaleX = this.scaleX();
if (data.length === 0) return [];
// Show ~5 labels
const step = Math.max(1, Math.floor(data.length / 5));
const labels: { x: number; text: string }[] = [];
for (let i = 0; i < data.length; i += step) {
const date = new Date(data[i].timestamp);
labels.push({
x: scaleX(i),
text: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
});
}
return labels;
});
}

View File

@@ -0,0 +1,114 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BudgetKpiTilesComponent } from './budget-kpi-tiles.component';
import type { BudgetKpis, BudgetStatus } from '../../../core/api/risk-budget.models';
describe('BudgetKpiTilesComponent', () => {
let component: BudgetKpiTilesComponent;
let fixture: ComponentFixture<BudgetKpiTilesComponent>;
const mockKpis: BudgetKpis = {
headroom: 500,
headroomDelta24h: -50,
unknownsDelta24h: 3,
riskRetired7d: 120,
exceptionsExpiring: 2,
burnRate: 15,
projectedDaysToExceeded: 30,
topContributors: [],
traceId: 'trace-123',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BudgetKpiTilesComponent],
}).compileComponents();
fixture = TestBed.createComponent(BudgetKpiTilesComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render KPI tiles when kpis are provided', () => {
component.kpis = mockKpis;
fixture.detectChanges();
const tiles = fixture.nativeElement.querySelectorAll('.kpi-tile');
expect(tiles.length).toBe(4); // Headroom, Unknowns, Retired, Expiring
});
it('should display headroom value correctly', () => {
component.kpis = mockKpis;
fixture.detectChanges();
const headroomTile = fixture.nativeElement.querySelector('.kpi-tile');
const value = headroomTile.querySelector('.kpi-value');
expect(value.textContent.trim()).toBe('500');
});
it('should show negative delta for headroom decrease', () => {
component.kpis = mockKpis;
fixture.detectChanges();
const headroomTile = fixture.nativeElement.querySelector('.kpi-tile');
const delta = headroomTile.querySelector('.kpi-delta');
expect(delta.textContent).toContain('-50');
});
it('should apply critical status class when status is critical', () => {
component.kpis = mockKpis;
component.status = 'critical';
fixture.detectChanges();
const headroomTile = fixture.nativeElement.querySelector('.kpi-tile');
expect(headroomTile.classList.contains('critical')).toBe(true);
});
it('should apply warning status class when status is warning', () => {
component.kpis = mockKpis;
component.status = 'warning';
fixture.detectChanges();
const headroomTile = fixture.nativeElement.querySelector('.kpi-tile');
expect(headroomTile.classList.contains('warning')).toBe(true);
});
it('should not render tiles when kpis is null', () => {
component.kpis = null;
fixture.detectChanges();
const tiles = fixture.nativeElement.querySelectorAll('.kpi-tile');
expect(tiles.length).toBe(0);
});
it('should show exceptions expiring count', () => {
component.kpis = mockKpis;
fixture.detectChanges();
const tiles = fixture.nativeElement.querySelectorAll('.kpi-tile');
const expiringTile = tiles[3]; // Fourth tile is exceptions expiring
const value = expiringTile.querySelector('.kpi-value');
expect(value.textContent.trim()).toBe('2');
});
it('should apply warning to exceptions tile when multiple expiring', () => {
component.kpis = { ...mockKpis, exceptionsExpiring: 2 };
fixture.detectChanges();
const tiles = fixture.nativeElement.querySelectorAll('.kpi-tile');
const expiringTile = tiles[3];
expect(expiringTile.classList.contains('warning')).toBe(true);
});
it('should apply critical to exceptions tile when many expiring', () => {
component.kpis = { ...mockKpis, exceptionsExpiring: 5 };
fixture.detectChanges();
const tiles = fixture.nativeElement.querySelectorAll('.kpi-tile');
const expiringTile = tiles[3];
expect(expiringTile.classList.contains('critical')).toBe(true);
});
});

View File

@@ -0,0 +1,193 @@
/**
* Budget KPI Tiles Component
*
* Displays key performance indicators for risk budget:
* - Headroom (points remaining)
* - Unknowns delta (24h)
* - Risk retired (7d)
* - Exceptions expiring
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-04
*/
import { Component, Input, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { BudgetKpis, BudgetStatus } from '../../../core/api/risk-budget.models';
export interface KpiTile {
id: string;
label: string;
value: number | string;
delta?: number;
deltaLabel?: string;
trend?: 'up' | 'down' | 'stable';
trendIsGood?: boolean;
icon?: string;
status?: 'normal' | 'warning' | 'critical';
}
@Component({
selector: 'st-budget-kpi-tiles',
standalone: true,
imports: [CommonModule],
template: `
<div class="kpi-grid">
@for (tile of tiles(); track tile.id) {
<div
class="kpi-tile"
[class.warning]="tile.status === 'warning'"
[class.critical]="tile.status === 'critical'"
>
<div class="kpi-header">
<span class="kpi-label">{{ tile.label }}</span>
@if (tile.delta !== undefined) {
<span
class="kpi-delta"
[class.positive]="tile.trend === 'up' && tile.trendIsGood"
[class.negative]="tile.trend === 'down' && !tile.trendIsGood || tile.trend === 'up' && !tile.trendIsGood"
>
@if (tile.trend === 'up') {
<span class="delta-arrow">&#x2191;</span>
} @else if (tile.trend === 'down') {
<span class="delta-arrow">&#x2193;</span>
}
{{ tile.delta > 0 ? '+' : '' }}{{ tile.delta }}
@if (tile.deltaLabel) {
<span class="delta-label">{{ tile.deltaLabel }}</span>
}
</span>
}
</div>
<div class="kpi-value">{{ tile.value }}</div>
</div>
}
</div>
`,
styles: [`
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
}
.kpi-tile {
background: var(--st-color-surface, #ffffff);
border: 1px solid var(--st-color-border, #e5e7eb);
border-radius: 8px;
padding: 16px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.kpi-tile:hover {
border-color: var(--st-color-primary, #3b82f6);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.kpi-tile.warning {
border-left: 3px solid var(--st-color-warning, #f59e0b);
}
.kpi-tile.critical {
border-left: 3px solid var(--st-color-error, #ef4444);
}
.kpi-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.kpi-label {
font-size: 12px;
font-weight: 500;
color: var(--st-color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.kpi-delta {
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: var(--st-color-surface-secondary, #f3f4f6);
}
.kpi-delta.positive {
color: var(--st-color-success, #22c55e);
background: var(--st-color-success-bg, #dcfce7);
}
.kpi-delta.negative {
color: var(--st-color-error, #ef4444);
background: var(--st-color-error-bg, #fee2e2);
}
.delta-arrow {
font-weight: bold;
}
.delta-label {
margin-left: 2px;
opacity: 0.8;
}
.kpi-value {
font-size: 28px;
font-weight: 600;
color: var(--st-color-text-primary, #111827);
font-variant-numeric: tabular-nums;
}
`],
})
export class BudgetKpiTilesComponent {
@Input() kpis: BudgetKpis | null = null;
@Input() status: BudgetStatus = 'healthy';
protected tiles = computed((): KpiTile[] => {
const kpis = this.kpis;
if (!kpis) return [];
return [
{
id: 'headroom',
label: 'Headroom',
value: kpis.headroom,
delta: kpis.headroomDelta24h,
deltaLabel: '24h',
trend: kpis.headroomDelta24h > 0 ? 'up' : kpis.headroomDelta24h < 0 ? 'down' : 'stable',
trendIsGood: kpis.headroomDelta24h >= 0, // More headroom is good
status: this.status === 'critical' || this.status === 'exceeded' ? 'critical' :
this.status === 'warning' ? 'warning' : 'normal',
},
{
id: 'unknowns',
label: 'Unknowns',
value: kpis.unknownsDelta24h,
deltaLabel: '24h',
trend: kpis.unknownsDelta24h > 0 ? 'up' : 'stable',
trendIsGood: false, // More unknowns is bad
status: kpis.unknownsDelta24h > 5 ? 'warning' : 'normal',
},
{
id: 'retired',
label: 'Risk Retired',
value: kpis.riskRetired7d,
deltaLabel: '7d',
trend: kpis.riskRetired7d > 0 ? 'up' : 'stable',
trendIsGood: true, // More retired is good
status: 'normal',
},
{
id: 'expiring',
label: 'Exceptions Expiring',
value: kpis.exceptionsExpiring,
status: kpis.exceptionsExpiring > 3 ? 'critical' :
kpis.exceptionsExpiring > 0 ? 'warning' : 'normal',
},
];
});
}

View File

@@ -0,0 +1,210 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { CreateExceptionModalComponent } from './create-exception-modal.component';
describe('CreateExceptionModalComponent', () => {
let component: CreateExceptionModalComponent;
let fixture: ComponentFixture<CreateExceptionModalComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CreateExceptionModalComponent, FormsModule],
}).compileComponents();
fixture = TestBed.createComponent(CreateExceptionModalComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should not render modal when isOpen is false', () => {
component.isOpen = false;
fixture.detectChanges();
const modal = fixture.nativeElement.querySelector('.modal-backdrop');
expect(modal).toBeFalsy();
});
it('should render modal when isOpen is true', () => {
component.isOpen = true;
fixture.detectChanges();
const modal = fixture.nativeElement.querySelector('.modal-backdrop');
expect(modal).toBeTruthy();
});
it('should render form fields', () => {
component.isOpen = true;
fixture.detectChanges();
const titleInput = fixture.nativeElement.querySelector('#title');
const typeSelect = fixture.nativeElement.querySelector('#type');
const severitySelect = fixture.nativeElement.querySelector('#severity');
const justificationTextarea = fixture.nativeElement.querySelector('#justification');
expect(titleInput).toBeTruthy();
expect(typeSelect).toBeTruthy();
expect(severitySelect).toBeTruthy();
expect(justificationTextarea).toBeTruthy();
});
it('should render TTL options', () => {
component.isOpen = true;
fixture.detectChanges();
const ttlButtons = fixture.nativeElement.querySelectorAll('.ttl-btn');
expect(ttlButtons.length).toBe(4); // 7, 14, 30, 90 days
});
it('should select TTL when option is clicked', async () => {
component.isOpen = true;
fixture.detectChanges();
const ttlButtons = fixture.nativeElement.querySelectorAll('.ttl-btn');
ttlButtons[0].click(); // 7 days
fixture.detectChanges();
expect(component.formData.ttlDays).toBe(7);
expect(ttlButtons[0].classList.contains('active')).toBe(true);
});
it('should emit closed event when close button is clicked', () => {
component.isOpen = true;
fixture.detectChanges();
spyOn(component.closed, 'emit');
const closeBtn = fixture.nativeElement.querySelector('.close-btn');
closeBtn.click();
expect(component.closed.emit).toHaveBeenCalled();
});
it('should emit closed event when backdrop is clicked', () => {
component.isOpen = true;
fixture.detectChanges();
spyOn(component.closed, 'emit');
const backdrop = fixture.nativeElement.querySelector('.modal-backdrop');
backdrop.click();
expect(component.closed.emit).toHaveBeenCalled();
});
it('should not close when modal content is clicked', () => {
component.isOpen = true;
fixture.detectChanges();
spyOn(component.closed, 'emit');
const content = fixture.nativeElement.querySelector('.modal-content');
content.click();
expect(component.closed.emit).not.toHaveBeenCalled();
});
it('should disable submit when form is invalid', () => {
component.isOpen = true;
component.formData.title = '';
component.formData.justification = '';
fixture.detectChanges();
const submitBtn = fixture.nativeElement.querySelector('.btn-primary');
expect(submitBtn.disabled).toBe(true);
});
it('should enable submit when form is valid', async () => {
component.isOpen = true;
component.formData.title = 'Test Exception';
component.formData.justification = 'Test justification for this exception';
fixture.detectChanges();
await fixture.whenStable();
const submitBtn = fixture.nativeElement.querySelector('.btn-primary');
expect(submitBtn.disabled).toBe(false);
});
it('should add evidence reference when add button is clicked', () => {
component.isOpen = true;
fixture.detectChanges();
const addBtn = fixture.nativeElement.querySelector('.add-evidence-btn');
addBtn.click();
fixture.detectChanges();
expect(component.formData.evidenceRefs.length).toBe(1);
const evidenceItems = fixture.nativeElement.querySelectorAll('.evidence-item');
expect(evidenceItems.length).toBe(1);
});
it('should remove evidence reference when remove button is clicked', () => {
component.isOpen = true;
component.formData.evidenceRefs = [
{ type: 'ticket', title: 'JIRA-123', url: 'https://jira.example.com/JIRA-123' },
];
fixture.detectChanges();
const removeBtn = fixture.nativeElement.querySelector('.remove-btn');
removeBtn.click();
fixture.detectChanges();
expect(component.formData.evidenceRefs.length).toBe(0);
});
it('should emit created event with form data on submit', async () => {
component.isOpen = true;
component.formData.title = 'Test Exception';
component.formData.type = 'vulnerability';
component.formData.severity = 'high';
component.formData.justification = 'Test justification';
component.formData.ttlDays = 30;
component.scopeCves = 'CVE-2025-1234';
fixture.detectChanges();
spyOn(component.created, 'emit');
const submitBtn = fixture.nativeElement.querySelector('.btn-primary');
submitBtn.click();
expect(component.created.emit).toHaveBeenCalled();
const emittedData = (component.created.emit as jasmine.Spy).calls.mostRecent().args[0];
expect(emittedData.title).toBe('Test Exception');
expect(emittedData.scope.cves).toContain('CVE-2025-1234');
});
it('should prefill CVEs from input', () => {
component.prefilledCves = ['CVE-2025-1111', 'CVE-2025-2222'];
component.isOpen = true;
component.ngOnInit();
fixture.detectChanges();
expect(component.scopeCves).toBe('CVE-2025-1111, CVE-2025-2222');
});
it('should prefill packages from input', () => {
component.prefilledPackages = ['lodash', 'express'];
component.isOpen = true;
component.ngOnInit();
fixture.detectChanges();
expect(component.scopePackages).toBe('lodash, express');
});
it('should reset form after close', () => {
component.isOpen = true;
component.formData.title = 'Test';
component.formData.justification = 'Test justification';
fixture.detectChanges();
const closeBtn = fixture.nativeElement.querySelector('.close-btn');
closeBtn.click();
expect(component.formData.title).toBe('');
expect(component.formData.justification).toBe('');
});
});

View File

@@ -0,0 +1,666 @@
/**
* Create Exception Modal Component
*
* Modal form for creating new risk exceptions:
* - Reason selection
* - Evidence references
* - TTL configuration
* - Scope selection
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-13
*/
import { Component, Input, Output, EventEmitter, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import type { ExceptionType, ExceptionScope } from '../../../core/api/exception.models';
export interface CreateExceptionData {
title: string;
type: ExceptionType;
severity: 'critical' | 'high' | 'medium' | 'low';
justification: string;
scope: ExceptionScope;
ttlDays: number;
evidenceRefs: EvidenceRef[];
}
export interface EvidenceRef {
type: 'ticket' | 'document' | 'scan' | 'other';
title: string;
url: string;
}
@Component({
selector: 'st-create-exception-modal',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
@if (isOpen) {
<div class="modal-backdrop" (click)="close()">
<div class="modal-content" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3 class="modal-title">Create Exception</h3>
<button type="button" class="close-btn" (click)="close()">
&#x2715;
</button>
</div>
<form class="modal-body" (ngSubmit)="submit()">
<!-- Title -->
<div class="form-group">
<label class="form-label" for="title">Title *</label>
<input
id="title"
type="text"
class="form-input"
[(ngModel)]="formData.title"
name="title"
placeholder="Brief description of the exception"
required
/>
</div>
<!-- Type and Severity -->
<div class="form-row">
<div class="form-group">
<label class="form-label" for="type">Type *</label>
<select
id="type"
class="form-select"
[(ngModel)]="formData.type"
name="type"
required
>
<option value="vulnerability">Vulnerability</option>
<option value="license">License</option>
<option value="policy">Policy</option>
<option value="entropy">Entropy</option>
<option value="determinism">Determinism</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="severity">Severity *</label>
<select
id="severity"
class="form-select"
[(ngModel)]="formData.severity"
name="severity"
required
>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
</div>
<!-- Scope -->
<div class="form-group">
<label class="form-label">Scope</label>
<div class="scope-inputs">
<div class="scope-field">
<label class="scope-label">CVEs (comma-separated)</label>
<input
type="text"
class="form-input"
[(ngModel)]="scopeCves"
name="scopeCves"
placeholder="CVE-2025-1234, CVE-2025-5678"
/>
</div>
<div class="scope-field">
<label class="scope-label">Packages (comma-separated)</label>
<input
type="text"
class="form-input"
[(ngModel)]="scopePackages"
name="scopePackages"
placeholder="lodash, express"
/>
</div>
<div class="scope-field">
<label class="scope-label">Images (glob patterns)</label>
<input
type="text"
class="form-input"
[(ngModel)]="scopeImages"
name="scopeImages"
placeholder="myrepo/myimage:*"
/>
</div>
</div>
</div>
<!-- TTL -->
<div class="form-group">
<label class="form-label" for="ttl">Time to Live *</label>
<div class="ttl-options">
@for (option of ttlOptions; track option.value) {
<button
type="button"
class="ttl-btn"
[class.active]="formData.ttlDays === option.value"
(click)="formData.ttlDays = option.value"
>
{{ option.label }}
</button>
}
</div>
<p class="ttl-note">
Exception will expire on {{ expiryDate() | date:'mediumDate' }}
</p>
</div>
<!-- Justification -->
<div class="form-group">
<label class="form-label" for="justification">Justification *</label>
<textarea
id="justification"
class="form-textarea"
[(ngModel)]="formData.justification"
name="justification"
rows="4"
placeholder="Explain why this exception is needed and any mitigating controls in place..."
required
></textarea>
<span class="char-count">{{ formData.justification.length }}/500</span>
</div>
<!-- Evidence -->
<div class="form-group">
<label class="form-label">Evidence References</label>
<div class="evidence-list">
@for (ref of formData.evidenceRefs; track ref; let i = $index) {
<div class="evidence-item">
<select
class="form-select evidence-type"
[(ngModel)]="ref.type"
[name]="'evType' + i"
>
<option value="ticket">Ticket</option>
<option value="document">Document</option>
<option value="scan">Scan Result</option>
<option value="other">Other</option>
</select>
<input
type="text"
class="form-input evidence-title"
[(ngModel)]="ref.title"
[name]="'evTitle' + i"
placeholder="Title"
/>
<input
type="url"
class="form-input evidence-url"
[(ngModel)]="ref.url"
[name]="'evUrl' + i"
placeholder="https://..."
/>
<button
type="button"
class="remove-btn"
(click)="removeEvidence(i)"
>
&#x2715;
</button>
</div>
}
<button
type="button"
class="add-evidence-btn"
(click)="addEvidence()"
>
+ Add Evidence
</button>
</div>
</div>
</form>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
(click)="close()"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
[disabled]="!isValid()"
(click)="submit()"
>
Create Exception
</button>
</div>
</div>
</div>
}
`,
styles: [`
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 100%;
max-width: 600px;
max-height: 90vh;
background: var(--st-color-surface, #ffffff);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--st-color-border, #e5e7eb);
}
.modal-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.close-btn {
width: 32px;
height: 32px;
font-size: 16px;
color: var(--st-color-text-secondary, #6b7280);
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
}
.close-btn:hover {
background: var(--st-color-surface-secondary, #f3f4f6);
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--st-color-text-primary, #111827);
margin-bottom: 6px;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 8px 12px;
font-size: 14px;
border: 1px solid var(--st-color-border, #d1d5db);
border-radius: 6px;
background: var(--st-color-surface, #ffffff);
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: var(--st-color-primary, #3b82f6);
box-shadow: 0 0 0 3px var(--st-color-primary-bg, #dbeafe);
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.char-count {
display: block;
text-align: right;
font-size: 11px;
color: var(--st-color-text-tertiary, #9ca3af);
margin-top: 4px;
}
.scope-inputs {
display: grid;
gap: 8px;
}
.scope-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.scope-label {
font-size: 11px;
color: var(--st-color-text-secondary, #6b7280);
}
.ttl-options {
display: flex;
gap: 8px;
}
.ttl-btn {
flex: 1;
padding: 8px;
font-size: 13px;
font-weight: 500;
color: var(--st-color-text-secondary, #6b7280);
background: var(--st-color-surface, #ffffff);
border: 1px solid var(--st-color-border, #d1d5db);
border-radius: 6px;
cursor: pointer;
}
.ttl-btn.active {
color: var(--st-color-primary, #3b82f6);
border-color: var(--st-color-primary, #3b82f6);
background: var(--st-color-primary-bg, #eff6ff);
}
.ttl-note {
margin: 8px 0 0 0;
font-size: 12px;
color: var(--st-color-text-secondary, #6b7280);
}
.evidence-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.evidence-item {
display: grid;
grid-template-columns: 100px 1fr 1fr 32px;
gap: 8px;
align-items: center;
}
.evidence-type {
padding: 6px 8px;
}
.evidence-title, .evidence-url {
padding: 6px 8px;
}
.remove-btn {
width: 32px;
height: 32px;
font-size: 14px;
color: var(--st-color-error, #ef4444);
background: none;
border: 1px solid var(--st-color-border, #d1d5db);
border-radius: 6px;
cursor: pointer;
}
.remove-btn:hover {
background: var(--st-color-error-bg, #fee2e2);
}
.add-evidence-btn {
padding: 8px;
font-size: 13px;
font-weight: 500;
color: var(--st-color-primary, #3b82f6);
background: none;
border: 1px dashed var(--st-color-border, #d1d5db);
border-radius: 6px;
cursor: pointer;
}
.add-evidence-btn:hover {
border-color: var(--st-color-primary, #3b82f6);
background: var(--st-color-primary-bg, #eff6ff);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--st-color-border, #e5e7eb);
}
.btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.btn-secondary {
color: var(--st-color-text-secondary, #6b7280);
background: var(--st-color-surface, #ffffff);
border: 1px solid var(--st-color-border, #d1d5db);
}
.btn-secondary:hover {
background: var(--st-color-surface-secondary, #f9fafb);
}
.btn-primary {
color: white;
background: var(--st-color-primary, #3b82f6);
border: none;
}
.btn-primary:hover:not(:disabled) {
background: var(--st-color-primary-dark, #2563eb);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive: Mobile */
@media (max-width: 639px) {
.modal-content {
max-width: 100%;
max-height: 100%;
height: 100%;
border-radius: 0;
}
.form-row {
grid-template-columns: 1fr;
}
.ttl-options {
flex-wrap: wrap;
}
.ttl-btn {
flex: 0 0 calc(50% - 4px);
}
.evidence-item {
grid-template-columns: 1fr;
gap: 4px;
}
.evidence-item .remove-btn {
justify-self: end;
}
.modal-footer {
flex-direction: column-reverse;
}
.modal-footer .btn {
width: 100%;
}
}
/* Tablet */
@media (min-width: 640px) and (max-width: 1023px) {
.modal-content {
max-width: 90%;
margin: 20px;
}
.evidence-item {
grid-template-columns: 90px 1fr 1fr 32px;
}
}
/* Desktop */
@media (min-width: 1024px) {
.modal-content {
max-width: 680px;
}
.modal-header {
padding: 20px 24px;
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 20px 24px;
}
}
`],
})
export class CreateExceptionModalComponent {
@Input() isOpen = false;
@Input() prefilledCves: string[] = [];
@Input() prefilledPackages: string[] = [];
@Output() closed = new EventEmitter<void>();
@Output() created = new EventEmitter<CreateExceptionData>();
formData: CreateExceptionData = {
title: '',
type: 'vulnerability',
severity: 'high',
justification: '',
scope: {},
ttlDays: 30,
evidenceRefs: [],
};
scopeCves = '';
scopePackages = '';
scopeImages = '';
ttlOptions = [
{ label: '7 days', value: 7 },
{ label: '14 days', value: 14 },
{ label: '30 days', value: 30 },
{ label: '90 days', value: 90 },
];
protected expiryDate = computed(() => {
const date = new Date();
date.setDate(date.getDate() + this.formData.ttlDays);
return date;
});
ngOnInit(): void {
if (this.prefilledCves.length > 0) {
this.scopeCves = this.prefilledCves.join(', ');
}
if (this.prefilledPackages.length > 0) {
this.scopePackages = this.prefilledPackages.join(', ');
}
}
protected isValid(): boolean {
return (
this.formData.title.trim().length > 0 &&
this.formData.justification.trim().length > 0 &&
this.formData.justification.length <= 500
);
}
protected addEvidence(): void {
this.formData.evidenceRefs.push({
type: 'ticket',
title: '',
url: '',
});
}
protected removeEvidence(index: number): void {
this.formData.evidenceRefs.splice(index, 1);
}
protected close(): void {
this.resetForm();
this.closed.emit();
}
protected submit(): void {
if (!this.isValid()) return;
// Build scope
const scope: ExceptionScope = {};
if (this.scopeCves.trim()) {
scope.cves = this.scopeCves.split(',').map(s => s.trim()).filter(s => s);
}
if (this.scopePackages.trim()) {
scope.packages = this.scopePackages.split(',').map(s => s.trim()).filter(s => s);
}
if (this.scopeImages.trim()) {
scope.images = this.scopeImages.split(',').map(s => s.trim()).filter(s => s);
}
const data: CreateExceptionData = {
...this.formData,
scope,
evidenceRefs: this.formData.evidenceRefs.filter(r => r.title && r.url),
};
this.created.emit(data);
this.resetForm();
}
private resetForm(): void {
this.formData = {
title: '',
type: 'vulnerability',
severity: 'high',
justification: '',
scope: {},
ttlDays: 30,
evidenceRefs: [],
};
this.scopeCves = '';
this.scopePackages = '';
this.scopeImages = '';
}
}

View File

@@ -0,0 +1,162 @@
/**
* Evidence Buttons Component
*
* Action buttons for opening evidence panels:
* - Show reachability slice
* - Show VEX sources
* - Show SBOM diff
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-07
*/
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
export type EvidencePanelType = 'reachability' | 'vex' | 'sbom_diff';
export interface EvidencePanelRequest {
type: EvidencePanelType;
artifactDigest?: string;
relatedIds?: string[];
}
@Component({
selector: 'st-evidence-buttons',
standalone: true,
imports: [CommonModule],
template: `
<div class="evidence-buttons" [class.vertical]="layout === 'vertical'">
<button
type="button"
class="evidence-btn reachability"
[disabled]="!reachabilityEnabled"
(click)="openPanel('reachability')"
>
<span class="btn-icon">&#x2192;</span>
<span class="btn-text">Show Reachability</span>
@if (reachabilityCount !== undefined) {
<span class="btn-badge">{{ reachabilityCount }}</span>
}
</button>
<button
type="button"
class="evidence-btn vex"
[disabled]="!vexEnabled"
(click)="openPanel('vex')"
>
<span class="btn-icon">&#x2139;</span>
<span class="btn-text">VEX Sources</span>
@if (vexCount !== undefined) {
<span class="btn-badge">{{ vexCount }}</span>
}
</button>
<button
type="button"
class="evidence-btn sbom"
[disabled]="!sbomDiffEnabled"
(click)="openPanel('sbom_diff')"
>
<span class="btn-icon">&#x2194;</span>
<span class="btn-text">SBOM Diff</span>
@if (sbomDiffCount !== undefined) {
<span class="btn-badge">{{ sbomDiffCount }}</span>
}
</button>
</div>
`,
styles: [`
.evidence-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.evidence-buttons.vertical {
flex-direction: column;
}
.evidence-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: var(--st-color-text-primary, #374151);
background: var(--st-color-surface, #ffffff);
border: 1px solid var(--st-color-border, #d1d5db);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.evidence-btn:hover:not(:disabled) {
border-color: var(--st-color-primary, #3b82f6);
background: var(--st-color-primary-bg, #eff6ff);
}
.evidence-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.evidence-btn.reachability:hover:not(:disabled) {
border-color: var(--st-color-info, #6366f1);
background: var(--st-color-info-bg, #eef2ff);
}
.evidence-btn.vex:hover:not(:disabled) {
border-color: var(--st-color-success, #22c55e);
background: var(--st-color-success-bg, #f0fdf4);
}
.evidence-btn.sbom:hover:not(:disabled) {
border-color: var(--st-color-warning, #f59e0b);
background: var(--st-color-warning-bg, #fffbeb);
}
.btn-icon {
font-size: 14px;
}
.btn-text {
flex: 1;
}
.btn-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 10px;
background: var(--st-color-surface-secondary, #f3f4f6);
}
`],
})
export class EvidenceButtonsComponent {
@Input() artifactDigest?: string;
@Input() layout: 'horizontal' | 'vertical' = 'horizontal';
@Input() reachabilityEnabled = true;
@Input() reachabilityCount?: number;
@Input() vexEnabled = true;
@Input() vexCount?: number;
@Input() sbomDiffEnabled = true;
@Input() sbomDiffCount?: number;
@Input() relatedVulnIds: string[] = [];
@Output() panelRequested = new EventEmitter<EvidencePanelRequest>();
protected openPanel(type: EvidencePanelType): void {
this.panelRequested.emit({
type,
artifactDigest: this.artifactDigest,
relatedIds: this.relatedVulnIds,
});
}
}

View File

@@ -0,0 +1,205 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ExceptionLedgerComponent } from './exception-ledger.component';
import type { Exception, ExceptionLedgerEntry, ExceptionStatus } from '../../../core/api/exception.models';
describe('ExceptionLedgerComponent', () => {
let component: ExceptionLedgerComponent;
let fixture: ComponentFixture<ExceptionLedgerComponent>;
const mockException: Exception = {
id: 'exc-1',
tenantId: 'tenant-1',
title: 'Test Exception',
type: 'vulnerability',
status: 'approved' as ExceptionStatus,
severity: 'high',
justification: 'Test justification',
scope: { cves: ['CVE-2025-1234'] },
createdAt: '2025-12-20T10:00:00Z',
createdBy: 'user-1',
expiresAt: '2026-01-20T10:00:00Z',
riskPointsCovered: 50,
reviewedBy: 'approver-1',
reviewedAt: '2025-12-21T10:00:00Z',
};
const mockLedger: ExceptionLedgerEntry[] = [
{
id: 'entry-1',
exceptionId: 'exc-1',
eventType: 'created',
timestamp: '2025-12-20T10:00:00Z',
actorId: 'user-1',
actorName: 'John Doe',
comment: 'Exception created',
},
{
id: 'entry-2',
exceptionId: 'exc-1',
eventType: 'approved',
timestamp: '2025-12-21T10:00:00Z',
actorId: 'approver-1',
actorName: 'Jane Smith',
comment: 'Approved after review',
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ExceptionLedgerComponent],
}).compileComponents();
fixture = TestBed.createComponent(ExceptionLedgerComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render exception header with title', () => {
component.exception = mockException;
component.ledger = mockLedger;
fixture.detectChanges();
const title = fixture.nativeElement.querySelector('.exception-title');
expect(title.textContent).toContain('Test Exception');
});
it('should display exception status badge', () => {
component.exception = mockException;
component.ledger = mockLedger;
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.status-badge');
expect(badge).toBeTruthy();
expect(badge.classList.contains('approved')).toBe(true);
});
it('should render timeline entries', () => {
component.exception = mockException;
component.ledger = mockLedger;
fixture.detectChanges();
const entries = fixture.nativeElement.querySelectorAll('.timeline-entry');
expect(entries.length).toBe(2);
});
it('should display actor names in timeline', () => {
component.exception = mockException;
component.ledger = mockLedger;
fixture.detectChanges();
const entries = fixture.nativeElement.querySelectorAll('.timeline-entry');
expect(entries[0].textContent).toContain('John Doe');
expect(entries[1].textContent).toContain('Jane Smith');
});
it('should show approve button for pending exceptions when user can approve', () => {
component.exception = { ...mockException, status: 'pending_review' as ExceptionStatus };
component.ledger = mockLedger;
component.canApprove = true;
fixture.detectChanges();
const approveBtn = fixture.nativeElement.querySelector('.approve-btn');
expect(approveBtn).toBeTruthy();
});
it('should hide approve button when user cannot approve', () => {
component.exception = { ...mockException, status: 'pending_review' as ExceptionStatus };
component.ledger = mockLedger;
component.canApprove = false;
fixture.detectChanges();
const approveBtn = fixture.nativeElement.querySelector('.approve-btn');
expect(approveBtn).toBeFalsy();
});
it('should hide approve button for already approved exceptions', () => {
component.exception = mockException; // status is 'approved'
component.ledger = mockLedger;
component.canApprove = true;
fixture.detectChanges();
const approveBtn = fixture.nativeElement.querySelector('.approve-btn');
expect(approveBtn).toBeFalsy();
});
it('should emit approved event when approve button is clicked', () => {
component.exception = { ...mockException, status: 'pending_review' as ExceptionStatus };
component.ledger = mockLedger;
component.canApprove = true;
fixture.detectChanges();
spyOn(component.approved, 'emit');
const approveBtn = fixture.nativeElement.querySelector('.approve-btn');
approveBtn.click();
expect(component.approved.emit).toHaveBeenCalledWith('exc-1');
});
it('should emit rejected event when reject button is clicked', () => {
component.exception = { ...mockException, status: 'pending_review' as ExceptionStatus };
component.ledger = mockLedger;
component.canApprove = true;
fixture.detectChanges();
spyOn(component.rejected, 'emit');
const rejectBtn = fixture.nativeElement.querySelector('.reject-btn');
rejectBtn.click();
expect(component.rejected.emit).toHaveBeenCalledWith('exc-1');
});
it('should display expiry information', () => {
component.exception = mockException;
component.ledger = mockLedger;
fixture.detectChanges();
const expiryInfo = fixture.nativeElement.querySelector('.expiry-info');
expect(expiryInfo).toBeTruthy();
});
it('should show warning for expiring soon exceptions', () => {
const expiringSoon = {
...mockException,
expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days
};
component.exception = expiringSoon;
component.ledger = mockLedger;
fixture.detectChanges();
const expiryWarning = fixture.nativeElement.querySelector('.expiry-warning');
expect(expiryWarning).toBeTruthy();
});
it('should display risk points covered', () => {
component.exception = mockException;
component.ledger = mockLedger;
fixture.detectChanges();
const riskPoints = fixture.nativeElement.querySelector('.risk-points');
expect(riskPoints.textContent).toContain('50');
});
it('should show empty state when no ledger entries', () => {
component.exception = mockException;
component.ledger = [];
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.timeline-empty');
expect(emptyState).toBeTruthy();
});
it('should display scope CVEs', () => {
component.exception = mockException;
component.ledger = mockLedger;
fixture.detectChanges();
const scopeInfo = fixture.nativeElement.querySelector('.scope-info');
expect(scopeInfo.textContent).toContain('CVE-2025-1234');
});
});

View File

@@ -0,0 +1,580 @@
/**
* Exception Ledger Component
*
* Timeline display of exception history:
* - Status changes
* - Expiry dates
* - Owner information
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-12
*/
import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { Exception, ExceptionLedgerEntry, ExceptionStatus } from '../../../core/api/exception.models';
@Component({
selector: 'st-exception-ledger',
standalone: true,
imports: [CommonModule],
template: `
<div class="exception-ledger">
<div class="ledger-header">
<h4 class="ledger-title">Exception History</h4>
<div class="header-actions">
@if (canCreate) {
<button
type="button"
class="create-btn"
(click)="createException.emit()"
>
+ New Exception
</button>
}
</div>
</div>
<!-- Summary stats -->
<div class="ledger-summary">
<div class="summary-item">
<span class="summary-value">{{ activeCount() }}</span>
<span class="summary-label">Active</span>
</div>
<div class="summary-item warning">
<span class="summary-value">{{ pendingCount() }}</span>
<span class="summary-label">Pending</span>
</div>
<div class="summary-item danger">
<span class="summary-value">{{ expiringCount() }}</span>
<span class="summary-label">Expiring Soon</span>
</div>
</div>
<!-- Exception list -->
<div class="exceptions-list">
@for (exception of exceptions; track exception.id) {
<div
class="exception-card"
[class]="exception.status"
[class.expanded]="expandedId() === exception.id"
>
<button
type="button"
class="exception-header"
(click)="toggleExpand(exception.id)"
>
<div class="exception-main">
<span class="status-badge" [class]="exception.status">
{{ statusLabel(exception.status) }}
</span>
<span class="exception-title">{{ exception.title }}</span>
</div>
<div class="exception-meta">
<span class="expiry" [class.soon]="isExpiringSoon(exception)">
{{ formatExpiry(exception) }}
</span>
<span class="expand-icon">
{{ expandedId() === exception.id ? '&#x25B2;' : '&#x25BC;' }}
</span>
</div>
</button>
@if (expandedId() === exception.id) {
<div class="exception-details">
<div class="detail-row">
<span class="detail-label">Type:</span>
<span class="detail-value">{{ exception.type }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Severity:</span>
<span class="detail-value severity" [class]="exception.severity">
{{ exception.severity }}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Requested by:</span>
<span class="detail-value">{{ exception.workflow.requestedBy }}</span>
</div>
@if (exception.workflow.approvedBy) {
<div class="detail-row">
<span class="detail-label">Approved by:</span>
<span class="detail-value">{{ exception.workflow.approvedBy }}</span>
</div>
}
<div class="detail-row full">
<span class="detail-label">Justification:</span>
<p class="justification">{{ exception.justification }}</p>
</div>
<!-- Timeline -->
@if (ledgerEntries.length > 0) {
<div class="timeline">
<h5 class="timeline-title">Timeline</h5>
@for (entry of getEntriesForException(exception.id); track entry.id) {
<div class="timeline-entry">
<div class="timeline-dot" [class]="entry.eventType"></div>
<div class="timeline-content">
<span class="entry-action">{{ eventLabel(entry.eventType) }}</span>
<span class="entry-actor">by {{ entry.actorName ?? entry.actorId }}</span>
<span class="entry-time">{{ formatTime(entry.timestamp) }}</span>
</div>
</div>
}
</div>
}
<!-- Actions -->
<div class="exception-actions">
@if (exception.status === 'pending_review' && canApprove) {
<button
type="button"
class="action-btn approve"
(click)="approveException.emit(exception)"
>
Approve
</button>
<button
type="button"
class="action-btn reject"
(click)="rejectException.emit(exception)"
>
Reject
</button>
}
@if (exception.status === 'approved' && canRevoke) {
<button
type="button"
class="action-btn revoke"
(click)="revokeException.emit(exception)"
>
Revoke
</button>
}
<button
type="button"
class="action-btn view"
(click)="viewException.emit(exception)"
>
View Details
</button>
</div>
</div>
}
</div>
}
@if (exceptions.length === 0) {
<div class="empty-state">
<p>No exceptions found</p>
@if (canCreate) {
<button
type="button"
class="create-btn-empty"
(click)="createException.emit()"
>
Create Exception
</button>
}
</div>
}
</div>
</div>
`,
styles: [`
.exception-ledger {
background: var(--st-color-surface, #ffffff);
border: 1px solid var(--st-color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.ledger-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--st-color-surface-secondary, #f9fafb);
border-bottom: 1px solid var(--st-color-border, #e5e7eb);
}
.ledger-title {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.create-btn {
font-size: 12px;
font-weight: 500;
padding: 6px 12px;
color: var(--st-color-primary, #3b82f6);
background: none;
border: 1px solid var(--st-color-primary, #3b82f6);
border-radius: 4px;
cursor: pointer;
}
.create-btn:hover {
background: var(--st-color-primary, #3b82f6);
color: white;
}
.ledger-summary {
display: flex;
gap: 16px;
padding: 12px 16px;
background: var(--st-color-surface-secondary, #f9fafb);
border-bottom: 1px solid var(--st-color-border, #e5e7eb);
}
.summary-item {
text-align: center;
}
.summary-value {
display: block;
font-size: 20px;
font-weight: 600;
color: var(--st-color-text-primary, #111827);
}
.summary-item.warning .summary-value {
color: var(--st-color-warning, #f59e0b);
}
.summary-item.danger .summary-value {
color: var(--st-color-error, #ef4444);
}
.summary-label {
font-size: 11px;
color: var(--st-color-text-secondary, #6b7280);
}
.exceptions-list {
max-height: 400px;
overflow-y: auto;
}
.exception-card {
border-bottom: 1px solid var(--st-color-border-subtle, #f3f4f6);
}
.exception-card:last-child {
border-bottom: none;
}
.exception-card.approved {
border-left: 3px solid var(--st-color-success, #22c55e);
}
.exception-card.pending_review {
border-left: 3px solid var(--st-color-warning, #f59e0b);
}
.exception-card.rejected, .exception-card.revoked {
border-left: 3px solid var(--st-color-error, #ef4444);
}
.exception-card.expired {
border-left: 3px solid var(--st-color-text-tertiary, #9ca3af);
}
.exception-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 12px 16px;
text-align: left;
background: none;
border: none;
cursor: pointer;
}
.exception-header:hover {
background: var(--st-color-surface-secondary, #f9fafb);
}
.exception-main {
display: flex;
align-items: center;
gap: 10px;
}
.status-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 3px;
}
.status-badge.approved { background: var(--st-color-success-bg, #dcfce7); color: var(--st-color-success-dark, #166534); }
.status-badge.pending_review { background: var(--st-color-warning-bg, #fef3c7); color: var(--st-color-warning-dark, #92400e); }
.status-badge.rejected, .status-badge.revoked { background: var(--st-color-error-bg, #fee2e2); color: var(--st-color-error-dark, #991b1b); }
.status-badge.expired, .status-badge.draft { background: var(--st-color-surface-secondary, #f3f4f6); color: var(--st-color-text-secondary, #6b7280); }
.exception-title {
font-size: 13px;
font-weight: 500;
color: var(--st-color-text-primary, #111827);
}
.exception-meta {
display: flex;
align-items: center;
gap: 12px;
}
.expiry {
font-size: 11px;
color: var(--st-color-text-secondary, #6b7280);
}
.expiry.soon {
color: var(--st-color-error, #ef4444);
font-weight: 500;
}
.expand-icon {
font-size: 10px;
color: var(--st-color-text-tertiary, #9ca3af);
}
.exception-details {
padding: 0 16px 16px 16px;
background: var(--st-color-surface-secondary, #f9fafb);
}
.detail-row {
display: flex;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
}
.detail-row.full {
flex-direction: column;
}
.detail-label {
color: var(--st-color-text-secondary, #6b7280);
min-width: 80px;
}
.detail-value {
color: var(--st-color-text-primary, #111827);
}
.detail-value.severity.critical { color: var(--st-color-error, #ef4444); }
.detail-value.severity.high { color: var(--st-color-warning, #f59e0b); }
.detail-value.severity.medium { color: var(--st-color-warning-dark, #d97706); }
.detail-value.severity.low { color: var(--st-color-info, #6366f1); }
.justification {
margin: 4px 0 0 0;
font-size: 12px;
color: var(--st-color-text-primary, #111827);
line-height: 1.4;
}
.timeline {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--st-color-border, #e5e7eb);
}
.timeline-title {
margin: 0 0 8px 0;
font-size: 12px;
font-weight: 600;
color: var(--st-color-text-secondary, #6b7280);
}
.timeline-entry {
display: flex;
gap: 10px;
margin-bottom: 8px;
}
.timeline-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 4px;
background: var(--st-color-border, #d1d5db);
}
.timeline-dot.created { background: var(--st-color-info, #6366f1); }
.timeline-dot.approved { background: var(--st-color-success, #22c55e); }
.timeline-dot.rejected { background: var(--st-color-error, #ef4444); }
.timeline-dot.expired { background: var(--st-color-text-tertiary, #9ca3af); }
.timeline-content {
flex: 1;
font-size: 11px;
}
.entry-action {
font-weight: 500;
color: var(--st-color-text-primary, #111827);
}
.entry-actor {
color: var(--st-color-text-secondary, #6b7280);
margin-left: 4px;
}
.entry-time {
display: block;
color: var(--st-color-text-tertiary, #9ca3af);
}
.exception-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.action-btn {
font-size: 12px;
font-weight: 500;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.action-btn.approve {
background: var(--st-color-success, #22c55e);
color: white;
border: none;
}
.action-btn.reject {
background: none;
color: var(--st-color-error, #ef4444);
border: 1px solid var(--st-color-error, #ef4444);
}
.action-btn.revoke {
background: none;
color: var(--st-color-warning, #f59e0b);
border: 1px solid var(--st-color-warning, #f59e0b);
}
.action-btn.view {
background: none;
color: var(--st-color-text-secondary, #6b7280);
border: 1px solid var(--st-color-border, #d1d5db);
}
.empty-state {
padding: 32px;
text-align: center;
color: var(--st-color-text-secondary, #6b7280);
}
.create-btn-empty {
margin-top: 12px;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: white;
background: var(--st-color-primary, #3b82f6);
border: none;
border-radius: 6px;
cursor: pointer;
}
`],
})
export class ExceptionLedgerComponent {
@Input() exceptions: Exception[] = [];
@Input() ledgerEntries: ExceptionLedgerEntry[] = [];
@Input() canCreate = true;
@Input() canApprove = false;
@Input() canRevoke = false;
@Output() createException = new EventEmitter<void>();
@Output() viewException = new EventEmitter<Exception>();
@Output() approveException = new EventEmitter<Exception>();
@Output() rejectException = new EventEmitter<Exception>();
@Output() revokeException = new EventEmitter<Exception>();
protected expandedId = signal<string | null>(null);
protected activeCount = computed(() =>
this.exceptions.filter(e => e.status === 'approved').length
);
protected pendingCount = computed(() =>
this.exceptions.filter(e => e.status === 'pending_review').length
);
protected expiringCount = computed(() =>
this.exceptions.filter(e => this.isExpiringSoon(e)).length
);
protected toggleExpand(id: string): void {
this.expandedId.set(this.expandedId() === id ? null : id);
}
protected statusLabel(status: ExceptionStatus): string {
const labels: Record<ExceptionStatus, string> = {
draft: 'Draft',
pending_review: 'Pending',
approved: 'Active',
rejected: 'Rejected',
expired: 'Expired',
revoked: 'Revoked',
};
return labels[status];
}
protected eventLabel(eventType: ExceptionLedgerEntry['eventType']): string {
const labels: Record<ExceptionLedgerEntry['eventType'], string> = {
created: 'Created',
approved: 'Approved',
rejected: 'Rejected',
expired: 'Expired',
revoked: 'Revoked',
extended: 'Extended',
modified: 'Modified',
};
return labels[eventType];
}
protected isExpiringSoon(exception: Exception): boolean {
return exception.status === 'approved' && exception.timebox.remainingDays <= 7;
}
protected formatExpiry(exception: Exception): string {
if (exception.status === 'expired') return 'Expired';
if (exception.status !== 'approved') return '';
const days = exception.timebox.remainingDays;
if (days <= 0) return 'Expires today';
if (days === 1) return 'Expires tomorrow';
return `${days} days left`;
}
protected formatTime(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
protected getEntriesForException(exceptionId: string): ExceptionLedgerEntry[] {
return this.ledgerEntries
.filter(e => e.exceptionId === exceptionId)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
}

View File

@@ -0,0 +1,20 @@
/**
* Risk Dashboard Components
*
* Barrel export for all risk dashboard components.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
*/
export { BudgetBurnupChartComponent, type ChartDimensions } from './budget-burnup-chart.component';
export { BudgetKpiTilesComponent, type KpiTile } from './budget-kpi-tiles.component';
export { VerdictBadgeComponent } from './verdict-badge.component';
export { VerdictWhySummaryComponent, type EvidenceRequest, type EvidenceType } from './verdict-why-summary.component';
export { EvidenceButtonsComponent } from './evidence-buttons.component';
export { ReachabilitySliceComponent } from './reachability-slice.component';
export { VexSourcesPanelComponent } from './vex-sources-panel.component';
export { SbomDiffPanelComponent } from './sbom-diff-panel.component';
export { SideBySideDiffComponent } from './side-by-side-diff.component';
export { ExceptionLedgerComponent } from './exception-ledger.component';
export { CreateExceptionModalComponent } from './create-exception-modal.component';
export { RiskDashboardLayoutComponent, type DashboardViewMode } from './risk-dashboard-layout.component';

View File

@@ -0,0 +1,175 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReachabilitySliceComponent, CallPathNode } from './reachability-slice.component';
describe('ReachabilitySliceComponent', () => {
let component: ReachabilitySliceComponent;
let fixture: ComponentFixture<ReachabilitySliceComponent>;
const mockPath: CallPathNode[] = [
{
id: 'entry',
label: 'main()',
type: 'entry',
file: 'src/main.ts',
line: 1,
},
{
id: 'call-1',
label: 'processRequest()',
type: 'call',
file: 'src/handler.ts',
line: 42,
},
{
id: 'call-2',
label: 'parseInput()',
type: 'call',
file: 'src/parser.ts',
line: 15,
},
{
id: 'sink',
label: 'vulnerableFunc()',
type: 'sink',
file: 'node_modules/vuln-lib/index.js',
line: 100,
vulnId: 'CVE-2025-1234',
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReachabilitySliceComponent],
}).compileComponents();
fixture = TestBed.createComponent(ReachabilitySliceComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render path nodes', () => {
component.path = mockPath;
fixture.detectChanges();
const nodes = fixture.nativeElement.querySelectorAll('.path-node');
expect(nodes.length).toBe(4);
});
it('should display node labels', () => {
component.path = mockPath;
fixture.detectChanges();
const labels = fixture.nativeElement.querySelectorAll('.node-label');
expect(labels[0].textContent).toContain('main()');
expect(labels[3].textContent).toContain('vulnerableFunc()');
});
it('should apply entry class to entry node', () => {
component.path = mockPath;
fixture.detectChanges();
const nodes = fixture.nativeElement.querySelectorAll('.path-node');
expect(nodes[0].classList.contains('entry')).toBe(true);
});
it('should apply sink class to sink node', () => {
component.path = mockPath;
fixture.detectChanges();
const nodes = fixture.nativeElement.querySelectorAll('.path-node');
expect(nodes[3].classList.contains('sink')).toBe(true);
});
it('should render connecting lines between nodes', () => {
component.path = mockPath;
fixture.detectChanges();
const connectors = fixture.nativeElement.querySelectorAll('.path-connector');
expect(connectors.length).toBe(3); // n-1 connectors for n nodes
});
it('should display file locations', () => {
component.path = mockPath;
fixture.detectChanges();
const locations = fixture.nativeElement.querySelectorAll('.node-location');
expect(locations[0].textContent).toContain('src/main.ts');
expect(locations[0].textContent).toContain('1');
});
it('should show CVE badge on sink node', () => {
component.path = mockPath;
fixture.detectChanges();
const cveBadge = fixture.nativeElement.querySelector('.vuln-badge');
expect(cveBadge).toBeTruthy();
expect(cveBadge.textContent).toContain('CVE-2025-1234');
});
it('should emit nodeClicked when node is clicked', () => {
component.path = mockPath;
fixture.detectChanges();
spyOn(component.nodeClicked, 'emit');
const nodes = fixture.nativeElement.querySelectorAll('.path-node');
nodes[1].click();
expect(component.nodeClicked.emit).toHaveBeenCalledWith(mockPath[1]);
});
it('should show empty state when path is empty', () => {
component.path = [];
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState).toBeTruthy();
});
it('should apply compact class when compact mode is enabled', () => {
component.path = mockPath;
component.compact = true;
fixture.detectChanges();
const container = fixture.nativeElement.querySelector('.reachability-slice');
expect(container.classList.contains('compact')).toBe(true);
});
it('should hide file locations in compact mode', () => {
component.path = mockPath;
component.compact = true;
fixture.detectChanges();
const locations = fixture.nativeElement.querySelectorAll('.node-location');
expect(locations.length).toBe(0);
});
it('should show path depth indicator', () => {
component.path = mockPath;
fixture.detectChanges();
const depthIndicator = fixture.nativeElement.querySelector('.path-depth');
expect(depthIndicator.textContent).toContain('4');
});
it('should highlight active node when highlighted prop is set', () => {
component.path = mockPath;
component.highlightedNodeId = 'call-1';
fixture.detectChanges();
const nodes = fixture.nativeElement.querySelectorAll('.path-node');
expect(nodes[1].classList.contains('highlighted')).toBe(true);
});
it('should show direction arrows on connectors', () => {
component.path = mockPath;
fixture.detectChanges();
const arrows = fixture.nativeElement.querySelectorAll('.connector-arrow');
expect(arrows.length).toBe(3);
});
});

View File

@@ -0,0 +1,337 @@
/**
* Reachability Slice Component
*
* Mini-graph visualizing entry->sink call paths.
* Shows reachable vulnerable code paths with
* expandable node details.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-08
*/
import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface ReachabilityNode {
id: string;
symbol: string;
file: string;
line: number;
type: 'entrypoint' | 'intermediate' | 'sink';
isVulnerable?: boolean;
vulnId?: string;
}
export interface ReachabilityPath {
id: string;
nodes: ReachabilityNode[];
confidence: 'confirmed' | 'likely' | 'possible';
vulnId: string;
summary: string;
}
@Component({
selector: 'st-reachability-slice',
standalone: true,
imports: [CommonModule],
template: `
<div class="reachability-slice">
<div class="slice-header">
<h4 class="slice-title">Reachability Paths</h4>
<span class="path-count">{{ paths.length }} path{{ paths.length !== 1 ? 's' : '' }}</span>
</div>
@if (paths.length === 0) {
<div class="empty-state">
<span class="empty-icon">&#x2713;</span>
<span class="empty-text">No reachable paths found</span>
</div>
} @else {
<div class="paths-list">
@for (path of displayPaths(); track path.id; let i = $index) {
<div
class="path-card"
[class.expanded]="expandedPath() === path.id"
[class.confirmed]="path.confidence === 'confirmed'"
[class.likely]="path.confidence === 'likely'"
>
<button
type="button"
class="path-header"
(click)="togglePath(path.id)"
>
<span class="path-confidence">{{ path.confidence }}</span>
<span class="path-summary">{{ path.summary }}</span>
<span class="path-vuln">{{ path.vulnId }}</span>
<span class="expand-icon">
{{ expandedPath() === path.id ? '&#x25B2;' : '&#x25BC;' }}
</span>
</button>
@if (expandedPath() === path.id) {
<div class="path-nodes">
@for (node of path.nodes; track node.id; let j = $index) {
<div class="node-row" [class]="node.type">
<div class="node-connector">
@if (j === 0) {
<span class="connector-start">&#x25CF;</span>
} @else {
<span class="connector-line"></span>
<span class="connector-arrow">&#x25BC;</span>
}
</div>
<div class="node-content">
<code class="node-symbol">{{ node.symbol }}</code>
<span class="node-location">{{ formatLocation(node) }}</span>
@if (node.isVulnerable) {
<span class="vuln-badge">{{ node.vulnId }}</span>
}
</div>
</div>
}
</div>
}
</div>
}
</div>
@if (paths.length > maxPaths) {
<button
type="button"
class="show-all-btn"
(click)="toggleShowAll()"
>
{{ showAll() ? 'Show fewer' : 'Show all ' + paths.length + ' paths' }}
</button>
}
}
</div>
`,
styles: [`
.reachability-slice {
background: var(--st-color-surface, #ffffff);
border: 1px solid var(--st-color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.slice-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--st-color-surface-secondary, #f9fafb);
border-bottom: 1px solid var(--st-color-border, #e5e7eb);
}
.slice-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--st-color-text-primary, #111827);
}
.path-count {
font-size: 12px;
color: var(--st-color-text-secondary, #6b7280);
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
color: var(--st-color-text-secondary, #6b7280);
}
.empty-icon {
font-size: 18px;
color: var(--st-color-success, #22c55e);
}
.paths-list {
padding: 8px;
}
.path-card {
margin-bottom: 8px;
border: 1px solid var(--st-color-border, #e5e7eb);
border-radius: 6px;
overflow: hidden;
}
.path-card.confirmed {
border-left: 3px solid var(--st-color-error, #ef4444);
}
.path-card.likely {
border-left: 3px solid var(--st-color-warning, #f59e0b);
}
.path-header {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
font-size: 13px;
text-align: left;
background: none;
border: none;
cursor: pointer;
transition: background 0.15s;
}
.path-header:hover {
background: var(--st-color-surface-secondary, #f9fafb);
}
.path-confidence {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 3px;
background: var(--st-color-surface-secondary, #f3f4f6);
}
.path-card.confirmed .path-confidence {
background: var(--st-color-error-bg, #fef2f2);
color: var(--st-color-error, #ef4444);
}
.path-card.likely .path-confidence {
background: var(--st-color-warning-bg, #fffbeb);
color: var(--st-color-warning-dark, #92400e);
}
.path-summary {
flex: 1;
color: var(--st-color-text-primary, #111827);
}
.path-vuln {
font-size: 11px;
font-family: var(--st-font-mono, monospace);
color: var(--st-color-text-secondary, #6b7280);
}
.expand-icon {
font-size: 10px;
color: var(--st-color-text-tertiary, #9ca3af);
}
.path-nodes {
padding: 8px 12px 12px 12px;
background: var(--st-color-surface-secondary, #f9fafb);
}
.node-row {
display: flex;
gap: 12px;
}
.node-connector {
display: flex;
flex-direction: column;
align-items: center;
width: 16px;
}
.connector-start {
font-size: 10px;
color: var(--st-color-success, #22c55e);
}
.connector-line {
flex: 1;
width: 2px;
background: var(--st-color-border, #d1d5db);
}
.connector-arrow {
font-size: 8px;
color: var(--st-color-text-tertiary, #9ca3af);
}
.node-content {
flex: 1;
padding: 6px 0;
}
.node-symbol {
display: block;
font-size: 12px;
color: var(--st-color-text-primary, #111827);
}
.node-location {
font-size: 11px;
color: var(--st-color-text-secondary, #6b7280);
}
.vuln-badge {
display: inline-block;
margin-left: 8px;
font-size: 10px;
font-weight: 600;
padding: 1px 4px;
border-radius: 3px;
background: var(--st-color-error-bg, #fef2f2);
color: var(--st-color-error, #ef4444);
}
.node-row.sink .node-symbol {
color: var(--st-color-error, #ef4444);
}
.node-row.entrypoint .connector-start {
color: var(--st-color-info, #6366f1);
}
.show-all-btn {
display: block;
width: calc(100% - 16px);
margin: 0 8px 8px 8px;
padding: 8px;
font-size: 13px;
font-weight: 500;
color: var(--st-color-primary, #3b82f6);
background: none;
border: 1px dashed var(--st-color-border, #d1d5db);
border-radius: 4px;
cursor: pointer;
}
.show-all-btn:hover {
border-color: var(--st-color-primary, #3b82f6);
}
`],
})
export class ReachabilitySliceComponent {
@Input() paths: ReachabilityPath[] = [];
@Input() maxPaths = 3;
@Output() pathSelected = new EventEmitter<ReachabilityPath>();
protected showAll = signal(false);
protected expandedPath = signal<string | null>(null);
protected displayPaths = computed(() => {
return this.showAll() ? this.paths : this.paths.slice(0, this.maxPaths);
});
protected togglePath(pathId: string): void {
this.expandedPath.set(this.expandedPath() === pathId ? null : pathId);
}
protected toggleShowAll(): void {
this.showAll.set(!this.showAll());
}
protected formatLocation(node: ReachabilityNode): string {
const file = node.file.split('/').pop() ?? node.file;
return `${file}:${node.line}`;
}
}

View File

@@ -0,0 +1,168 @@
/**
* Risk Dashboard Layout Component
*
* Responsive container for risk dashboard with
* adaptive grid layout for tablet and desktop.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-15
*/
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
export type DashboardViewMode = 'overview' | 'detail' | 'compare';
@Component({
selector: 'st-risk-dashboard-layout',
standalone: true,
imports: [CommonModule],
template: `
<div class="dashboard-layout" [class]="viewMode">
<!-- Header section -->
<header class="dashboard-header">
<ng-content select="[slot=header]"></ng-content>
</header>
<!-- Main content area -->
<div class="dashboard-body">
<!-- Primary content (left/top) -->
<main class="dashboard-main">
<ng-content select="[slot=main]"></ng-content>
</main>
<!-- Secondary content (right/bottom) -->
<aside class="dashboard-aside">
<ng-content select="[slot=aside]"></ng-content>
</aside>
</div>
<!-- Footer section -->
<footer class="dashboard-footer">
<ng-content select="[slot=footer]"></ng-content>
</footer>
<!-- Default slot for flexible content -->
<ng-content></ng-content>
</div>
`,
styles: [`
:host {
display: block;
width: 100%;
}
.dashboard-layout {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
min-height: 100%;
}
.dashboard-header {
flex-shrink: 0;
}
.dashboard-body {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
}
.dashboard-main {
flex: 1;
min-width: 0;
}
.dashboard-aside {
flex-shrink: 0;
}
.dashboard-footer {
flex-shrink: 0;
margin-top: auto;
}
/* Tablet (768px+) */
@media (min-width: 768px) {
.dashboard-layout {
padding: 20px;
gap: 20px;
}
.dashboard-body {
flex-direction: row;
gap: 20px;
}
.dashboard-main {
flex: 2;
}
.dashboard-aside {
flex: 1;
max-width: 360px;
}
}
/* Desktop (1024px+) */
@media (min-width: 1024px) {
.dashboard-layout {
padding: 24px;
gap: 24px;
}
.dashboard-body {
gap: 24px;
}
.dashboard-aside {
max-width: 400px;
}
}
/* Large Desktop (1440px+) */
@media (min-width: 1440px) {
.dashboard-layout {
padding: 32px;
max-width: 1600px;
margin: 0 auto;
}
.dashboard-aside {
max-width: 480px;
}
}
/* Detail view mode */
.dashboard-layout.detail .dashboard-body {
flex-direction: column;
}
.dashboard-layout.detail .dashboard-aside {
max-width: none;
}
/* Compare view mode */
.dashboard-layout.compare .dashboard-body {
flex-direction: column;
}
@media (min-width: 1024px) {
.dashboard-layout.compare .dashboard-body {
flex-direction: row;
}
.dashboard-layout.compare .dashboard-main,
.dashboard-layout.compare .dashboard-aside {
flex: 1;
max-width: none;
}
}
`],
})
export class RiskDashboardLayoutComponent {
@Input() viewMode: DashboardViewMode = 'overview';
}

View File

@@ -0,0 +1,223 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SbomDiffPanelComponent, PackageChange, PackageChangeType } from './sbom-diff-panel.component';
describe('SbomDiffPanelComponent', () => {
let component: SbomDiffPanelComponent;
let fixture: ComponentFixture<SbomDiffPanelComponent>;
const mockChanges: PackageChange[] = [
{
name: 'lodash',
ecosystem: 'npm',
changeType: 'added' as PackageChangeType,
afterVersion: '4.17.21',
vulnsAfter: 0,
riskDelta: 0,
},
{
name: 'express',
ecosystem: 'npm',
changeType: 'upgraded' as PackageChangeType,
beforeVersion: '4.17.1',
afterVersion: '4.18.0',
vulnsBefore: 2,
vulnsAfter: 0,
riskDelta: -20,
},
{
name: 'leftpad',
ecosystem: 'npm',
changeType: 'removed' as PackageChangeType,
beforeVersion: '1.0.0',
vulnsBefore: 1,
riskDelta: -10,
},
{
name: 'axios',
ecosystem: 'npm',
changeType: 'downgraded' as PackageChangeType,
beforeVersion: '1.5.0',
afterVersion: '1.4.0',
vulnsBefore: 0,
vulnsAfter: 1,
riskDelta: 15,
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SbomDiffPanelComponent],
}).compileComponents();
fixture = TestBed.createComponent(SbomDiffPanelComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render panel header with title', () => {
component.changes = mockChanges;
fixture.detectChanges();
const title = fixture.nativeElement.querySelector('.panel-title');
expect(title.textContent).toContain('SBOM Changes');
});
it('should display summary badges', () => {
component.changes = mockChanges;
fixture.detectChanges();
const badges = fixture.nativeElement.querySelectorAll('.summary-badge');
expect(badges.length).toBeGreaterThan(0);
});
it('should render filter tabs', () => {
component.changes = mockChanges;
fixture.detectChanges();
const tabs = fixture.nativeElement.querySelectorAll('.filter-tab');
expect(tabs.length).toBe(4); // All, Added, Removed, Changed
});
it('should show all changes by default', () => {
component.changes = mockChanges;
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.change-row');
expect(rows.length).toBe(4);
});
it('should filter to added only when added tab clicked', () => {
component.changes = mockChanges;
fixture.detectChanges();
const tabs = fixture.nativeElement.querySelectorAll('.filter-tab');
tabs[1].click(); // Added tab
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.change-row');
expect(rows.length).toBe(1);
expect(rows[0].classList.contains('added')).toBe(true);
});
it('should filter to removed only when removed tab clicked', () => {
component.changes = mockChanges;
fixture.detectChanges();
const tabs = fixture.nativeElement.querySelectorAll('.filter-tab');
tabs[2].click(); // Removed tab
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.change-row');
expect(rows.length).toBe(1);
expect(rows[0].classList.contains('removed')).toBe(true);
});
it('should filter to changed (upgraded/downgraded) when changed tab clicked', () => {
component.changes = mockChanges;
fixture.detectChanges();
const tabs = fixture.nativeElement.querySelectorAll('.filter-tab');
tabs[3].click(); // Changed tab
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.change-row');
expect(rows.length).toBe(2); // upgraded and downgraded
});
it('should display package names', () => {
component.changes = mockChanges;
fixture.detectChanges();
const packageNames = fixture.nativeElement.querySelectorAll('.package-name code');
expect(packageNames[0].textContent).toContain('lodash');
expect(packageNames[1].textContent).toContain('express');
});
it('should display ecosystem badges', () => {
component.changes = mockChanges;
fixture.detectChanges();
const ecosystemBadges = fixture.nativeElement.querySelectorAll('.ecosystem-badge');
expect(ecosystemBadges.length).toBe(4);
expect(ecosystemBadges[0].textContent).toContain('npm');
});
it('should display version transitions for upgraded packages', () => {
component.changes = mockChanges;
fixture.detectChanges();
const versionInfos = fixture.nativeElement.querySelectorAll('.version-info');
const upgradeRow = versionInfos[1]; // express
expect(upgradeRow.textContent).toContain('4.17.1');
expect(upgradeRow.textContent).toContain('4.18.0');
});
it('should display change type icons', () => {
component.changes = mockChanges;
fixture.detectChanges();
const icons = fixture.nativeElement.querySelectorAll('.icon');
expect(icons[0].classList.contains('added')).toBe(true);
expect(icons[1].classList.contains('upgraded')).toBe(true);
expect(icons[2].classList.contains('removed')).toBe(true);
expect(icons[3].classList.contains('downgraded')).toBe(true);
});
it('should display vulnerability counts', () => {
component.changes = mockChanges;
fixture.detectChanges();
const vulnCounts = fixture.nativeElement.querySelectorAll('.vuln-count');
expect(vulnCounts.length).toBeGreaterThan(0);
});
it('should display risk delta with positive/negative styling', () => {
component.changes = mockChanges;
fixture.detectChanges();
const riskDeltas = fixture.nativeElement.querySelectorAll('.risk-delta');
const positiveDeltas = fixture.nativeElement.querySelectorAll('.risk-delta.positive');
const negativeDeltas = fixture.nativeElement.querySelectorAll('.risk-delta.negative');
expect(positiveDeltas.length).toBeGreaterThan(0); // Risk reduction
expect(negativeDeltas.length).toBeGreaterThan(0); // Risk increase
});
it('should show total risk impact in summary', () => {
component.changes = mockChanges;
fixture.detectChanges();
const riskSummary = fixture.nativeElement.querySelector('.risk-summary');
expect(riskSummary).toBeTruthy();
// Total: 0 + (-20) + (-10) + 15 = -15
expect(riskSummary.textContent).toContain('-15');
});
it('should apply positive styling to risk summary when overall risk decreased', () => {
component.changes = mockChanges;
fixture.detectChanges();
const riskSummary = fixture.nativeElement.querySelector('.risk-summary');
expect(riskSummary.classList.contains('positive')).toBe(true);
});
it('should show empty state when no changes in selected filter', () => {
component.changes = [];
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState.textContent).toContain('No changes');
});
it('should highlight has-vulns class for packages with vulnerabilities', () => {
component.changes = mockChanges;
fixture.detectChanges();
const vulnCounts = fixture.nativeElement.querySelectorAll('.vuln-count.has-vulns');
expect(vulnCounts.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,401 @@
/**
* SBOM Diff Panel Component
*
* Side-by-side display of packages added, removed, and changed
* between two SBOM versions.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-10
*/
import { Component, Input, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
export type PackageChangeType = 'added' | 'removed' | 'upgraded' | 'downgraded' | 'unchanged';
export interface PackageChange {
name: string;
ecosystem: string;
changeType: PackageChangeType;
beforeVersion?: string;
afterVersion?: string;
vulnsBefore?: number;
vulnsAfter?: number;
riskDelta?: number;
}
export interface SbomDiffSummary {
added: number;
removed: number;
upgraded: number;
downgraded: number;
unchanged: number;
totalRiskDelta: number;
}
@Component({
selector: 'st-sbom-diff-panel',
standalone: true,
imports: [CommonModule],
template: `
<div class="sbom-diff-panel">
<div class="panel-header">
<h4 class="panel-title">SBOM Changes</h4>
<div class="diff-summary">
@if (summary().added > 0) {
<span class="summary-badge added">+{{ summary().added }}</span>
}
@if (summary().removed > 0) {
<span class="summary-badge removed">-{{ summary().removed }}</span>
}
@if (summary().upgraded > 0) {
<span class="summary-badge upgraded">&#x2191;{{ summary().upgraded }}</span>
}
@if (summary().downgraded > 0) {
<span class="summary-badge downgraded">&#x2193;{{ summary().downgraded }}</span>
}
</div>
</div>
<!-- Filter tabs -->
<div class="filter-tabs">
<button
type="button"
class="filter-tab"
[class.active]="activeFilter() === 'all'"
(click)="setFilter('all')"
>
All ({{ changes.length }})
</button>
<button
type="button"
class="filter-tab added"
[class.active]="activeFilter() === 'added'"
(click)="setFilter('added')"
>
Added ({{ summary().added }})
</button>
<button
type="button"
class="filter-tab removed"
[class.active]="activeFilter() === 'removed'"
(click)="setFilter('removed')"
>
Removed ({{ summary().removed }})
</button>
<button
type="button"
class="filter-tab changed"
[class.active]="activeFilter() === 'changed'"
(click)="setFilter('changed')"
>
Changed ({{ summary().upgraded + summary().downgraded }})
</button>
</div>
<!-- Changes list -->
<div class="changes-list">
@for (change of filteredChanges(); track change.name) {
<div class="change-row" [class]="change.changeType">
<div class="change-icon">
@switch (change.changeType) {
@case ('added') { <span class="icon added">+</span> }
@case ('removed') { <span class="icon removed">-</span> }
@case ('upgraded') { <span class="icon upgraded">&#x2191;</span> }
@case ('downgraded') { <span class="icon downgraded">&#x2193;</span> }
}
</div>
<div class="package-info">
<div class="package-name">
<code>{{ change.name }}</code>
<span class="ecosystem-badge">{{ change.ecosystem }}</span>
</div>
<div class="version-info">
@if (change.beforeVersion && change.afterVersion) {
<span class="version before">{{ change.beforeVersion }}</span>
<span class="version-arrow">&#x2192;</span>
<span class="version after">{{ change.afterVersion }}</span>
} @else if (change.afterVersion) {
<span class="version after">{{ change.afterVersion }}</span>
} @else if (change.beforeVersion) {
<span class="version before">{{ change.beforeVersion }}</span>
}
</div>
</div>
<div class="vuln-delta">
@if (change.vulnsAfter !== undefined || change.vulnsBefore !== undefined) {
<span class="vuln-count" [class.has-vulns]="(change.vulnsAfter ?? 0) > 0">
{{ change.vulnsAfter ?? 0 }} vuln{{ (change.vulnsAfter ?? 0) !== 1 ? 's' : '' }}
</span>
}
@if (change.riskDelta !== undefined && change.riskDelta !== 0) {
<span
class="risk-delta"
[class.positive]="change.riskDelta < 0"
[class.negative]="change.riskDelta > 0"
>
{{ change.riskDelta > 0 ? '+' : '' }}{{ change.riskDelta }}
</span>
}
</div>
</div>
}
@if (filteredChanges().length === 0) {
<div class="empty-state">
No changes in this category
</div>
}
</div>
<!-- Risk summary -->
@if (summary().totalRiskDelta !== 0) {
<div class="risk-summary" [class.positive]="summary().totalRiskDelta < 0" [class.negative]="summary().totalRiskDelta > 0">
<span class="risk-label">Total Risk Impact:</span>
<span class="risk-value">
{{ summary().totalRiskDelta > 0 ? '+' : '' }}{{ summary().totalRiskDelta }} pts
</span>
</div>
}
</div>
`,
styles: [`
.sbom-diff-panel {
background: var(--st-color-surface, #ffffff);
border: 1px solid var(--st-color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--st-color-surface-secondary, #f9fafb);
border-bottom: 1px solid var(--st-color-border, #e5e7eb);
}
.panel-title {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.diff-summary {
display: flex;
gap: 6px;
}
.summary-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
}
.summary-badge.added { background: var(--st-color-success-bg, #dcfce7); color: var(--st-color-success-dark, #166534); }
.summary-badge.removed { background: var(--st-color-error-bg, #fee2e2); color: var(--st-color-error-dark, #991b1b); }
.summary-badge.upgraded { background: var(--st-color-info-bg, #e0e7ff); color: var(--st-color-info-dark, #3730a3); }
.summary-badge.downgraded { background: var(--st-color-warning-bg, #fef3c7); color: var(--st-color-warning-dark, #92400e); }
.filter-tabs {
display: flex;
border-bottom: 1px solid var(--st-color-border, #e5e7eb);
}
.filter-tab {
flex: 1;
padding: 10px;
font-size: 12px;
font-weight: 500;
color: var(--st-color-text-secondary, #6b7280);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s;
}
.filter-tab:hover {
background: var(--st-color-surface-secondary, #f9fafb);
}
.filter-tab.active {
color: var(--st-color-primary, #3b82f6);
border-bottom-color: var(--st-color-primary, #3b82f6);
}
.changes-list {
max-height: 300px;
overflow-y: auto;
}
.change-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--st-color-border-subtle, #f3f4f6);
}
.change-row:last-child {
border-bottom: none;
}
.change-row.added { background: var(--st-color-success-bg, #f0fdf4); }
.change-row.removed { background: var(--st-color-error-bg, #fef2f2); }
.change-icon {
width: 24px;
text-align: center;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: 14px;
font-weight: bold;
border-radius: 4px;
}
.icon.added { background: var(--st-color-success, #22c55e); color: white; }
.icon.removed { background: var(--st-color-error, #ef4444); color: white; }
.icon.upgraded { background: var(--st-color-info, #6366f1); color: white; }
.icon.downgraded { background: var(--st-color-warning, #f59e0b); color: white; }
.package-info {
flex: 1;
}
.package-name {
display: flex;
align-items: center;
gap: 8px;
}
.package-name code {
font-size: 13px;
color: var(--st-color-text-primary, #111827);
}
.ecosystem-badge {
font-size: 10px;
font-weight: 500;
padding: 1px 4px;
border-radius: 3px;
background: var(--st-color-surface-secondary, #f3f4f6);
color: var(--st-color-text-secondary, #6b7280);
}
.version-info {
margin-top: 2px;
font-size: 11px;
color: var(--st-color-text-secondary, #6b7280);
font-family: var(--st-font-mono, monospace);
}
.version-arrow {
margin: 0 4px;
}
.vuln-delta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.vuln-count {
font-size: 11px;
color: var(--st-color-text-tertiary, #9ca3af);
}
.vuln-count.has-vulns {
color: var(--st-color-error, #ef4444);
}
.risk-delta {
font-size: 11px;
font-weight: 600;
padding: 1px 4px;
border-radius: 3px;
}
.risk-delta.positive { background: var(--st-color-success-bg, #dcfce7); color: var(--st-color-success-dark, #166534); }
.risk-delta.negative { background: var(--st-color-error-bg, #fee2e2); color: var(--st-color-error-dark, #991b1b); }
.empty-state {
padding: 24px;
text-align: center;
color: var(--st-color-text-secondary, #6b7280);
font-size: 13px;
}
.risk-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--st-color-surface-secondary, #f9fafb);
border-top: 1px solid var(--st-color-border, #e5e7eb);
}
.risk-summary.positive { background: var(--st-color-success-bg, #f0fdf4); }
.risk-summary.negative { background: var(--st-color-error-bg, #fef2f2); }
.risk-label {
font-size: 13px;
font-weight: 500;
}
.risk-value {
font-size: 14px;
font-weight: 600;
}
.risk-summary.positive .risk-value { color: var(--st-color-success-dark, #166534); }
.risk-summary.negative .risk-value { color: var(--st-color-error-dark, #991b1b); }
`],
})
export class SbomDiffPanelComponent {
@Input() changes: PackageChange[] = [];
protected activeFilter = signal<'all' | 'added' | 'removed' | 'changed'>('all');
protected summary = computed((): SbomDiffSummary => {
const result: SbomDiffSummary = {
added: 0,
removed: 0,
upgraded: 0,
downgraded: 0,
unchanged: 0,
totalRiskDelta: 0,
};
for (const change of this.changes) {
result[change.changeType]++;
result.totalRiskDelta += change.riskDelta ?? 0;
}
return result;
});
protected filteredChanges = computed(() => {
const filter = this.activeFilter();
if (filter === 'all') return this.changes;
if (filter === 'changed') {
return this.changes.filter(c => c.changeType === 'upgraded' || c.changeType === 'downgraded');
}
return this.changes.filter(c => c.changeType === filter);
});
protected setFilter(filter: 'all' | 'added' | 'removed' | 'changed'): void {
this.activeFilter.set(filter);
}
}

View File

@@ -0,0 +1,180 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SideBySideDiffComponent, RiskStateSnapshot } from './side-by-side-diff.component';
import type { VerdictLevel } from '../../../core/api/delta-verdict.models';
describe('SideBySideDiffComponent', () => {
let component: SideBySideDiffComponent;
let fixture: ComponentFixture<SideBySideDiffComponent>;
const mockBefore: RiskStateSnapshot = {
verdict: {
id: 'v1',
artifactDigest: 'sha256:abc123',
level: 'routine' as VerdictLevel,
drivers: [],
timestamp: '2025-12-24T10:00:00Z',
traceId: 'trace-1',
},
riskScore: 200,
criticalCount: 0,
highCount: 2,
mediumCount: 5,
lowCount: 10,
unknownCount: 1,
exceptionsActive: 1,
budgetUtilization: 20,
};
const mockAfter: RiskStateSnapshot = {
verdict: {
id: 'v2',
artifactDigest: 'sha256:abc123',
level: 'review' as VerdictLevel,
drivers: [],
timestamp: '2025-12-25T10:00:00Z',
traceId: 'trace-2',
},
riskScore: 350,
criticalCount: 1,
highCount: 3,
mediumCount: 5,
lowCount: 10,
unknownCount: 2,
exceptionsActive: 2,
budgetUtilization: 35,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SideBySideDiffComponent],
}).compileComponents();
fixture = TestBed.createComponent(SideBySideDiffComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render before and after panes', () => {
component.before = mockBefore;
component.after = mockAfter;
fixture.detectChanges();
const beforePane = fixture.nativeElement.querySelector('.pane.before');
const afterPane = fixture.nativeElement.querySelector('.pane.after');
expect(beforePane).toBeTruthy();
expect(afterPane).toBeTruthy();
});
it('should display verdict badges for before and after', () => {
component.before = mockBefore;
component.after = mockAfter;
fixture.detectChanges();
const badges = fixture.nativeElement.querySelectorAll('.verdict-badge');
expect(badges.length).toBe(2);
expect(badges[0].classList.contains('routine')).toBe(true);
expect(badges[1].classList.contains('review')).toBe(true);
});
it('should show risk scores', () => {
component.before = mockBefore;
component.after = mockAfter;
fixture.detectChanges();
const riskScores = fixture.nativeElement.querySelectorAll('.risk-score');
expect(riskScores[0].textContent).toContain('200');
expect(riskScores[1].textContent).toContain('350');
});
it('should calculate and display risk delta', () => {
component.before = mockBefore;
component.after = mockAfter;
fixture.detectChanges();
const deltaBadge = fixture.nativeElement.querySelector('.delta-badge');
expect(deltaBadge.textContent).toContain('+150');
expect(deltaBadge.classList.contains('negative')).toBe(true); // Increase is bad
});
it('should show positive delta style when risk decreases', () => {
const betterAfter = { ...mockAfter, riskScore: 100 };
component.before = mockBefore;
component.after = betterAfter;
fixture.detectChanges();
const deltaBadge = fixture.nativeElement.querySelector('.delta-badge');
expect(deltaBadge.textContent).toContain('-100');
expect(deltaBadge.classList.contains('positive')).toBe(true);
});
it('should display metric deltas in after pane', () => {
component.before = mockBefore;
component.after = mockAfter;
fixture.detectChanges();
const metricDeltas = fixture.nativeElement.querySelectorAll('.metric-delta');
expect(metricDeltas.length).toBeGreaterThan(0);
});
it('should highlight changed metrics', () => {
component.before = mockBefore;
component.after = mockAfter;
fixture.detectChanges();
const changedMetrics = fixture.nativeElement.querySelectorAll('.metric.changed');
expect(changedMetrics.length).toBeGreaterThan(0);
});
it('should show "No previous state" when before is missing', () => {
component.before = undefined;
component.after = mockAfter;
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.pane.before .pane-empty');
expect(emptyState.textContent).toContain('No previous state');
});
it('should show "No current state" when after is missing', () => {
component.before = mockBefore;
component.after = undefined;
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.pane.after .pane-empty');
expect(emptyState.textContent).toContain('No current state');
});
it('should display time difference between states', () => {
component.before = mockBefore;
component.after = mockAfter;
fixture.detectChanges();
const timeDiff = fixture.nativeElement.querySelector('.time-diff');
expect(timeDiff.textContent).toContain('day');
});
it('should show budget utilization delta', () => {
component.before = mockBefore;
component.after = mockAfter;
fixture.detectChanges();
const budgetStat = fixture.nativeElement.querySelectorAll('.stat')[2];
expect(budgetStat.textContent).toContain('35%');
expect(budgetStat.textContent).toContain('+15%');
});
it('should display formatted timestamps', () => {
component.before = mockBefore;
component.after = mockAfter;
fixture.detectChanges();
const timestamps = fixture.nativeElement.querySelectorAll('.pane-time');
expect(timestamps.length).toBe(2);
expect(timestamps[0].textContent).toBeTruthy();
expect(timestamps[1].textContent).toBeTruthy();
});
});

View File

@@ -0,0 +1,547 @@
/**
* Side-by-Side Diff Component
*
* Before vs After risk state comparison with
* highlighted changes.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-11
*/
import { Component, Input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { DeltaVerdict, VerdictLevel } from '../../../core/api/delta-verdict.models';
export interface RiskStateSnapshot {
verdict: DeltaVerdict;
riskScore: number;
criticalCount: number;
highCount: number;
mediumCount: number;
lowCount: number;
unknownCount: number;
exceptionsActive: number;
budgetUtilization: number;
}
@Component({
selector: 'st-side-by-side-diff',
standalone: true,
imports: [CommonModule],
template: `
<div class="side-by-side-diff">
<div class="diff-header">
<h4 class="diff-title">Risk State Comparison</h4>
@if (timeDiff()) {
<span class="time-diff">{{ timeDiff() }}</span>
}
</div>
<div class="diff-panes">
<!-- Before pane -->
<div class="pane before">
<div class="pane-header">
<span class="pane-label">Before</span>
@if (before) {
<span class="pane-time">{{ formatTime(before.verdict.timestamp) }}</span>
}
</div>
@if (before) {
<div class="pane-content">
<div class="verdict-row">
<span class="verdict-badge" [class]="before.verdict.level">
{{ levelLabel(before.verdict.level) }}
</span>
<span class="risk-score">{{ before.riskScore }} pts</span>
</div>
<div class="metrics-grid">
<div class="metric">
<span class="metric-label">Critical</span>
<span class="metric-value critical">{{ before.criticalCount }}</span>
</div>
<div class="metric">
<span class="metric-label">High</span>
<span class="metric-value high">{{ before.highCount }}</span>
</div>
<div class="metric">
<span class="metric-label">Medium</span>
<span class="metric-value medium">{{ before.mediumCount }}</span>
</div>
<div class="metric">
<span class="metric-label">Low</span>
<span class="metric-value low">{{ before.lowCount }}</span>
</div>
</div>
<div class="additional-stats">
<div class="stat">
<span class="stat-label">Unknown</span>
<span class="stat-value">{{ before.unknownCount }}</span>
</div>
<div class="stat">
<span class="stat-label">Exceptions</span>
<span class="stat-value">{{ before.exceptionsActive }}</span>
</div>
<div class="stat">
<span class="stat-label">Budget</span>
<span class="stat-value">{{ before.budgetUtilization }}%</span>
</div>
</div>
</div>
} @else {
<div class="pane-empty">No previous state</div>
}
</div>
<!-- Delta indicator -->
<div class="delta-column">
<div class="delta-arrow">&#x2192;</div>
@if (riskDelta()) {
<div
class="delta-badge"
[class.positive]="riskDelta()! < 0"
[class.negative]="riskDelta()! > 0"
>
{{ riskDelta()! > 0 ? '+' : '' }}{{ riskDelta() }}
</div>
}
</div>
<!-- After pane -->
<div class="pane after">
<div class="pane-header">
<span class="pane-label">After</span>
@if (after) {
<span class="pane-time">{{ formatTime(after.verdict.timestamp) }}</span>
}
</div>
@if (after) {
<div class="pane-content">
<div class="verdict-row">
<span class="verdict-badge" [class]="after.verdict.level">
{{ levelLabel(after.verdict.level) }}
</span>
<span class="risk-score">{{ after.riskScore }} pts</span>
</div>
<div class="metrics-grid">
@for (metric of metricsWithDeltas(); track metric.label) {
<div class="metric" [class.changed]="metric.delta !== 0">
<span class="metric-label">{{ metric.label }}</span>
<span class="metric-value" [class]="metric.class">
{{ metric.value }}
@if (metric.delta !== 0) {
<span
class="metric-delta"
[class.positive]="metric.deltaIsGood"
[class.negative]="!metric.deltaIsGood"
>
{{ metric.delta > 0 ? '+' : '' }}{{ metric.delta }}
</span>
}
</span>
</div>
}
</div>
<div class="additional-stats">
<div class="stat" [class.changed]="unknownDelta() !== 0">
<span class="stat-label">Unknown</span>
<span class="stat-value">
{{ after.unknownCount }}
@if (unknownDelta() !== 0) {
<span class="stat-delta negative">
{{ unknownDelta() > 0 ? '+' : '' }}{{ unknownDelta() }}
</span>
}
</span>
</div>
<div class="stat">
<span class="stat-label">Exceptions</span>
<span class="stat-value">{{ after.exceptionsActive }}</span>
</div>
<div class="stat" [class.changed]="budgetDelta() !== 0">
<span class="stat-label">Budget</span>
<span class="stat-value">
{{ after.budgetUtilization }}%
@if (budgetDelta() !== 0) {
<span
class="stat-delta"
[class.positive]="budgetDelta() < 0"
[class.negative]="budgetDelta() > 0"
>
{{ budgetDelta() > 0 ? '+' : '' }}{{ budgetDelta() }}%
</span>
}
</span>
</div>
</div>
</div>
} @else {
<div class="pane-empty">No current state</div>
}
</div>
</div>
</div>
`,
styles: [`
.side-by-side-diff {
background: var(--st-color-surface, #ffffff);
border: 1px solid var(--st-color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.diff-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--st-color-surface-secondary, #f9fafb);
border-bottom: 1px solid var(--st-color-border, #e5e7eb);
}
.diff-title {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.time-diff {
font-size: 12px;
color: var(--st-color-text-secondary, #6b7280);
}
.diff-panes {
display: flex;
}
.pane {
flex: 1;
padding: 16px;
}
.pane.before {
background: var(--st-color-surface-secondary, #f9fafb);
}
.pane.after {
background: var(--st-color-surface, #ffffff);
}
.delta-column {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 8px;
background: var(--st-color-surface-secondary, #f9fafb);
border-left: 1px solid var(--st-color-border, #e5e7eb);
border-right: 1px solid var(--st-color-border, #e5e7eb);
}
.delta-arrow {
font-size: 20px;
color: var(--st-color-text-tertiary, #9ca3af);
margin-bottom: 8px;
}
.delta-badge {
font-size: 13px;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
}
.delta-badge.positive {
background: var(--st-color-success-bg, #dcfce7);
color: var(--st-color-success-dark, #166534);
}
.delta-badge.negative {
background: var(--st-color-error-bg, #fee2e2);
color: var(--st-color-error-dark, #991b1b);
}
.pane-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.pane-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--st-color-text-secondary, #6b7280);
}
.pane-time {
font-size: 11px;
color: var(--st-color-text-tertiary, #9ca3af);
}
.pane-empty {
padding: 24px;
text-align: center;
color: var(--st-color-text-tertiary, #9ca3af);
font-style: italic;
}
.verdict-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.verdict-badge {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
padding: 4px 10px;
border-radius: 4px;
}
.verdict-badge.routine {
background: var(--st-color-success-bg, #dcfce7);
color: var(--st-color-success-dark, #166534);
}
.verdict-badge.review {
background: var(--st-color-warning-bg, #fef3c7);
color: var(--st-color-warning-dark, #92400e);
}
.verdict-badge.block {
background: var(--st-color-error-bg, #fee2e2);
color: var(--st-color-error-dark, #991b1b);
}
.risk-score {
font-size: 18px;
font-weight: 600;
color: var(--st-color-text-primary, #111827);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.metric {
padding: 8px;
background: var(--st-color-surface, #ffffff);
border: 1px solid var(--st-color-border-subtle, #f3f4f6);
border-radius: 4px;
}
.metric.changed {
border-color: var(--st-color-primary, #3b82f6);
background: var(--st-color-primary-bg, #eff6ff);
}
.metric-label {
display: block;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
color: var(--st-color-text-secondary, #6b7280);
margin-bottom: 2px;
}
.metric-value {
font-size: 16px;
font-weight: 600;
}
.metric-value.critical { color: var(--st-color-error, #ef4444); }
.metric-value.high { color: var(--st-color-warning, #f59e0b); }
.metric-value.medium { color: var(--st-color-warning-dark, #d97706); }
.metric-value.low { color: var(--st-color-info, #6366f1); }
.metric-delta, .stat-delta {
font-size: 11px;
margin-left: 4px;
}
.metric-delta.positive, .stat-delta.positive {
color: var(--st-color-success, #22c55e);
}
.metric-delta.negative, .stat-delta.negative {
color: var(--st-color-error, #ef4444);
}
.additional-stats {
display: flex;
gap: 16px;
}
.stat {
flex: 1;
}
.stat.changed {
padding: 4px 6px;
background: var(--st-color-primary-bg, #eff6ff);
border-radius: 4px;
}
.stat-label {
display: block;
font-size: 10px;
color: var(--st-color-text-secondary, #6b7280);
}
.stat-value {
font-size: 13px;
font-weight: 500;
}
/* Responsive: Stack panes on mobile */
@media (max-width: 767px) {
.diff-panes {
flex-direction: column;
}
.delta-column {
flex-direction: row;
padding: 8px 16px;
border-left: none;
border-right: none;
border-top: 1px solid var(--st-color-border, #e5e7eb);
border-bottom: 1px solid var(--st-color-border, #e5e7eb);
}
.delta-arrow {
transform: rotate(90deg);
margin-bottom: 0;
margin-right: 12px;
}
.metrics-grid {
grid-template-columns: repeat(4, 1fr);
}
.pane.before,
.pane.after {
background: var(--st-color-surface, #ffffff);
}
.pane.before {
border-bottom: none;
}
}
/* Tablet */
@media (min-width: 768px) and (max-width: 1023px) {
.metrics-grid {
grid-template-columns: repeat(4, 1fr);
}
.metric {
padding: 6px;
}
.metric-value {
font-size: 14px;
}
}
/* Desktop */
@media (min-width: 1024px) {
.pane {
padding: 20px;
}
.verdict-badge {
font-size: 13px;
padding: 5px 12px;
}
.risk-score {
font-size: 20px;
}
}
`],
})
export class SideBySideDiffComponent {
@Input() before?: RiskStateSnapshot;
@Input() after?: RiskStateSnapshot;
protected levelLabel(level: VerdictLevel): string {
const labels: Record<VerdictLevel, string> = {
routine: 'Routine',
review: 'Review',
block: 'Block',
};
return labels[level];
}
protected formatTime(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
protected timeDiff = computed(() => {
if (!this.before || !this.after) return null;
const beforeDate = new Date(this.before.verdict.timestamp);
const afterDate = new Date(this.after.verdict.timestamp);
const diffMs = afterDate.getTime() - beforeDate.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays} day${diffDays !== 1 ? 's' : ''} apart`;
if (diffHours > 0) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} apart`;
return 'Just now';
});
protected riskDelta = computed(() => {
if (!this.before || !this.after) return null;
return this.after.riskScore - this.before.riskScore;
});
protected unknownDelta = computed(() => {
if (!this.before || !this.after) return 0;
return this.after.unknownCount - this.before.unknownCount;
});
protected budgetDelta = computed(() => {
if (!this.before || !this.after) return 0;
return this.after.budgetUtilization - this.before.budgetUtilization;
});
protected metricsWithDeltas = computed(() => {
if (!this.after) return [];
const metrics = [
{ label: 'Critical', value: this.after.criticalCount, class: 'critical', key: 'criticalCount' as const },
{ label: 'High', value: this.after.highCount, class: 'high', key: 'highCount' as const },
{ label: 'Medium', value: this.after.mediumCount, class: 'medium', key: 'mediumCount' as const },
{ label: 'Low', value: this.after.lowCount, class: 'low', key: 'lowCount' as const },
];
return metrics.map(m => {
const delta = this.before ? this.after![m.key] - this.before[m.key] : 0;
return {
...m,
delta,
deltaIsGood: delta < 0, // Fewer vulns is good
};
});
});
}

View File

@@ -0,0 +1,102 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VerdictBadgeComponent } from './verdict-badge.component';
import type { VerdictLevel } from '../../../core/api/delta-verdict.models';
describe('VerdictBadgeComponent', () => {
let component: VerdictBadgeComponent;
let fixture: ComponentFixture<VerdictBadgeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VerdictBadgeComponent],
}).compileComponents();
fixture = TestBed.createComponent(VerdictBadgeComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display routine badge with correct class', () => {
component.level = 'routine';
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.verdict-badge');
expect(badge).toBeTruthy();
expect(badge.classList.contains('routine')).toBe(true);
expect(badge.textContent.trim()).toBe('Routine');
});
it('should display review badge with correct class', () => {
component.level = 'review';
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.verdict-badge');
expect(badge).toBeTruthy();
expect(badge.classList.contains('review')).toBe(true);
expect(badge.textContent.trim()).toBe('Review');
});
it('should display block badge with correct class', () => {
component.level = 'block';
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.verdict-badge');
expect(badge).toBeTruthy();
expect(badge.classList.contains('block')).toBe(true);
expect(badge.textContent.trim()).toBe('Block');
});
it('should apply small size when size input is small', () => {
component.level = 'routine';
component.size = 'small';
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.verdict-badge');
expect(badge.classList.contains('small')).toBe(true);
});
it('should apply large size when size input is large', () => {
component.level = 'routine';
component.size = 'large';
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.verdict-badge');
expect(badge.classList.contains('large')).toBe(true);
});
it('should show tooltip when showTooltip is true and summary exists', () => {
component.level = 'block';
component.showTooltip = true;
component.summary = 'Critical vulnerability detected';
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.verdict-badge');
expect(badge.getAttribute('title')).toBe('Critical vulnerability detected');
});
it('should not show tooltip when showTooltip is false', () => {
component.level = 'block';
component.showTooltip = false;
component.summary = 'Critical vulnerability detected';
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.verdict-badge');
expect(badge.getAttribute('title')).toBeFalsy();
});
it('should emit clicked event when badge is clicked', () => {
component.level = 'routine';
fixture.detectChanges();
spyOn(component.clicked, 'emit');
const badge = fixture.nativeElement.querySelector('.verdict-badge');
badge.click();
expect(component.clicked.emit).toHaveBeenCalledWith('routine');
});
});

View File

@@ -0,0 +1,200 @@
/**
* Verdict Badge Component
*
* Displays policy verdict with color-coded badge:
* - Routine (green): No action required
* - Review (yellow): Manual review needed
* - Block (red): Deployment blocked
*
* Includes tooltip with verdict summary.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-05
*/
import { Component, Input, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { VerdictLevel, VerdictDriver } from '../../../core/api/delta-verdict.models';
@Component({
selector: 'st-verdict-badge',
standalone: true,
imports: [CommonModule],
template: `
<div
class="verdict-badge"
[class.routine]="level === 'routine'"
[class.review]="level === 'review'"
[class.block]="level === 'block'"
[class.compact]="compact"
[attr.title]="tooltipText()"
>
<span class="badge-icon">
@switch (level) {
@case ('routine') { &#x2713; }
@case ('review') { &#x26A0; }
@case ('block') { &#x2717; }
}
</span>
@if (!compact) {
<span class="badge-text">{{ levelText() }}</span>
}
@if (showDelta && delta !== undefined) {
<span class="badge-delta" [class.positive]="delta < 0" [class.negative]="delta > 0">
{{ delta > 0 ? '+' : '' }}{{ delta }}
</span>
}
</div>
@if (showDrivers && drivers.length > 0) {
<div class="verdict-drivers">
@for (driver of drivers.slice(0, maxDrivers); track driver.category) {
<div class="driver-item">
<span class="driver-bullet">&#x2022;</span>
<span class="driver-text">{{ driver.summary }}</span>
</div>
}
@if (drivers.length > maxDrivers) {
<div class="driver-more">
+{{ drivers.length - maxDrivers }} more
</div>
}
</div>
}
`,
styles: [`
:host {
display: inline-block;
}
.verdict-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-weight: 600;
font-size: 14px;
transition: transform 0.1s;
}
.verdict-badge:hover {
transform: scale(1.02);
}
.verdict-badge.compact {
padding: 4px 8px;
font-size: 12px;
}
.verdict-badge.routine {
background: var(--st-color-success-bg, #dcfce7);
color: var(--st-color-success-dark, #166534);
border: 1px solid var(--st-color-success, #22c55e);
}
.verdict-badge.review {
background: var(--st-color-warning-bg, #fef3c7);
color: var(--st-color-warning-dark, #92400e);
border: 1px solid var(--st-color-warning, #f59e0b);
}
.verdict-badge.block {
background: var(--st-color-error-bg, #fee2e2);
color: var(--st-color-error-dark, #991b1b);
border: 1px solid var(--st-color-error, #ef4444);
}
.badge-icon {
font-size: 1.1em;
}
.badge-text {
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-delta {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.5);
font-weight: 500;
}
.badge-delta.positive {
color: var(--st-color-success-dark, #166534);
}
.badge-delta.negative {
color: var(--st-color-error-dark, #991b1b);
}
.verdict-drivers {
margin-top: 8px;
padding-left: 4px;
}
.driver-item {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 13px;
color: var(--st-color-text-secondary, #6b7280);
margin-bottom: 4px;
}
.driver-bullet {
color: var(--st-color-text-tertiary, #9ca3af);
}
.driver-text {
flex: 1;
}
.driver-more {
font-size: 12px;
color: var(--st-color-text-tertiary, #9ca3af);
font-style: italic;
padding-left: 14px;
}
`],
})
export class VerdictBadgeComponent {
@Input() level: VerdictLevel = 'routine';
@Input() drivers: VerdictDriver[] = [];
@Input() delta?: number;
@Input() compact = false;
@Input() showDelta = true;
@Input() showDrivers = false;
@Input() maxDrivers = 3;
protected levelText = computed(() => {
switch (this.level) {
case 'routine': return 'Routine';
case 'review': return 'Review';
case 'block': return 'Block';
default: return 'Unknown';
}
});
protected tooltipText = computed(() => {
const lines = [this.levelText()];
if (this.drivers.length > 0) {
lines.push('');
lines.push('Key factors:');
this.drivers.slice(0, 3).forEach(d => {
lines.push(`- ${d.summary}`);
});
}
if (this.delta !== undefined) {
lines.push('');
lines.push(`Risk delta: ${this.delta > 0 ? '+' : ''}${this.delta}`);
}
return lines.join('\n');
});
}

View File

@@ -0,0 +1,281 @@
/**
* Verdict Why Summary Component
*
* Displays 3-5 bullet explanation of verdict drivers.
* Each bullet links to evidence for drill-down.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-06
*/
import { Component, Input, Output, EventEmitter, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { VerdictDriver, VerdictDriverCategory } from '../../../core/api/delta-verdict.models';
export type EvidenceType = 'reachability' | 'vex' | 'sbom_diff' | 'exception';
export interface EvidenceRequest {
type: EvidenceType;
driver: VerdictDriver;
}
@Component({
selector: 'st-verdict-why-summary',
standalone: true,
imports: [CommonModule],
template: `
<div class="why-summary">
<h4 class="summary-title">{{ title }}</h4>
<ul class="driver-list">
@for (driver of displayDrivers(); track driver.category) {
<li class="driver-item" [class]="categoryClass(driver.category)">
<div class="driver-content">
<span class="driver-icon">{{ categoryIcon(driver.category) }}</span>
<div class="driver-body">
<span class="driver-summary">{{ driver.summary }}</span>
@if (showDescriptions) {
<p class="driver-description">{{ driver.description }}</p>
}
</div>
@if (driver.evidenceType && showEvidenceLinks) {
<button
type="button"
class="evidence-link"
(click)="requestEvidence(driver)"
>
{{ evidenceButtonText(driver.evidenceType) }}
</button>
}
</div>
@if (driver.impact !== undefined && showImpact) {
<div class="driver-impact">
<span class="impact-label">Impact:</span>
<span class="impact-value" [class.high]="isHighImpact(driver)">
{{ formatImpact(driver.impact) }}
</span>
</div>
}
</li>
}
</ul>
@if (drivers.length > maxItems) {
<button
type="button"
class="show-more"
(click)="toggleShowAll()"
>
{{ showAll ? 'Show less' : 'Show ' + (drivers.length - maxItems) + ' more' }}
</button>
}
</div>
`,
styles: [`
.why-summary {
background: var(--st-color-surface, #ffffff);
border: 1px solid var(--st-color-border, #e5e7eb);
border-radius: 8px;
padding: 16px;
}
.summary-title {
font-size: 14px;
font-weight: 600;
color: var(--st-color-text-primary, #111827);
margin: 0 0 12px 0;
}
.driver-list {
list-style: none;
margin: 0;
padding: 0;
}
.driver-item {
padding: 10px 12px;
margin-bottom: 8px;
border-radius: 6px;
background: var(--st-color-surface-secondary, #f9fafb);
border-left: 3px solid var(--st-color-border, #e5e7eb);
}
.driver-item.critical_vuln,
.driver-item.budget_exceeded {
border-left-color: var(--st-color-error, #ef4444);
background: var(--st-color-error-bg, #fef2f2);
}
.driver-item.high_vuln,
.driver-item.exception_expired {
border-left-color: var(--st-color-warning, #f59e0b);
background: var(--st-color-warning-bg, #fffbeb);
}
.driver-item.unknown_risk,
.driver-item.vex_source {
border-left-color: var(--st-color-info, #6366f1);
background: var(--st-color-info-bg, #eef2ff);
}
.driver-content {
display: flex;
align-items: flex-start;
gap: 10px;
}
.driver-icon {
font-size: 16px;
line-height: 1.4;
}
.driver-body {
flex: 1;
}
.driver-summary {
font-size: 14px;
font-weight: 500;
color: var(--st-color-text-primary, #111827);
}
.driver-description {
font-size: 13px;
color: var(--st-color-text-secondary, #6b7280);
margin: 4px 0 0 0;
line-height: 1.4;
}
.evidence-link {
flex-shrink: 0;
font-size: 12px;
font-weight: 500;
color: var(--st-color-primary, #3b82f6);
background: none;
border: 1px solid var(--st-color-primary, #3b82f6);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.evidence-link:hover {
background: var(--st-color-primary, #3b82f6);
color: white;
}
.driver-impact {
display: flex;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--st-color-border-subtle, #e5e7eb);
font-size: 12px;
}
.impact-label {
color: var(--st-color-text-tertiary, #9ca3af);
}
.impact-value {
font-weight: 500;
color: var(--st-color-text-secondary, #6b7280);
}
.impact-value.high {
color: var(--st-color-error, #ef4444);
}
.show-more {
display: block;
width: 100%;
margin-top: 8px;
padding: 8px;
font-size: 13px;
font-weight: 500;
color: var(--st-color-primary, #3b82f6);
background: none;
border: 1px dashed var(--st-color-border, #e5e7eb);
border-radius: 4px;
cursor: pointer;
transition: border-color 0.15s;
}
.show-more:hover {
border-color: var(--st-color-primary, #3b82f6);
}
`],
})
export class VerdictWhySummaryComponent {
@Input() title = 'Why this verdict?';
@Input() drivers: VerdictDriver[] = [];
@Input() maxItems = 5;
@Input() showDescriptions = true;
@Input() showEvidenceLinks = true;
@Input() showImpact = true;
@Output() evidenceRequested = new EventEmitter<EvidenceRequest>();
showAll = false;
protected displayDrivers = computed(() => {
return this.showAll ? this.drivers : this.drivers.slice(0, this.maxItems);
});
protected categoryIcon(category: VerdictDriverCategory): string {
const icons: Record<VerdictDriverCategory, string> = {
critical_vuln: '\u26A0', // Warning
high_vuln: '\u26A0', // Warning
budget_exceeded: '\u2757', // Exclamation
unknown_risk: '\u2753', // Question
exception_expired: '\u23F0', // Alarm
reachability: '\u2192', // Arrow
vex_source: '\u2139', // Info
sbom_drift: '\u2194', // Left-right arrow
policy_rule: '\u2696', // Scales
};
return icons[category] || '\u2022';
}
protected categoryClass(category: VerdictDriverCategory): string {
return category.replace(/_/g, '-');
}
protected evidenceButtonText(type: EvidenceType): string {
const texts: Record<EvidenceType, string> = {
reachability: 'Show Paths',
vex: 'VEX Sources',
sbom_diff: 'SBOM Diff',
exception: 'View Exception',
};
return texts[type] || 'View';
}
protected isHighImpact(driver: VerdictDriver): boolean {
if (typeof driver.impact === 'number') {
return driver.impact > 50;
}
return driver.impact === true;
}
protected formatImpact(impact: number | boolean): string {
if (typeof impact === 'boolean') {
return impact ? 'Yes' : 'No';
}
return `${impact > 0 ? '+' : ''}${impact} pts`;
}
protected toggleShowAll(): void {
this.showAll = !this.showAll;
}
protected requestEvidence(driver: VerdictDriver): void {
if (driver.evidenceType) {
this.evidenceRequested.emit({
type: driver.evidenceType,
driver,
});
}
}
}

View File

@@ -0,0 +1,173 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VexSourcesPanelComponent, VexSource, VexStatus } from './vex-sources-panel.component';
describe('VexSourcesPanelComponent', () => {
let component: VexSourcesPanelComponent;
let fixture: ComponentFixture<VexSourcesPanelComponent>;
const mockSources: VexSource[] = [
{
id: 'vex-1',
issuer: 'Vendor Inc',
issuerType: 'vendor',
vulnId: 'CVE-2025-1234',
status: 'not_affected' as VexStatus,
justification: 'Component not used in our configuration',
trustScore: 95,
publishedAt: '2025-12-20T10:00:00Z',
lastUpdatedAt: '2025-12-24T10:00:00Z',
documentUrl: 'https://vendor.com/vex/2025-1234',
},
{
id: 'vex-2',
issuer: 'CERT/CC',
issuerType: 'coordinator',
vulnId: 'CVE-2025-1234',
status: 'under_investigation' as VexStatus,
trustScore: 80,
publishedAt: '2025-12-21T10:00:00Z',
lastUpdatedAt: '2025-12-22T10:00:00Z',
},
{
id: 'vex-3',
issuer: 'Community Project',
issuerType: 'community',
vulnId: 'CVE-2025-1234',
status: 'affected' as VexStatus,
justification: 'Vulnerable version in use',
trustScore: 45,
publishedAt: '2025-12-19T10:00:00Z',
lastUpdatedAt: '2025-11-01T10:00:00Z', // Stale
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VexSourcesPanelComponent],
}).compileComponents();
fixture = TestBed.createComponent(VexSourcesPanelComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display source count', () => {
component.sources = mockSources;
fixture.detectChanges();
const count = fixture.nativeElement.querySelector('.source-count');
expect(count.textContent).toContain('3 sources');
});
it('should render source cards', () => {
component.sources = mockSources;
fixture.detectChanges();
const cards = fixture.nativeElement.querySelectorAll('.source-card');
expect(cards.length).toBe(3);
});
it('should display issuer badges with correct types', () => {
component.sources = mockSources;
fixture.detectChanges();
const badges = fixture.nativeElement.querySelectorAll('.issuer-badge');
expect(badges[0].classList.contains('vendor')).toBe(true);
expect(badges[1].classList.contains('coordinator')).toBe(true);
expect(badges[2].classList.contains('community')).toBe(true);
});
it('should display VEX status badges', () => {
component.sources = mockSources;
fixture.detectChanges();
const statusBadges = fixture.nativeElement.querySelectorAll('.status-badge');
expect(statusBadges[0].classList.contains('not_affected')).toBe(true);
expect(statusBadges[1].classList.contains('under_investigation')).toBe(true);
expect(statusBadges[2].classList.contains('affected')).toBe(true);
});
it('should display trust scores with bars', () => {
component.sources = mockSources;
fixture.detectChanges();
const trustBars = fixture.nativeElement.querySelectorAll('.trust-bar');
expect(trustBars.length).toBe(3);
const trustValues = fixture.nativeElement.querySelectorAll('.trust-value');
expect(trustValues[0].textContent).toContain('95%');
});
it('should apply trust score classes correctly', () => {
component.sources = mockSources;
fixture.detectChanges();
const trustScores = fixture.nativeElement.querySelectorAll('.trust-score');
expect(trustScores[0].classList.contains('high')).toBe(true); // 95
expect(trustScores[1].classList.contains('high')).toBe(true); // 80
expect(trustScores[2].classList.contains('medium')).toBe(true); // 45
});
it('should display justification when present', () => {
component.sources = mockSources;
fixture.detectChanges();
const justifications = fixture.nativeElement.querySelectorAll('.justification');
expect(justifications.length).toBe(2); // Only first and third have justification
expect(justifications[0].textContent).toContain('not used in our configuration');
});
it('should show document link when URL is present', () => {
component.sources = mockSources;
fixture.detectChanges();
const docLinks = fixture.nativeElement.querySelectorAll('.doc-link');
expect(docLinks.length).toBe(1); // Only first source has URL
expect(docLinks[0].getAttribute('href')).toBe('https://vendor.com/vex/2025-1234');
});
it('should display freshness indicator', () => {
component.sources = mockSources;
fixture.detectChanges();
const freshnessElements = fixture.nativeElement.querySelectorAll('.freshness');
expect(freshnessElements.length).toBe(3);
});
it('should mark stale sources', () => {
component.sources = mockSources;
fixture.detectChanges();
const freshnessElements = fixture.nativeElement.querySelectorAll('.freshness');
expect(freshnessElements[2].classList.contains('stale')).toBe(true);
});
it('should show empty state when no sources', () => {
component.sources = [];
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState.textContent).toContain('No VEX statements available');
});
it('should display CVE ID', () => {
component.sources = mockSources;
fixture.detectChanges();
const vulnIds = fixture.nativeElement.querySelectorAll('.vuln-id');
expect(vulnIds[0].textContent).toContain('CVE-2025-1234');
});
it('should apply issuer type border styling', () => {
component.sources = mockSources;
fixture.detectChanges();
const cards = fixture.nativeElement.querySelectorAll('.source-card');
expect(cards[0].classList.contains('vendor')).toBe(true);
expect(cards[1].classList.contains('coordinator')).toBe(true);
});
});

View File

@@ -0,0 +1,368 @@
/**
* VEX Sources Panel Component
*
* Displays VEX statement sources with trust scores,
* freshness indicators, and status badges.
*
* @sprint SPRINT_20251226_004_FE_risk_dashboard
* @task DASH-09
*/
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
export type VexStatus = 'not_affected' | 'affected' | 'fixed' | 'under_investigation' | 'unknown';
export interface VexSource {
id: string;
issuer: string;
issuerType: 'vendor' | 'coordinator' | 'community' | 'internal';
vulnId: string;
status: VexStatus;
justification?: string;
trustScore: number; // 0-100
publishedAt: string;
lastUpdatedAt: string;
documentUrl?: string;
}
@Component({
selector: 'st-vex-sources-panel',
standalone: true,
imports: [CommonModule],
template: `
<div class="vex-panel">
<div class="panel-header">
<h4 class="panel-title">VEX Sources</h4>
<span class="source-count">{{ sources.length }} source{{ sources.length !== 1 ? 's' : '' }}</span>
</div>
@if (sources.length === 0) {
<div class="empty-state">
<span class="empty-text">No VEX statements available</span>
</div>
} @else {
<div class="sources-list">
@for (source of sources; track source.id) {
<div class="source-card" [class]="source.issuerType">
<div class="source-header">
<div class="issuer-info">
<span class="issuer-badge" [class]="source.issuerType">
{{ issuerTypeLabel(source.issuerType) }}
</span>
<span class="issuer-name">{{ source.issuer }}</span>
</div>
<div class="trust-score" [class]="trustClass(source.trustScore)">
<span class="trust-bar" [style.width.%]="source.trustScore"></span>
<span class="trust-value">{{ source.trustScore }}%</span>
</div>
</div>
<div class="source-body">
<div class="vuln-row">
<code class="vuln-id">{{ source.vulnId }}</code>
<span class="status-badge" [class]="source.status">
{{ statusLabel(source.status) }}
</span>
</div>
@if (source.justification) {
<p class="justification">{{ source.justification }}</p>
}
<div class="meta-row">
<span class="freshness" [class]="freshnessClass(source.lastUpdatedAt)">
Updated {{ formatDate(source.lastUpdatedAt) }}
</span>
@if (source.documentUrl) {
<a
class="doc-link"
[href]="source.documentUrl"
target="_blank"
rel="noopener"
>
View Document &#x2197;
</a>
}
</div>
</div>
</div>
}
</div>
}
</div>
`,
styles: [`
.vex-panel {
background: var(--st-color-surface, #ffffff);
border: 1px solid var(--st-color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--st-color-surface-secondary, #f9fafb);
border-bottom: 1px solid var(--st-color-border, #e5e7eb);
}
.panel-title {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.source-count {
font-size: 12px;
color: var(--st-color-text-secondary, #6b7280);
}
.empty-state {
padding: 24px;
text-align: center;
color: var(--st-color-text-secondary, #6b7280);
}
.sources-list {
padding: 8px;
}
.source-card {
margin-bottom: 8px;
border: 1px solid var(--st-color-border, #e5e7eb);
border-radius: 6px;
overflow: hidden;
}
.source-card.vendor {
border-left: 3px solid var(--st-color-success, #22c55e);
}
.source-card.coordinator {
border-left: 3px solid var(--st-color-info, #6366f1);
}
.source-card.community {
border-left: 3px solid var(--st-color-warning, #f59e0b);
}
.source-card.internal {
border-left: 3px solid var(--st-color-primary, #3b82f6);
}
.source-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--st-color-surface-secondary, #f9fafb);
}
.issuer-info {
display: flex;
align-items: center;
gap: 8px;
}
.issuer-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 3px;
}
.issuer-badge.vendor {
background: var(--st-color-success-bg, #dcfce7);
color: var(--st-color-success-dark, #166534);
}
.issuer-badge.coordinator {
background: var(--st-color-info-bg, #e0e7ff);
color: var(--st-color-info-dark, #3730a3);
}
.issuer-badge.community {
background: var(--st-color-warning-bg, #fef3c7);
color: var(--st-color-warning-dark, #92400e);
}
.issuer-badge.internal {
background: var(--st-color-primary-bg, #dbeafe);
color: var(--st-color-primary-dark, #1e40af);
}
.issuer-name {
font-size: 13px;
font-weight: 500;
color: var(--st-color-text-primary, #111827);
}
.trust-score {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.trust-bar {
height: 4px;
width: 40px;
background: var(--st-color-success, #22c55e);
border-radius: 2px;
}
.trust-score.medium .trust-bar {
background: var(--st-color-warning, #f59e0b);
}
.trust-score.low .trust-bar {
background: var(--st-color-error, #ef4444);
}
.trust-value {
color: var(--st-color-text-secondary, #6b7280);
}
.source-body {
padding: 10px 12px;
}
.vuln-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.vuln-id {
font-size: 12px;
font-family: var(--st-font-mono, monospace);
}
.status-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 3px;
}
.status-badge.not_affected {
background: var(--st-color-success-bg, #dcfce7);
color: var(--st-color-success-dark, #166534);
}
.status-badge.affected {
background: var(--st-color-error-bg, #fee2e2);
color: var(--st-color-error-dark, #991b1b);
}
.status-badge.fixed {
background: var(--st-color-info-bg, #e0e7ff);
color: var(--st-color-info-dark, #3730a3);
}
.status-badge.under_investigation {
background: var(--st-color-warning-bg, #fef3c7);
color: var(--st-color-warning-dark, #92400e);
}
.status-badge.unknown {
background: var(--st-color-surface-secondary, #f3f4f6);
color: var(--st-color-text-secondary, #6b7280);
}
.justification {
margin: 6px 0;
font-size: 12px;
color: var(--st-color-text-secondary, #6b7280);
line-height: 1.4;
}
.meta-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 11px;
}
.freshness {
color: var(--st-color-text-tertiary, #9ca3af);
}
.freshness.stale {
color: var(--st-color-warning, #f59e0b);
}
.freshness.old {
color: var(--st-color-error, #ef4444);
}
.doc-link {
color: var(--st-color-primary, #3b82f6);
text-decoration: none;
}
.doc-link:hover {
text-decoration: underline;
}
`],
})
export class VexSourcesPanelComponent {
@Input() sources: VexSource[] = [];
@Input() vulnId?: string;
protected issuerTypeLabel(type: VexSource['issuerType']): string {
const labels: Record<VexSource['issuerType'], string> = {
vendor: 'Vendor',
coordinator: 'Coordinator',
community: 'Community',
internal: 'Internal',
};
return labels[type];
}
protected statusLabel(status: VexStatus): string {
const labels: Record<VexStatus, string> = {
not_affected: 'Not Affected',
affected: 'Affected',
fixed: 'Fixed',
under_investigation: 'Investigating',
unknown: 'Unknown',
};
return labels[status];
}
protected trustClass(score: number): string {
if (score >= 70) return 'high';
if (score >= 40) return 'medium';
return 'low';
}
protected freshnessClass(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const daysDiff = (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24);
if (daysDiff > 90) return 'old';
if (daysDiff > 30) return 'stale';
return 'fresh';
}
protected formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const daysDiff = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 0) return 'today';
if (daysDiff === 1) return 'yesterday';
if (daysDiff < 7) return `${daysDiff} days ago`;
if (daysDiff < 30) return `${Math.floor(daysDiff / 7)} weeks ago`;
return date.toLocaleDateString();
}
}

View File

@@ -0,0 +1,271 @@
/**
* AI Assist Panel Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-16
*
* AI assistance panel for finding detail view.
* Visually subordinate to Verdict and Evidence panels.
*/
import { Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AiSummaryComponent, AiSummaryExpanded, AiSummaryCitation } from './ai-summary.component';
import { AiExplainChipComponent, ExplainContext } from './ai-explain-chip.component';
import { AiFixChipComponent, FixState } from './ai-fix-chip.component';
import { AiVexDraftChipComponent, VexDraftState } from './ai-vex-draft-chip.component';
import { AiNeedsEvidenceChipComponent, EvidenceType } from './ai-needs-evidence-chip.component';
import { AIAuthority } from './ai-authority-badge.component';
/**
* AI assistance data for a finding.
*/
export interface AiAssistData {
/** Summary lines */
summary: {
line1: string;
line2: string;
line3: string;
};
/** Authority level */
authority: AIAuthority;
/** Whether expanded content is available */
hasExpandedContent: boolean;
/** Expanded content */
expandedContent?: AiSummaryExpanded;
/** Fix state */
fixState: FixState;
/** Fix is PR-ready */
fixPrReady: boolean;
/** VEX draft state */
vexState: VexDraftState;
/** Proposed VEX status */
proposedVexStatus?: string;
/** Evidence needed */
evidenceNeeded?: {
type: EvidenceType;
description: string;
effort: 'low' | 'medium' | 'high';
};
/** Cheapest next evidence suggestion */
cheapestEvidence?: string;
}
@Component({
selector: 'stella-ai-assist-panel',
standalone: true,
imports: [
CommonModule,
AiSummaryComponent,
AiExplainChipComponent,
AiFixChipComponent,
AiVexDraftChipComponent,
AiNeedsEvidenceChipComponent
],
template: `
<div class="ai-assist-panel">
<header class="ai-assist-panel__header">
<h3 class="ai-assist-panel__title">AI Assist</h3>
<span class="ai-assist-panel__subtitle">(non-authoritative)</span>
</header>
@if (data()) {
<div class="ai-assist-panel__content">
<stella-ai-summary
[line1]="data()!.summary.line1"
[line2]="data()!.summary.line2"
[line3]="data()!.summary.line3"
[authority]="data()!.authority"
[hasMore]="data()!.hasExpandedContent"
[expandedContent]="data()!.expandedContent ?? null"
[expandLabel]="'details'"
(citationClick)="onCitationClick($event)"
/>
@if (data()!.cheapestEvidence) {
<div class="ai-assist-panel__cheapest">
<span class="ai-assist-panel__cheapest-label">Cheapest next evidence:</span>
<span class="ai-assist-panel__cheapest-value">{{ data()!.cheapestEvidence }}</span>
</div>
}
<div class="ai-assist-panel__actions">
<stella-ai-explain-chip
[context]="'vulnerability'"
[subject]="vulnerabilityId()"
[hasEvidence]="data()!.authority === 'evidence-backed'"
(explain)="onExplain($event)"
/>
<stella-ai-fix-chip
[state]="data()!.fixState"
[prReady]="data()!.fixPrReady"
[target]="vulnerabilityId()"
(fix)="onFix($event)"
/>
<stella-ai-vex-draft-chip
[state]="data()!.vexState"
[proposedStatus]="data()!.proposedVexStatus ?? ''"
[vulnerabilityId]="vulnerabilityId()"
(draftVex)="onDraftVex($event)"
/>
@if (data()!.evidenceNeeded) {
<stella-ai-needs-evidence-chip
[evidenceType]="data()!.evidenceNeeded!.type"
[needed]="data()!.evidenceNeeded!.description"
[effort]="data()!.evidenceNeeded!.effort"
(gatherEvidence)="onGatherEvidence($event)"
/>
}
</div>
</div>
} @else {
<div class="ai-assist-panel__loading">
<span class="ai-assist-panel__loading-spinner">⏳</span>
<span>Loading AI assistance...</span>
</div>
}
</div>
`,
styles: [`
.ai-assist-panel {
background: rgba(249, 250, 251, 0.5);
border: 1px solid rgba(209, 213, 219, 0.5);
border-radius: 8px;
padding: 0.75rem;
}
.ai-assist-panel__header {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.ai-assist-panel__title {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: #6b7280;
}
.ai-assist-panel__subtitle {
font-size: 0.6875rem;
color: #9ca3af;
font-style: italic;
}
.ai-assist-panel__content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ai-assist-panel__cheapest {
display: flex;
align-items: baseline;
gap: 0.375rem;
padding: 0.5rem;
background: rgba(79, 70, 229, 0.05);
border-radius: 4px;
font-size: 0.8125rem;
}
.ai-assist-panel__cheapest-label {
color: #6b7280;
font-weight: 500;
}
.ai-assist-panel__cheapest-value {
color: #4f46e5;
}
.ai-assist-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(209, 213, 219, 0.3);
}
.ai-assist-panel__loading {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
.ai-assist-panel__loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`]
})
export class AiAssistPanelComponent {
/**
* AI assistance data.
*/
readonly data = input<AiAssistData | null>(null);
/**
* Vulnerability ID for context.
*/
readonly vulnerabilityId = input<string>('');
/**
* Component PURL for context.
*/
readonly componentPurl = input<string>('');
/**
* Explain action.
*/
readonly explain = output<{ context: ExplainContext; subject: string }>();
/**
* Fix action.
*/
readonly fix = output<{ target: string; prReady: boolean }>();
/**
* Draft VEX action.
*/
readonly draftVex = output<{ vulnerabilityId: string; proposedStatus: string }>();
/**
* Gather evidence action.
*/
readonly gatherEvidence = output<{ evidenceType: EvidenceType; needed: string }>();
/**
* Citation clicked.
*/
readonly citationClicked = output<AiSummaryCitation>();
onExplain(event: { context: ExplainContext; subject: string }): void {
this.explain.emit(event);
}
onFix(event: { target: string; prReady: boolean }): void {
this.fix.emit(event);
}
onDraftVex(event: { vulnerabilityId: string; proposedStatus: string }): void {
this.draftVex.emit(event);
}
onGatherEvidence(event: { evidenceType: EvidenceType; needed: string }): void {
this.gatherEvidence.emit(event);
}
onCitationClick(citation: AiSummaryCitation): void {
this.citationClicked.emit(citation);
}
}

View File

@@ -0,0 +1,107 @@
/**
* AI Authority Badge Component Tests.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-39
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AiAuthorityBadgeComponent, AIAuthority } from './ai-authority-badge.component';
describe('AiAuthorityBadgeComponent', () => {
let component: AiAuthorityBadgeComponent;
let fixture: ComponentFixture<AiAuthorityBadgeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AiAuthorityBadgeComponent]
}).compileComponents();
fixture = TestBed.createComponent(AiAuthorityBadgeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display suggestion by default', () => {
expect(component.authority()).toBe('suggestion');
expect(component.label()).toBe('Suggestion');
expect(component.icon()).toBe('◇');
});
it('should display evidence-backed correctly', () => {
fixture.componentRef.setInput('authority', 'evidence-backed');
fixture.detectChanges();
expect(component.label()).toBe('Evidence-backed');
expect(component.icon()).toBe('✓');
expect(component.badgeClass()).toContain('evidence-backed');
});
it('should display authority-threshold correctly', () => {
fixture.componentRef.setInput('authority', 'authority-threshold');
fixture.detectChanges();
expect(component.label()).toBe('High Confidence');
expect(component.icon()).toBe('★');
});
it('should show label by default', () => {
expect(component.showLabel()).toBe(true);
});
it('should hide label when showLabel is false', () => {
fixture.componentRef.setInput('showLabel', false);
fixture.detectChanges();
const labelElement = fixture.nativeElement.querySelector('.ai-authority-badge__label');
expect(labelElement).toBeNull();
});
it('should apply compact class when compact is true', () => {
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
expect(component.badgeClass()).toContain('compact');
});
it('should use custom tooltip when provided', () => {
const customTooltip = 'Custom tooltip text';
fixture.componentRef.setInput('customTooltip', customTooltip);
fixture.detectChanges();
expect(component.tooltip()).toBe(customTooltip);
});
it('should have correct aria-label for accessibility', () => {
fixture.componentRef.setInput('authority', 'evidence-backed');
fixture.detectChanges();
expect(component.ariaLabel()).toBe('AI content is evidence-backed');
});
describe('tooltip content', () => {
it('should have appropriate tooltip for suggestion', () => {
fixture.componentRef.setInput('authority', 'suggestion');
fixture.detectChanges();
expect(component.tooltip()).toContain('human review');
});
it('should have appropriate tooltip for evidence-backed', () => {
fixture.componentRef.setInput('authority', 'evidence-backed');
fixture.detectChanges();
expect(component.tooltip()).toContain('verified');
});
it('should have appropriate tooltip for authority-threshold', () => {
fixture.componentRef.setInput('authority', 'authority-threshold');
fixture.detectChanges();
expect(component.tooltip()).toContain('high-confidence');
});
});
});

View File

@@ -0,0 +1,186 @@
/**
* AI Authority Badge Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-01
*
* Displays authority level for AI-generated content:
* - Evidence-backed (green): Claims are verified against evidence
* - Suggestion (amber): AI output not fully backed by evidence
*/
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* AI authority levels.
*/
export type AIAuthority = 'evidence-backed' | 'suggestion' | 'authority-threshold';
@Component({
selector: 'stella-ai-authority-badge',
standalone: true,
imports: [CommonModule],
template: `
<span
class="ai-authority-badge"
[class]="badgeClass()"
[attr.title]="tooltip()"
[attr.aria-label]="ariaLabel()"
role="status"
>
<span class="ai-authority-badge__icon" aria-hidden="true">{{ icon() }}</span>
@if (showLabel()) {
<span class="ai-authority-badge__label">{{ label() }}</span>
}
</span>
`,
styles: [`
.ai-authority-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
cursor: help;
transition: opacity 0.15s, transform 0.1s;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
}
.ai-authority-badge__icon {
font-size: 0.75rem;
line-height: 1;
}
.ai-authority-badge__label {
white-space: nowrap;
}
// Evidence-backed: green - indicates verified claims
.ai-authority-badge--evidence-backed {
background: rgba(40, 167, 69, 0.15);
color: #28a745;
border: 1px solid rgba(40, 167, 69, 0.3);
}
// Suggestion: amber - AI output requires human review
.ai-authority-badge--suggestion {
background: rgba(255, 193, 7, 0.15);
color: #d39e00;
border: 1px solid rgba(255, 193, 7, 0.3);
}
// Authority threshold: blue - high confidence automation-ready
.ai-authority-badge--authority-threshold {
background: rgba(0, 123, 255, 0.15);
color: #007bff;
border: 1px solid rgba(0, 123, 255, 0.3);
}
// Compact variant
.ai-authority-badge--compact {
padding: 0.0625rem 0.375rem;
font-size: 0.625rem;
}
`]
})
export class AiAuthorityBadgeComponent {
/**
* Authority level.
*/
readonly authority = input<AIAuthority>('suggestion');
/**
* Whether to show the text label.
*/
readonly showLabel = input<boolean>(true);
/**
* Compact mode for tighter layouts.
*/
readonly compact = input<boolean>(false);
/**
* Custom tooltip override.
*/
readonly customTooltip = input<string | undefined>(undefined);
/**
* Computed CSS class.
*/
readonly badgeClass = computed(() => {
const base = `ai-authority-badge ai-authority-badge--${this.authority()}`;
return this.compact() ? `${base} ai-authority-badge--compact` : base;
});
/**
* Computed icon.
*/
readonly icon = computed(() => {
switch (this.authority()) {
case 'evidence-backed':
return '✓';
case 'authority-threshold':
return '★';
case 'suggestion':
default:
return '◇';
}
});
/**
* Computed label text.
*/
readonly label = computed(() => {
switch (this.authority()) {
case 'evidence-backed':
return 'Evidence-backed';
case 'authority-threshold':
return 'High Confidence';
case 'suggestion':
default:
return 'Suggestion';
}
});
/**
* Computed tooltip.
*/
readonly tooltip = computed(() => {
if (this.customTooltip()) {
return this.customTooltip();
}
switch (this.authority()) {
case 'evidence-backed':
return 'AI claims are verified against evidence sources. Citations are resolvable and validated.';
case 'authority-threshold':
return 'AI output meets high-confidence threshold for automated processing.';
case 'suggestion':
default:
return 'AI suggestion requiring human review. Not all claims are evidence-backed.';
}
});
/**
* Aria label for accessibility.
*/
readonly ariaLabel = computed(() => {
switch (this.authority()) {
case 'evidence-backed':
return 'AI content is evidence-backed';
case 'authority-threshold':
return 'AI content meets authority threshold';
case 'suggestion':
default:
return 'AI content is a suggestion';
}
});
}

View File

@@ -0,0 +1,143 @@
/**
* AI Chip Component Tests.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-39
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AiChipComponent } from './ai-chip.component';
describe('AiChipComponent', () => {
let component: AiChipComponent;
let fixture: ComponentFixture<AiChipComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AiChipComponent]
}).compileComponents();
fixture = TestBed.createComponent(AiChipComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('label', 'Test Label');
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display the label', () => {
const labelElement = fixture.nativeElement.querySelector('.ai-chip__label');
expect(labelElement.textContent).toBe('Test Label');
});
it('should display icon when provided', () => {
fixture.componentRef.setInput('icon', '🔧');
fixture.detectChanges();
const iconElement = fixture.nativeElement.querySelector('.ai-chip__icon');
expect(iconElement.textContent).toBe('🔧');
});
it('should not display icon when not provided', () => {
const iconElement = fixture.nativeElement.querySelector('.ai-chip__icon');
expect(iconElement).toBeNull();
});
it('should apply action variant by default', () => {
expect(component.chipClass()).toContain('action');
});
it('should apply different variants correctly', () => {
const variants: Array<'action' | 'status' | 'evidence' | 'warning'> = ['action', 'status', 'evidence', 'warning'];
for (const variant of variants) {
fixture.componentRef.setInput('variant', variant);
fixture.detectChanges();
expect(component.chipClass()).toContain(variant);
}
});
it('should be disabled when disabled is true', () => {
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.disabled).toBe(true);
});
it('should show chevron when showChevron is true', () => {
fixture.componentRef.setInput('showChevron', true);
fixture.detectChanges();
const chevron = fixture.nativeElement.querySelector('.ai-chip__chevron');
expect(chevron).toBeTruthy();
});
it('should not show chevron by default', () => {
const chevron = fixture.nativeElement.querySelector('.ai-chip__chevron');
expect(chevron).toBeNull();
});
it('should apply pressed class when pressed is true', () => {
fixture.componentRef.setInput('pressed', true);
fixture.detectChanges();
expect(component.chipClass()).toContain('pressed');
});
it('should apply loading class when loading is true', () => {
fixture.componentRef.setInput('loading', true);
fixture.detectChanges();
expect(component.chipClass()).toContain('loading');
});
it('should emit clicked event on click', () => {
const spy = jest.spyOn(component.clicked, 'emit');
const button = fixture.nativeElement.querySelector('button');
button.click();
expect(spy).toHaveBeenCalled();
});
it('should not emit clicked event when disabled', () => {
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
const spy = jest.spyOn(component.clicked, 'emit');
component.handleClick(new MouseEvent('click'));
expect(spy).not.toHaveBeenCalled();
});
it('should not emit clicked event when loading', () => {
fixture.componentRef.setInput('loading', true);
fixture.detectChanges();
const spy = jest.spyOn(component.clicked, 'emit');
component.handleClick(new MouseEvent('click'));
expect(spy).not.toHaveBeenCalled();
});
it('should use tooltip as aria-label by default', () => {
fixture.componentRef.setInput('tooltip', 'Click to explain');
fixture.detectChanges();
expect(component.ariaLabel()).toBe('Click to explain');
});
it('should use custom aria-label when provided', () => {
fixture.componentRef.setInput('customAriaLabel', 'Custom label');
fixture.componentRef.setInput('tooltip', 'Different tooltip');
fixture.detectChanges();
expect(component.ariaLabel()).toBe('Custom label');
});
it('should use label as aria-label when no tooltip or custom label', () => {
expect(component.ariaLabel()).toBe('Test Label');
});
});

View File

@@ -0,0 +1,233 @@
/**
* AI Chip Base Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-02
*
* Base component for AI action chips. Follows the 3-5 word action chip pattern.
* Used as foundation for specialized chips (Explain, Fix, VexDraft, etc.)
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Chip variant types.
*/
export type AiChipVariant = 'action' | 'status' | 'evidence' | 'warning';
@Component({
selector: 'stella-ai-chip',
standalone: true,
imports: [CommonModule],
template: `
<button
type="button"
class="ai-chip"
[class]="chipClass()"
[disabled]="disabled()"
[attr.title]="tooltip()"
[attr.aria-label]="ariaLabel()"
[attr.aria-pressed]="pressed()"
(click)="handleClick($event)"
>
@if (icon()) {
<span class="ai-chip__icon" aria-hidden="true">{{ icon() }}</span>
}
<span class="ai-chip__label">{{ label() }}</span>
@if (showChevron()) {
<span class="ai-chip__chevron" aria-hidden="true"></span>
}
</button>
`,
styles: [`
.ai-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border-radius: 16px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s ease;
white-space: nowrap;
&:hover:not(:disabled) {
transform: translateY(-1px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ai-chip__icon {
font-size: 0.875rem;
line-height: 1;
}
.ai-chip__label {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.ai-chip__chevron {
font-size: 0.875rem;
opacity: 0.7;
margin-left: 0.125rem;
}
// Action variant: primary action, blue
.ai-chip--action {
background: rgba(79, 70, 229, 0.12);
color: #4f46e5;
border: 1px solid rgba(79, 70, 229, 0.25);
&:hover:not(:disabled) {
background: rgba(79, 70, 229, 0.2);
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.2);
}
}
// Status variant: informational, gray
.ai-chip--status {
background: rgba(107, 114, 128, 0.12);
color: #6b7280;
border: 1px solid rgba(107, 114, 128, 0.25);
&:hover:not(:disabled) {
background: rgba(107, 114, 128, 0.2);
}
}
// Evidence variant: evidence-related, green
.ai-chip--evidence {
background: rgba(16, 185, 129, 0.12);
color: #059669;
border: 1px solid rgba(16, 185, 129, 0.25);
&:hover:not(:disabled) {
background: rgba(16, 185, 129, 0.2);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.2);
}
}
// Warning variant: needs attention, amber
.ai-chip--warning {
background: rgba(245, 158, 11, 0.12);
color: #d97706;
border: 1px solid rgba(245, 158, 11, 0.25);
&:hover:not(:disabled) {
background: rgba(245, 158, 11, 0.2);
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.2);
}
}
// Pressed state
.ai-chip--pressed {
background: rgba(79, 70, 229, 0.25) !important;
}
// Loading state
.ai-chip--loading {
.ai-chip__icon {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`]
})
export class AiChipComponent {
/**
* Chip label (max 5 words recommended).
*/
readonly label = input.required<string>();
/**
* Optional icon (emoji or icon character).
*/
readonly icon = input<string>('');
/**
* Chip variant.
*/
readonly variant = input<AiChipVariant>('action');
/**
* Whether the chip is disabled.
*/
readonly disabled = input<boolean>(false);
/**
* Whether to show chevron (indicates drill-down).
*/
readonly showChevron = input<boolean>(false);
/**
* Whether chip is in pressed/active state.
*/
readonly pressed = input<boolean>(false);
/**
* Whether chip is in loading state.
*/
readonly loading = input<boolean>(false);
/**
* Tooltip text.
*/
readonly tooltip = input<string>('');
/**
* Aria label override.
*/
readonly customAriaLabel = input<string | undefined>(undefined);
/**
* Click event.
*/
readonly clicked = output<MouseEvent>();
/**
* Computed CSS class.
*/
readonly chipClass = computed(() => {
let cls = `ai-chip ai-chip--${this.variant()}`;
if (this.pressed()) cls += ' ai-chip--pressed';
if (this.loading()) cls += ' ai-chip--loading';
return cls;
});
/**
* Aria label for accessibility.
*/
readonly ariaLabel = computed(() =>
this.customAriaLabel() ?? this.tooltip() ?? this.label()
);
/**
* Handle click.
*/
handleClick(event: MouseEvent): void {
if (!this.disabled() && !this.loading()) {
this.clicked.emit(event);
}
}
}

View File

@@ -0,0 +1,105 @@
/**
* AI Explain Chip Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-03
*
* Specialized chip for triggering AI explanations.
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AiChipComponent } from './ai-chip.component';
/**
* Explanation context types.
*/
export type ExplainContext = 'vulnerability' | 'path' | 'policy' | 'evidence' | 'risk';
@Component({
selector: 'stella-ai-explain-chip',
standalone: true,
imports: [CommonModule, AiChipComponent],
template: `
<stella-ai-chip
[label]="chipLabel()"
[icon]="'💡'"
[variant]="hasEvidence() ? 'evidence' : 'action'"
[disabled]="disabled()"
[loading]="loading()"
[tooltip]="tooltipText()"
[showChevron]="true"
(clicked)="onExplainClick($event)"
/>
`
})
export class AiExplainChipComponent {
/**
* Context of what to explain.
*/
readonly context = input<ExplainContext>('vulnerability');
/**
* Whether explanation is backed by evidence.
*/
readonly hasEvidence = input<boolean>(false);
/**
* Subject to explain (CVE ID, etc.).
*/
readonly subject = input<string>('');
/**
* Whether chip is disabled.
*/
readonly disabled = input<boolean>(false);
/**
* Loading state.
*/
readonly loading = input<boolean>(false);
/**
* Click event emitting context and subject.
*/
readonly explain = output<{ context: ExplainContext; subject: string }>();
/**
* Computed label.
*/
readonly chipLabel = computed(() => {
return this.hasEvidence() ? 'Explain with evidence' : 'Explain';
});
/**
* Computed tooltip.
*/
readonly tooltipText = computed(() => {
const sub = this.subject();
const ctx = this.context();
switch (ctx) {
case 'vulnerability':
return sub ? `Explain why ${sub} is relevant` : 'Explain this vulnerability';
case 'path':
return 'Explain this code path';
case 'policy':
return 'Explain this policy decision';
case 'evidence':
return 'Explain this evidence';
case 'risk':
return 'Explain risk factors';
default:
return 'Get AI explanation';
}
});
/**
* Handle click.
*/
onExplainClick(event: MouseEvent): void {
this.explain.emit({
context: this.context(),
subject: this.subject()
});
}
}

View File

@@ -0,0 +1,166 @@
/**
* AI Exploitability Chip Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-07
*
* Specialized chip showing AI assessment of exploitability.
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AiChipComponent } from './ai-chip.component';
/**
* Exploitability assessment levels.
*/
export type ExploitabilityLevel = 'confirmed' | 'likely' | 'unlikely' | 'not-exploitable' | 'unknown';
@Component({
selector: 'stella-ai-exploitability-chip',
standalone: true,
imports: [CommonModule, AiChipComponent],
template: `
<stella-ai-chip
[label]="chipLabel()"
[icon]="chipIcon()"
[variant]="chipVariant()"
[disabled]="disabled()"
[loading]="loading()"
[tooltip]="tooltipText()"
[showChevron]="true"
(clicked)="onExploitabilityClick($event)"
/>
`
})
export class AiExploitabilityChipComponent {
/**
* Exploitability level.
*/
readonly level = input<ExploitabilityLevel>('unknown');
/**
* Confidence in assessment (0.0-1.0).
*/
readonly confidence = input<number>(0.5);
/**
* Key reason for the assessment.
*/
readonly reason = input<string>('');
/**
* Whether assessment is backed by evidence.
*/
readonly evidenceBacked = input<boolean>(false);
/**
* Whether chip is disabled.
*/
readonly disabled = input<boolean>(false);
/**
* Loading state.
*/
readonly loading = input<boolean>(false);
/**
* Click event.
*/
readonly showDetails = output<{ level: ExploitabilityLevel; confidence: number }>();
/**
* Computed label.
*/
readonly chipLabel = computed(() => {
switch (this.level()) {
case 'confirmed':
return 'Reachable Path';
case 'likely':
return 'Likely Exploitable';
case 'unlikely':
return 'Unlikely Exploitable';
case 'not-exploitable':
return 'Not Exploitable';
case 'unknown':
default:
return 'Unknown Risk';
}
});
/**
* Computed icon.
*/
readonly chipIcon = computed(() => {
switch (this.level()) {
case 'confirmed':
return '⚠';
case 'likely':
return '❗';
case 'unlikely':
return '↓';
case 'not-exploitable':
return '✓';
case 'unknown':
default:
return '?';
}
});
/**
* Computed variant.
*/
readonly chipVariant = computed(() => {
switch (this.level()) {
case 'confirmed':
case 'likely':
return 'warning';
case 'unlikely':
case 'not-exploitable':
return 'evidence';
case 'unknown':
default:
return 'status';
}
});
/**
* Computed tooltip.
*/
readonly tooltipText = computed(() => {
const reason = this.reason();
const confidence = Math.round(this.confidence() * 100);
const backed = this.evidenceBacked() ? ' (evidence-backed)' : ' (AI assessment)';
switch (this.level()) {
case 'confirmed':
return reason
? `Reachable path confirmed: ${reason}${backed}`
: `Exploitation path confirmed (${confidence}% confidence)${backed}`;
case 'likely':
return reason
? `Likely exploitable: ${reason}${backed}`
: `Likely exploitable (${confidence}% confidence)${backed}`;
case 'unlikely':
return reason
? `Unlikely exploitable: ${reason}${backed}`
: `Unlikely to be exploitable (${confidence}% confidence)${backed}`;
case 'not-exploitable':
return reason
? `Not exploitable: ${reason}${backed}`
: `Not exploitable in this context (${confidence}% confidence)${backed}`;
case 'unknown':
default:
return 'Exploitability could not be determined';
}
});
/**
* Handle click.
*/
onExploitabilityClick(event: MouseEvent): void {
this.showDetails.emit({
level: this.level(),
confidence: this.confidence()
});
}
}

View File

@@ -0,0 +1,160 @@
/**
* AI Fix Chip Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-04
*
* Specialized chip for triggering AI-generated fixes/remediations.
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AiChipComponent } from './ai-chip.component';
/**
* Fix availability state.
*/
export type FixState = 'available' | 'partial' | 'pending' | 'none';
@Component({
selector: 'stella-ai-fix-chip',
standalone: true,
imports: [CommonModule, AiChipComponent],
template: `
<stella-ai-chip
[label]="chipLabel()"
[icon]="chipIcon()"
[variant]="chipVariant()"
[disabled]="disabled() || state() === 'none'"
[loading]="loading()"
[tooltip]="tooltipText()"
[showChevron]="state() !== 'none'"
(clicked)="onFixClick($event)"
/>
`
})
export class AiFixChipComponent {
/**
* Fix availability state.
*/
readonly state = input<FixState>('available');
/**
* Whether a PR is ready.
*/
readonly prReady = input<boolean>(false);
/**
* Number of steps in the remediation.
*/
readonly stepCount = input<number>(0);
/**
* Target vulnerability or component.
*/
readonly target = input<string>('');
/**
* Whether chip is disabled.
*/
readonly disabled = input<boolean>(false);
/**
* Loading state.
*/
readonly loading = input<boolean>(false);
/**
* Click event.
*/
readonly fix = output<{ target: string; prReady: boolean }>();
/**
* Computed label.
*/
readonly chipLabel = computed(() => {
switch (this.state()) {
case 'available':
return this.prReady() ? 'Fix in 1 PR' : 'Fix available';
case 'partial':
return 'Partial fix';
case 'pending':
return 'Fix pending';
case 'none':
default:
return 'No fix';
}
});
/**
* Computed icon.
*/
readonly chipIcon = computed(() => {
switch (this.state()) {
case 'available':
return this.prReady() ? '🔧' : '🛠';
case 'partial':
return '⚙';
case 'pending':
return '⏳';
case 'none':
default:
return '✗';
}
});
/**
* Computed variant.
*/
readonly chipVariant = computed(() => {
switch (this.state()) {
case 'available':
return this.prReady() ? 'evidence' : 'action';
case 'partial':
return 'warning';
case 'pending':
return 'status';
case 'none':
default:
return 'status';
}
});
/**
* Computed tooltip.
*/
readonly tooltipText = computed(() => {
const target = this.target();
const steps = this.stepCount();
switch (this.state()) {
case 'available':
if (this.prReady()) {
return target
? `Open PR to fix ${target}`
: 'Open PR to apply the fix';
}
return steps > 0
? `${steps} step${steps === 1 ? '' : 's'} to remediate`
: 'View remediation plan';
case 'partial':
return 'Partial remediation available - some manual steps required';
case 'pending':
return 'Fix is being generated';
case 'none':
default:
return 'No automated fix available';
}
});
/**
* Handle click.
*/
onFixClick(event: MouseEvent): void {
if (this.state() !== 'none') {
this.fix.emit({
target: this.target(),
prReady: this.prReady()
});
}
}
}

View File

@@ -0,0 +1,130 @@
/**
* AI Needs Evidence Chip Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-06
*
* Specialized chip indicating what evidence is needed to close uncertainty.
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AiChipComponent } from './ai-chip.component';
/**
* Evidence types that might be needed.
*/
export type EvidenceType = 'runtime' | 'reachability' | 'vex' | 'test' | 'patch' | 'config';
@Component({
selector: 'stella-ai-needs-evidence-chip',
standalone: true,
imports: [CommonModule, AiChipComponent],
template: `
<stella-ai-chip
[label]="chipLabel()"
[icon]="'🔍'"
[variant]="'warning'"
[disabled]="disabled()"
[loading]="loading()"
[tooltip]="tooltipText()"
[showChevron]="true"
(clicked)="onEvidenceClick($event)"
/>
`
})
export class AiNeedsEvidenceChipComponent {
/**
* Type of evidence needed.
*/
readonly evidenceType = input<EvidenceType>('runtime');
/**
* Brief description of what's needed.
*/
readonly needed = input<string>('');
/**
* Effort level to obtain evidence.
*/
readonly effort = input<'low' | 'medium' | 'high'>('medium');
/**
* Whether chip is disabled.
*/
readonly disabled = input<boolean>(false);
/**
* Loading state.
*/
readonly loading = input<boolean>(false);
/**
* Click event.
*/
readonly gatherEvidence = output<{ evidenceType: EvidenceType; needed: string }>();
/**
* Computed label.
*/
readonly chipLabel = computed(() => {
const type = this.evidenceType();
switch (type) {
case 'runtime':
return 'Needs: runtime';
case 'reachability':
return 'Needs: reachability';
case 'vex':
return 'Needs: VEX';
case 'test':
return 'Needs: test';
case 'patch':
return 'Needs: patch check';
case 'config':
return 'Needs: config';
default:
return 'Gather evidence';
}
});
/**
* Computed tooltip.
*/
readonly tooltipText = computed(() => {
const type = this.evidenceType();
const needed = this.needed();
const effort = this.effort();
const effortText = effort === 'low' ? '(quick)' : effort === 'high' ? '(requires effort)' : '';
if (needed) {
return `${needed} ${effortText}`.trim();
}
switch (type) {
case 'runtime':
return `Runtime observation needed to confirm exploitability ${effortText}`.trim();
case 'reachability':
return `Reachability analysis needed ${effortText}`.trim();
case 'vex':
return `VEX statement from vendor needed ${effortText}`.trim();
case 'test':
return `Test execution needed to verify ${effortText}`.trim();
case 'patch':
return `Patch verification needed ${effortText}`.trim();
case 'config':
return `Configuration check needed ${effortText}`.trim();
default:
return 'Additional evidence needed';
}
});
/**
* Handle click.
*/
onEvidenceClick(event: MouseEvent): void {
this.gatherEvidence.emit({
evidenceType: this.evidenceType(),
needed: this.needed()
});
}
}

View File

@@ -0,0 +1,172 @@
/**
* AI Summary Component Tests.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-40
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AiSummaryComponent, AiSummaryExpanded } from './ai-summary.component';
describe('AiSummaryComponent', () => {
let component: AiSummaryComponent;
let fixture: ComponentFixture<AiSummaryComponent>;
const defaultLines = {
line1: 'Package libfoo upgraded from 1.2.3 to 1.2.5',
line2: 'Fixes CVE-2025-1234 which was reachable in production',
line3: 'Review and merge the PR to complete remediation'
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AiSummaryComponent]
}).compileComponents();
fixture = TestBed.createComponent(AiSummaryComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('line1', defaultLines.line1);
fixture.componentRef.setInput('line2', defaultLines.line2);
fixture.componentRef.setInput('line3', defaultLines.line3);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display all three lines', () => {
const lines = fixture.nativeElement.querySelectorAll('.ai-summary__line');
expect(lines.length).toBe(3);
expect(lines[0].textContent).toContain(defaultLines.line1);
expect(lines[1].textContent).toContain(defaultLines.line2);
expect(lines[2].textContent).toContain(defaultLines.line3);
});
it('should display authority badge with suggestion by default', () => {
expect(component.authority()).toBe('suggestion');
});
it('should display authority badge with evidence-backed when set', () => {
fixture.componentRef.setInput('authority', 'evidence-backed');
fixture.detectChanges();
expect(component.authority()).toBe('evidence-backed');
});
it('should not show expand button by default', () => {
const expandBtn = fixture.nativeElement.querySelector('.ai-summary__expand-btn');
expect(expandBtn).toBeNull();
});
it('should show expand button when hasMore is true', () => {
fixture.componentRef.setInput('hasMore', true);
fixture.detectChanges();
const expandBtn = fixture.nativeElement.querySelector('.ai-summary__expand-btn');
expect(expandBtn).toBeTruthy();
});
it('should toggle expanded state on expand button click', () => {
fixture.componentRef.setInput('hasMore', true);
fixture.detectChanges();
expect(component.expanded()).toBe(false);
component.toggleExpanded();
expect(component.expanded()).toBe(true);
component.toggleExpanded();
expect(component.expanded()).toBe(false);
});
it('should display expanded content when expanded', () => {
const expandedContent: AiSummaryExpanded = {
fullExplanation: 'This is the full explanation with more details.',
citations: [
{
claim: 'Vulnerability is reachable',
evidenceId: 'sha256:evidence1',
evidenceType: 'reachability',
verified: true
}
]
};
fixture.componentRef.setInput('hasMore', true);
fixture.componentRef.setInput('expandedContent', expandedContent);
fixture.detectChanges();
component.toggleExpanded();
fixture.detectChanges();
const fullText = fixture.nativeElement.querySelector('.ai-summary__full-text');
expect(fullText.textContent).toContain('full explanation');
const citations = fixture.nativeElement.querySelector('.ai-summary__citations');
expect(citations).toBeTruthy();
});
it('should emit citation click event', () => {
const citation = {
claim: 'Test claim',
evidenceId: 'sha256:evidence1',
evidenceType: 'sbom',
verified: true
};
const spy = jest.spyOn(component.citationClick, 'emit');
component.onCitationClick(citation);
expect(spy).toHaveBeenCalledWith(citation);
});
it('should display alternatives when provided in expanded content', () => {
const expandedContent: AiSummaryExpanded = {
fullExplanation: 'Full explanation.',
citations: [],
alternatives: ['Alternative fix 1', 'Alternative fix 2']
};
fixture.componentRef.setInput('hasMore', true);
fixture.componentRef.setInput('expandedContent', expandedContent);
fixture.detectChanges();
component.toggleExpanded();
fixture.detectChanges();
const alternatives = fixture.nativeElement.querySelector('.ai-summary__alternatives');
expect(alternatives).toBeTruthy();
});
it('should display model label when provided', () => {
fixture.componentRef.setInput('modelLabel', 'claude-3-opus');
fixture.detectChanges();
const modelLabel = fixture.nativeElement.querySelector('.ai-summary__model');
expect(modelLabel.textContent).toContain('claude-3-opus');
});
it('should have correct aria-expanded attribute', () => {
fixture.componentRef.setInput('hasMore', true);
fixture.detectChanges();
const container = fixture.nativeElement.querySelector('.ai-summary');
expect(container.getAttribute('aria-expanded')).toBe('false');
component.toggleExpanded();
fixture.detectChanges();
expect(container.getAttribute('aria-expanded')).toBe('true');
});
it('should apply expanded class when expanded', () => {
fixture.componentRef.setInput('hasMore', true);
fixture.detectChanges();
component.toggleExpanded();
fixture.detectChanges();
const container = fixture.nativeElement.querySelector('.ai-summary');
expect(container.classList).toContain('ai-summary--expanded');
});
});

View File

@@ -0,0 +1,386 @@
/**
* AI Summary Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Tasks: AIUX-08, AIUX-09, AIUX-10, AIUX-11
*
* 3-line AI summary following the progressive disclosure pattern.
* - Line 1: What changed
* - Line 2: Why it matters
* - Line 3: Next action
*/
import { Component, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AiAuthorityBadgeComponent, AIAuthority } from './ai-authority-badge.component';
/**
* Citation reference in the summary.
*/
export interface AiSummaryCitation {
/** Claim text */
claim: string;
/** Evidence node ID */
evidenceId: string;
/** Evidence type */
evidenceType: string;
/** Whether verified */
verified: boolean;
}
/**
* Expanded content for the AI summary.
*/
export interface AiSummaryExpanded {
/** Full explanation */
fullExplanation: string;
/** Citations */
citations: AiSummaryCitation[];
/** Alternative options if applicable */
alternatives?: string[];
}
@Component({
selector: 'stella-ai-summary',
standalone: true,
imports: [CommonModule, AiAuthorityBadgeComponent],
template: `
<div
class="ai-summary"
[class.ai-summary--expanded]="expanded()"
[attr.aria-expanded]="expanded()"
>
<div class="ai-summary__header">
<stella-ai-authority-badge
[authority]="authority()"
[compact]="true"
/>
@if (modelLabel()) {
<span class="ai-summary__model">{{ modelLabel() }}</span>
}
</div>
<div class="ai-summary__content">
<p class="ai-summary__line ai-summary__line--what">
<span class="ai-summary__line-label">What:</span>
{{ line1() }}
</p>
<p class="ai-summary__line ai-summary__line--why">
<span class="ai-summary__line-label">Why:</span>
{{ line2() }}
</p>
<p class="ai-summary__line ai-summary__line--action">
<span class="ai-summary__line-label">Next:</span>
{{ line3() }}
</p>
</div>
@if (hasMore()) {
<div class="ai-summary__expand-section">
<button
type="button"
class="ai-summary__expand-btn"
(click)="toggleExpanded()"
[attr.aria-label]="expanded() ? 'Collapse details' : 'Show ' + expandLabel()"
>
{{ expanded() ? 'Hide details' : 'Show ' + expandLabel() }}
<span class="ai-summary__chevron" [class.ai-summary__chevron--up]="expanded()">
</span>
</button>
</div>
}
@if (expanded() && expandedContent()) {
<div class="ai-summary__expanded" @expandCollapse>
<div class="ai-summary__full-text">
{{ expandedContent()!.fullExplanation }}
</div>
@if (expandedContent()!.citations.length > 0) {
<div class="ai-summary__citations">
<h4 class="ai-summary__citations-header">Evidence Citations</h4>
<ul class="ai-summary__citations-list">
@for (citation of expandedContent()!.citations; track citation.evidenceId) {
<li class="ai-summary__citation">
<button
type="button"
class="ai-summary__citation-link"
[class.ai-summary__citation-link--verified]="citation.verified"
(click)="onCitationClick(citation)"
>
<span class="ai-summary__citation-icon">
{{ citation.verified ? '✓' : '◇' }}
</span>
<span class="ai-summary__citation-claim">{{ citation.claim }}</span>
<span class="ai-summary__citation-type">[{{ citation.evidenceType }}]</span>
</button>
</li>
}
</ul>
</div>
}
@if (expandedContent()!.alternatives && expandedContent()!.alternatives!.length > 0) {
<div class="ai-summary__alternatives">
<h4 class="ai-summary__alternatives-header">Alternatives</h4>
<ul class="ai-summary__alternatives-list">
@for (alt of expandedContent()!.alternatives!; track alt) {
<li class="ai-summary__alternative">{{ alt }}</li>
}
</ul>
</div>
}
</div>
}
</div>
`,
styles: [`
.ai-summary {
background: rgba(107, 114, 128, 0.05);
border: 1px solid rgba(107, 114, 128, 0.15);
border-radius: 8px;
padding: 0.75rem;
font-size: 0.875rem;
}
.ai-summary__header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.ai-summary__model {
font-size: 0.6875rem;
color: #6b7280;
font-style: italic;
}
.ai-summary__content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ai-summary__line {
margin: 0;
line-height: 1.4;
color: #374151;
display: flex;
gap: 0.5rem;
}
.ai-summary__line-label {
font-weight: 600;
color: #6b7280;
min-width: 2.5rem;
font-size: 0.75rem;
text-transform: uppercase;
}
.ai-summary__line--what {
color: #1f2937;
}
.ai-summary__line--why {
color: #4b5563;
}
.ai-summary__line--action {
color: #4f46e5;
font-weight: 500;
}
.ai-summary__expand-section {
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(107, 114, 128, 0.1);
}
.ai-summary__expand-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid rgba(79, 70, 229, 0.3);
border-radius: 4px;
color: #4f46e5;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(79, 70, 229, 0.08);
}
&:focus-visible {
outline: 2px solid #4f46e5;
outline-offset: 2px;
}
}
.ai-summary__chevron {
transition: transform 0.2s;
font-size: 0.875rem;
}
.ai-summary__chevron--up {
transform: rotate(90deg);
}
.ai-summary__expanded {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(107, 114, 128, 0.15);
}
.ai-summary__full-text {
color: #374151;
line-height: 1.6;
margin-bottom: 1rem;
}
.ai-summary__citations,
.ai-summary__alternatives {
margin-top: 0.75rem;
}
.ai-summary__citations-header,
.ai-summary__alternatives-header {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
margin: 0 0 0.5rem 0;
}
.ai-summary__citations-list,
.ai-summary__alternatives-list {
list-style: none;
padding: 0;
margin: 0;
}
.ai-summary__citation {
margin-bottom: 0.25rem;
}
.ai-summary__citation-link {
display: flex;
align-items: flex-start;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
border-radius: 4px;
color: #4b5563;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
width: 100%;
transition: background 0.15s;
&:hover {
background: rgba(79, 70, 229, 0.08);
}
}
.ai-summary__citation-link--verified {
.ai-summary__citation-icon {
color: #059669;
}
}
.ai-summary__citation-icon {
color: #d97706;
flex-shrink: 0;
}
.ai-summary__citation-claim {
flex: 1;
}
.ai-summary__citation-type {
font-size: 0.6875rem;
color: #9ca3af;
flex-shrink: 0;
}
.ai-summary__alternative {
padding: 0.25rem 0;
color: #4b5563;
font-size: 0.8125rem;
&::before {
content: '• ';
color: #9ca3af;
}
}
`]
})
export class AiSummaryComponent {
/**
* Line 1: What changed.
*/
readonly line1 = input.required<string>();
/**
* Line 2: Why it matters.
*/
readonly line2 = input.required<string>();
/**
* Line 3: Next action.
*/
readonly line3 = input.required<string>();
/**
* Authority level.
*/
readonly authority = input<AIAuthority>('suggestion');
/**
* Whether there is more content available.
*/
readonly hasMore = input<boolean>(false);
/**
* Label for expand button.
*/
readonly expandLabel = input<string>('details');
/**
* Expanded content.
*/
readonly expandedContent = input<AiSummaryExpanded | null>(null);
/**
* Model label (optional).
*/
readonly modelLabel = input<string>('');
/**
* Expanded state.
*/
readonly expanded = signal(false);
/**
* Citation click event.
*/
readonly citationClick = output<AiSummaryCitation>();
/**
* Toggle expanded state.
*/
toggleExpanded(): void {
this.expanded.update(v => !v);
}
/**
* Handle citation click.
*/
onCitationClick(citation: AiSummaryCitation): void {
this.citationClick.emit(citation);
}
}

View File

@@ -0,0 +1,157 @@
/**
* AI VEX Draft Chip Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-05
*
* Specialized chip for triggering AI-generated VEX statement drafts.
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AiChipComponent } from './ai-chip.component';
/**
* VEX draft state.
*/
export type VexDraftState = 'available' | 'ready' | 'conflict' | 'none';
@Component({
selector: 'stella-ai-vex-draft-chip',
standalone: true,
imports: [CommonModule, AiChipComponent],
template: `
<stella-ai-chip
[label]="chipLabel()"
[icon]="chipIcon()"
[variant]="chipVariant()"
[disabled]="disabled() || state() === 'none'"
[loading]="loading()"
[tooltip]="tooltipText()"
[showChevron]="state() !== 'none'"
(clicked)="onVexClick($event)"
/>
`
})
export class AiVexDraftChipComponent {
/**
* VEX draft state.
*/
readonly state = input<VexDraftState>('available');
/**
* Proposed VEX status if available.
*/
readonly proposedStatus = input<string>('');
/**
* Whether draft is auto-approvable.
*/
readonly autoApprovable = input<boolean>(false);
/**
* Target vulnerability.
*/
readonly vulnerabilityId = input<string>('');
/**
* Whether chip is disabled.
*/
readonly disabled = input<boolean>(false);
/**
* Loading state.
*/
readonly loading = input<boolean>(false);
/**
* Click event.
*/
readonly draftVex = output<{ vulnerabilityId: string; proposedStatus: string }>();
/**
* Computed label.
*/
readonly chipLabel = computed(() => {
switch (this.state()) {
case 'ready':
return 'VEX ready';
case 'available':
return 'Draft VEX';
case 'conflict':
return 'VEX conflict';
case 'none':
default:
return 'No VEX';
}
});
/**
* Computed icon.
*/
readonly chipIcon = computed(() => {
switch (this.state()) {
case 'ready':
return '📋';
case 'available':
return '📝';
case 'conflict':
return '⚠';
case 'none':
default:
return '—';
}
});
/**
* Computed variant.
*/
readonly chipVariant = computed(() => {
switch (this.state()) {
case 'ready':
return this.autoApprovable() ? 'evidence' : 'action';
case 'available':
return 'action';
case 'conflict':
return 'warning';
case 'none':
default:
return 'status';
}
});
/**
* Computed tooltip.
*/
readonly tooltipText = computed(() => {
const status = this.proposedStatus();
const vulnId = this.vulnerabilityId();
switch (this.state()) {
case 'ready':
return status
? `VEX statement ready: ${status}`
: 'VEX statement draft is ready for review';
case 'available':
return vulnId
? `Generate VEX statement for ${vulnId}`
: 'Generate VEX statement draft';
case 'conflict':
return 'Draft conflicts with existing VEX - review required';
case 'none':
default:
return 'VEX drafting not available for this context';
}
});
/**
* Handle click.
*/
onVexClick(event: MouseEvent): void {
if (this.state() !== 'none') {
this.draftVex.emit({
vulnerabilityId: this.vulnerabilityId(),
proposedStatus: this.proposedStatus()
});
}
}
}

View File

@@ -0,0 +1,107 @@
/**
* Ask Stella Button Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-19
*
* Small entry point button for opening the Ask Stella command bar.
*/
import { Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'stella-ask-stella-button',
standalone: true,
imports: [CommonModule],
template: `
<button
type="button"
class="ask-stella-btn"
[class.ask-stella-btn--compact]="compact()"
[disabled]="disabled()"
[attr.title]="'Ask Stella for help (Ctrl+K)'"
[attr.aria-label]="'Open Ask Stella command bar'"
(click)="onClick($event)"
>
<span class="ask-stella-btn__icon">✨</span>
@if (!compact()) {
<span class="ask-stella-btn__label">Ask Stella</span>
}
</button>
`,
styles: [`
.ask-stella-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: linear-gradient(135deg, rgba(79, 70, 229, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
border: 1px solid rgba(79, 70, 229, 0.25);
border-radius: 6px;
color: #4f46e5;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(79, 70, 229, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
border-color: rgba(79, 70, 229, 0.4);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.2);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&:focus-visible {
outline: 2px solid #4f46e5;
outline-offset: 2px;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ask-stella-btn--compact {
padding: 0.375rem;
border-radius: 50%;
width: 32px;
height: 32px;
justify-content: center;
}
.ask-stella-btn__icon {
font-size: 1rem;
line-height: 1;
}
.ask-stella-btn__label {
white-space: nowrap;
}
`]
})
export class AskStellaButtonComponent {
/**
* Compact mode (icon only).
*/
readonly compact = input<boolean>(false);
/**
* Disabled state.
*/
readonly disabled = input<boolean>(false);
/**
* Click event.
*/
readonly clicked = output<void>();
onClick(event: MouseEvent): void {
event.stopPropagation();
this.clicked.emit();
}
}

View File

@@ -0,0 +1,459 @@
/**
* Ask Stella Panel Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Tasks: AIUX-20, AIUX-21, AIUX-22, AIUX-23, AIUX-24
*
* Command bar panel for contextual AI queries.
* Shows suggested prompts prominently, freeform input as secondary.
*/
import { Component, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AiChipComponent } from './ai-chip.component';
/**
* Context scope for the panel.
*/
export interface AskStellaContext {
/** Vulnerability ID if applicable */
vulnerabilityId?: string;
/** Component PURL if applicable */
componentPurl?: string;
/** Service name if applicable */
serviceName?: string;
/** Environment (prod, staging, etc.) */
environment?: string;
/** Image digest if applicable */
imageDigest?: string;
}
/**
* Suggested prompt.
*/
export interface SuggestedPrompt {
/** Prompt ID */
id: string;
/** Display label */
label: string;
/** Full prompt text */
prompt: string;
/** Icon */
icon?: string;
}
/**
* Query result from AI.
*/
export interface AskStellaResult {
/** Response text */
response: string;
/** Authority level */
authority: 'evidence-backed' | 'suggestion';
/** Citations if any */
citations?: Array<{ claim: string; evidenceId: string }>;
/** Follow-up suggestions */
followUps?: string[];
}
@Component({
selector: 'stella-ask-stella-panel',
standalone: true,
imports: [CommonModule, FormsModule, AiChipComponent],
template: `
<div class="ask-stella-panel" [class.ask-stella-panel--loading]="isLoading()">
<header class="ask-stella-panel__header">
<h3 class="ask-stella-panel__title">Ask Stella</h3>
<div class="ask-stella-panel__context">
@for (chip of contextChips(); track chip.label) {
<span class="ask-stella-panel__context-chip">{{ chip.label }}</span>
}
</div>
<button
type="button"
class="ask-stella-panel__close"
(click)="onClose()"
aria-label="Close"
>
</button>
</header>
<div class="ask-stella-panel__suggestions">
@for (prompt of suggestedPrompts(); track prompt.id) {
<button
type="button"
class="ask-stella-panel__suggestion"
[disabled]="isLoading()"
(click)="onSuggestionClick(prompt)"
>
@if (prompt.icon) {
<span class="ask-stella-panel__suggestion-icon">{{ prompt.icon }}</span>
}
{{ prompt.label }}
</button>
}
</div>
<div class="ask-stella-panel__input-section">
<div class="ask-stella-panel__input-wrapper">
<input
type="text"
class="ask-stella-panel__input"
placeholder="Or type your question..."
[disabled]="isLoading()"
[(ngModel)]="freeformInput"
(keydown.enter)="onSubmitFreeform()"
/>
<button
type="button"
class="ask-stella-panel__submit"
[disabled]="isLoading() || !freeformInput().trim()"
(click)="onSubmitFreeform()"
>
{{ isLoading() ? '...' : 'Ask' }}
</button>
</div>
</div>
@if (result()) {
<div class="ask-stella-panel__result">
<div class="ask-stella-panel__result-header">
<span class="ask-stella-panel__result-authority"
[class.ask-stella-panel__result-authority--evidence]="result()!.authority === 'evidence-backed'"
>
{{ result()!.authority === 'evidence-backed' ? '✓ Evidence-backed' : '◇ Suggestion' }}
</span>
</div>
<div class="ask-stella-panel__result-content">
{{ result()!.response }}
</div>
@if (result()!.followUps && result()!.followUps!.length > 0) {
<div class="ask-stella-panel__followups">
<span class="ask-stella-panel__followups-label">Related questions:</span>
@for (followUp of result()!.followUps!; track followUp) {
<button
type="button"
class="ask-stella-panel__followup"
(click)="onFollowUp(followUp)"
>
{{ followUp }}
</button>
}
</div>
}
</div>
}
</div>
`,
styles: [`
.ask-stella-panel {
background: #fff;
border: 1px solid rgba(79, 70, 229, 0.2);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 1rem;
min-width: 400px;
max-width: 600px;
}
.ask-stella-panel--loading {
opacity: 0.8;
}
.ask-stella-panel__header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.ask-stella-panel__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #1f2937;
}
.ask-stella-panel__context {
display: flex;
gap: 0.375rem;
flex: 1;
}
.ask-stella-panel__context-chip {
display: inline-block;
padding: 0.125rem 0.5rem;
background: rgba(79, 70, 229, 0.1);
border-radius: 12px;
font-size: 0.6875rem;
color: #4f46e5;
}
.ask-stella-panel__close {
padding: 0.25rem;
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
font-size: 1rem;
line-height: 1;
border-radius: 4px;
&:hover {
color: #6b7280;
background: rgba(0, 0, 0, 0.05);
}
}
.ask-stella-panel__suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.ask-stella-panel__suggestion {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
background: rgba(79, 70, 229, 0.08);
border: 1px solid rgba(79, 70, 229, 0.2);
border-radius: 16px;
color: #4f46e5;
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
background: rgba(79, 70, 229, 0.15);
border-color: rgba(79, 70, 229, 0.3);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ask-stella-panel__suggestion-icon {
font-size: 0.875rem;
}
.ask-stella-panel__input-section {
margin-bottom: 0.75rem;
}
.ask-stella-panel__input-wrapper {
display: flex;
gap: 0.5rem;
}
.ask-stella-panel__input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
font-size: 0.875rem;
color: #374151;
&:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
&:disabled {
background: #f9fafb;
}
&::placeholder {
color: #9ca3af;
}
}
.ask-stella-panel__submit {
padding: 0.5rem 1rem;
background: #4f46e5;
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:hover:not(:disabled) {
background: #4338ca;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ask-stella-panel__result {
background: rgba(79, 70, 229, 0.03);
border: 1px solid rgba(79, 70, 229, 0.1);
border-radius: 8px;
padding: 0.75rem;
}
.ask-stella-panel__result-header {
margin-bottom: 0.5rem;
}
.ask-stella-panel__result-authority {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
color: #d97706;
}
.ask-stella-panel__result-authority--evidence {
color: #059669;
}
.ask-stella-panel__result-content {
font-size: 0.875rem;
line-height: 1.6;
color: #374151;
}
.ask-stella-panel__followups {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.ask-stella-panel__followups-label {
width: 100%;
font-size: 0.6875rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.ask-stella-panel__followup {
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid rgba(79, 70, 229, 0.2);
border-radius: 12px;
color: #4f46e5;
font-size: 0.75rem;
cursor: pointer;
&:hover {
background: rgba(79, 70, 229, 0.08);
}
}
`]
})
export class AskStellaPanelComponent {
/**
* Context for the query.
*/
readonly context = input<AskStellaContext>({});
/**
* Suggested prompts.
*/
readonly suggestedPrompts = input<SuggestedPrompt[]>([
{ id: 'explain', label: 'Explain why exploitable', prompt: 'Explain why this vulnerability is exploitable in this context', icon: '💡' },
{ id: 'evidence', label: 'Show minimal evidence', prompt: 'What is the minimum evidence needed to close this finding?', icon: '🔍' },
{ id: 'fix', label: 'How to fix?', prompt: 'How can I fix this vulnerability?', icon: '🔧' },
{ id: 'vex', label: 'Draft VEX', prompt: 'Draft a VEX statement for this finding', icon: '📝' },
{ id: 'test', label: 'What test closes Unknown?', prompt: 'What test would close the uncertainty on this finding?', icon: '🧪' }
]);
/**
* Loading state.
*/
readonly isLoading = signal(false);
/**
* Result from AI.
*/
readonly result = signal<AskStellaResult | null>(null);
/**
* Freeform input.
*/
readonly freeformInput = signal('');
/**
* Query submitted.
*/
readonly query = output<{ prompt: string; context: AskStellaContext }>();
/**
* Panel closed.
*/
readonly closed = output<void>();
/**
* Computed context chips.
*/
readonly contextChips = computed(() => {
const ctx = this.context();
const chips: Array<{ label: string }> = [];
if (ctx.vulnerabilityId) {
chips.push({ label: ctx.vulnerabilityId });
}
if (ctx.serviceName) {
chips.push({ label: ctx.serviceName });
}
if (ctx.environment) {
chips.push({ label: ctx.environment });
}
return chips;
});
/**
* Handle suggestion click.
*/
onSuggestionClick(prompt: SuggestedPrompt): void {
this.query.emit({
prompt: prompt.prompt,
context: this.context()
});
}
/**
* Handle freeform submit.
*/
onSubmitFreeform(): void {
const input = this.freeformInput().trim();
if (input) {
this.query.emit({
prompt: input,
context: this.context()
});
}
}
/**
* Handle follow-up click.
*/
onFollowUp(followUp: string): void {
this.query.emit({
prompt: followUp,
context: this.context()
});
}
/**
* Handle close.
*/
onClose(): void {
this.closed.emit();
}
}

View File

@@ -0,0 +1,21 @@
/**
* AI Components Public API.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
*/
// Core components
export { AiAuthorityBadgeComponent, type AIAuthority } from './ai-authority-badge.component';
export { AiChipComponent, type AiChipVariant } from './ai-chip.component';
export { AiSummaryComponent, type AiSummaryCitation, type AiSummaryExpanded } from './ai-summary.component';
// Specialized chips
export { AiExplainChipComponent, type ExplainContext } from './ai-explain-chip.component';
export { AiFixChipComponent, type FixState } from './ai-fix-chip.component';
export { AiVexDraftChipComponent, type VexDraftState } from './ai-vex-draft-chip.component';
export { AiNeedsEvidenceChipComponent, type EvidenceType } from './ai-needs-evidence-chip.component';
export { AiExploitabilityChipComponent, type ExploitabilityLevel } from './ai-exploitability-chip.component';
// Panels (to be created)
export { AiAssistPanelComponent } from './ai-assist-panel.component';
export { AskStellaButtonComponent } from './ask-stella-button.component';
export { AskStellaPanelComponent } from './ask-stella-panel.component';

View File

@@ -0,0 +1,377 @@
/**
* Graph Diff Engine
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-01
*
* Computes diff between two reachability graphs.
*/
import {
ReachabilityGraph,
GraphNode,
GraphEdge,
DiffNode,
DiffEdge,
GraphDiffResult,
DiffSummary,
ChangeType,
NodePosition,
LayoutOptions,
HighlightState,
} from './graph-diff.models';
/**
* Computes the diff between base and head graphs.
*/
export function computeGraphDiff(
base: ReachabilityGraph | null,
head: ReachabilityGraph | null
): GraphDiffResult {
const baseNodes = new Map(base?.nodes.map(n => [n.id, n]) ?? []);
const headNodes = new Map(head?.nodes.map(n => [n.id, n]) ?? []);
const baseEdges = new Map(base?.edges.map(e => [e.id, e]) ?? []);
const headEdges = new Map(head?.edges.map(e => [e.id, e]) ?? []);
const diffNodes: DiffNode[] = [];
const diffEdges: DiffEdge[] = [];
// Process head nodes
for (const [id, node] of headNodes) {
const baseNode = baseNodes.get(id);
if (!baseNode) {
diffNodes.push({ ...node, changeType: 'added' });
} else if (hasNodeChanged(baseNode, node)) {
diffNodes.push({ ...node, changeType: 'changed', previousState: baseNode });
} else {
diffNodes.push({ ...node, changeType: 'unchanged' });
}
}
// Process removed nodes
for (const [id, node] of baseNodes) {
if (!headNodes.has(id)) {
diffNodes.push({ ...node, changeType: 'removed' });
}
}
// Process head edges
for (const [id, edge] of headEdges) {
const baseEdge = baseEdges.get(id);
if (!baseEdge) {
diffEdges.push({ ...edge, changeType: 'added' });
} else if (hasEdgeChanged(baseEdge, edge)) {
diffEdges.push({ ...edge, changeType: 'changed', previousState: baseEdge });
} else {
diffEdges.push({ ...edge, changeType: 'unchanged' });
}
}
// Process removed edges
for (const [id, edge] of baseEdges) {
if (!headEdges.has(id)) {
diffEdges.push({ ...edge, changeType: 'removed' });
}
}
const summary = computeSummary(diffNodes, diffEdges, base, head);
return {
baseDigest: base?.digest ?? '',
headDigest: head?.digest ?? '',
nodes: diffNodes,
edges: diffEdges,
summary,
};
}
function hasNodeChanged(base: GraphNode, head: GraphNode): boolean {
if (base.type !== head.type) return true;
if (base.label !== head.label) return true;
if (JSON.stringify(base.metadata) !== JSON.stringify(head.metadata)) return true;
return false;
}
function hasEdgeChanged(base: GraphEdge, head: GraphEdge): boolean {
if (base.type !== head.type) return true;
if (base.sourceId !== head.sourceId) return true;
if (base.targetId !== head.targetId) return true;
if (JSON.stringify(base.metadata) !== JSON.stringify(head.metadata)) return true;
return false;
}
function computeSummary(
nodes: DiffNode[],
edges: DiffEdge[],
base: ReachabilityGraph | null,
head: ReachabilityGraph | null
): DiffSummary {
const baseVulnNodes = new Set(base?.vulnerableNodes ?? []);
const headVulnNodes = new Set(head?.vulnerableNodes ?? []);
let newVulnerablePaths = 0;
let removedVulnerablePaths = 0;
for (const nodeId of headVulnNodes) {
if (!baseVulnNodes.has(nodeId)) {
newVulnerablePaths++;
}
}
for (const nodeId of baseVulnNodes) {
if (!headVulnNodes.has(nodeId)) {
removedVulnerablePaths++;
}
}
return {
nodesAdded: nodes.filter(n => n.changeType === 'added').length,
nodesRemoved: nodes.filter(n => n.changeType === 'removed').length,
nodesChanged: nodes.filter(n => n.changeType === 'changed').length,
edgesAdded: edges.filter(e => e.changeType === 'added').length,
edgesRemoved: edges.filter(e => e.changeType === 'removed').length,
edgesChanged: edges.filter(e => e.changeType === 'changed').length,
newVulnerablePaths,
removedVulnerablePaths,
};
}
/**
* Finds connected nodes and edges from a given node.
*/
export function findConnectedElements(
nodeId: string,
nodes: DiffNode[],
edges: DiffEdge[],
direction: 'both' | 'upstream' | 'downstream' = 'both'
): HighlightState {
const connectedNodes = new Set<string>();
const connectedEdges = new Set<string>();
const highlightedPath: string[] = [];
const edgeMap = new Map<string, DiffEdge[]>();
const reverseEdgeMap = new Map<string, DiffEdge[]>();
for (const edge of edges) {
if (!edgeMap.has(edge.sourceId)) {
edgeMap.set(edge.sourceId, []);
}
edgeMap.get(edge.sourceId)!.push(edge);
if (!reverseEdgeMap.has(edge.targetId)) {
reverseEdgeMap.set(edge.targetId, []);
}
reverseEdgeMap.get(edge.targetId)!.push(edge);
}
const visited = new Set<string>();
function traverseDownstream(currentId: string): void {
if (visited.has(currentId)) return;
visited.add(currentId);
connectedNodes.add(currentId);
const outEdges = edgeMap.get(currentId) ?? [];
for (const edge of outEdges) {
connectedEdges.add(edge.id);
traverseDownstream(edge.targetId);
}
}
function traverseUpstream(currentId: string): void {
if (visited.has(currentId)) return;
visited.add(currentId);
connectedNodes.add(currentId);
const inEdges = reverseEdgeMap.get(currentId) ?? [];
for (const edge of inEdges) {
connectedEdges.add(edge.id);
traverseUpstream(edge.sourceId);
}
}
if (direction === 'both' || direction === 'downstream') {
traverseDownstream(nodeId);
}
visited.clear();
if (direction === 'both' || direction === 'upstream') {
traverseUpstream(nodeId);
}
return {
hoveredNodeId: nodeId,
selectedNodeId: null,
highlightedPath,
connectedNodes,
connectedEdges,
};
}
/**
* Finds the shortest path between two nodes.
*/
export function findPath(
sourceId: string,
targetId: string,
edges: DiffEdge[]
): string[] {
const edgeMap = new Map<string, DiffEdge[]>();
for (const edge of edges) {
if (!edgeMap.has(edge.sourceId)) {
edgeMap.set(edge.sourceId, []);
}
edgeMap.get(edge.sourceId)!.push(edge);
}
const queue: { nodeId: string; path: string[] }[] = [
{ nodeId: sourceId, path: [sourceId] },
];
const visited = new Set<string>();
while (queue.length > 0) {
const { nodeId, path } = queue.shift()!;
if (nodeId === targetId) {
return path;
}
if (visited.has(nodeId)) continue;
visited.add(nodeId);
const outEdges = edgeMap.get(nodeId) ?? [];
for (const edge of outEdges) {
if (!visited.has(edge.targetId)) {
queue.push({
nodeId: edge.targetId,
path: [...path, edge.targetId],
});
}
}
}
return [];
}
/**
* Simple hierarchical layout algorithm.
*/
export function computeLayout(
nodes: DiffNode[],
edges: DiffEdge[],
options: LayoutOptions = { direction: 'TB', nodeSpacing: 60, rankSpacing: 100, algorithm: 'dagre' }
): Map<string, NodePosition> {
const positions = new Map<string, NodePosition>();
// Build adjacency lists
const children = new Map<string, string[]>();
const parents = new Map<string, string[]>();
const nodeSet = new Set(nodes.map(n => n.id));
for (const edge of edges) {
if (!nodeSet.has(edge.sourceId) || !nodeSet.has(edge.targetId)) continue;
if (!children.has(edge.sourceId)) {
children.set(edge.sourceId, []);
}
children.get(edge.sourceId)!.push(edge.targetId);
if (!parents.has(edge.targetId)) {
parents.set(edge.targetId, []);
}
parents.get(edge.targetId)!.push(edge.sourceId);
}
// Find root nodes (no parents)
const roots = nodes.filter(n => !parents.has(n.id) || parents.get(n.id)!.length === 0);
// Assign ranks (depth)
const ranks = new Map<string, number>();
const visited = new Set<string>();
function assignRank(nodeId: string, rank: number): void {
if (visited.has(nodeId)) {
ranks.set(nodeId, Math.max(ranks.get(nodeId) ?? 0, rank));
return;
}
visited.add(nodeId);
ranks.set(nodeId, rank);
const childIds = children.get(nodeId) ?? [];
for (const childId of childIds) {
assignRank(childId, rank + 1);
}
}
for (const root of roots) {
assignRank(root.id, 0);
}
// Handle unvisited nodes (cycles or disconnected)
for (const node of nodes) {
if (!ranks.has(node.id)) {
ranks.set(node.id, 0);
}
}
// Group nodes by rank
const rankGroups = new Map<number, string[]>();
for (const [nodeId, rank] of ranks) {
if (!rankGroups.has(rank)) {
rankGroups.set(rank, []);
}
rankGroups.get(rank)!.push(nodeId);
}
// Assign positions
const nodeWidth = 120;
const nodeHeight = 40;
for (const [rank, nodeIds] of rankGroups) {
const totalWidth = nodeIds.length * (nodeWidth + options.nodeSpacing) - options.nodeSpacing;
let startX = -totalWidth / 2;
for (let i = 0; i < nodeIds.length; i++) {
const nodeId = nodeIds[i];
const x = startX + i * (nodeWidth + options.nodeSpacing);
const y = rank * (nodeHeight + options.rankSpacing);
positions.set(nodeId, {
nodeId,
x: options.direction === 'LR' ? y : x,
y: options.direction === 'LR' ? x : y,
width: nodeWidth,
height: nodeHeight,
});
}
}
return positions;
}
/**
* Gets color for change type (WCAG 2.1 AA compliant).
*/
export function getChangeColor(changeType: ChangeType, element: 'fill' | 'stroke' | 'text'): string {
const colors: Record<ChangeType, { fill: string; stroke: string; text: string }> = {
added: { fill: '#e8f5e9', stroke: '#2e7d32', text: '#1b5e20' },
removed: { fill: '#ffebee', stroke: '#c62828', text: '#b71c1c' },
changed: { fill: '#fff3e0', stroke: '#ef6c00', text: '#e65100' },
unchanged: { fill: '#fafafa', stroke: '#9e9e9e', text: '#424242' },
};
return colors[changeType][element];
}
/**
* Gets accessible indicator pattern for color-blind users.
*/
export function getChangePattern(changeType: ChangeType): string {
const patterns: Record<ChangeType, string> = {
added: 'plus', // + symbol
removed: 'minus', // - symbol
changed: 'delta', // triangle
unchanged: 'none',
};
return patterns[changeType];
}

View File

@@ -0,0 +1,313 @@
/**
* Graph Diff Component Tests
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-11
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GraphDiffComponent } from './graph-diff.component';
import { ReachabilityGraph, DiffNode, GraphDiffResult } from './graph-diff.models';
import { computeGraphDiff, findConnectedElements, findPath, getChangeColor } from './graph-diff-engine';
describe('GraphDiffComponent', () => {
let component: GraphDiffComponent;
let fixture: ComponentFixture<GraphDiffComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GraphDiffComponent],
}).compileComponents();
fixture = TestBed.createComponent(GraphDiffComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render empty state when no graphs provided', () => {
fixture.detectChanges();
const svg = fixture.nativeElement.querySelector('.graph-diff__svg');
expect(svg).toBeTruthy();
});
it('should compute diff when graphs are set', () => {
const baseGraph = createMockGraph('base', ['nodeA', 'nodeB']);
const headGraph = createMockGraph('head', ['nodeA', 'nodeC']);
fixture.componentRef.setInput('baseGraph', baseGraph);
fixture.componentRef.setInput('headGraph', headGraph);
fixture.detectChanges();
const diff = component.diffResult();
expect(diff).toBeTruthy();
expect(diff!.nodes.some(n => n.id === 'nodeB' && n.changeType === 'removed')).toBeTrue();
expect(diff!.nodes.some(n => n.id === 'nodeC' && n.changeType === 'added')).toBeTrue();
});
it('should emit nodeSelected when node is clicked', () => {
const baseGraph = createMockGraph('base', ['nodeA']);
fixture.componentRef.setInput('baseGraph', null);
fixture.componentRef.setInput('headGraph', baseGraph);
fixture.detectChanges();
const emitSpy = spyOn(component.nodeSelected, 'emit');
const node: DiffNode = {
id: 'nodeA',
label: 'Node A',
type: 'function',
changeType: 'added',
};
component.onNodeClick(node);
expect(emitSpy).toHaveBeenCalledWith(node);
});
it('should highlight connected nodes on hover', () => {
const graph = createMockGraphWithEdges();
fixture.componentRef.setInput('baseGraph', null);
fixture.componentRef.setInput('headGraph', graph);
fixture.detectChanges();
const node: DiffNode = {
id: 'nodeA',
label: 'Node A',
type: 'function',
changeType: 'added',
};
component.onNodeHover(node);
const highlight = component.highlight();
expect(highlight.hoveredNodeId).toBe('nodeA');
expect(highlight.connectedNodes.size).toBeGreaterThan(0);
});
it('should zoom in and out correctly', () => {
const initialScale = component.viewport().scale;
component.zoomIn();
expect(component.viewport().scale).toBeGreaterThan(initialScale);
component.zoomOut();
component.zoomOut();
expect(component.viewport().scale).toBeLessThan(initialScale);
});
it('should reset view on resetView', () => {
component.zoomIn();
component.zoomIn();
component.resetView();
const viewport = component.viewport();
expect(viewport.scale).toBe(1);
expect(viewport.translateX).toBe(0);
expect(viewport.translateY).toBe(0);
});
it('should add to breadcrumbs on node selection', () => {
const graph = createMockGraph('head', ['nodeA', 'nodeB']);
fixture.componentRef.setInput('headGraph', graph);
fixture.detectChanges();
component.selectNode('nodeA');
component.selectNode('nodeB');
const breadcrumbs = component.breadcrumbs();
expect(breadcrumbs.length).toBe(2);
expect(breadcrumbs[0].nodeId).toBe('nodeA');
expect(breadcrumbs[1].nodeId).toBe('nodeB');
});
it('should clear selection on clearSelection', () => {
const graph = createMockGraph('head', ['nodeA']);
fixture.componentRef.setInput('headGraph', graph);
fixture.detectChanges();
component.selectNode('nodeA');
expect(component.highlight().selectedNodeId).toBe('nodeA');
component.clearSelection();
expect(component.highlight().selectedNodeId).toBeNull();
});
it('should truncate long labels', () => {
const label = 'ThisIsAVeryLongFunctionName';
const truncated = component.truncateLabel(label, 15);
expect(truncated.length).toBeLessThanOrEqual(15);
expect(truncated.endsWith('..')).toBeTrue();
});
it('should return correct node type icons', () => {
expect(component.getNodeTypeIcon('function')).toBe('()');
expect(component.getNodeTypeIcon('package')).toBe('{ }');
expect(component.getNodeTypeIcon('entry')).toBe('->');
expect(component.getNodeTypeIcon('vulnerable')).toBe('!!');
});
it('should return correct change indicators', () => {
expect(component.getChangeIndicator('added')).toBe('+');
expect(component.getChangeIndicator('removed')).toBe('-');
expect(component.getChangeIndicator('changed')).toBe('~');
expect(component.getChangeIndicator('unchanged')).toBe('');
});
});
describe('Graph Diff Engine', () => {
describe('computeGraphDiff', () => {
it('should identify added nodes', () => {
const base = createMockGraph('base', ['nodeA']);
const head = createMockGraph('head', ['nodeA', 'nodeB']);
const diff = computeGraphDiff(base, head);
expect(diff.summary.nodesAdded).toBe(1);
expect(diff.nodes.find(n => n.id === 'nodeB')?.changeType).toBe('added');
});
it('should identify removed nodes', () => {
const base = createMockGraph('base', ['nodeA', 'nodeB']);
const head = createMockGraph('head', ['nodeA']);
const diff = computeGraphDiff(base, head);
expect(diff.summary.nodesRemoved).toBe(1);
expect(diff.nodes.find(n => n.id === 'nodeB')?.changeType).toBe('removed');
});
it('should identify changed nodes', () => {
const base: ReachabilityGraph = {
id: 'base',
digest: 'sha256:base',
nodes: [{ id: 'nodeA', label: 'Node A', type: 'function' }],
edges: [],
entryPoints: [],
vulnerableNodes: [],
};
const head: ReachabilityGraph = {
id: 'head',
digest: 'sha256:head',
nodes: [{ id: 'nodeA', label: 'Node A Updated', type: 'function' }],
edges: [],
entryPoints: [],
vulnerableNodes: [],
};
const diff = computeGraphDiff(base, head);
expect(diff.summary.nodesChanged).toBe(1);
expect(diff.nodes.find(n => n.id === 'nodeA')?.changeType).toBe('changed');
});
it('should handle null base graph', () => {
const head = createMockGraph('head', ['nodeA']);
const diff = computeGraphDiff(null, head);
expect(diff.summary.nodesAdded).toBe(1);
});
it('should handle null head graph', () => {
const base = createMockGraph('base', ['nodeA']);
const diff = computeGraphDiff(base, null);
expect(diff.summary.nodesRemoved).toBe(1);
});
});
describe('findConnectedElements', () => {
it('should find downstream connected nodes', () => {
const nodes: DiffNode[] = [
{ id: 'A', label: 'A', type: 'entry', changeType: 'unchanged' },
{ id: 'B', label: 'B', type: 'function', changeType: 'unchanged' },
{ id: 'C', label: 'C', type: 'sink', changeType: 'unchanged' },
];
const edges = [
{ id: 'A-B', sourceId: 'A', targetId: 'B', type: 'call' as const, changeType: 'unchanged' as const },
{ id: 'B-C', sourceId: 'B', targetId: 'C', type: 'call' as const, changeType: 'unchanged' as const },
];
const result = findConnectedElements('A', nodes, edges, 'downstream');
expect(result.connectedNodes.has('A')).toBeTrue();
expect(result.connectedNodes.has('B')).toBeTrue();
expect(result.connectedNodes.has('C')).toBeTrue();
});
});
describe('findPath', () => {
it('should find path between nodes', () => {
const edges = [
{ id: 'A-B', sourceId: 'A', targetId: 'B', type: 'call' as const, changeType: 'unchanged' as const },
{ id: 'B-C', sourceId: 'B', targetId: 'C', type: 'call' as const, changeType: 'unchanged' as const },
];
const path = findPath('A', 'C', edges);
expect(path).toEqual(['A', 'B', 'C']);
});
it('should return empty array when no path exists', () => {
const edges = [
{ id: 'A-B', sourceId: 'A', targetId: 'B', type: 'call' as const, changeType: 'unchanged' as const },
];
const path = findPath('A', 'C', edges);
expect(path).toEqual([]);
});
});
describe('getChangeColor', () => {
it('should return correct colors for added', () => {
expect(getChangeColor('added', 'fill')).toBe('#e8f5e9');
expect(getChangeColor('added', 'stroke')).toBe('#2e7d32');
});
it('should return correct colors for removed', () => {
expect(getChangeColor('removed', 'fill')).toBe('#ffebee');
expect(getChangeColor('removed', 'stroke')).toBe('#c62828');
});
it('should return correct colors for changed', () => {
expect(getChangeColor('changed', 'fill')).toBe('#fff3e0');
expect(getChangeColor('changed', 'stroke')).toBe('#ef6c00');
});
});
});
// Helper functions
function createMockGraph(id: string, nodeIds: string[]): ReachabilityGraph {
return {
id,
digest: `sha256:${id}`,
nodes: nodeIds.map(nid => ({
id: nid,
label: `Node ${nid}`,
type: 'function' as const,
})),
edges: [],
entryPoints: nodeIds.length > 0 ? [nodeIds[0]] : [],
vulnerableNodes: [],
};
}
function createMockGraphWithEdges(): ReachabilityGraph {
return {
id: 'test',
digest: 'sha256:test',
nodes: [
{ id: 'nodeA', label: 'Node A', type: 'entry' },
{ id: 'nodeB', label: 'Node B', type: 'function' },
{ id: 'nodeC', label: 'Node C', type: 'sink' },
],
edges: [
{ id: 'A-B', sourceId: 'nodeA', targetId: 'nodeB', type: 'call' },
{ id: 'B-C', sourceId: 'nodeB', targetId: 'nodeC', type: 'call' },
],
entryPoints: ['nodeA'],
vulnerableNodes: ['nodeC'],
};
}

View File

@@ -0,0 +1,157 @@
/**
* Graph Diff Models
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-01
*/
/**
* Represents a node in a reachability graph.
*/
export interface GraphNode {
id: string;
label: string;
type: NodeType;
metadata?: NodeMetadata;
}
export type NodeType = 'function' | 'component' | 'package' | 'entry' | 'sink' | 'vulnerable';
export interface NodeMetadata {
purl?: string;
filePath?: string;
lineNumber?: number;
cveIds?: string[];
signature?: string;
}
/**
* Represents an edge (call path) in a reachability graph.
*/
export interface GraphEdge {
id: string;
sourceId: string;
targetId: string;
type: EdgeType;
metadata?: EdgeMetadata;
}
export type EdgeType = 'call' | 'import' | 'dynamic' | 'indirect';
export interface EdgeMetadata {
callSite?: string;
confidence?: number;
isConditional?: boolean;
}
/**
* Complete reachability graph structure.
*/
export interface ReachabilityGraph {
id: string;
digest: string;
nodes: GraphNode[];
edges: GraphEdge[];
entryPoints: string[];
vulnerableNodes: string[];
metadata?: GraphMetadata;
}
export interface GraphMetadata {
imageDigest?: string;
createdAt?: string;
analyzer?: string;
version?: string;
}
/**
* Change classification for graph diff.
*/
export type ChangeType = 'added' | 'removed' | 'changed' | 'unchanged';
/**
* Node with diff information.
*/
export interface DiffNode extends GraphNode {
changeType: ChangeType;
previousState?: GraphNode;
}
/**
* Edge with diff information.
*/
export interface DiffEdge extends GraphEdge {
changeType: ChangeType;
previousState?: GraphEdge;
}
/**
* Result of computing graph diff.
*/
export interface GraphDiffResult {
baseDigest: string;
headDigest: string;
nodes: DiffNode[];
edges: DiffEdge[];
summary: DiffSummary;
}
export interface DiffSummary {
nodesAdded: number;
nodesRemoved: number;
nodesChanged: number;
edgesAdded: number;
edgesRemoved: number;
edgesChanged: number;
newVulnerablePaths: number;
removedVulnerablePaths: number;
}
/**
* Position information for SVG rendering.
*/
export interface NodePosition {
nodeId: string;
x: number;
y: number;
width: number;
height: number;
}
/**
* Viewport state for pan/zoom.
*/
export interface ViewportState {
scale: number;
translateX: number;
translateY: number;
}
/**
* Graph layout options.
*/
export interface LayoutOptions {
direction: 'TB' | 'LR' | 'BT' | 'RL';
nodeSpacing: number;
rankSpacing: number;
algorithm: 'dagre' | 'force' | 'tree';
}
/**
* Highlight state for interactive navigation.
*/
export interface HighlightState {
hoveredNodeId: string | null;
selectedNodeId: string | null;
highlightedPath: string[];
connectedNodes: Set<string>;
connectedEdges: Set<string>;
}
/**
* Navigation breadcrumb for history.
*/
export interface NavigationBreadcrumb {
nodeId: string;
label: string;
timestamp: number;
}

View File

@@ -0,0 +1,368 @@
/**
* Graph Split View Component
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-02
*
* Side-by-side graph comparison with synchronized navigation.
*/
import {
Component,
input,
output,
signal,
computed,
effect,
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { GraphDiffComponent } from './graph-diff.component';
import { ReachabilityGraph, GraphNode, ViewportState } from './graph-diff.models';
export type ViewMode = 'split' | 'unified' | 'base-only' | 'head-only';
@Component({
selector: 'stellaops-graph-split-view',
standalone: true,
imports: [CommonModule, GraphDiffComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="split-view" [class]="'split-view--' + viewMode()">
<!-- View mode toggle -->
<div class="split-view__header">
<div class="split-view__mode-toggle" role="tablist" aria-label="View mode">
<button
class="split-view__mode-btn"
[class.split-view__mode-btn--active]="viewMode() === 'split'"
(click)="setViewMode('split')"
role="tab"
[attr.aria-selected]="viewMode() === 'split'"
title="Split view (side-by-side)">
Split
</button>
<button
class="split-view__mode-btn"
[class.split-view__mode-btn--active]="viewMode() === 'unified'"
(click)="setViewMode('unified')"
role="tab"
[attr.aria-selected]="viewMode() === 'unified'"
title="Unified diff view">
Unified
</button>
<button
class="split-view__mode-btn"
[class.split-view__mode-btn--active]="viewMode() === 'base-only'"
(click)="setViewMode('base-only')"
role="tab"
[attr.aria-selected]="viewMode() === 'base-only'"
title="Show base (before) only">
Before
</button>
<button
class="split-view__mode-btn"
[class.split-view__mode-btn--active]="viewMode() === 'head-only'"
(click)="setViewMode('head-only')"
role="tab"
[attr.aria-selected]="viewMode() === 'head-only'"
title="Show head (after) only">
After
</button>
</div>
<div class="split-view__sync-toggle">
<label class="split-view__checkbox-label">
<input
type="checkbox"
[checked]="syncNavigation()"
(change)="toggleSyncNavigation()" />
Sync navigation
</label>
</div>
</div>
<!-- Split panels -->
<div class="split-view__panels">
@if (viewMode() === 'split' || viewMode() === 'base-only') {
<div class="split-view__panel split-view__panel--base">
<header class="split-view__panel-header">
<span class="split-view__panel-label">Before (Base)</span>
@if (baseGraph(); as base) {
<code class="split-view__digest">{{ truncateDigest(base.digest) }}</code>
}
</header>
<stellaops-graph-diff
[baseGraph]="null"
[headGraph]="baseGraph()"
[highlightedNode]="syncNavigation() ? selectedNodeId() : null"
[showMinimap]="false"
(nodeSelected)="onNodeSelected($event, 'base')" />
</div>
}
@if (viewMode() === 'split' || viewMode() === 'head-only') {
<div class="split-view__panel split-view__panel--head">
<header class="split-view__panel-header">
<span class="split-view__panel-label">After (Head)</span>
@if (headGraph(); as head) {
<code class="split-view__digest">{{ truncateDigest(head.digest) }}</code>
}
</header>
<stellaops-graph-diff
[baseGraph]="null"
[headGraph]="headGraph()"
[highlightedNode]="syncNavigation() ? selectedNodeId() : null"
[showMinimap]="false"
(nodeSelected)="onNodeSelected($event, 'head')" />
</div>
}
@if (viewMode() === 'unified') {
<div class="split-view__panel split-view__panel--unified">
<header class="split-view__panel-header">
<span class="split-view__panel-label">Unified Diff</span>
<span class="split-view__comparison">
{{ truncateDigest(baseGraph()?.digest ?? '') }} → {{ truncateDigest(headGraph()?.digest ?? '') }}
</span>
</header>
<stellaops-graph-diff
[baseGraph]="baseGraph()"
[headGraph]="headGraph()"
[highlightedNode]="selectedNodeId()"
(nodeSelected)="onNodeSelected($event, 'unified')" />
</div>
}
</div>
</div>
`,
styles: [`
.split-view {
display: flex;
flex-direction: column;
height: 100%;
background: var(--stellaops-card-bg, #fff);
border: 1px solid var(--stellaops-border, #e0e0e0);
border-radius: 8px;
overflow: hidden;
}
.split-view__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #f5f5f5;
border-bottom: 1px solid var(--stellaops-border, #e0e0e0);
}
.split-view__mode-toggle {
display: flex;
gap: 2px;
background: #e0e0e0;
border-radius: 4px;
padding: 2px;
}
.split-view__mode-btn {
background: transparent;
border: none;
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s;
}
.split-view__mode-btn:hover {
background: rgba(255, 255, 255, 0.5);
}
.split-view__mode-btn--active {
background: white;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.split-view__sync-toggle {
display: flex;
align-items: center;
}
.split-view__checkbox-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
cursor: pointer;
}
.split-view__panels {
display: flex;
flex: 1;
overflow: hidden;
}
.split-view--split .split-view__panels {
gap: 1px;
background: var(--stellaops-border, #e0e0e0);
}
.split-view__panel {
flex: 1;
display: flex;
flex-direction: column;
background: var(--stellaops-card-bg, #fff);
overflow: hidden;
}
.split-view__panel--unified {
width: 100%;
}
.split-view__panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background: #fafafa;
border-bottom: 1px solid var(--stellaops-border, #e0e0e0);
font-size: 12px;
}
.split-view__panel--base .split-view__panel-header {
background: #fff8f8;
}
.split-view__panel--head .split-view__panel-header {
background: #f8fff8;
}
.split-view__panel-label {
font-weight: 500;
}
.split-view__digest {
font-family: 'SF Mono', monospace;
font-size: 10px;
background: rgba(0, 0, 0, 0.05);
padding: 2px 6px;
border-radius: 3px;
}
.split-view__comparison {
font-family: 'SF Mono', monospace;
font-size: 10px;
}
/* Responsive: Stack vertically on small screens */
@media (max-width: 768px) {
.split-view--split .split-view__panels {
flex-direction: column;
}
.split-view__panel {
min-height: 300px;
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.split-view__header,
.split-view__mode-toggle {
background: #2d2d2d;
}
.split-view__mode-btn--active {
background: #444;
color: #e0e0e0;
}
.split-view__panel-header {
background: #252525;
}
.split-view__panel--base .split-view__panel-header {
background: #2a2020;
}
.split-view__panel--head .split-view__panel-header {
background: #202a20;
}
}
`],
})
export class GraphSplitViewComponent {
/** Base graph (before). */
baseGraph = input<ReachabilityGraph | null>(null);
/** Head graph (after). */
headGraph = input<ReachabilityGraph | null>(null);
/** Initial view mode. */
initialViewMode = input<ViewMode>('unified');
/** Persist preference key for localStorage. */
preferenceKey = input<string>('stellaops-graph-view-mode');
/** Emitted when a node is selected. */
nodeSelected = output<{ node: GraphNode; source: 'base' | 'head' | 'unified' }>();
/** Emitted when view mode changes. */
viewModeChanged = output<ViewMode>();
// State
viewMode = signal<ViewMode>('unified');
syncNavigation = signal<boolean>(true);
selectedNodeId = signal<string | null>(null);
constructor() {
// Load preference from localStorage
effect(() => {
const key = this.preferenceKey();
const initial = this.initialViewMode();
try {
const saved = localStorage.getItem(key);
if (saved && ['split', 'unified', 'base-only', 'head-only'].includes(saved)) {
this.viewMode.set(saved as ViewMode);
} else {
this.viewMode.set(initial);
}
} catch {
this.viewMode.set(initial);
}
}, { allowSignalWrites: true });
}
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
this.viewModeChanged.emit(mode);
// Persist to localStorage
try {
localStorage.setItem(this.preferenceKey(), mode);
} catch {
// Ignore storage errors
}
}
toggleSyncNavigation(): void {
this.syncNavigation.update(v => !v);
}
onNodeSelected(node: GraphNode, source: 'base' | 'head' | 'unified'): void {
if (this.syncNavigation()) {
this.selectedNodeId.set(node.id);
}
this.nodeSelected.emit({ node, source });
}
truncateDigest(digest: string): string {
if (!digest) return '';
const colonIdx = digest.indexOf(':');
if (colonIdx > 0 && colonIdx < 10) {
const hashPart = digest.substring(colonIdx + 1);
return digest.substring(0, colonIdx + 1) + hashPart.substring(0, 8) + '...';
}
return digest.length > 16 ? digest.substring(0, 12) + '...' : digest;
}
}

View File

@@ -0,0 +1,8 @@
/**
* Graph Diff Module Exports
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
*/
export * from './graph-diff.models';
export * from './graph-diff-engine';
export * from './graph-diff.component';

View File

@@ -0,0 +1,188 @@
/**
* Plain Language Toggle Component
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-05
*
* Toggle for switching between technical and plain language modes.
*/
import {
Component,
model,
output,
inject,
HostListener,
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { PlainLanguageService } from '../../services/plain-language.service';
@Component({
selector: 'stellaops-plain-language-toggle',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button
class="toggle"
[class.toggle--enabled]="enabled()"
(click)="toggle()"
type="button"
role="switch"
[attr.aria-checked]="enabled()"
[attr.aria-label]="ariaLabel"
title="Toggle plain language mode (Alt+P)">
<span class="toggle__track">
<span class="toggle__thumb" [class.toggle__thumb--enabled]="enabled()">
@if (enabled()) {
<span class="toggle__icon">Aa</span>
} @else {
<span class="toggle__icon">&lt;/&gt;</span>
}
</span>
</span>
<span class="toggle__label">
@if (enabled()) {
Plain language
} @else {
Technical
}
</span>
</button>
`,
styles: [`
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
background: transparent;
border: 1px solid var(--stellaops-border, #e0e0e0);
border-radius: 20px;
padding: 4px 12px 4px 4px;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.toggle:hover {
background: var(--stellaops-hover-bg, #f5f5f5);
border-color: var(--stellaops-accent, #1976d2);
}
.toggle:focus {
outline: 2px solid var(--stellaops-accent, #1976d2);
outline-offset: 2px;
}
.toggle--enabled {
background: color-mix(in srgb, var(--stellaops-accent, #1976d2) 10%, transparent);
border-color: var(--stellaops-accent, #1976d2);
}
.toggle__track {
width: 36px;
height: 20px;
background: #e0e0e0;
border-radius: 10px;
position: relative;
transition: background 0.2s ease;
}
.toggle--enabled .toggle__track {
background: var(--stellaops-accent, #1976d2);
}
.toggle__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.toggle__thumb--enabled {
transform: translateX(16px);
}
.toggle__icon {
font-size: 8px;
font-weight: 700;
color: var(--stellaops-text-secondary, #666);
}
.toggle--enabled .toggle__icon {
color: var(--stellaops-accent, #1976d2);
}
.toggle__label {
font-size: 12px;
font-weight: 500;
color: var(--stellaops-text, #1a1a1a);
white-space: nowrap;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.toggle {
border-color: #444;
}
.toggle:hover {
background: #333;
}
.toggle__track {
background: #444;
}
.toggle__label {
color: #e0e0e0;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.toggle,
.toggle__track,
.toggle__thumb {
transition: none;
}
}
`],
})
export class PlainLanguageToggleComponent {
private readonly plainLanguageService = inject(PlainLanguageService);
/** Two-way binding for enabled state. */
enabled = model<boolean>(false);
/** Emitted when toggled. */
toggled = output<boolean>();
readonly ariaLabel = 'Toggle between technical and plain language explanations';
constructor() {
// Sync with service
this.enabled.set(this.plainLanguageService.isPlainLanguageEnabled());
}
@HostListener('document:keydown.alt.p', ['$event'])
onAltP(event: KeyboardEvent): void {
event.preventDefault();
this.toggle();
}
toggle(): void {
const newValue = !this.enabled();
this.enabled.set(newValue);
this.plainLanguageService.setPlainLanguage(newValue);
this.toggled.emit(newValue);
}
}

View File

@@ -0,0 +1,232 @@
/**
* Glossary Tooltip Directive
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-07
*
* Auto-detects technical terms and adds plain language tooltips.
*/
import {
Directive,
ElementRef,
OnInit,
OnDestroy,
inject,
input,
Renderer2,
effect,
} from '@angular/core';
import { PlainLanguageService, GlossaryEntry } from '../services/plain-language.service';
@Directive({
selector: '[stellaopsGlossaryTooltip]',
standalone: true,
})
export class GlossaryTooltipDirective implements OnInit, OnDestroy {
private readonly el = inject(ElementRef);
private readonly renderer = inject(Renderer2);
private readonly plainLanguageService = inject(PlainLanguageService);
/** Whether to auto-detect terms in the element's text content. */
autoDetect = input<boolean>(true);
/** Specific term to show tooltip for (if not auto-detecting). */
term = input<string | null>(null);
private tooltipElement: HTMLElement | null = null;
private cleanupFns: (() => void)[] = [];
private originalHtml: string | null = null;
constructor() {
effect(() => {
if (this.plainLanguageService.isPlainLanguageEnabled()) {
this.processContent();
} else {
this.restoreOriginal();
}
});
}
ngOnInit(): void {
this.originalHtml = this.el.nativeElement.innerHTML;
if (this.plainLanguageService.isPlainLanguageEnabled()) {
this.processContent();
}
}
ngOnDestroy(): void {
this.cleanup();
this.restoreOriginal();
}
private processContent(): void {
const specificTerm = this.term();
if (specificTerm) {
// Handle specific term
const entry = this.plainLanguageService.getGlossaryEntry(specificTerm);
if (entry) {
this.wrapElement(entry);
}
} else if (this.autoDetect()) {
// Auto-detect terms
this.processTextContent();
}
}
private wrapElement(entry: GlossaryEntry): void {
const el = this.el.nativeElement as HTMLElement;
this.renderer.addClass(el, 'glossary-term');
this.renderer.setAttribute(el, 'tabindex', '0');
this.renderer.setAttribute(el, 'role', 'button');
this.renderer.setAttribute(el, 'aria-describedby', `glossary-tooltip-${entry.term.toLowerCase()}`);
const mouseEnter = this.renderer.listen(el, 'mouseenter', () => this.showTooltip(entry, el));
const mouseLeave = this.renderer.listen(el, 'mouseleave', () => this.hideTooltip());
const focus = this.renderer.listen(el, 'focus', () => this.showTooltip(entry, el));
const blur = this.renderer.listen(el, 'blur', () => this.hideTooltip());
this.cleanupFns.push(mouseEnter, mouseLeave, focus, blur);
}
private processTextContent(): void {
const el = this.el.nativeElement as HTMLElement;
const text = el.textContent ?? '';
const terms = this.plainLanguageService.findTermsInText(text);
if (terms.length === 0) return;
// Store original for restoration
if (!this.originalHtml) {
this.originalHtml = el.innerHTML;
}
// Process terms in reverse order to maintain positions
let html = el.innerHTML;
const processedPositions = new Set<number>();
for (const { term, start, end } of [...terms].reverse()) {
// Skip if this position overlaps with already processed
if (processedPositions.has(start)) continue;
const entry = this.plainLanguageService.getGlossaryEntry(term);
if (!entry) continue;
// Find the term in HTML (accounting for potential tags)
const regex = new RegExp(`(${this.escapeRegex(text.substring(start, end))})`, 'gi');
const replacement = `<span class="glossary-term glossary-term--inline" data-term="${entry.term.toLowerCase()}" tabindex="0" role="button">$1</span>`;
html = html.replace(regex, replacement);
processedPositions.add(start);
}
el.innerHTML = html;
// Add event listeners to wrapped terms
const termElements = el.querySelectorAll('.glossary-term--inline');
termElements.forEach(termEl => {
const termName = termEl.getAttribute('data-term');
const entry = termName ? this.plainLanguageService.getGlossaryEntry(termName) : null;
if (entry) {
const mouseEnter = this.renderer.listen(termEl, 'mouseenter', () =>
this.showTooltip(entry, termEl as HTMLElement)
);
const mouseLeave = this.renderer.listen(termEl, 'mouseleave', () => this.hideTooltip());
const focus = this.renderer.listen(termEl, 'focus', () =>
this.showTooltip(entry, termEl as HTMLElement)
);
const blur = this.renderer.listen(termEl, 'blur', () => this.hideTooltip());
this.cleanupFns.push(mouseEnter, mouseLeave, focus, blur);
}
});
}
private showTooltip(entry: GlossaryEntry, anchor: HTMLElement): void {
this.hideTooltip();
this.tooltipElement = this.renderer.createElement('div');
this.renderer.addClass(this.tooltipElement, 'glossary-tooltip');
this.renderer.setAttribute(this.tooltipElement, 'role', 'tooltip');
this.renderer.setAttribute(this.tooltipElement, 'id', `glossary-tooltip-${entry.term.toLowerCase()}`);
const content = `
<div class="glossary-tooltip__header">
<span class="glossary-tooltip__term">${entry.term}</span>
${entry.abbreviation ? `<span class="glossary-tooltip__abbr">(${entry.abbreviation})</span>` : ''}
</div>
<div class="glossary-tooltip__body">
<p class="glossary-tooltip__plain">${entry.plainLanguage}</p>
<p class="glossary-tooltip__detail">${entry.detailedExplanation}</p>
${entry.learnMoreUrl ? `<a class="glossary-tooltip__link" href="${entry.learnMoreUrl}" target="_blank" rel="noopener">Learn more →</a>` : ''}
</div>
`;
this.tooltipElement.innerHTML = content;
this.renderer.appendChild(document.body, this.tooltipElement);
// Position tooltip
this.positionTooltip(anchor);
// Add close on click outside
setTimeout(() => {
const clickOutside = this.renderer.listen('document', 'click', (event: MouseEvent) => {
if (!this.tooltipElement?.contains(event.target as Node) && event.target !== anchor) {
this.hideTooltip();
}
});
this.cleanupFns.push(clickOutside);
}, 0);
}
private positionTooltip(anchor: HTMLElement): void {
if (!this.tooltipElement) return;
const rect = anchor.getBoundingClientRect();
const tooltipRect = this.tooltipElement.getBoundingClientRect();
let top = rect.bottom + 8;
let left = rect.left + rect.width / 2 - tooltipRect.width / 2;
// Keep within viewport
if (left < 8) left = 8;
if (left + tooltipRect.width > window.innerWidth - 8) {
left = window.innerWidth - tooltipRect.width - 8;
}
// Flip to top if not enough space below
if (top + tooltipRect.height > window.innerHeight - 8) {
top = rect.top - tooltipRect.height - 8;
this.renderer.addClass(this.tooltipElement, 'glossary-tooltip--above');
}
this.renderer.setStyle(this.tooltipElement, 'top', `${top}px`);
this.renderer.setStyle(this.tooltipElement, 'left', `${left}px`);
}
private hideTooltip(): void {
if (this.tooltipElement) {
this.renderer.removeChild(document.body, this.tooltipElement);
this.tooltipElement = null;
}
}
private restoreOriginal(): void {
if (this.originalHtml !== null) {
this.el.nativeElement.innerHTML = this.originalHtml;
}
this.cleanup();
}
private cleanup(): void {
this.hideTooltip();
this.cleanupFns.forEach(fn => fn());
this.cleanupFns = [];
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}

View File

@@ -0,0 +1,307 @@
/**
* Graph Export Service
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-08
*
* Export graph diff visualizations to SVG/PNG for audit reports.
*/
import { Injectable } from '@angular/core';
import { GraphDiffResult, DiffNode, DiffEdge, NodePosition } from '../components/graph-diff/graph-diff.models';
import { computeLayout, getChangeColor } from '../components/graph-diff/graph-diff-engine';
export interface ExportOptions {
format: 'svg' | 'png';
scale?: number;
includeLegend?: boolean;
includeMetadata?: boolean;
backgroundColor?: string;
filename?: string;
}
export interface ExportMetadata {
baseDigest: string;
headDigest: string;
exportedAt: string;
nodesAdded: number;
nodesRemoved: number;
nodesChanged: number;
}
@Injectable({ providedIn: 'root' })
export class GraphExportService {
/**
* Export graph diff to SVG.
*/
exportToSvg(diff: GraphDiffResult, options: Partial<ExportOptions> = {}): string {
const opts: ExportOptions = {
format: 'svg',
scale: 1,
includeLegend: true,
includeMetadata: true,
backgroundColor: '#ffffff',
...options,
};
const positions = computeLayout(diff.nodes, diff.edges);
const { width, height, viewBox } = this.calculateDimensions(positions);
const legendHeight = opts.includeLegend ? 60 : 0;
const metadataHeight = opts.includeMetadata ? 40 : 0;
const totalHeight = height + legendHeight + metadataHeight + 40;
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
width="${width * opts.scale!}"
height="${totalHeight * opts.scale!}"
viewBox="0 0 ${width} ${totalHeight}"
style="background: ${opts.backgroundColor}">
<!-- Styles -->
<style>
.node-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 11px; }
.legend-text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 10px; fill: #666; }
.metadata-text { font-family: 'SF Mono', Consolas, monospace; font-size: 9px; fill: #999; }
.title-text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; font-weight: 600; fill: #333; }
</style>
<!-- Defs -->
<defs>
<marker id="arrow-added" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2e7d32" />
</marker>
<marker id="arrow-removed" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#c62828" />
</marker>
<marker id="arrow-changed" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#ef6c00" />
</marker>
<marker id="arrow-unchanged" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#9e9e9e" />
</marker>
</defs>
<!-- Title -->
<text x="20" y="25" class="title-text">Graph Diff: ${diff.summary.nodesAdded} added, ${diff.summary.nodesRemoved} removed, ${diff.summary.nodesChanged} changed</text>
<!-- Graph content -->
<g transform="translate(${-viewBox.minX + 20}, ${-viewBox.minY + 45})">
<!-- Edges -->
${this.renderEdges(diff.edges, positions)}
<!-- Nodes -->
${this.renderNodes(diff.nodes, positions)}
</g>
${opts.includeLegend ? this.renderLegend(height + 50) : ''}
${opts.includeMetadata ? this.renderMetadata(diff, totalHeight - 25) : ''}
</svg>`;
return svg;
}
/**
* Export graph diff to PNG (via canvas).
*/
async exportToPng(diff: GraphDiffResult, options: Partial<ExportOptions> = {}): Promise<Blob> {
const svg = this.exportToSvg(diff, { ...options, format: 'svg' });
const scale = options.scale ?? 2; // Higher default for PNG
return new Promise((resolve, reject) => {
const img = new Image();
const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width * scale;
canvas.height = img.height * scale;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.scale(scale, scale);
ctx.fillStyle = options.backgroundColor ?? '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
canvas.toBlob(
blob => {
URL.revokeObjectURL(url);
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to create PNG blob'));
}
},
'image/png',
1.0
);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load SVG image'));
};
img.src = url;
});
}
/**
* Download graph diff export.
*/
async download(diff: GraphDiffResult, options: Partial<ExportOptions> = {}): Promise<void> {
const format = options.format ?? 'svg';
const baseDigestShort = diff.baseDigest.substring(0, 12);
const headDigestShort = diff.headDigest.substring(0, 12);
const filename = options.filename ?? `graph-diff-${baseDigestShort}-${headDigestShort}.${format}`;
let blob: Blob;
let mimeType: string;
if (format === 'png') {
blob = await this.exportToPng(diff, options);
mimeType = 'image/png';
} else {
const svg = this.exportToSvg(diff, options);
blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
mimeType = 'image/svg+xml';
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
private calculateDimensions(positions: Map<string, NodePosition>): {
width: number;
height: number;
viewBox: { minX: number; minY: number; maxX: number; maxY: number };
} {
if (positions.size === 0) {
return { width: 400, height: 300, viewBox: { minX: 0, minY: 0, maxX: 400, maxY: 300 } };
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const pos of positions.values()) {
minX = Math.min(minX, pos.x - pos.width / 2);
minY = Math.min(minY, pos.y - pos.height / 2);
maxX = Math.max(maxX, pos.x + pos.width / 2);
maxY = Math.max(maxY, pos.y + pos.height / 2);
}
const padding = 40;
return {
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2,
viewBox: { minX, minY, maxX, maxY },
};
}
private renderNodes(nodes: DiffNode[], positions: Map<string, NodePosition>): string {
return nodes
.map(node => {
const pos = positions.get(node.id);
if (!pos) return '';
const fill = getChangeColor(node.changeType, 'fill');
const stroke = getChangeColor(node.changeType, 'stroke');
const textColor = getChangeColor(node.changeType, 'text');
const indicator = this.getChangeIndicator(node.changeType);
const label = this.truncateLabel(node.label, 14);
return `
<g transform="translate(${pos.x}, ${pos.y})">
<rect x="${-pos.width / 2}" y="${-pos.height / 2}" width="${pos.width}" height="${pos.height}"
rx="6" fill="${fill}" stroke="${stroke}" stroke-width="2" />
${node.changeType !== 'unchanged' ? `
<circle cx="${pos.width / 2 - 8}" cy="${-pos.height / 2 + 8}" r="8" fill="${stroke}" />
<text x="${pos.width / 2 - 8}" y="${-pos.height / 2 + 12}" text-anchor="middle"
font-size="11" font-weight="bold" fill="white">${indicator}</text>
` : ''}
<text x="${-pos.width / 2 + 10}" y="4" class="node-label" fill="${textColor}">${label}</text>
</g>`;
})
.join('\n');
}
private renderEdges(edges: DiffEdge[], positions: Map<string, NodePosition>): string {
return edges
.map(edge => {
const sourcePos = positions.get(edge.sourceId);
const targetPos = positions.get(edge.targetId);
if (!sourcePos || !targetPos) return '';
const stroke = getChangeColor(edge.changeType, 'stroke');
const midY = (sourcePos.y + targetPos.y) / 2;
const dashArray = edge.changeType === 'removed' ? 'stroke-dasharray="5,5"' : '';
const opacity = edge.changeType === 'removed' ? 'opacity="0.5"' : '';
return `
<path d="M ${sourcePos.x} ${sourcePos.y + sourcePos.height / 2}
C ${sourcePos.x} ${midY}, ${targetPos.x} ${midY},
${targetPos.x} ${targetPos.y - targetPos.height / 2}"
fill="none" stroke="${stroke}" stroke-width="2"
marker-end="url(#arrow-${edge.changeType})" ${dashArray} ${opacity} />`;
})
.join('\n');
}
private renderLegend(y: number): string {
return `
<g transform="translate(20, ${y})">
<rect x="0" y="0" width="300" height="50" fill="#fafafa" stroke="#e0e0e0" rx="4" />
<rect x="15" y="15" width="16" height="16" fill="#e8f5e9" stroke="#2e7d32" stroke-width="2" rx="2" />
<text x="38" y="27" class="legend-text">Added (+)</text>
<rect x="95" y="15" width="16" height="16" fill="#ffebee" stroke="#c62828" stroke-width="2" rx="2" />
<text x="118" y="27" class="legend-text">Removed (-)</text>
<rect x="185" y="15" width="16" height="16" fill="#fff3e0" stroke="#ef6c00" stroke-width="2" rx="2" />
<text x="208" y="27" class="legend-text">Changed (~)</text>
</g>`;
}
private renderMetadata(diff: GraphDiffResult, y: number): string {
const timestamp = new Date().toISOString();
return `
<text x="20" y="${y}" class="metadata-text">
Base: ${diff.baseDigest.substring(0, 16)}... | Head: ${diff.headDigest.substring(0, 16)}... | Exported: ${timestamp}
</text>`;
}
private getChangeIndicator(changeType: string): string {
switch (changeType) {
case 'added': return '+';
case 'removed': return '-';
case 'changed': return '~';
default: return '';
}
}
private truncateLabel(label: string, maxLength: number): string {
if (label.length <= maxLength) return this.escapeXml(label);
return this.escapeXml(label.substring(0, maxLength - 2)) + '..';
}
private escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}

View File

@@ -0,0 +1,199 @@
/**
* Plain Language Service Tests
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-11
*/
import { TestBed } from '@angular/core/testing';
import { PlainLanguageService } from './plain-language.service';
describe('PlainLanguageService', () => {
let service: PlainLanguageService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(PlainLanguageService);
// Clear localStorage before each test
localStorage.removeItem('stellaops-plain-language-enabled');
});
afterEach(() => {
localStorage.removeItem('stellaops-plain-language-enabled');
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('toggle functionality', () => {
it('should start disabled by default', () => {
expect(service.isPlainLanguageEnabled()).toBeFalse();
});
it('should toggle on', () => {
service.togglePlainLanguage();
expect(service.isPlainLanguageEnabled()).toBeTrue();
});
it('should toggle off after toggling on', () => {
service.togglePlainLanguage();
service.togglePlainLanguage();
expect(service.isPlainLanguageEnabled()).toBeFalse();
});
it('should set explicitly', () => {
service.setPlainLanguage(true);
expect(service.isPlainLanguageEnabled()).toBeTrue();
service.setPlainLanguage(false);
expect(service.isPlainLanguageEnabled()).toBeFalse();
});
it('should persist preference to localStorage', () => {
service.setPlainLanguage(true);
expect(localStorage.getItem('stellaops-plain-language-enabled')).toBe('true');
});
});
describe('translate', () => {
it('should return technical term when disabled', () => {
service.setPlainLanguage(false);
const result = service.translate('component_added_reachable_cve');
expect(result).toBe('component_added_reachable_cve');
});
it('should translate known delta categories when enabled', () => {
service.setPlainLanguage(true);
const result = service.translate('component_added_reachable_cve');
expect(result).toContain('new library');
});
it('should translate VEX status terms', () => {
service.setPlainLanguage(true);
const result = service.translate('vex_status_not_affected');
expect(result).toContain('vendor confirmed');
});
it('should translate reachability terms', () => {
service.setPlainLanguage(true);
const result = service.translate('reachability_unreachable');
expect(result).toContain('never actually runs');
});
it('should translate KEV term', () => {
service.setPlainLanguage(true);
const result = service.translate('kev_flagged');
expect(result).toContain('actively exploiting');
});
it('should return original term for unknown terms', () => {
service.setPlainLanguage(true);
const result = service.translate('unknown_term_xyz');
expect(result).toBe('unknown_term_xyz');
});
});
describe('getFullTranslation', () => {
it('should return full translation with impact', () => {
const result = service.getFullTranslation('kev_flagged');
expect(result).toBeTruthy();
expect(result?.plain).toContain('actively exploiting');
expect(result?.impact).toContain('Critical');
expect(result?.action).toBeTruthy();
});
it('should return null for unknown terms', () => {
const result = service.getFullTranslation('unknown_term');
expect(result).toBeNull();
});
});
describe('getGlossaryEntry', () => {
it('should return glossary entry for SBOM', () => {
const entry = service.getGlossaryEntry('sbom');
expect(entry).toBeTruthy();
expect(entry?.abbreviation).toBe('Software Bill of Materials');
expect(entry?.plainLanguage).toContain('list of all the parts');
});
it('should return glossary entry for CVE', () => {
const entry = service.getGlossaryEntry('cve');
expect(entry).toBeTruthy();
expect(entry?.abbreviation).toBe('Common Vulnerabilities and Exposures');
});
it('should return glossary entry for CVSS', () => {
const entry = service.getGlossaryEntry('cvss');
expect(entry).toBeTruthy();
expect(entry?.plainLanguage).toContain('score from 0-10');
});
it('should return glossary entry for reachability', () => {
const entry = service.getGlossaryEntry('reachability');
expect(entry).toBeTruthy();
expect(entry?.plainLanguage).toContain('code actually runs');
});
it('should return null for unknown terms', () => {
const entry = service.getGlossaryEntry('unknown_term');
expect(entry).toBeNull();
});
it('should be case-insensitive', () => {
const entry1 = service.getGlossaryEntry('SBOM');
const entry2 = service.getGlossaryEntry('sbom');
expect(entry1).toEqual(entry2);
});
});
describe('getAllGlossaryEntries', () => {
it('should return all glossary entries', () => {
const entries = service.getAllGlossaryEntries();
expect(entries.length).toBeGreaterThan(10);
expect(entries.some(e => e.term === 'SBOM')).toBeTrue();
expect(entries.some(e => e.term === 'CVE')).toBeTrue();
expect(entries.some(e => e.term === 'VEX')).toBeTrue();
});
});
describe('findTermsInText', () => {
it('should find SBOM in text', () => {
const text = 'This SBOM contains all dependencies';
const terms = service.findTermsInText(text);
expect(terms.some(t => t.term === 'SBOM')).toBeTrue();
});
it('should find multiple terms', () => {
const text = 'The CVE has a high CVSS score and is in the KEV list';
const terms = service.findTermsInText(text);
expect(terms.some(t => t.term === 'CVE')).toBeTrue();
expect(terms.some(t => t.term === 'CVSS')).toBeTrue();
expect(terms.some(t => t.term === 'KEV')).toBeTrue();
});
it('should return correct positions', () => {
const text = 'Check the SBOM';
const terms = service.findTermsInText(text);
const sbomTerm = terms.find(t => t.term === 'SBOM');
expect(sbomTerm).toBeTruthy();
expect(text.substring(sbomTerm!.start, sbomTerm!.end).toUpperCase()).toBe('SBOM');
});
it('should not find terms that are substrings of other words', () => {
const text = 'The reachability analysis shows VEX status';
const terms = service.findTermsInText(text);
// Should find VEX and Reachability
expect(terms.some(t => t.term === 'VEX')).toBeTrue();
});
it('should handle empty text', () => {
const terms = service.findTermsInText('');
expect(terms.length).toBe(0);
});
});
});

View File

@@ -0,0 +1,395 @@
/**
* Plain Language Service
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Tasks: VD-ENH-05, VD-ENH-06, VD-ENH-07
*
* Provides translations from technical security terms to plain language.
*/
import { Injectable, signal, computed, effect } from '@angular/core';
/**
* Context for translation to provide more accurate explanations.
*/
export interface TranslationContext {
category?: 'delta' | 'verdict' | 'evidence' | 'risk' | 'general';
severity?: 'critical' | 'high' | 'medium' | 'low' | 'info';
audience?: 'developer' | 'security' | 'executive' | 'general';
}
/**
* Technical term with plain language explanation.
*/
export interface GlossaryEntry {
term: string;
abbreviation?: string;
plainLanguage: string;
detailedExplanation: string;
learnMoreUrl?: string;
relatedTerms?: string[];
}
/**
* Delta category translation mapping.
*/
export interface DeltaCategoryTranslation {
technical: string;
plain: string;
impact: string;
action?: string;
}
const PREFERENCE_KEY = 'stellaops-plain-language-enabled';
@Injectable({ providedIn: 'root' })
export class PlainLanguageService {
/** Whether plain language mode is enabled. */
private _enabled = signal<boolean>(false);
/** Observable for plain language state. */
readonly enabled = this._enabled.asReadonly();
/** Computed signal for checking if enabled. */
readonly isPlainLanguageEnabled = computed(() => this._enabled());
/** Delta category translations */
private readonly deltaCategoryTranslations: Map<string, DeltaCategoryTranslation> = new Map([
['component_added_reachable_cve', {
technical: 'Component added with reachable CVE',
plain: 'A new library was added that has a known security issue that your code actually uses',
impact: 'Your application may be vulnerable to attacks through this new dependency',
action: 'Review the vulnerability and consider whether this library is necessary',
}],
['component_added_unreachable_cve', {
technical: 'Component added with unreachable CVE',
plain: 'A new library was added that has a security issue, but your code doesn\'t use the vulnerable part',
impact: 'Low risk - the vulnerable code exists but isn\'t called',
action: 'Monitor but no immediate action required',
}],
['component_removed', {
technical: 'Component removed',
plain: 'A library was removed from your application',
impact: 'This might fix vulnerabilities or change functionality',
}],
['cve_newly_reachable', {
technical: 'CVE became reachable',
plain: 'A security issue that was dormant is now being used by your code',
impact: 'Increased risk - previously safe code is now potentially vulnerable',
action: 'Prioritize remediation of this vulnerability',
}],
['cve_no_longer_reachable', {
technical: 'CVE no longer reachable',
plain: 'A security issue is no longer being used by your code',
impact: 'Reduced risk - the vulnerable code is still there but not called',
}],
['vex_status_not_affected', {
technical: 'VEX status: not_affected',
plain: 'The vendor confirmed this security issue doesn\'t apply to your version',
impact: 'No action needed - this is an official "all clear"',
}],
['vex_status_affected', {
technical: 'VEX status: affected',
plain: 'The vendor confirmed this security issue affects your version',
impact: 'Action required - update or apply mitigations',
action: 'Update to a patched version or apply vendor-recommended mitigations',
}],
['vex_status_fixed', {
technical: 'VEX status: fixed',
plain: 'The vendor has released a fix for this security issue',
impact: 'A patch is available',
action: 'Update to the fixed version',
}],
['reachability_confirmed', {
technical: 'Reachability confirmed',
plain: 'We verified that your application actually runs the vulnerable code',
impact: 'Higher confidence that this vulnerability is a real risk',
}],
['reachability_unreachable', {
technical: 'Reachability: unreachable',
plain: 'This vulnerability exists in the code, but your app never actually runs that code',
impact: 'Lower risk - the vulnerability can\'t be exploited in practice',
}],
['risk_score_increased', {
technical: 'Risk score increased',
plain: 'This release is riskier than the last one',
impact: 'More vulnerabilities or higher severity issues detected',
action: 'Review the new risks before deploying',
}],
['risk_score_decreased', {
technical: 'Risk score decreased',
plain: 'This release is safer than the last one',
impact: 'Fewer vulnerabilities or issues have been resolved',
}],
['kev_flagged', {
technical: 'KEV flagged',
plain: 'Attackers are actively exploiting this vulnerability in the wild right now',
impact: 'Critical - this is being used in real attacks',
action: 'Remediate immediately - this is a high-priority security issue',
}],
['epss_high', {
technical: 'High EPSS score',
plain: 'This vulnerability is likely to be exploited soon based on threat intelligence',
impact: 'Higher priority for remediation',
}],
]);
/** Glossary of technical terms */
private readonly glossary: Map<string, GlossaryEntry> = new Map([
['sbom', {
term: 'SBOM',
abbreviation: 'Software Bill of Materials',
plainLanguage: 'A list of all the parts (libraries, packages) that make up your software',
detailedExplanation: 'Think of it like an ingredients list for your software. It tells you exactly what components are included, their versions, and where they came from.',
learnMoreUrl: 'https://www.cisa.gov/sbom',
relatedTerms: ['purl', 'dependency'],
}],
['vex', {
term: 'VEX',
abbreviation: 'Vulnerability Exploitability eXchange',
plainLanguage: 'A statement from a vendor about whether a vulnerability actually affects their product',
detailedExplanation: 'When a vulnerability is found, VEX documents let vendors say "yes, this affects us" or "no, this doesn\'t apply to our product." It helps reduce noise from vulnerabilities that don\'t actually matter.',
learnMoreUrl: 'https://www.cisa.gov/vex',
relatedTerms: ['cve', 'affected'],
}],
['cve', {
term: 'CVE',
abbreviation: 'Common Vulnerabilities and Exposures',
plainLanguage: 'A unique ID for a specific security vulnerability',
detailedExplanation: 'CVE-2024-1234 is like a serial number for a security bug. It lets everyone refer to the same vulnerability without confusion.',
learnMoreUrl: 'https://cve.mitre.org/',
relatedTerms: ['vulnerability', 'cvss'],
}],
['cvss', {
term: 'CVSS',
abbreviation: 'Common Vulnerability Scoring System',
plainLanguage: 'A score from 0-10 showing how dangerous a vulnerability is',
detailedExplanation: 'CVSS rates vulnerabilities based on how easy they are to exploit and how much damage they could cause. 0 is harmless, 10 is catastrophic.',
learnMoreUrl: 'https://www.first.org/cvss/',
relatedTerms: ['severity', 'cve'],
}],
['epss', {
term: 'EPSS',
abbreviation: 'Exploit Prediction Scoring System',
plainLanguage: 'The probability that this vulnerability will be exploited in the next 30 days',
detailedExplanation: 'EPSS uses machine learning to predict which vulnerabilities attackers are likely to target soon, helping you prioritize what to fix first.',
learnMoreUrl: 'https://www.first.org/epss/',
relatedTerms: ['kev', 'exploit'],
}],
['kev', {
term: 'KEV',
abbreviation: 'Known Exploited Vulnerabilities',
plainLanguage: 'Vulnerabilities that attackers are actively using right now',
detailedExplanation: 'CISA maintains a list of vulnerabilities confirmed to be actively exploited. If something is on this list, it means real attackers are using it in actual attacks.',
learnMoreUrl: 'https://www.cisa.gov/known-exploited-vulnerabilities-catalog',
relatedTerms: ['exploit', 'epss'],
}],
['reachability', {
term: 'Reachability',
plainLanguage: 'Whether your code actually runs the vulnerable function',
detailedExplanation: 'Just because a vulnerability exists in a library doesn\'t mean your app uses that part. Reachability analysis traces your code to see if the vulnerable function is ever actually called.',
relatedTerms: ['call-path', 'entry-point'],
}],
['call-path', {
term: 'Call Path',
plainLanguage: 'The chain of function calls from your code to a vulnerable function',
detailedExplanation: 'Like following a trail of breadcrumbs, a call path shows exactly how your code leads to the vulnerable function: main() → process() → parse() → vulnerable_func().',
relatedTerms: ['reachability', 'entry-point'],
}],
['entry-point', {
term: 'Entry Point',
plainLanguage: 'Where external input enters your application',
detailedExplanation: 'Entry points are places like API endpoints, form handlers, or file readers where untrusted data comes into your app. Attackers typically exploit vulnerabilities through these.',
relatedTerms: ['call-path', 'sink'],
}],
['dsse', {
term: 'DSSE',
abbreviation: 'Dead Simple Signing Envelope',
plainLanguage: 'A secure wrapper that proves who signed a document and that it hasn\'t been tampered with',
detailedExplanation: 'DSSE is a standard format for digitally signing documents. It includes both the signature and metadata about who signed it.',
relatedTerms: ['attestation', 'signature'],
}],
['attestation', {
term: 'Attestation',
plainLanguage: 'A signed statement proving something about your software',
detailedExplanation: 'Like a notarized document, an attestation is a cryptographically signed statement. For example, "this SBOM was generated from this specific container image by this scanner."',
relatedTerms: ['dsse', 'merkle-proof'],
}],
['merkle-proof', {
term: 'Merkle Proof',
plainLanguage: 'Cryptographic proof that data hasn\'t been changed',
detailedExplanation: 'A Merkle proof uses math to prove that a piece of data is part of a larger set without revealing the whole set. It\'s like proving a receipt matches a ledger without showing all transactions.',
relatedTerms: ['attestation', 'hash'],
}],
['baseline', {
term: 'Baseline',
plainLanguage: 'The previous version you\'re comparing against',
detailedExplanation: 'When looking at changes, the baseline is your starting point - usually the last known good release or the production version.',
relatedTerms: ['head', 'delta'],
}],
['head', {
term: 'Head',
plainLanguage: 'The current/new version you\'re evaluating',
detailedExplanation: 'In a comparison, head is what you\'re checking - usually a new build or PR that you want to deploy.',
relatedTerms: ['baseline', 'delta'],
}],
['delta', {
term: 'Delta',
plainLanguage: 'What changed between two versions',
detailedExplanation: 'The delta shows everything that\'s different: new vulnerabilities, fixed issues, changed dependencies, and risk score changes.',
relatedTerms: ['baseline', 'head'],
}],
['purl', {
term: 'PURL',
abbreviation: 'Package URL',
plainLanguage: 'A standard way to identify a software package',
detailedExplanation: 'PURL is like a unique address for packages. "pkg:npm/lodash@4.17.21" tells you exactly which package, from which ecosystem, at which version.',
learnMoreUrl: 'https://github.com/package-url/purl-spec',
relatedTerms: ['sbom', 'dependency'],
}],
]);
constructor() {
// Load preference on init
this.loadPreference();
}
/**
* Load preference from localStorage.
*/
private loadPreference(): void {
try {
const saved = localStorage.getItem(PREFERENCE_KEY);
if (saved === 'true') {
this._enabled.set(true);
}
} catch {
// Ignore storage errors
}
}
/**
* Toggle plain language mode.
*/
togglePlainLanguage(): void {
const newValue = !this._enabled();
this._enabled.set(newValue);
try {
localStorage.setItem(PREFERENCE_KEY, String(newValue));
} catch {
// Ignore storage errors
}
}
/**
* Set plain language mode explicitly.
*/
setPlainLanguage(enabled: boolean): void {
this._enabled.set(enabled);
try {
localStorage.setItem(PREFERENCE_KEY, String(enabled));
} catch {
// Ignore storage errors
}
}
/**
* Translate a technical term or phrase to plain language.
*/
translate(technicalTerm: string, context?: TranslationContext): string {
if (!this._enabled()) {
return technicalTerm;
}
// Check delta category translations
const normalizedKey = technicalTerm.toLowerCase().replace(/[\s-]+/g, '_');
const categoryTranslation = this.deltaCategoryTranslations.get(normalizedKey);
if (categoryTranslation) {
return categoryTranslation.plain;
}
// Check glossary
const glossaryEntry = this.glossary.get(normalizedKey);
if (glossaryEntry) {
return glossaryEntry.plainLanguage;
}
return technicalTerm;
}
/**
* Get full translation with impact and action.
*/
getFullTranslation(technicalTerm: string): DeltaCategoryTranslation | null {
const normalizedKey = technicalTerm.toLowerCase().replace(/[\s-]+/g, '_');
return this.deltaCategoryTranslations.get(normalizedKey) ?? null;
}
/**
* Get glossary entry for a term.
*/
getGlossaryEntry(term: string): GlossaryEntry | null {
const normalizedKey = term.toLowerCase().replace(/[\s-]+/g, '_');
return this.glossary.get(normalizedKey) ?? null;
}
/**
* Get all glossary entries.
*/
getAllGlossaryEntries(): GlossaryEntry[] {
return Array.from(this.glossary.values());
}
/**
* Find technical terms in text that have glossary entries.
*/
findTermsInText(text: string): Array<{ term: string; start: number; end: number }> {
const found: Array<{ term: string; start: number; end: number }> = [];
const lowerText = text.toLowerCase();
for (const [key, entry] of this.glossary) {
// Check full term
let index = lowerText.indexOf(entry.term.toLowerCase());
while (index !== -1) {
found.push({
term: entry.term,
start: index,
end: index + entry.term.length,
});
index = lowerText.indexOf(entry.term.toLowerCase(), index + 1);
}
// Check abbreviation
if (entry.abbreviation) {
index = lowerText.indexOf(key);
while (index !== -1) {
// Only match whole words
const before = index === 0 || /\W/.test(text[index - 1]);
const after = index + key.length >= text.length || /\W/.test(text[index + key.length]);
if (before && after) {
found.push({
term: entry.term,
start: index,
end: index + key.length,
});
}
index = lowerText.indexOf(key, index + 1);
}
}
}
// Sort by position and remove overlaps
found.sort((a, b) => a.start - b.start);
const deduped: typeof found = [];
for (const item of found) {
const last = deduped[deduped.length - 1];
if (!last || item.start >= last.end) {
deduped.push(item);
}
}
return deduped;
}
}

View File

@@ -0,0 +1,264 @@
/**
* Graph Controls Component Stories
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-10
*/
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { GraphDiffComponent } from '../../app/shared/components/graph-diff/graph-diff.component';
import { ReachabilityGraph } from '../../app/shared/components/graph-diff/graph-diff.models';
const mockGraph: ReachabilityGraph = {
id: 'demo',
digest: 'sha256:demo123',
nodes: [
{ id: 'main', label: 'main()', type: 'entry' },
{ id: 'process', label: 'process()', type: 'function' },
{ id: 'validate', label: 'validate()', type: 'function' },
{ id: 'transform', label: 'transform()', type: 'function' },
{ id: 'output', label: 'output()', type: 'function' },
],
edges: [
{ id: 'e1', sourceId: 'main', targetId: 'process', type: 'call' },
{ id: 'e2', sourceId: 'process', targetId: 'validate', type: 'call' },
{ id: 'e3', sourceId: 'validate', targetId: 'transform', type: 'call' },
{ id: 'e4', sourceId: 'transform', targetId: 'output', type: 'call' },
],
entryPoints: ['main'],
vulnerableNodes: [],
};
const meta: Meta = {
title: 'Graph Diff/Graph Controls',
decorators: [
moduleMetadata({
imports: [GraphDiffComponent],
}),
],
parameters: {
docs: {
description: {
component: `
Graph Controls provide navigation functionality for the GraphDiffComponent.
**Controls:**
- **Zoom In (+)**: Increase zoom level
- **Zoom Out (-)**: Decrease zoom level
- **Fit to View**: Reset to fit entire graph in viewport
- **Reset**: Return to default zoom and position
**Keyboard Shortcuts:**
- \`+\` or \`=\`: Zoom in
- \`-\`: Zoom out
- \`0\`: Fit to view
- \`R\`: Reset view
- \`Escape\`: Clear selection
**Minimap:**
For graphs with more than 50 nodes, a minimap is displayed for easier navigation.
`,
},
},
},
};
export default meta;
type Story = StoryObj;
export const ZoomControls: Story = {
name: 'Zoom Controls Demo',
render: () => ({
template: `
<div style="padding: 24px; background: #1a1a2e; height: 500px;">
<div style="margin-bottom: 16px; color: #fff;">
<strong>Zoom Controls:</strong>
<ul style="margin: 8px 0; padding-left: 20px; color: #aaa;">
<li>Click the + button to zoom in</li>
<li>Click the - button to zoom out</li>
<li>Use keyboard shortcuts: +/- or scroll with Ctrl</li>
</ul>
</div>
<stellaops-graph-diff
[baseGraph]="null"
[headGraph]="graph">
</stellaops-graph-diff>
</div>
`,
props: {
graph: mockGraph,
},
}),
};
export const FitToView: Story = {
name: 'Fit to View Demo',
render: () => ({
template: `
<div style="padding: 24px; background: #1a1a2e; height: 500px;">
<div style="margin-bottom: 16px; color: #fff;">
<strong>Fit to View:</strong>
<p style="margin: 8px 0; color: #aaa;">
After zooming or panning, click the "Fit" button to show the entire graph.
Or press "0" on the keyboard.
</p>
</div>
<stellaops-graph-diff
[baseGraph]="null"
[headGraph]="graph">
</stellaops-graph-diff>
</div>
`,
props: {
graph: mockGraph,
},
}),
};
export const PanNavigation: Story = {
name: 'Pan Navigation Demo',
render: () => ({
template: `
<div style="padding: 24px; background: #1a1a2e; height: 500px;">
<div style="margin-bottom: 16px; color: #fff;">
<strong>Pan Navigation:</strong>
<ul style="margin: 8px 0; padding-left: 20px; color: #aaa;">
<li>Click and drag to pan the view</li>
<li>Use arrow keys when focused</li>
<li>Touch devices: swipe to pan</li>
</ul>
</div>
<stellaops-graph-diff
[baseGraph]="null"
[headGraph]="graph">
</stellaops-graph-diff>
</div>
`,
props: {
graph: mockGraph,
},
}),
};
export const KeyboardShortcuts: Story = {
name: 'Keyboard Shortcuts Reference',
render: () => ({
template: `
<div style="padding: 24px; background: #f5f5f5;">
<h3 style="margin-top: 0;">Graph Navigation Keyboard Shortcuts</h3>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #e0e0e0;">
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #ccc;">Key</th>
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #ccc;">Action</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;"><code>+</code> or <code>=</code></td>
<td style="padding: 12px; border-bottom: 1px solid #eee;">Zoom in</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;"><code>-</code></td>
<td style="padding: 12px; border-bottom: 1px solid #eee;">Zoom out</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;"><code>0</code></td>
<td style="padding: 12px; border-bottom: 1px solid #eee;">Fit graph to view</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;"><code>R</code></td>
<td style="padding: 12px; border-bottom: 1px solid #eee;">Reset to default view</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;"><code>Escape</code></td>
<td style="padding: 12px; border-bottom: 1px solid #eee;">Clear selection</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;"><code>Tab</code></td>
<td style="padding: 12px; border-bottom: 1px solid #eee;">Navigate between nodes</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;"><code>Enter</code></td>
<td style="padding: 12px; border-bottom: 1px solid #eee;">Select focused node</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;"><code>Arrow Keys</code></td>
<td style="padding: 12px; border-bottom: 1px solid #eee;">Pan the view</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;"><code>Ctrl + Scroll</code></td>
<td style="padding: 12px; border-bottom: 1px solid #eee;">Zoom with mouse wheel</td>
</tr>
</tbody>
</table>
</div>
`,
}),
};
export const MinimapDemo: Story = {
name: 'Minimap (Large Graph)',
render: () => ({
template: `
<div style="padding: 24px; background: #1a1a2e; height: 600px;">
<div style="margin-bottom: 16px; color: #fff;">
<strong>Minimap Navigation:</strong>
<p style="margin: 8px 0; color: #aaa;">
For graphs with 50+ nodes, a minimap appears in the corner.
Click on the minimap to jump to that location.
</p>
</div>
<stellaops-graph-diff
[baseGraph]="null"
[headGraph]="largeGraph">
</stellaops-graph-diff>
</div>
`,
props: {
largeGraph: generateLargeGraph(60),
},
}),
};
function generateLargeGraph(nodeCount: number): ReachabilityGraph {
const nodes = [];
const edges = [];
for (let i = 0; i < nodeCount; i++) {
nodes.push({
id: `node-${i}`,
label: `function_${i}()`,
type: i === 0 ? 'entry' : i === nodeCount - 1 ? 'sink' : 'function',
});
if (i > 0) {
edges.push({
id: `edge-${i - 1}-${i}`,
sourceId: `node-${i - 1}`,
targetId: `node-${i}`,
type: 'call',
});
// Add some branches
if (i > 2 && i % 5 === 0) {
edges.push({
id: `edge-branch-${i}`,
sourceId: `node-${i - 3}`,
targetId: `node-${i}`,
type: 'call',
});
}
}
}
return {
id: 'large',
digest: 'sha256:large',
nodes,
edges,
entryPoints: ['node-0'],
vulnerableNodes: [`node-${nodeCount - 1}`],
};
}

View File

@@ -0,0 +1,408 @@
/**
* Graph Diff Component Stories
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-10
*/
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { GraphDiffComponent } from '../../app/shared/components/graph-diff/graph-diff.component';
import { ReachabilityGraph, GraphNode, GraphEdge } from '../../app/shared/components/graph-diff/graph-diff.models';
// --- Mock Data Factories ---
const createMockGraph = (
id: string,
nodeCount: number,
options: {
includeVulnerable?: boolean;
maxEdgesPerNode?: number;
} = {}
): ReachabilityGraph => {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const vulnerableNodes: string[] = [];
const entryPoints: string[] = [];
// Create nodes
for (let i = 0; i < nodeCount; i++) {
const nodeId = `node-${i}`;
const type = i === 0 ? 'entry' :
(options.includeVulnerable && i === nodeCount - 1) ? 'sink' :
'function';
nodes.push({
id: nodeId,
label: `function_${i}()`,
type,
});
if (type === 'entry') entryPoints.push(nodeId);
if (type === 'sink') vulnerableNodes.push(nodeId);
}
// Create edges (simple chain plus some branches)
const maxEdges = options.maxEdgesPerNode ?? 2;
for (let i = 0; i < nodeCount - 1; i++) {
edges.push({
id: `edge-${i}-${i + 1}`,
sourceId: `node-${i}`,
targetId: `node-${i + 1}`,
type: 'call',
});
// Add some branch edges
if (i > 0 && i < nodeCount - 2 && i % 3 === 0) {
edges.push({
id: `edge-${i}-${i + 2}`,
sourceId: `node-${i}`,
targetId: `node-${i + 2}`,
type: 'call',
});
}
}
return {
id,
digest: `sha256:${id}123456789abcdef`,
nodes,
edges,
entryPoints,
vulnerableNodes,
};
};
const mockBaseGraph: ReachabilityGraph = {
id: 'base',
digest: 'sha256:base123456789abcdef1234567890abcdef12345678',
nodes: [
{ id: 'main', label: 'main()', type: 'entry' },
{ id: 'init', label: 'init()', type: 'function' },
{ id: 'parseInput', label: 'parseInput()', type: 'function' },
{ id: 'processData', label: 'processData()', type: 'function' },
{ id: 'vulnerableFunc', label: 'vulnerable_handler()', type: 'sink' },
{ id: 'cleanup', label: 'cleanup()', type: 'function' },
],
edges: [
{ id: 'e1', sourceId: 'main', targetId: 'init', type: 'call' },
{ id: 'e2', sourceId: 'init', targetId: 'parseInput', type: 'call' },
{ id: 'e3', sourceId: 'parseInput', targetId: 'processData', type: 'call' },
{ id: 'e4', sourceId: 'processData', targetId: 'vulnerableFunc', type: 'call' },
{ id: 'e5', sourceId: 'main', targetId: 'cleanup', type: 'call' },
],
entryPoints: ['main'],
vulnerableNodes: ['vulnerableFunc'],
};
const mockHeadGraph: ReachabilityGraph = {
id: 'head',
digest: 'sha256:head456789abcdef1234567890abcdef123456789',
nodes: [
{ id: 'main', label: 'main()', type: 'entry' },
{ id: 'init', label: 'init_v2()', type: 'function' },
{ id: 'parseInput', label: 'parseInput()', type: 'function' },
{ id: 'validateInput', label: 'validateInput()', type: 'function' },
{ id: 'processData', label: 'processData()', type: 'function' },
{ id: 'safeHandler', label: 'safe_handler()', type: 'function' },
{ id: 'cleanup', label: 'cleanup()', type: 'function' },
],
edges: [
{ id: 'e1', sourceId: 'main', targetId: 'init', type: 'call' },
{ id: 'e2', sourceId: 'init', targetId: 'parseInput', type: 'call' },
{ id: 'e3', sourceId: 'parseInput', targetId: 'validateInput', type: 'call' },
{ id: 'e4', sourceId: 'validateInput', targetId: 'processData', type: 'call' },
{ id: 'e5', sourceId: 'processData', targetId: 'safeHandler', type: 'call' },
{ id: 'e6', sourceId: 'main', targetId: 'cleanup', type: 'call' },
],
entryPoints: ['main'],
vulnerableNodes: [],
};
// --- Storybook Meta ---
const meta: Meta<GraphDiffComponent> = {
title: 'Graph Diff/Graph Diff Component',
component: GraphDiffComponent,
decorators: [
moduleMetadata({
imports: [GraphDiffComponent],
}),
],
argTypes: {
nodeSelected: { action: 'nodeSelected' },
edgeSelected: { action: 'edgeSelected' },
},
parameters: {
a11y: {
element: '#graph-diff-story',
},
docs: {
description: {
component: `
GraphDiffComponent visualizes differences between two reachability graphs.
**Features:**
- SVG-based rendering for performance and accessibility
- Color-coded change indicators (added: green, removed: red, changed: amber)
- Pattern indicators for color-blind accessibility
- Interactive hover highlighting of connected paths
- Keyboard navigation support (+/- for zoom, arrow keys for pan)
- Breadcrumb navigation history
- Minimap for large graphs (>50 nodes)
**Props:**
- \`baseGraph\`: The baseline graph for comparison
- \`headGraph\`: The head/current graph to compare against baseline
- \`highlightedNode\`: Optional node ID to highlight externally
**Events:**
- \`nodeSelected\`: Emits when a node is clicked
- \`edgeSelected\`: Emits when an edge is clicked
`,
},
},
},
render: (args) => ({
props: args,
template: `
<div id="graph-diff-story" style="padding: 24px; background: #1a1a2e; height: 600px;">
<stellaops-graph-diff
[baseGraph]="baseGraph"
[headGraph]="headGraph"
[highlightedNode]="highlightedNode"
(nodeSelected)="nodeSelected($event)"
(edgeSelected)="edgeSelected($event)">
</stellaops-graph-diff>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<GraphDiffComponent>;
// --- Basic Examples ---
export const Default: Story = {
name: 'Default Comparison',
args: {
baseGraph: mockBaseGraph,
headGraph: mockHeadGraph,
highlightedNode: null,
},
};
export const HeadOnly: Story = {
name: 'Head Graph Only (All Added)',
args: {
baseGraph: null,
headGraph: mockHeadGraph,
highlightedNode: null,
},
};
export const BaseOnly: Story = {
name: 'Base Graph Only (All Removed)',
args: {
baseGraph: mockBaseGraph,
headGraph: null,
highlightedNode: null,
},
};
export const NoChanges: Story = {
name: 'No Changes (Identical Graphs)',
args: {
baseGraph: mockBaseGraph,
headGraph: mockBaseGraph,
highlightedNode: null,
},
};
export const EmptyGraphs: Story = {
name: 'Empty State',
args: {
baseGraph: null,
headGraph: null,
highlightedNode: null,
},
};
// --- Change Types ---
export const ManyAdditions: Story = {
name: 'Many Additions',
args: {
baseGraph: createMockGraph('base', 5),
headGraph: createMockGraph('head', 15, { includeVulnerable: true }),
highlightedNode: null,
},
};
export const ManyRemovals: Story = {
name: 'Many Removals',
args: {
baseGraph: createMockGraph('base', 15, { includeVulnerable: true }),
headGraph: createMockGraph('head', 5),
highlightedNode: null,
},
};
export const VulnerabilityRemoved: Story = {
name: 'Vulnerability Removed',
args: {
baseGraph: mockBaseGraph,
headGraph: mockHeadGraph,
highlightedNode: null,
},
parameters: {
docs: {
description: {
story: 'Shows the removal of a vulnerable function (vulnerableFunc) and addition of a safe handler.',
},
},
},
};
// --- Graph Sizes ---
export const SmallGraph: Story = {
name: 'Small Graph (5 nodes)',
args: {
baseGraph: createMockGraph('base', 3),
headGraph: createMockGraph('head', 5),
highlightedNode: null,
},
};
export const MediumGraph: Story = {
name: 'Medium Graph (25 nodes)',
args: {
baseGraph: createMockGraph('base', 20, { includeVulnerable: true }),
headGraph: createMockGraph('head', 25, { includeVulnerable: false }),
highlightedNode: null,
},
};
export const LargeGraph: Story = {
name: 'Large Graph (50+ nodes with minimap)',
args: {
baseGraph: createMockGraph('base', 50, { includeVulnerable: true }),
headGraph: createMockGraph('head', 55, { includeVulnerable: true }),
highlightedNode: null,
},
parameters: {
docs: {
description: {
story: 'Large graphs (>50 nodes) automatically display a minimap for navigation.',
},
},
},
};
// --- Highlighting ---
export const WithHighlightedNode: Story = {
name: 'With Highlighted Node',
args: {
baseGraph: mockBaseGraph,
headGraph: mockHeadGraph,
highlightedNode: 'processData',
},
};
export const HighlightedVulnerableNode: Story = {
name: 'Highlighted Vulnerable Node',
args: {
baseGraph: mockBaseGraph,
headGraph: mockBaseGraph,
highlightedNode: 'vulnerableFunc',
},
};
// --- Accessibility ---
export const AccessibilityDemo: Story = {
name: 'Accessibility Demo',
args: {
baseGraph: mockBaseGraph,
headGraph: mockHeadGraph,
highlightedNode: null,
},
parameters: {
docs: {
description: {
story: `
This demo showcases accessibility features:
- **Keyboard navigation**: Use Tab to focus nodes, +/- for zoom, 0 to reset, R to refresh
- **Color-blind patterns**: Change indicators include patterns in addition to colors
- **ARIA labels**: All interactive elements have descriptive labels
- **Focus indicators**: Clear focus rings for keyboard navigation
`,
},
},
},
};
// --- Edge Cases ---
export const SingleNode: Story = {
name: 'Single Node',
args: {
baseGraph: null,
headGraph: {
id: 'single',
digest: 'sha256:single',
nodes: [{ id: 'main', label: 'main()', type: 'entry' }],
edges: [],
entryPoints: ['main'],
vulnerableNodes: [],
},
highlightedNode: null,
},
};
export const DisconnectedNodes: Story = {
name: 'Disconnected Nodes',
args: {
baseGraph: null,
headGraph: {
id: 'disconnected',
digest: 'sha256:disconnected',
nodes: [
{ id: 'node1', label: 'isolated_1()', type: 'function' },
{ id: 'node2', label: 'isolated_2()', type: 'function' },
{ id: 'node3', label: 'isolated_3()', type: 'function' },
],
edges: [],
entryPoints: [],
vulnerableNodes: [],
},
highlightedNode: null,
},
};
export const CyclicGraph: Story = {
name: 'Cyclic Graph (Recursive Calls)',
args: {
baseGraph: null,
headGraph: {
id: 'cyclic',
digest: 'sha256:cyclic',
nodes: [
{ id: 'main', label: 'main()', type: 'entry' },
{ id: 'funcA', label: 'recursive_a()', type: 'function' },
{ id: 'funcB', label: 'recursive_b()', type: 'function' },
],
edges: [
{ id: 'e1', sourceId: 'main', targetId: 'funcA', type: 'call' },
{ id: 'e2', sourceId: 'funcA', targetId: 'funcB', type: 'call' },
{ id: 'e3', sourceId: 'funcB', targetId: 'funcA', type: 'call' },
],
entryPoints: ['main'],
vulnerableNodes: [],
},
highlightedNode: null,
},
};

View File

@@ -0,0 +1,263 @@
/**
* Plain Language Toggle Component Stories
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-10
*/
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { PlainLanguageToggleComponent } from '../../app/shared/components/plain-language-toggle/plain-language-toggle.component';
const meta: Meta<PlainLanguageToggleComponent> = {
title: 'Graph Diff/Plain Language Toggle',
component: PlainLanguageToggleComponent,
decorators: [
moduleMetadata({
imports: [PlainLanguageToggleComponent],
}),
],
argTypes: {
toggled: { action: 'toggled' },
},
parameters: {
a11y: {
element: '#plain-language-toggle-story',
},
docs: {
description: {
component: `
PlainLanguageToggleComponent provides a toggle switch for enabling "plain language" mode.
When enabled, technical security terms are translated into plain language explanations
that are easier for non-security professionals to understand.
**Features:**
- Toggle switch with clear on/off states
- Keyboard accessible (Alt+P shortcut)
- Persists preference to localStorage
- Animated state transitions
- Works with PlainLanguageService for translations
**Usage:**
\`\`\`html
<stellaops-plain-language-toggle
[(enabled)]="isPlainLanguageEnabled"
(toggled)="onToggle($event)">
</stellaops-plain-language-toggle>
\`\`\`
**Keyboard Shortcut:**
- \`Alt+P\`: Toggle plain language mode from anywhere in the application
`,
},
},
},
render: (args) => ({
props: args,
template: `
<div id="plain-language-toggle-story" style="padding: 24px; background: #f5f5f5;">
<stellaops-plain-language-toggle
[(enabled)]="enabled"
(toggled)="toggled($event)">
</stellaops-plain-language-toggle>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<PlainLanguageToggleComponent>;
// --- Basic States ---
export const Disabled: Story = {
name: 'Disabled (Default)',
args: {
enabled: false,
},
};
export const Enabled: Story = {
name: 'Enabled',
args: {
enabled: true,
},
};
// --- Context Examples ---
export const InToolbar: Story = {
name: 'In Toolbar Context',
args: {
enabled: false,
},
render: (args) => ({
props: args,
template: `
<div style="padding: 12px 24px; background: #2d2d3d; display: flex; align-items: center; gap: 16px; border-radius: 8px;">
<span style="color: #fff; font-size: 14px;">View Options:</span>
<stellaops-plain-language-toggle
[(enabled)]="enabled"
(toggled)="toggled($event)">
</stellaops-plain-language-toggle>
<span style="color: #aaa; font-size: 12px; margin-left: 8px;">Alt+P to toggle</span>
</div>
`,
}),
};
export const InHeader: Story = {
name: 'In Page Header',
args: {
enabled: false,
},
render: (args) => ({
props: args,
template: `
<div style="padding: 16px 24px; background: #1a1a2e; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center;">
<h2 style="color: #fff; margin: 0; font-size: 20px;">Compare View</h2>
<div style="display: flex; align-items: center; gap: 12px;">
<label style="color: #ccc; font-size: 13px;">Explain like I'm new</label>
<stellaops-plain-language-toggle
[(enabled)]="enabled"
(toggled)="toggled($event)">
</stellaops-plain-language-toggle>
</div>
</div>
`,
}),
};
// --- Dark/Light Themes ---
export const DarkTheme: Story = {
name: 'Dark Theme',
args: {
enabled: true,
},
render: (args) => ({
props: args,
template: `
<div style="padding: 24px; background: #1a1a2e;">
<stellaops-plain-language-toggle
[(enabled)]="enabled"
(toggled)="toggled($event)">
</stellaops-plain-language-toggle>
</div>
`,
}),
};
export const LightTheme: Story = {
name: 'Light Theme',
args: {
enabled: true,
},
render: (args) => ({
props: args,
template: `
<div style="padding: 24px; background: #ffffff;">
<stellaops-plain-language-toggle
[(enabled)]="enabled"
(toggled)="toggled($event)">
</stellaops-plain-language-toggle>
</div>
`,
}),
};
// --- With Translation Examples ---
export const WithTranslationDemo: Story = {
name: 'With Translation Demo',
args: {
enabled: false,
},
render: (args) => ({
props: { ...args, translations: getTranslations(args.enabled) },
template: `
<div style="padding: 24px; background: #f5f5f5; max-width: 600px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 20px;">
<label style="font-weight: 500;">Plain Language Mode:</label>
<stellaops-plain-language-toggle
[(enabled)]="enabled"
(toggled)="enabled = $event; translations = getTranslations($event)">
</stellaops-plain-language-toggle>
</div>
<div style="background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h4 style="margin-top: 0; color: #333;">Example Translations:</h4>
<ul style="list-style: none; padding: 0; margin: 0;">
<li style="padding: 8px 0; border-bottom: 1px solid #eee;">
<strong>KEV Flagged:</strong><br/>
<span style="color: #666;">{{ enabled ? 'Attackers are actively exploiting this vulnerability in the wild' : 'kev_flagged' }}</span>
</li>
<li style="padding: 8px 0; border-bottom: 1px solid #eee;">
<strong>VEX Not Affected:</strong><br/>
<span style="color: #666;">{{ enabled ? 'The vendor confirmed this issue doesn\\'t apply to your version' : 'vex_status_not_affected' }}</span>
</li>
<li style="padding: 8px 0;">
<strong>Reachability Unreachable:</strong><br/>
<span style="color: #666;">{{ enabled ? 'This vulnerability exists in the code, but your app never actually runs that code' : 'reachability_unreachable' }}</span>
</li>
</ul>
</div>
</div>
`,
}),
};
function getTranslations(enabled: boolean) {
if (!enabled) {
return {
kevFlagged: 'kev_flagged',
vexNotAffected: 'vex_status_not_affected',
unreachable: 'reachability_unreachable',
};
}
return {
kevFlagged: 'Attackers are actively exploiting this vulnerability in the wild',
vexNotAffected: "The vendor confirmed this issue doesn't apply to your version",
unreachable: "This vulnerability exists in the code, but your app never actually runs that code",
};
}
// --- Accessibility ---
export const AccessibilityDemo: Story = {
name: 'Accessibility Demo',
args: {
enabled: false,
},
parameters: {
docs: {
description: {
story: `
**Accessibility Features:**
- Role: \`switch\` with proper ARIA attributes
- \`aria-checked\`: Reflects the current state
- \`aria-label\`: Descriptive label for screen readers
- Keyboard operable: Space/Enter to toggle when focused
- Global shortcut: Alt+P works from anywhere
- Focus visible: Clear focus ring when navigating with keyboard
`,
},
},
},
render: (args) => ({
props: args,
template: `
<div style="padding: 24px; background: #f5f5f5;">
<p style="margin-bottom: 16px; color: #666;">
<strong>Test accessibility:</strong> Use Tab to focus, then Space or Enter to toggle.
Or use Alt+P from anywhere on the page.
</p>
<stellaops-plain-language-toggle
[(enabled)]="enabled"
(toggled)="toggled($event)">
</stellaops-plain-language-toggle>
</div>
`,
}),
};

View File

@@ -0,0 +1,529 @@
import { expect, test } from '@playwright/test';
const mockConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stellaops-ui',
authorizeEndpoint: 'https://authority.local/connect/authorize',
tokenEndpoint: 'https://authority.local/connect/token',
logoutEndpoint: 'https://authority.local/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope: 'openid profile email ui.read risk:read risk:manage exceptions:read exceptions:manage',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
quickstartMode: true,
};
const mockBudgetSnapshot = {
config: {
id: 'budget-1',
tenantId: 'tenant-1',
totalBudget: 1000,
warningThreshold: 70,
criticalThreshold: 90,
period: 'monthly',
createdAt: '2025-12-01T00:00:00Z',
updatedAt: '2025-12-01T00:00:00Z',
},
currentRiskPoints: 450,
headroom: 550,
utilizationPercent: 45,
status: 'healthy',
timeSeries: [
{ timestamp: '2025-12-20T00:00:00Z', actual: 300, budget: 1000, headroom: 700 },
{ timestamp: '2025-12-21T00:00:00Z', actual: 350, budget: 1000, headroom: 650 },
{ timestamp: '2025-12-22T00:00:00Z', actual: 380, budget: 1000, headroom: 620 },
{ timestamp: '2025-12-23T00:00:00Z', actual: 420, budget: 1000, headroom: 580 },
{ timestamp: '2025-12-24T00:00:00Z', actual: 450, budget: 1000, headroom: 550 },
],
computedAt: '2025-12-26T00:00:00Z',
traceId: 'trace-budget-1',
};
const mockBudgetKpis = {
headroom: 550,
headroomDelta24h: -30,
unknownsDelta24h: 2,
riskRetired7d: 85,
exceptionsExpiring: 1,
burnRate: 12.5,
projectedDaysToExceeded: 44,
topContributors: [
{ vulnId: 'CVE-2025-1234', riskPoints: 50, packageName: 'lodash' },
{ vulnId: 'CVE-2025-5678', riskPoints: 35, packageName: 'express' },
],
traceId: 'trace-kpi-1',
};
const mockVerdict = {
id: 'verdict-1',
artifactDigest: 'sha256:abc123def456',
level: 'review',
drivers: [
{
category: 'high_vuln',
summary: '2 high severity vulnerabilities detected',
description: 'CVE-2025-1234 and CVE-2025-5678 require review',
impact: 2,
relatedIds: ['CVE-2025-1234', 'CVE-2025-5678'],
evidenceType: 'vex',
},
{
category: 'budget_exceeded',
summary: 'Budget utilization at 45%',
description: 'Within healthy range but trending upward',
impact: false,
},
],
previousVerdict: {
level: 'routine',
timestamp: '2025-12-23T10:00:00Z',
},
riskDelta: {
totalDelta: 30,
criticalDelta: 0,
highDelta: 2,
mediumDelta: 1,
lowDelta: -3,
},
timestamp: '2025-12-26T10:00:00Z',
traceId: 'trace-verdict-1',
};
const mockExceptions = {
items: [
{
id: 'exc-1',
tenantId: 'tenant-1',
title: 'Known false positive in lodash',
type: 'vulnerability',
status: 'approved',
severity: 'high',
justification: 'Not exploitable in our configuration',
scope: { cves: ['CVE-2025-1234'] },
createdAt: '2025-12-20T10:00:00Z',
createdBy: 'user-1',
expiresAt: '2026-01-20T10:00:00Z',
riskPointsCovered: 50,
reviewedBy: 'approver-1',
reviewedAt: '2025-12-21T10:00:00Z',
},
],
total: 1,
};
const mockSession = {
accessToken: 'mock-access-token',
idToken: 'mock-id-token',
expiresAt: Date.now() + 3600000,
user: {
sub: 'user-pm-1',
name: 'PM User',
email: 'pm@stellaops.test',
},
scopes: ['risk:read', 'risk:manage', 'exceptions:read', 'exceptions:manage'],
tenantId: 'tenant-1',
};
function setupMockRoutes(page) {
// Mock config
page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
// Mock budget snapshot API
page.route('**/api/risk/budgets/*/snapshot', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBudgetSnapshot),
})
);
// Mock budget KPIs API
page.route('**/api/risk/budgets/*/kpis', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBudgetKpis),
})
);
// Mock verdict API
page.route('**/api/risk/gate/verdict*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockVerdict),
})
);
// Mock verdict history API
page.route('**/api/risk/gate/history*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([mockVerdict]),
})
);
// Mock exceptions API
page.route('**/api/v1/exceptions*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockExceptions),
})
);
// Mock exception create API
page.route('**/api/v1/exceptions', async (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
...mockExceptions.items[0],
id: 'exc-new-1',
status: 'pending_review',
}),
});
} else {
route.continue();
}
});
// Block authority
page.route('https://authority.local/**', (route) => route.abort());
}
test.describe('Risk Dashboard - Budget View', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, mockSession);
await setupMockRoutes(page);
});
test('displays budget burn-up chart', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Chart should be visible
const chart = page.locator('.burnup-chart, [data-testid="budget-chart"]');
await expect(chart).toBeVisible();
});
test('displays budget KPI tiles', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// KPI tiles should show headroom
await expect(page.getByText('550')).toBeVisible(); // Headroom value
await expect(page.getByText(/headroom/i)).toBeVisible();
});
test('shows budget status indicator', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Status should be healthy (45% utilization)
const healthyIndicator = page.locator('.healthy, [data-status="healthy"]');
await expect(healthyIndicator.first()).toBeVisible();
});
test('displays exceptions expiring count', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Exceptions expiring KPI
await expect(page.getByText(/exceptions.*expir/i)).toBeVisible();
});
test('shows risk retired in 7 days', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Risk retired value (85)
await expect(page.getByText('85')).toBeVisible();
});
});
test.describe('Risk Dashboard - Verdict View', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, mockSession);
await setupMockRoutes(page);
});
test('displays verdict badge', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Verdict badge should show "Review"
const verdictBadge = page.locator('.verdict-badge, [data-testid="verdict-badge"]');
await expect(verdictBadge).toBeVisible();
await expect(verdictBadge).toContainText(/review/i);
});
test('displays verdict drivers', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Driver summary should be visible
await expect(page.getByText(/high severity vulnerabilities/i)).toBeVisible();
});
test('shows risk delta from previous verdict', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Risk delta indicator
await expect(page.getByText(/\+30|\+2/)).toBeVisible();
});
test('clicking evidence button opens panel', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Find and click evidence button
const evidenceButton = page.getByRole('button', { name: /show.*vex|view.*evidence/i });
if (await evidenceButton.isVisible()) {
await evidenceButton.click();
// Panel should open
await expect(page.locator('.vex-panel, [data-testid="vex-panel"]')).toBeVisible();
}
});
test('verdict tooltip shows summary', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Hover over verdict badge
const verdictBadge = page.locator('.verdict-badge, [data-testid="verdict-badge"]');
await verdictBadge.hover();
// Tooltip should appear with summary
// Note: Actual tooltip behavior depends on implementation
});
});
test.describe('Risk Dashboard - Exception Workflow', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, mockSession);
await setupMockRoutes(page);
});
test('displays active exceptions', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Exception should be visible
await expect(page.getByText(/lodash|CVE-2025-1234/i)).toBeVisible();
});
test('opens create exception modal', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Find create exception button
const createButton = page.getByRole('button', { name: /create.*exception|add.*exception/i });
if (await createButton.isVisible()) {
await createButton.click();
// Modal should open
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText(/create exception/i)).toBeVisible();
}
});
test('exception form validates required fields', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Open create modal
const createButton = page.getByRole('button', { name: /create.*exception|add.*exception/i });
if (await createButton.isVisible()) {
await createButton.click();
await expect(page.getByRole('dialog')).toBeVisible();
// Submit button should be disabled without required fields
const submitButton = page.getByRole('button', { name: /create|submit/i }).last();
await expect(submitButton).toBeDisabled();
// Fill required fields
await page.getByLabel(/title/i).fill('Test Exception');
await page.getByLabel(/justification/i).fill('Test justification for E2E');
// Submit should now be enabled
await expect(submitButton).toBeEnabled();
}
});
test('shows exception expiry warning', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Look for expiry information
const expiryInfo = page.getByText(/expires|expiring/i);
await expect(expiryInfo.first()).toBeVisible();
});
});
test.describe('Risk Dashboard - Side-by-Side Diff', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, mockSession);
await setupMockRoutes(page);
});
test('displays before and after states', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Look for comparison view
const beforePane = page.locator('.pane.before, [data-testid="before-pane"]');
const afterPane = page.locator('.pane.after, [data-testid="after-pane"]');
if (await beforePane.isVisible()) {
await expect(afterPane).toBeVisible();
}
});
test('highlights metric changes', async ({ page }) => {
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Look for delta indicators
const deltaIndicator = page.locator('.metric-delta, .delta-badge, [data-testid="delta"]');
if (await deltaIndicator.first().isVisible()) {
// Delta should show change direction
await expect(deltaIndicator.first()).toBeVisible();
}
});
});
test.describe('Risk Dashboard - Responsive Design', () => {
test('adapts to tablet viewport', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, mockSession);
await setupMockRoutes(page);
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Dashboard should be usable on tablet
const dashboard = page.locator('.dashboard-layout, [data-testid="risk-dashboard"]');
await expect(dashboard).toBeVisible();
});
test('adapts to desktop viewport', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, mockSession);
await setupMockRoutes(page);
await page.goto('/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Dashboard should use full width on desktop
const dashboard = page.locator('.dashboard-layout, [data-testid="risk-dashboard"]');
await expect(dashboard).toBeVisible();
});
});

View File

@@ -0,0 +1,505 @@
/**
* Visual Diff E2E Tests
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
* Task: VD-ENH-12
*
* Tests for the visual diff workflow including graph diff, plain language toggle,
* and export functionality.
*/
import { expect, test } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const mockConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stellaops-ui',
authorizeEndpoint: 'https://authority.local/connect/authorize',
tokenEndpoint: 'https://authority.local/connect/token',
logoutEndpoint: 'https://authority.local/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
quickstartMode: true,
};
// Mock graph data for testing
const mockBaseGraph = {
id: 'base-graph',
digest: 'sha256:base123',
nodes: [
{ id: 'main', label: 'main()', type: 'entry' },
{ id: 'parseInput', label: 'parseInput()', type: 'function' },
{ id: 'processData', label: 'processData()', type: 'function' },
{ id: 'vulnerableFunc', label: 'vulnerableFunc()', type: 'sink' },
],
edges: [
{ id: 'main-parseInput', sourceId: 'main', targetId: 'parseInput', type: 'call' },
{ id: 'parseInput-processData', sourceId: 'parseInput', targetId: 'processData', type: 'call' },
{ id: 'processData-vulnerable', sourceId: 'processData', targetId: 'vulnerableFunc', type: 'call' },
],
entryPoints: ['main'],
vulnerableNodes: ['vulnerableFunc'],
};
const mockHeadGraph = {
id: 'head-graph',
digest: 'sha256:head456',
nodes: [
{ id: 'main', label: 'main()', type: 'entry' },
{ id: 'parseInput', label: 'parseInput() v2', type: 'function' },
{ id: 'validateInput', label: 'validateInput()', type: 'function' },
{ id: 'processData', label: 'processData()', type: 'function' },
{ id: 'safeFunc', label: 'safeFunc()', type: 'function' },
],
edges: [
{ id: 'main-parseInput', sourceId: 'main', targetId: 'parseInput', type: 'call' },
{ id: 'parseInput-validate', sourceId: 'parseInput', targetId: 'validateInput', type: 'call' },
{ id: 'validate-processData', sourceId: 'validateInput', targetId: 'processData', type: 'call' },
{ id: 'processData-safe', sourceId: 'processData', targetId: 'safeFunc', type: 'call' },
],
entryPoints: ['main'],
vulnerableNodes: [],
};
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors in restricted contexts
}
(window as any).__stellaopsTestSession = session;
}, policyAuthorSession);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
// Mock compare API endpoint
await page.route('**/api/v1/compare/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
baseGraph: mockBaseGraph,
headGraph: mockHeadGraph,
summary: {
nodesAdded: 2,
nodesRemoved: 1,
nodesChanged: 1,
edgesAdded: 2,
edgesRemoved: 1,
},
}),
})
);
});
test.describe('Graph Diff Component', () => {
test('should load compare view with two digests', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
// Wait for the graph diff component to load
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Check that the SVG viewport is rendered
await expect(page.locator('.graph-diff__svg')).toBeVisible();
});
test('should display graph diff summary', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Check for diff summary indicators
await expect(page.getByText(/added/i)).toBeVisible();
await expect(page.getByText(/removed/i)).toBeVisible();
});
test('should toggle between split and unified view', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Find and click the view mode toggle
const viewToggle = page.getByRole('button', { name: /split|unified/i });
if (await viewToggle.isVisible()) {
// Click to toggle to split view
await viewToggle.click();
// Check for split view container
const splitView = page.locator('.graph-split-view');
if (await splitView.isVisible()) {
await expect(splitView).toHaveClass(/split-mode/);
}
// Toggle back to unified
await viewToggle.click();
}
});
test('should navigate graph with keyboard', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Focus the graph container
const graphContainer = page.locator('.graph-diff__container');
await graphContainer.focus();
// Test zoom in with + key
await page.keyboard.press('+');
// Test zoom out with - key
await page.keyboard.press('-');
// Test reset view with 0 key
await page.keyboard.press('0');
});
test('should highlight connected nodes on hover', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Find a node element and hover
const node = page.locator('.graph-node').first();
if (await node.isVisible()) {
await node.hover();
// Check for highlight class
await expect(page.locator('.graph-node--highlighted')).toBeVisible();
}
});
test('should show node details on click', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Click on a node
const node = page.locator('.graph-node').first();
if (await node.isVisible()) {
await node.click();
// Check for node detail panel
const detailPanel = page.locator('.node-detail-panel, .graph-diff__detail');
if (await detailPanel.isVisible()) {
await expect(detailPanel).toBeVisible();
}
}
});
test('should add breadcrumbs for navigation history', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Click on multiple nodes to create breadcrumbs
const nodes = page.locator('.graph-node');
const count = await nodes.count();
if (count >= 2) {
await nodes.nth(0).click();
await nodes.nth(1).click();
// Check for breadcrumb trail
const breadcrumbs = page.locator('.graph-breadcrumb, .navigation-breadcrumb');
if (await breadcrumbs.isVisible()) {
await expect(breadcrumbs.locator('.breadcrumb-item')).toHaveCount(2);
}
}
});
});
test.describe('Plain Language Toggle', () => {
test('should toggle plain language mode', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
// Find the plain language toggle
const toggle = page.getByRole('switch', { name: /plain language|explain/i });
if (await toggle.isVisible()) {
// Initially should be off
await expect(toggle).not.toBeChecked();
// Toggle on
await toggle.click();
await expect(toggle).toBeChecked();
// Toggle off
await toggle.click();
await expect(toggle).not.toBeChecked();
}
});
test('should use Alt+P keyboard shortcut', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
const toggle = page.getByRole('switch', { name: /plain language|explain/i });
if (await toggle.isVisible()) {
const initialState = await toggle.isChecked();
// Press Alt+P
await page.keyboard.press('Alt+P');
// State should have toggled
const newState = await toggle.isChecked();
expect(newState).not.toBe(initialState);
}
});
test('should persist preference across page loads', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
const toggle = page.getByRole('switch', { name: /plain language|explain/i });
if (await toggle.isVisible()) {
// Enable plain language
if (!(await toggle.isChecked())) {
await toggle.click();
}
await expect(toggle).toBeChecked();
// Reload the page
await page.reload();
// Toggle should still be checked
const toggleAfterReload = page.getByRole('switch', { name: /plain language|explain/i });
if (await toggleAfterReload.isVisible()) {
await expect(toggleAfterReload).toBeChecked();
}
}
});
test('should show plain language explanations when enabled', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
const toggle = page.getByRole('switch', { name: /plain language|explain/i });
if (await toggle.isVisible()) {
// Enable plain language
await toggle.click();
// Check for plain language text patterns
// These are translations from the PlainLanguageService
const plainText = page.locator('text=/new library|vendor confirmed|never actually runs/i');
if ((await plainText.count()) > 0) {
await expect(plainText.first()).toBeVisible();
}
}
});
});
test.describe('Graph Export', () => {
test('should export graph diff as SVG', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Find export button
const exportButton = page.getByRole('button', { name: /export/i });
if (await exportButton.isVisible()) {
// Set up download listener
const downloadPromise = page.waitForEvent('download');
// Open export menu and select SVG
await exportButton.click();
const svgOption = page.getByRole('menuitem', { name: /svg/i });
if (await svgOption.isVisible()) {
await svgOption.click();
// Wait for download
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/graph-diff.*\.svg$/);
}
}
});
test('should export graph diff as PNG', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Find export button
const exportButton = page.getByRole('button', { name: /export/i });
if (await exportButton.isVisible()) {
// Set up download listener
const downloadPromise = page.waitForEvent('download');
// Open export menu and select PNG
await exportButton.click();
const pngOption = page.getByRole('menuitem', { name: /png/i });
if (await pngOption.isVisible()) {
await pngOption.click();
// Wait for download
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/graph-diff.*\.png$/);
}
}
});
});
test.describe('Zoom and Pan Controls', () => {
test('should zoom in with button', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
const zoomInButton = page.getByRole('button', { name: /zoom in|\+/i });
if (await zoomInButton.isVisible()) {
await zoomInButton.click();
// Verify zoom changed (implementation-specific check)
}
});
test('should zoom out with button', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
const zoomOutButton = page.getByRole('button', { name: /zoom out|-/i });
if (await zoomOutButton.isVisible()) {
await zoomOutButton.click();
// Verify zoom changed
}
});
test('should fit to view', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
const fitButton = page.getByRole('button', { name: /fit|reset/i });
if (await fitButton.isVisible()) {
// First zoom in
const zoomInButton = page.getByRole('button', { name: /zoom in|\+/i });
if (await zoomInButton.isVisible()) {
await zoomInButton.click();
await zoomInButton.click();
}
// Then fit to view
await fitButton.click();
}
});
test('should show minimap for large graphs', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Minimap should be visible for large graphs
const minimap = page.locator('.graph-minimap');
// Note: Minimap visibility depends on graph size (>50 nodes typically)
// This test checks the element exists when applicable
});
});
test.describe('Accessibility', () => {
test('should have proper ARIA labels', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Check for ARIA labels on interactive elements
const graphContainer = page.locator('[role="application"], [role="img"]');
if (await graphContainer.isVisible()) {
await expect(graphContainer).toHaveAttribute('aria-label');
}
// Check for keyboard focus indicators
const focusableElements = page.locator('.graph-node[tabindex], .graph-controls button');
const count = await focusableElements.count();
expect(count).toBeGreaterThan(0);
});
test('should support keyboard navigation between nodes', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Focus the graph container
const container = page.locator('.graph-diff__container');
await container.focus();
// Tab through nodes
await page.keyboard.press('Tab');
// Check that a node is focused
const focusedNode = page.locator('.graph-node:focus');
if (await focusedNode.isVisible()) {
await expect(focusedNode).toBeFocused();
}
});
test('should have color-blind safe indicators', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
// Check for pattern indicators (not just color)
const addedNodes = page.locator('.graph-node--added');
const removedNodes = page.locator('.graph-node--removed');
// Both should have additional indicators besides color
if (await addedNodes.first().isVisible()) {
// Check for icon or pattern class
const indicator = addedNodes.first().locator('.change-indicator, .node-icon');
// Verify some non-color indicator exists
}
});
});
test.describe('Glossary Tooltips', () => {
test('should show tooltip for technical terms', async ({ page }) => {
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
// Enable plain language mode to activate tooltips
const toggle = page.getByRole('switch', { name: /plain language|explain/i });
if (await toggle.isVisible()) {
await toggle.click();
}
// Find a technical term with tooltip directive
const technicalTerm = page.locator('[stellaopsGlossaryTooltip], .glossary-term').first();
if (await technicalTerm.isVisible()) {
await technicalTerm.hover();
// Check for tooltip appearance
const tooltip = page.locator('.glossary-tooltip, [role="tooltip"]');
await expect(tooltip).toBeVisible({ timeout: 5000 });
}
});
});