save progress
This commit is contained in:
302
src/Web/StellaOps.Web/src/app/core/api/noise-gating.client.ts
Normal file
302
src/Web/StellaOps.Web/src/app/core/api/noise-gating.client.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
266
src/Web/StellaOps.Web/src/app/core/api/noise-gating.models.ts
Normal file
266
src/Web/StellaOps.Web/src/app/core/api/noise-gating.models.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user