feat: Add PathViewer and RiskDriftCard components with templates and styles
- Implemented PathViewerComponent for visualizing reachability call paths. - Added RiskDriftCardComponent to display reachability drift results. - Created corresponding HTML templates and SCSS styles for both components. - Introduced test fixtures for reachability analysis in JSON format. - Enhanced user interaction with collapsible and expandable features in PathViewer. - Included risk trend visualization and summary metrics in RiskDriftCard.
This commit is contained in:
@@ -10,14 +10,30 @@ import {
|
||||
EvidencePanelMetricsService,
|
||||
EvidencePanelAction,
|
||||
} from './evidence-panel-metrics.service';
|
||||
import { APP_CONFIG } from '../config/app.config';
|
||||
import { APP_CONFIG, AppConfig } from '../config/app-config.model';
|
||||
|
||||
describe('EvidencePanelMetricsService', () => {
|
||||
let service: EvidencePanelMetricsService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
const mockConfig = {
|
||||
apiBaseUrl: 'http://localhost:5000/api',
|
||||
const mockConfig: AppConfig = {
|
||||
authority: {
|
||||
issuer: 'https://auth.stellaops.test/',
|
||||
clientId: 'ui-client',
|
||||
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
|
||||
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
|
||||
redirectUri: 'https://ui.stellaops.test/auth/callback',
|
||||
scope: 'openid profile email ui.read',
|
||||
audience: 'https://scanner.stellaops.test',
|
||||
},
|
||||
apiBaseUrls: {
|
||||
gateway: 'http://localhost:5000/api',
|
||||
authority: 'https://auth.stellaops.test',
|
||||
scanner: 'https://scanner.stellaops.test',
|
||||
policy: 'https://policy.stellaops.test',
|
||||
concelier: 'https://concelier.stellaops.test',
|
||||
attestor: 'https://attestor.stellaops.test',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -197,7 +213,7 @@ describe('EvidencePanelMetricsService', () => {
|
||||
}
|
||||
|
||||
// Expect POST to metrics endpoint
|
||||
const req = httpMock.expectOne(`${mockConfig.apiBaseUrl}/metrics/evidence-panel`);
|
||||
const req = httpMock.expectOne(`${mockConfig.apiBaseUrls.gateway}/metrics/evidence-panel`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body.sessions.length).toBe(10);
|
||||
|
||||
@@ -213,7 +229,7 @@ describe('EvidencePanelMetricsService', () => {
|
||||
service.endSession();
|
||||
}
|
||||
|
||||
const req = httpMock.expectOne(`${mockConfig.apiBaseUrl}/metrics/evidence-panel`);
|
||||
const req = httpMock.expectOne(`${mockConfig.apiBaseUrls.gateway}/metrics/evidence-panel`);
|
||||
const sessions = req.request.body.sessions;
|
||||
|
||||
expect(sessions[0]).toEqual(jasmine.objectContaining({
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { APP_CONFIG, AppConfig } from '../config/app.config';
|
||||
import { APP_CONFIG, AppConfig } from '../config/app-config.model';
|
||||
|
||||
/**
|
||||
* Types of actions tracked in the Evidence Panel
|
||||
@@ -243,7 +243,7 @@ export class EvidencePanelMetricsService {
|
||||
|
||||
// Fire-and-forget POST to metrics endpoint
|
||||
this.http.post(
|
||||
`${this.config.apiBaseUrl}/metrics/evidence-panel`,
|
||||
`${this.resolveMetricsBaseUrl()}/metrics/evidence-panel`,
|
||||
{
|
||||
sessions: sessions.map(s => ({
|
||||
sessionId: s.sessionId,
|
||||
@@ -264,6 +264,10 @@ export class EvidencePanelMetricsService {
|
||||
});
|
||||
}
|
||||
|
||||
private resolveMetricsBaseUrl(): string {
|
||||
return this.config.apiBaseUrls.gateway ?? this.config.apiBaseUrls.scanner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current metrics summary for debugging/display
|
||||
*/
|
||||
|
||||
@@ -194,11 +194,11 @@ export class TriageEvidenceHttpClient implements TriageEvidenceApi {
|
||||
}
|
||||
}
|
||||
|
||||
private buildParams(options?: Record<string, unknown>): HttpParams {
|
||||
private buildParams(options?: object): HttpParams {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (options) {
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
for (const [key, value] of Object.entries(options as Record<string, unknown>)) {
|
||||
if (value !== undefined && value !== null && key !== 'tenantId' && key !== 'traceId') {
|
||||
params = params.set(key, String(value));
|
||||
}
|
||||
|
||||
@@ -83,13 +83,15 @@ export class TelemetrySamplerService {
|
||||
}
|
||||
|
||||
private createSessionId(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
const cryptoApi = this.getCryptoApi();
|
||||
|
||||
if (cryptoApi?.randomUUID) {
|
||||
return cryptoApi.randomUUID();
|
||||
}
|
||||
|
||||
if (typeof crypto !== 'undefined' && 'getRandomValues' in crypto) {
|
||||
if (cryptoApi?.getRandomValues) {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
cryptoApi.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
@@ -97,13 +99,21 @@ export class TelemetrySamplerService {
|
||||
}
|
||||
|
||||
private createSampleValue(): number {
|
||||
if (typeof crypto !== 'undefined' && 'getRandomValues' in crypto) {
|
||||
const cryptoApi = this.getCryptoApi();
|
||||
|
||||
if (cryptoApi?.getRandomValues) {
|
||||
const bytes = new Uint32Array(1);
|
||||
crypto.getRandomValues(bytes);
|
||||
cryptoApi.getRandomValues(bytes);
|
||||
return bytes[0] / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
return Math.random();
|
||||
}
|
||||
}
|
||||
|
||||
private getCryptoApi(): Crypto | null {
|
||||
if (typeof globalThis === 'undefined') return null;
|
||||
|
||||
const value = (globalThis as unknown as { crypto?: Crypto }).crypto;
|
||||
return value ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
|
||||
import type { EvidenceApi } from '../../core/api/evidence.client';
|
||||
import { EVIDENCE_API } from '../../core/api/evidence.client';
|
||||
import type { EvidenceData, VexDecision, VexStatus } from '../../core/api/evidence.models';
|
||||
import { APP_CONFIG, type AppConfig } from '../../core/config/app-config.model';
|
||||
import { EvidencePanelComponent } from './evidence-panel.component';
|
||||
|
||||
function createVexDecision(status: VexStatus, id: string): VexDecision {
|
||||
@@ -32,8 +34,31 @@ describe('EvidencePanelComponent', () => {
|
||||
]);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidencePanelComponent],
|
||||
providers: [{ provide: EVIDENCE_API, useValue: api }],
|
||||
imports: [HttpClientTestingModule, EvidencePanelComponent],
|
||||
providers: [
|
||||
{ provide: EVIDENCE_API, useValue: api },
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useValue: {
|
||||
authority: {
|
||||
issuer: 'https://auth.stellaops.test/',
|
||||
clientId: 'ui-client',
|
||||
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
|
||||
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
|
||||
redirectUri: 'https://ui.stellaops.test/auth/callback',
|
||||
scope: 'openid profile email ui.read',
|
||||
audience: 'https://scanner.stellaops.test',
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://auth.stellaops.test',
|
||||
scanner: 'https://scanner.stellaops.test',
|
||||
policy: 'https://policy.stellaops.test',
|
||||
concelier: 'https://concelier.stellaops.test',
|
||||
attestor: 'https://attestor.stellaops.test',
|
||||
},
|
||||
} satisfies AppConfig,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EvidencePanelComponent);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* PathViewerComponent barrel export
|
||||
*/
|
||||
export * from './path-viewer.component';
|
||||
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
PathViewerComponent Template
|
||||
Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
Task: UI-003
|
||||
-->
|
||||
<div class="path-viewer" [class.path-viewer--collapsed]="collapsed()">
|
||||
<!-- Header -->
|
||||
<div class="path-viewer__header">
|
||||
<span class="path-viewer__title">{{ title() }}</span>
|
||||
<div class="path-viewer__actions">
|
||||
@if (hiddenNodeCount() > 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="path-viewer__btn path-viewer__btn--expand"
|
||||
(click)="toggleExpand()"
|
||||
[attr.aria-expanded]="isExpanded()">
|
||||
@if (isExpanded()) {
|
||||
Collapse ({{ hiddenNodeCount() }} hidden)
|
||||
} @else {
|
||||
Expand (+{{ hiddenNodeCount() }} nodes)
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (collapsible()) {
|
||||
<button
|
||||
type="button"
|
||||
class="path-viewer__btn path-viewer__btn--collapse"
|
||||
(click)="toggleCollapse()"
|
||||
[attr.aria-expanded]="!collapsed()">
|
||||
{{ collapsed() ? 'Show' : 'Hide' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
@if (!collapsed()) {
|
||||
<div class="path-viewer__content">
|
||||
<!-- Path visualization -->
|
||||
<ol class="path-viewer__nodes" role="list">
|
||||
@for (node of displayNodes(); track node.nodeId; let i = $index; let last = $last) {
|
||||
<!-- Node -->
|
||||
<li
|
||||
[class]="getNodeClass(node)"
|
||||
(click)="onNodeClick(node)"
|
||||
(keydown.enter)="onNodeClick(node)"
|
||||
tabindex="0"
|
||||
role="listitem"
|
||||
[attr.aria-label]="node.symbol">
|
||||
<span class="path-node__icon" [attr.aria-hidden]="true">
|
||||
{{ getNodeIcon(node) }}
|
||||
</span>
|
||||
<div class="path-node__details">
|
||||
<span class="path-node__symbol">{{ node.symbol }}</span>
|
||||
@if (node.file) {
|
||||
<span class="path-node__location">
|
||||
{{ node.file }}@if (node.line) {:{{ node.line }}}
|
||||
</span>
|
||||
}
|
||||
@if (node.package) {
|
||||
<span class="path-node__package">{{ node.package }}</span>
|
||||
}
|
||||
@if (showConfidence() && node.confidence !== undefined) {
|
||||
<span class="path-node__confidence">
|
||||
{{ formatConfidence(node.confidence) }}
|
||||
</span>
|
||||
}
|
||||
@if (highlightChanges() && node.isChanged && node.changeKind) {
|
||||
<span class="path-node__change-badge" [class]="'path-node__change-badge--' + node.changeKind">
|
||||
{{ formatChangeKind(node.changeKind) }}
|
||||
</span>
|
||||
}
|
||||
@if (node.nodeType === 'entrypoint') {
|
||||
<span class="path-node__type-badge path-node__type-badge--entrypoint">
|
||||
ENTRYPOINT
|
||||
</span>
|
||||
}
|
||||
@if (node.nodeType === 'sink') {
|
||||
<span class="path-node__type-badge path-node__type-badge--sink">
|
||||
SINK
|
||||
</span>
|
||||
}
|
||||
@if (node.nodeType === 'gate') {
|
||||
<span class="path-node__type-badge path-node__type-badge--gate">
|
||||
GATE
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Connector -->
|
||||
@if (!last) {
|
||||
<li class="path-viewer__connector" role="presentation" aria-hidden="true">
|
||||
<span class="path-viewer__connector-line"></span>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ol>
|
||||
|
||||
<!-- Hidden nodes indicator -->
|
||||
@if (hiddenNodeCount() > 0 && !isExpanded()) {
|
||||
<div class="path-viewer__hidden-indicator">
|
||||
<span class="path-viewer__hidden-text">
|
||||
… {{ hiddenNodeCount() }} intermediate node(s) hidden …
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* PathViewerComponent Styles
|
||||
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
* Task: UI-004
|
||||
*/
|
||||
|
||||
// Variables
|
||||
$color-entrypoint: #10b981; // Green
|
||||
$color-sink: #ef4444; // Red
|
||||
$color-gate: #f59e0b; // Amber
|
||||
$color-changed: #8b5cf6; // Purple
|
||||
$color-added: #22c55e;
|
||||
$color-removed: #ef4444;
|
||||
$color-modified: #f59e0b;
|
||||
$color-border: #e5e7eb;
|
||||
$color-bg: #ffffff;
|
||||
$color-bg-hover: #f9fafb;
|
||||
$color-text: #111827;
|
||||
$color-text-muted: #6b7280;
|
||||
|
||||
.path-viewer {
|
||||
font-family: var(--font-family-sans, system-ui, sans-serif);
|
||||
background: $color-bg;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&--collapsed {
|
||||
.path-viewer__content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid $color-border;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 4px;
|
||||
background: $color-bg;
|
||||
color: $color-text-muted;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--expand {
|
||||
color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__nodes {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__connector {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4px 0;
|
||||
|
||||
&-line {
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: $color-border;
|
||||
}
|
||||
}
|
||||
|
||||
&__hidden-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
&__hidden-text {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.path-node {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 6px;
|
||||
background: $color-bg;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__symbol {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family-mono, 'SF Mono', Consolas, monospace);
|
||||
color: $color-text;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__location {
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
font-family: var(--font-family-mono, 'SF Mono', Consolas, monospace);
|
||||
}
|
||||
|
||||
&__package {
|
||||
font-size: 11px;
|
||||
color: $color-text-muted;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
&__confidence {
|
||||
font-size: 11px;
|
||||
color: $color-text-muted;
|
||||
background: #e0e7ff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
&__change-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
|
||||
&--added {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--removed {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
&--modified {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
&__type-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
|
||||
&--entrypoint {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&--sink {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
&--gate {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
// Node type variants
|
||||
&--entrypoint {
|
||||
border-color: $color-entrypoint;
|
||||
|
||||
.path-node__icon {
|
||||
background: #d1fae5;
|
||||
color: $color-entrypoint;
|
||||
}
|
||||
}
|
||||
|
||||
&--sink {
|
||||
border-color: $color-sink;
|
||||
|
||||
.path-node__icon {
|
||||
background: #fee2e2;
|
||||
color: $color-sink;
|
||||
}
|
||||
}
|
||||
|
||||
&--gate {
|
||||
border-color: $color-gate;
|
||||
|
||||
.path-node__icon {
|
||||
background: #fef3c7;
|
||||
color: $color-gate;
|
||||
}
|
||||
}
|
||||
|
||||
// Changed state
|
||||
&--changed {
|
||||
border-color: $color-changed;
|
||||
background: #faf5ff;
|
||||
|
||||
.path-node__icon {
|
||||
background: #ede9fe;
|
||||
color: $color-changed;
|
||||
}
|
||||
}
|
||||
|
||||
&--added {
|
||||
border-color: $color-added;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
&--removed {
|
||||
border-color: $color-removed;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
&--modified {
|
||||
border-color: $color-modified;
|
||||
background: #fffbeb;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* PathViewerComponent - Call Path Visualization
|
||||
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
* Task: UI-003
|
||||
*/
|
||||
|
||||
import { Component, input, output, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CompressedPath, PathNode, ExpandedPath, PathEdge } from '../../models/path-viewer.models';
|
||||
|
||||
/**
|
||||
* Visualizes reachability call paths from entrypoint to sink.
|
||||
* Supports both compressed and expanded views.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <app-path-viewer
|
||||
* [path]="compressedPath"
|
||||
* [collapsible]="true"
|
||||
* [showConfidence]="true"
|
||||
* (nodeClick)="onNodeClick($event)"
|
||||
* (expandRequest)="onExpandPath($event)">
|
||||
* </app-path-viewer>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-path-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './path-viewer.component.html',
|
||||
styleUrl: './path-viewer.component.scss'
|
||||
})
|
||||
export class PathViewerComponent {
|
||||
/** The compressed path to display */
|
||||
path = input.required<CompressedPath>();
|
||||
|
||||
/** Optional title for the path viewer */
|
||||
title = input<string>('Reachability Path');
|
||||
|
||||
/** Whether the viewer can be collapsed */
|
||||
collapsible = input<boolean>(true);
|
||||
|
||||
/** Whether to show confidence scores */
|
||||
showConfidence = input<boolean>(false);
|
||||
|
||||
/** Whether to highlight changed nodes */
|
||||
highlightChanges = input<boolean>(true);
|
||||
|
||||
/** Maximum depth to show before collapsing */
|
||||
maxVisibleDepth = input<number>(5);
|
||||
|
||||
/** Emits when a node is clicked */
|
||||
nodeClick = output<PathNode>();
|
||||
|
||||
/** Emits when path expansion is requested */
|
||||
expandRequest = output<string>();
|
||||
|
||||
/** Internal collapsed state */
|
||||
collapsed = signal<boolean>(false);
|
||||
|
||||
/** Whether the full path is expanded */
|
||||
isExpanded = signal<boolean>(false);
|
||||
|
||||
/** Computed: effective nodes to display */
|
||||
displayNodes = computed(() => {
|
||||
const p = this.path();
|
||||
if (this.isExpanded()) {
|
||||
return this.buildFullNodeList(p);
|
||||
}
|
||||
return [p.entrypoint, ...p.keyNodes, p.sink];
|
||||
});
|
||||
|
||||
/** Computed: count of hidden nodes */
|
||||
hiddenNodeCount = computed(() => {
|
||||
const p = this.path();
|
||||
if (this.isExpanded()) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, p.intermediateCount - p.keyNodes.length);
|
||||
});
|
||||
|
||||
/** Toggle collapsed state */
|
||||
toggleCollapse(): void {
|
||||
this.collapsed.update(v => !v);
|
||||
}
|
||||
|
||||
/** Toggle expanded state */
|
||||
toggleExpand(): void {
|
||||
const p = this.path();
|
||||
if (!this.isExpanded() && p.fullPath && p.fullPath.length > 0) {
|
||||
this.expandRequest.emit(p.fullPath[0]);
|
||||
}
|
||||
this.isExpanded.update(v => !v);
|
||||
}
|
||||
|
||||
/** Handle node click */
|
||||
onNodeClick(node: PathNode): void {
|
||||
this.nodeClick.emit(node);
|
||||
}
|
||||
|
||||
/** Get CSS class for node type */
|
||||
getNodeClass(node: PathNode): string {
|
||||
const classes: string[] = ['path-node'];
|
||||
|
||||
if (node.nodeType) {
|
||||
classes.push(`path-node--${node.nodeType}`);
|
||||
}
|
||||
|
||||
if (this.highlightChanges() && node.isChanged) {
|
||||
classes.push('path-node--changed');
|
||||
if (node.changeKind) {
|
||||
classes.push(`path-node--${node.changeKind}`);
|
||||
}
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
/** Get icon for node type */
|
||||
getNodeIcon(node: PathNode): string {
|
||||
if (node.isChanged) {
|
||||
return '●';
|
||||
}
|
||||
|
||||
switch (node.nodeType) {
|
||||
case 'entrypoint':
|
||||
return '▶';
|
||||
case 'sink':
|
||||
return '⚠';
|
||||
case 'gate':
|
||||
return '◆';
|
||||
default:
|
||||
return '○';
|
||||
}
|
||||
}
|
||||
|
||||
/** Format change kind for display */
|
||||
formatChangeKind(kind?: string): string {
|
||||
if (!kind) return '';
|
||||
return kind.charAt(0).toUpperCase() + kind.slice(1);
|
||||
}
|
||||
|
||||
/** Format confidence as percentage */
|
||||
formatConfidence(confidence?: number): string {
|
||||
if (confidence === undefined) return '';
|
||||
return `${Math.round(confidence * 100)}%`;
|
||||
}
|
||||
|
||||
/** Build full node list from path */
|
||||
private buildFullNodeList(path: CompressedPath): PathNode[] {
|
||||
// For now, return compressed representation
|
||||
// Full expansion requires additional data
|
||||
return [path.entrypoint, ...path.keyNodes, path.sink];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* RiskDriftCardComponent barrel export
|
||||
*/
|
||||
export * from './risk-drift-card.component';
|
||||
@@ -0,0 +1,136 @@
|
||||
<!--
|
||||
RiskDriftCardComponent Template
|
||||
Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
Task: UI-007
|
||||
-->
|
||||
<article class="risk-drift-card" [class.risk-drift-card--compact]="compact()">
|
||||
<!-- Header -->
|
||||
<header class="risk-drift-card__header">
|
||||
<div class="risk-drift-card__title">
|
||||
<h3 class="risk-drift-card__heading">Reachability Drift</h3>
|
||||
@if (showAttestation() && isSigned()) {
|
||||
<span class="risk-drift-card__attestation-badge" title="Signed with DSSE">
|
||||
✓ Attested
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<time class="risk-drift-card__time" [attr.datetime]="drift().comparedAt">
|
||||
{{ formatTime(drift().comparedAt) }}
|
||||
</time>
|
||||
</header>
|
||||
|
||||
<!-- Summary metrics -->
|
||||
<div class="risk-drift-card__summary">
|
||||
<!-- Risk trend -->
|
||||
<div class="risk-drift-card__metric risk-drift-card__metric--trend">
|
||||
<span class="risk-drift-card__trend" [class]="trendClass()">
|
||||
<span class="risk-drift-card__trend-icon">{{ trendIcon() }}</span>
|
||||
<span class="risk-drift-card__trend-label">
|
||||
{{ summary().riskTrend | titlecase }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="risk-drift-card__delta" [class.positive]="summary().netRiskDelta > 0" [class.negative]="summary().netRiskDelta < 0">
|
||||
{{ formatRiskDelta(summary().netRiskDelta) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Key stats -->
|
||||
@if (!compact()) {
|
||||
<div class="risk-drift-card__stats">
|
||||
<div class="risk-drift-card__stat">
|
||||
<span class="risk-drift-card__stat-value">{{ summary().increasedReachability }}</span>
|
||||
<span class="risk-drift-card__stat-label">Increased</span>
|
||||
</div>
|
||||
<div class="risk-drift-card__stat">
|
||||
<span class="risk-drift-card__stat-value">{{ summary().decreasedReachability }}</span>
|
||||
<span class="risk-drift-card__stat-label">Decreased</span>
|
||||
</div>
|
||||
<div class="risk-drift-card__stat">
|
||||
<span class="risk-drift-card__stat-value">{{ summary().newSinks }}</span>
|
||||
<span class="risk-drift-card__stat-label">New</span>
|
||||
</div>
|
||||
<div class="risk-drift-card__stat">
|
||||
<span class="risk-drift-card__stat-value">{{ summary().removedSinks }}</span>
|
||||
<span class="risk-drift-card__stat-label">Removed</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Severity breakdown -->
|
||||
<div class="risk-drift-card__severity-bar">
|
||||
@if (summary().bySeverity.critical > 0) {
|
||||
<span class="risk-drift-card__severity risk-drift-card__severity--critical" [title]="'Critical: ' + summary().bySeverity.critical">
|
||||
{{ summary().bySeverity.critical }}
|
||||
</span>
|
||||
}
|
||||
@if (summary().bySeverity.high > 0) {
|
||||
<span class="risk-drift-card__severity risk-drift-card__severity--high" [title]="'High: ' + summary().bySeverity.high">
|
||||
{{ summary().bySeverity.high }}
|
||||
</span>
|
||||
}
|
||||
@if (summary().bySeverity.medium > 0) {
|
||||
<span class="risk-drift-card__severity risk-drift-card__severity--medium" [title]="'Medium: ' + summary().bySeverity.medium">
|
||||
{{ summary().bySeverity.medium }}
|
||||
</span>
|
||||
}
|
||||
@if (summary().bySeverity.low > 0) {
|
||||
<span class="risk-drift-card__severity risk-drift-card__severity--low" [title]="'Low: ' + summary().bySeverity.low">
|
||||
{{ summary().bySeverity.low }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview sinks -->
|
||||
@if (!compact() && previewSinks().length > 0) {
|
||||
<div class="risk-drift-card__preview">
|
||||
<h4 class="risk-drift-card__preview-title">Top Drifted Sinks</h4>
|
||||
<ul class="risk-drift-card__sink-list">
|
||||
@for (sink of previewSinks(); track sink.sink.nodeId) {
|
||||
<li
|
||||
class="risk-drift-card__sink-item"
|
||||
(click)="onSinkClick(sink)"
|
||||
(keydown.enter)="onSinkClick(sink)"
|
||||
tabindex="0"
|
||||
role="button">
|
||||
<span class="risk-drift-card__sink-icon" [class]="getSeverityClass(sink.severity)">
|
||||
@if (sink.isRiskIncrease) { ↑ } @else { ↓ }
|
||||
</span>
|
||||
<div class="risk-drift-card__sink-details">
|
||||
<span class="risk-drift-card__sink-name">{{ sink.sink.symbol }}</span>
|
||||
@if (sink.cveId) {
|
||||
<span class="risk-drift-card__sink-cve">{{ sink.cveId }}</span>
|
||||
}
|
||||
<span class="risk-drift-card__sink-bucket">
|
||||
{{ getBucketLabel(sink.previousBucket) }} → {{ getBucketLabel(sink.currentBucket) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="risk-drift-card__sink-delta" [class.positive]="sink.riskDelta > 0" [class.negative]="sink.riskDelta < 0">
|
||||
{{ formatRiskDelta(sink.riskDelta) }}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (additionalSinksCount() > 0) {
|
||||
<p class="risk-drift-card__more">
|
||||
+{{ additionalSinksCount() }} more sinks
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Actions -->
|
||||
<footer class="risk-drift-card__footer">
|
||||
@if (drift().pullRequestNumber) {
|
||||
<span class="risk-drift-card__pr">
|
||||
PR #{{ drift().pullRequestNumber }}
|
||||
</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="risk-drift-card__btn"
|
||||
(click)="onViewDetails()">
|
||||
View Details
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* RiskDriftCardComponent Styles
|
||||
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
* Task: UI-008
|
||||
*/
|
||||
|
||||
// Variables
|
||||
$color-critical: #dc2626;
|
||||
$color-high: #ea580c;
|
||||
$color-medium: #d97706;
|
||||
$color-low: #ca8a04;
|
||||
$color-info: #6b7280;
|
||||
$color-positive: #dc2626; // risk increase is bad
|
||||
$color-negative: #16a34a; // risk decrease is good
|
||||
$color-border: #e5e7eb;
|
||||
$color-bg: #ffffff;
|
||||
$color-bg-hover: #f9fafb;
|
||||
$color-text: #111827;
|
||||
$color-text-muted: #6b7280;
|
||||
|
||||
.risk-drift-card {
|
||||
font-family: var(--font-family-sans, system-ui, sans-serif);
|
||||
background: $color-bg;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&--compact {
|
||||
.risk-drift-card__preview,
|
||||
.risk-drift-card__stats {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid $color-border;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__heading {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
&__attestation-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #059669;
|
||||
background: #d1fae5;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__summary {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__metric--trend {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
|
||||
&--increasing {
|
||||
color: $color-positive;
|
||||
}
|
||||
|
||||
&--decreasing {
|
||||
color: $color-negative;
|
||||
}
|
||||
|
||||
&--stable {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
&__trend-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&__delta {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-family-mono, 'SF Mono', Consolas, monospace);
|
||||
|
||||
&.positive {
|
||||
color: $color-positive;
|
||||
}
|
||||
|
||||
&.negative {
|
||||
color: $color-negative;
|
||||
}
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid $color-border;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-size: 11px;
|
||||
color: $color-text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__severity-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__severity {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
color: white;
|
||||
|
||||
&--critical {
|
||||
background: $color-critical;
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: $color-high;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: $color-medium;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: $color-low;
|
||||
}
|
||||
|
||||
&--info {
|
||||
background: $color-info;
|
||||
}
|
||||
}
|
||||
|
||||
&__preview {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
&__preview-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__sink-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__sink-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__sink-icon {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
color: $color-text-muted;
|
||||
|
||||
&.risk-drift-card__severity--critical {
|
||||
background: #fee2e2;
|
||||
color: $color-critical;
|
||||
}
|
||||
|
||||
&.risk-drift-card__severity--high {
|
||||
background: #ffedd5;
|
||||
color: $color-high;
|
||||
}
|
||||
|
||||
&.risk-drift-card__severity--medium {
|
||||
background: #fef3c7;
|
||||
color: $color-medium;
|
||||
}
|
||||
|
||||
&.risk-drift-card__severity--low {
|
||||
background: #fef9c3;
|
||||
color: $color-low;
|
||||
}
|
||||
}
|
||||
|
||||
&__sink-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__sink-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family-mono, 'SF Mono', Consolas, monospace);
|
||||
color: $color-text;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__sink-cve {
|
||||
font-size: 12px;
|
||||
color: $color-critical;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__sink-bucket {
|
||||
font-size: 11px;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__sink-delta {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family-mono, 'SF Mono', Consolas, monospace);
|
||||
|
||||
&.positive {
|
||||
color: $color-positive;
|
||||
}
|
||||
|
||||
&.negative {
|
||||
color: $color-negative;
|
||||
}
|
||||
}
|
||||
|
||||
&__more {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid $color-border;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
&__pr {
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
background: #f3f4f6;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #3b82f6;
|
||||
background: transparent;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* RiskDriftCardComponent - Drift Summary Card
|
||||
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
* Task: UI-007
|
||||
*/
|
||||
|
||||
import { Component, input, output, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DriftResult, DriftSummary, DriftedSink } from '../../models/drift.models';
|
||||
|
||||
/**
|
||||
* Summary card showing reachability drift results.
|
||||
* Displays risk trend, key metrics, and links to details.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <app-risk-drift-card
|
||||
* [drift]="driftResult"
|
||||
* [compact]="true"
|
||||
* (viewDetails)="onViewDetails()"
|
||||
* (sinkClick)="onSinkClick($event)">
|
||||
* </app-risk-drift-card>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-risk-drift-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './risk-drift-card.component.html',
|
||||
styleUrl: './risk-drift-card.component.scss'
|
||||
})
|
||||
export class RiskDriftCardComponent {
|
||||
/** The drift result to display */
|
||||
drift = input.required<DriftResult>();
|
||||
|
||||
/** Compact mode (less detail) */
|
||||
compact = input<boolean>(false);
|
||||
|
||||
/** Whether to show attestation badge */
|
||||
showAttestation = input<boolean>(true);
|
||||
|
||||
/** Maximum sinks to show in preview */
|
||||
maxPreviewSinks = input<number>(3);
|
||||
|
||||
/** Emits when "View Details" is clicked */
|
||||
viewDetails = output<void>();
|
||||
|
||||
/** Emits when a specific sink is clicked */
|
||||
sinkClick = output<DriftedSink>();
|
||||
|
||||
/** Computed: summary from drift */
|
||||
summary = computed<DriftSummary>(() => this.drift().summary);
|
||||
|
||||
/** Computed: is signed with DSSE */
|
||||
isSigned = computed(() => !!this.drift().attestationDigest);
|
||||
|
||||
/** Computed: risk trend icon */
|
||||
trendIcon = computed(() => {
|
||||
const trend = this.summary().riskTrend;
|
||||
switch (trend) {
|
||||
case 'increasing':
|
||||
return '↑';
|
||||
case 'decreasing':
|
||||
return '↓';
|
||||
default:
|
||||
return '→';
|
||||
}
|
||||
});
|
||||
|
||||
/** Computed: risk trend CSS class */
|
||||
trendClass = computed(() => {
|
||||
const trend = this.summary().riskTrend;
|
||||
return `risk-drift-card__trend--${trend}`;
|
||||
});
|
||||
|
||||
/** Computed: top drifted sinks to preview */
|
||||
previewSinks = computed(() => {
|
||||
const sinks = this.drift().driftedSinks;
|
||||
const max = this.maxPreviewSinks();
|
||||
// Sort by risk delta (highest first), then severity
|
||||
return sinks
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
const aSev = severityOrder[a.severity ?? 'info'];
|
||||
const bSev = severityOrder[b.severity ?? 'info'];
|
||||
if (aSev !== bSev) return aSev - bSev;
|
||||
return b.riskDelta - a.riskDelta;
|
||||
})
|
||||
.slice(0, max);
|
||||
});
|
||||
|
||||
/** Computed: additional sinks count */
|
||||
additionalSinksCount = computed(() => {
|
||||
return Math.max(0, this.drift().driftedSinks.length - this.maxPreviewSinks());
|
||||
});
|
||||
|
||||
/** Handle view details click */
|
||||
onViewDetails(): void {
|
||||
this.viewDetails.emit();
|
||||
}
|
||||
|
||||
/** Handle sink click */
|
||||
onSinkClick(sink: DriftedSink): void {
|
||||
this.sinkClick.emit(sink);
|
||||
}
|
||||
|
||||
/** Format risk delta */
|
||||
formatRiskDelta(delta: number): string {
|
||||
if (delta > 0) return `+${delta}`;
|
||||
return delta.toString();
|
||||
}
|
||||
|
||||
/** Get severity badge class */
|
||||
getSeverityClass(severity?: string): string {
|
||||
return severity ? `risk-drift-card__severity--${severity}` : '';
|
||||
}
|
||||
|
||||
/** Format timestamp */
|
||||
formatTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
/** Get bucket label */
|
||||
getBucketLabel(bucket: string | null): string {
|
||||
if (!bucket) return 'N/A';
|
||||
const labels: Record<string, string> = {
|
||||
entrypoint: 'Entry Point',
|
||||
direct: 'Direct',
|
||||
runtime: 'Runtime',
|
||||
unknown: 'Unknown',
|
||||
unreachable: 'Unreachable'
|
||||
};
|
||||
return labels[bucket] ?? bucket;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user