save progress

This commit is contained in:
StellaOps Bot
2026-01-04 14:54:52 +02:00
parent c49b03a254
commit 3098e84de4
132 changed files with 19783 additions and 31 deletions

View File

@@ -0,0 +1,302 @@
/**
* Noise-Gating API client.
* Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui (NG-FE-003)
* Description: API client for noise-gating delta reports from VexLens.
*/
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, InjectionToken, inject, signal, computed } from '@angular/core';
import { Observable, throwError, of, shareReplay } from 'rxjs';
import { map, catchError, tap } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { generateTraceId } from './trace.util';
import {
NoiseGatingDeltaReport,
ComputeDeltaRequest,
GatedSnapshotResponse,
GateSnapshotRequest,
AggregatedGatingStatistics,
GatingStatisticsQuery,
} from './noise-gating.models';
/**
* Query options for noise-gating API calls.
*/
export interface NoiseGatingQueryOptions {
traceId?: string;
bypassCache?: boolean;
}
/**
* Noise-gating API interface.
*/
export interface NoiseGatingApi {
// Delta operations
computeDelta(request: ComputeDeltaRequest, options?: NoiseGatingQueryOptions): Observable<NoiseGatingDeltaReport>;
// Snapshot operations
gateSnapshot(snapshotId: string, request: GateSnapshotRequest, options?: NoiseGatingQueryOptions): Observable<GatedSnapshotResponse>;
// Statistics
getGatingStatistics(query?: GatingStatisticsQuery, options?: NoiseGatingQueryOptions): Observable<AggregatedGatingStatistics>;
}
export const NOISE_GATING_API = new InjectionToken<NoiseGatingApi>('NOISE_GATING_API');
export const NOISE_GATING_API_BASE_URL = new InjectionToken<string>('NOISE_GATING_API_BASE_URL');
const normalizeBaseUrl = (baseUrl: string): string =>
baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
/**
* HTTP implementation of noise-gating API client.
*/
@Injectable({ providedIn: 'root' })
export class NoiseGatingApiHttpClient implements NoiseGatingApi {
private readonly http = inject(HttpClient);
private readonly authSession = inject(AuthSessionStore);
private readonly baseUrl = normalizeBaseUrl(
inject(NOISE_GATING_API_BASE_URL, { optional: true }) ?? '/api/v1/vexlens'
);
// Cache for delta reports (key: fromId|toId)
private readonly deltaCache = new Map<string, Observable<NoiseGatingDeltaReport>>();
// Signal-based state for current delta report
private readonly _currentReport = signal<NoiseGatingDeltaReport | null>(null);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
/** Current delta report signal */
readonly currentReport = this._currentReport.asReadonly();
/** Loading state signal */
readonly loading = this._loading.asReadonly();
/** Error state signal */
readonly error = this._error.asReadonly();
/** Computed: whether current report has actionable changes */
readonly hasActionableChanges = computed(() => this._currentReport()?.hasActionableChanges ?? false);
/** Computed: summary from current report */
readonly summary = computed(() => this._currentReport()?.summary ?? null);
computeDelta(
request: ComputeDeltaRequest,
options: NoiseGatingQueryOptions = {}
): Observable<NoiseGatingDeltaReport> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
const cacheKey = `${request.fromSnapshotId}|${request.toSnapshotId}`;
// Check cache unless bypass requested
if (!options.bypassCache && this.deltaCache.has(cacheKey)) {
return this.deltaCache.get(cacheKey)!;
}
this._loading.set(true);
this._error.set(null);
const obs = this.http
.post<NoiseGatingDeltaReport>(`${this.baseUrl}/deltas/compute`, request, { headers })
.pipe(
tap((report) => {
this._currentReport.set(report);
this._loading.set(false);
}),
catchError((err) => {
this._loading.set(false);
this._error.set(this.extractErrorMessage(err, traceId));
return throwError(() => this.mapError(err, traceId));
}),
shareReplay(1)
);
this.deltaCache.set(cacheKey, obs);
return obs;
}
gateSnapshot(
snapshotId: string,
request: GateSnapshotRequest,
options: NoiseGatingQueryOptions = {}
): Observable<GatedSnapshotResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http
.post<GatedSnapshotResponse>(
`${this.baseUrl}/gating/snapshots/${encodeURIComponent(snapshotId)}/gate`,
request,
{ headers }
)
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
getGatingStatistics(
query: GatingStatisticsQuery = {},
options: NoiseGatingQueryOptions = {}
): Observable<AggregatedGatingStatistics> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
let params = new HttpParams();
if (query.tenantId) params = params.set('tenantId', query.tenantId);
if (query.fromDate) params = params.set('fromDate', query.fromDate);
if (query.toDate) params = params.set('toDate', query.toDate);
return this.http
.get<AggregatedGatingStatistics>(`${this.baseUrl}/gating/statistics`, { headers, params })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
/** Clear the delta cache */
clearCache(): void {
this.deltaCache.clear();
this._currentReport.set(null);
this._error.set(null);
}
/** Clear current report state */
clearCurrentReport(): void {
this._currentReport.set(null);
this._error.set(null);
}
private buildHeaders(traceId: string): HttpHeaders {
const tenant = this.authSession.getActiveTenantId() || '';
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
'Content-Type': 'application/json',
Accept: 'application/json',
});
}
private extractErrorMessage(err: unknown, traceId: string): string {
if (err && typeof err === 'object' && 'error' in err) {
const httpError = err as { error?: { message?: string } };
if (httpError.error?.message) {
return httpError.error.message;
}
}
if (err instanceof Error) {
return err.message;
}
return `Request failed (trace: ${traceId})`;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Noise-gating error: ${err.message}`);
}
return new Error(`[${traceId}] Noise-gating error: Unknown error`);
}
}
/**
* Mock implementation for testing.
*/
@Injectable({ providedIn: 'root' })
export class MockNoiseGatingClient implements NoiseGatingApi {
private readonly mockReport: NoiseGatingDeltaReport = {
reportId: 'delta-mock-001',
fromSnapshotDigest: 'sha256:abc123',
toSnapshotDigest: 'sha256:def456',
generatedAt: new Date().toISOString(),
entries: [
{
section: 'new',
vulnerabilityId: 'CVE-2024-12345',
productKey: 'pkg:npm/lodash@4.17.20',
toStatus: 'affected',
toConfidence: 0.85,
summary: 'New affected finding',
createdAt: new Date().toISOString(),
},
{
section: 'resolved',
vulnerabilityId: 'CVE-2024-11111',
productKey: 'pkg:npm/axios@1.6.0',
fromStatus: 'affected',
toStatus: 'not_affected',
fromConfidence: 0.75,
toConfidence: 0.95,
justification: 'vulnerable_code_not_present',
summary: 'Resolved: affected -> not_affected',
createdAt: new Date().toISOString(),
},
{
section: 'confidence_up',
vulnerabilityId: 'CVE-2024-22222',
productKey: 'pkg:npm/express@4.18.2',
fromStatus: 'affected',
toStatus: 'affected',
fromConfidence: 0.6,
toConfidence: 0.9,
summary: 'Confidence increased: 60% -> 90%',
createdAt: new Date().toISOString(),
},
],
summary: {
totalCount: 3,
newCount: 1,
resolvedCount: 1,
confidenceUpCount: 1,
confidenceDownCount: 0,
policyImpactCount: 0,
dampedCount: 0,
evidenceChangedCount: 0,
},
hasActionableChanges: true,
};
computeDelta(
_request: ComputeDeltaRequest,
_options?: NoiseGatingQueryOptions
): Observable<NoiseGatingDeltaReport> {
return of(this.mockReport);
}
gateSnapshot(
snapshotId: string,
_request: GateSnapshotRequest,
_options?: NoiseGatingQueryOptions
): Observable<GatedSnapshotResponse> {
return of({
snapshotId,
digest: 'sha256:mock123',
createdAt: new Date().toISOString(),
edgeCount: 150,
verdictCount: 45,
statistics: {
originalEdgeCount: 200,
deduplicatedEdgeCount: 150,
edgeReductionPercent: 25,
totalVerdictCount: 50,
surfacedVerdictCount: 45,
dampedVerdictCount: 5,
duration: '00:00:01.234',
},
});
}
getGatingStatistics(
_query?: GatingStatisticsQuery,
_options?: NoiseGatingQueryOptions
): Observable<AggregatedGatingStatistics> {
return of({
totalSnapshots: 100,
totalEdgesProcessed: 15000,
totalEdgesAfterDedup: 12000,
averageEdgeReductionPercent: 20,
totalVerdicts: 5000,
totalSurfaced: 4500,
totalDamped: 500,
averageDampingPercent: 10,
computedAt: new Date().toISOString(),
});
}
}

View File

@@ -0,0 +1,266 @@
/**
* Noise-Gating Delta Report Models
* Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
* Description: TypeScript models for noise-gating delta reports from VexLens.
*/
import { VexStatementStatus } from './vex-hub.models';
// Delta section types - matches backend DeltaSection enum
export type NoiseGatingDeltaSection =
| 'new'
| 'resolved'
| 'confidence_up'
| 'confidence_down'
| 'policy_impact'
| 'damped'
| 'evidence_changed';
// VEX justification types for delta entries
export type VexJustification =
| 'component_not_present'
| 'vulnerable_code_not_present'
| 'vulnerable_code_not_in_execute_path'
| 'vulnerable_code_cannot_be_controlled_by_adversary'
| 'inline_mitigations_already_exist';
/**
* Single delta entry in API format.
*/
export interface NoiseGatingDeltaEntry {
readonly section: NoiseGatingDeltaSection;
readonly vulnerabilityId: string;
readonly productKey: string;
readonly fromStatus?: VexStatementStatus;
readonly toStatus?: VexStatementStatus;
readonly fromConfidence?: number;
readonly toConfidence?: number;
readonly justification?: VexJustification;
readonly fromRationaleClass?: string;
readonly toRationaleClass?: string;
readonly summary?: string;
readonly contributingSources?: readonly string[];
readonly createdAt: string;
}
/**
* Summary counts for delta report.
*/
export interface NoiseGatingDeltaSummary {
readonly totalCount: number;
readonly newCount: number;
readonly resolvedCount: number;
readonly confidenceUpCount: number;
readonly confidenceDownCount: number;
readonly policyImpactCount: number;
readonly dampedCount: number;
readonly evidenceChangedCount: number;
}
/**
* Delta report response from backend.
*/
export interface NoiseGatingDeltaReport {
readonly reportId: string;
readonly fromSnapshotDigest: string;
readonly toSnapshotDigest: string;
readonly generatedAt: string;
readonly entries: readonly NoiseGatingDeltaEntry[];
readonly summary: NoiseGatingDeltaSummary;
readonly hasActionableChanges: boolean;
}
/**
* Request to compute delta between two snapshots.
*/
export interface ComputeDeltaRequest {
readonly fromSnapshotId: string;
readonly toSnapshotId: string;
readonly tenantId?: string;
readonly options?: DeltaReportOptionsRequest;
}
/**
* Options for delta report computation.
*/
export interface DeltaReportOptionsRequest {
readonly confidenceChangeThreshold?: number;
readonly includeDamped?: boolean;
readonly includeEvidenceChanges?: boolean;
}
/**
* Gating statistics for API response.
*/
export interface GatingStatistics {
readonly originalEdgeCount: number;
readonly deduplicatedEdgeCount: number;
readonly edgeReductionPercent: number;
readonly totalVerdictCount: number;
readonly surfacedVerdictCount: number;
readonly dampedVerdictCount: number;
readonly duration: string;
}
/**
* Response from gating a snapshot.
*/
export interface GatedSnapshotResponse {
readonly snapshotId: string;
readonly digest: string;
readonly createdAt: string;
readonly edgeCount: number;
readonly verdictCount: number;
readonly statistics: GatingStatistics;
}
/**
* Request to gate a graph snapshot.
*/
export interface GateSnapshotRequest {
readonly snapshotId: string;
readonly tenantId?: string;
readonly options?: NoiseGateOptionsRequest;
}
/**
* Options for noise-gating.
*/
export interface NoiseGateOptionsRequest {
readonly edgeDeduplicationEnabled?: boolean;
readonly stabilityDampingEnabled?: boolean;
readonly minConfidenceThreshold?: number;
readonly confidenceChangeThreshold?: number;
}
/**
* Aggregated gating statistics response.
*/
export interface AggregatedGatingStatistics {
readonly totalSnapshots: number;
readonly totalEdgesProcessed: number;
readonly totalEdgesAfterDedup: number;
readonly averageEdgeReductionPercent: number;
readonly totalVerdicts: number;
readonly totalSurfaced: number;
readonly totalDamped: number;
readonly averageDampingPercent: number;
readonly computedAt: string;
}
/**
* Query parameters for gating statistics.
*/
export interface GatingStatisticsQuery {
readonly tenantId?: string;
readonly fromDate?: string;
readonly toDate?: string;
}
// === Helper Functions ===
/**
* Get display label for delta section.
*/
export function getSectionLabel(section: NoiseGatingDeltaSection): string {
switch (section) {
case 'new': return 'New';
case 'resolved': return 'Resolved';
case 'confidence_up': return 'Confidence Up';
case 'confidence_down': return 'Confidence Down';
case 'policy_impact': return 'Policy Impact';
case 'damped': return 'Damped';
case 'evidence_changed': return 'Evidence Changed';
default: return section;
}
}
/**
* Get CSS color class for delta section.
*/
export function getSectionColorClass(section: NoiseGatingDeltaSection): string {
switch (section) {
case 'new': return 'section-new';
case 'resolved': return 'section-resolved';
case 'confidence_up': return 'section-confidence-up';
case 'confidence_down': return 'section-confidence-down';
case 'policy_impact': return 'section-policy-impact';
case 'damped': return 'section-damped';
case 'evidence_changed': return 'section-evidence';
default: return 'section-unknown';
}
}
/**
* Get icon for delta section (ASCII-only per CLAUDE.md rules).
*/
export function getSectionIcon(section: NoiseGatingDeltaSection): string {
switch (section) {
case 'new': return '+';
case 'resolved': return '-';
case 'confidence_up': return '^';
case 'confidence_down': return 'v';
case 'policy_impact': return '!';
case 'damped': return '~';
case 'evidence_changed': return '*';
default: return '?';
}
}
/**
* Format confidence as percentage.
*/
export function formatConfidence(confidence?: number): string {
if (confidence === undefined || confidence === null) return '--';
return (confidence * 100).toFixed(0) + '%';
}
/**
* Format confidence delta.
*/
export function formatConfidenceDelta(from?: number, to?: number): string {
if (from === undefined || to === undefined) return '--';
const delta = (to - from) * 100;
const sign = delta >= 0 ? '+' : '';
return sign + delta.toFixed(0) + '%';
}
/**
* Check if a section represents an actionable change.
*/
export function isActionableSection(section: NoiseGatingDeltaSection): boolean {
return section === 'new' || section === 'policy_impact' || section === 'confidence_up';
}
/**
* Get the priority order for section sorting.
*/
export function getSectionPriority(section: NoiseGatingDeltaSection): number {
switch (section) {
case 'new': return 1;
case 'policy_impact': return 2;
case 'confidence_up': return 3;
case 'confidence_down': return 4;
case 'resolved': return 5;
case 'evidence_changed': return 6;
case 'damped': return 7;
default: return 99;
}
}
/**
* Group entries by section.
*/
export function groupEntriesBySection(
entries: readonly NoiseGatingDeltaEntry[]
): Map<NoiseGatingDeltaSection, NoiseGatingDeltaEntry[]> {
const grouped = new Map<NoiseGatingDeltaSection, NoiseGatingDeltaEntry[]>();
for (const entry of entries) {
const existing = grouped.get(entry.section) ?? [];
existing.push(entry);
grouped.set(entry.section, existing);
}
return grouped;
}

View File

@@ -58,3 +58,11 @@ export { VexTrustDisplayComponent } from './vex-trust-display/vex-trust-display.
export { ReplayCommandComponent } from './replay-command/replay-command.component';
export { VerdictLadderComponent } from './verdict-ladder/verdict-ladder.component';
export { CaseHeaderComponent } from './case-header/case-header.component';
// Noise-Gating Delta Report (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
export {
NoiseGatingSummaryStripComponent,
DeltaEntryCardComponent,
NoiseGatingDeltaReportComponent,
GatingStatisticsCardComponent,
} from './noise-gating';

View File

@@ -0,0 +1,344 @@
// -----------------------------------------------------------------------------
// delta-entry-card.component.ts
// Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
// Task: NG-FE-005 - DeltaEntryCardComponent
// Description: Card component for displaying individual delta entries.
// Shows CVE ID, package, status transition, and confidence changes.
// -----------------------------------------------------------------------------
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
NoiseGatingDeltaEntry,
getSectionLabel,
getSectionColorClass,
formatConfidence,
formatConfidenceDelta,
isActionableSection,
} from '../../../../core/api/noise-gating.models';
/**
* Card component for individual delta entries.
*/
@Component({
selector: 'app-delta-entry-card',
standalone: true,
imports: [CommonModule],
template: `
<div
class="delta-entry-card"
[class]="sectionClass()"
[class.actionable]="isActionable()"
(click)="onCardClick()"
role="button"
tabindex="0"
(keydown.enter)="onCardClick()"
(keydown.space)="onCardClick()"
>
<!-- Section badge -->
<div class="delta-entry-card__section">
<span class="delta-entry-card__section-badge">
{{ sectionLabel() }}
</span>
</div>
<!-- Main content -->
<div class="delta-entry-card__content">
<!-- Vulnerability ID -->
<div class="delta-entry-card__vuln">
<span class="delta-entry-card__vuln-id">{{ entry().vulnerabilityId }}</span>
</div>
<!-- Product/Package -->
<div class="delta-entry-card__product" [title]="entry().productKey">
{{ shortProductKey() }}
</div>
<!-- Status transition -->
@if (hasStatusChange()) {
<div class="delta-entry-card__status-change">
<span class="delta-entry-card__status delta-entry-card__status--from">
{{ entry().fromStatus ?? '-' }}
</span>
<span class="delta-entry-card__arrow">-></span>
<span class="delta-entry-card__status delta-entry-card__status--to">
{{ entry().toStatus ?? '-' }}
</span>
</div>
}
<!-- Confidence change -->
@if (hasConfidenceChange()) {
<div class="delta-entry-card__confidence">
<span class="delta-entry-card__confidence-from">{{ fromConfidence() }}</span>
<span class="delta-entry-card__arrow">-></span>
<span class="delta-entry-card__confidence-to">{{ toConfidence() }}</span>
<span class="delta-entry-card__confidence-delta" [class.positive]="isConfidenceUp()" [class.negative]="!isConfidenceUp()">
({{ confidenceDelta() }})
</span>
</div>
}
<!-- Summary -->
@if (entry().summary) {
<div class="delta-entry-card__summary">{{ entry().summary }}</div>
}
<!-- Justification -->
@if (entry().justification) {
<div class="delta-entry-card__justification">
<span class="delta-entry-card__justification-label">Justification:</span>
{{ formatJustification(entry().justification) }}
</div>
}
<!-- Contributing sources -->
@if (entry().contributingSources?.length) {
<div class="delta-entry-card__sources">
<span class="delta-entry-card__sources-label">Sources:</span>
@for (source of entry().contributingSources; track source) {
<span class="delta-entry-card__source-tag">{{ source }}</span>
}
</div>
}
</div>
<!-- Timestamp -->
<div class="delta-entry-card__timestamp">
{{ formattedTimestamp() }}
</div>
</div>
`,
styles: [`
.delta-entry-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem 1rem;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
.delta-entry-card:hover {
border-color: var(--primary-color, #3b82f6);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.delta-entry-card:focus {
outline: 2px solid var(--primary-color, #3b82f6);
outline-offset: 2px;
}
.delta-entry-card.actionable {
border-left: 3px solid var(--warning-color, #f59e0b);
}
/* Section-specific left border colors */
.delta-entry-card.section-new { border-left-color: #22c55e; }
.delta-entry-card.section-resolved { border-left-color: #3b82f6; }
.delta-entry-card.section-confidence-up { border-left-color: #14b8a6; }
.delta-entry-card.section-confidence-down { border-left-color: #f97316; }
.delta-entry-card.section-policy-impact { border-left-color: #ef4444; }
.delta-entry-card.section-damped { border-left-color: #9ca3af; }
.delta-entry-card.section-evidence { border-left-color: #8b5cf6; }
.delta-entry-card__section {
display: flex;
justify-content: flex-start;
}
.delta-entry-card__section-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
background: var(--bg-secondary, #f3f4f6);
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary, #6b7280);
}
.delta-entry-card__content {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.delta-entry-card__vuln-id {
font-weight: 600;
font-size: 0.9375rem;
color: var(--text-primary, #111827);
font-family: var(--font-mono, monospace);
}
.delta-entry-card__product {
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delta-entry-card__status-change,
.delta-entry-card__confidence {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
}
.delta-entry-card__arrow {
color: var(--text-muted, #9ca3af);
font-family: monospace;
}
.delta-entry-card__status {
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.delta-entry-card__status--from {
background: #f3f4f6;
color: #6b7280;
}
.delta-entry-card__status--to {
background: #dbeafe;
color: #1e40af;
}
.delta-entry-card__confidence-from,
.delta-entry-card__confidence-to {
font-family: var(--font-mono, monospace);
font-weight: 500;
}
.delta-entry-card__confidence-delta {
font-size: 0.75rem;
font-weight: 600;
}
.delta-entry-card__confidence-delta.positive { color: #15803d; }
.delta-entry-card__confidence-delta.negative { color: #dc2626; }
.delta-entry-card__summary {
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
line-height: 1.4;
}
.delta-entry-card__justification {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
.delta-entry-card__justification-label {
font-weight: 500;
margin-right: 0.25rem;
}
.delta-entry-card__sources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
}
.delta-entry-card__sources-label {
color: var(--text-muted, #9ca3af);
font-weight: 500;
}
.delta-entry-card__source-tag {
padding: 0.125rem 0.375rem;
background: #f3f4f6;
border-radius: 3px;
font-size: 0.6875rem;
color: #6b7280;
}
.delta-entry-card__timestamp {
font-size: 0.6875rem;
color: var(--text-muted, #9ca3af);
text-align: right;
}
`],
})
export class DeltaEntryCardComponent {
/** The delta entry to display */
readonly entry = input.required<NoiseGatingDeltaEntry>();
/** Emits when card is clicked */
readonly cardClick = output<NoiseGatingDeltaEntry>();
/** Section label */
readonly sectionLabel = computed(() => getSectionLabel(this.entry().section));
/** Section CSS class */
readonly sectionClass = computed(() => getSectionColorClass(this.entry().section));
/** Whether this is an actionable section */
readonly isActionable = computed(() => isActionableSection(this.entry().section));
/** Short product key (last part of PURL) */
readonly shortProductKey = computed(() => {
const purl = this.entry().productKey;
// Extract name@version from PURL like pkg:npm/name@version
const match = purl.match(/\/([^/]+)$/);
return match ? match[1] : purl;
});
/** Whether there's a status change */
readonly hasStatusChange = computed(() => {
const e = this.entry();
return e.fromStatus !== undefined && e.toStatus !== undefined && e.fromStatus !== e.toStatus;
});
/** Whether there's a confidence change */
readonly hasConfidenceChange = computed(() => {
const e = this.entry();
return e.fromConfidence !== undefined && e.toConfidence !== undefined;
});
/** Formatted from confidence */
readonly fromConfidence = computed(() => formatConfidence(this.entry().fromConfidence));
/** Formatted to confidence */
readonly toConfidence = computed(() => formatConfidence(this.entry().toConfidence));
/** Formatted confidence delta */
readonly confidenceDelta = computed(() =>
formatConfidenceDelta(this.entry().fromConfidence, this.entry().toConfidence)
);
/** Whether confidence increased */
readonly isConfidenceUp = computed(() => {
const e = this.entry();
if (e.fromConfidence === undefined || e.toConfidence === undefined) return false;
return e.toConfidence > e.fromConfidence;
});
/** Formatted timestamp */
readonly formattedTimestamp = computed(() => {
const ts = this.entry().createdAt;
if (!ts) return '';
try {
const date = new Date(ts);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return ts;
}
});
/** Handle card click */
onCardClick(): void {
this.cardClick.emit(this.entry());
}
/** Format justification for display */
formatJustification(j: string | undefined): string {
if (!j) return '';
// Convert snake_case to Title Case
return j.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
}

View File

@@ -0,0 +1,364 @@
// -----------------------------------------------------------------------------
// gating-statistics-card.component.ts
// Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
// Task: NG-FE-007 - GatingStatisticsCardComponent
// Description: Card component displaying noise-gating statistics.
// Shows edge reduction and verdict damping metrics.
// -----------------------------------------------------------------------------
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
GatingStatistics,
AggregatedGatingStatistics,
} from '../../../../core/api/noise-gating.models';
/**
* Card component for displaying gating statistics.
*/
@Component({
selector: 'app-gating-statistics-card',
standalone: true,
imports: [CommonModule],
template: `
<div class="gating-stats-card">
<header class="gating-stats-card__header">
<h3 class="gating-stats-card__title">{{ title() }}</h3>
@if (computedAt()) {
<span class="gating-stats-card__timestamp">{{ formattedComputedAt() }}</span>
}
</header>
<div class="gating-stats-card__body">
<!-- Edge Reduction Section -->
<div class="gating-stats-card__section">
<h4 class="gating-stats-card__section-title">Edge Deduplication</h4>
<div class="gating-stats-card__metrics">
<div class="gating-stats-card__metric">
<span class="gating-stats-card__metric-label">Original</span>
<span class="gating-stats-card__metric-value">{{ edgesOriginal() }}</span>
</div>
<div class="gating-stats-card__metric-arrow">-></div>
<div class="gating-stats-card__metric">
<span class="gating-stats-card__metric-label">After Dedup</span>
<span class="gating-stats-card__metric-value">{{ edgesDeduped() }}</span>
</div>
</div>
<div class="gating-stats-card__progress-container">
<div class="gating-stats-card__progress">
<div
class="gating-stats-card__progress-bar gating-stats-card__progress-bar--reduction"
[style.width.%]="edgeReductionPercent()"
></div>
</div>
<span class="gating-stats-card__progress-label">
{{ edgeReductionPercent().toFixed(1) }}% reduction
</span>
</div>
</div>
<!-- Verdict Damping Section -->
<div class="gating-stats-card__section">
<h4 class="gating-stats-card__section-title">Verdict Damping</h4>
<div class="gating-stats-card__verdict-stats">
<div class="gating-stats-card__verdict-stat gating-stats-card__verdict-stat--surfaced">
<span class="gating-stats-card__verdict-icon">+</span>
<span class="gating-stats-card__verdict-value">{{ verdictsSurfaced() }}</span>
<span class="gating-stats-card__verdict-label">Surfaced</span>
</div>
<div class="gating-stats-card__verdict-stat gating-stats-card__verdict-stat--damped">
<span class="gating-stats-card__verdict-icon">~</span>
<span class="gating-stats-card__verdict-value">{{ verdictsDamped() }}</span>
<span class="gating-stats-card__verdict-label">Damped</span>
</div>
<div class="gating-stats-card__verdict-stat gating-stats-card__verdict-stat--total">
<span class="gating-stats-card__verdict-icon">=</span>
<span class="gating-stats-card__verdict-value">{{ verdictsTotal() }}</span>
<span class="gating-stats-card__verdict-label">Total</span>
</div>
</div>
@if (verdictsTotal() > 0) {
<div class="gating-stats-card__ratio-bar">
<div
class="gating-stats-card__ratio-segment gating-stats-card__ratio-segment--surfaced"
[style.flex-grow]="verdictsSurfaced()"
[title]="verdictsSurfaced() + ' surfaced'"
></div>
<div
class="gating-stats-card__ratio-segment gating-stats-card__ratio-segment--damped"
[style.flex-grow]="verdictsDamped()"
[title]="verdictsDamped() + ' damped'"
></div>
</div>
}
</div>
<!-- Duration (if single snapshot) -->
@if (duration()) {
<div class="gating-stats-card__duration">
<span class="gating-stats-card__duration-label">Processing time:</span>
<span class="gating-stats-card__duration-value">{{ duration() }}</span>
</div>
}
<!-- Aggregated stats (if available) -->
@if (showAggregated() && aggregatedStats()) {
<div class="gating-stats-card__aggregated">
<h4 class="gating-stats-card__section-title">Aggregated</h4>
<div class="gating-stats-card__agg-stats">
<div class="gating-stats-card__agg-stat">
<span class="gating-stats-card__agg-value">{{ aggregatedStats()!.totalSnapshots }}</span>
<span class="gating-stats-card__agg-label">snapshots</span>
</div>
<div class="gating-stats-card__agg-stat">
<span class="gating-stats-card__agg-value">{{ aggregatedStats()!.averageEdgeReductionPercent.toFixed(1) }}%</span>
<span class="gating-stats-card__agg-label">avg reduction</span>
</div>
<div class="gating-stats-card__agg-stat">
<span class="gating-stats-card__agg-value">{{ aggregatedStats()!.averageDampingPercent.toFixed(1) }}%</span>
<span class="gating-stats-card__agg-label">avg damping</span>
</div>
</div>
</div>
}
</div>
</div>
`,
styles: [`
.gating-stats-card {
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.gating-stats-card__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
background: var(--bg-secondary, #f9fafb);
}
.gating-stats-card__title {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
.gating-stats-card__timestamp {
font-size: 0.6875rem;
color: var(--text-muted, #9ca3af);
}
.gating-stats-card__body {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.gating-stats-card__section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.gating-stats-card__section-title {
margin: 0;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Edge metrics */
.gating-stats-card__metrics {
display: flex;
align-items: center;
gap: 0.5rem;
}
.gating-stats-card__metric {
display: flex;
flex-direction: column;
align-items: center;
}
.gating-stats-card__metric-label {
font-size: 0.6875rem;
color: var(--text-muted, #9ca3af);
}
.gating-stats-card__metric-value {
font-size: 1.125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text-primary, #111827);
}
.gating-stats-card__metric-arrow {
color: var(--text-muted, #9ca3af);
font-family: monospace;
}
/* Progress bar */
.gating-stats-card__progress-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.gating-stats-card__progress {
flex: 1;
height: 6px;
background: var(--bg-tertiary, #e5e7eb);
border-radius: 3px;
overflow: hidden;
}
.gating-stats-card__progress-bar {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.gating-stats-card__progress-bar--reduction {
background: linear-gradient(90deg, #22c55e, #16a34a);
}
.gating-stats-card__progress-label {
font-size: 0.75rem;
font-weight: 500;
color: #15803d;
white-space: nowrap;
}
/* Verdict stats */
.gating-stats-card__verdict-stats {
display: flex;
gap: 1rem;
}
.gating-stats-card__verdict-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: 6px;
min-width: 60px;
}
.gating-stats-card__verdict-stat--surfaced {
background: #dcfce7;
}
.gating-stats-card__verdict-stat--damped {
background: #f3f4f6;
}
.gating-stats-card__verdict-stat--total {
background: #dbeafe;
}
.gating-stats-card__verdict-icon {
font-family: monospace;
font-weight: 700;
font-size: 0.875rem;
}
.gating-stats-card__verdict-stat--surfaced .gating-stats-card__verdict-icon { color: #15803d; }
.gating-stats-card__verdict-stat--damped .gating-stats-card__verdict-icon { color: #6b7280; }
.gating-stats-card__verdict-stat--total .gating-stats-card__verdict-icon { color: #1e40af; }
.gating-stats-card__verdict-value {
font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.gating-stats-card__verdict-label {
font-size: 0.6875rem;
color: var(--text-secondary, #6b7280);
}
/* Ratio bar */
.gating-stats-card__ratio-bar {
display: flex;
height: 8px;
border-radius: 4px;
overflow: hidden;
}
.gating-stats-card__ratio-segment {
min-width: 2px;
transition: flex-grow 0.3s ease;
}
.gating-stats-card__ratio-segment--surfaced {
background: #22c55e;
}
.gating-stats-card__ratio-segment--damped {
background: #9ca3af;
}
/* Duration */
.gating-stats-card__duration {
display: flex;
justify-content: space-between;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e5e7eb);
font-size: 0.75rem;
}
.gating-stats-card__duration-label {
color: var(--text-muted, #9ca3af);
}
.gating-stats-card__duration-value {
font-family: var(--font-mono, monospace);
color: var(--text-secondary, #6b7280);
}
/* Aggregated */
.gating-stats-card__aggregated {
padding-top: 0.75rem;
border-top: 1px solid var(--border-color, #e5e7eb);
}
.gating-stats-card__agg-stats {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.gating-stats-card__agg-stat {
display: flex;
flex-direction: column;
align-items: center;
}
.gating-stats-card__agg-value {
font-size: 0.9375rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.gating-stats-card__agg-label {
font-size: 0.625rem;
color: var(--text-muted, #9ca3af);
}
`],
})
export class GatingStatisticsCardComponent {
/** Single snapshot statistics */
readonly statistics = input<GatingStatistics | null>(null);
/** Aggregated statistics (for overview) */
readonly aggregatedStats = input<AggregatedGatingStatistics | null>(null);
/** Card title */
readonly title = input('Gating Statistics');
/** Whether to show aggregated section */
readonly showAggregated = input(false);
// Computed values for single snapshot
readonly edgesOriginal = computed(() => this.statistics()?.originalEdgeCount ?? 0);
readonly edgesDeduped = computed(() => this.statistics()?.deduplicatedEdgeCount ?? 0);
readonly edgeReductionPercent = computed(() => this.statistics()?.edgeReductionPercent ?? 0);
readonly verdictsTotal = computed(() => this.statistics()?.totalVerdictCount ?? 0);
readonly verdictsSurfaced = computed(() => this.statistics()?.surfacedVerdictCount ?? 0);
readonly verdictsDamped = computed(() => this.statistics()?.dampedVerdictCount ?? 0);
readonly duration = computed(() => this.statistics()?.duration);
/** Computed timestamp from aggregated stats */
readonly computedAt = computed(() => this.aggregatedStats()?.computedAt);
/** Formatted computed at timestamp */
readonly formattedComputedAt = computed(() => {
const ts = this.computedAt();
if (!ts) return '';
try {
return new Date(ts).toLocaleString();
} catch {
return ts;
}
});
}

View File

@@ -0,0 +1,10 @@
// -----------------------------------------------------------------------------
// index.ts
// Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
// Description: Barrel exports for noise-gating components.
// -----------------------------------------------------------------------------
export { NoiseGatingSummaryStripComponent } from './noise-gating-summary-strip.component';
export { DeltaEntryCardComponent } from './delta-entry-card.component';
export { NoiseGatingDeltaReportComponent } from './noise-gating-delta-report.component';
export { GatingStatisticsCardComponent } from './gating-statistics-card.component';

View File

@@ -0,0 +1,393 @@
// -----------------------------------------------------------------------------
// noise-gating-delta-report.component.ts
// Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
// Task: NG-FE-006 - NoiseGatingDeltaReportComponent
// Description: Container component for displaying noise-gating delta reports.
// Uses tabs for section navigation with summary strip and entry cards.
// -----------------------------------------------------------------------------
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
NoiseGatingDeltaReport,
NoiseGatingDeltaEntry,
NoiseGatingDeltaSection,
getSectionLabel,
groupEntriesBySection,
getSectionPriority,
} from '../../../../core/api/noise-gating.models';
import { NoiseGatingSummaryStripComponent } from './noise-gating-summary-strip.component';
import { DeltaEntryCardComponent } from './delta-entry-card.component';
/** Tab definition for section navigation */
interface SectionTab {
section: NoiseGatingDeltaSection;
label: string;
count: number;
}
/**
* Container component for noise-gating delta reports.
* Provides tabbed navigation through delta sections with entry cards.
*/
@Component({
selector: 'app-noise-gating-delta-report',
standalone: true,
imports: [
CommonModule,
NoiseGatingSummaryStripComponent,
DeltaEntryCardComponent,
],
template: `
<div class="ng-delta-report">
<!-- Header with summary -->
<header class="ng-delta-report__header">
<div class="ng-delta-report__title">
<h2>Delta Report</h2>
@if (report()) {
<span class="ng-delta-report__id">{{ report()!.reportId }}</span>
}
</div>
@if (report()?.generatedAt) {
<div class="ng-delta-report__meta">
Generated: {{ formattedGeneratedAt() }}
</div>
}
</header>
<!-- Summary strip -->
@if (report()) {
<app-noise-gating-summary-strip
[summary]="report()!.summary"
[hasActionableChanges]="report()!.hasActionableChanges"
[activeSection]="activeSection()"
[showDamped]="showDamped()"
[showEvidenceChanges]="showEvidenceChanges()"
(sectionClick)="onSectionSelect($event)"
/>
}
<!-- Tab navigation -->
<nav class="ng-delta-report__tabs" role="tablist">
@for (tab of tabs(); track tab.section) {
<button
type="button"
class="ng-delta-report__tab"
[class.active]="activeSection() === tab.section"
[class.empty]="tab.count === 0"
role="tab"
[attr.aria-selected]="activeSection() === tab.section"
[attr.aria-controls]="'panel-' + tab.section"
(click)="onSectionSelect(tab.section)"
>
<span class="ng-delta-report__tab-label">{{ tab.label }}</span>
<span class="ng-delta-report__tab-count">{{ tab.count }}</span>
</button>
}
</nav>
<!-- Content area -->
<div class="ng-delta-report__content">
@if (loading()) {
<div class="ng-delta-report__loading">
<div class="ng-delta-report__spinner"></div>
<span>Loading delta report...</span>
</div>
} @else if (error()) {
<div class="ng-delta-report__error">
<span class="ng-delta-report__error-icon">!</span>
<span>{{ error() }}</span>
</div>
} @else if (!report()) {
<div class="ng-delta-report__empty">
<span>No delta report available</span>
<span class="ng-delta-report__empty-hint">
Select two snapshots to compare
</span>
</div>
} @else if (filteredEntries().length === 0) {
<div class="ng-delta-report__no-entries">
<span>No entries in this section</span>
</div>
} @else {
<div
class="ng-delta-report__entries"
role="tabpanel"
[attr.id]="'panel-' + activeSection()"
>
@for (entry of filteredEntries(); track entry.vulnerabilityId + entry.productKey) {
<app-delta-entry-card
[entry]="entry"
(cardClick)="onEntryClick($event)"
/>
}
</div>
}
</div>
<!-- Footer with snapshot info -->
@if (report()) {
<footer class="ng-delta-report__footer">
<div class="ng-delta-report__snapshot-info">
<span class="ng-delta-report__snapshot">
From: <code>{{ truncateDigest(report()!.fromSnapshotDigest) }}</code>
</span>
<span class="ng-delta-report__snapshot">
To: <code>{{ truncateDigest(report()!.toSnapshotDigest) }}</code>
</span>
</div>
</footer>
}
</div>
`,
styles: [`
.ng-delta-report {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 12px;
max-height: 100%;
overflow: hidden;
}
.ng-delta-report__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.5rem;
}
.ng-delta-report__title {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.ng-delta-report__title h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
.ng-delta-report__id {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
font-family: var(--font-mono, monospace);
}
.ng-delta-report__meta {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
/* Tabs */
.ng-delta-report__tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
padding-bottom: 0.5rem;
overflow-x: auto;
}
.ng-delta-report__tab {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
transition: background-color 0.15s, color 0.15s;
white-space: nowrap;
}
.ng-delta-report__tab:hover:not(.empty) {
background: var(--bg-secondary, #f3f4f6);
color: var(--text-primary, #111827);
}
.ng-delta-report__tab.active {
background: var(--primary-color, #3b82f6);
color: #fff;
}
.ng-delta-report__tab.empty {
opacity: 0.4;
cursor: default;
}
.ng-delta-report__tab-label {
font-weight: 500;
}
.ng-delta-report__tab-count {
padding: 0.125rem 0.375rem;
background: rgba(0, 0, 0, 0.1);
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
}
.ng-delta-report__tab.active .ng-delta-report__tab-count {
background: rgba(255, 255, 255, 0.25);
}
/* Content */
.ng-delta-report__content {
flex: 1;
overflow-y: auto;
min-height: 200px;
}
.ng-delta-report__entries {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* States */
.ng-delta-report__loading,
.ng-delta-report__error,
.ng-delta-report__empty,
.ng-delta-report__no-entries {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
text-align: center;
color: var(--text-muted, #9ca3af);
}
.ng-delta-report__spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color, #e5e7eb);
border-top-color: var(--primary-color, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.ng-delta-report__error {
color: var(--error-color, #dc2626);
}
.ng-delta-report__error-icon {
width: 24px;
height: 24px;
border-radius: 50%;
background: #fee2e2;
color: #dc2626;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.ng-delta-report__empty-hint {
font-size: 0.75rem;
}
/* Footer */
.ng-delta-report__footer {
padding-top: 0.75rem;
border-top: 1px solid var(--border-color, #e5e7eb);
}
.ng-delta-report__snapshot-info {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
.ng-delta-report__snapshot code {
font-family: var(--font-mono, monospace);
background: var(--bg-secondary, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 3px;
}
`],
})
export class NoiseGatingDeltaReportComponent {
/** The delta report to display */
readonly report = input<NoiseGatingDeltaReport | null>(null);
/** Loading state */
readonly loading = input(false);
/** Error message */
readonly error = input<string | null>(null);
/** Whether to show damped section */
readonly showDamped = input(true);
/** Whether to show evidence changes section */
readonly showEvidenceChanges = input(true);
/** Emits when an entry card is clicked */
readonly entryClick = output<NoiseGatingDeltaEntry>();
/** Currently active section */
readonly activeSection = signal<NoiseGatingDeltaSection>('new');
/** Available tabs based on report content */
readonly tabs = computed<SectionTab[]>(() => {
const r = this.report();
if (!r) return [];
const allSections: SectionTab[] = [
{ section: 'new', label: 'New', count: r.summary.newCount },
{ section: 'resolved', label: 'Resolved', count: r.summary.resolvedCount },
{ section: 'confidence_up', label: 'Conf+', count: r.summary.confidenceUpCount },
{ section: 'confidence_down', label: 'Conf-', count: r.summary.confidenceDownCount },
{ section: 'policy_impact', label: 'Policy', count: r.summary.policyImpactCount },
];
if (this.showDamped()) {
allSections.push({ section: 'damped', label: 'Damped', count: r.summary.dampedCount });
}
if (this.showEvidenceChanges()) {
allSections.push({ section: 'evidence_changed', label: 'Evidence', count: r.summary.evidenceChangedCount });
}
// Sort by priority and filter out empty if configured
return allSections.sort((a, b) => getSectionPriority(a.section) - getSectionPriority(b.section));
});
/** Entries filtered by active section */
readonly filteredEntries = computed<NoiseGatingDeltaEntry[]>(() => {
const r = this.report();
if (!r) return [];
const grouped = groupEntriesBySection(r.entries);
return grouped.get(this.activeSection()) ?? [];
});
/** Formatted generated timestamp */
readonly formattedGeneratedAt = computed(() => {
const ts = this.report()?.generatedAt;
if (!ts) return '';
try {
return new Date(ts).toLocaleString();
} catch {
return ts;
}
});
/** Handle section selection */
onSectionSelect(section: NoiseGatingDeltaSection): void {
this.activeSection.set(section);
}
/** Handle entry card click */
onEntryClick(entry: NoiseGatingDeltaEntry): void {
this.entryClick.emit(entry);
}
/** Truncate digest for display */
truncateDigest(digest: string): string {
if (!digest) return '';
if (digest.length <= 20) return digest;
// Format: sha256:abc123...def456
const prefix = digest.slice(0, 12);
const suffix = digest.slice(-6);
return `${prefix}...${suffix}`;
}
}

View File

@@ -0,0 +1,305 @@
// -----------------------------------------------------------------------------
// noise-gating-summary-strip.component.ts
// Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui
// Task: NG-FE-004 - NoiseGatingSummaryStripComponent
// Description: Summary strip showing delta section counts for noise-gating reports.
// Follows DeltaSummaryStripComponent pattern with noise-gating-specific sections.
// -----------------------------------------------------------------------------
import { Component, input, computed, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
NoiseGatingDeltaSummary,
NoiseGatingDeltaSection,
getSectionLabel,
} from '../../../../core/api/noise-gating.models';
/**
* Summary strip component for noise-gating delta reports.
* Displays section counts as interactive badges.
*/
@Component({
selector: 'app-noise-gating-summary-strip',
standalone: true,
imports: [CommonModule],
template: `
<div class="ng-summary-strip" role="status" aria-live="polite">
<div class="ng-summary-strip__counts">
<!-- New findings -->
<button
type="button"
class="ng-badge ng-badge--new"
[class.empty]="!summary()?.newCount"
[class.active]="activeSection() === 'new'"
(click)="onSectionClick('new')"
[attr.aria-label]="summary()?.newCount + ' new findings'"
>
<span class="ng-badge__icon">+</span>
<span class="ng-badge__count">{{ summary()?.newCount ?? 0 }}</span>
<span class="ng-badge__label">new</span>
</button>
<!-- Resolved -->
<button
type="button"
class="ng-badge ng-badge--resolved"
[class.empty]="!summary()?.resolvedCount"
[class.active]="activeSection() === 'resolved'"
(click)="onSectionClick('resolved')"
[attr.aria-label]="summary()?.resolvedCount + ' resolved findings'"
>
<span class="ng-badge__icon">-</span>
<span class="ng-badge__count">{{ summary()?.resolvedCount ?? 0 }}</span>
<span class="ng-badge__label">resolved</span>
</button>
<!-- Confidence Up -->
<button
type="button"
class="ng-badge ng-badge--confidence-up"
[class.empty]="!summary()?.confidenceUpCount"
[class.active]="activeSection() === 'confidence_up'"
(click)="onSectionClick('confidence_up')"
[attr.aria-label]="summary()?.confidenceUpCount + ' confidence increased'"
>
<span class="ng-badge__icon">^</span>
<span class="ng-badge__count">{{ summary()?.confidenceUpCount ?? 0 }}</span>
<span class="ng-badge__label">conf+</span>
</button>
<!-- Confidence Down -->
<button
type="button"
class="ng-badge ng-badge--confidence-down"
[class.empty]="!summary()?.confidenceDownCount"
[class.active]="activeSection() === 'confidence_down'"
(click)="onSectionClick('confidence_down')"
[attr.aria-label]="summary()?.confidenceDownCount + ' confidence decreased'"
>
<span class="ng-badge__icon">v</span>
<span class="ng-badge__count">{{ summary()?.confidenceDownCount ?? 0 }}</span>
<span class="ng-badge__label">conf-</span>
</button>
<!-- Policy Impact -->
<button
type="button"
class="ng-badge ng-badge--policy"
[class.empty]="!summary()?.policyImpactCount"
[class.active]="activeSection() === 'policy_impact'"
(click)="onSectionClick('policy_impact')"
[attr.aria-label]="summary()?.policyImpactCount + ' policy impacts'"
>
<span class="ng-badge__icon">!</span>
<span class="ng-badge__count">{{ summary()?.policyImpactCount ?? 0 }}</span>
<span class="ng-badge__label">policy</span>
</button>
@if (showDamped()) {
<!-- Damped -->
<button
type="button"
class="ng-badge ng-badge--damped"
[class.empty]="!summary()?.dampedCount"
[class.active]="activeSection() === 'damped'"
(click)="onSectionClick('damped')"
[attr.aria-label]="summary()?.dampedCount + ' damped'"
>
<span class="ng-badge__icon">~</span>
<span class="ng-badge__count">{{ summary()?.dampedCount ?? 0 }}</span>
<span class="ng-badge__label">damped</span>
</button>
}
@if (showEvidenceChanges()) {
<!-- Evidence Changed -->
<button
type="button"
class="ng-badge ng-badge--evidence"
[class.empty]="!summary()?.evidenceChangedCount"
[class.active]="activeSection() === 'evidence_changed'"
(click)="onSectionClick('evidence_changed')"
[attr.aria-label]="summary()?.evidenceChangedCount + ' evidence changes'"
>
<span class="ng-badge__icon">*</span>
<span class="ng-badge__count">{{ summary()?.evidenceChangedCount ?? 0 }}</span>
<span class="ng-badge__label">evidence</span>
</button>
}
</div>
<div class="ng-summary-strip__meta">
<span class="ng-summary-strip__total">
<span class="ng-summary-strip__total-label">Total:</span>
<span class="ng-summary-strip__total-count">{{ totalCount() }}</span>
</span>
@if (hasActionableChanges()) {
<span class="ng-summary-strip__actionable" title="Has actionable changes">
Action needed
</span>
}
</div>
</div>
`,
styles: [`
.ng-summary-strip {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-secondary, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
flex-wrap: wrap;
gap: 0.75rem;
}
.ng-summary-strip__counts {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.ng-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: opacity 0.15s, transform 0.15s, box-shadow 0.15s;
}
.ng-badge:hover:not(.empty) { transform: scale(1.03); }
.ng-badge.empty { opacity: 0.4; cursor: default; }
.ng-badge.active {
box-shadow: 0 0 0 2px var(--primary-color, #3b82f6);
transform: scale(1.05);
}
.ng-badge__icon {
font-weight: 700;
font-size: 0.875rem;
font-family: monospace;
}
.ng-badge__count {
font-variant-numeric: tabular-nums;
min-width: 1.25ch;
text-align: center;
}
.ng-badge__label {
font-size: 0.6875rem;
font-weight: 400;
text-transform: lowercase;
}
/* Section colors */
.ng-badge--new {
background: #dcfce7;
color: #15803d;
border-color: #86efac;
}
.ng-badge--resolved {
background: #dbeafe;
color: #1e40af;
border-color: #93c5fd;
}
.ng-badge--confidence-up {
background: #ccfbf1;
color: #0d9488;
border-color: #5eead4;
}
.ng-badge--confidence-down {
background: #ffedd5;
color: #c2410c;
border-color: #fdba74;
}
.ng-badge--policy {
background: #fee2e2;
color: #dc2626;
border-color: #fca5a5;
}
.ng-badge--damped {
background: #f3f4f6;
color: #6b7280;
border-color: #d1d5db;
}
.ng-badge--evidence {
background: #f3e8ff;
color: #7c3aed;
border-color: #c4b5fd;
}
.ng-summary-strip__meta {
display: flex;
align-items: center;
gap: 1rem;
}
.ng-summary-strip__total {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
}
.ng-summary-strip__total-label { color: var(--text-muted, #6b7280); }
.ng-summary-strip__total-count { font-weight: 600; }
.ng-summary-strip__actionable {
padding: 0.25rem 0.5rem;
background: #fef3c7;
color: #92400e;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
`],
})
export class NoiseGatingSummaryStripComponent {
/** The delta summary to display */
readonly summary = input<NoiseGatingDeltaSummary | null>(null);
/** Whether to show damped section */
readonly showDamped = input(true);
/** Whether to show evidence changes section */
readonly showEvidenceChanges = input(true);
/** Whether the report has actionable changes */
readonly hasActionableChanges = input(false);
/** Currently active/selected section */
readonly activeSection = input<NoiseGatingDeltaSection | null>(null);
/** Emits when a section badge is clicked */
readonly sectionClick = output<NoiseGatingDeltaSection>();
/** Total count of all entries */
readonly totalCount = computed(() => {
const s = this.summary();
if (!s) return 0;
return s.totalCount;
});
/** Handle section badge click */
onSectionClick(section: NoiseGatingDeltaSection): void {
const count = this.getSectionCount(section);
if (count > 0) {
this.sectionClick.emit(section);
}
}
/** Get count for a specific section */
private getSectionCount(section: NoiseGatingDeltaSection): number {
const s = this.summary();
if (!s) return 0;
switch (section) {
case 'new': return s.newCount;
case 'resolved': return s.resolvedCount;
case 'confidence_up': return s.confidenceUpCount;
case 'confidence_down': return s.confidenceDownCount;
case 'policy_impact': return s.policyImpactCount;
case 'damped': return s.dampedCount;
case 'evidence_changed': return s.evidenceChangedCount;
default: return 0;
}
}
}

View File

@@ -22,8 +22,21 @@ import { VulnerabilityListService, type Vulnerability, type VulnerabilityFilter
import { AdvisoryAiService, type AiRecommendation } from '../../services/advisory-ai.service';
import { VexDecisionService, type VexDecision } from '../../services/vex-decision.service';
// Noise-gating delta report integration (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
import { NoiseGatingDeltaReportComponent } from '../noise-gating/noise-gating-delta-report.component';
import { GatingStatisticsCardComponent } from '../noise-gating/gating-statistics-card.component';
import {
NoiseGatingApiClient,
NOISE_GATING_API_CLIENT,
} from '../../../../core/api/noise-gating.client';
import {
NoiseGatingDeltaReport,
NoiseGatingDeltaEntry,
GatingStatistics,
} from '../../../../core/api/noise-gating.models';
export type CanvasPaneMode = 'list' | 'split' | 'detail';
export type CanvasDetailTab = 'overview' | 'reachability' | 'ai' | 'history' | 'evidence';
export type CanvasDetailTab = 'overview' | 'reachability' | 'ai' | 'history' | 'evidence' | 'delta';
interface CanvasLayout {
leftPaneWidth: number;
@@ -34,7 +47,7 @@ interface CanvasLayout {
@Component({
selector: 'app-triage-canvas',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, NoiseGatingDeltaReportComponent, GatingStatisticsCardComponent],
template: `
<div class="triage-canvas" [class.triage-canvas--split]="layout().mode === 'split'" [class.triage-canvas--detail]="layout().mode === 'detail'">
<!-- Header -->
@@ -413,6 +426,26 @@ interface CanvasLayout {
</div>
</section>
}
@case ('delta') {
<section class="detail-panel detail-panel--delta" id="panel-delta" role="tabpanel" aria-labelledby="tab-delta">
<div class="delta-panel-layout">
<div class="delta-panel-layout__main">
<app-noise-gating-delta-report
[report]="deltaReport()"
[loading]="deltaLoading()"
[error]="deltaError()"
(entryClick)="onDeltaEntryClick($event)"
/>
</div>
<aside class="delta-panel-layout__sidebar">
<app-gating-statistics-card
[statistics]="gatingStatistics()"
title="Gating Statistics"
/>
</aside>
</div>
</section>
}
}
</div>
@@ -1137,6 +1170,41 @@ interface CanvasLayout {
margin-top: 0.5rem;
}
/* Delta Panel Layout (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui) */
.detail-panel--delta {
padding: 0;
}
.delta-panel-layout {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1rem;
height: 100%;
padding: 1rem;
}
.delta-panel-layout__main {
overflow-y: auto;
min-height: 0;
}
.delta-panel-layout__sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (max-width: 1024px) {
.delta-panel-layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.delta-panel-layout__sidebar {
order: -1;
}
}
/* Responsive */
@media (max-width: 768px) {
.triage-canvas--split .triage-canvas__list-pane {
@@ -1162,6 +1230,9 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
// Noise-gating delta integration (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
private readonly noiseGatingClient = inject(NOISE_GATING_API_CLIENT, { optional: true });
private subscriptions: Subscription[] = [];
private resizing = false;
@@ -1178,6 +1249,7 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
{ id: 'ai', label: 'AI Analysis' },
{ id: 'history', label: 'VEX History' },
{ id: 'evidence', label: 'Evidence' },
{ id: 'delta', label: 'Delta' },
];
readonly layout = signal<CanvasLayout>({
@@ -1198,6 +1270,12 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
return this.aiService.getCachedRecommendations(selected.id) ?? [];
});
// Delta report signals (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
readonly deltaReport = signal<NoiseGatingDeltaReport | null>(null);
readonly deltaLoading = signal(false);
readonly deltaError = signal<string | null>(null);
readonly gatingStatistics = signal<GatingStatistics | null>(null);
private keyboardStatusTimeout: ReturnType<typeof setTimeout> | null = null;
ngOnInit(): void {
@@ -1265,8 +1343,13 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
case 'Escape':
this.clearBulkSelection();
break;
case 'd':
event.preventDefault();
this.setDetailTab('delta');
this.announceStatus('Delta Report');
break;
case '?':
this.announceStatus('N: Next, P: Prev, M: Mark Not Affected, A: Analyze, V: VEX');
this.announceStatus('N: Next, P: Prev, M: Mark Not Affected, A: Analyze, V: VEX, D: Delta');
break;
}
}
@@ -1291,6 +1374,11 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
setDetailTab(tab: CanvasDetailTab): void {
this.activeDetailTab.set(tab);
// Load delta report when switching to delta tab (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
if (tab === 'delta' && !this.deltaReport() && !this.deltaLoading()) {
this.loadDeltaReport();
}
}
isSeverityActive(severity: string): boolean {
@@ -1464,6 +1552,65 @@ export class TriageCanvasComponent implements OnInit, OnDestroy {
);
}
// Delta report methods (Sprint: SPRINT_20260104_002_FE_noise_gating_delta_ui)
onDeltaEntryClick(entry: NoiseGatingDeltaEntry): void {
// Navigate to the specific vulnerability if it's in our list
const items = this.vulnService.items();
const matchingVuln = items.find(v => v.cveId === entry.vulnerabilityId);
if (matchingVuln) {
this.selectVulnerability(matchingVuln);
this.activeDetailTab.set('overview');
} else {
// Show the entry details in a status message
this.announceStatus(`${entry.vulnerabilityId}: ${entry.section}`);
}
}
loadDeltaReport(): void {
if (!this.noiseGatingClient) {
this.deltaError.set('Delta report API not available');
return;
}
this.deltaLoading.set(true);
this.deltaError.set(null);
// For demo: use mock snapshot IDs - in real usage these would come from scan context
const fromSnapshotId = 'snapshot-previous';
const toSnapshotId = 'snapshot-current';
this.subscriptions.push(
this.noiseGatingClient.computeDelta(fromSnapshotId, toSnapshotId).subscribe({
next: (report) => {
this.deltaReport.set(report);
this.deltaLoading.set(false);
},
error: (err) => {
this.deltaError.set(err?.message ?? 'Failed to load delta report');
this.deltaLoading.set(false);
},
})
);
// Also load gating statistics
this.loadGatingStatistics();
}
private loadGatingStatistics(): void {
if (!this.noiseGatingClient) return;
this.subscriptions.push(
this.noiseGatingClient.getGatingStatistics().subscribe({
next: (stats) => {
this.gatingStatistics.set(stats);
},
error: (err) => {
console.error('Failed to load gating statistics:', err);
},
})
);
}
private selectNextVuln(): void {
const items = this.vulnService.items();
const current = this.vulnService.selectedItem();