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:
168
src/Web/StellaOps.Web/src/app/core/api/delta-verdict.models.ts
Normal file
168
src/Web/StellaOps.Web/src/app/core/api/delta-verdict.models.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
120
src/Web/StellaOps.Web/src/app/core/api/risk-budget.models.ts
Normal file
120
src/Web/StellaOps.Web/src/app/core/api/risk-budget.models.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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">↑</span>
|
||||
} @else if (tile.trend === 'down') {
|
||||
<span class="delta-arrow">↓</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',
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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()">
|
||||
✕
|
||||
</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)"
|
||||
>
|
||||
✕
|
||||
</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 = '';
|
||||
}
|
||||
}
|
||||
@@ -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">→</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">ℹ</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">↔</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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 ? '▲' : '▼' }}
|
||||
</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());
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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">✓</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 ? '▲' : '▼' }}
|
||||
</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">●</span>
|
||||
} @else {
|
||||
<span class="connector-line"></span>
|
||||
<span class="connector-arrow">▼</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}`;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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">↑{{ summary().upgraded }}</span>
|
||||
}
|
||||
@if (summary().downgraded > 0) {
|
||||
<span class="summary-badge downgraded">↓{{ 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">↑</span> }
|
||||
@case ('downgraded') { <span class="icon downgraded">↓</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">→</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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">→</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
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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') { ✓ }
|
||||
@case ('review') { ⚠ }
|
||||
@case ('block') { ✗ }
|
||||
}
|
||||
</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">•</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');
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ↗
|
||||
</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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
21
src/Web/StellaOps.Web/src/app/shared/components/ai/index.ts
Normal file
21
src/Web/StellaOps.Web/src/app/shared/components/ai/index.ts
Normal 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';
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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"></></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);
|
||||
}
|
||||
}
|
||||
@@ -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, '\\$&');
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
529
src/Web/StellaOps.Web/tests/e2e/risk-dashboard.spec.ts
Normal file
529
src/Web/StellaOps.Web/tests/e2e/risk-dashboard.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
505
src/Web/StellaOps.Web/tests/e2e/visual-diff.spec.ts
Normal file
505
src/Web/StellaOps.Web/tests/e2e/visual-diff.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user