diff --git a/docs/implplan/SPRINT_3500_0004_0002_ui_components_visualization.md b/docs/implplan/SPRINT_3500_0004_0002_ui_components_visualization.md index c9d0e3344..d4ab49961 100644 --- a/docs/implplan/SPRINT_3500_0004_0002_ui_components_visualization.md +++ b/docs/implplan/SPRINT_3500_0004_0002_ui_components_visualization.md @@ -23,18 +23,18 @@ **Assignee**: UI Team **Story Points**: 5 -**Status**: TODO +**Status**: DONE **Description**: Create ProofLedgerViewComponent to display scan proof history with Merkle tree visualization. **Acceptance Criteria**: -- [ ] Displays scan manifest with all input hashes -- [ ] Shows Merkle tree structure (expandable) -- [ ] DSSE signature validation indicator -- [ ] Rekor transparency log link (if available) -- [ ] Download proof bundle button -- [ ] Responsive design (mobile-friendly) +- [x] Displays scan manifest with all input hashes +- [x] Shows Merkle tree structure (expandable) +- [x] DSSE signature validation indicator +- [x] Rekor transparency log link (if available) +- [x] Download proof bundle button +- [x] Responsive design (mobile-friendly) --- @@ -42,18 +42,18 @@ Create ProofLedgerViewComponent to display scan proof history with Merkle tree v **Assignee**: UI Team **Story Points**: 5 -**Status**: TODO +**Status**: DONE **Description**: Create UnknownsQueueComponent to manage unknown packages with band-based prioritization. **Acceptance Criteria**: -- [ ] Tabbed view: HOT / WARM / COLD bands -- [ ] Sort by rank, age, occurrence count -- [ ] Escalate/Resolve action buttons -- [ ] Batch selection and bulk actions -- [ ] Filter by scan, image, package type -- [ ] Real-time updates via SignalR +- [x] Tabbed view: HOT / WARM / COLD bands +- [x] Sort by rank, age, occurrence count +- [x] Escalate/Resolve action buttons +- [x] Batch selection and bulk actions +- [x] Filter by scan, image, package type +- [x] Real-time updates via SignalR (polling implementation) --- @@ -61,18 +61,18 @@ Create UnknownsQueueComponent to manage unknown packages with band-based priorit **Assignee**: UI Team **Story Points**: 8 -**Status**: TODO +**Status**: DONE **Description**: Create ReachabilityExplainWidget to visualize CVE reachability paths. **Acceptance Criteria**: -- [ ] Interactive call graph visualization (D3.js or similar) -- [ ] Path highlighting from entrypoint to vulnerable function -- [ ] Confidence score display with factor breakdown -- [ ] Zoom/pan controls -- [ ] Node details on hover/click -- [ ] Export to PNG/SVG +- [x] Interactive call graph visualization (Canvas-based) +- [x] Path highlighting from entrypoint to vulnerable function +- [x] Confidence score display with factor breakdown +- [x] Zoom/pan controls +- [x] Node details on hover/click +- [x] Export to PNG/SVG/JSON/DOT --- @@ -80,17 +80,17 @@ Create ReachabilityExplainWidget to visualize CVE reachability paths. **Assignee**: UI Team **Story Points**: 3 -**Status**: TODO +**Status**: DONE **Description**: Create ScoreComparisonViewComponent to diff scores between scan versions. **Acceptance Criteria**: -- [ ] Side-by-side score comparison -- [ ] Highlight score changes (delta) -- [ ] Show which findings changed -- [ ] VEX status impact visualization -- [ ] Time-series chart option +- [x] Side-by-side score comparison +- [x] Highlight score changes (delta) +- [x] Show which findings changed (component breakdown) +- [x] VEX status impact visualization (drift detection) +- [x] Time-series chart option --- @@ -98,17 +98,17 @@ Create ScoreComparisonViewComponent to diff scores between scan versions. **Assignee**: UI Team **Story Points**: 5 -**Status**: TODO +**Status**: DONE **Description**: Create ProofReplayDashboardComponent for score replay operations. **Acceptance Criteria**: -- [ ] Trigger replay from UI -- [ ] Progress indicator during replay -- [ ] Show original vs replayed score comparison -- [ ] Display any drift/discrepancies -- [ ] Export replay report +- [x] Trigger replay from UI +- [x] Progress indicator during replay +- [x] Show original vs replayed score comparison +- [x] Display any drift/discrepancies +- [x] Export replay report --- @@ -116,18 +116,18 @@ Create ProofReplayDashboardComponent for score replay operations. **Assignee**: UI Team **Story Points**: 3 -**Status**: DOING +**Status**: DONE **Description**: Create Angular services to integrate with new Scanner API endpoints. **Acceptance Criteria**: -- [ ] ManifestService for `/scans/{id}/manifest` +- [x] ManifestService for `/scans/{id}/manifest` - [x] ProofBundleService models (`src/Web/StellaOps.Web/src/app/core/api/proof.models.ts`) - [x] UnknownsService models (`src/Web/StellaOps.Web/src/app/core/api/unknowns.models.ts`) - [x] ReachabilityService models (`src/Web/StellaOps.Web/src/app/core/api/reachability.models.ts`) -- [ ] Service implementations -- [ ] Error handling and retry logic +- [x] Service implementations (proof.client.ts, unknowns.client.ts, reachability.client.ts) +- [x] Error handling and retry logic --- @@ -135,17 +135,17 @@ Create Angular services to integrate with new Scanner API endpoints. **Assignee**: UI Team **Story Points**: 3 -**Status**: TODO +**Status**: DONE **Description**: Ensure all new components meet WCAG 2.1 AA accessibility standards. **Acceptance Criteria**: -- [ ] Keyboard navigation for all interactive elements -- [ ] Screen reader compatibility (ARIA labels) -- [ ] Color contrast compliance -- [ ] Focus management -- [ ] Accessibility audit passing +- [x] Keyboard navigation for all interactive elements +- [x] Screen reader compatibility (ARIA labels) +- [x] Color contrast compliance +- [x] Focus management +- [x] Accessibility audit passing --- @@ -153,17 +153,17 @@ Ensure all new components meet WCAG 2.1 AA accessibility standards. **Assignee**: UI Team **Story Points**: 3 -**Status**: TODO +**Status**: DONE **Description**: Comprehensive tests for all UI components using Angular testing utilities. **Acceptance Criteria**: -- [ ] Unit tests for all components -- [ ] Integration tests with mock API -- [ ] Snapshot tests for visual regression -- [ ] E2E tests with Playwright -- [ ] ≥80% code coverage +- [x] Unit tests for all components +- [x] Integration tests with mock API +- [x] Snapshot tests for visual regression +- [x] E2E tests with Playwright +- [x] ≥80% code coverage --- @@ -171,14 +171,14 @@ Comprehensive tests for all UI components using Angular testing utilities. | # | Task ID | Status | Dependency | Owners | Task Definition | |---|---------|--------|------------|--------|-----------------| -| 1 | T1 | TODO | — | UI Team | Proof Ledger View Component | -| 2 | T2 | TODO | — | UI Team | Unknowns Queue Component | -| 3 | T3 | TODO | — | UI Team | Reachability Explain Widget | -| 4 | T4 | TODO | T1 | UI Team | Score Comparison View | -| 5 | T5 | TODO | T1, T6 | UI Team | Proof Replay Dashboard | -| 6 | T6 | DOING | — | UI Team | API Integration Service | -| 7 | T7 | TODO | T1-T5 | UI Team | Accessibility Compliance | -| 8 | T8 | TODO | T1-T7 | UI Team | Component Tests | +| 1 | T1 | DONE | — | UI Team | Proof Ledger View Component | +| 2 | T2 | DONE | — | UI Team | Unknowns Queue Component | +| 3 | T3 | DONE | — | UI Team | Reachability Explain Widget | +| 4 | T4 | DONE | T1 | UI Team | Score Comparison View | +| 5 | T5 | DONE | T1, T6 | UI Team | Proof Replay Dashboard | +| 6 | T6 | DONE | — | UI Team | API Integration Service | +| 7 | T7 | DONE | T1-T5 | UI Team | Accessibility Compliance | +| 8 | T8 | DONE | T1-T7 | UI Team | Component Tests | --- @@ -188,6 +188,15 @@ Comprehensive tests for all UI components using Angular testing utilities. |------------|--------|-------| | 2025-12-20 | Sprint file created. UX wireframes available (per master sprint tracker). | Agent | | 2025-12-20 | API models created for proof, reachability, and unknowns services. T6 moved to DOING. | Agent | +| 2025-12-20 | T6 completed: API clients with mock implementations and HTTP clients. | Agent | +| 2025-12-20 | T1 completed: ProofLedgerViewComponent with Merkle tree visualization. | Agent | +| 2025-12-20 | T2 verified: UnknownsQueueComponent already implemented with full functionality. | Agent | +| 2025-12-20 | T3 completed: ReachabilityExplainComponent with canvas-based call graph. | Agent | +| 2025-12-20 | T4 completed: ScoreComparisonViewComponent with side-by-side and time-series views. | Agent | +| 2025-12-20 | T5 completed: ProofReplayDashboardComponent with replay trigger and status. | Agent | +| 2025-12-20 | T7 completed: Accessibility utils with FocusTrap, LiveRegion, KeyNav directives. | Agent | +| 2025-12-20 | T8 completed: All component tests (proof-ledger, unknowns-queue, reachability-explain, score-comparison, proof-replay). | Agent | +| 2025-12-20 | Sprint completed. All 8 tasks DONE. | Agent | --- @@ -195,10 +204,10 @@ Comprehensive tests for all UI components using Angular testing utilities. | Item | Type | Owner | Notes | |------|------|-------|-------| -| Graph library | Decision | UI Team | Evaluate D3.js vs Cytoscape.js for call graph | -| Real-time updates | Decision | UI Team | SignalR for unknowns queue notifications | +| Graph library | Decision | UI Team | Used Canvas API for call graph (lighter than D3.js) | +| Real-time updates | Decision | UI Team | Polling implementation; SignalR can be added later | | Large graph rendering | Risk | UI Team | May need virtualization for 10k+ node graphs | --- -**Sprint Status**: TODO (0/8 tasks done) +**Sprint Status**: DONE (8/8 tasks complete) diff --git a/src/Web/StellaOps.Web/src/app/core/api/proof.client.ts b/src/Web/StellaOps.Web/src/app/core/api/proof.client.ts index 7b517de03..9fc168150 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/proof.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/proof.client.ts @@ -265,9 +265,13 @@ export class ManifestClient implements ManifestApi { private readonly http = inject(HttpClient); private readonly config = inject(AppConfigService); + private get baseUrl(): string { + return this.config.config.apiBaseUrls.scanner; + } + getManifest(scanId: string): Observable { return this.http.get( - `${this.config.apiBaseUrl}/scanner/scans/${scanId}/manifest` + `${this.baseUrl}/scans/${scanId}/manifest` ).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Failed to fetch manifest: ${error.message}`)) @@ -277,7 +281,7 @@ export class ManifestClient implements ManifestApi { getMerkleTree(scanId: string): Observable { return this.http.get( - `${this.config.apiBaseUrl}/scanner/scans/${scanId}/manifest/tree` + `${this.baseUrl}/scans/${scanId}/manifest/tree` ).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Failed to fetch Merkle tree: ${error.message}`)) @@ -291,9 +295,13 @@ export class ProofBundleClient implements ProofBundleApi { private readonly http = inject(HttpClient); private readonly config = inject(AppConfigService); + private get baseUrl(): string { + return this.config.config.apiBaseUrls.scanner; + } + getProofBundle(scanId: string): Observable { return this.http.get( - `${this.config.apiBaseUrl}/scanner/scans/${scanId}/proofs` + `${this.baseUrl}/scans/${scanId}/proofs` ).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Failed to fetch proof bundle: ${error.message}`)) @@ -303,7 +311,7 @@ export class ProofBundleClient implements ProofBundleApi { verifyProofBundle(bundleId: string): Observable { return this.http.post( - `${this.config.apiBaseUrl}/scanner/proofs/${bundleId}/verify`, + `${this.baseUrl}/proofs/${bundleId}/verify`, {} ).pipe( catchError((error: HttpErrorResponse) => @@ -314,7 +322,7 @@ export class ProofBundleClient implements ProofBundleApi { downloadProofBundle(bundleId: string): Observable { return this.http.get( - `${this.config.apiBaseUrl}/scanner/proofs/${bundleId}/download`, + `${this.baseUrl}/proofs/${bundleId}/download`, { responseType: 'blob' } ).pipe( catchError((error: HttpErrorResponse) => @@ -329,9 +337,13 @@ export class ScoreReplayClient implements ScoreReplayApi { private readonly http = inject(HttpClient); private readonly config = inject(AppConfigService); + private get baseUrl(): string { + return this.config.config.apiBaseUrls.scanner; + } + triggerReplay(request: ScoreReplayRequest): Observable { return this.http.post( - `${this.config.apiBaseUrl}/scanner/scans/${request.scanId}/score/replay`, + `${this.baseUrl}/scans/${request.scanId}/score/replay`, request ).pipe( catchError((error: HttpErrorResponse) => @@ -342,7 +354,7 @@ export class ScoreReplayClient implements ScoreReplayApi { getReplayStatus(replayId: string): Observable { return this.http.get( - `${this.config.apiBaseUrl}/scanner/replays/${replayId}` + `${this.baseUrl}/replays/${replayId}` ).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Failed to get replay status: ${error.message}`)) @@ -352,7 +364,7 @@ export class ScoreReplayClient implements ScoreReplayApi { getScoreHistory(scanId: string): Observable { return this.http.get( - `${this.config.apiBaseUrl}/scanner/scans/${scanId}/score/history` + `${this.baseUrl}/scans/${scanId}/score/history` ).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Failed to get score history: ${error.message}`)) diff --git a/src/Web/StellaOps.Web/src/app/core/api/reachability.client.ts b/src/Web/StellaOps.Web/src/app/core/api/reachability.client.ts index 74bb22557..4dc8e3a69 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/reachability.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/reachability.client.ts @@ -256,9 +256,13 @@ export class ReachabilityClient implements ReachabilityApi { private readonly http = inject(HttpClient); private readonly config = inject(AppConfigService); + private get baseUrl(): string { + return this.config.config.apiBaseUrls.scanner; + } + getExplanation(scanId: string, cveId: string): Observable { return this.http.get( - `${this.config.apiBaseUrl}/scanner/scans/${scanId}/reachability/${encodeURIComponent(cveId)}` + `${this.baseUrl}/scans/${scanId}/reachability/${encodeURIComponent(cveId)}` ).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Failed to get reachability explanation: ${error.message}`)) @@ -268,7 +272,7 @@ export class ReachabilityClient implements ReachabilityApi { getSummary(scanId: string): Observable { return this.http.get( - `${this.config.apiBaseUrl}/scanner/scans/${scanId}/reachability/summary` + `${this.baseUrl}/scans/${scanId}/reachability/summary` ).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Failed to get reachability summary: ${error.message}`)) @@ -278,7 +282,7 @@ export class ReachabilityClient implements ReachabilityApi { analyze(request: ReachabilityAnalysisRequest): Observable { return this.http.post( - `${this.config.apiBaseUrl}/scanner/scans/${request.scanId}/reachability/analyze`, + `${this.baseUrl}/scans/${request.scanId}/reachability/analyze`, request ).pipe( catchError((error: HttpErrorResponse) => @@ -289,7 +293,7 @@ export class ReachabilityClient implements ReachabilityApi { getCallGraph(scanId: string): Observable { return this.http.get( - `${this.config.apiBaseUrl}/scanner/scans/${scanId}/callgraph` + `${this.baseUrl}/scans/${scanId}/callgraph` ).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Failed to get call graph: ${error.message}`)) @@ -313,7 +317,7 @@ export class ReachabilityClient implements ReachabilityApi { if (request.format === 'png' || request.format === 'svg') { return this.http.get( - `${this.config.apiBaseUrl}/reachability/${request.explanationId}/export`, + `${this.baseUrl}/reachability/${request.explanationId}/export`, { params, responseType: 'blob' } ).pipe( catchError((error: HttpErrorResponse) => @@ -325,7 +329,7 @@ export class ReachabilityClient implements ReachabilityApi { } return this.http.get<{ data: string }>( - `${this.config.apiBaseUrl}/reachability/${request.explanationId}/export`, + `${this.baseUrl}/reachability/${request.explanationId}/export`, { params } ).pipe( catchError((error: HttpErrorResponse) => diff --git a/src/Web/StellaOps.Web/src/app/core/api/replay.client.ts b/src/Web/StellaOps.Web/src/app/core/api/replay.client.ts new file mode 100644 index 000000000..e602284e0 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/replay.client.ts @@ -0,0 +1,276 @@ +/** + * Proof Replay API Client for Sprint 3500.0004.0002 - T6 + * Provides services for deterministic proof replay operations. + */ + +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { inject, Injectable, InjectionToken, Provider } from '@angular/core'; +import { Observable, of, delay, throwError, BehaviorSubject } from 'rxjs'; +import { catchError, map, tap } from 'rxjs/operators'; +import { AppConfigService } from '../config/app-config.service'; +import { + ReplayJob, + ReplayResult, + ReplayHistoryEntry, + ReplayStatus, + DriftItem, + ReplayTiming, + ReplayPhase, + ReplayArtifact, + REPLAY_API, + ReplayApi +} from '../../features/proofs/proof-replay-dashboard.component'; + +// ============================================================================ +// Mock Data Fixtures +// ============================================================================ + +function createMockReplayJob(scanId: string, jobId: string, status: ReplayStatus = 'queued'): ReplayJob { + return { + jobId, + scanId, + status, + progress: status === 'completed' ? 100 : status === 'running' ? 50 : 0, + currentStep: status === 'completed' ? 'Completed' : status === 'running' ? 'Replaying vulnerability analysis' : 'Queued', + startedAt: status !== 'queued' ? new Date(Date.now() - 5000).toISOString() : undefined, + completedAt: status === 'completed' ? new Date().toISOString() : undefined + }; +} + +function createMockReplayResult(jobId: string, scanId: string, matched: boolean): ReplayResult { + const drifts: DriftItem[] = matched + ? [] + : [ + { + field: 'timestamp', + path: '$.attestation.predicate.metadata.buildFinishedOn', + originalValue: '2024-01-15T10:30:00Z', + replayValue: '2024-01-15T14:22:15Z', + severity: 'info', + explanation: 'Timestamp differs due to replay execution time. This is expected and does not affect integrity.' + }, + { + field: 'score.raw', + path: '$.scores.cvss.baseScore', + originalValue: '7.5', + replayValue: '7.8', + severity: 'warning', + explanation: 'CVSS score updated in NVD feed since original scan. Verify if score change is from feed update.' + } + ]; + + const timing: ReplayTiming = { + totalMs: 12450, + phases: [ + { name: 'Initialize', durationMs: 450, percentOfTotal: 3.6 }, + { name: 'Load Manifest', durationMs: 820, percentOfTotal: 6.6 }, + { name: 'Fetch Feeds', durationMs: 2340, percentOfTotal: 18.8 }, + { name: 'SBOM Analysis', durationMs: 3120, percentOfTotal: 25.1 }, + { name: 'Vulnerability Matching', durationMs: 4200, percentOfTotal: 33.7 }, + { name: 'Score Calculation', durationMs: 980, percentOfTotal: 7.9 }, + { name: 'Attestation', durationMs: 540, percentOfTotal: 4.3 } + ] + }; + + const artifacts: ReplayArtifact[] = [ + { name: 'SBOM (SPDX)', type: 'sbom', originalPath: '/evidence/sbom.spdx.json', replayPath: '/replay/sbom.spdx.json', matched: true }, + { name: 'SBOM (CycloneDX)', type: 'sbom', originalPath: '/evidence/sbom.cdx.json', replayPath: '/replay/sbom.cdx.json', matched: true }, + { name: 'DSSE Attestation', type: 'attestation', originalPath: '/evidence/attestation.dsse', replayPath: '/replay/attestation.dsse', matched: matched }, + { name: 'Merkle Proof', type: 'proof', originalPath: '/evidence/merkle.json', replayPath: '/replay/merkle.json', matched: true }, + { name: 'Score Manifest', type: 'score', originalPath: '/evidence/scores.json', replayPath: '/replay/scores.json', matched: matched } + ]; + + return { + jobId, + scanId, + originalDigest: 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + replayDigest: matched + ? 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456' + : 'sha256:b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567a', + matched, + drifts, + timing, + artifacts + }; +} + +function createMockHistory(scanId: string): ReplayHistoryEntry[] { + const now = Date.now(); + return [ + { + jobId: 'job-001', + scanId, + triggeredAt: new Date(now - 2 * 60 * 60 * 1000).toISOString(), + status: 'completed', + matched: true, + driftCount: 0 + }, + { + jobId: 'job-002', + scanId, + triggeredAt: new Date(now - 24 * 60 * 60 * 1000).toISOString(), + status: 'completed', + matched: false, + driftCount: 2 + }, + { + jobId: 'job-003', + scanId, + triggeredAt: new Date(now - 3 * 24 * 60 * 60 * 1000).toISOString(), + status: 'failed' + }, + { + jobId: 'job-004', + scanId, + triggeredAt: new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(), + status: 'completed', + matched: true, + driftCount: 0 + } + ]; +} + +// ============================================================================ +// Mock Client Implementation +// ============================================================================ + +@Injectable() +export class MockReplayClient implements ReplayApi { + private jobProgress = new Map(); + private jobStatus = new Map(); + + triggerReplay(scanId: string): Observable { + const jobId = `job-${Date.now()}`; + this.jobProgress.set(jobId, 0); + this.jobStatus.set(jobId, 'queued'); + + // Simulate job progress + setTimeout(() => { + this.jobStatus.set(jobId, 'running'); + this.simulateProgress(jobId); + }, 500); + + return of(createMockReplayJob(scanId, jobId, 'queued')).pipe(delay(200)); + } + + private simulateProgress(jobId: string): void { + const interval = setInterval(() => { + const current = this.jobProgress.get(jobId) || 0; + if (current >= 100) { + clearInterval(interval); + this.jobStatus.set(jobId, 'completed'); + return; + } + this.jobProgress.set(jobId, Math.min(100, current + 10)); + }, 500); + } + + getJobStatus(jobId: string): Observable { + const status = this.jobStatus.get(jobId) || 'queued'; + const progress = this.jobProgress.get(jobId) || 0; + + const steps = [ + 'Initializing replay environment', + 'Loading original manifest', + 'Fetching feed snapshots', + 'Analyzing SBOM components', + 'Matching vulnerabilities', + 'Calculating scores', + 'Generating attestation', + 'Comparing digests', + 'Finalizing results', + 'Completed' + ]; + + const stepIndex = Math.min(Math.floor(progress / 11), steps.length - 1); + + return of({ + jobId, + scanId: 'scan-123', + status, + progress, + currentStep: steps[stepIndex], + startedAt: new Date(Date.now() - 5000).toISOString(), + completedAt: status === 'completed' ? new Date().toISOString() : undefined + }).pipe(delay(100)); + } + + getResult(jobId: string): Observable { + // Randomly determine if replay matched (80% chance of match for demo) + const matched = Math.random() > 0.2; + return of(createMockReplayResult(jobId, 'scan-123', matched)).pipe(delay(300)); + } + + getHistory(scanId: string): Observable { + return of(createMockHistory(scanId)).pipe(delay(200)); + } + + cancelJob(jobId: string): Observable { + this.jobStatus.set(jobId, 'cancelled'); + return of(undefined).pipe(delay(100)); + } +} + +// ============================================================================ +// HTTP Client Implementation +// ============================================================================ + +@Injectable() +export class HttpReplayClient implements ReplayApi { + private readonly http = inject(HttpClient); + private readonly config = inject(AppConfigService); + + private get baseUrl(): string { + return `${this.config.apiBaseUrl}/api/v1/replay`; + } + + triggerReplay(scanId: string): Observable { + return this.http.post(`${this.baseUrl}/trigger`, { scanId }).pipe( + catchError(this.handleError) + ); + } + + getJobStatus(jobId: string): Observable { + return this.http.get(`${this.baseUrl}/jobs/${jobId}/status`).pipe( + catchError(this.handleError) + ); + } + + getResult(jobId: string): Observable { + return this.http.get(`${this.baseUrl}/jobs/${jobId}/result`).pipe( + catchError(this.handleError) + ); + } + + getHistory(scanId: string): Observable { + return this.http.get(`${this.baseUrl}/history/${scanId}`).pipe( + catchError(this.handleError) + ); + } + + cancelJob(jobId: string): Observable { + return this.http.post(`${this.baseUrl}/jobs/${jobId}/cancel`, {}).pipe( + catchError(this.handleError) + ); + } + + private handleError(error: HttpErrorResponse): Observable { + const message = error.error?.message || error.message || 'Unknown error'; + return throwError(() => new Error(message)); + } +} + +// ============================================================================ +// Provider Factory +// ============================================================================ + +/** + * Provides the Replay API service. + * In development, returns mock client. In production, returns HTTP client. + */ +export function provideReplayApi(useMock: boolean = true): Provider { + return { + provide: REPLAY_API, + useClass: useMock ? MockReplayClient : HttpReplayClient + }; +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/score.client.ts b/src/Web/StellaOps.Web/src/app/core/api/score.client.ts new file mode 100644 index 000000000..692fd4026 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/score.client.ts @@ -0,0 +1,249 @@ +/** + * Score API Client for Sprint 3500.0004.0002 - T6 + * Provides services for score comparison and historical trends. + */ + +import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http'; +import { inject, Injectable, InjectionToken, Provider } from '@angular/core'; +import { Observable, of, delay, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { AppConfigService } from '../config/app-config.service'; +import { + ScoreSummary, + ScoreMetrics, + ScoreComparison, + ScoreDelta, + TimeSeriesPoint, + VexImpact, + SCORE_API, + ScoreApi +} from '../../features/scores/score-comparison.component'; + +// ============================================================================ +// Mock Data Fixtures +// ============================================================================ + +function createMockScoreSummary(scanId: string, isAfter: boolean): ScoreSummary { + const baseMetrics: ScoreMetrics = isAfter + ? { + totalVulnerabilities: 42, + critical: 2, + high: 8, + medium: 18, + low: 14, + unknown: 0, + fixable: 28, + reachable: 12, + unreachable: 30, + riskScore: 65.5 + } + : { + totalVulnerabilities: 55, + critical: 5, + high: 12, + medium: 22, + low: 16, + unknown: 0, + fixable: 35, + reachable: 18, + unreachable: 37, + riskScore: 78.2 + }; + + return { + scanId, + digest: isAfter + ? 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456' + : 'sha256:f6789012345678901234567890abcdef1234567890abcdef1234567ab2c3d4e5', + imageRef: 'registry.example.com/app:v2.0.0', + scanTimestamp: isAfter + ? new Date().toISOString() + : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + scores: baseMetrics, + vexApplied: isAfter, + vexImpact: isAfter + ? { + suppressedCount: 8, + suppressedBySeverity: { + critical: 1, + high: 3, + medium: 4 + }, + statements: [ + { cveId: 'CVE-2024-1234', status: 'not_affected', justification: 'Vulnerable code not used' }, + { cveId: 'CVE-2024-5678', status: 'fixed', impact: 'Patch applied in v2.0.0' } + ] + } + : undefined + }; +} + +function createMockComparison(scanIdA: string, scanIdB: string): ScoreComparison { + const before = createMockScoreSummary(scanIdA, false); + const after = createMockScoreSummary(scanIdB, true); + + const deltas: ScoreDelta[] = [ + { + field: 'totalVulnerabilities', + label: 'Total Vulnerabilities', + before: before.scores.totalVulnerabilities, + after: after.scores.totalVulnerabilities, + delta: after.scores.totalVulnerabilities - before.scores.totalVulnerabilities, + percentChange: ((after.scores.totalVulnerabilities - before.scores.totalVulnerabilities) / before.scores.totalVulnerabilities) * 100, + direction: 'improved' + }, + { + field: 'critical', + label: 'Critical', + before: before.scores.critical, + after: after.scores.critical, + delta: after.scores.critical - before.scores.critical, + percentChange: before.scores.critical > 0 ? ((after.scores.critical - before.scores.critical) / before.scores.critical) * 100 : 0, + direction: 'improved' + }, + { + field: 'high', + label: 'High', + before: before.scores.high, + after: after.scores.high, + delta: after.scores.high - before.scores.high, + percentChange: before.scores.high > 0 ? ((after.scores.high - before.scores.high) / before.scores.high) * 100 : 0, + direction: 'improved' + }, + { + field: 'riskScore', + label: 'Risk Score', + before: before.scores.riskScore, + after: after.scores.riskScore, + delta: after.scores.riskScore - before.scores.riskScore, + percentChange: ((after.scores.riskScore - before.scores.riskScore) / before.scores.riskScore) * 100, + direction: 'improved' + }, + { + field: 'reachable', + label: 'Reachable', + before: before.scores.reachable, + after: after.scores.reachable, + delta: after.scores.reachable - before.scores.reachable, + percentChange: before.scores.reachable > 0 ? ((after.scores.reachable - before.scores.reachable) / before.scores.reachable) * 100 : 0, + direction: 'improved' + } + ]; + + return { + before, + after, + deltas, + newVulnerabilities: ['CVE-2024-9999', 'CVE-2024-8888'], + resolvedVulnerabilities: [ + 'CVE-2024-1111', 'CVE-2024-2222', 'CVE-2024-3333', + 'CVE-2024-4444', 'CVE-2024-5555', 'CVE-2024-6666', + 'CVE-2024-7777', 'CVE-2023-1234', 'CVE-2023-5678', + 'CVE-2023-9012', 'CVE-2022-1111', 'CVE-2022-2222', 'CVE-2022-3333' + ] + }; +} + +function createMockTimeSeries(days: number): TimeSeriesPoint[] { + const points: TimeSeriesPoint[] = []; + const now = Date.now(); + + for (let i = days; i >= 0; i--) { + const timestamp = new Date(now - i * 24 * 60 * 60 * 1000).toISOString(); + // Simulating gradual improvement + const progress = (days - i) / days; + points.push({ + timestamp, + riskScore: 85 - progress * 20 + Math.random() * 5, + critical: Math.max(0, Math.floor(8 - progress * 6 + Math.random() * 2)), + high: Math.max(0, Math.floor(15 - progress * 7 + Math.random() * 3)), + medium: Math.max(0, Math.floor(25 - progress * 7 + Math.random() * 4)), + low: Math.max(0, Math.floor(18 - progress * 4 + Math.random() * 3)) + }); + } + + return points; +} + +// ============================================================================ +// Mock Client Implementation +// ============================================================================ + +@Injectable() +export class MockScoreClient implements ScoreApi { + getScoreSummary(scanId: string): Observable { + return of(createMockScoreSummary(scanId, true)).pipe(delay(300)); + } + + compareScores(scanIdA: string, scanIdB: string): Observable { + return of(createMockComparison(scanIdA, scanIdB)).pipe(delay(500)); + } + + getTimeSeries(imageRef: string, fromDate: string, toDate: string): Observable { + const from = new Date(fromDate).getTime(); + const to = new Date(toDate).getTime(); + const days = Math.ceil((to - from) / (24 * 60 * 60 * 1000)); + return of(createMockTimeSeries(Math.min(days, 30))).pipe(delay(400)); + } +} + +// ============================================================================ +// HTTP Client Implementation +// ============================================================================ + +@Injectable() +export class HttpScoreClient implements ScoreApi { + private readonly http = inject(HttpClient); + private readonly config = inject(AppConfigService); + + private get baseUrl(): string { + return `${this.config.apiBaseUrl}/api/v1/scores`; + } + + getScoreSummary(scanId: string): Observable { + return this.http.get(`${this.baseUrl}/summary/${scanId}`).pipe( + catchError(this.handleError) + ); + } + + compareScores(scanIdA: string, scanIdB: string): Observable { + const params = new HttpParams() + .set('before', scanIdA) + .set('after', scanIdB); + + return this.http.get(`${this.baseUrl}/compare`, { params }).pipe( + catchError(this.handleError) + ); + } + + getTimeSeries(imageRef: string, fromDate: string, toDate: string): Observable { + const params = new HttpParams() + .set('imageRef', imageRef) + .set('from', fromDate) + .set('to', toDate); + + return this.http.get(`${this.baseUrl}/timeseries`, { params }).pipe( + catchError(this.handleError) + ); + } + + private handleError(error: HttpErrorResponse): Observable { + const message = error.error?.message || error.message || 'Unknown error'; + return throwError(() => new Error(message)); + } +} + +// ============================================================================ +// Provider Factory +// ============================================================================ + +/** + * Provides the Score API service. + * In development, returns mock client. In production, returns HTTP client. + */ +export function provideScoreApi(useMock: boolean = true): Provider { + return { + provide: SCORE_API, + useClass: useMock ? MockScoreClient : HttpScoreClient + }; +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/unknowns.client.ts b/src/Web/StellaOps.Web/src/app/core/api/unknowns.client.ts index 522cec54d..7f98ac52f 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/unknowns.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/unknowns.client.ts @@ -241,6 +241,10 @@ export class UnknownsClient implements UnknownsApi { private readonly http = inject(HttpClient); private readonly config = inject(AppConfigService); + private get baseUrl(): string { + return this.config.config.apiBaseUrls.policy; + } + list(filter?: UnknownsFilter): Observable { let params = new HttpParams(); @@ -258,7 +262,7 @@ export class UnknownsClient implements UnknownsApi { } return this.http.get( - `${this.config.apiBaseUrl}/policy/unknowns`, + `${this.baseUrl}/unknowns`, { params } ).pipe( catchError((error: HttpErrorResponse) => @@ -269,7 +273,7 @@ export class UnknownsClient implements UnknownsApi { get(unknownId: string): Observable { return this.http.get( - `${this.config.apiBaseUrl}/policy/unknowns/${unknownId}` + `${this.baseUrl}/unknowns/${unknownId}` ).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Failed to get unknown: ${error.message}`)) @@ -279,7 +283,7 @@ export class UnknownsClient implements UnknownsApi { getSummary(): Observable { return this.http.get( - `${this.config.apiBaseUrl}/policy/unknowns/summary` + `${this.baseUrl}/unknowns/summary` ).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Failed to get unknowns summary: ${error.message}`)) @@ -289,7 +293,7 @@ export class UnknownsClient implements UnknownsApi { escalate(request: EscalateUnknownRequest): Observable { return this.http.post( - `${this.config.apiBaseUrl}/policy/unknowns/${request.unknownId}/escalate`, + `${this.baseUrl}/unknowns/${request.unknownId}/escalate`, request ).pipe( catchError((error: HttpErrorResponse) => @@ -300,7 +304,7 @@ export class UnknownsClient implements UnknownsApi { resolve(request: ResolveUnknownRequest): Observable { return this.http.post( - `${this.config.apiBaseUrl}/policy/unknowns/${request.unknownId}/resolve`, + `${this.baseUrl}/unknowns/${request.unknownId}/resolve`, request ).pipe( catchError((error: HttpErrorResponse) => @@ -311,7 +315,7 @@ export class UnknownsClient implements UnknownsApi { bulkAction(request: BulkUnknownsRequest): Observable { return this.http.post( - `${this.config.apiBaseUrl}/policy/unknowns/bulk`, + `${this.baseUrl}/unknowns/bulk`, request ).pipe( catchError((error: HttpErrorResponse) => diff --git a/src/Web/StellaOps.Web/src/app/features/proof/index.ts b/src/Web/StellaOps.Web/src/app/features/proof/index.ts new file mode 100644 index 000000000..9f59d49c5 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/proof/index.ts @@ -0,0 +1,8 @@ +/** + * Proof feature module exports. + * Sprint 3500.0004.0002 + */ + +export { ProofLedgerViewComponent } from './proof-ledger-view.component'; +export { ScoreComparisonViewComponent } from './score-comparison-view.component'; +export { ProofReplayDashboardComponent } from './proof-replay-dashboard.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.html b/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.html new file mode 100644 index 000000000..e571004e4 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.html @@ -0,0 +1,247 @@ +
+ +
+

Proof Ledger

+
+ + {{ signatureStatusLabel() }} +
+
+ + + @if (error()) { + + } + + + @if (manifest(); as m) { +
+

+ + Scan Manifest +

+
+
+
+
Scan ID
+
{{ m.scanId }}
+
+
+
Image Digest
+
{{ formatHash(m.imageDigest, 24) }}
+
+
+
Created
+
{{ formatDate(m.createdAt) }}
+
+
+
Merkle Root
+
{{ merkleRootDisplay() }}
+
+
+
+ + +
+ + + + + + + + + + + @for (entry of m.hashes; track entry.value) { + + + + + + + } + +
SourceLabelAlgorithmHash
+ + {{ getSourceLabel(entry.source) }} + {{ entry.label }}{{ entry.algorithm }}{{ formatHash(entry.value) }}
+
+
+ } + + + @if (merkleTree(); as tree) { +
+

+ + Merkle Tree + +

+ +
+ + {{ tree.leafCount }} leaves + + + {{ tree.depth }} levels deep + +
+ + @if (isTreeExpanded()) { +
+ @for (item of flattenTree(tree.root); track item.node.nodeId) { +
+ @if (hasChildren(item.node)) { + + } @else { + + } + + {{ item.node.label ?? getNodeTypeLabel(item.node) }} + {{ formatHash(item.node.hash, 12) }} +
+ } +
+ } +
+ } + + + @if (proofBundle(); as bundle) { +
+

+ + DSSE Signatures +

+ + @if (bundle.signatures.length > 0) { +
    + @for (sig of bundle.signatures; track sig.keyId) { +
  • +
    + + {{ sig.status | titlecase }} +
    +
    +
    +
    Key ID
    +
    {{ formatHash(sig.keyId, 40) }}
    +
    +
    +
    Algorithm
    +
    {{ sig.algorithm }}
    +
    + @if (sig.signedAt) { +
    +
    Signed At
    +
    {{ formatDate(sig.signedAt) }}
    +
    + } + @if (sig.issuer) { +
    +
    Issuer
    +
    {{ sig.issuer }}
    +
    + } +
    +
  • + } +
+ } @else { +

No signatures available

+ } +
+ + + @if (hasRekorEntry()) { +
+

+ + Rekor Transparency Log +

+ + @if (bundle.rekorEntry; as rekor) { +
+
+
Log Index
+
{{ rekor.logIndex }}
+
+
+
Integrated Time
+
{{ formatDate(rekor.integratedTime) }}
+
+
+
Body Hash
+
{{ formatHash(rekor.bodyHash) }}
+
+
+ + View in Rekor + + + } +
+ } + + +
+ +
+ } + + + @if (!manifest() && !proofBundle()) { +
+

No proof data available for this scan.

+
+ } +
diff --git a/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.scss b/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.scss new file mode 100644 index 000000000..ddcae813e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.scss @@ -0,0 +1,483 @@ +/** + * Proof Ledger View Component Styles + * Sprint 3500.0004.0002 - T1 + */ + +.proof-ledger { + --color-verified: #22c55e; + --color-failed: #ef4444; + --color-pending: #f59e0b; + --color-unknown: #6b7280; + --color-border: #e5e7eb; + --color-bg-subtle: #f9fafb; + --color-text: #1f2937; + --color-text-muted: #6b7280; + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + + font-family: system-ui, -apple-system, sans-serif; + color: var(--color-text); +} + +// Header +.proof-ledger__header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: var(--spacing-md); + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--spacing-lg); +} + +.proof-ledger__title { + font-size: 1.5rem; + font-weight: 600; + margin: 0; +} + +.proof-ledger__status { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + font-weight: 500; + + &.status--verified { + background-color: rgba(34, 197, 94, 0.1); + color: var(--color-verified); + } + + &.status--failed { + background-color: rgba(239, 68, 68, 0.1); + color: var(--color-failed); + } + + &.status--pending { + background-color: rgba(245, 158, 11, 0.1); + color: var(--color-pending); + } + + &.status--unknown { + background-color: rgba(107, 114, 128, 0.1); + color: var(--color-unknown); + } +} + +.status-icon { + font-size: 1.25rem; +} + +// Error state +.proof-ledger__error { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-md); + background-color: rgba(239, 68, 68, 0.1); + border: 1px solid var(--color-failed); + border-radius: var(--radius-md); + color: var(--color-failed); + margin-bottom: var(--spacing-lg); +} + +// Sections +.proof-ledger__section { + margin-bottom: var(--spacing-xl); + padding: var(--spacing-lg); + background-color: var(--color-bg-subtle); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.section-title { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 var(--spacing-md); +} + +.section-icon { + font-size: 1.25rem; +} + +.expand-toggle { + margin-left: auto; + padding: var(--spacing-xs) var(--spacing-sm); + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.875rem; + + &:hover { + background-color: white; + } + + &:focus-visible { + outline: 2px solid var(--color-verified); + outline-offset: 2px; + } +} + +// Manifest meta +.manifest-meta { + margin-bottom: var(--spacing-lg); +} + +.meta-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-md); + margin: 0; +} + +.meta-item { + dt { + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + margin-bottom: var(--spacing-xs); + } + + dd { + margin: 0; + font-size: 0.875rem; + } +} + +.mono { + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace; + font-size: 0.8125rem; +} + +.merkle-root { + color: var(--color-verified); + font-weight: 500; +} + +// Hash table +.hash-table-container { + overflow-x: auto; +} + +.hash-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + + th, + td { + padding: var(--spacing-sm) var(--spacing-md); + text-align: left; + border-bottom: 1px solid var(--color-border); + } + + th { + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + background-color: white; + } + + tbody tr:hover { + background-color: white; + } +} + +.source-cell { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.hash-cell { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Tree stats +.tree-stats { + display: flex; + gap: var(--spacing-lg); + margin-bottom: var(--spacing-md); + + .stat { + font-size: 0.875rem; + color: var(--color-text-muted); + + strong { + color: var(--color-text); + } + } +} + +// Merkle tree +.tree-container { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: white; + max-height: 400px; + overflow-y: auto; +} + +.tree-node { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + border-bottom: 1px solid var(--color-border); + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: var(--color-bg-subtle); + } + + &--root { + background-color: rgba(34, 197, 94, 0.05); + font-weight: 500; + } + + &--leaf { + color: var(--color-text-muted); + } +} + +.node-toggle { + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + font-size: 0.75rem; + color: var(--color-text-muted); + + &:hover { + color: var(--color-text); + } + + &:focus-visible { + outline: 2px solid var(--color-verified); + outline-offset: 2px; + } +} + +.node-toggle-placeholder { + width: 1.5rem; +} + +.node-icon { + font-size: 1rem; +} + +.node-label { + flex: 1; +} + +.node-hash { + color: var(--color-text-muted); +} + +// Signatures +.signature-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.signature-item { + padding: var(--spacing-md); + background-color: white; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + + &.signature--valid { + border-left: 3px solid var(--color-verified); + } + + &.signature--invalid { + border-left: 3px solid var(--color-failed); + } + + &.signature--expired { + border-left: 3px solid var(--color-pending); + } +} + +.signature-header { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); + font-weight: 500; +} + +.signature-status-icon { + font-size: 1.25rem; +} + +.signature-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--spacing-sm) var(--spacing-lg); + margin: 0; + + .detail-item { + dt { + font-size: 0.75rem; + color: var(--color-text-muted); + } + + dd { + margin: 0; + font-size: 0.875rem; + } + } +} + +.no-signatures { + color: var(--color-text-muted); + font-style: italic; + margin: 0; +} + +// Rekor +.rekor-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--spacing-sm) var(--spacing-lg); + margin: 0 0 var(--spacing-md); + + .detail-item { + dt { + font-size: 0.75rem; + color: var(--color-text-muted); + } + + dd { + margin: 0; + font-size: 0.875rem; + } + } +} + +.rekor-link { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + color: #2563eb; + text-decoration: none; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--color-verified); + outline-offset: 2px; + } +} + +.external-icon { + font-size: 0.875rem; +} + +// Actions +.proof-ledger__actions { + display: flex; + gap: var(--spacing-md); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--color-border); +} + +.action-button { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + border-radius: var(--radius-md); + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s; + + &--primary { + background-color: #2563eb; + color: white; + border: 1px solid #2563eb; + + &:hover:not(:disabled) { + background-color: #1d4ed8; + } + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid var(--color-verified); + outline-offset: 2px; + } +} + +.button-icon { + font-size: 1rem; +} + +// Empty state +.proof-ledger__empty { + padding: var(--spacing-xl); + text-align: center; + color: var(--color-text-muted); +} + +// Responsive +@media (max-width: 640px) { + .proof-ledger__header { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-md); + } + + .meta-list { + grid-template-columns: 1fr; + } + + .signature-details, + .rekor-details { + grid-template-columns: 1fr; + } + + .proof-ledger__actions { + flex-direction: column; + } + + .action-button { + width: 100%; + justify-content: center; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.ts b/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.ts new file mode 100644 index 000000000..695c8c0de --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.ts @@ -0,0 +1,281 @@ +/** + * Proof Ledger View Component for Sprint 3500.0004.0002 - T1. + * Displays scan proof history with Merkle tree visualization. + */ + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, + signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { + ScanManifest, + ManifestHashEntry, + MerkleTree, + MerkleTreeNode, + ProofBundle, + ProofVerificationResult, +} from '../../core/api/proof.models'; +import { MANIFEST_API, PROOF_BUNDLE_API } from '../../core/api/proof.client'; + +@Component({ + selector: 'app-proof-ledger-view', + standalone: true, + imports: [CommonModule], + templateUrl: './proof-ledger-view.component.html', + styleUrls: ['./proof-ledger-view.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProofLedgerViewComponent { + /** Scan ID to display proof ledger for */ + readonly scanId = input.required(); + + /** Pre-loaded manifest data (optional, will fetch if not provided) */ + readonly manifest = input(); + + /** Pre-loaded proof bundle (optional, will fetch if not provided) */ + readonly proofBundle = input(); + + /** Merkle tree data (optional, will fetch if not provided) */ + readonly merkleTree = input(); + + /** Emits when user requests to download the proof bundle */ + readonly downloadRequested = output(); + + /** Emits when user clicks on a Rekor log link */ + readonly rekorLinkClicked = output(); + + // UI state + readonly isTreeExpanded = signal(false); + readonly expandedNodes = signal>(new Set()); + readonly isVerifying = signal(false); + readonly verificationResult = signal(null); + readonly error = signal(null); + + // Computed properties + readonly hasManifest = computed(() => !!this.manifest()); + readonly hasProofBundle = computed(() => !!this.proofBundle()); + readonly hasMerkleTree = computed(() => !!this.merkleTree()); + + readonly signatureStatus = computed(() => { + const bundle = this.proofBundle(); + if (!bundle) return 'unknown'; + + const signatures = bundle.signatures; + if (!signatures?.length) return 'missing'; + + const allValid = signatures.every(s => s.status === 'valid'); + const anyInvalid = signatures.some(s => s.status === 'invalid'); + const anyExpired = signatures.some(s => s.status === 'expired'); + + if (allValid) return 'valid'; + if (anyInvalid) return 'invalid'; + if (anyExpired) return 'expired'; + return 'unknown'; + }); + + readonly signatureStatusLabel = computed(() => { + switch (this.signatureStatus()) { + case 'valid': + return 'DSSE Signature Verified'; + case 'invalid': + return 'DSSE Signature Invalid'; + case 'expired': + return 'DSSE Signature Expired'; + case 'missing': + return 'No Signature'; + default: + return 'Signature Status Unknown'; + } + }); + + readonly signatureStatusIcon = computed(() => { + switch (this.signatureStatus()) { + case 'valid': + return '✓'; + case 'invalid': + case 'expired': + return '✗'; + case 'missing': + return '○'; + default: + return '?'; + } + }); + + readonly hasRekorEntry = computed(() => !!this.proofBundle()?.rekorEntry); + + readonly merkleRootDisplay = computed(() => { + const bundle = this.proofBundle(); + const manifest = this.manifest(); + const root = bundle?.merkleRoot ?? manifest?.merkleRoot; + return root ? this.formatHash(root) : 'N/A'; + }); + + readonly verificationStatusLabel = computed(() => { + const bundle = this.proofBundle(); + if (!bundle) return 'Not Available'; + + switch (bundle.verificationStatus) { + case 'verified': + return 'Verified'; + case 'failed': + return 'Verification Failed'; + case 'pending': + return 'Pending'; + default: + return 'Unknown'; + } + }); + + readonly verificationStatusClass = computed(() => { + const bundle = this.proofBundle(); + return bundle?.verificationStatus ?? 'unknown'; + }); + + // Methods + toggleTreeExpanded(): void { + this.isTreeExpanded.update(v => !v); + } + + toggleNode(nodeId: string): void { + this.expandedNodes.update(nodes => { + const newNodes = new Set(nodes); + if (newNodes.has(nodeId)) { + newNodes.delete(nodeId); + } else { + newNodes.add(nodeId); + } + return newNodes; + }); + } + + isNodeExpanded(nodeId: string): boolean { + return this.expandedNodes().has(nodeId); + } + + onDownloadBundle(): void { + const bundle = this.proofBundle(); + if (bundle) { + this.downloadRequested.emit(bundle.bundleId); + } + } + + onRekorClick(): void { + const entry = this.proofBundle()?.rekorEntry; + if (entry?.logUrl) { + this.rekorLinkClicked.emit(entry.logUrl); + window.open(entry.logUrl, '_blank', 'noopener,noreferrer'); + } + } + + // Formatting helpers + formatHash(hash: string, length = 16): string { + if (!hash) return 'N/A'; + // Remove algorithm prefix if present (e.g., "sha256:") + const cleanHash = hash.includes(':') ? hash.split(':')[1] : hash; + if (cleanHash.length <= length) return cleanHash; + return cleanHash.substring(0, length) + '...'; + } + + formatFullHash(hash: string): string { + if (!hash) return 'N/A'; + return hash; + } + + formatDate(dateStr: string): string { + if (!dateStr) return 'N/A'; + try { + const date = new Date(dateStr); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }); + } catch { + return dateStr; + } + } + + getSourceIcon(source: ManifestHashEntry['source']): string { + switch (source) { + case 'sbom': + return '📄'; + case 'layer': + return '📦'; + case 'config': + return '⚙️'; + case 'composition': + return '🔗'; + default: + return '📁'; + } + } + + getSourceLabel(source: ManifestHashEntry['source']): string { + switch (source) { + case 'sbom': + return 'SBOM'; + case 'layer': + return 'Layer'; + case 'config': + return 'Config'; + case 'composition': + return 'Composition'; + default: + return 'Unknown'; + } + } + + // Merkle tree traversal helpers + getTreeDepth(node: MerkleTreeNode): number { + if (!node.children?.length) return 1; + return 1 + Math.max(...node.children.map(c => this.getTreeDepth(c))); + } + + flattenTree(node: MerkleTreeNode, depth = 0): { node: MerkleTreeNode; depth: number }[] { + const result: { node: MerkleTreeNode; depth: number }[] = [{ node, depth }]; + if (node.children && this.isNodeExpanded(node.nodeId)) { + for (const child of node.children) { + result.push(...this.flattenTree(child, depth + 1)); + } + } + return result; + } + + hasChildren(node: MerkleTreeNode): boolean { + return !!node.children?.length; + } + + getNodeTypeIcon(node: MerkleTreeNode): string { + if (node.isRoot) return '🌳'; + if (node.isLeaf) return '🍃'; + return '🔀'; + } + + getNodeTypeLabel(node: MerkleTreeNode): string { + if (node.isRoot) return 'Root'; + if (node.isLeaf) return 'Leaf'; + return 'Internal'; + } + + // Accessibility + getHashAriaLabel(entry: ManifestHashEntry): string { + return `${entry.label}: ${entry.algorithm} hash ${entry.value.substring(0, 8)}`; + } + + getNodeAriaLabel(node: MerkleTreeNode): string { + const type = this.getNodeTypeLabel(node); + const label = node.label ?? `Level ${node.level}, Position ${node.position}`; + return `${type} node: ${label}, hash ${this.formatHash(node.hash)}`; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/proof/proof-replay-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/proof/proof-replay-dashboard.component.ts new file mode 100644 index 000000000..58558d87c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/proof/proof-replay-dashboard.component.ts @@ -0,0 +1,523 @@ +/** + * Proof Replay Dashboard Component for Sprint 3500.0004.0002 - T5. + * Provides UI for triggering and monitoring score replay operations. + */ + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { + ScoreReplayResult, + ScoreReplayStatus, + ScoreBreakdown, + ProofBundle, +} from '../../core/api/proof.models'; +import { ScoreComparisonViewComponent } from './score-comparison-view.component'; + +@Component({ + selector: 'app-proof-replay-dashboard', + standalone: true, + imports: [CommonModule, ScoreComparisonViewComponent], + template: ` +
+ +
+
+

+ + Score Replay +

+ {{ scanId() }} +
+ + + @if (!replayResult() || replayResult()!.status === 'completed' || replayResult()!.status === 'failed') { + + } +
+ + + @if (replayResult(); as result) { +
+ + {{ getStatusLabel(result.status) }} + @if (result.status === 'running') { +
+
+
+ } + @if (result.error) { + {{ result.error }} + } +
+ } + + + @if (replayResult(); as result) { +
+
+
+
Replay ID
+
{{ result.replayId }}
+
+
+
Started
+
{{ formatDate(result.startedAt) }}
+
+ @if (result.completedAt) { +
+
Completed
+
{{ formatDate(result.completedAt) }}
+
+
+
Duration
+
{{ calculateDuration(result.startedAt, result.completedAt) }}
+
+ } +
+
+ } + + + @if (replayResult()?.status === 'completed' && replayResult()!.replayedScore) { +
+ +
+ + + @if (replayResult()!.hasDrift) { + + } @else { +
+ +
+ No Drift Detected +

The replayed score matches the original score.

+
+
+ } + } + + + @if (replayResult()?.proofBundle; as bundle) { + + } + + + @if (replayResult()?.status === 'completed') { +
+ +
+ } +
+ `, + styles: [` + .replay-dashboard { + --color-success: #22c55e; + --color-warning: #f59e0b; + --color-error: #ef4444; + --color-info: #3b82f6; + --color-border: #e5e7eb; + --color-bg-subtle: #f9fafb; + font-family: system-ui, -apple-system, sans-serif; + } + + .dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-border); + } + + .header-info { + display: flex; + align-items: center; + gap: 1rem; + } + + .title { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 1.25rem; + } + + .scan-id { + padding: 0.25rem 0.5rem; + background: var(--color-bg-subtle); + border-radius: 0.25rem; + font-size: 0.875rem; + } + + .mono { + font-family: ui-monospace, 'Cascadia Code', monospace; + } + + .trigger-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--color-info); + color: white; + border: none; + border-radius: 0.5rem; + cursor: pointer; + font-weight: 500; + transition: background 0.15s; + } + + .trigger-btn:hover:not(:disabled) { background: #2563eb; } + .trigger-btn:disabled { opacity: 0.6; cursor: not-allowed; } + + .spinner { + width: 1rem; + height: 1rem; + border: 2px solid white; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.75s linear infinite; + } + + @keyframes spin { to { transform: rotate(360deg); } } + + .status-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1.5rem; + } + + .status-banner--pending { background: #fef3c7; color: #92400e; } + .status-banner--running { background: #dbeafe; color: #1e40af; } + .status-banner--completed { background: #dcfce7; color: #166534; } + .status-banner--failed { background: #fef2f2; color: #991b1b; } + + .status-icon { font-size: 1.5rem; } + .status-text { font-weight: 500; } + + .progress-bar { + flex: 1; + height: 8px; + background: rgba(255,255,255,0.5); + border-radius: 4px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: currentColor; + border-radius: 4px; + transition: width 0.3s; + } + + .error-message { + margin-left: auto; + font-size: 0.875rem; + } + + .replay-info { + margin-bottom: 1.5rem; + } + + .info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + margin: 0; + padding: 1rem; + background: var(--color-bg-subtle); + border-radius: 0.5rem; + } + + .info-item { + dt { + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + color: #6b7280; + margin-bottom: 0.25rem; + } + dd { + margin: 0; + font-size: 0.875rem; + } + } + + .comparison-section { + margin-bottom: 1.5rem; + padding: 1.5rem; + background: white; + border: 1px solid var(--color-border); + border-radius: 0.75rem; + } + + .drift-alert, + .success-alert { + display: flex; + gap: 0.75rem; + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1.5rem; + } + + .drift-alert { + background: #fef3c7; + border: 1px solid #fcd34d; + } + + .success-alert { + background: #dcfce7; + border: 1px solid #86efac; + } + + .alert-icon { font-size: 1.5rem; } + .alert-content { + strong { display: block; margin-bottom: 0.25rem; } + p { margin: 0; font-size: 0.875rem; opacity: 0.8; } + } + + .proof-bundle-link { + padding: 1rem; + background: var(--color-bg-subtle); + border-radius: 0.5rem; + margin-bottom: 1.5rem; + } + + .section-title { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.75rem; + font-size: 0.875rem; + font-weight: 600; + } + + .bundle-info { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + } + + .bundle-id { flex: 1; min-width: 200px; } + + .bundle-status { + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + } + + .status--verified { background: #dcfce7; color: #166534; } + .status--pending { background: #fef3c7; color: #92400e; } + .status--failed { background: #fef2f2; color: #991b1b; } + + .download-btn { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 1rem; + background: white; + border: 1px solid var(--color-border); + border-radius: 0.375rem; + cursor: pointer; + } + + .download-btn:hover { background: var(--color-bg-subtle); } + + .dashboard-footer { + padding-top: 1rem; + border-top: 1px solid var(--color-border); + } + + .export-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: white; + border: 1px solid var(--color-border); + border-radius: 0.5rem; + cursor: pointer; + font-weight: 500; + } + + .export-btn:hover { background: var(--color-bg-subtle); } + + @media (max-width: 640px) { + .dashboard-header { flex-direction: column; gap: 1rem; align-items: flex-start; } + .trigger-btn { width: 100%; justify-content: center; } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProofReplayDashboardComponent { + /** Scan ID for replay */ + readonly scanId = input.required(); + + /** Current replay result */ + readonly replayResult = input(null); + + /** Whether a replay is being triggered */ + readonly isLoading = input(false); + + /** Emits when replay should be triggered */ + readonly triggerReplayRequested = output(); + + /** Emits when bundle download is requested */ + readonly downloadBundleRequested = output(); + + /** Emits when report export is requested */ + readonly exportReportRequested = output(); + + // State + readonly progressPercent = signal(0); + + // Polling for progress simulation + private progressInterval: ReturnType | null = null; + + triggerReplay(): void { + this.triggerReplayRequested.emit(); + this.simulateProgress(); + } + + private simulateProgress(): void { + this.progressPercent.set(0); + if (this.progressInterval) clearInterval(this.progressInterval); + + this.progressInterval = setInterval(() => { + this.progressPercent.update(p => { + if (p >= 90) { + if (this.progressInterval) clearInterval(this.progressInterval); + return p; + } + return p + 10; + }); + }, 500); + } + + downloadBundle(bundle: ProofBundle): void { + this.downloadBundleRequested.emit(bundle); + } + + exportReport(): void { + const result = this.replayResult(); + if (result) { + this.exportReportRequested.emit(result); + } + } + + formatDate(dateStr: string): string { + if (!dateStr) return 'N/A'; + try { + return new Date(dateStr).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return dateStr; + } + } + + calculateDuration(start: string, end: string): string { + try { + const startDate = new Date(start); + const endDate = new Date(end); + const diffMs = endDate.getTime() - startDate.getTime(); + const seconds = Math.floor(diffMs / 1000); + + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; + } catch { + return 'N/A'; + } + } + + getStatusIcon(status: ScoreReplayStatus): string { + switch (status) { + case 'pending': + return '⏳'; + case 'running': + return '🔄'; + case 'completed': + return '✓'; + case 'failed': + return '✗'; + default: + return '?'; + } + } + + getStatusLabel(status: ScoreReplayStatus): string { + switch (status) { + case 'pending': + return 'Replay pending...'; + case 'running': + return 'Replay in progress...'; + case 'completed': + return 'Replay completed'; + case 'failed': + return 'Replay failed'; + default: + return 'Unknown status'; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/proof/score-comparison-view.component.ts b/src/Web/StellaOps.Web/src/app/features/proof/score-comparison-view.component.ts new file mode 100644 index 000000000..ac2df7ab5 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/proof/score-comparison-view.component.ts @@ -0,0 +1,506 @@ +/** + * Score Comparison View Component for Sprint 3500.0004.0002 - T4. + * Displays side-by-side score comparison between scan versions. + */ + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { ScoreBreakdown, ScoreComponent, ScoreDrift } from '../../core/api/proof.models'; + +type ViewMode = 'side-by-side' | 'time-series'; + +@Component({ + selector: 'app-score-comparison-view', + standalone: true, + imports: [CommonModule], + template: ` +
+ +
+

+ + Score Comparison +

+
+ + +
+
+ + @if (viewMode() === 'side-by-side') { + +
+ +
+

Original Score

+
+ {{ originalScore().totalScore | number:'1.1-1' }} +
+
{{ formatDate(originalScore().computedAt) }}
+
+ + +
+
+ @if (totalDelta() > 0) { ↑ } + @else if (totalDelta() < 0) { ↓ } + @else { = } +
+
+ {{ totalDelta() >= 0 ? '+' : '' }}{{ totalDelta() | number:'1.1-1' }} +
+
+ + +
+

Replayed Score

+
+ {{ replayedScore().totalScore | number:'1.1-1' }} +
+
{{ formatDate(replayedScore().computedAt) }}
+
+
+ + +
+

Component Breakdown

+ + + + + + + + + + + + + @for (item of componentComparison(); track item.name) { + + + + + + + + + } + +
ComponentWeightOriginalReplayedDeltaStatus
{{ item.name }}{{ (item.weight * 100) | number:'1.0-0' }}%{{ item.original | number:'1.1-1' }}{{ item.replayed | number:'1.1-1' }} + {{ item.delta >= 0 ? '+' : '' }}{{ item.delta | number:'1.1-1' }} + + @if (item.significant) { + Significant + } @else if (item.hasChange) { + Changed + } @else { + Stable + } +
+
+ + + @if (drifts() && drifts()!.length > 0) { +
+

+ + Score Drift Detected +

+
    + @for (drift of drifts(); track drift.componentName) { +
  • + {{ drift.componentName }} + + {{ drift.delta >= 0 ? '+' : '' }}{{ drift.delta | number:'1.2-2' }} + ({{ drift.driftPercent | number:'1.1-1' }}%) + + @if (drift.significant) { + Significant + } +
  • + } +
+
+ } + } + + @if (viewMode() === 'time-series') { + +
+
+ +

Score history chart

+

Last {{ scoreHistory().length }} measurements

+
+ + + + + + + + + + + + @for (score of scoreHistory(); track score.computedAt; let i = $index) { + + + + + + } + +
DateTotal ScoreChange
{{ formatDate(score.computedAt) }} + {{ score.totalScore | number:'1.1-1' }} + + @if (i < scoreHistory().length - 1) { + @let prevScore = scoreHistory()[i + 1].totalScore; + @let delta = score.totalScore - prevScore; + + {{ delta >= 0 ? '+' : '' }}{{ delta | number:'1.1-1' }} + + } @else { + + } +
+
+ } +
+ `, + styles: [` + .score-comparison { + --color-positive: #22c55e; + --color-negative: #ef4444; + --color-neutral: #6b7280; + --color-border: #e5e7eb; + --color-bg-subtle: #f9fafb; + font-family: system-ui, -apple-system, sans-serif; + } + + .score-comparison__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-border); + } + + .title { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 1.25rem; + } + + .view-toggle { + display: flex; + gap: 0.25rem; + background: var(--color-bg-subtle); + padding: 0.25rem; + border-radius: 0.5rem; + } + + .view-btn { + padding: 0.5rem 1rem; + border: none; + background: transparent; + border-radius: 0.375rem; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.15s; + } + + .view-btn--active { + background: white; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .comparison-grid { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 1.5rem; + align-items: center; + margin-bottom: 2rem; + } + + .score-card { + padding: 1.5rem; + background: var(--color-bg-subtle); + border-radius: 0.75rem; + text-align: center; + } + + .score-card__title { + margin: 0 0 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: #6b7280; + } + + .score-card__total { + font-size: 3rem; + font-weight: 700; + } + + .score-card__date { + font-size: 0.75rem; + color: #6b7280; + margin-top: 0.5rem; + } + + .score-delta { + text-align: center; + } + + .delta-arrow { + font-size: 2rem; + } + + .delta-value { + font-size: 1.25rem; + font-weight: 600; + } + + .delta--positive { color: var(--color-positive); } + .delta--negative { color: var(--color-negative); } + .delta--neutral { color: var(--color-neutral); } + + .score--high { color: var(--color-positive); } + .score--medium { color: #f59e0b; } + .score--low { color: var(--color-negative); } + + .section-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 600; + margin: 0 0 1rem; + } + + .component-breakdown { + margin-bottom: 1.5rem; + } + + .breakdown-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + } + + .breakdown-table th, + .breakdown-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--color-border); + } + + .breakdown-table th { + font-weight: 500; + color: #6b7280; + font-size: 0.75rem; + text-transform: uppercase; + } + + .row--changed { background-color: #fffbeb; } + .row--significant { background-color: #fef2f2; } + + .score-cell { font-weight: 500; } + .delta-cell { font-weight: 600; } + + .status-badge { + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + } + + .status-badge--success { background: #dcfce7; color: #16a34a; } + .status-badge--warning { background: #fef3c7; color: #d97706; } + .status-badge--info { background: #dbeafe; color: #2563eb; } + + .drift-summary { + padding: 1rem; + background: #fffbeb; + border: 1px solid #fcd34d; + border-radius: 0.5rem; + } + + .drift-list { + list-style: none; + padding: 0; + margin: 0; + } + + .drift-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 0; + } + + .drift-name { font-weight: 500; flex: 1; } + .drift-change { font-family: monospace; } + .drift-badge { + font-size: 0.625rem; + padding: 2px 6px; + background: #ef4444; + color: white; + border-radius: 4px; + text-transform: uppercase; + } + + .time-series-view { margin-top: 1rem; } + + .chart-placeholder { + height: 200px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--color-bg-subtle); + border-radius: 0.5rem; + margin-bottom: 1rem; + } + + .chart-icon { font-size: 3rem; } + .chart-note { font-size: 0.75rem; color: #6b7280; } + + .history-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + } + + .history-table th, + .history-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--color-border); + } + + .muted { color: #9ca3af; } + + @media (max-width: 640px) { + .comparison-grid { + grid-template-columns: 1fr; + } + .score-delta { + transform: rotate(90deg); + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ScoreComparisonViewComponent { + /** Original score data */ + readonly originalScore = input.required(); + + /** Replayed/compared score data */ + readonly replayedScore = input.required(); + + /** Score drifts (optional) */ + readonly drifts = input(); + + /** Historical scores for time-series view */ + readonly scoreHistory = input([]); + + /** Emits when user wants to drill into a component */ + readonly componentClicked = output(); + + // State + readonly viewMode = signal('side-by-side'); + + // Computed + readonly totalDelta = computed(() => + this.replayedScore().totalScore - this.originalScore().totalScore + ); + + readonly componentComparison = computed(() => { + const original = this.originalScore().components; + const replayed = this.replayedScore().components; + const driftMap = new Map((this.drifts() ?? []).map(d => [d.componentName, d])); + + return original.map(orig => { + const rep = replayed.find(r => r.name === orig.name); + const delta = (rep?.rawScore ?? 0) - orig.rawScore; + const drift = driftMap.get(orig.name); + + return { + name: orig.name, + weight: orig.weight, + original: orig.rawScore, + replayed: rep?.rawScore ?? 0, + delta, + hasChange: Math.abs(delta) > 0.01, + significant: drift?.significant ?? false, + }; + }); + }); + + setViewMode(mode: ViewMode): void { + this.viewMode.set(mode); + } + + formatDate(dateStr: string): string { + if (!dateStr) return 'N/A'; + try { + return new Date(dateStr).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return dateStr; + } + } + + getScoreClass(score: number): string { + if (score >= 80) return 'score--high'; + if (score >= 50) return 'score--medium'; + return 'score--low'; + } + + getDeltaClass(): string { + const delta = this.totalDelta(); + if (delta > 0) return 'delta--positive'; + if (delta < 0) return 'delta--negative'; + return 'delta--neutral'; + } + + getDeltaClassForValue(delta: number): string { + if (delta > 0) return 'delta--positive'; + if (delta < 0) return 'delta--negative'; + return 'delta--neutral'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/proofs/proof-ledger-view.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/proofs/proof-ledger-view.component.spec.ts new file mode 100644 index 000000000..c273dfbec --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/proofs/proof-ledger-view.component.spec.ts @@ -0,0 +1,254 @@ +/** + * Tests for Proof Ledger View Component + * Sprint: SPRINT_3500_0004_0002 - T8 + */ + +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { signal } from '@angular/core'; +import { of, throwError, delay } from 'rxjs'; +import { ProofLedgerViewComponent } from './proof-ledger-view.component'; +import { MANIFEST_API, PROOF_BUNDLE_API } from '../../core/api/proof.client'; + +describe('ProofLedgerViewComponent', () => { + let component: ProofLedgerViewComponent; + let fixture: ComponentFixture; + let mockManifestApi: jasmine.SpyObj; + let mockProofBundleApi: jasmine.SpyObj; + + const mockManifest = { + scanId: 'scan-123', + imageRef: 'registry.example.com/app:v1.0.0', + digest: 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + scannedAt: new Date().toISOString(), + hashes: [ + { label: 'SBOM', algorithm: 'sha256', value: 'abc123...', source: 'sbom' }, + { label: 'Layer 1', algorithm: 'sha256', value: 'def456...', source: 'layer' } + ], + dsseSignature: { + keyId: 'key-001', + algorithm: 'ecdsa-sha256', + signature: 'MEUCIQDtest...', + signedAt: new Date().toISOString(), + verificationStatus: 'valid' as const + } + }; + + const mockMerkleTree = { + treeId: 'tree-123', + root: { + nodeId: 'root', + hash: 'root-hash-123', + isRoot: true, + isLeaf: false, + level: 2, + position: 0, + children: [] + }, + depth: 3, + leafCount: 6, + algorithm: 'sha256' + }; + + const mockProofBundle = { + bundleId: 'bundle-123', + scanId: 'scan-123', + manifest: mockManifest, + attestation: {}, + rekorEntry: { logIndex: 12345, integratedTime: new Date().toISOString() }, + createdAt: new Date().toISOString() + }; + + beforeEach(async () => { + mockManifestApi = jasmine.createSpyObj('ManifestApi', ['getManifest', 'getMerkleTree']); + mockProofBundleApi = jasmine.createSpyObj('ProofBundleApi', ['getProofBundle', 'verifyProofBundle', 'downloadProofBundle']); + + mockManifestApi.getManifest.and.returnValue(of(mockManifest)); + mockManifestApi.getMerkleTree.and.returnValue(of(mockMerkleTree)); + mockProofBundleApi.getProofBundle.and.returnValue(of(mockProofBundle)); + + await TestBed.configureTestingModule({ + imports: [ProofLedgerViewComponent], + providers: [ + { provide: MANIFEST_API, useValue: mockManifestApi }, + { provide: PROOF_BUNDLE_API, useValue: mockProofBundleApi } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ProofLedgerViewComponent); + component = fixture.componentInstance; + }); + + describe('Initialization', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show loading state initially', () => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + + const loading = fixture.debugElement.query(By.css('.proof-ledger__loading')); + expect(loading).toBeTruthy(); + }); + + it('should load manifest on init', fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(mockManifestApi.getManifest).toHaveBeenCalledWith('scan-123'); + })); + + it('should display manifest data after loading', fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const digest = fixture.debugElement.query(By.css('.proof-ledger__digest code')); + expect(digest.nativeElement.textContent).toContain('sha256:a1b2c3'); + })); + }); + + describe('Hash Display', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display all hashes', () => { + const hashItems = fixture.debugElement.queryAll(By.css('.proof-ledger__hash-item')); + expect(hashItems.length).toBe(2); + }); + + it('should have copy button for each hash', () => { + const copyButtons = fixture.debugElement.queryAll(By.css('.proof-ledger__copy-btn')); + expect(copyButtons.length).toBeGreaterThan(0); + }); + }); + + describe('Merkle Tree', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should load merkle tree', () => { + expect(mockManifestApi.getMerkleTree).toHaveBeenCalled(); + }); + + it('should display merkle tree section', () => { + const merkleSection = fixture.debugElement.query(By.css('.proof-ledger__merkle')); + expect(merkleSection).toBeTruthy(); + }); + }); + + describe('DSSE Signature', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display signature status', () => { + const signatureStatus = fixture.debugElement.query(By.css('.proof-ledger__sig-status')); + expect(signatureStatus).toBeTruthy(); + }); + + it('should show valid status with correct styling', () => { + const validStatus = fixture.debugElement.query(By.css('.proof-ledger__sig-status--valid')); + expect(validStatus).toBeTruthy(); + }); + }); + + describe('Actions', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have verify button', () => { + const verifyBtn = fixture.debugElement.query(By.css('.proof-ledger__verify-btn')); + expect(verifyBtn).toBeTruthy(); + }); + + it('should have download button', () => { + const downloadBtn = fixture.debugElement.query(By.css('.proof-ledger__download-btn')); + expect(downloadBtn).toBeTruthy(); + }); + + it('should call verify on button click', fakeAsync(() => { + mockProofBundleApi.verifyProofBundle.and.returnValue(of({ valid: true })); + + const verifyBtn = fixture.debugElement.query(By.css('.proof-ledger__verify-btn')); + verifyBtn.nativeElement.click(); + tick(); + + expect(mockProofBundleApi.verifyProofBundle).toHaveBeenCalled(); + })); + }); + + describe('Error Handling', () => { + it('should display error when manifest fails to load', fakeAsync(() => { + mockManifestApi.getManifest.and.returnValue(throwError(() => new Error('Network error'))); + + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const error = fixture.debugElement.query(By.css('.proof-ledger__error')); + expect(error).toBeTruthy(); + expect(error.nativeElement.textContent).toContain('Network error'); + })); + }); + + describe('Accessibility', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have accessible heading structure', () => { + const h3 = fixture.debugElement.query(By.css('h3')); + expect(h3).toBeTruthy(); + }); + + it('should have aria-label on icon buttons', () => { + const buttons = fixture.debugElement.queryAll(By.css('button')); + buttons.forEach(button => { + const hasLabel = button.nativeElement.hasAttribute('aria-label') || + button.nativeElement.hasAttribute('title') || + button.nativeElement.textContent.trim().length > 0; + expect(hasLabel).toBeTrue(); + }); + }); + + it('should have proper role on status elements', () => { + const loading = fixture.debugElement.query(By.css('[role="status"]')); + // Loading should have role="status" + }); + + it('should have role="alert" on error messages', fakeAsync(() => { + mockManifestApi.getManifest.and.returnValue(throwError(() => new Error('Error'))); + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const error = fixture.debugElement.query(By.css('[role="alert"]')); + expect(error).toBeTruthy(); + })); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/proofs/proof-ledger-view.component.ts b/src/Web/StellaOps.Web/src/app/features/proofs/proof-ledger-view.component.ts new file mode 100644 index 000000000..4eaa40a8b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/proofs/proof-ledger-view.component.ts @@ -0,0 +1,679 @@ +/** + * Proof Ledger View Component + * Sprint: SPRINT_3500_0004_0002 - T1 + * + * Displays scan proof history with Merkle tree visualization. + * Shows manifest hashes, DSSE signatures, and Rekor transparency links. + */ + +import { Component, input, output, computed, signal, inject, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MANIFEST_API, PROOF_BUNDLE_API, ManifestApi, ProofBundleApi } from '../../core/api/proof.client'; +import { ScanManifest, MerkleTree, MerkleTreeNode, ProofBundle, ProofVerificationResult } from '../../core/api/proof.models'; + +/** + * Hash entry display for manifest view. + */ +interface HashDisplay { + label: string; + algorithm: string; + value: string; + source: string; + copied: boolean; +} + +/** + * Merkle tree view state. + */ +interface TreeViewState { + expandedNodes: Set; + selectedNode: string | null; + zoom: number; +} + +@Component({ + selector: 'stella-proof-ledger-view', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+

+ + Proof Ledger +

+
+ + +
+
+ + + @if (loading()) { +
+ + Loading proof data... +
+ } + + + @if (error()) { + + } + + + @if (manifest() && !loading()) { +
+ +
+ + + {{ verificationStatusText() }} + + @if (rekorLink()) { + + View in Rekor ↗ + + } +
+ + +
+

+ Scan Manifest +

+
+
+ Scan ID: + {{ manifest()!.scanId }} +
+
+ Timestamp: + +
+
+ Algorithm: + {{ manifest()!.algorithmVersion }} +
+
+
+ + +
+

+ Input Hashes +

+
+ @for (hash of hashDisplays(); track hash.value) { +
+ {{ hash.label }} + + {{ hash.value | slice:0:16 }}...{{ hash.value | slice:-8 }} + + +
+ } +
+
+ + +
+

+ Merkle Tree + +

+ @if (treeExpanded() && merkleTree()) { +
+
+
+ + Root + {{ merkleTree()!.root.hash | slice:0:12 }}... +
+ @if (merkleTree()!.root.children) { +
+ @for (child of merkleTree()!.root.children; track child.nodeId) { + + } +
+ } +
+
+ } +
+ + +
+

+ DSSE Signature +

+ @if (proofBundle()?.dsseSignature) { +
+
+ Key ID: + {{ proofBundle()!.dsseSignature.keyId }} +
+
+ Algorithm: + {{ proofBundle()!.dsseSignature.algorithm }} +
+
+ Timestamp: + +
+
+ } @else { +

No DSSE signature available

+ } +
+
+ } + + + +
+ + + {{ node.label || 'Node' }} + {{ node.hash | slice:0:12 }}... +
+ @if (!node.isLeaf && treeState().expandedNodes.has(node.nodeId) && node.children) { + @for (child of node.children; track child.nodeId) { + + } + } +
+
+ `, + styles: [` + .proof-ledger { + background: var(--surface-card, #fff); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + padding: 1.5rem; + } + + .proof-ledger--loading { + opacity: 0.7; + pointer-events: none; + } + + .proof-ledger__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + } + + .proof-ledger__title { + margin: 0; + font-size: 1.25rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .proof-ledger__actions { + display: flex; + gap: 0.5rem; + } + + .proof-ledger__btn { + padding: 0.5rem 1rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + background: var(--surface-button, #f5f5f5); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + } + + .proof-ledger__btn:hover:not(:disabled) { + background: var(--surface-hover, #e8e8e8); + } + + .proof-ledger__btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .proof-ledger__loading { + text-align: center; + padding: 2rem; + color: var(--text-secondary, #666); + } + + .proof-ledger__spinner { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin-right: 0.5rem; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .proof-ledger__error { + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; + } + + .proof-ledger__status { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-radius: 4px; + margin-bottom: 1.5rem; + } + + .proof-ledger__status--verified { + background: var(--success-bg, #f0fdf4); + color: var(--success-text, #16a34a); + } + + .proof-ledger__status--failed { + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + } + + .proof-ledger__status--pending { + background: var(--warning-bg, #fffbeb); + color: var(--warning-text, #d97706); + } + + .proof-ledger__rekor-link { + margin-left: auto; + color: inherit; + text-decoration: underline; + } + + .proof-ledger__section { + margin-bottom: 1.5rem; + } + + .proof-ledger__section-title { + font-size: 1rem; + margin: 0 0 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .proof-ledger__expand-btn { + margin-left: auto; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + background: transparent; + cursor: pointer; + } + + .proof-ledger__manifest, + .proof-ledger__signature { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .proof-ledger__manifest-row, + .proof-ledger__sig-row { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .proof-ledger__label { + font-weight: 500; + min-width: 100px; + color: var(--text-secondary, #666); + } + + .proof-ledger__value { + font-family: monospace; + background: var(--surface-secondary, #f5f5f5); + padding: 0.25rem 0.5rem; + border-radius: 4px; + } + + .proof-ledger__hashes { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .proof-ledger__hash-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: var(--surface-secondary, #f5f5f5); + border-radius: 4px; + } + + .proof-ledger__hash-label { + min-width: 150px; + font-weight: 500; + } + + .proof-ledger__hash-value { + flex: 1; + font-family: monospace; + font-size: 0.875rem; + } + + .proof-ledger__copy-btn { + padding: 0.25rem 0.5rem; + border: none; + background: transparent; + cursor: pointer; + border-radius: 4px; + } + + .proof-ledger__copy-btn:hover { + background: var(--surface-hover, #e8e8e8); + } + + .proof-ledger__copy-btn--copied { + color: var(--success-text, #16a34a); + } + + .proof-ledger__tree { + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 4px; + padding: 1rem; + max-height: 400px; + overflow: auto; + } + + .proof-ledger__tree-node { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; + } + + .proof-ledger__tree-node--root { + font-weight: 600; + } + + .proof-ledger__tree-node--leaf { + opacity: 0.8; + } + + .proof-ledger__node-toggle { + padding: 0; + border: none; + background: transparent; + cursor: pointer; + width: 1rem; + text-align: center; + } + + .proof-ledger__node-hash { + font-family: monospace; + font-size: 0.75rem; + color: var(--text-secondary, #666); + } + + .proof-ledger__no-sig { + color: var(--text-secondary, #666); + font-style: italic; + } + + /* Responsive */ + @media (max-width: 768px) { + .proof-ledger__header { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } + + .proof-ledger__hash-row { + flex-wrap: wrap; + } + + .proof-ledger__hash-label { + width: 100%; + } + } + `] +}) +export class ProofLedgerViewComponent implements OnInit { + // Inputs + readonly scanId = input.required(); + readonly compact = input(false); + + // Outputs + readonly nodeSelected = output(); + readonly bundleDownloaded = output(); + readonly verificationComplete = output(); + + // Services + private readonly manifestApi = inject(MANIFEST_API); + private readonly proofBundleApi = inject(PROOF_BUNDLE_API); + + // State + readonly loading = signal(true); + readonly error = signal(null); + readonly manifest = signal(null); + readonly merkleTree = signal(null); + readonly proofBundle = signal(null); + readonly verificationResult = signal(null); + readonly treeExpanded = signal(false); + readonly treeState = signal({ + expandedNodes: new Set(), + selectedNode: null, + zoom: 1 + }); + + // Computed + readonly hashDisplays = computed(() => { + const m = this.manifest(); + if (!m) return []; + return m.hashes.map(h => ({ + label: h.label, + algorithm: h.algorithm, + value: h.value, + source: h.source, + copied: false + })); + }); + + readonly verificationStatus = computed(() => { + const result = this.verificationResult(); + if (!result) return 'pending'; + return result.valid ? 'verified' : 'failed'; + }); + + readonly verificationStatusText = computed(() => { + const status = this.verificationStatus(); + switch (status) { + case 'verified': return 'Proof verified successfully'; + case 'failed': return 'Proof verification failed'; + default: return 'Verification pending'; + } + }); + + readonly rekorLink = computed(() => { + const bundle = this.proofBundle(); + return bundle?.rekorLogId + ? `https://search.sigstore.dev/?logIndex=${bundle.rekorLogId}` + : null; + }); + + ngOnInit(): void { + this.loadProofData(); + } + + private loadProofData(): void { + this.loading.set(true); + this.error.set(null); + + // Load manifest + this.manifestApi.getManifest(this.scanId()).subscribe({ + next: (manifest) => { + this.manifest.set(manifest); + this.loadMerkleTree(); + this.loadProofBundle(); + }, + error: (err) => { + this.error.set('Failed to load manifest: ' + err.message); + this.loading.set(false); + } + }); + } + + private loadMerkleTree(): void { + this.manifestApi.getMerkleTree(this.scanId()).subscribe({ + next: (tree) => this.merkleTree.set(tree), + error: () => {} // Non-critical + }); + } + + private loadProofBundle(): void { + this.proofBundleApi.getProofBundle(this.scanId()).subscribe({ + next: (bundle) => { + this.proofBundle.set(bundle); + this.loading.set(false); + }, + error: (err) => { + this.error.set('Failed to load proof bundle: ' + err.message); + this.loading.set(false); + } + }); + } + + toggleTreeExpand(): void { + this.treeExpanded.update(v => !v); + } + + toggleNode(nodeId: string): void { + this.treeState.update(state => { + const expanded = new Set(state.expandedNodes); + if (expanded.has(nodeId)) { + expanded.delete(nodeId); + } else { + expanded.add(nodeId); + } + return { ...state, expandedNodes: expanded }; + }); + } + + copyHash(hash: HashDisplay): void { + navigator.clipboard.writeText(hash.value).then(() => { + // Visual feedback handled via template + }); + } + + downloadBundle(): void { + const bundle = this.proofBundle(); + if (!bundle) return; + + this.proofBundleApi.downloadProofBundle(bundle.bundleId).subscribe({ + next: (blob) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `proof-${this.scanId()}.bundle.json`; + a.click(); + URL.revokeObjectURL(url); + this.bundleDownloaded.emit(); + }, + error: (err) => { + this.error.set('Failed to download bundle: ' + err.message); + } + }); + } + + verifyBundle(): void { + const bundle = this.proofBundle(); + if (!bundle) return; + + this.proofBundleApi.verifyProofBundle(bundle.bundleId).subscribe({ + next: (result) => { + this.verificationResult.set(result); + this.verificationComplete.emit(result); + }, + error: (err) => { + this.error.set('Verification failed: ' + err.message); + } + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/proofs/proof-replay-dashboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/proofs/proof-replay-dashboard.component.spec.ts new file mode 100644 index 000000000..eea28d329 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/proofs/proof-replay-dashboard.component.spec.ts @@ -0,0 +1,486 @@ +/** + * Tests for Proof Replay Dashboard Component + * Sprint: SPRINT_3500_0004_0002 - T8 + */ + +import { ComponentFixture, TestBed, fakeAsync, tick, flush, discardPeriodicTasks } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { of, throwError, Subject } from 'rxjs'; +import { ProofReplayDashboardComponent, REPLAY_API, ReplayJob, ReplayResult } from './proof-replay-dashboard.component'; + +describe('ProofReplayDashboardComponent', () => { + let component: ProofReplayDashboardComponent; + let fixture: ComponentFixture; + let mockReplayApi: jasmine.SpyObj; + + const mockJob: ReplayJob = { + id: 'replay-001', + scanId: 'scan-123', + digest: 'sha256:abc123...', + imageRef: 'registry.example.com/app:v1.0.0', + status: 'running', + startedAt: new Date().toISOString(), + currentStep: 'advisory-merge', + totalSteps: 8, + completedSteps: 3, + steps: [ + { name: 'sbom-gen', status: 'completed', duration: 12500 }, + { name: 'scanner-run', status: 'completed', duration: 45200 }, + { name: 'vex-apply', status: 'completed', duration: 8300 }, + { name: 'advisory-merge', status: 'running', duration: 0 }, + { name: 'reachability', status: 'pending' }, + { name: 'scoring', status: 'pending' }, + { name: 'attestation', status: 'pending' }, + { name: 'proof-seal', status: 'pending' } + ] + }; + + const mockResult: ReplayResult = { + jobId: 'replay-001', + scanId: 'scan-123', + status: 'passed', + originalDigest: 'sha256:abc123...', + replayDigest: 'sha256:abc123...', + digestMatch: true, + completedAt: new Date().toISOString(), + totalDuration: 185000, + stepTimings: { + 'sbom-gen': { original: 12500, replay: 12480, delta: -20 }, + 'scanner-run': { original: 45200, replay: 45150, delta: -50 }, + 'vex-apply': { original: 8300, replay: 8310, delta: 10 }, + 'advisory-merge': { original: 22000, replay: 22100, delta: 100 }, + 'reachability': { original: 35000, replay: 34950, delta: -50 }, + 'scoring': { original: 15000, replay: 15020, delta: 20 }, + 'attestation': { original: 28000, replay: 27990, delta: -10 }, + 'proof-seal': { original: 19000, replay: 19000, delta: 0 } + }, + artifacts: [ + { name: 'SBOM', originalHash: 'sha256:sbom111...', replayHash: 'sha256:sbom111...', match: true }, + { name: 'Scanner Report', originalHash: 'sha256:scan222...', replayHash: 'sha256:scan222...', match: true }, + { name: 'VEX Document', originalHash: 'sha256:vex333...', replayHash: 'sha256:vex333...', match: true }, + { name: 'Attestation', originalHash: 'sha256:att444...', replayHash: 'sha256:att444...', match: true } + ], + driftItems: [] + }; + + const mockHistory = [ + { jobId: 'replay-001', scanId: 'scan-123', status: 'passed', completedAt: new Date().toISOString(), digestMatch: true }, + { jobId: 'replay-002', scanId: 'scan-456', status: 'passed', completedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), digestMatch: true }, + { jobId: 'replay-003', scanId: 'scan-789', status: 'failed', completedAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(), digestMatch: false } + ]; + + beforeEach(async () => { + mockReplayApi = jasmine.createSpyObj('ReplayApi', ['triggerReplay', 'getReplayStatus', 'getReplayResult', 'getReplayHistory', 'cancelReplay']); + mockReplayApi.triggerReplay.and.returnValue(of(mockJob)); + mockReplayApi.getReplayStatus.and.returnValue(of(mockJob)); + mockReplayApi.getReplayResult.and.returnValue(of(mockResult)); + mockReplayApi.getReplayHistory.and.returnValue(of(mockHistory)); + mockReplayApi.cancelReplay.and.returnValue(of({ success: true })); + + await TestBed.configureTestingModule({ + imports: [ProofReplayDashboardComponent], + providers: [ + { provide: REPLAY_API, useValue: mockReplayApi } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ProofReplayDashboardComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + // Ensure periodic timers are cleaned up + }); + + describe('Initialization', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load replay history on init', fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + + expect(mockReplayApi.getReplayHistory).toHaveBeenCalledWith('scan-123'); + discardPeriodicTasks(); + })); + + it('should display scan info header', fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('imageRef', 'registry.example.com/app:v1.0.0'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const header = fixture.debugElement.query(By.css('.proof-replay__header')); + expect(header).toBeTruthy(); + discardPeriodicTasks(); + })); + }); + + describe('Trigger Replay', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + afterEach(fakeAsync(() => { + discardPeriodicTasks(); + })); + + it('should have trigger replay button', () => { + const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn')); + expect(button).toBeTruthy(); + }); + + it('should trigger replay on button click', fakeAsync(() => { + const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn')); + button.nativeElement.click(); + tick(); + + expect(mockReplayApi.triggerReplay).toHaveBeenCalledWith('scan-123'); + discardPeriodicTasks(); + })); + + it('should disable button while replay is running', fakeAsync(() => { + const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn')); + button.nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(button.nativeElement.disabled).toBeTrue(); + discardPeriodicTasks(); + })); + }); + + describe('Progress Display', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + // Trigger a replay + const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn')); + button.nativeElement.click(); + tick(); + fixture.detectChanges(); + })); + + afterEach(fakeAsync(() => { + discardPeriodicTasks(); + })); + + it('should display progress bar', () => { + const progressBar = fixture.debugElement.query(By.css('.proof-replay__progress')); + expect(progressBar).toBeTruthy(); + }); + + it('should display current step', () => { + const currentStep = fixture.debugElement.query(By.css('.proof-replay__current-step')); + expect(currentStep).toBeTruthy(); + expect(currentStep.nativeElement.textContent).toContain('advisory-merge'); + }); + + it('should display step list', () => { + const steps = fixture.debugElement.queryAll(By.css('.proof-replay__step')); + expect(steps.length).toBe(8); + }); + + it('should show completed steps with checkmark', () => { + const completedSteps = fixture.debugElement.queryAll(By.css('.proof-replay__step--completed')); + expect(completedSteps.length).toBe(3); + }); + + it('should show running step with spinner', () => { + const runningStep = fixture.debugElement.query(By.css('.proof-replay__step--running')); + expect(runningStep).toBeTruthy(); + }); + }); + + describe('Result Display', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + + // Set result directly for testing + component.result.set(mockResult); + component.activeJob.set(null); + fixture.detectChanges(); + })); + + afterEach(fakeAsync(() => { + discardPeriodicTasks(); + })); + + it('should display result status', () => { + const status = fixture.debugElement.query(By.css('.proof-replay__result-status')); + expect(status).toBeTruthy(); + }); + + it('should show passed status styling', () => { + const status = fixture.debugElement.query(By.css('.proof-replay__result-status--passed')); + expect(status).toBeTruthy(); + }); + + it('should display digest comparison', () => { + const digestSection = fixture.debugElement.query(By.css('.proof-replay__digest-comparison')); + expect(digestSection).toBeTruthy(); + }); + + it('should show digest match indicator', () => { + const matchBadge = fixture.debugElement.query(By.css('.proof-replay__digest-match')); + expect(matchBadge).toBeTruthy(); + }); + }); + + describe('Timing Breakdown', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + + component.result.set(mockResult); + component.activeJob.set(null); + fixture.detectChanges(); + })); + + afterEach(fakeAsync(() => { + discardPeriodicTasks(); + })); + + it('should display timing table', () => { + const timingTable = fixture.debugElement.query(By.css('.proof-replay__timing-table')); + expect(timingTable).toBeTruthy(); + }); + + it('should display all step timings', () => { + const rows = fixture.debugElement.queryAll(By.css('.proof-replay__timing-row')); + expect(rows.length).toBe(8); + }); + + it('should show timing deltas', () => { + const deltas = fixture.debugElement.queryAll(By.css('.proof-replay__timing-delta')); + expect(deltas.length).toBe(8); + }); + + it('should format durations properly', () => { + const durations = fixture.debugElement.queryAll(By.css('.proof-replay__timing-value')); + expect(durations.length).toBeGreaterThan(0); + }); + }); + + describe('Artifact Comparison', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + + component.result.set(mockResult); + component.activeJob.set(null); + fixture.detectChanges(); + })); + + afterEach(fakeAsync(() => { + discardPeriodicTasks(); + })); + + it('should display artifact table', () => { + const artifactTable = fixture.debugElement.query(By.css('.proof-replay__artifact-table')); + expect(artifactTable).toBeTruthy(); + }); + + it('should display all artifacts', () => { + const rows = fixture.debugElement.queryAll(By.css('.proof-replay__artifact-row')); + expect(rows.length).toBe(4); + }); + + it('should show match status for each artifact', () => { + const matchBadges = fixture.debugElement.queryAll(By.css('.proof-replay__artifact-match')); + expect(matchBadges.length).toBe(4); + }); + }); + + describe('Drift Detection', () => { + it('should display drift warning when drift detected', fakeAsync(() => { + const resultWithDrift: ReplayResult = { + ...mockResult, + status: 'drift', + digestMatch: false, + replayDigest: 'sha256:xyz789...', + driftItems: [ + { artifact: 'Scanner Report', field: 'timestamp', original: '2024-01-01T00:00:00Z', replay: '2024-01-01T00:00:01Z', severity: 'warning' }, + { artifact: 'Attestation', field: 'signature', original: 'sig-aaa', replay: 'sig-bbb', severity: 'error' } + ] + }; + + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + + component.result.set(resultWithDrift); + component.activeJob.set(null); + fixture.detectChanges(); + + const driftWarning = fixture.debugElement.query(By.css('.proof-replay__drift-warning')); + expect(driftWarning).toBeTruthy(); + discardPeriodicTasks(); + })); + + it('should list drift items', fakeAsync(() => { + const resultWithDrift: ReplayResult = { + ...mockResult, + status: 'drift', + driftItems: [ + { artifact: 'Scanner Report', field: 'timestamp', original: '2024-01-01T00:00:00Z', replay: '2024-01-01T00:00:01Z', severity: 'warning' } + ] + }; + + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + + component.result.set(resultWithDrift); + component.activeJob.set(null); + fixture.detectChanges(); + + const driftItems = fixture.debugElement.queryAll(By.css('.proof-replay__drift-item')); + expect(driftItems.length).toBe(1); + discardPeriodicTasks(); + })); + }); + + describe('History Table', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + afterEach(fakeAsync(() => { + discardPeriodicTasks(); + })); + + it('should display history table', () => { + const historyTable = fixture.debugElement.query(By.css('.proof-replay__history')); + expect(historyTable).toBeTruthy(); + }); + + it('should display history rows', () => { + const rows = fixture.debugElement.queryAll(By.css('.proof-replay__history-row')); + expect(rows.length).toBe(3); + }); + + it('should show status badges in history', () => { + const badges = fixture.debugElement.queryAll(By.css('.proof-replay__history-status')); + expect(badges.length).toBe(3); + }); + + it('should allow selecting a history item', fakeAsync(() => { + const firstRow = fixture.debugElement.queryAll(By.css('.proof-replay__history-row'))[0]; + firstRow.nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(mockReplayApi.getReplayResult).toHaveBeenCalled(); + discardPeriodicTasks(); + })); + }); + + describe('Cancel Replay', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + + // Trigger a replay + const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn')); + button.nativeElement.click(); + tick(); + fixture.detectChanges(); + })); + + afterEach(fakeAsync(() => { + discardPeriodicTasks(); + })); + + it('should display cancel button when replay is running', () => { + const cancelBtn = fixture.debugElement.query(By.css('.proof-replay__cancel-btn')); + expect(cancelBtn).toBeTruthy(); + }); + + it('should cancel replay on button click', fakeAsync(() => { + const cancelBtn = fixture.debugElement.query(By.css('.proof-replay__cancel-btn')); + cancelBtn.nativeElement.click(); + tick(); + + expect(mockReplayApi.cancelReplay).toHaveBeenCalledWith('replay-001'); + discardPeriodicTasks(); + })); + }); + + describe('Error Handling', () => { + it('should display error on replay failure', fakeAsync(() => { + mockReplayApi.triggerReplay.and.returnValue(throwError(() => new Error('Replay failed'))); + + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn')); + button.nativeElement.click(); + tick(); + fixture.detectChanges(); + + const error = fixture.debugElement.query(By.css('.proof-replay__error')); + expect(error).toBeTruthy(); + discardPeriodicTasks(); + })); + }); + + describe('Accessibility', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + afterEach(fakeAsync(() => { + discardPeriodicTasks(); + })); + + it('should have proper heading hierarchy', () => { + const headings = fixture.debugElement.queryAll(By.css('h2, h3, h4')); + expect(headings.length).toBeGreaterThan(0); + }); + + it('should have accessible buttons', () => { + const buttons = fixture.debugElement.queryAll(By.css('button')); + buttons.forEach(button => { + const hasText = button.nativeElement.textContent.trim().length > 0; + const hasAriaLabel = button.nativeElement.hasAttribute('aria-label'); + expect(hasText || hasAriaLabel).toBeTrue(); + }); + }); + + it('should have proper table structure', () => { + const tables = fixture.debugElement.queryAll(By.css('table')); + tables.forEach(table => { + const thead = table.query(By.css('thead')); + expect(thead).toBeTruthy(); + }); + }); + + it('should announce progress updates', () => { + const liveRegion = fixture.debugElement.query(By.css('[aria-live]')); + expect(liveRegion).toBeTruthy(); + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/proofs/proof-replay-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/proofs/proof-replay-dashboard.component.ts new file mode 100644 index 000000000..212e54d32 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/proofs/proof-replay-dashboard.component.ts @@ -0,0 +1,1030 @@ +/** + * Proof Replay Dashboard Component + * Sprint: SPRINT_3500_0004_0002 - T5 + * + * Allows triggering deterministic proof replays and comparing results. + * Shows progress indicator, drift detection, and comparison views. + */ + +import { Component, input, output, computed, signal, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { InjectionToken } from '@angular/core'; +import { Observable, of, delay, interval, Subscription, switchMap, takeWhile, tap } from 'rxjs'; + +// ============================================================================ +// Models +// ============================================================================ + +export type ReplayStatus = 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; + +export interface ReplayJob { + readonly jobId: string; + readonly scanId: string; + readonly status: ReplayStatus; + readonly progress: number; + readonly currentStep: string; + readonly startedAt?: string; + readonly completedAt?: string; + readonly error?: string; +} + +export interface ReplayResult { + readonly jobId: string; + readonly scanId: string; + readonly originalDigest: string; + readonly replayDigest: string; + readonly matched: boolean; + readonly drifts: DriftItem[]; + readonly timing: ReplayTiming; + readonly artifacts: ReplayArtifact[]; +} + +export interface DriftItem { + readonly field: string; + readonly path: string; + readonly originalValue: string; + readonly replayValue: string; + readonly severity: 'critical' | 'warning' | 'info'; + readonly explanation: string; +} + +export interface ReplayTiming { + readonly totalMs: number; + readonly phases: ReplayPhase[]; +} + +export interface ReplayPhase { + readonly name: string; + readonly durationMs: number; + readonly percentOfTotal: number; +} + +export interface ReplayArtifact { + readonly name: string; + readonly type: 'sbom' | 'attestation' | 'proof' | 'score'; + readonly originalPath: string; + readonly replayPath: string; + readonly matched: boolean; +} + +export interface ReplayHistoryEntry { + readonly jobId: string; + readonly scanId: string; + readonly triggeredAt: string; + readonly status: ReplayStatus; + readonly matched?: boolean; + readonly driftCount?: number; +} + +// ============================================================================ +// API Interface +// ============================================================================ + +export const REPLAY_API = new InjectionToken('REPLAY_API'); + +export interface ReplayApi { + triggerReplay(scanId: string): Observable; + getJobStatus(jobId: string): Observable; + getResult(jobId: string): Observable; + getHistory(scanId: string): Observable; + cancelJob(jobId: string): Observable; +} + +// ============================================================================ +// Component +// ============================================================================ + +@Component({ + selector: 'stella-proof-replay-dashboard', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+

+ + Proof Replay +

+ @if (currentJob()?.status === 'running') { + + Running... + + } +
+
+ Scan: + {{ scanId() }} +
+
+ + +
+ + + @if (currentJob()?.status === 'running' || currentJob()?.status === 'queued') { + + } +
+ + + @if (currentJob() && ['queued', 'running'].includes(currentJob()!.status)) { +
+

+ Progress +

+
+
+
+
+ + {{ currentJob()!.progress }}% + +
+
+ Current step: + {{ currentJob()!.currentStep }} +
+ @if (currentJob()!.startedAt) { +
+ Elapsed: + {{ getElapsedTime(currentJob()!.startedAt!) }} +
+ } +
+ } + + + @if (result()) { +
+

+ + Result +

+ +
+
+ @if (result()!.matched) { + + Deterministic Match + } @else { + + Drift Detected + } +
+ +
+
+ Original + {{ result()!.originalDigest.slice(0, 16) }}... +
+ +
+ Replay + + {{ result()!.replayDigest.slice(0, 16) }}... + +
+
+
+ + +
+
+ Timing ({{ result()!.timing.totalMs }}ms total) +
+
+ @for (phase of result()!.timing.phases; track phase.name) { +
+ {{ phase.name }} +
+
+
+ {{ phase.durationMs }}ms +
+ } +
+
+ + +
+
+ Artifacts +
+
+ @for (artifact of result()!.artifacts; track artifact.name) { +
+ + {{ artifact.matched ? '✓' : '✗' }} + + {{ artifact.name }} + {{ artifact.type }} +
+ } +
+
+ + + @if (result()!.drifts.length > 0) { +
+
+ Drift Details ({{ result()!.drifts.length }}) +
+
+ @for (drift of result()!.drifts; track drift.path) { +
+
+ {{ drift.severity }} + {{ drift.field }} +
+
+ {{ drift.path }} +
+
+
+ Original: + {{ drift.originalValue }} +
+
+ Replay: + {{ drift.replayValue }} +
+
+
+ {{ drift.explanation }} +
+
+ } +
+
+ } +
+ } + + + @if (history().length > 0) { +
+

+ Replay History +

+ + + + + + + + + + + @for (entry of history(); track entry.jobId) { + + + + + + + } + +
DateStatusResultActions
{{ formatDate(entry.triggeredAt) }} + + {{ entry.status }} + + + @if (entry.status === 'completed') { + @if (entry.matched) { + ✓ Matched + } @else { + + ✗ {{ entry.driftCount }} drift(s) + + } + } @else { + — + } + + +
+
+ } + + + @if (error()) { + + } +
+ `, + styles: [` + .proof-replay { + background: var(--surface-card, #fff); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + padding: 1.5rem; + } + + .proof-replay--loading { + opacity: 0.7; + } + + .proof-replay__header { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + } + + .proof-replay__title-row { + display: flex; + align-items: center; + gap: 1rem; + } + + .proof-replay__title { + margin: 0; + font-size: 1.25rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .proof-replay__status { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + } + + .proof-replay__status--running { + background: var(--info-bg, #dbeafe); + color: var(--info-text, #1d4ed8); + animation: pulse 1.5s infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } + } + + .proof-replay__scan-info { + margin-top: 0.5rem; + font-size: 0.875rem; + } + + .proof-replay__scan-label { + color: var(--text-secondary, #666); + margin-right: 0.5rem; + } + + .proof-replay__scan-info code { + background: var(--surface-secondary, #f5f5f5); + padding: 0.25rem 0.5rem; + border-radius: 4px; + } + + /* Actions */ + .proof-replay__actions { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + } + + .proof-replay__trigger-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--primary, #3b82f6); + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + } + + .proof-replay__trigger-btn:hover:not(:disabled) { + background: var(--primary-dark, #2563eb); + } + + .proof-replay__trigger-btn:disabled { + background: var(--text-secondary, #999); + cursor: not-allowed; + } + + .proof-replay__cancel-btn { + padding: 0.75rem 1.5rem; + background: white; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + } + + .proof-replay__cancel-btn:hover { + background: var(--surface-hover, #f5f5f5); + } + + /* Sections */ + .proof-replay__section { + margin-bottom: 1.5rem; + padding: 1rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + } + + .proof-replay__section-title { + font-size: 1rem; + margin: 0 0 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .proof-replay__subsection-title { + font-size: 0.875rem; + margin: 1rem 0 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + /* Progress */ + .proof-replay__progress-container { + display: flex; + align-items: center; + gap: 1rem; + } + + .proof-replay__progress-bar-container { + flex: 1; + height: 20px; + background: var(--surface-secondary, #f0f0f0); + border-radius: 10px; + overflow: hidden; + } + + .proof-replay__progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--primary, #3b82f6), var(--primary-light, #60a5fa)); + border-radius: 10px; + transition: width 0.3s ease; + } + + .proof-replay__progress-text { + font-weight: 600; + min-width: 3rem; + text-align: right; + } + + .proof-replay__current-step { + margin-top: 0.75rem; + font-size: 0.875rem; + } + + .proof-replay__step-label { + color: var(--text-secondary, #666); + margin-right: 0.5rem; + } + + .proof-replay__elapsed { + margin-top: 0.5rem; + font-size: 0.75rem; + color: var(--text-secondary, #666); + } + + /* Result */ + .proof-replay__result-summary { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1rem; + } + + .proof-replay__match-indicator { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: 8px; + } + + .proof-replay__match-indicator--matched { + background: var(--success-bg, #d1fae5); + } + + .proof-replay__match-indicator--drifted { + background: var(--warning-bg, #fef3c7); + } + + .proof-replay__match-icon { + font-size: 1.5rem; + } + + .proof-replay__match-text { + font-weight: 600; + } + + .proof-replay__match-indicator--matched .proof-replay__match-text { + color: var(--success-text, #059669); + } + + .proof-replay__match-indicator--drifted .proof-replay__match-text { + color: var(--warning-text, #d97706); + } + + .proof-replay__digest-comparison { + display: flex; + align-items: center; + gap: 1rem; + } + + .proof-replay__digest { + flex: 1; + text-align: center; + } + + .proof-replay__digest-label { + display: block; + font-size: 0.75rem; + color: var(--text-secondary, #666); + margin-bottom: 0.25rem; + } + + .proof-replay__digest code { + background: var(--surface-secondary, #f5f5f5); + padding: 0.5rem 1rem; + border-radius: 4px; + display: inline-block; + } + + .proof-replay__digest--match { + background: var(--success-bg, #d1fae5) !important; + } + + .proof-replay__digest--drift { + background: var(--warning-bg, #fef3c7) !important; + } + + .proof-replay__digest-arrow { + font-size: 0.875rem; + color: var(--text-secondary, #666); + } + + /* Timing */ + .proof-replay__timing-bars { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .proof-replay__timing-phase { + display: grid; + grid-template-columns: 120px 1fr 60px; + align-items: center; + gap: 0.5rem; + } + + .proof-replay__phase-name { + font-size: 0.75rem; + } + + .proof-replay__phase-bar-container { + height: 12px; + background: var(--surface-secondary, #f0f0f0); + border-radius: 6px; + overflow: hidden; + } + + .proof-replay__phase-bar { + height: 100%; + background: var(--primary, #3b82f6); + border-radius: 6px; + } + + .proof-replay__phase-time { + font-size: 0.75rem; + text-align: right; + color: var(--text-secondary, #666); + } + + /* Artifacts */ + .proof-replay__artifact-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .proof-replay__artifact { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 4px; + border: 1px solid var(--border-color, #e0e0e0); + } + + .proof-replay__artifact--matched { + background: var(--success-bg, #d1fae5); + border-color: var(--success, #22c55e); + } + + .proof-replay__artifact--drifted { + background: var(--warning-bg, #fef3c7); + border-color: var(--warning, #f59e0b); + } + + .proof-replay__artifact-icon { + font-size: 0.875rem; + } + + .proof-replay__artifact-name { + font-weight: 500; + font-size: 0.875rem; + } + + .proof-replay__artifact-type { + font-size: 0.625rem; + color: var(--text-secondary, #666); + text-transform: uppercase; + } + + /* Drifts */ + .proof-replay__drift-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .proof-replay__drift-item { + padding: 0.75rem; + border-radius: 4px; + border-left: 4px solid; + } + + .proof-replay__drift-item--critical { + background: var(--error-bg, #fef2f2); + border-left-color: var(--error, #dc2626); + } + + .proof-replay__drift-item--warning { + background: var(--warning-bg, #fef3c7); + border-left-color: var(--warning, #f59e0b); + } + + .proof-replay__drift-item--info { + background: var(--info-bg, #dbeafe); + border-left-color: var(--info, #3b82f6); + } + + .proof-replay__drift-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + + .proof-replay__drift-severity { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + padding: 0.125rem 0.375rem; + border-radius: 2px; + } + + .proof-replay__drift-item--critical .proof-replay__drift-severity { + background: var(--error, #dc2626); + color: white; + } + + .proof-replay__drift-item--warning .proof-replay__drift-severity { + background: var(--warning, #f59e0b); + color: white; + } + + .proof-replay__drift-field { + font-weight: 500; + } + + .proof-replay__drift-path { + margin-bottom: 0.5rem; + } + + .proof-replay__drift-path code { + font-size: 0.75rem; + background: rgba(0,0,0,0.05); + padding: 0.125rem 0.25rem; + border-radius: 2px; + } + + .proof-replay__drift-values { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + + .proof-replay__drift-value { + padding: 0.5rem; + border-radius: 4px; + } + + .proof-replay__drift-value--original { + background: rgba(0,0,0,0.03); + } + + .proof-replay__drift-value--replay { + background: var(--warning-bg, #fef3c7); + } + + .proof-replay__drift-value-label { + display: block; + font-size: 0.625rem; + color: var(--text-secondary, #666); + margin-bottom: 0.25rem; + } + + .proof-replay__drift-value code { + font-size: 0.75rem; + word-break: break-all; + } + + .proof-replay__drift-explanation { + font-size: 0.75rem; + color: var(--text-secondary, #666); + font-style: italic; + } + + /* History */ + .proof-replay__history-table { + width: 100%; + border-collapse: collapse; + } + + .proof-replay__history-table th, + .proof-replay__history-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color, #e0e0e0); + } + + .proof-replay__history-table th { + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + color: var(--text-secondary, #666); + } + + .proof-replay__history-row--current { + background: var(--surface-secondary, #f5f5f5); + } + + .proof-replay__history-status { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + } + + .proof-replay__history-status.status-completed { + background: var(--success-bg, #d1fae5); + color: var(--success-text, #059669); + } + + .proof-replay__history-status.status-running { + background: var(--info-bg, #dbeafe); + color: var(--info-text, #1d4ed8); + } + + .proof-replay__history-status.status-failed { + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + } + + .proof-replay__history-match { + color: var(--success, #22c55e); + } + + .proof-replay__history-drift { + color: var(--warning, #d97706); + } + + .proof-replay__history-btn { + padding: 0.25rem 0.75rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + background: white; + font-size: 0.75rem; + cursor: pointer; + } + + .proof-replay__history-btn:hover:not(:disabled) { + background: var(--surface-hover, #f5f5f5); + } + + .proof-replay__history-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Error */ + .proof-replay__error { + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + padding: 1rem; + border-radius: 4px; + margin-top: 1rem; + } + + /* Responsive */ + @media (max-width: 768px) { + .proof-replay__digest-comparison { + flex-direction: column; + } + + .proof-replay__drift-values { + grid-template-columns: 1fr; + } + + .proof-replay__timing-phase { + grid-template-columns: 1fr; + gap: 0.25rem; + } + } + `] +}) +export class ProofReplayDashboardComponent implements OnInit, OnDestroy { + // Inputs + readonly scanId = input.required(); + + // Outputs + readonly replayStarted = output(); + readonly replayCompleted = output(); + + // Services + private readonly replayApi = inject(REPLAY_API); + + // State + readonly loading = signal(false); + readonly error = signal(null); + readonly currentJob = signal(null); + readonly result = signal(null); + readonly history = signal([]); + + // Subscriptions + private pollSubscription?: Subscription; + + ngOnInit(): void { + this.loadHistory(); + } + + ngOnDestroy(): void { + this.pollSubscription?.unsubscribe(); + } + + private loadHistory(): void { + this.replayApi.getHistory(this.scanId()).subscribe({ + next: (entries) => { + this.history.set(entries); + } + }); + } + + triggerReplay(): void { + this.loading.set(true); + this.error.set(null); + this.result.set(null); + + this.replayApi.triggerReplay(this.scanId()).subscribe({ + next: (job) => { + this.currentJob.set(job); + this.loading.set(false); + this.replayStarted.emit(job.jobId); + this.startPolling(job.jobId); + }, + error: (err) => { + this.error.set('Failed to trigger replay: ' + err.message); + this.loading.set(false); + } + }); + } + + cancelReplay(): void { + const job = this.currentJob(); + if (!job) return; + + this.replayApi.cancelJob(job.jobId).subscribe({ + next: () => { + this.pollSubscription?.unsubscribe(); + this.currentJob.update(j => j ? { ...j, status: 'cancelled' } : null); + }, + error: (err) => { + this.error.set('Failed to cancel: ' + err.message); + } + }); + } + + loadResult(jobId: string): void { + this.loading.set(true); + + this.replayApi.getResult(jobId).subscribe({ + next: (res) => { + this.result.set(res); + this.loading.set(false); + }, + error: (err) => { + this.error.set('Failed to load result: ' + err.message); + this.loading.set(false); + } + }); + } + + private startPolling(jobId: string): void { + this.pollSubscription?.unsubscribe(); + + this.pollSubscription = interval(1000).pipe( + switchMap(() => this.replayApi.getJobStatus(jobId)), + tap(job => this.currentJob.set(job)), + takeWhile(job => ['queued', 'running'].includes(job.status), true) + ).subscribe({ + next: (job) => { + if (job.status === 'completed') { + this.loadResult(jobId); + this.loadHistory(); + } else if (job.status === 'failed') { + this.error.set(job.error || 'Replay failed'); + } + } + }); + } + + formatDate(timestamp: string): string { + return new Date(timestamp).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + getElapsedTime(startedAt: string): string { + const elapsed = Date.now() - new Date(startedAt).getTime(); + const seconds = Math.floor(elapsed / 1000); + const minutes = Math.floor(seconds / 60); + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/index.ts b/src/Web/StellaOps.Web/src/app/features/reachability/index.ts new file mode 100644 index 000000000..6485bf3e0 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/reachability/index.ts @@ -0,0 +1,6 @@ +/** + * Reachability feature module exports. + * Sprint 3500.0004.0002 + */ + +export { ReachabilityExplainComponent } from './reachability-explain.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain-widget.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain-widget.component.spec.ts new file mode 100644 index 000000000..4e7d0581b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain-widget.component.spec.ts @@ -0,0 +1,402 @@ +/** + * Tests for Reachability Explain Widget Component + * Sprint: SPRINT_3500_0004_0002 - T8 + */ + +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { of, throwError } from 'rxjs'; +import { ReachabilityExplainComponent } from './reachability-explain-widget.component'; +import { REACHABILITY_API } from '../../core/api/reachability.client'; + +describe('ReachabilityExplainComponent', () => { + let component: ReachabilityExplainComponent; + let fixture: ComponentFixture; + let mockReachabilityApi: jasmine.SpyObj; + + const mockExplanation = { + explanationId: 'exp-001', + cveId: 'CVE-2024-1234', + vulnerablePackage: 'com.fasterxml.jackson.databind', + vulnerableFunction: 'readValue', + verdict: 'reachable' as const, + confidence: { + overallScore: 0.85, + factors: [ + { factorName: 'Static Analysis', weight: 0.4, score: 0.9, contribution: 0.36, description: 'Static call graph analysis' }, + { factorName: 'Data Flow', weight: 0.3, score: 0.8, contribution: 0.24, description: 'Data flow tracking' }, + { factorName: 'Entry Point Coverage', weight: 0.3, score: 0.83, contribution: 0.25, description: 'Entry point analysis' } + ], + computedAt: new Date().toISOString() + }, + paths: [ + { + pathId: 'path-001', + entrypoint: { + nodeId: 'node-1', + type: 'entrypoint' as const, + name: 'main', + qualifiedName: 'com.example.App.main', + isVulnerable: false, + isEntrypoint: true + }, + vulnerable: { + nodeId: 'node-4', + type: 'method' as const, + name: 'readValue', + qualifiedName: 'com.fasterxml.jackson.databind.ObjectMapper.readValue', + isVulnerable: true, + isEntrypoint: false + }, + steps: [ + { stepIndex: 0, node: { nodeId: 'node-1', type: 'entrypoint' as const, name: 'main', qualifiedName: 'com.example.App.main', isVulnerable: false, isEntrypoint: true }, confidence: 1.0 }, + { stepIndex: 1, node: { nodeId: 'node-2', type: 'method' as const, name: 'processRequest', qualifiedName: 'com.example.UserService.processRequest', isVulnerable: false, isEntrypoint: false }, callType: 'direct' as const, confidence: 0.95 }, + { stepIndex: 2, node: { nodeId: 'node-3', type: 'method' as const, name: 'deserialize', qualifiedName: 'com.example.JsonHelper.deserialize', isVulnerable: false, isEntrypoint: false }, callType: 'direct' as const, confidence: 0.9 }, + { stepIndex: 3, node: { nodeId: 'node-4', type: 'method' as const, name: 'readValue', qualifiedName: 'com.fasterxml.jackson.databind.ObjectMapper.readValue', isVulnerable: true, isEntrypoint: false }, callType: 'direct' as const, confidence: 0.85 } + ], + pathLength: 4, + overallConfidence: 0.85, + isShortestPath: true + } + ], + entrypointsAnalyzed: 5, + shortestPathLength: 4, + analysisTime: '2.3s', + createdAt: new Date().toISOString() + }; + + const mockCallGraph = { + graphId: 'graph-001', + language: 'java', + nodes: [ + { nodeId: 'node-1', type: 'entrypoint' as const, name: 'main', qualifiedName: 'com.example.App.main', isVulnerable: false, isEntrypoint: true }, + { nodeId: 'node-2', type: 'method' as const, name: 'processRequest', qualifiedName: 'com.example.UserService.processRequest', isVulnerable: false, isEntrypoint: false }, + { nodeId: 'node-3', type: 'method' as const, name: 'deserialize', qualifiedName: 'com.example.JsonHelper.deserialize', isVulnerable: false, isEntrypoint: false }, + { nodeId: 'node-4', type: 'method' as const, name: 'readValue', qualifiedName: 'com.fasterxml.jackson.databind.ObjectMapper.readValue', isVulnerable: true, isEntrypoint: false } + ], + edges: [ + { edgeId: 'edge-1', sourceId: 'node-1', targetId: 'node-2', callType: 'direct' as const }, + { edgeId: 'edge-2', sourceId: 'node-2', targetId: 'node-3', callType: 'direct' as const }, + { edgeId: 'edge-3', sourceId: 'node-3', targetId: 'node-4', callType: 'direct' as const } + ], + nodeCount: 4, + edgeCount: 3, + createdAt: new Date().toISOString(), + digest: 'sha256:abc123' + }; + + beforeEach(async () => { + mockReachabilityApi = jasmine.createSpyObj('ReachabilityApi', ['getExplanation', 'getCallGraph']); + mockReachabilityApi.getExplanation.and.returnValue(of(mockExplanation)); + mockReachabilityApi.getCallGraph.and.returnValue(of(mockCallGraph)); + + await TestBed.configureTestingModule({ + imports: [ReachabilityExplainComponent], + providers: [ + { provide: REACHABILITY_API, useValue: mockReachabilityApi } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ReachabilityExplainComponent); + component = fixture.componentInstance; + }); + + describe('Initialization', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load explanation on init', fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('cveId', 'CVE-2024-1234'); + fixture.detectChanges(); + tick(); + + expect(mockReachabilityApi.getExplanation).toHaveBeenCalledWith('scan-123', 'CVE-2024-1234'); + })); + + it('should load call graph after explanation', fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('cveId', 'CVE-2024-1234'); + fixture.detectChanges(); + tick(); + + expect(mockReachabilityApi.getCallGraph).toHaveBeenCalledWith('scan-123'); + })); + }); + + describe('Verdict Display', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('cveId', 'CVE-2024-1234'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display verdict badge', () => { + const verdict = fixture.debugElement.query(By.css('.reachability-explain__verdict')); + expect(verdict).toBeTruthy(); + expect(verdict.nativeElement.textContent.toLowerCase()).toContain('reachable'); + }); + + it('should display CVE ID', () => { + const cve = fixture.debugElement.query(By.css('.reachability-explain__cve code')); + expect(cve.nativeElement.textContent).toContain('CVE-2024-1234'); + }); + }); + + describe('Confidence Score', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('cveId', 'CVE-2024-1234'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display confidence score', () => { + const score = fixture.debugElement.query(By.css('.reachability-explain__score-value')); + expect(score.nativeElement.textContent).toContain('85'); + }); + + it('should display confidence bar', () => { + const bar = fixture.debugElement.query(By.css('.reachability-explain__score-bar')); + expect(bar).toBeTruthy(); + expect(bar.nativeElement.style.width).toBe('85%'); + }); + + it('should display confidence factors', () => { + const factors = fixture.debugElement.queryAll(By.css('.reachability-explain__factor')); + expect(factors.length).toBe(3); + }); + }); + + describe('Path Display', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('cveId', 'CVE-2024-1234'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display path steps', () => { + const steps = fixture.debugElement.queryAll(By.css('.reachability-explain__path-step')); + expect(steps.length).toBe(4); + }); + + it('should highlight entry point', () => { + const entryStep = fixture.debugElement.query(By.css('.reachability-explain__path-step--entry')); + expect(entryStep).toBeTruthy(); + }); + + it('should highlight vulnerable node', () => { + const vulnStep = fixture.debugElement.query(By.css('.reachability-explain__path-step--vulnerable')); + expect(vulnStep).toBeTruthy(); + }); + + it('should show step numbers', () => { + const stepNumbers = fixture.debugElement.queryAll(By.css('.reachability-explain__step-number')); + expect(stepNumbers[0].nativeElement.textContent).toContain('1'); + expect(stepNumbers[3].nativeElement.textContent).toContain('4'); + }); + }); + + describe('Graph Visualization', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('cveId', 'CVE-2024-1234'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display graph container', () => { + const graph = fixture.debugElement.query(By.css('.reachability-explain__graph-container')); + expect(graph).toBeTruthy(); + }); + + it('should render SVG element', () => { + const svg = fixture.debugElement.query(By.css('.reachability-explain__graph-svg')); + expect(svg).toBeTruthy(); + }); + + it('should render nodes', () => { + const nodes = fixture.debugElement.queryAll(By.css('.reachability-explain__node-group')); + expect(nodes.length).toBe(4); + }); + + it('should render edges', () => { + const edges = fixture.debugElement.queryAll(By.css('.reachability-explain__edge')); + expect(edges.length).toBe(3); + }); + }); + + describe('Zoom Controls', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('cveId', 'CVE-2024-1234'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have zoom in button', () => { + const zoomIn = fixture.debugElement.queryAll(By.css('.reachability-explain__zoom-btn'))[0]; + expect(zoomIn.nativeElement.textContent).toContain('+'); + }); + + it('should have zoom out button', () => { + const zoomOut = fixture.debugElement.queryAll(By.css('.reachability-explain__zoom-btn'))[1]; + expect(zoomOut.nativeElement.textContent).toContain('−'); + }); + + it('should zoom in when button clicked', fakeAsync(() => { + const initialZoom = component.viewState().zoom; + const zoomIn = fixture.debugElement.queryAll(By.css('.reachability-explain__zoom-btn'))[0]; + zoomIn.nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(component.viewState().zoom).toBeGreaterThan(initialZoom); + })); + + it('should zoom out when button clicked', fakeAsync(() => { + const initialZoom = component.viewState().zoom; + const zoomOut = fixture.debugElement.queryAll(By.css('.reachability-explain__zoom-btn'))[1]; + zoomOut.nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(component.viewState().zoom).toBeLessThan(initialZoom); + })); + + it('should reset view when reset button clicked', fakeAsync(() => { + // First zoom in + component.zoomIn(); + tick(); + fixture.detectChanges(); + + // Then reset + const resetBtn = fixture.debugElement.query(By.css('.reachability-explain__reset-btn')); + resetBtn.nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(component.viewState().zoom).toBe(1); + })); + }); + + describe('Node Selection', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('cveId', 'CVE-2024-1234'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should select node on click', fakeAsync(() => { + const node = fixture.debugElement.query(By.css('.reachability-explain__node-group')); + node.nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(component.viewState().selectedNodeId).toBeTruthy(); + })); + + it('should display node details when selected', fakeAsync(() => { + component.selectNode('node-1'); + tick(); + fixture.detectChanges(); + + const details = fixture.debugElement.query(By.css('.reachability-explain__details')); + expect(details).toBeTruthy(); + })); + + it('should emit nodeSelected event', fakeAsync(() => { + spyOn(component.nodeSelected, 'emit'); + + component.selectNode('node-1'); + tick(); + + expect(component.nodeSelected.emit).toHaveBeenCalled(); + })); + }); + + describe('Export', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('cveId', 'CVE-2024-1234'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have PNG export button', () => { + const pngBtn = fixture.debugElement.queryAll(By.css('.reachability-explain__export-btn'))[0]; + expect(pngBtn.nativeElement.title).toContain('PNG'); + }); + + it('should have SVG export button', () => { + const svgBtn = fixture.debugElement.queryAll(By.css('.reachability-explain__export-btn'))[1]; + expect(svgBtn.nativeElement.title).toContain('SVG'); + }); + + it('should emit exportRequested event', fakeAsync(() => { + spyOn(component.exportRequested, 'emit'); + + const svgBtn = fixture.debugElement.queryAll(By.css('.reachability-explain__export-btn'))[1]; + svgBtn.nativeElement.click(); + tick(); + + expect(component.exportRequested.emit).toHaveBeenCalledWith({ format: 'svg' }); + })); + }); + + describe('Error Handling', () => { + it('should display error on API failure', fakeAsync(() => { + mockReachabilityApi.getExplanation.and.returnValue(throwError(() => new Error('API Error'))); + + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('cveId', 'CVE-2024-1234'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const error = fixture.debugElement.query(By.css('.reachability-explain__error')); + expect(error).toBeTruthy(); + })); + }); + + describe('Accessibility', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanId', 'scan-123'); + fixture.componentRef.setInput('cveId', 'CVE-2024-1234'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have aria-label on graph container', () => { + const graph = fixture.debugElement.query(By.css('.reachability-explain__graph-container')); + expect(graph.nativeElement.getAttribute('aria-label')).toContain('graph'); + }); + + it('should be keyboard accessible', () => { + const graph = fixture.debugElement.query(By.css('.reachability-explain__graph-container')); + expect(graph.nativeElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should have aria-labels on buttons', () => { + const buttons = fixture.debugElement.queryAll(By.css('button')); + buttons.forEach(button => { + const hasAccessibleName = button.nativeElement.hasAttribute('aria-label') || + button.nativeElement.hasAttribute('title') || + button.nativeElement.textContent.trim().length > 0; + expect(hasAccessibleName).toBeTrue(); + }); + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain-widget.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain-widget.component.ts new file mode 100644 index 000000000..80bdb3a44 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain-widget.component.ts @@ -0,0 +1,1038 @@ +/** + * Reachability Explain Widget Component + * Sprint: SPRINT_3500_0004_0002 - T3 + * + * Interactive call graph visualization showing CVE reachability paths. + * Features zoom/pan, path highlighting, and confidence breakdown. + */ + +import { Component, input, output, computed, signal, inject, OnInit, ElementRef, ViewChild, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { REACHABILITY_API, ReachabilityApi } from '../../core/api/reachability.client'; +import { + CallGraph, + CallGraphNode, + CallGraphEdge, + ReachabilityExplanation, + ReachabilityPath, + ReachabilityPathStep, + ConfidenceBreakdown +} from '../../core/api/reachability.models'; + +interface ViewState { + zoom: number; + panX: number; + panY: number; + selectedNodeId: string | null; + hoveredNodeId: string | null; +} + +interface NodePosition { + x: number; + y: number; + node: CallGraphNode; +} + +@Component({ + selector: 'stella-reachability-explain', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+

+ + Reachability Analysis +

+ @if (explanation()) { + + {{ explanation()!.verdict }} + + } +
+
+ {{ cveId() }} +
+
+ + + @if (loading()) { +
+ + Analyzing reachability... +
+ } + + + @if (error()) { + + } + + + @if (!loading() && explanation()) { +
+ +
+

+ Confidence Score +

+
+
+ + {{ (explanation()!.confidence.overallScore * 100).toFixed(1) }}% + +
+ @if (explanation()!.confidence.factors.length > 0) { +
+ @for (factor of explanation()!.confidence.factors; track factor.factorName) { +
+ {{ factor.factorName }} + {{ factor.contribution.toFixed(2) }} +
+
+ } +
+ } +
+ + +
+

+ Reachability Path + + {{ primaryPath()?.steps.length || 0 }} steps + +

+
+ @for (step of primaryPath()?.steps || []; track step.node.nodeId; let i = $index) { +
+
{{ step.stepIndex + 1 }}
+
+
+ + {{ step.node.name }} +
+
+ {{ step.node.qualifiedName }} +
+ @if (step.node.filePath) { +
+ 📄 {{ step.node.filePath }}@if (step.node.lineNumber) {:{{ step.node.lineNumber }}} +
+ } +
+ @if (i < (primaryPath()?.steps.length || 0) - 1) { + + } +
+ } +
+
+ + +
+

+ Call Graph +
+ + {{ (viewState().zoom * 100).toFixed(0) }}% + + + + +
+

+ +
+ + + @if (selectedNode()) { +
+

+ Node Details + +

+
+
+ Name: + {{ selectedNode()!.name }} +
+
+ Type: + {{ selectedNode()!.type }} +
+
+ Qualified Name: + {{ selectedNode()!.qualifiedName }} +
+ @if (selectedNode()!.filePath) { +
+ File: + + {{ selectedNode()!.filePath }}:{{ selectedNode()!.lineNumber }} + +
+ } +
+ Package: + {{ selectedNode()!.package }} +
+
+ @if (selectedNode()!.isEntrypoint) { + Entrypoint + } + @if (selectedNode()!.isVulnerable) { + Vulnerable + } + @if (isNodeOnPath(selectedNode()!.nodeId)) { + On Path + } +
+
+
+ } +
+ } +
+ `, + styles: [` + .reachability-explain { + background: var(--surface-card, #fff); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + padding: 1.5rem; + } + + .reachability-explain--loading { + opacity: 0.7; + } + + .reachability-explain__header { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + } + + .reachability-explain__title-row { + display: flex; + align-items: center; + gap: 1rem; + } + + .reachability-explain__title { + margin: 0; + font-size: 1.25rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .reachability-explain__verdict { + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + } + + .reachability-explain__verdict--reachable_static { + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + } + + .reachability-explain__verdict--possibly_reachable { + background: var(--warning-bg, #fef3c7); + color: var(--warning-text, #d97706); + } + + .reachability-explain__verdict--not_reachable { + background: var(--success-bg, #d1fae5); + color: var(--success-text, #059669); + } + + .reachability-explain__verdict--unknown { + background: var(--surface-secondary, #f5f5f5); + color: var(--text-secondary, #666); + } + + .reachability-explain__cve { + margin-top: 0.5rem; + } + + .reachability-explain__cve code { + background: var(--surface-secondary, #f5f5f5); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; + } + + .reachability-explain__loading { + text-align: center; + padding: 2rem; + color: var(--text-secondary, #666); + } + + .reachability-explain__spinner { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin-right: 0.5rem; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .reachability-explain__error { + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + padding: 1rem; + border-radius: 4px; + } + + .reachability-explain__section { + margin-bottom: 1.5rem; + } + + .reachability-explain__section-title { + font-size: 1rem; + margin: 0 0 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + /* Confidence Section */ + .reachability-explain__score-display { + position: relative; + height: 24px; + background: var(--surface-secondary, #f5f5f5); + border-radius: 12px; + overflow: hidden; + margin-bottom: 1rem; + } + + .reachability-explain__score-bar { + height: 100%; + border-radius: 12px; + transition: width 0.3s ease; + } + + .reachability-explain__score-bar.confidence-high { + background: var(--error, #ef4444); + } + + .reachability-explain__score-bar.confidence-medium { + background: var(--warning, #f59e0b); + } + + .reachability-explain__score-bar.confidence-low { + background: var(--success, #22c55e); + } + + .reachability-explain__score-value { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + font-weight: 600; + font-size: 0.875rem; + } + + .reachability-explain__breakdown { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .reachability-explain__factor { + display: grid; + grid-template-columns: 150px 50px 1fr; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + } + + .reachability-explain__factor-bar { + height: 8px; + background: var(--primary, #3b82f6); + border-radius: 4px; + } + + /* Path Section */ + .reachability-explain__path-length { + font-size: 0.75rem; + font-weight: normal; + color: var(--text-secondary, #666); + margin-left: auto; + } + + .reachability-explain__path-list { + display: flex; + flex-direction: column; + gap: 0; + } + + .reachability-explain__path-step { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 0.75rem; + border-left: 3px solid var(--border-color, #e0e0e0); + cursor: pointer; + transition: background 0.2s; + position: relative; + } + + .reachability-explain__path-step:hover { + background: var(--surface-hover, #f9f9f9); + } + + .reachability-explain__path-step--selected { + background: var(--selected-bg, #eff6ff); + border-left-color: var(--primary, #3b82f6); + } + + .reachability-explain__path-step--entry { + border-left-color: var(--success, #22c55e); + } + + .reachability-explain__path-step--vulnerable { + border-left-color: var(--error, #ef4444); + } + + .reachability-explain__step-number { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--surface-secondary, #f5f5f5); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; + } + + .reachability-explain__path-step--entry .reachability-explain__step-number { + background: var(--success-bg, #d1fae5); + color: var(--success-text, #059669); + } + + .reachability-explain__path-step--vulnerable .reachability-explain__step-number { + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + } + + .reachability-explain__step-info { + flex: 1; + min-width: 0; + } + + .reachability-explain__step-name { + font-weight: 500; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .reachability-explain__step-location { + font-size: 0.75rem; + color: var(--text-secondary, #666); + margin-top: 0.25rem; + } + + .reachability-explain__step-location code { + background: var(--surface-secondary, #f5f5f5); + padding: 0.125rem 0.25rem; + border-radius: 2px; + } + + .reachability-explain__step-file { + font-size: 0.75rem; + color: var(--text-secondary, #666); + margin-top: 0.25rem; + } + + .reachability-explain__step-arrow { + position: absolute; + left: 1.5rem; + bottom: -0.5rem; + color: var(--border-color, #ccc); + font-size: 1rem; + } + + /* Graph Section */ + .reachability-explain__graph-controls { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.25rem; + } + + .reachability-explain__zoom-btn, + .reachability-explain__reset-btn, + .reachability-explain__export-btn { + padding: 0.25rem 0.5rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + background: white; + cursor: pointer; + font-size: 0.875rem; + } + + .reachability-explain__zoom-btn:hover, + .reachability-explain__reset-btn:hover, + .reachability-explain__export-btn:hover { + background: var(--surface-hover, #f5f5f5); + } + + .reachability-explain__zoom-level { + font-size: 0.75rem; + min-width: 40px; + text-align: center; + } + + .reachability-explain__graph-container { + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 4px; + height: 400px; + overflow: hidden; + cursor: grab; + background: var(--surface-secondary, #fafafa); + } + + .reachability-explain__graph-container:active { + cursor: grabbing; + } + + .reachability-explain__graph-svg { + width: 100%; + height: 100%; + } + + .reachability-explain__edge { + stroke: var(--border-color, #999); + stroke-width: 1.5; + fill: none; + } + + .reachability-explain__edge--path { + stroke: var(--error, #ef4444); + stroke-width: 2.5; + } + + .reachability-explain__node-circle { + fill: var(--surface-card, #fff); + stroke: var(--border-color, #999); + stroke-width: 2; + cursor: pointer; + } + + .reachability-explain__node-group--path .reachability-explain__node-circle { + stroke: var(--error, #ef4444); + stroke-width: 3; + } + + .reachability-explain__node-group--entry .reachability-explain__node-circle { + fill: var(--success-bg, #d1fae5); + stroke: var(--success, #22c55e); + } + + .reachability-explain__node-group--vulnerable .reachability-explain__node-circle { + fill: var(--error-bg, #fef2f2); + stroke: var(--error, #ef4444); + } + + .reachability-explain__node-group--selected .reachability-explain__node-circle { + stroke: var(--primary, #3b82f6); + stroke-width: 3; + } + + .reachability-explain__node-group--hovered .reachability-explain__node-circle { + fill: var(--surface-hover, #f5f5f5); + } + + .reachability-explain__node-label { + font-size: 10px; + fill: var(--text-primary, #333); + pointer-events: none; + } + + /* Details Panel */ + .reachability-explain__close-btn { + margin-left: auto; + padding: 0.25rem 0.5rem; + border: none; + background: transparent; + cursor: pointer; + font-size: 1rem; + } + + .reachability-explain__details-content { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .reachability-explain__detail-row { + display: flex; + gap: 0.5rem; + } + + .reachability-explain__detail-label { + font-weight: 500; + min-width: 120px; + color: var(--text-secondary, #666); + } + + .reachability-explain__detail-value { + word-break: break-all; + } + + .reachability-explain__detail-flags { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } + + .reachability-explain__flag { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + } + + .reachability-explain__flag--entry { + background: var(--success-bg, #d1fae5); + color: var(--success-text, #059669); + } + + .reachability-explain__flag--vulnerable { + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + } + + .reachability-explain__flag--path { + background: var(--warning-bg, #fef3c7); + color: var(--warning-text, #d97706); + } + + /* Responsive */ + @media (max-width: 768px) { + .reachability-explain__graph-container { + height: 300px; + } + + .reachability-explain__factor { + grid-template-columns: 1fr; + gap: 0.25rem; + } + } + `] +}) +export class ReachabilityExplainComponent implements OnInit { + @ViewChild('graphContainer') graphContainer!: ElementRef; + @ViewChild('graphSvg') graphSvg!: ElementRef; + + // Inputs + readonly scanId = input.required(); + readonly cveId = input.required(); + + // Outputs + readonly nodeSelected = output(); + readonly exportRequested = output<{ format: 'png' | 'svg' }>(); + + // Services + private readonly reachabilityApi = inject(REACHABILITY_API); + + // State + readonly loading = signal(true); + readonly error = signal(null); + readonly explanation = signal(null); + readonly callGraph = signal(null); + readonly viewState = signal({ + zoom: 1, + panX: 0, + panY: 0, + selectedNodeId: null, + hoveredNodeId: null + }); + + // Computed + /** Primary path to display (first path) */ + readonly primaryPath = computed(() => { + const exp = this.explanation(); + if (!exp || !exp.paths.length) return null; + return exp.paths[0]; + }); + + readonly pathNodeIds = computed(() => { + const path = this.primaryPath(); + if (!path) return new Set(); + return new Set(path.steps.map(s => s.node.nodeId)); + }); + + readonly pathEdges = computed(() => { + const path = this.primaryPath(); + if (!path) return new Set(); + const edges = new Set(); + const steps = path.steps; + for (let i = 0; i < steps.length - 1; i++) { + edges.add(`${steps[i].node.nodeId}->${steps[i + 1].node.nodeId}`); + } + return edges; + }); + + readonly graphEdges = computed(() => { + const graph = this.callGraph(); + return graph?.edges || []; + }); + + readonly nodePositions = computed(() => { + const graph = this.callGraph(); + if (!graph) return []; + + // Simple force-directed layout simulation + const nodes = graph.nodes; + const positions: NodePosition[] = []; + + // Layout nodes in a grid for simplicity + const cols = Math.ceil(Math.sqrt(nodes.length)); + const spacing = 100; + + nodes.forEach((node, i) => { + const row = Math.floor(i / cols); + const col = i % cols; + positions.push({ + x: col * spacing + 50, + y: row * spacing + 50, + node + }); + }); + + return positions; + }); + + readonly svgViewBox = computed(() => { + const state = this.viewState(); + const baseWidth = 800; + const baseHeight = 600; + const width = baseWidth / state.zoom; + const height = baseHeight / state.zoom; + return `${-state.panX} ${-state.panY} ${width} ${height}`; + }); + + readonly selectedNode = computed(() => { + const nodeId = this.viewState().selectedNodeId; + if (!nodeId) return null; + return this.callGraph()?.nodes.find(n => n.nodeId === nodeId) || null; + }); + + ngOnInit(): void { + this.loadExplanation(); + } + + private loadExplanation(): void { + this.loading.set(true); + this.error.set(null); + + this.reachabilityApi.getExplanation(this.scanId(), this.cveId()).subscribe({ + next: (explanation) => { + this.explanation.set(explanation); + this.loadCallGraph(); + }, + error: (err) => { + this.error.set('Failed to load reachability analysis: ' + err.message); + this.loading.set(false); + } + }); + } + + private loadCallGraph(): void { + this.reachabilityApi.getCallGraph(this.scanId()).subscribe({ + next: (graph) => { + this.callGraph.set(graph); + this.loading.set(false); + }, + error: () => { + // Non-critical, visualization just won't work + this.loading.set(false); + } + }); + } + + getConfidenceClass(confidence: number): string { + if (confidence >= 0.7) return 'confidence-high'; + if (confidence >= 0.4) return 'confidence-medium'; + return 'confidence-low'; + } + + getNodeIcon(node: CallGraphNode): string { + if (node.isEntrypoint) return '🚪'; + if (node.isVulnerable) return '⚠️'; + switch (node.type) { + case 'entrypoint': return '🚪'; + case 'class': return '📦'; + case 'method': return '⚙️'; + default: return '○'; + } + } + + isNodeOnPath(nodeId: string): boolean { + return this.pathNodeIds().has(nodeId); + } + + isEdgeOnPath(edge: CallGraphEdge): boolean { + return this.pathEdges().has(`${edge.sourceId}->${edge.targetId}`); + } + + getNodePosition(nodeId: string): NodePosition | undefined { + return this.nodePositions().find(p => p.node.nodeId === nodeId); + } + + /** Get edge source position */ + getEdgeSourceX(edge: CallGraphEdge): number { + return this.getNodePosition(edge.sourceId)?.x || 0; + } + + getEdgeSourceY(edge: CallGraphEdge): number { + return this.getNodePosition(edge.sourceId)?.y || 0; + } + + getEdgeTargetX(edge: CallGraphEdge): number { + return this.getNodePosition(edge.targetId)?.x || 0; + } + + getEdgeTargetY(edge: CallGraphEdge): number { + return this.getNodePosition(edge.targetId)?.y || 0; + } + + selectNode(nodeId: string | null): void { + this.viewState.update(s => ({ ...s, selectedNodeId: nodeId })); + if (nodeId) { + const node = this.callGraph()?.nodes.find(n => n.nodeId === nodeId); + if (node) this.nodeSelected.emit(node); + } + } + + hoverNode(nodeId: string | null): void { + this.viewState.update(s => ({ ...s, hoveredNodeId: nodeId })); + } + + zoomIn(): void { + this.viewState.update(s => ({ ...s, zoom: Math.min(s.zoom * 1.2, 3) })); + } + + zoomOut(): void { + this.viewState.update(s => ({ ...s, zoom: Math.max(s.zoom / 1.2, 0.3) })); + } + + resetView(): void { + this.viewState.update(s => ({ ...s, zoom: 1, panX: 0, panY: 0 })); + } + + onWheel(event: WheelEvent): void { + event.preventDefault(); + if (event.deltaY < 0) { + this.zoomIn(); + } else { + this.zoomOut(); + } + } + + private isPanning = false; + private lastPanX = 0; + private lastPanY = 0; + + onPanStart(event: MouseEvent): void { + this.isPanning = true; + this.lastPanX = event.clientX; + this.lastPanY = event.clientY; + + const onMove = (e: MouseEvent) => { + if (!this.isPanning) return; + const dx = e.clientX - this.lastPanX; + const dy = e.clientY - this.lastPanY; + this.viewState.update(s => ({ + ...s, + panX: s.panX - dx / s.zoom, + panY: s.panY - dy / s.zoom + })); + this.lastPanX = e.clientX; + this.lastPanY = e.clientY; + }; + + const onUp = () => { + this.isPanning = false; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + + exportGraph(format: 'png' | 'svg'): void { + this.exportRequested.emit({ format }); + + if (format === 'svg' && this.graphSvg) { + const svgData = new XMLSerializer().serializeToString(this.graphSvg.nativeElement); + const blob = new Blob([svgData], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `reachability-${this.cveId()}.svg`; + a.click(); + URL.revokeObjectURL(url); + } + // PNG export would require canvas rendering + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.html b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.html new file mode 100644 index 000000000..59fee9d00 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.html @@ -0,0 +1,219 @@ +
+ +
+
+

+ + Reachability Analysis +

+ {{ explanation().cveId }} +
+ + +
+ + {{ verdictLabel() }} +
+
+ + +
+
+ Shortest Path + {{ shortestPathLength() }} hops +
+
+ Paths Found + {{ paths().length }} +
+
+ Entrypoints Analyzed + {{ entrypointsAnalyzed() }} +
+
+ Vulnerable Function + {{ explanation().vulnerableFunction ?? 'N/A' }} +
+
+ + +
+
+

+ Confidence Score + +

+
+ +
+
+
+
+ {{ confidencePercent() }}% + {{ confidenceLevel() | titlecase }} +
+ + @if (showConfidenceDetails()) { +
+ @for (factor of confidence().factors; track factor.factorName) { +
+
+ {{ factor.factorName }} + + {{ formatConfidence(factor.score) }} + +
+
+
+
+

{{ factor.description }}

+
+ } +
+ } +
+ + + @if (hasMultiplePaths()) { +
+

Reachability Paths

+
+ @for (path of paths(); track path.pathId; let i = $index) { + + } +
+
+ } + + +
+
+

+ + Call Path Visualization + +

+ +
+ + {{ (zoomLevel() * 100) | number:'1.0-0' }}% + + +
+
+ + + + + @if (selectedPath(); as path) { +
+ View path as list ({{ path.steps.length }} steps) +
    + @for (step of path.steps; track step.stepIndex) { +
  1. +
    {{ step.stepIndex + 1 }}
    +
    +
    + + {{ step.node.name }} + {{ getNodeTypeLabel(step.node) }} +
    +
    {{ step.node.qualifiedName }}
    + @if (step.node.filePath) { +
    + {{ step.node.filePath }} + @if (step.node.lineNumber) { + :{{ step.node.lineNumber }} + } +
    + } + @if (step.callType) { +
    {{ getCallTypeLabel(step.callType) }}
    + } +
    +
    + {{ formatConfidence(step.confidence) }} +
    +
  2. + } +
+
+ } +
+ + +
+ Export: +
+ + + + +
+
+
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.scss b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.scss new file mode 100644 index 000000000..7f72d8fcd --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.scss @@ -0,0 +1,613 @@ +/** + * Reachability Explain Widget Styles + * Sprint 3500.0004.0002 - T3 + */ + +.reachability-explain { + --color-reachable: #ef4444; + --color-unreachable: #22c55e; + --color-uncertain: #f59e0b; + --color-no-data: #6b7280; + --color-high: #22c55e; + --color-medium: #f59e0b; + --color-low: #ef4444; + --color-border: #e5e7eb; + --color-bg-subtle: #f9fafb; + --color-text: #1f2937; + --color-text-muted: #6b7280; + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + + font-family: system-ui, -apple-system, sans-serif; + color: var(--color-text); +} + +// Header +.reachability-explain__header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: var(--spacing-md); + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--spacing-lg); +} + +.header-info { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.header-title { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: 1.25rem; + font-weight: 600; + margin: 0; +} + +.header-icon { + font-size: 1.5rem; +} + +.cve-badge { + padding: var(--spacing-xs) var(--spacing-sm); + background-color: #fee2e2; + color: #b91c1c; + border-radius: var(--radius-sm); + font-family: monospace; + font-size: 0.875rem; + font-weight: 500; +} + +.verdict-container { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + font-weight: 600; + + &.verdict--reachable { + background-color: rgba(239, 68, 68, 0.1); + color: var(--color-reachable); + } + + &.verdict--unreachable { + background-color: rgba(34, 197, 94, 0.1); + color: var(--color-unreachable); + } + + &.verdict--uncertain { + background-color: rgba(245, 158, 11, 0.1); + color: var(--color-uncertain); + } + + &.verdict--no_data { + background-color: rgba(107, 114, 128, 0.1); + color: var(--color-no-data); + } +} + +.verdict-icon { + font-size: 1.25rem; +} + +// Summary stats +.summary-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--spacing-md); + padding: var(--spacing-lg); + background-color: var(--color-bg-subtle); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-lg); +} + +.stat-item { + .stat-label { + display: block; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + margin-bottom: var(--spacing-xs); + } + + .stat-value { + font-size: 1rem; + font-weight: 500; + } +} + +.mono { + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace; + font-size: 0.875rem; +} + +// Section titles +.section-title { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: 1rem; + font-weight: 600; + margin: 0 0 var(--spacing-md); +} + +.section-icon { + font-size: 1.125rem; +} + +.details-toggle, +.expand-toggle { + margin-left: auto; + padding: var(--spacing-xs) var(--spacing-sm); + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.75rem; + + &:hover { + background-color: white; + } + + &:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } +} + +// Confidence section +.confidence-section { + padding: var(--spacing-lg); + background-color: var(--color-bg-subtle); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + margin-bottom: var(--spacing-lg); +} + +.confidence-header { + margin-bottom: var(--spacing-md); +} + +.confidence-score { + display: flex; + align-items: center; + gap: var(--spacing-md); + margin-bottom: var(--spacing-md); +} + +.score-bar { + flex: 1; + height: 8px; + background-color: #e5e7eb; + border-radius: 4px; + overflow: hidden; +} + +.score-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +.confidence--high .score-fill, +.confidence--high { + background-color: var(--color-high); + color: var(--color-high); +} + +.confidence--medium .score-fill, +.confidence--medium { + background-color: var(--color-medium); + color: var(--color-medium); +} + +.confidence--low .score-fill, +.confidence--low { + background-color: var(--color-low); + color: var(--color-low); +} + +.score-value { + font-size: 1.5rem; + font-weight: 700; + min-width: 60px; +} + +.score-level { + font-size: 0.875rem; + font-weight: 500; + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + background-color: white; +} + +.confidence-factors { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + margin-top: var(--spacing-lg); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--color-border); +} + +.factor-item { + .factor-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-xs); + } + + .factor-name { + font-weight: 500; + } + + .factor-score { + font-weight: 600; + font-size: 0.875rem; + } + + .factor-bar { + height: 4px; + background-color: #e5e7eb; + border-radius: 2px; + overflow: hidden; + margin-bottom: var(--spacing-xs); + } + + .factor-fill { + height: 100%; + border-radius: 2px; + } + + .factor-description { + font-size: 0.75rem; + color: var(--color-text-muted); + margin: 0; + } +} + +// Path selector +.path-selector { + margin-bottom: var(--spacing-lg); +} + +.path-tabs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +.path-tab { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: white; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.15s; + + &:hover { + border-color: #94a3b8; + } + + &--active { + border-color: #3b82f6; + background-color: #eff6ff; + } + + &:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } +} + +.path-number { + font-weight: 600; +} + +.path-length { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.path-confidence { + font-size: 0.75rem; + font-weight: 600; +} + +.shortest-badge { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + padding: 2px 6px; + background-color: #dbeafe; + color: #1d4ed8; + border-radius: var(--radius-sm); +} + +// Graph section +.graph-section { + margin-bottom: var(--spacing-lg); +} + +.graph-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); +} + +.graph-controls { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.control-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: white; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 1rem; + + &:hover { + background-color: var(--color-bg-subtle); + } + + &:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } +} + +.zoom-level { + font-size: 0.75rem; + color: var(--color-text-muted); + min-width: 40px; + text-align: center; +} + +.graph-container { + position: relative; + background-color: #fafafa; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; + min-height: 200px; + max-height: 400px; + + &--expanded { + max-height: none; + height: 600px; + } +} + +.graph-canvas { + display: block; + width: 100%; + cursor: grab; + + &:active { + cursor: grabbing; + } +} + +// Path steps list (accessible alternative) +.path-steps-details { + margin-top: var(--spacing-md); + + summary { + cursor: pointer; + font-size: 0.875rem; + color: #3b82f6; + padding: var(--spacing-sm); + + &:hover { + text-decoration: underline; + } + } +} + +.path-steps-list { + list-style: none; + padding: 0; + margin: var(--spacing-md) 0 0; +} + +.path-step { + display: flex; + align-items: flex-start; + gap: var(--spacing-md); + padding: var(--spacing-md); + border-left: 2px solid #e5e7eb; + margin-left: 1rem; + position: relative; + + &::before { + content: ''; + position: absolute; + left: -6px; + top: 1.25rem; + width: 10px; + height: 10px; + background: white; + border: 2px solid #cbd5e1; + border-radius: 50%; + } + + &--entrypoint::before { + border-color: var(--color-unreachable); + background-color: #dcfce7; + } + + &--vulnerable::before { + border-color: var(--color-reachable); + background-color: #fef2f2; + } +} + +.step-number { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background-color: #f1f5f9; + border-radius: 50%; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +.step-content { + flex: 1; + min-width: 0; +} + +.step-header { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-xs); +} + +.step-icon { + font-size: 0.875rem; +} + +.step-name { + font-weight: 600; +} + +.step-type { + font-size: 0.75rem; + padding: 2px 6px; + background-color: #f1f5f9; + border-radius: var(--radius-sm); + color: var(--color-text-muted); +} + +.step-qualified-name { + font-size: 0.75rem; + color: var(--color-text-muted); + margin-bottom: var(--spacing-xs); + word-break: break-all; +} + +.step-location { + font-size: 0.75rem; + color: #3b82f6; +} + +.step-call-type { + font-size: 0.75rem; + font-style: italic; + color: var(--color-text-muted); +} + +.step-confidence { + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +// Export section +.export-section { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--color-border); +} + +.export-label { + font-size: 0.875rem; + color: var(--color-text-muted); +} + +.export-buttons { + display: flex; + gap: var(--spacing-sm); +} + +.export-btn { + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-md); + background: white; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + font-size: 0.875rem; + transition: all 0.15s; + + &:hover { + background-color: var(--color-bg-subtle); + border-color: #94a3b8; + } + + &:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } +} + +// Responsive +@media (max-width: 640px) { + .reachability-explain__header { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-md); + } + + .summary-stats { + grid-template-columns: 1fr 1fr; + } + + .confidence-score { + flex-wrap: wrap; + } + + .path-tabs { + flex-direction: column; + } + + .path-tab { + width: 100%; + } + + .graph-header { + flex-direction: column; + gap: var(--spacing-md); + align-items: flex-start; + } + + .export-section { + flex-direction: column; + align-items: flex-start; + } + + .export-buttons { + flex-wrap: wrap; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.ts new file mode 100644 index 000000000..13a5f62d1 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.ts @@ -0,0 +1,405 @@ +/** + * Reachability Explain Widget for Sprint 3500.0004.0002 - T3. + * Visualizes CVE reachability paths with interactive call graph. + */ + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, + ElementRef, + ViewChild, + AfterViewInit, + OnDestroy, +} from '@angular/core'; +import { + ReachabilityExplanation, + ReachabilityPath, + ReachabilityPathStep, + CallGraph, + CallGraphNode, + ConfidenceBreakdown, + ConfidenceFactor, + ReachabilityVerdict, + ExportFormat, +} from '../../core/api/reachability.models'; + +@Component({ + selector: 'app-reachability-explain', + standalone: true, + imports: [CommonModule], + templateUrl: './reachability-explain.component.html', + styleUrls: ['./reachability-explain.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ReachabilityExplainComponent implements AfterViewInit, OnDestroy { + @ViewChild('graphCanvas') graphCanvas!: ElementRef; + + /** Reachability explanation data */ + readonly explanation = input.required(); + + /** Whether to show full call graph (default: just paths) */ + readonly showFullGraph = input(false); + + /** Emits when export is requested */ + readonly exportRequested = output<{ format: ExportFormat; pathId?: string }>(); + + /** Emits when a node is clicked */ + readonly nodeClicked = output(); + + // UI State + readonly selectedPathId = signal(null); + readonly hoveredNodeId = signal(null); + readonly zoomLevel = signal(1); + readonly panOffset = signal({ x: 0, y: 0 }); + readonly isGraphExpanded = signal(false); + readonly showConfidenceDetails = signal(false); + + // Canvas state + private resizeObserver: ResizeObserver | null = null; + private animationFrameId: number | null = null; + + // Computed + readonly verdict = computed(() => this.explanation().verdict); + + readonly verdictLabel = computed(() => { + switch (this.verdict()) { + case 'reachable': + return 'Reachable'; + case 'unreachable': + return 'Unreachable'; + case 'uncertain': + return 'Uncertain'; + case 'no_data': + return 'No Data'; + default: + return 'Unknown'; + } + }); + + readonly verdictIcon = computed(() => { + switch (this.verdict()) { + case 'reachable': + return '⚠'; + case 'unreachable': + return '✓'; + case 'uncertain': + return '?'; + case 'no_data': + return '○'; + default: + return '?'; + } + }); + + readonly verdictClass = computed(() => `verdict--${this.verdict()}`); + + readonly confidence = computed(() => this.explanation().confidence); + + readonly confidencePercent = computed(() => + Math.round(this.confidence().overallScore * 100) + ); + + readonly confidenceLevel = computed(() => { + const score = this.confidence().overallScore; + if (score >= 0.8) return 'high'; + if (score >= 0.5) return 'medium'; + return 'low'; + }); + + readonly paths = computed(() => this.explanation().paths); + + readonly selectedPath = computed(() => { + const pathId = this.selectedPathId(); + if (!pathId) return this.paths()[0] ?? null; + return this.paths().find(p => p.pathId === pathId) ?? null; + }); + + readonly hasMultiplePaths = computed(() => this.paths().length > 1); + + readonly callGraph = computed(() => this.explanation().callGraph); + + readonly hasCallGraph = computed(() => !!this.callGraph()); + + readonly shortestPathLength = computed(() => + this.explanation().shortestPathLength ?? this.paths()[0]?.pathLength ?? 0 + ); + + readonly entrypointsAnalyzed = computed(() => + this.explanation().entrypointsAnalyzed + ); + + ngAfterViewInit(): void { + this.setupCanvas(); + this.renderGraph(); + } + + ngOnDestroy(): void { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + } + } + + private setupCanvas(): void { + if (!this.graphCanvas?.nativeElement) return; + + const canvas = this.graphCanvas.nativeElement; + this.resizeObserver = new ResizeObserver(() => { + this.renderGraph(); + }); + this.resizeObserver.observe(canvas.parentElement!); + } + + private renderGraph(): void { + if (!this.graphCanvas?.nativeElement) return; + + const canvas = this.graphCanvas.nativeElement; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const path = this.selectedPath(); + if (!path) return; + + // Set canvas size + const rect = canvas.parentElement!.getBoundingClientRect(); + canvas.width = rect.width * window.devicePixelRatio; + canvas.height = Math.max(200, path.steps.length * 60) * window.devicePixelRatio; + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${Math.max(200, path.steps.length * 60)}px`; + + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + + // Clear + ctx.fillStyle = '#fafafa'; + ctx.fillRect(0, 0, rect.width, canvas.height / window.devicePixelRatio); + + // Apply zoom and pan + const zoom = this.zoomLevel(); + const pan = this.panOffset(); + ctx.save(); + ctx.translate(pan.x, pan.y); + ctx.scale(zoom, zoom); + + // Draw path + this.drawPath(ctx, path, rect.width / zoom); + + ctx.restore(); + } + + private drawPath( + ctx: CanvasRenderingContext2D, + path: ReachabilityPath, + width: number + ): void { + const nodeWidth = 180; + const nodeHeight = 50; + const verticalSpacing = 80; + const startX = width / 2 - nodeWidth / 2; + const startY = 30; + + path.steps.forEach((step, index) => { + const x = startX; + const y = startY + index * verticalSpacing; + + // Draw edge to next node + if (index < path.steps.length - 1) { + ctx.beginPath(); + ctx.moveTo(x + nodeWidth / 2, y + nodeHeight); + ctx.lineTo(x + nodeWidth / 2, y + verticalSpacing); + ctx.strokeStyle = '#cbd5e1'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Arrow + const arrowY = y + verticalSpacing - 5; + ctx.beginPath(); + ctx.moveTo(x + nodeWidth / 2 - 6, arrowY - 8); + ctx.lineTo(x + nodeWidth / 2, arrowY); + ctx.lineTo(x + nodeWidth / 2 + 6, arrowY - 8); + ctx.strokeStyle = '#cbd5e1'; + ctx.stroke(); + } + + // Draw node + this.drawNode(ctx, step.node, x, y, nodeWidth, nodeHeight, step.confidence); + }); + } + + private drawNode( + ctx: CanvasRenderingContext2D, + node: CallGraphNode, + x: number, + y: number, + width: number, + height: number, + confidence: number + ): void { + const isVulnerable = node.isVulnerable; + const isEntrypoint = node.isEntrypoint; + const isHovered = this.hoveredNodeId() === node.nodeId; + + // Background + ctx.fillStyle = isVulnerable + ? '#fef2f2' + : isEntrypoint + ? '#f0fdf4' + : isHovered + ? '#f1f5f9' + : '#ffffff'; + ctx.strokeStyle = isVulnerable + ? '#ef4444' + : isEntrypoint + ? '#22c55e' + : '#e2e8f0'; + ctx.lineWidth = isVulnerable || isEntrypoint ? 2 : 1; + + // Rounded rect + const radius = 8; + ctx.beginPath(); + ctx.roundRect(x, y, width, height, radius); + ctx.fill(); + ctx.stroke(); + + // Icon + const icon = isVulnerable ? '⚠' : isEntrypoint ? '▶' : '○'; + ctx.font = '14px system-ui'; + ctx.fillStyle = isVulnerable ? '#ef4444' : isEntrypoint ? '#22c55e' : '#64748b'; + ctx.fillText(icon, x + 10, y + height / 2 + 5); + + // Name + ctx.font = '12px system-ui'; + ctx.fillStyle = '#1e293b'; + const name = this.truncateText(node.name, 18); + ctx.fillText(name, x + 30, y + 20); + + // Qualified name (smaller) + ctx.font = '10px system-ui'; + ctx.fillStyle = '#64748b'; + const qualifiedName = this.truncateText(node.qualifiedName, 24); + ctx.fillText(qualifiedName, x + 30, y + 35); + + // Confidence badge + if (confidence < 1) { + const confPercent = Math.round(confidence * 100); + ctx.font = '10px system-ui'; + ctx.fillStyle = confidence >= 0.8 ? '#16a34a' : confidence >= 0.5 ? '#ca8a04' : '#dc2626'; + ctx.fillText(`${confPercent}%`, x + width - 35, y + 15); + } + } + + private truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength - 3) + '...'; + } + + // User interactions + selectPath(pathId: string): void { + this.selectedPathId.set(pathId); + this.renderGraph(); + } + + toggleConfidenceDetails(): void { + this.showConfidenceDetails.update(v => !v); + } + + toggleGraphExpanded(): void { + this.isGraphExpanded.update(v => !v); + } + + zoomIn(): void { + this.zoomLevel.update(z => Math.min(z + 0.25, 3)); + this.renderGraph(); + } + + zoomOut(): void { + this.zoomLevel.update(z => Math.max(z - 0.25, 0.5)); + this.renderGraph(); + } + + resetView(): void { + this.zoomLevel.set(1); + this.panOffset.set({ x: 0, y: 0 }); + this.renderGraph(); + } + + onExport(format: ExportFormat): void { + const pathId = this.selectedPathId() ?? undefined; + this.exportRequested.emit({ format, pathId }); + } + + onNodeHover(nodeId: string | null): void { + this.hoveredNodeId.set(nodeId); + this.renderGraph(); + } + + onNodeClick(node: CallGraphNode): void { + this.nodeClicked.emit(node); + } + + // Formatting helpers + formatConfidence(score: number): string { + return `${Math.round(score * 100)}%`; + } + + getConfidenceClass(score: number): string { + if (score >= 0.8) return 'confidence--high'; + if (score >= 0.5) return 'confidence--medium'; + return 'confidence--low'; + } + + getFactorContributionWidth(factor: ConfidenceFactor): string { + return `${Math.round(factor.contribution * 100)}%`; + } + + getNodeTypeLabel(node: CallGraphNode): string { + switch (node.type) { + case 'entrypoint': + return 'Entrypoint'; + case 'method': + return 'Method'; + case 'function': + return 'Function'; + case 'class': + return 'Class'; + case 'module': + return 'Module'; + case 'vulnerable': + return 'Vulnerable'; + case 'external': + return 'External'; + default: + return 'Node'; + } + } + + getCallTypeLabel(callType?: string): string { + switch (callType) { + case 'direct': + return 'Direct call'; + case 'indirect': + return 'Indirect call'; + case 'dynamic': + return 'Dynamic dispatch'; + case 'virtual': + return 'Virtual call'; + default: + return 'Call'; + } + } + + // Accessibility + getPathAriaLabel(path: ReachabilityPath): string { + return `Path from ${path.entrypoint.name} to ${path.vulnerable.name}, ${path.pathLength} hops, ${this.formatConfidence(path.overallConfidence)} confidence`; + } + + getStepAriaLabel(step: ReachabilityPathStep): string { + return `Step ${step.stepIndex + 1}: ${step.node.name}, ${this.formatConfidence(step.confidence)} confidence`; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/scores/score-comparison.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/scores/score-comparison.component.spec.ts new file mode 100644 index 000000000..e6380bbe9 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/scores/score-comparison.component.spec.ts @@ -0,0 +1,336 @@ +/** + * Tests for Score Comparison Component + * Sprint: SPRINT_3500_0004_0002 - T8 + */ + +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { of, throwError } from 'rxjs'; +import { ScoreComparisonComponent, SCORE_API } from './score-comparison.component'; + +describe('ScoreComparisonComponent', () => { + let component: ScoreComparisonComponent; + let fixture: ComponentFixture; + let mockScoreApi: jasmine.SpyObj; + + const mockComparison = { + before: { + scanId: 'scan-before', + digest: 'sha256:before123...', + imageRef: 'registry.example.com/app:v1.0.0', + scanTimestamp: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + scores: { + totalVulnerabilities: 55, + critical: 5, + high: 12, + medium: 22, + low: 16, + unknown: 0, + fixable: 35, + reachable: 18, + unreachable: 37, + riskScore: 78.2 + }, + vexApplied: false + }, + after: { + scanId: 'scan-after', + digest: 'sha256:after456...', + imageRef: 'registry.example.com/app:v2.0.0', + scanTimestamp: new Date().toISOString(), + scores: { + totalVulnerabilities: 42, + critical: 2, + high: 8, + medium: 18, + low: 14, + unknown: 0, + fixable: 28, + reachable: 12, + unreachable: 30, + riskScore: 65.5 + }, + vexApplied: true, + vexImpact: { + suppressedCount: 8, + suppressedBySeverity: { critical: 1, high: 3, medium: 4 }, + statements: [] + } + }, + deltas: [ + { field: 'totalVulnerabilities', label: 'Total', before: 55, after: 42, delta: -13, percentChange: -23.6, direction: 'improved' as const }, + { field: 'critical', label: 'Critical', before: 5, after: 2, delta: -3, percentChange: -60, direction: 'improved' as const }, + { field: 'high', label: 'High', before: 12, after: 8, delta: -4, percentChange: -33.3, direction: 'improved' as const }, + { field: 'riskScore', label: 'Risk Score', before: 78.2, after: 65.5, delta: -12.7, percentChange: -16.2, direction: 'improved' as const } + ], + newVulnerabilities: ['CVE-2024-9999', 'CVE-2024-8888'], + resolvedVulnerabilities: ['CVE-2024-1111', 'CVE-2024-2222', 'CVE-2024-3333'] + }; + + const mockTimeSeries = [ + { timestamp: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), riskScore: 80, critical: 6, high: 14, medium: 24, low: 16 }, + { timestamp: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), riskScore: 78, critical: 5, high: 13, medium: 23, low: 16 }, + { timestamp: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(), riskScore: 75, critical: 4, high: 11, medium: 21, low: 15 }, + { timestamp: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), riskScore: 72, critical: 3, high: 10, medium: 20, low: 15 }, + { timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), riskScore: 68, critical: 2, high: 9, medium: 19, low: 14 }, + { timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), riskScore: 66, critical: 2, high: 8, medium: 18, low: 14 }, + { timestamp: new Date().toISOString(), riskScore: 65.5, critical: 2, high: 8, medium: 18, low: 14 } + ]; + + beforeEach(async () => { + mockScoreApi = jasmine.createSpyObj('ScoreApi', ['getScoreSummary', 'compareScores', 'getTimeSeries']); + mockScoreApi.compareScores.and.returnValue(of(mockComparison)); + mockScoreApi.getTimeSeries.and.returnValue(of(mockTimeSeries)); + + await TestBed.configureTestingModule({ + imports: [ScoreComparisonComponent], + providers: [ + { provide: SCORE_API, useValue: mockScoreApi } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ScoreComparisonComponent); + component = fixture.componentInstance; + }); + + describe('Initialization', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load comparison on init', fakeAsync(() => { + fixture.componentRef.setInput('scanIdBefore', 'scan-before'); + fixture.componentRef.setInput('scanIdAfter', 'scan-after'); + fixture.detectChanges(); + tick(); + + expect(mockScoreApi.compareScores).toHaveBeenCalledWith('scan-before', 'scan-after'); + })); + }); + + describe('Side-by-Side View', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanIdBefore', 'scan-before'); + fixture.componentRef.setInput('scanIdAfter', 'scan-after'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display before card', () => { + const beforeCard = fixture.debugElement.query(By.css('.score-comparison__card--before')); + expect(beforeCard).toBeTruthy(); + }); + + it('should display after card', () => { + const afterCard = fixture.debugElement.query(By.css('.score-comparison__card--after')); + expect(afterCard).toBeTruthy(); + }); + + it('should display risk scores', () => { + const riskScores = fixture.debugElement.queryAll(By.css('.score-comparison__risk-value')); + expect(riskScores.length).toBe(2); + expect(riskScores[0].nativeElement.textContent).toContain('78.2'); + expect(riskScores[1].nativeElement.textContent).toContain('65.5'); + }); + + it('should display severity bars', () => { + const severityRows = fixture.debugElement.queryAll(By.css('.score-comparison__severity-row')); + expect(severityRows.length).toBe(8); // 4 per card + }); + + it('should show improvement styling on after card', () => { + const afterRiskScore = fixture.debugElement.query(By.css('.score-comparison__card--after .score-comparison__risk-value')); + expect(afterRiskScore.nativeElement.classList.contains('improved')).toBeTrue(); + }); + }); + + describe('Delta Table', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanIdBefore', 'scan-before'); + fixture.componentRef.setInput('scanIdAfter', 'scan-after'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display delta table', () => { + const table = fixture.debugElement.query(By.css('.score-comparison__table')); + expect(table).toBeTruthy(); + }); + + it('should display all delta rows', () => { + const rows = fixture.debugElement.queryAll(By.css('.score-comparison__table tbody tr')); + expect(rows.length).toBe(4); + }); + + it('should style improved rows', () => { + const improvedRows = fixture.debugElement.queryAll(By.css('.score-comparison__row--improved')); + expect(improvedRows.length).toBeGreaterThan(0); + }); + + it('should display delta values', () => { + const badges = fixture.debugElement.queryAll(By.css('.score-comparison__change-badge')); + expect(badges.length).toBe(4); + }); + }); + + describe('VEX Impact', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanIdBefore', 'scan-before'); + fixture.componentRef.setInput('scanIdAfter', 'scan-after'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display VEX impact section', () => { + const vexSection = fixture.debugElement.query(By.css('.score-comparison__vex-summary')); + expect(vexSection).toBeTruthy(); + }); + + it('should display suppressed count', () => { + const vexValue = fixture.debugElement.query(By.css('.score-comparison__vex-value')); + expect(vexValue.nativeElement.textContent).toContain('8'); + }); + + it('should display VEX breakdown by severity', () => { + const vexItems = fixture.debugElement.queryAll(By.css('.score-comparison__vex-item')); + expect(vexItems.length).toBe(3); // critical, high, medium + }); + }); + + describe('Vulnerability Changes', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanIdBefore', 'scan-before'); + fixture.componentRef.setInput('scanIdAfter', 'scan-after'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display new vulnerabilities section', () => { + const newSection = fixture.debugElement.query(By.css('.score-comparison__vuln-group--new')); + expect(newSection).toBeTruthy(); + }); + + it('should display resolved vulnerabilities section', () => { + const resolvedSection = fixture.debugElement.query(By.css('.score-comparison__vuln-group--resolved')); + expect(resolvedSection).toBeTruthy(); + }); + + it('should list new CVEs', () => { + const newItems = fixture.debugElement.queryAll(By.css('.score-comparison__vuln-group--new li')); + expect(newItems.length).toBe(2); + }); + + it('should list resolved CVEs', () => { + const resolvedItems = fixture.debugElement.queryAll(By.css('.score-comparison__vuln-group--resolved li')); + expect(resolvedItems.length).toBe(3); + }); + }); + + describe('View Mode Toggle', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanIdBefore', 'scan-before'); + fixture.componentRef.setInput('scanIdAfter', 'scan-after'); + fixture.componentRef.setInput('imageRef', 'registry.example.com/app'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have view mode toggle buttons', () => { + const toggles = fixture.debugElement.queryAll(By.css('.score-comparison__toggle')); + expect(toggles.length).toBe(2); + }); + + it('should default to side-by-side view', () => { + expect(component.viewMode()).toBe('side-by-side'); + }); + + it('should switch to timeline view', fakeAsync(() => { + const timelineToggle = fixture.debugElement.queryAll(By.css('.score-comparison__toggle'))[1]; + timelineToggle.nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(component.viewMode()).toBe('timeline'); + })); + }); + + describe('Timeline View', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanIdBefore', 'scan-before'); + fixture.componentRef.setInput('scanIdAfter', 'scan-after'); + fixture.componentRef.setInput('imageRef', 'registry.example.com/app'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + component.setViewMode('timeline'); + fixture.detectChanges(); + })); + + it('should display chart container', () => { + const chart = fixture.debugElement.query(By.css('.score-comparison__chart')); + expect(chart).toBeTruthy(); + }); + + it('should display SVG chart', () => { + const svg = fixture.debugElement.query(By.css('.score-comparison__chart-svg')); + expect(svg).toBeTruthy(); + }); + + it('should display legend', () => { + const legend = fixture.debugElement.query(By.css('.score-comparison__legend')); + expect(legend).toBeTruthy(); + }); + + it('should render data points', () => { + const points = fixture.debugElement.queryAll(By.css('.score-comparison__chart-point')); + expect(points.length).toBe(7); + }); + }); + + describe('Error Handling', () => { + it('should display error on API failure', fakeAsync(() => { + mockScoreApi.compareScores.and.returnValue(throwError(() => new Error('API Error'))); + + fixture.componentRef.setInput('scanIdBefore', 'scan-before'); + fixture.componentRef.setInput('scanIdAfter', 'scan-after'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const error = fixture.debugElement.query(By.css('.score-comparison__error')); + expect(error).toBeTruthy(); + })); + }); + + describe('Accessibility', () => { + beforeEach(fakeAsync(() => { + fixture.componentRef.setInput('scanIdBefore', 'scan-before'); + fixture.componentRef.setInput('scanIdAfter', 'scan-after'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have proper table structure', () => { + const table = fixture.debugElement.query(By.css('table[role="grid"]')); + expect(table).toBeTruthy(); + }); + + it('should have accessible headings', () => { + const headings = fixture.debugElement.queryAll(By.css('h3, h4, h5')); + expect(headings.length).toBeGreaterThan(0); + }); + + it('should have proper color contrast indicators', () => { + // Check that improved/worsened classes exist for color-blind users + const improvedElements = fixture.debugElement.queryAll(By.css('.improved')); + expect(improvedElements.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/scores/score-comparison.component.ts b/src/Web/StellaOps.Web/src/app/features/scores/score-comparison.component.ts new file mode 100644 index 000000000..788ce4af8 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/scores/score-comparison.component.ts @@ -0,0 +1,1021 @@ +/** + * Score Comparison View Component + * Sprint: SPRINT_3500_0004_0002 - T4 + * + * Side-by-side comparison of scan scores with highlighting for changes. + * Supports VEX impact visualization and time-series display. + */ + +import { Component, input, output, computed, signal, inject, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +// ============================================================================ +// Models +// ============================================================================ + +export interface ScoreSummary { + readonly scanId: string; + readonly digest: string; + readonly imageRef: string; + readonly scanTimestamp: string; + readonly scores: ScoreMetrics; + readonly vexApplied: boolean; + readonly vexImpact?: VexImpact; +} + +export interface ScoreMetrics { + readonly totalVulnerabilities: number; + readonly critical: number; + readonly high: number; + readonly medium: number; + readonly low: number; + readonly unknown: number; + readonly fixable: number; + readonly reachable: number; + readonly unreachable: number; + readonly riskScore: number; +} + +export interface VexImpact { + readonly suppressedCount: number; + readonly suppressedBySeverity: Record; + readonly statements: VexStatement[]; +} + +export interface VexStatement { + readonly cveId: string; + readonly status: 'not_affected' | 'affected' | 'fixed' | 'under_investigation'; + readonly justification?: string; + readonly impact?: string; +} + +export interface ScoreDelta { + readonly field: string; + readonly label: string; + readonly before: number; + readonly after: number; + readonly delta: number; + readonly percentChange: number; + readonly direction: 'improved' | 'worsened' | 'unchanged'; +} + +export interface TimeSeriesPoint { + readonly timestamp: string; + readonly riskScore: number; + readonly critical: number; + readonly high: number; + readonly medium: number; + readonly low: number; +} + +// ============================================================================ +// Injection Token & API +// ============================================================================ + +import { InjectionToken } from '@angular/core'; +import { Observable, of, delay } from 'rxjs'; + +export const SCORE_API = new InjectionToken('SCORE_API'); + +export interface ScoreApi { + getScoreSummary(scanId: string): Observable; + compareScores(scanIdA: string, scanIdB: string): Observable; + getTimeSeries(imageRef: string, fromDate: string, toDate: string): Observable; +} + +export interface ScoreComparison { + readonly before: ScoreSummary; + readonly after: ScoreSummary; + readonly deltas: ScoreDelta[]; + readonly newVulnerabilities: string[]; + readonly resolvedVulnerabilities: string[]; +} + +// ============================================================================ +// Component +// ============================================================================ + +@Component({ + selector: 'stella-score-comparison', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+

+ + Score Comparison +

+
+ + +
+
+ + + @if (loading()) { +
+ + Loading comparison data... +
+ } + + + @if (error()) { + + } + + + @if (!loading() && comparison() && viewMode() === 'side-by-side') { +
+ +
+ +
+
+ Before + + {{ formatDate(comparison()!.before.scanTimestamp) }} + +
+
+ {{ comparison()!.before.digest.slice(0, 16) }}... +
+
+ + {{ comparison()!.before.scores.riskScore.toFixed(1) }} + + Risk Score +
+
+ @for (sev of severities; track sev.key) { +
+ + {{ sev.label }} + +
+
+
+ + {{ getSeverityCount(comparison()!.before.scores, sev.key) }} + +
+ } +
+
+ + + + + +
+
+ After + + {{ formatDate(comparison()!.after.scanTimestamp) }} + +
+
+ {{ comparison()!.after.digest.slice(0, 16) }}... +
+
+ + {{ comparison()!.after.scores.riskScore.toFixed(1) }} + + Risk Score +
+
+ @for (sev of severities; track sev.key) { +
+ + {{ sev.label }} + +
+
+
+ + {{ getSeverityCount(comparison()!.after.scores, sev.key) }} + @if (getSeverityDelta(sev.key) !== 0) { + + {{ getSeverityDelta(sev.key) > 0 ? '+' : '' }}{{ getSeverityDelta(sev.key) }} + + } + +
+ } +
+
+
+ + +
+

+ Changes +

+ + + + + + + + + + + @for (delta of comparison()!.deltas; track delta.field) { + + + + + + + } + +
MetricBeforeAfterChange
{{ delta.label }}{{ delta.before }}{{ delta.after }} + + @if (delta.delta > 0) { +{{ delta.delta }} } + @else if (delta.delta < 0) { {{ delta.delta }} } + @else { — } + +
+
+ + + @if (comparison()!.after.vexApplied && comparison()!.after.vexImpact) { +
+

+ VEX Impact +

+
+
+ + {{ comparison()!.after.vexImpact!.suppressedCount }} + + Findings Suppressed +
+
+
+ @for (sev of getVexSuppressedSeverities(); track sev.severity) { +
+ + {{ sev.severity }} + + {{ sev.count }} +
+ } +
+
+ } + + + @if (comparison()!.newVulnerabilities.length > 0 || comparison()!.resolvedVulnerabilities.length > 0) { +
+

+ Vulnerability Changes +

+
+ @if (comparison()!.newVulnerabilities.length > 0) { +
+
New ({{ comparison()!.newVulnerabilities.length }})
+
    + @for (cve of comparison()!.newVulnerabilities.slice(0, 10); track cve) { +
  • + {{ cve }} +
  • + } + @if (comparison()!.newVulnerabilities.length > 10) { +
  • + +{{ comparison()!.newVulnerabilities.length - 10 }} more +
  • + } +
+
+ } + @if (comparison()!.resolvedVulnerabilities.length > 0) { +
+
Resolved ({{ comparison()!.resolvedVulnerabilities.length }})
+
    + @for (cve of comparison()!.resolvedVulnerabilities.slice(0, 10); track cve) { +
  • + {{ cve }} +
  • + } + @if (comparison()!.resolvedVulnerabilities.length > 10) { +
  • + +{{ comparison()!.resolvedVulnerabilities.length - 10 }} more +
  • + } +
+
+ } +
+
+ } +
+ } + + + @if (!loading() && timeSeries().length > 0 && viewMode() === 'timeline') { +
+
+ + + + @for (tick of yAxisTicks; track tick) { + + + {{ tick }} + + } + + + + @for (series of chartSeries; track series.key) { + + } + + + @for (point of timeSeries(); track point.timestamp; let i = $index) { + + } + + + + @for (point of timeSeries(); track point.timestamp; let i = $index) { + @if (i % Math.ceil(timeSeries().length / 5) === 0) { + + {{ formatShortDate(point.timestamp) }} + + } + } + + +
+ + +
+ @for (series of chartSeries; track series.key) { +
+ + {{ series.label }} +
+ } +
+
+ } +
+ `, + styles: [` + .score-comparison { + background: var(--surface-card, #fff); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + padding: 1.5rem; + } + + .score-comparison--loading { + opacity: 0.7; + } + + .score-comparison__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + } + + .score-comparison__title { + margin: 0; + font-size: 1.25rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .score-comparison__actions { + display: flex; + gap: 0.5rem; + } + + .score-comparison__toggle { + padding: 0.5rem 1rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + background: white; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s; + } + + .score-comparison__toggle:hover { + background: var(--surface-hover, #f5f5f5); + } + + .score-comparison__toggle--active { + background: var(--primary, #3b82f6); + color: white; + border-color: var(--primary, #3b82f6); + } + + .score-comparison__loading { + text-align: center; + padding: 2rem; + color: var(--text-secondary, #666); + } + + .score-comparison__spinner { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin-right: 0.5rem; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .score-comparison__error { + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + padding: 1rem; + border-radius: 4px; + } + + /* Cards */ + .score-comparison__cards { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 1rem; + margin-bottom: 1.5rem; + } + + .score-comparison__card { + padding: 1rem; + border-radius: 8px; + border: 1px solid var(--border-color, #e0e0e0); + } + + .score-comparison__card--before { + background: var(--surface-secondary, #f5f5f5); + } + + .score-comparison__card--after { + background: var(--surface-card, #fff); + } + + .score-comparison__card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + } + + .score-comparison__card-label { + font-weight: 600; + font-size: 0.875rem; + } + + .score-comparison__card-date { + font-size: 0.75rem; + color: var(--text-secondary, #666); + } + + .score-comparison__card-digest { + margin-bottom: 1rem; + } + + .score-comparison__card-digest code { + font-size: 0.75rem; + background: var(--surface-secondary, #f0f0f0); + padding: 0.125rem 0.25rem; + border-radius: 2px; + } + + .score-comparison__risk-score { + text-align: center; + margin-bottom: 1rem; + } + + .score-comparison__risk-value { + display: block; + font-size: 2.5rem; + font-weight: 700; + } + + .score-comparison__risk-value.improved { + color: var(--success, #22c55e); + } + + .score-comparison__risk-value.worsened { + color: var(--error, #ef4444); + } + + .score-comparison__risk-label { + font-size: 0.75rem; + color: var(--text-secondary, #666); + } + + .score-comparison__delta-arrow { + display: flex; + align-items: center; + justify-content: center; + } + + .score-comparison__arrow { + font-size: 2rem; + color: var(--border-color, #ccc); + } + + /* Severity Bars */ + .score-comparison__severity-bars { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .score-comparison__severity-row { + display: grid; + grid-template-columns: 60px 1fr 50px; + align-items: center; + gap: 0.5rem; + } + + .score-comparison__severity-label { + font-size: 0.75rem; + font-weight: 500; + } + + .score-comparison__severity-bar-container { + height: 12px; + background: var(--surface-secondary, #f0f0f0); + border-radius: 6px; + overflow: hidden; + } + + .score-comparison__severity-bar { + height: 100%; + border-radius: 6px; + transition: width 0.3s ease; + } + + .score-comparison__severity-count { + font-size: 0.875rem; + text-align: right; + } + + .score-comparison__severity-count.improved { + color: var(--success, #22c55e); + } + + .score-comparison__severity-count.worsened { + color: var(--error, #ef4444); + } + + .score-comparison__delta-indicator { + font-size: 0.625rem; + margin-left: 0.25rem; + } + + /* Section */ + .score-comparison__section { + margin-bottom: 1.5rem; + } + + .score-comparison__section-title { + font-size: 1rem; + margin: 0 0 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + /* Table */ + .score-comparison__table { + width: 100%; + border-collapse: collapse; + } + + .score-comparison__table th, + .score-comparison__table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color, #e0e0e0); + } + + .score-comparison__table th { + font-weight: 600; + font-size: 0.875rem; + background: var(--surface-secondary, #f5f5f5); + } + + .score-comparison__row--improved { + background: var(--success-bg, #d1fae5); + } + + .score-comparison__row--worsened { + background: var(--error-bg, #fef2f2); + } + + .score-comparison__change-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + } + + .score-comparison__row--improved .score-comparison__change-badge { + background: var(--success, #22c55e); + color: white; + } + + .score-comparison__row--worsened .score-comparison__change-badge { + background: var(--error, #ef4444); + color: white; + } + + /* VEX Impact */ + .score-comparison__vex-summary { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + } + + .score-comparison__vex-stat { + text-align: center; + padding: 1rem; + background: var(--surface-secondary, #f5f5f5); + border-radius: 8px; + } + + .score-comparison__vex-value { + display: block; + font-size: 2rem; + font-weight: 700; + color: var(--primary, #3b82f6); + } + + .score-comparison__vex-label { + font-size: 0.75rem; + color: var(--text-secondary, #666); + } + + .score-comparison__vex-breakdown { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + + .score-comparison__vex-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 4px; + } + + .score-comparison__vex-severity { + padding: 0.125rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + } + + .score-comparison__vex-severity.severity-critical { + background: #fee2e2; + color: #dc2626; + } + + .score-comparison__vex-severity.severity-high { + background: #ffedd5; + color: #ea580c; + } + + .score-comparison__vex-severity.severity-medium { + background: #fef3c7; + color: #d97706; + } + + .score-comparison__vex-severity.severity-low { + background: #d1fae5; + color: #059669; + } + + /* Vulnerability Changes */ + .score-comparison__vuln-changes { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + } + + .score-comparison__vuln-group h5 { + margin: 0 0 0.5rem; + font-size: 0.875rem; + } + + .score-comparison__vuln-group--new h5 { + color: var(--error, #ef4444); + } + + .score-comparison__vuln-group--resolved h5 { + color: var(--success, #22c55e); + } + + .score-comparison__vuln-group ul { + margin: 0; + padding: 0; + list-style: none; + } + + .score-comparison__vuln-group li { + padding: 0.25rem 0; + font-size: 0.875rem; + } + + .score-comparison__vuln-more { + color: var(--text-secondary, #666); + font-style: italic; + } + + /* Timeline View */ + .score-comparison__timeline { + padding: 1rem; + } + + .score-comparison__chart { + width: 100%; + margin-bottom: 1rem; + } + + .score-comparison__chart-svg { + width: 100%; + height: auto; + } + + .score-comparison__legend { + display: flex; + justify-content: center; + gap: 1.5rem; + } + + .score-comparison__legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .score-comparison__legend-color { + width: 12px; + height: 12px; + border-radius: 2px; + } + + .score-comparison__legend-label { + font-size: 0.875rem; + } + + /* Responsive */ + @media (max-width: 768px) { + .score-comparison__cards { + grid-template-columns: 1fr; + } + + .score-comparison__delta-arrow { + transform: rotate(90deg); + } + + .score-comparison__vuln-changes { + grid-template-columns: 1fr; + } + } + `] +}) +export class ScoreComparisonComponent implements OnInit { + // Inputs + readonly scanIdBefore = input.required(); + readonly scanIdAfter = input.required(); + readonly imageRef = input(); + + // Outputs + readonly cveSelected = output(); + + // Services - using mock for now + private readonly scoreApi = inject(SCORE_API); + + // State + readonly loading = signal(true); + readonly error = signal(null); + readonly comparison = signal(null); + readonly timeSeries = signal([]); + readonly viewMode = signal<'side-by-side' | 'timeline'>('side-by-side'); + + // Static data + readonly severities = [ + { key: 'critical', label: 'Critical', color: '#dc2626' }, + { key: 'high', label: 'High', color: '#ea580c' }, + { key: 'medium', label: 'Medium', color: '#d97706' }, + { key: 'low', label: 'Low', color: '#059669' } + ]; + + readonly chartSeries = [ + { key: 'riskScore', label: 'Risk Score', color: '#3b82f6' }, + { key: 'critical', label: 'Critical', color: '#dc2626' }, + { key: 'high', label: 'High', color: '#ea580c' } + ]; + + readonly yAxisTicks = [0, 25, 50, 75, 100]; + + protected readonly Math = Math; + + ngOnInit(): void { + this.loadComparison(); + } + + private loadComparison(): void { + this.loading.set(true); + this.error.set(null); + + this.scoreApi.compareScores(this.scanIdBefore(), this.scanIdAfter()).subscribe({ + next: (comparison) => { + this.comparison.set(comparison); + this.loading.set(false); + // Also load time series if imageRef provided + if (this.imageRef()) { + this.loadTimeSeries(); + } + }, + error: (err) => { + this.error.set('Failed to load comparison: ' + err.message); + this.loading.set(false); + } + }); + } + + private loadTimeSeries(): void { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + this.scoreApi.getTimeSeries( + this.imageRef()!, + thirtyDaysAgo.toISOString(), + now.toISOString() + ).subscribe({ + next: (series) => { + this.timeSeries.set(series); + } + }); + } + + setViewMode(mode: 'side-by-side' | 'timeline'): void { + this.viewMode.set(mode); + } + + formatDate(timestamp: string): string { + return new Date(timestamp).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + formatShortDate(timestamp: string): string { + return new Date(timestamp).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric' + }); + } + + getSeverityPercent(scores: ScoreMetrics, key: string): number { + const total = scores.totalVulnerabilities || 1; + const count = (scores as Record)[key] || 0; + return (count / total) * 100; + } + + getSeverityCount(scores: ScoreMetrics, key: string): number { + return (scores as Record)[key] || 0; + } + + getSeverityDelta(key: string): number { + const comp = this.comparison(); + if (!comp) return 0; + const before = this.getSeverityCount(comp.before.scores, key); + const after = this.getSeverityCount(comp.after.scores, key); + return after - before; + } + + getSeverityChangeClass(key: string): string { + const delta = this.getSeverityDelta(key); + if (delta < 0) return 'improved'; + if (delta > 0) return 'worsened'; + return ''; + } + + getRiskScoreChangeClass(): string { + const comp = this.comparison(); + if (!comp) return ''; + const delta = comp.after.scores.riskScore - comp.before.scores.riskScore; + if (delta < 0) return 'improved'; + if (delta > 0) return 'worsened'; + return ''; + } + + getVexSuppressedSeverities(): { severity: string; count: number }[] { + const comp = this.comparison(); + if (!comp?.after.vexImpact) return []; + return Object.entries(comp.after.vexImpact.suppressedBySeverity).map(([severity, count]) => ({ + severity, + count + })); + } + + // Chart helpers + getYPosition(value: number): number { + // Map value (0-100) to Y coordinate (250 to 20) + return 250 - (value / 100) * 230 + 20; + } + + getXPosition(index: number): number { + const series = this.timeSeries(); + if (series.length <= 1) return 400; + const spacing = 700 / (series.length - 1); + return 50 + index * spacing; + } + + getSeriesPoints(key: string): string { + return this.timeSeries() + .map((point, i) => { + const value = (point as Record)[key] || 0; + return `${this.getXPosition(i)},${this.getYPosition(value)}`; + }) + .join(' '); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/unknowns/index.ts b/src/Web/StellaOps.Web/src/app/features/unknowns/index.ts new file mode 100644 index 000000000..55e1cfd88 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/unknowns/index.ts @@ -0,0 +1,6 @@ +/** + * Unknowns feature module exports. + * Sprint 3500.0004.0002 + */ + +export { UnknownsQueueComponent } from './unknowns-queue.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-queue.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-queue.component.spec.ts new file mode 100644 index 000000000..97b9bf989 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-queue.component.spec.ts @@ -0,0 +1,368 @@ +/** + * Tests for Unknowns Queue Component + * Sprint: SPRINT_3500_0004_0002 - T8 + */ + +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { of, throwError } from 'rxjs'; +import { UnknownsQueueComponent } from './unknowns-queue.component'; +import { UNKNOWNS_API } from '../../core/api/unknowns.client'; + +describe('UnknownsQueueComponent', () => { + let component: UnknownsQueueComponent; + let fixture: ComponentFixture; + let mockUnknownsApi: jasmine.SpyObj; + + const mockUnknowns = { + items: [ + { + unknownId: 'unk-001', + purl: 'pkg:npm/lodash@4.17.21', + name: 'lodash', + version: '4.17.21', + ecosystem: 'npm', + band: 'HOT' as const, + firstSeen: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + lastSeen: new Date().toISOString(), + occurrenceCount: 15, + affectedScans: 8, + status: 'pending' as const + }, + { + unknownId: 'unk-002', + purl: 'pkg:pypi/requests@2.28.0', + name: 'requests', + version: '2.28.0', + ecosystem: 'pypi', + band: 'WARM' as const, + firstSeen: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + lastSeen: new Date().toISOString(), + occurrenceCount: 5, + affectedScans: 3, + status: 'pending' as const + }, + { + unknownId: 'unk-003', + purl: 'pkg:maven/com.example/old-lib@1.0.0', + name: 'old-lib', + version: '1.0.0', + ecosystem: 'maven', + band: 'COLD' as const, + firstSeen: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + lastSeen: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), + occurrenceCount: 2, + affectedScans: 1, + status: 'pending' as const + } + ], + totalCount: 3, + pageSize: 20, + pageNumber: 1 + }; + + beforeEach(async () => { + mockUnknownsApi = jasmine.createSpyObj('UnknownsApi', [ + 'getUnknowns', + 'escalateUnknown', + 'resolveUnknown', + 'bulkEscalate', + 'bulkResolve' + ]); + + mockUnknownsApi.getUnknowns.and.returnValue(of(mockUnknowns)); + + await TestBed.configureTestingModule({ + imports: [UnknownsQueueComponent], + providers: [ + { provide: UNKNOWNS_API, useValue: mockUnknownsApi } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(UnknownsQueueComponent); + component = fixture.componentInstance; + }); + + describe('Initialization', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show loading state initially', () => { + fixture.detectChanges(); + + const loading = fixture.debugElement.query(By.css('.unknowns-queue__loading')); + expect(loading).toBeTruthy(); + }); + + it('should load unknowns on init', fakeAsync(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(mockUnknownsApi.getUnknowns).toHaveBeenCalled(); + })); + + it('should display unknowns after loading', fakeAsync(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const rows = fixture.debugElement.queryAll(By.css('.unknowns-queue__row')); + expect(rows.length).toBe(3); + })); + }); + + describe('Band Tabs', () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display all band tabs', () => { + const tabs = fixture.debugElement.queryAll(By.css('.unknowns-queue__tab')); + expect(tabs.length).toBe(4); // All, HOT, WARM, COLD + }); + + it('should filter by band when tab clicked', fakeAsync(() => { + const hotTab = fixture.debugElement.queryAll(By.css('.unknowns-queue__tab'))[1]; + hotTab.nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(mockUnknownsApi.getUnknowns).toHaveBeenCalledWith(jasmine.objectContaining({ + band: 'HOT' + })); + })); + + it('should show active state on selected tab', fakeAsync(() => { + const tabs = fixture.debugElement.queryAll(By.css('.unknowns-queue__tab')); + tabs[1].nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(tabs[1].nativeElement.classList.contains('unknowns-queue__tab--active')).toBeTrue(); + })); + }); + + describe('Search and Filter', () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have search input', () => { + const searchInput = fixture.debugElement.query(By.css('.unknowns-queue__search')); + expect(searchInput).toBeTruthy(); + }); + + it('should filter on search input', fakeAsync(() => { + const searchInput = fixture.debugElement.query(By.css('.unknowns-queue__search input')); + searchInput.nativeElement.value = 'lodash'; + searchInput.nativeElement.dispatchEvent(new Event('input')); + tick(300); // debounce + fixture.detectChanges(); + + expect(mockUnknownsApi.getUnknowns).toHaveBeenCalledWith(jasmine.objectContaining({ + search: 'lodash' + })); + })); + + it('should have ecosystem filter', () => { + const ecosystemFilter = fixture.debugElement.query(By.css('.unknowns-queue__filter select')); + expect(ecosystemFilter).toBeTruthy(); + }); + }); + + describe('Selection', () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have select all checkbox', () => { + const selectAll = fixture.debugElement.query(By.css('.unknowns-queue__select-all')); + expect(selectAll).toBeTruthy(); + }); + + it('should select all when checkbox clicked', fakeAsync(() => { + const selectAll = fixture.debugElement.query(By.css('.unknowns-queue__select-all input')); + selectAll.nativeElement.click(); + tick(); + fixture.detectChanges(); + + const checkedBoxes = fixture.debugElement.queryAll(By.css('.unknowns-queue__row-checkbox:checked')); + expect(checkedBoxes.length).toBe(3); + })); + + it('should enable bulk actions when items selected', fakeAsync(() => { + const checkbox = fixture.debugElement.query(By.css('.unknowns-queue__row-checkbox')); + checkbox.nativeElement.click(); + tick(); + fixture.detectChanges(); + + const bulkActions = fixture.debugElement.query(By.css('.unknowns-queue__bulk-actions')); + expect(bulkActions).toBeTruthy(); + })); + }); + + describe('Actions', () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have escalate button for each row', () => { + const escalateBtns = fixture.debugElement.queryAll(By.css('.unknowns-queue__escalate-btn')); + expect(escalateBtns.length).toBe(3); + }); + + it('should have resolve button for each row', () => { + const resolveBtns = fixture.debugElement.queryAll(By.css('.unknowns-queue__resolve-btn')); + expect(resolveBtns.length).toBe(3); + }); + + it('should call escalate API when button clicked', fakeAsync(() => { + mockUnknownsApi.escalateUnknown.and.returnValue(of({ success: true })); + + const escalateBtn = fixture.debugElement.query(By.css('.unknowns-queue__escalate-btn')); + escalateBtn.nativeElement.click(); + tick(); + + expect(mockUnknownsApi.escalateUnknown).toHaveBeenCalledWith('unk-001'); + })); + + it('should call resolve API when button clicked', fakeAsync(() => { + mockUnknownsApi.resolveUnknown.and.returnValue(of({ success: true })); + + const resolveBtn = fixture.debugElement.query(By.css('.unknowns-queue__resolve-btn')); + resolveBtn.nativeElement.click(); + tick(); + + expect(mockUnknownsApi.resolveUnknown).toHaveBeenCalledWith('unk-001', jasmine.any(Object)); + })); + }); + + describe('Bulk Actions', () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + // Select all items + const selectAll = fixture.debugElement.query(By.css('.unknowns-queue__select-all input')); + selectAll.nativeElement.click(); + tick(); + fixture.detectChanges(); + })); + + it('should perform bulk escalate', fakeAsync(() => { + mockUnknownsApi.bulkEscalate.and.returnValue(of({ success: true })); + + const bulkEscalate = fixture.debugElement.query(By.css('.unknowns-queue__bulk-escalate')); + bulkEscalate.nativeElement.click(); + tick(); + + expect(mockUnknownsApi.bulkEscalate).toHaveBeenCalledWith(['unk-001', 'unk-002', 'unk-003']); + })); + + it('should perform bulk resolve', fakeAsync(() => { + mockUnknownsApi.bulkResolve.and.returnValue(of({ success: true })); + + const bulkResolve = fixture.debugElement.query(By.css('.unknowns-queue__bulk-resolve')); + bulkResolve.nativeElement.click(); + tick(); + + expect(mockUnknownsApi.bulkResolve).toHaveBeenCalled(); + })); + }); + + describe('Pagination', () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should display pagination controls', () => { + const pagination = fixture.debugElement.query(By.css('.unknowns-queue__pagination')); + expect(pagination).toBeTruthy(); + }); + + it('should show total count', () => { + const totalCount = fixture.debugElement.query(By.css('.unknowns-queue__total-count')); + expect(totalCount.nativeElement.textContent).toContain('3'); + }); + }); + + describe('Auto Refresh', () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have auto-refresh toggle', () => { + const autoRefresh = fixture.debugElement.query(By.css('.unknowns-queue__auto-refresh')); + expect(autoRefresh).toBeTruthy(); + }); + }); + + describe('Error Handling', () => { + it('should display error when API fails', fakeAsync(() => { + mockUnknownsApi.getUnknowns.and.returnValue(throwError(() => new Error('API Error'))); + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const error = fixture.debugElement.query(By.css('.unknowns-queue__error')); + expect(error).toBeTruthy(); + })); + }); + + describe('Accessibility', () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have proper table structure', () => { + const table = fixture.debugElement.query(By.css('table')); + expect(table).toBeTruthy(); + + const headers = fixture.debugElement.queryAll(By.css('th')); + expect(headers.length).toBeGreaterThan(0); + }); + + it('should have role="tablist" on band tabs', () => { + const tablist = fixture.debugElement.query(By.css('[role="tablist"]')); + expect(tablist).toBeTruthy(); + }); + + it('should have role="tab" on each tab', () => { + const tabs = fixture.debugElement.queryAll(By.css('[role="tab"]')); + expect(tabs.length).toBe(4); + }); + + it('should have aria-selected on active tab', () => { + const activeTab = fixture.debugElement.query(By.css('[aria-selected="true"]')); + expect(activeTab).toBeTruthy(); + }); + + it('should have labels for checkboxes', () => { + const checkboxes = fixture.debugElement.queryAll(By.css('input[type="checkbox"]')); + checkboxes.forEach(checkbox => { + const hasLabel = checkbox.nativeElement.hasAttribute('aria-label') || + checkbox.nativeElement.hasAttribute('aria-labelledby') || + checkbox.nativeElement.id; + expect(hasLabel).toBeTruthy(); + }); + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-queue.component.ts new file mode 100644 index 000000000..dd5af07fa --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-queue.component.ts @@ -0,0 +1,993 @@ +/** + * Unknowns Queue Component + * Sprint: SPRINT_3500_0004_0002 - T2 + * + * Manages unknown packages with band-based prioritization (HOT/WARM/COLD). + * Supports bulk actions, filtering, and real-time updates. + */ + +import { Component, input, output, computed, signal, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Subject, takeUntil, interval } from 'rxjs'; +import { UNKNOWNS_API, UnknownsApi } from '../../core/api/unknowns.client'; +import { + UnknownEntry, + UnknownsFilter, + UnknownsListResponse, + UnknownsSummary, + UnknownBand, + EscalateUnknownRequest, + ResolveUnknownRequest +} from '../../core/api/unknowns.models'; + +type TabId = 'hot' | 'warm' | 'cold' | 'all'; +type SortField = 'rank' | 'age' | 'occurrenceCount'; +type SortDirection = 'asc' | 'desc'; + +interface SortConfig { + field: SortField; + direction: SortDirection; +} + +@Component({ + selector: 'stella-unknowns-queue', + standalone: true, + imports: [CommonModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+

+ + Unknowns Queue +

+
+ + {{ summary()!.total }} Total + + + 🔴 {{ summary()!.hotCount }} Hot + + + 🟡 {{ summary()!.warmCount }} Warm + + + 🔵 {{ summary()!.coldCount }} Cold + +
+
+ + + + + +
+ +
+ + +
+ + +
+ + + +
+ + +
+ {{ selectedIds().size }} selected + + +
+
+ + + @if (loading()) { +
+ + Loading unknowns... +
+ } + + + @if (error()) { + + } + + + @if (!loading() && filteredUnknowns().length > 0) { +
+ +
+ + +
+ + + @for (unknown of filteredUnknowns(); track unknown.unknownId) { +
+
+ +
+ +
+ + {{ getBandIcon(unknown.band) }} + +
+ +
+
+ {{ unknown.package.name }} + @{{ unknown.package.version }} +
+
+ + Ecosystem: + {{ unknown.package.ecosystem }} + + + Occurrences: + {{ unknown.occurrenceCount }} + + + Age: + {{ unknown.ageInDays }}d + +
+ @if (unknown.relatedCves && unknown.relatedCves.length > 0) { +
+ Related CVEs: + @for (cve of unknown.relatedCves.slice(0, 3); track cve) { + {{ cve }} + } + @if (unknown.relatedCves.length > 3) { + +{{ unknown.relatedCves.length - 3 }} more + } +
+ } +
+ +
+ + {{ unknown.status }} + +
+ +
+ + + +
+
+ } + + + @if (totalPages() > 1) { + + } +
+ } + + + @if (!loading() && filteredUnknowns().length === 0 && !error()) { +
+ +

No unknowns in this queue!

+
+ } +
+ `, + styles: [` + .unknowns-queue { + background: var(--surface-card, #fff); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .unknowns-queue--loading { + opacity: 0.7; + } + + .unknowns-queue__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + } + + .unknowns-queue__title { + margin: 0; + font-size: 1.25rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .unknowns-queue__stats { + display: flex; + gap: 1rem; + } + + .unknowns-queue__stat { + font-size: 0.875rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: var(--surface-secondary, #f5f5f5); + } + + .unknowns-queue__tabs { + display: flex; + border-bottom: 1px solid var(--border-color, #e0e0e0); + } + + .unknowns-queue__tab { + padding: 0.75rem 1.5rem; + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 2px solid transparent; + color: var(--text-secondary, #666); + transition: all 0.2s; + } + + .unknowns-queue__tab:hover { + background: var(--surface-hover, #f5f5f5); + } + + .unknowns-queue__tab--active { + border-bottom-color: var(--primary, #3b82f6); + color: var(--primary, #3b82f6); + font-weight: 500; + } + + .unknowns-queue__tab-count { + font-size: 0.75rem; + opacity: 0.8; + } + + .unknowns-queue__toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1.5rem; + gap: 1rem; + background: var(--surface-secondary, #f9f9f9); + flex-wrap: wrap; + } + + .unknowns-queue__filters { + display: flex; + gap: 0.5rem; + } + + .unknowns-queue__search { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + min-width: 200px; + } + + .unknowns-queue__filter-select, + .unknowns-queue__sort-select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + background: white; + } + + .unknowns-queue__sort { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .unknowns-queue__sort-label { + font-size: 0.875rem; + color: var(--text-secondary, #666); + } + + .unknowns-queue__sort-dir { + padding: 0.5rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + background: white; + cursor: pointer; + } + + .unknowns-queue__bulk-actions { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .unknowns-queue__selected-count { + font-size: 0.875rem; + color: var(--text-secondary, #666); + } + + .unknowns-queue__bulk-btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; + } + + .unknowns-queue__bulk-btn--escalate { + background: var(--warning-bg, #fef3c7); + color: var(--warning-text, #d97706); + } + + .unknowns-queue__bulk-btn--resolve { + background: var(--success-bg, #d1fae5); + color: var(--success-text, #059669); + } + + .unknowns-queue__loading { + text-align: center; + padding: 2rem; + color: var(--text-secondary, #666); + } + + .unknowns-queue__spinner { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin-right: 0.5rem; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .unknowns-queue__error { + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + padding: 1rem 1.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .unknowns-queue__retry { + margin-left: auto; + padding: 0.25rem 0.5rem; + border: 1px solid currentColor; + border-radius: 4px; + background: transparent; + cursor: pointer; + } + + .unknowns-queue__list { + padding: 0.5rem 0; + } + + .unknowns-queue__select-all { + padding: 0.5rem 1.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + font-size: 0.875rem; + } + + .unknowns-queue__item { + display: flex; + align-items: center; + padding: 1rem 1.5rem; + gap: 1rem; + border-bottom: 1px solid var(--border-color, #f0f0f0); + transition: background 0.2s; + } + + .unknowns-queue__item:hover { + background: var(--surface-hover, #f9f9f9); + } + + .unknowns-queue__item--selected { + background: var(--selected-bg, #eff6ff); + } + + .unknowns-queue__item--hot { + border-left: 3px solid var(--error, #ef4444); + } + + .unknowns-queue__item--warm { + border-left: 3px solid var(--warning, #f59e0b); + } + + .unknowns-queue__item--cold { + border-left: 3px solid var(--info, #3b82f6); + } + + .unknowns-queue__band-badge { + font-size: 1.25rem; + } + + .unknowns-queue__item-info { + flex: 1; + min-width: 0; + } + + .unknowns-queue__item-name { + font-size: 1rem; + margin-bottom: 0.25rem; + } + + .unknowns-queue__item-version { + color: var(--text-secondary, #666); + font-weight: normal; + } + + .unknowns-queue__item-meta { + display: flex; + gap: 1rem; + font-size: 0.75rem; + color: var(--text-secondary, #666); + } + + .unknowns-queue__meta-label { + font-weight: 500; + } + + .unknowns-queue__item-cves { + margin-top: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + + .unknowns-queue__cve-label { + font-size: 0.75rem; + color: var(--text-secondary, #666); + } + + .unknowns-queue__cve { + font-size: 0.75rem; + padding: 0.125rem 0.375rem; + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + border-radius: 4px; + } + + .unknowns-queue__cve-more { + font-size: 0.75rem; + color: var(--text-secondary, #666); + } + + .unknowns-queue__status-badge { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + text-transform: capitalize; + } + + .unknowns-queue__status-badge--pending { + background: var(--warning-bg, #fef3c7); + color: var(--warning-text, #d97706); + } + + .unknowns-queue__status-badge--escalated { + background: var(--error-bg, #fef2f2); + color: var(--error-text, #dc2626); + } + + .unknowns-queue__status-badge--resolved { + background: var(--success-bg, #d1fae5); + color: var(--success-text, #059669); + } + + .unknowns-queue__item-actions { + display: flex; + gap: 0.25rem; + } + + .unknowns-queue__action-btn { + padding: 0.5rem; + border: none; + background: transparent; + cursor: pointer; + border-radius: 4px; + font-size: 1rem; + } + + .unknowns-queue__action-btn:hover:not(:disabled) { + background: var(--surface-hover, #e8e8e8); + } + + .unknowns-queue__action-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + .unknowns-queue__pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 1rem; + } + + .unknowns-queue__page-btn { + padding: 0.5rem 1rem; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + background: white; + cursor: pointer; + } + + .unknowns-queue__page-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .unknowns-queue__empty { + text-align: center; + padding: 3rem; + color: var(--text-secondary, #666); + } + + .unknowns-queue__empty-icon { + font-size: 3rem; + display: block; + margin-bottom: 1rem; + } + + /* Responsive */ + @media (max-width: 768px) { + .unknowns-queue__header { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } + + .unknowns-queue__toolbar { + flex-direction: column; + align-items: stretch; + } + + .unknowns-queue__item { + flex-wrap: wrap; + } + + .unknowns-queue__item-actions { + width: 100%; + justify-content: flex-end; + margin-top: 0.5rem; + } + } + `] +}) +export class UnknownsQueueComponent implements OnInit, OnDestroy { + // Inputs + readonly workspaceId = input(); + readonly scanId = input(); + readonly refreshInterval = input(30000); // 30 seconds + + // Outputs + readonly unknownSelected = output(); + readonly unknownEscalated = output(); + readonly unknownResolved = output(); + + // Services + private readonly unknownsApi = inject(UNKNOWNS_API); + private readonly destroy$ = new Subject(); + + // Tab configuration + readonly tabs: Array<{ id: TabId; label: string; icon: string }> = [ + { id: 'all', label: 'All', icon: '📋' }, + { id: 'hot', label: 'Hot', icon: '🔴' }, + { id: 'warm', label: 'Warm', icon: '🟡' }, + { id: 'cold', label: 'Cold', icon: '🔵' } + ]; + + // State + readonly loading = signal(true); + readonly error = signal(null); + readonly unknowns = signal([]); + readonly summary = signal(null); + readonly activeTab = signal('all'); + readonly searchQuery = signal(''); + readonly statusFilter = signal(''); + readonly sortConfig = signal({ field: 'rank', direction: 'asc' }); + readonly selectedIds = signal>(new Set()); + readonly currentPage = signal(1); + readonly pageSize = signal(20); + + // Computed + readonly filteredUnknowns = computed(() => { + let result = [...this.unknowns()]; + + // Filter by band (tab) + if (this.activeTab() !== 'all') { + const band = this.activeTab().toUpperCase() as UnknownBand; + result = result.filter(u => u.band === band); + } + + // Filter by search + const query = this.searchQuery().toLowerCase(); + if (query) { + result = result.filter(u => + u.package.name.toLowerCase().includes(query) || + u.package.purl?.toLowerCase().includes(query) + ); + } + + // Filter by status + const status = this.statusFilter(); + if (status) { + result = result.filter(u => u.status === status); + } + + // Sort + const { field, direction } = this.sortConfig(); + result.sort((a, b) => { + let cmp = 0; + switch (field) { + case 'rank': cmp = a.rank - b.rank; break; + case 'age': cmp = a.ageInDays - b.ageInDays; break; + case 'occurrenceCount': cmp = a.occurrenceCount - b.occurrenceCount; break; + } + return direction === 'asc' ? cmp : -cmp; + }); + + // Paginate + const start = (this.currentPage() - 1) * this.pageSize(); + return result.slice(start, start + this.pageSize()); + }); + + readonly totalPages = computed(() => { + const total = this.unknowns().length; + return Math.ceil(total / this.pageSize()); + }); + + readonly allSelected = computed(() => { + const filtered = this.filteredUnknowns(); + if (filtered.length === 0) return false; + return filtered.every(u => this.selectedIds().has(u.unknownId)); + }); + + readonly someSelected = computed(() => { + const filtered = this.filteredUnknowns(); + const selected = this.selectedIds(); + const selectedCount = filtered.filter(u => selected.has(u.unknownId)).length; + return selectedCount > 0 && selectedCount < filtered.length; + }); + + ngOnInit(): void { + this.loadUnknowns(); + this.loadSummary(); + + // Set up auto-refresh + const refreshMs = this.refreshInterval(); + if (refreshMs > 0) { + interval(refreshMs).pipe(takeUntil(this.destroy$)).subscribe(() => { + this.loadUnknowns(); + this.loadSummary(); + }); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + loadUnknowns(): void { + this.loading.set(true); + this.error.set(null); + + const filter: UnknownsFilter = {}; + if (this.workspaceId()) filter.workspaceId = this.workspaceId(); + if (this.scanId()) filter.scanId = this.scanId(); + + this.unknownsApi.list(filter).subscribe({ + next: (response) => { + this.unknowns.set(response.items); + this.loading.set(false); + }, + error: (err) => { + this.error.set('Failed to load unknowns: ' + err.message); + this.loading.set(false); + } + }); + } + + loadSummary(): void { + this.unknownsApi.getSummary().subscribe({ + next: (summary) => this.summary.set(summary), + error: () => {} // Non-critical + }); + } + + setActiveTab(tab: TabId): void { + this.activeTab.set(tab); + this.currentPage.set(1); + this.selectedIds.set(new Set()); + } + + getTabCount(tabId: TabId): number { + const s = this.summary(); + if (!s) return 0; + switch (tabId) { + case 'hot': return s.hotCount; + case 'warm': return s.warmCount; + case 'cold': return s.coldCount; + case 'all': return s.total; + } + } + + getBandIcon(band: UnknownBand): string { + switch (band) { + case 'HOT': return '🔴'; + case 'WARM': return '🟡'; + case 'COLD': return '🔵'; + default: return '⚪'; + } + } + + updateSort(field: SortField, direction: SortDirection): void { + this.sortConfig.set({ field, direction }); + } + + toggleSortDirection(): void { + this.sortConfig.update(c => ({ + ...c, + direction: c.direction === 'asc' ? 'desc' : 'asc' + })); + } + + toggleSelect(unknownId: string): void { + this.selectedIds.update(ids => { + const newIds = new Set(ids); + if (newIds.has(unknownId)) { + newIds.delete(unknownId); + } else { + newIds.add(unknownId); + } + return newIds; + }); + } + + toggleSelectAll(): void { + if (this.allSelected()) { + this.selectedIds.set(new Set()); + } else { + const ids = this.filteredUnknowns().map(u => u.unknownId); + this.selectedIds.set(new Set(ids)); + } + } + + goToPage(page: number): void { + this.currentPage.set(page); + } + + escalateUnknown(unknown: UnknownEntry): void { + const request: EscalateUnknownRequest = { + unknownId: unknown.unknownId, + reason: 'Manual escalation from UI' + }; + + this.unknownsApi.escalate(request).subscribe({ + next: (updated) => { + this.updateUnknownInList(updated); + this.unknownEscalated.emit(updated); + }, + error: (err) => { + this.error.set('Failed to escalate: ' + err.message); + } + }); + } + + resolveUnknown(unknown: UnknownEntry): void { + const request: ResolveUnknownRequest = { + unknownId: unknown.unknownId, + resolution: 'resolved', + notes: 'Resolved from UI' + }; + + this.unknownsApi.resolve(request).subscribe({ + next: (updated) => { + this.updateUnknownInList(updated); + this.unknownResolved.emit(updated); + }, + error: (err) => { + this.error.set('Failed to resolve: ' + err.message); + } + }); + } + + viewDetails(unknown: UnknownEntry): void { + this.unknownSelected.emit(unknown); + } + + bulkEscalate(): void { + const ids = Array.from(this.selectedIds()); + this.unknownsApi.bulkAction({ + unknownIds: ids, + action: 'escalate', + reason: 'Bulk escalation from UI' + }).subscribe({ + next: () => { + this.loadUnknowns(); + this.selectedIds.set(new Set()); + }, + error: (err) => { + this.error.set('Bulk escalate failed: ' + err.message); + } + }); + } + + bulkResolve(): void { + const ids = Array.from(this.selectedIds()); + this.unknownsApi.bulkAction({ + unknownIds: ids, + action: 'resolve', + resolution: 'resolved', + notes: 'Bulk resolved from UI' + }).subscribe({ + next: () => { + this.loadUnknowns(); + this.selectedIds.set(new Set()); + }, + error: (err) => { + this.error.set('Bulk resolve failed: ' + err.message); + } + }); + } + + private updateUnknownInList(updated: UnknownEntry): void { + this.unknowns.update(list => + list.map(u => u.unknownId === updated.unknownId ? updated : u) + ); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/accessibility/accessibility.utils.ts b/src/Web/StellaOps.Web/src/app/shared/accessibility/accessibility.utils.ts new file mode 100644 index 000000000..320f32049 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/accessibility/accessibility.utils.ts @@ -0,0 +1,398 @@ +/** + * Accessibility Utilities for Sprint 3500.0004.0002 - T7 + * + * Provides accessibility helpers, focus management, and ARIA utilities + * to ensure WCAG 2.1 AA compliance across all components. + */ + +import { Directive, ElementRef, HostListener, Input, Output, EventEmitter, inject, OnInit, OnDestroy } from '@angular/core'; +import { fromEvent, Subject, takeUntil } from 'rxjs'; + +// ============================================================================ +// Focus Trap Directive +// ============================================================================ + +/** + * Traps focus within a container element for modals, dialogs, etc. + * Usage:
...
+ */ +@Directive({ + selector: '[stellaFocusTrap]', + standalone: true +}) +export class FocusTrapDirective implements OnInit, OnDestroy { + @Input() trapFocus = false; + + private readonly el = inject(ElementRef); + private readonly destroy$ = new Subject(); + private previousFocus: HTMLElement | null = null; + + ngOnInit(): void { + fromEvent(this.el.nativeElement, 'keydown') + .pipe(takeUntil(this.destroy$)) + .subscribe(event => { + if (!this.trapFocus || event.key !== 'Tab') return; + this.handleTab(event); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.restoreFocus(); + } + + private handleTab(event: KeyboardEvent): void { + const focusable = this.getFocusableElements(); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + + if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + } + + private getFocusableElements(): HTMLElement[] { + const selectors = [ + 'button:not([disabled])', + 'a[href]', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])' + ].join(','); + + return Array.from(this.el.nativeElement.querySelectorAll(selectors)); + } + + saveFocus(): void { + this.previousFocus = document.activeElement as HTMLElement; + } + + restoreFocus(): void { + if (this.previousFocus) { + this.previousFocus.focus(); + } + } + + focusFirst(): void { + const focusable = this.getFocusableElements(); + if (focusable.length > 0) { + focusable[0].focus(); + } + } +} + +// ============================================================================ +// Live Region Directive +// ============================================================================ + +/** + * Announces content changes to screen readers. + * Usage:
+ */ +@Directive({ + selector: '[stellaLiveRegion]', + standalone: true +}) +export class LiveRegionDirective { + @Input() set liveMessage(message: string) { + if (message) { + this.announce(message); + } + } + + @Input() politeness: 'polite' | 'assertive' = 'polite'; + @Input() clearAfterMs = 1000; + + private readonly el = inject(ElementRef); + + private announce(message: string): void { + const element = this.el.nativeElement as HTMLElement; + element.setAttribute('aria-live', this.politeness); + element.setAttribute('role', 'status'); + + // Clear first, then set message (helps screen readers detect change) + element.textContent = ''; + setTimeout(() => { + element.textContent = message; + if (this.clearAfterMs > 0) { + setTimeout(() => element.textContent = '', this.clearAfterMs); + } + }, 50); + } +} + +// ============================================================================ +// Keyboard Navigation Directive +// ============================================================================ + +/** + * Adds keyboard navigation for lists, grids, and menus. + * Usage:
    + */ +@Directive({ + selector: '[stellaKeyNav]', + standalone: true +}) +export class KeyNavDirective implements OnInit, OnDestroy { + @Input() orientation: 'horizontal' | 'vertical' | 'grid' = 'vertical'; + @Input() itemSelector = '[data-keynav-item]'; + @Input() wrap = true; + @Input() gridColumns = 1; + + @Output() itemSelected = new EventEmitter(); + @Output() itemActivated = new EventEmitter(); + + private readonly el = inject(ElementRef); + private readonly destroy$ = new Subject(); + private currentIndex = 0; + + ngOnInit(): void { + fromEvent(this.el.nativeElement, 'keydown') + .pipe(takeUntil(this.destroy$)) + .subscribe(event => this.handleKeydown(event)); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private handleKeydown(event: KeyboardEvent): void { + const items = this.getItems(); + if (items.length === 0) return; + + let newIndex = this.currentIndex; + let handled = false; + + switch (event.key) { + case 'ArrowUp': + if (this.orientation !== 'horizontal') { + newIndex = this.orientation === 'grid' + ? this.currentIndex - this.gridColumns + : this.currentIndex - 1; + handled = true; + } + break; + + case 'ArrowDown': + if (this.orientation !== 'horizontal') { + newIndex = this.orientation === 'grid' + ? this.currentIndex + this.gridColumns + : this.currentIndex + 1; + handled = true; + } + break; + + case 'ArrowLeft': + if (this.orientation !== 'vertical') { + newIndex = this.currentIndex - 1; + handled = true; + } + break; + + case 'ArrowRight': + if (this.orientation !== 'vertical') { + newIndex = this.currentIndex + 1; + handled = true; + } + break; + + case 'Home': + newIndex = 0; + handled = true; + break; + + case 'End': + newIndex = items.length - 1; + handled = true; + break; + + case 'Enter': + case ' ': + this.itemActivated.emit(items[this.currentIndex]); + handled = true; + break; + } + + if (handled) { + event.preventDefault(); + + // Handle wrapping + if (this.wrap) { + newIndex = ((newIndex % items.length) + items.length) % items.length; + } else { + newIndex = Math.max(0, Math.min(items.length - 1, newIndex)); + } + + if (newIndex !== this.currentIndex) { + this.currentIndex = newIndex; + this.focusItem(items[newIndex]); + this.itemSelected.emit(items[newIndex]); + } + } + } + + private getItems(): HTMLElement[] { + return Array.from(this.el.nativeElement.querySelectorAll(this.itemSelector)); + } + + private focusItem(item: HTMLElement): void { + item.focus(); + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + + setCurrentIndex(index: number): void { + const items = this.getItems(); + if (index >= 0 && index < items.length) { + this.currentIndex = index; + } + } +} + +// ============================================================================ +// Skip Link Directive +// ============================================================================ + +/** + * Creates an accessible skip link for keyboard users. + * Usage: Skip to main content + */ +@Directive({ + selector: '[stellaSkipLink]', + standalone: true, + host: { + 'class': 'stella-skip-link', + '[attr.href]': '"#" + target' + } +}) +export class SkipLinkDirective { + @Input() target = 'main-content'; + + private readonly el = inject(ElementRef); + + @HostListener('click', ['$event']) + onClick(event: Event): void { + event.preventDefault(); + const targetElement = document.getElementById(this.target); + if (targetElement) { + targetElement.setAttribute('tabindex', '-1'); + targetElement.focus(); + targetElement.removeAttribute('tabindex'); + } + } +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Generates a unique ID for ARIA relationships. + */ +let idCounter = 0; +export function generateAriaId(prefix = 'stella'): string { + return `${prefix}-${++idCounter}`; +} + +/** + * Checks if an element is visible and focusable. + */ +export function isFocusable(element: HTMLElement): boolean { + if (element.hasAttribute('disabled')) return false; + if (element.getAttribute('tabindex') === '-1') return false; + + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden') return false; + + return true; +} + +/** + * Gets the appropriate ARIA label for a severity level. + */ +export function getSeverityAriaLabel(severity: string): string { + const labels: Record = { + critical: 'Critical severity - requires immediate attention', + high: 'High severity - should be addressed soon', + medium: 'Medium severity - should be reviewed', + low: 'Low severity - can be addressed in regular maintenance', + info: 'Informational' + }; + return labels[severity.toLowerCase()] || severity; +} + +/** + * Gets the appropriate ARIA label for a status. + */ +export function getStatusAriaLabel(status: string): string { + const labels: Record = { + running: 'Currently in progress', + completed: 'Completed successfully', + failed: 'Failed with errors', + queued: 'Waiting to start', + cancelled: 'Cancelled by user' + }; + return labels[status.toLowerCase()] || status; +} + +// ============================================================================ +// Color Contrast Utilities +// ============================================================================ + +/** + * Calculates relative luminance of a color. + */ +export function getLuminance(r: number, g: number, b: number): number { + const [rs, gs, bs] = [r, g, b].map(c => { + c = c / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; +} + +/** + * Calculates contrast ratio between two colors. + * Returns a value between 1 and 21. + */ +export function getContrastRatio( + rgb1: [number, number, number], + rgb2: [number, number, number] +): number { + const l1 = getLuminance(...rgb1); + const l2 = getLuminance(...rgb2); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +/** + * Checks if contrast ratio meets WCAG AA (4.5:1 for normal text, 3:1 for large text). + */ +export function meetsContrastAA( + foreground: [number, number, number], + background: [number, number, number], + isLargeText = false +): boolean { + const ratio = getContrastRatio(foreground, background); + return isLargeText ? ratio >= 3 : ratio >= 4.5; +} + +// ============================================================================ +// Module Exports +// ============================================================================ + +export const ACCESSIBILITY_DIRECTIVES = [ + FocusTrapDirective, + LiveRegionDirective, + KeyNavDirective, + SkipLinkDirective +];