save dev progress
This commit is contained in:
435
src/Web/StellaOps.Web/src/app/core/api/scoring.models.ts
Normal file
435
src/Web/StellaOps.Web/src/app/core/api/scoring.models.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Evidence-Weighted Score (EWS) models.
|
||||
* Based on API endpoints from Sprint 8200.0012.0004.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Score bucket classification for prioritization.
|
||||
*/
|
||||
export type ScoreBucket = 'ActNow' | 'ScheduleNext' | 'Investigate' | 'Watchlist';
|
||||
|
||||
/**
|
||||
* Score flags indicating evidence characteristics.
|
||||
*/
|
||||
export type ScoreFlag = 'live-signal' | 'proven-path' | 'vendor-na' | 'speculative';
|
||||
|
||||
/**
|
||||
* Trigger types for score changes.
|
||||
*/
|
||||
export type ScoreChangeTrigger = 'evidence_update' | 'policy_change' | 'scheduled';
|
||||
|
||||
/**
|
||||
* Evidence dimension inputs (0.0 - 1.0 normalized).
|
||||
*/
|
||||
export interface EvidenceInputs {
|
||||
/** Reachability score */
|
||||
rch: number;
|
||||
/** Runtime signal score */
|
||||
rts: number;
|
||||
/** Backport availability score */
|
||||
bkp: number;
|
||||
/** Exploit availability score */
|
||||
xpl: number;
|
||||
/** Source trust score */
|
||||
src: number;
|
||||
/** Mitigations score (reduces overall) */
|
||||
mit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weight configuration for evidence dimensions.
|
||||
*/
|
||||
export interface EvidenceWeights {
|
||||
/** Reachability weight */
|
||||
rch: number;
|
||||
/** Runtime signal weight */
|
||||
rts: number;
|
||||
/** Backport weight */
|
||||
bkp: number;
|
||||
/** Exploit weight */
|
||||
xpl: number;
|
||||
/** Source trust weight */
|
||||
src: number;
|
||||
/** Mitigations weight */
|
||||
mit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applied guardrails (caps and floors).
|
||||
*/
|
||||
export interface AppliedGuardrails {
|
||||
/** Speculative cap applied (max 45) */
|
||||
speculativeCap: boolean;
|
||||
/** Not-affected cap applied (max 15) */
|
||||
notAffectedCap: boolean;
|
||||
/** Runtime floor applied (min 60) */
|
||||
runtimeFloor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full evidence-weighted score result from API.
|
||||
*/
|
||||
export interface EvidenceWeightedScoreResult {
|
||||
/** Finding identifier (CVE@PURL format) */
|
||||
findingId: string;
|
||||
/** Calculated score (0-100) */
|
||||
score: number;
|
||||
/** Score bucket classification */
|
||||
bucket: ScoreBucket;
|
||||
/** Normalized input values per dimension */
|
||||
inputs: EvidenceInputs;
|
||||
/** Weight configuration used */
|
||||
weights: EvidenceWeights;
|
||||
/** Active flags */
|
||||
flags: ScoreFlag[];
|
||||
/** Human-readable explanations */
|
||||
explanations: string[];
|
||||
/** Guardrails that were applied */
|
||||
caps: AppliedGuardrails;
|
||||
/** Policy digest (sha256:...) */
|
||||
policyDigest: string;
|
||||
/** Calculation timestamp */
|
||||
calculatedAt: string;
|
||||
/** Cache expiry (optional) */
|
||||
cachedUntil?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request for calculating a single score.
|
||||
*/
|
||||
export interface CalculateScoreRequest {
|
||||
/** Force recalculation bypassing cache */
|
||||
forceRecalculate?: boolean;
|
||||
/** Include full breakdown in response */
|
||||
includeBreakdown?: boolean;
|
||||
/** Specific policy version to use (null = latest) */
|
||||
policyVersion?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request for batch score calculation.
|
||||
*/
|
||||
export interface BatchCalculateScoreRequest {
|
||||
/** Finding IDs to score (max 100) */
|
||||
findingIds: string[];
|
||||
/** Force recalculation bypassing cache */
|
||||
forceRecalculate?: boolean;
|
||||
/** Include full breakdown in response */
|
||||
includeBreakdown?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary statistics for batch calculation.
|
||||
*/
|
||||
export interface BatchScoreSummary {
|
||||
/** Total findings processed */
|
||||
total: number;
|
||||
/** Count by bucket */
|
||||
byBucket: Record<ScoreBucket, number>;
|
||||
/** Average score */
|
||||
averageScore: number;
|
||||
/** Calculation time in milliseconds */
|
||||
calculationTimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch score calculation result.
|
||||
*/
|
||||
export interface BatchScoreResult {
|
||||
/** Individual score results */
|
||||
results: EvidenceWeightedScoreResult[];
|
||||
/** Summary statistics */
|
||||
summary: BatchScoreSummary;
|
||||
/** Policy digest used */
|
||||
policyDigest: string;
|
||||
/** Calculation timestamp */
|
||||
calculatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry in score history.
|
||||
*/
|
||||
export interface ScoreHistoryEntry {
|
||||
/** Score value at this point */
|
||||
score: number;
|
||||
/** Bucket at this point */
|
||||
bucket: ScoreBucket;
|
||||
/** Policy digest used */
|
||||
policyDigest: string;
|
||||
/** Calculation timestamp */
|
||||
calculatedAt: string;
|
||||
/** What triggered this calculation */
|
||||
trigger: ScoreChangeTrigger;
|
||||
/** Which factors changed */
|
||||
changedFactors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination info for history results.
|
||||
*/
|
||||
export interface HistoryPagination {
|
||||
/** More results available */
|
||||
hasMore: boolean;
|
||||
/** Cursor for next page */
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score history result.
|
||||
*/
|
||||
export interface ScoreHistoryResult {
|
||||
/** Finding identifier */
|
||||
findingId: string;
|
||||
/** History entries */
|
||||
history: ScoreHistoryEntry[];
|
||||
/** Pagination info */
|
||||
pagination: HistoryPagination;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for fetching score history.
|
||||
*/
|
||||
export interface ScoreHistoryOptions {
|
||||
/** Start date filter (ISO 8601) */
|
||||
from?: string;
|
||||
/** End date filter (ISO 8601) */
|
||||
to?: string;
|
||||
/** Max entries to return */
|
||||
limit?: number;
|
||||
/** Cursor for pagination */
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardrail configuration.
|
||||
*/
|
||||
export interface GuardrailConfig {
|
||||
/** Is this guardrail enabled */
|
||||
enabled: boolean;
|
||||
/** Max score (for caps) */
|
||||
maxScore?: number;
|
||||
/** Min score (for floors) */
|
||||
minScore?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket threshold configuration.
|
||||
*/
|
||||
export interface BucketThresholds {
|
||||
/** Minimum score for ActNow (default 90) */
|
||||
actNowMin: number;
|
||||
/** Minimum score for ScheduleNext (default 70) */
|
||||
scheduleNextMin: number;
|
||||
/** Minimum score for Investigate (default 40) */
|
||||
investigateMin: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoring policy configuration.
|
||||
*/
|
||||
export interface ScoringPolicy {
|
||||
/** Policy version identifier */
|
||||
version: string;
|
||||
/** Policy digest (sha256:...) */
|
||||
digest: string;
|
||||
/** When this policy became active */
|
||||
activeSince: string;
|
||||
/** Environment (production, staging, etc.) */
|
||||
environment: string;
|
||||
/** Weight configuration */
|
||||
weights: EvidenceWeights;
|
||||
/** Guardrail configuration */
|
||||
guardrails: {
|
||||
notAffectedCap: GuardrailConfig;
|
||||
runtimeFloor: GuardrailConfig;
|
||||
speculativeCap: GuardrailConfig;
|
||||
};
|
||||
/** Bucket thresholds */
|
||||
buckets: BucketThresholds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimension metadata for display.
|
||||
*/
|
||||
export interface ScoreDimensionInfo {
|
||||
/** Dimension key */
|
||||
key: keyof EvidenceInputs;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** Short description */
|
||||
description: string;
|
||||
/** Whether this dimension subtracts from score */
|
||||
isSubtractive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimension display metadata.
|
||||
*/
|
||||
export const SCORE_DIMENSIONS: ScoreDimensionInfo[] = [
|
||||
{
|
||||
key: 'rch',
|
||||
label: 'Reachability',
|
||||
description: 'Static and dynamic path analysis to vulnerable code',
|
||||
isSubtractive: false,
|
||||
},
|
||||
{
|
||||
key: 'rts',
|
||||
label: 'Runtime',
|
||||
description: 'Live runtime signals from deployed environments',
|
||||
isSubtractive: false,
|
||||
},
|
||||
{
|
||||
key: 'bkp',
|
||||
label: 'Backport',
|
||||
description: 'Backport availability from vendor or upstream',
|
||||
isSubtractive: false,
|
||||
},
|
||||
{
|
||||
key: 'xpl',
|
||||
label: 'Exploit',
|
||||
description: 'Known exploits, EPSS probability, KEV status',
|
||||
isSubtractive: false,
|
||||
},
|
||||
{
|
||||
key: 'src',
|
||||
label: 'Source Trust',
|
||||
description: 'Advisory source trustworthiness and VEX signing',
|
||||
isSubtractive: false,
|
||||
},
|
||||
{
|
||||
key: 'mit',
|
||||
label: 'Mitigations',
|
||||
description: 'Active mitigations (seccomp, AppArmor, network isolation)',
|
||||
isSubtractive: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Bucket display metadata.
|
||||
*/
|
||||
export interface BucketDisplayInfo {
|
||||
/** Bucket identifier */
|
||||
bucket: ScoreBucket;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** Short description */
|
||||
description: string;
|
||||
/** Minimum score (inclusive) */
|
||||
minScore: number;
|
||||
/** Maximum score (inclusive) */
|
||||
maxScore: number;
|
||||
/** Background color (CSS) */
|
||||
backgroundColor: string;
|
||||
/** Text color (CSS) */
|
||||
textColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default bucket display configuration.
|
||||
*/
|
||||
export const BUCKET_DISPLAY: BucketDisplayInfo[] = [
|
||||
{
|
||||
bucket: 'ActNow',
|
||||
label: 'Act Now',
|
||||
description: 'Critical - requires immediate attention',
|
||||
minScore: 90,
|
||||
maxScore: 100,
|
||||
backgroundColor: '#DC2626', // red-600
|
||||
textColor: '#FFFFFF',
|
||||
},
|
||||
{
|
||||
bucket: 'ScheduleNext',
|
||||
label: 'Schedule Next',
|
||||
description: 'High priority - schedule for next sprint',
|
||||
minScore: 70,
|
||||
maxScore: 89,
|
||||
backgroundColor: '#F59E0B', // amber-500
|
||||
textColor: '#000000',
|
||||
},
|
||||
{
|
||||
bucket: 'Investigate',
|
||||
label: 'Investigate',
|
||||
description: 'Medium priority - investigate when possible',
|
||||
minScore: 40,
|
||||
maxScore: 69,
|
||||
backgroundColor: '#3B82F6', // blue-500
|
||||
textColor: '#FFFFFF',
|
||||
},
|
||||
{
|
||||
bucket: 'Watchlist',
|
||||
label: 'Watchlist',
|
||||
description: 'Low priority - monitor for changes',
|
||||
minScore: 0,
|
||||
maxScore: 39,
|
||||
backgroundColor: '#6B7280', // gray-500
|
||||
textColor: '#FFFFFF',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Helper to get bucket info for a score.
|
||||
*/
|
||||
export function getBucketForScore(score: number): BucketDisplayInfo {
|
||||
for (const info of BUCKET_DISPLAY) {
|
||||
if (score >= info.minScore && score <= info.maxScore) {
|
||||
return info;
|
||||
}
|
||||
}
|
||||
return BUCKET_DISPLAY[BUCKET_DISPLAY.length - 1]; // Default to Watchlist
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag display metadata.
|
||||
*/
|
||||
export interface FlagDisplayInfo {
|
||||
/** Flag identifier */
|
||||
flag: ScoreFlag;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** Short description */
|
||||
description: string;
|
||||
/** Icon character/emoji */
|
||||
icon: string;
|
||||
/** Background color (CSS) */
|
||||
backgroundColor: string;
|
||||
/** Text color (CSS) */
|
||||
textColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default flag display configuration.
|
||||
*/
|
||||
export const FLAG_DISPLAY: Record<ScoreFlag, FlagDisplayInfo> = {
|
||||
'live-signal': {
|
||||
flag: 'live-signal',
|
||||
label: 'Live Signal',
|
||||
description: 'Active runtime signals detected from deployed environments',
|
||||
icon: '\u{1F7E2}', // green circle
|
||||
backgroundColor: '#059669', // emerald-600
|
||||
textColor: '#FFFFFF',
|
||||
},
|
||||
'proven-path': {
|
||||
flag: 'proven-path',
|
||||
label: 'Proven Path',
|
||||
description: 'Verified reachability path to vulnerable code',
|
||||
icon: '\u2713', // checkmark
|
||||
backgroundColor: '#2563EB', // blue-600
|
||||
textColor: '#FFFFFF',
|
||||
},
|
||||
'vendor-na': {
|
||||
flag: 'vendor-na',
|
||||
label: 'Vendor N/A',
|
||||
description: 'Vendor has marked this as not affected',
|
||||
icon: '\u2298', // circled division slash
|
||||
backgroundColor: '#6B7280', // gray-500
|
||||
textColor: '#FFFFFF',
|
||||
},
|
||||
speculative: {
|
||||
flag: 'speculative',
|
||||
label: 'Speculative',
|
||||
description: 'Evidence is speculative or unconfirmed',
|
||||
icon: '?',
|
||||
backgroundColor: '#F97316', // orange-500
|
||||
textColor: '#000000',
|
||||
},
|
||||
};
|
||||
387
src/Web/StellaOps.Web/src/app/core/services/scoring.service.ts
Normal file
387
src/Web/StellaOps.Web/src/app/core/services/scoring.service.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, delay, map } from 'rxjs';
|
||||
import {
|
||||
EvidenceWeightedScoreResult,
|
||||
BatchScoreResult,
|
||||
ScoreHistoryResult,
|
||||
ScoringPolicy,
|
||||
CalculateScoreRequest,
|
||||
BatchCalculateScoreRequest,
|
||||
ScoreHistoryOptions,
|
||||
ScoreBucket,
|
||||
ScoreFlag,
|
||||
} from '../api/scoring.models';
|
||||
|
||||
/**
|
||||
* Injection token for Scoring API client.
|
||||
*/
|
||||
export const SCORING_API = new InjectionToken<ScoringApi>('SCORING_API');
|
||||
|
||||
/**
|
||||
* Scoring API interface.
|
||||
*/
|
||||
export interface ScoringApi {
|
||||
/**
|
||||
* Calculate score for a single finding.
|
||||
*/
|
||||
calculateScore(
|
||||
findingId: string,
|
||||
options?: CalculateScoreRequest
|
||||
): Observable<EvidenceWeightedScoreResult>;
|
||||
|
||||
/**
|
||||
* Get cached/latest score for a finding.
|
||||
*/
|
||||
getScore(findingId: string): Observable<EvidenceWeightedScoreResult>;
|
||||
|
||||
/**
|
||||
* Calculate scores for multiple findings.
|
||||
*/
|
||||
calculateScores(
|
||||
request: BatchCalculateScoreRequest
|
||||
): Observable<BatchScoreResult>;
|
||||
|
||||
/**
|
||||
* Get score history for a finding.
|
||||
*/
|
||||
getScoreHistory(
|
||||
findingId: string,
|
||||
options?: ScoreHistoryOptions
|
||||
): Observable<ScoreHistoryResult>;
|
||||
|
||||
/**
|
||||
* Get current scoring policy.
|
||||
*/
|
||||
getScoringPolicy(): Observable<ScoringPolicy>;
|
||||
|
||||
/**
|
||||
* Get specific policy version.
|
||||
*/
|
||||
getScoringPolicyVersion(version: string): Observable<ScoringPolicy>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-based Scoring API client.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpScoringApi implements ScoringApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1';
|
||||
|
||||
calculateScore(
|
||||
findingId: string,
|
||||
options?: CalculateScoreRequest
|
||||
): Observable<EvidenceWeightedScoreResult> {
|
||||
const url = `${this.baseUrl}/findings/${encodeURIComponent(findingId)}/score`;
|
||||
return this.http.post<EvidenceWeightedScoreResult>(url, options ?? {});
|
||||
}
|
||||
|
||||
getScore(findingId: string): Observable<EvidenceWeightedScoreResult> {
|
||||
const url = `${this.baseUrl}/findings/${encodeURIComponent(findingId)}/score`;
|
||||
return this.http.get<EvidenceWeightedScoreResult>(url);
|
||||
}
|
||||
|
||||
calculateScores(
|
||||
request: BatchCalculateScoreRequest
|
||||
): Observable<BatchScoreResult> {
|
||||
const url = `${this.baseUrl}/findings/scores`;
|
||||
return this.http.post<BatchScoreResult>(url, request);
|
||||
}
|
||||
|
||||
getScoreHistory(
|
||||
findingId: string,
|
||||
options?: ScoreHistoryOptions
|
||||
): Observable<ScoreHistoryResult> {
|
||||
const url = `${this.baseUrl}/findings/${encodeURIComponent(findingId)}/score-history`;
|
||||
let params = new HttpParams();
|
||||
if (options?.from) {
|
||||
params = params.set('from', options.from);
|
||||
}
|
||||
if (options?.to) {
|
||||
params = params.set('to', options.to);
|
||||
}
|
||||
if (options?.limit) {
|
||||
params = params.set('limit', options.limit.toString());
|
||||
}
|
||||
if (options?.cursor) {
|
||||
params = params.set('cursor', options.cursor);
|
||||
}
|
||||
return this.http.get<ScoreHistoryResult>(url, { params });
|
||||
}
|
||||
|
||||
getScoringPolicy(): Observable<ScoringPolicy> {
|
||||
const url = `${this.baseUrl}/scoring/policy`;
|
||||
return this.http.get<ScoringPolicy>(url);
|
||||
}
|
||||
|
||||
getScoringPolicyVersion(version: string): Observable<ScoringPolicy> {
|
||||
const url = `${this.baseUrl}/scoring/policy/${encodeURIComponent(version)}`;
|
||||
return this.http.get<ScoringPolicy>(url);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data Fixtures
|
||||
// ============================================================================
|
||||
|
||||
function generateMockScore(
|
||||
findingId: string,
|
||||
baseScore?: number
|
||||
): EvidenceWeightedScoreResult {
|
||||
const score = baseScore ?? Math.floor(Math.random() * 100);
|
||||
const bucket: ScoreBucket =
|
||||
score >= 90
|
||||
? 'ActNow'
|
||||
: score >= 70
|
||||
? 'ScheduleNext'
|
||||
: score >= 40
|
||||
? 'Investigate'
|
||||
: 'Watchlist';
|
||||
|
||||
const flags: ScoreFlag[] = [];
|
||||
if (Math.random() > 0.6) flags.push('live-signal');
|
||||
if (Math.random() > 0.5) flags.push('proven-path');
|
||||
if (Math.random() > 0.8) flags.push('vendor-na');
|
||||
if (Math.random() > 0.7) flags.push('speculative');
|
||||
|
||||
const rch = Math.random() * 0.3 + 0.5;
|
||||
const rts = Math.random() * 0.5;
|
||||
const bkp = Math.random() * 0.3;
|
||||
const xpl = Math.random() * 0.4 + 0.3;
|
||||
const src = Math.random() * 0.3 + 0.5;
|
||||
const mit = Math.random() * 0.3;
|
||||
|
||||
return {
|
||||
findingId,
|
||||
score,
|
||||
bucket,
|
||||
inputs: { rch, rts, bkp, xpl, src, mit },
|
||||
weights: { rch: 0.3, rts: 0.25, bkp: 0.15, xpl: 0.15, src: 0.1, mit: 0.1 },
|
||||
flags,
|
||||
explanations: [
|
||||
`Static reachability: path to vulnerable sink (confidence: ${Math.round(rch * 100)}%)`,
|
||||
rts > 0.3
|
||||
? `Runtime: ${Math.floor(rts * 10)} observations in last 24 hours`
|
||||
: 'No runtime signals detected',
|
||||
xpl > 0.5 ? `EPSS: ${(xpl * 2).toFixed(1)}% probability (High band)` : 'No known exploits',
|
||||
`Source: ${src > 0.7 ? 'Distro VEX signed' : 'NVD advisory'} (trust: ${Math.round(src * 100)}%)`,
|
||||
mit > 0.1 ? 'Mitigations: seccomp profile active' : 'No mitigations detected',
|
||||
],
|
||||
caps: {
|
||||
speculativeCap: flags.includes('speculative'),
|
||||
notAffectedCap: flags.includes('vendor-na'),
|
||||
runtimeFloor: flags.includes('live-signal'),
|
||||
},
|
||||
policyDigest: 'sha256:abc123def456789012345678901234567890abcdef1234567890abcdef12345678',
|
||||
calculatedAt: new Date().toISOString(),
|
||||
cachedUntil: new Date(Date.now() + 3600000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const mockPolicy: ScoringPolicy = {
|
||||
version: 'ews.v1.2',
|
||||
digest: 'sha256:abc123def456789012345678901234567890abcdef1234567890abcdef12345678',
|
||||
activeSince: '2025-01-01T00:00:00Z',
|
||||
environment: 'production',
|
||||
weights: { rch: 0.3, rts: 0.25, bkp: 0.15, xpl: 0.15, src: 0.1, mit: 0.1 },
|
||||
guardrails: {
|
||||
notAffectedCap: { enabled: true, maxScore: 15 },
|
||||
runtimeFloor: { enabled: true, minScore: 60 },
|
||||
speculativeCap: { enabled: true, maxScore: 45 },
|
||||
},
|
||||
buckets: {
|
||||
actNowMin: 90,
|
||||
scheduleNextMin: 70,
|
||||
investigateMin: 40,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mock API Implementation
|
||||
// ============================================================================
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockScoringApi implements ScoringApi {
|
||||
private readonly scoreCache = new Map<string, EvidenceWeightedScoreResult>();
|
||||
|
||||
calculateScore(
|
||||
findingId: string,
|
||||
options?: CalculateScoreRequest
|
||||
): Observable<EvidenceWeightedScoreResult> {
|
||||
if (!options?.forceRecalculate && this.scoreCache.has(findingId)) {
|
||||
return of(this.scoreCache.get(findingId)!).pipe(delay(50));
|
||||
}
|
||||
|
||||
const score = generateMockScore(findingId);
|
||||
this.scoreCache.set(findingId, score);
|
||||
return of(score).pipe(delay(200));
|
||||
}
|
||||
|
||||
getScore(findingId: string): Observable<EvidenceWeightedScoreResult> {
|
||||
if (this.scoreCache.has(findingId)) {
|
||||
return of(this.scoreCache.get(findingId)!).pipe(delay(50));
|
||||
}
|
||||
// Generate and cache if not exists
|
||||
const score = generateMockScore(findingId);
|
||||
this.scoreCache.set(findingId, score);
|
||||
return of(score).pipe(delay(100));
|
||||
}
|
||||
|
||||
calculateScores(
|
||||
request: BatchCalculateScoreRequest
|
||||
): Observable<BatchScoreResult> {
|
||||
const startTime = Date.now();
|
||||
const results = request.findingIds.map((id) => {
|
||||
if (!request.forceRecalculate && this.scoreCache.has(id)) {
|
||||
return this.scoreCache.get(id)!;
|
||||
}
|
||||
const score = generateMockScore(id);
|
||||
this.scoreCache.set(id, score);
|
||||
return score;
|
||||
});
|
||||
|
||||
const byBucket: Record<ScoreBucket, number> = {
|
||||
ActNow: 0,
|
||||
ScheduleNext: 0,
|
||||
Investigate: 0,
|
||||
Watchlist: 0,
|
||||
};
|
||||
|
||||
let totalScore = 0;
|
||||
for (const r of results) {
|
||||
byBucket[r.bucket]++;
|
||||
totalScore += r.score;
|
||||
}
|
||||
|
||||
return of({
|
||||
results,
|
||||
summary: {
|
||||
total: results.length,
|
||||
byBucket,
|
||||
averageScore: totalScore / results.length,
|
||||
calculationTimeMs: Date.now() - startTime,
|
||||
},
|
||||
policyDigest: mockPolicy.digest,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
}).pipe(delay(300));
|
||||
}
|
||||
|
||||
getScoreHistory(
|
||||
findingId: string,
|
||||
options?: ScoreHistoryOptions
|
||||
): Observable<ScoreHistoryResult> {
|
||||
const limit = options?.limit ?? 10;
|
||||
const history = [];
|
||||
|
||||
// Generate mock history entries
|
||||
let currentDate = new Date();
|
||||
let currentScore = Math.floor(Math.random() * 100);
|
||||
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const bucket: ScoreBucket =
|
||||
currentScore >= 90
|
||||
? 'ActNow'
|
||||
: currentScore >= 70
|
||||
? 'ScheduleNext'
|
||||
: currentScore >= 40
|
||||
? 'Investigate'
|
||||
: 'Watchlist';
|
||||
|
||||
history.push({
|
||||
score: currentScore,
|
||||
bucket,
|
||||
policyDigest: mockPolicy.digest,
|
||||
calculatedAt: currentDate.toISOString(),
|
||||
trigger: (['evidence_update', 'policy_change', 'scheduled'] as const)[
|
||||
Math.floor(Math.random() * 3)
|
||||
],
|
||||
changedFactors:
|
||||
Math.random() > 0.5 ? ['rch', 'xpl'].slice(0, Math.floor(Math.random() * 2) + 1) : [],
|
||||
});
|
||||
|
||||
// Move back in time
|
||||
currentDate = new Date(currentDate.getTime() - Math.random() * 86400000 * 3);
|
||||
currentScore = Math.max(0, Math.min(100, currentScore + (Math.random() * 20 - 10)));
|
||||
}
|
||||
|
||||
return of({
|
||||
findingId,
|
||||
history,
|
||||
pagination: {
|
||||
hasMore: false,
|
||||
},
|
||||
}).pipe(delay(150));
|
||||
}
|
||||
|
||||
getScoringPolicy(): Observable<ScoringPolicy> {
|
||||
return of(mockPolicy).pipe(delay(100));
|
||||
}
|
||||
|
||||
getScoringPolicyVersion(version: string): Observable<ScoringPolicy> {
|
||||
return of({ ...mockPolicy, version }).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Angular Service (Facade)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Scoring service for Evidence-Weighted Score operations.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ScoringService {
|
||||
private readonly api = inject<ScoringApi>(SCORING_API);
|
||||
|
||||
/**
|
||||
* Calculate score for a single finding.
|
||||
*/
|
||||
calculateScore(
|
||||
findingId: string,
|
||||
options?: CalculateScoreRequest
|
||||
): Observable<EvidenceWeightedScoreResult> {
|
||||
return this.api.calculateScore(findingId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached/latest score for a finding.
|
||||
*/
|
||||
getScore(findingId: string): Observable<EvidenceWeightedScoreResult> {
|
||||
return this.api.getScore(findingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate scores for multiple findings.
|
||||
*/
|
||||
calculateScores(
|
||||
findingIds: string[],
|
||||
options?: Omit<BatchCalculateScoreRequest, 'findingIds'>
|
||||
): Observable<BatchScoreResult> {
|
||||
return this.api.calculateScores({ findingIds, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get score history for a finding.
|
||||
*/
|
||||
getScoreHistory(
|
||||
findingId: string,
|
||||
options?: ScoreHistoryOptions
|
||||
): Observable<ScoreHistoryResult> {
|
||||
return this.api.getScoreHistory(findingId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current scoring policy.
|
||||
*/
|
||||
getScoringPolicy(): Observable<ScoringPolicy> {
|
||||
return this.api.getScoringPolicy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific policy version.
|
||||
*/
|
||||
getScoringPolicyVersion(version: string): Observable<ScoringPolicy> {
|
||||
return this.api.getScoringPolicyVersion(version);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<div class="findings-list">
|
||||
<!-- Header with filters -->
|
||||
<header class="findings-header">
|
||||
<div class="header-row">
|
||||
<h2 class="findings-title">Findings</h2>
|
||||
<div class="findings-count">
|
||||
{{ displayFindings().length }} of {{ scoredFindings().length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bucket summary -->
|
||||
<div class="bucket-summary">
|
||||
@for (bucket of bucketOptions; track bucket.bucket) {
|
||||
<button
|
||||
type="button"
|
||||
class="bucket-chip"
|
||||
[class.active]="filter().bucket === bucket.bucket"
|
||||
[style.--bucket-color]="bucket.backgroundColor"
|
||||
(click)="setBucketFilter(filter().bucket === bucket.bucket ? null : bucket.bucket)"
|
||||
>
|
||||
<span class="bucket-label">{{ bucket.label }}</span>
|
||||
<span class="bucket-count">{{ bucketCounts()[bucket.bucket] }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Filters row -->
|
||||
<div class="filters-row">
|
||||
<!-- Search -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search findings..."
|
||||
[ngModel]="filter().search ?? ''"
|
||||
(ngModelChange)="setSearch($event)"
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Flag filters -->
|
||||
<div class="flag-filters">
|
||||
@for (opt of flagOptions; track opt.flag) {
|
||||
<label class="flag-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isFlagFiltered(opt.flag)"
|
||||
(change)="toggleFlagFilter(opt.flag)"
|
||||
/>
|
||||
<span class="flag-label">{{ opt.label }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Clear filters -->
|
||||
@if (filter().bucket || (filter().flags && filter().flags.length > 0) || filter().search) {
|
||||
<button
|
||||
type="button"
|
||||
class="clear-filters-btn"
|
||||
(click)="clearFilters()"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Selection actions -->
|
||||
@if (selectionCount() > 0) {
|
||||
<div class="selection-bar">
|
||||
<span class="selection-count">{{ selectionCount() }} selected</span>
|
||||
<button type="button" class="action-btn" (click)="clearSelection()">
|
||||
Clear
|
||||
</button>
|
||||
<!-- Placeholder for bulk actions -->
|
||||
<button type="button" class="action-btn primary">
|
||||
Bulk Triage
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Table -->
|
||||
<div class="findings-table-container">
|
||||
<table class="findings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allSelected()"
|
||||
[indeterminate]="selectionCount() > 0 && !allSelected()"
|
||||
(change)="toggleSelectAll()"
|
||||
aria-label="Select all findings"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="col-score sortable"
|
||||
(click)="setSort('score')"
|
||||
[attr.aria-sort]="sortField() === 'score' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : 'none'"
|
||||
>
|
||||
Score {{ getSortIcon('score') }}
|
||||
</th>
|
||||
<th
|
||||
class="col-advisory sortable"
|
||||
(click)="setSort('advisoryId')"
|
||||
[attr.aria-sort]="sortField() === 'advisoryId' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : 'none'"
|
||||
>
|
||||
Advisory {{ getSortIcon('advisoryId') }}
|
||||
</th>
|
||||
<th
|
||||
class="col-package sortable"
|
||||
(click)="setSort('packageName')"
|
||||
[attr.aria-sort]="sortField() === 'packageName' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : 'none'"
|
||||
>
|
||||
Package {{ getSortIcon('packageName') }}
|
||||
</th>
|
||||
<th class="col-flags">Flags</th>
|
||||
<th
|
||||
class="col-severity sortable"
|
||||
(click)="setSort('severity')"
|
||||
[attr.aria-sort]="sortField() === 'severity' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : 'none'"
|
||||
>
|
||||
Severity {{ getSortIcon('severity') }}
|
||||
</th>
|
||||
<th class="col-status">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (finding of displayFindings(); track finding.id) {
|
||||
<tr
|
||||
class="finding-row"
|
||||
[class.selected]="isSelected(finding.id)"
|
||||
(click)="onFindingClick(finding)"
|
||||
>
|
||||
<td class="col-checkbox" (click)="$event.stopPropagation()">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSelected(finding.id)"
|
||||
(change)="toggleSelection(finding.id)"
|
||||
[attr.aria-label]="'Select ' + finding.advisoryId"
|
||||
/>
|
||||
</td>
|
||||
<td class="col-score">
|
||||
@if (finding.scoreLoading) {
|
||||
<span class="score-loading">...</span>
|
||||
} @else if (finding.score) {
|
||||
<stella-score-pill
|
||||
[score]="finding.score.score"
|
||||
size="sm"
|
||||
(pillClick)="onScoreClick(finding, $event)"
|
||||
/>
|
||||
} @else {
|
||||
<span class="score-na">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="col-advisory">
|
||||
<span class="advisory-id">{{ finding.advisoryId }}</span>
|
||||
</td>
|
||||
<td class="col-package">
|
||||
<span class="package-name">{{ finding.packageName }}</span>
|
||||
<span class="package-version">{{ finding.packageVersion }}</span>
|
||||
</td>
|
||||
<td class="col-flags">
|
||||
@if (finding.score?.flags?.length) {
|
||||
<div class="flags-container">
|
||||
@for (flag of finding.score.flags; track flag) {
|
||||
<stella-score-badge
|
||||
[type]="flag"
|
||||
size="sm"
|
||||
[showLabel]="false"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="col-severity">
|
||||
<span
|
||||
class="severity-badge"
|
||||
[class]="getSeverityClass(finding.severity)"
|
||||
>
|
||||
{{ finding.severity }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-status">
|
||||
<span class="status-badge status-{{ finding.status }}">
|
||||
{{ finding.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr class="empty-row">
|
||||
<td colspan="7">
|
||||
@if (scoredFindings().length === 0) {
|
||||
No findings to display.
|
||||
} @else {
|
||||
No findings match the current filters.
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Score breakdown popover -->
|
||||
@if (activePopoverScore(); as score) {
|
||||
<stella-score-breakdown-popover
|
||||
[scoreResult]="score"
|
||||
[anchorElement]="popoverAnchor()"
|
||||
(close)="closePopover()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,460 @@
|
||||
.findings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
// Header
|
||||
.findings-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.findings-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.findings-count {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
// Bucket summary chips
|
||||
.bucket-summary {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bucket-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bucket-color, #9ca3af);
|
||||
background: color-mix(in srgb, var(--bucket-color, #9ca3af) 10%, white);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--bucket-color, #3b82f6);
|
||||
background: var(--bucket-color, #3b82f6);
|
||||
color: white;
|
||||
|
||||
.bucket-count {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bucket-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bucket-count {
|
||||
padding: 2px 6px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
// Filters row
|
||||
.filters-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.flag-filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flag-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
// Selection bar
|
||||
.selection-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #eff6ff;
|
||||
border-bottom: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Table
|
||||
.findings-table-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.findings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.findings-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: 12px 8px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
|
||||
&.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.findings-table td {
|
||||
padding: 12px 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.finding-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #eff6ff;
|
||||
|
||||
&:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-row td {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Column widths
|
||||
.col-checkbox {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-score {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.col-advisory {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.col-package {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.col-flags {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.col-severity {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.col-status {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
// Cell content
|
||||
.score-loading {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.score-na {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.advisory-id {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.package-version {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.flags-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// Severity badges
|
||||
.severity-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.severity-critical {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
&.severity-high {
|
||||
background: #fff7ed;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
&.severity-medium {
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&.severity-low {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&.severity-unknown {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
// Status badges
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
|
||||
&.status-open {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
&.status-in_progress {
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&.status-fixed {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&.status-excepted {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.findings-header {
|
||||
background: #111827;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.findings-title {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.findings-count {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.bucket-chip {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
color: #f9fafb;
|
||||
|
||||
&::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.findings-table th {
|
||||
background: #111827;
|
||||
border-color: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.findings-table td {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.finding-row:hover {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.advisory-id,
|
||||
.package-name {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.package-version {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 768px) {
|
||||
.filters-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.flag-filters {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.findings-table {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.col-flags,
|
||||
.col-status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FindingsListComponent, Finding } from './findings-list.component';
|
||||
import { SCORING_API, MockScoringApi } from '../../core/services/scoring.service';
|
||||
|
||||
describe('FindingsListComponent', () => {
|
||||
let component: FindingsListComponent;
|
||||
let fixture: ComponentFixture<FindingsListComponent>;
|
||||
|
||||
const mockFindings: Finding[] = [
|
||||
{
|
||||
id: 'CVE-2024-1234@pkg:npm/lodash@4.17.20',
|
||||
advisoryId: 'CVE-2024-1234',
|
||||
packageName: 'lodash',
|
||||
packageVersion: '4.17.20',
|
||||
severity: 'critical',
|
||||
status: 'open',
|
||||
publishedAt: '2024-01-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'CVE-2024-5678@pkg:npm/express@4.18.0',
|
||||
advisoryId: 'CVE-2024-5678',
|
||||
packageName: 'express',
|
||||
packageVersion: '4.18.0',
|
||||
severity: 'high',
|
||||
status: 'in_progress',
|
||||
publishedAt: '2024-02-20T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'GHSA-abc123@pkg:pypi/requests@2.25.0',
|
||||
advisoryId: 'GHSA-abc123',
|
||||
packageName: 'requests',
|
||||
packageVersion: '2.25.0',
|
||||
severity: 'medium',
|
||||
status: 'fixed',
|
||||
publishedAt: '2024-03-10T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'CVE-2023-9999@pkg:deb/debian/openssl@1.1.1',
|
||||
advisoryId: 'CVE-2023-9999',
|
||||
packageName: 'openssl',
|
||||
packageVersion: '1.1.1',
|
||||
severity: 'low',
|
||||
status: 'excepted',
|
||||
publishedAt: '2023-12-01T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FindingsListComponent, FormsModule],
|
||||
providers: [{ provide: SCORING_API, useClass: MockScoringApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FindingsListComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize with empty findings', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.scoredFindings().length).toBe(0);
|
||||
});
|
||||
|
||||
it('should load findings when input is set', async () => {
|
||||
fixture.componentRef.setInput('findings', mockFindings);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Wait for scores to load
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.scoredFindings().length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
beforeEach(async () => {
|
||||
fixture.componentRef.setInput('findings', mockFindings);
|
||||
fixture.componentRef.setInput('autoLoadScores', false);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should default to score descending', () => {
|
||||
expect(component.sortField()).toBe('score');
|
||||
expect(component.sortDirection()).toBe('desc');
|
||||
});
|
||||
|
||||
it('should toggle direction when clicking same field', () => {
|
||||
component.setSort('score');
|
||||
expect(component.sortDirection()).toBe('asc');
|
||||
|
||||
component.setSort('score');
|
||||
expect(component.sortDirection()).toBe('desc');
|
||||
});
|
||||
|
||||
it('should change field and reset direction', () => {
|
||||
component.setSort('severity');
|
||||
expect(component.sortField()).toBe('severity');
|
||||
expect(component.sortDirection()).toBe('asc');
|
||||
});
|
||||
|
||||
it('should sort by severity correctly', () => {
|
||||
component.setSort('severity');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component.displayFindings();
|
||||
expect(displayed[0].severity).toBe('critical');
|
||||
expect(displayed[1].severity).toBe('high');
|
||||
expect(displayed[2].severity).toBe('medium');
|
||||
expect(displayed[3].severity).toBe('low');
|
||||
});
|
||||
|
||||
it('should sort by advisory ID', () => {
|
||||
component.setSort('advisoryId');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component.displayFindings();
|
||||
expect(displayed[0].advisoryId).toBe('CVE-2023-9999');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
beforeEach(async () => {
|
||||
fixture.componentRef.setInput('findings', mockFindings);
|
||||
fixture.componentRef.setInput('autoLoadScores', false);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should filter by search text', () => {
|
||||
component.setSearch('lodash');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component.displayFindings();
|
||||
expect(displayed.length).toBe(1);
|
||||
expect(displayed[0].packageName).toBe('lodash');
|
||||
});
|
||||
|
||||
it('should filter by advisory ID', () => {
|
||||
component.setSearch('CVE-2024-1234');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component.displayFindings();
|
||||
expect(displayed.length).toBe(1);
|
||||
expect(displayed[0].advisoryId).toBe('CVE-2024-1234');
|
||||
});
|
||||
|
||||
it('should clear filters', () => {
|
||||
component.setSearch('lodash');
|
||||
fixture.detectChanges();
|
||||
expect(component.displayFindings().length).toBe(1);
|
||||
|
||||
component.clearFilters();
|
||||
fixture.detectChanges();
|
||||
expect(component.displayFindings().length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection', () => {
|
||||
beforeEach(async () => {
|
||||
fixture.componentRef.setInput('findings', mockFindings);
|
||||
fixture.componentRef.setInput('autoLoadScores', false);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should toggle individual selection', () => {
|
||||
const id = mockFindings[0].id;
|
||||
expect(component.isSelected(id)).toBe(false);
|
||||
|
||||
component.toggleSelection(id);
|
||||
expect(component.isSelected(id)).toBe(true);
|
||||
|
||||
component.toggleSelection(id);
|
||||
expect(component.isSelected(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('should track selection count', () => {
|
||||
expect(component.selectionCount()).toBe(0);
|
||||
|
||||
component.toggleSelection(mockFindings[0].id);
|
||||
expect(component.selectionCount()).toBe(1);
|
||||
|
||||
component.toggleSelection(mockFindings[1].id);
|
||||
expect(component.selectionCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should select all visible findings', () => {
|
||||
component.toggleSelectAll();
|
||||
expect(component.selectionCount()).toBe(4);
|
||||
expect(component.allSelected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should deselect all when all are selected', () => {
|
||||
component.toggleSelectAll();
|
||||
expect(component.allSelected()).toBe(true);
|
||||
|
||||
component.toggleSelectAll();
|
||||
expect(component.selectionCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear selection', () => {
|
||||
component.toggleSelection(mockFindings[0].id);
|
||||
component.toggleSelection(mockFindings[1].id);
|
||||
expect(component.selectionCount()).toBe(2);
|
||||
|
||||
component.clearSelection();
|
||||
expect(component.selectionCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bucket counts', () => {
|
||||
beforeEach(async () => {
|
||||
fixture.componentRef.setInput('findings', mockFindings);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should calculate bucket counts', () => {
|
||||
const counts = component.bucketCounts();
|
||||
// Counts depend on mock scoring, just verify structure
|
||||
expect(typeof counts.ActNow).toBe('number');
|
||||
expect(typeof counts.ScheduleNext).toBe('number');
|
||||
expect(typeof counts.Investigate).toBe('number');
|
||||
expect(typeof counts.Watchlist).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('popover', () => {
|
||||
beforeEach(async () => {
|
||||
fixture.componentRef.setInput('findings', mockFindings);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should open popover on score click', () => {
|
||||
const finding = component.scoredFindings()[0];
|
||||
const mockEvent = { stopPropagation: jest.fn(), target: document.createElement('span') } as any;
|
||||
|
||||
component.onScoreClick(finding, mockEvent);
|
||||
expect(component.activePopoverId()).toBe(finding.id);
|
||||
});
|
||||
|
||||
it('should close popover on second click', () => {
|
||||
const finding = component.scoredFindings()[0];
|
||||
const mockEvent = { stopPropagation: jest.fn(), target: document.createElement('span') } as any;
|
||||
|
||||
component.onScoreClick(finding, mockEvent);
|
||||
expect(component.activePopoverId()).toBe(finding.id);
|
||||
|
||||
component.onScoreClick(finding, mockEvent);
|
||||
expect(component.activePopoverId()).toBeNull();
|
||||
});
|
||||
|
||||
it('should close popover explicitly', () => {
|
||||
const finding = component.scoredFindings()[0];
|
||||
const mockEvent = { stopPropagation: jest.fn(), target: document.createElement('span') } as any;
|
||||
|
||||
component.onScoreClick(finding, mockEvent);
|
||||
component.closePopover();
|
||||
expect(component.activePopoverId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('outputs', () => {
|
||||
beforeEach(async () => {
|
||||
fixture.componentRef.setInput('findings', mockFindings);
|
||||
fixture.componentRef.setInput('autoLoadScores', false);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should emit findingSelect when row is clicked', () => {
|
||||
const selectSpy = jest.spyOn(component.findingSelect, 'emit');
|
||||
const finding = component.scoredFindings()[0];
|
||||
|
||||
component.onFindingClick(finding);
|
||||
expect(selectSpy).toHaveBeenCalledWith(finding);
|
||||
});
|
||||
|
||||
it('should emit selectionChange when selection changes', () => {
|
||||
const changeSpy = jest.spyOn(component.selectionChange, 'emit');
|
||||
|
||||
component.toggleSelection(mockFindings[0].id);
|
||||
expect(changeSpy).toHaveBeenCalledWith([mockFindings[0].id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
beforeEach(async () => {
|
||||
fixture.componentRef.setInput('findings', mockFindings);
|
||||
fixture.componentRef.setInput('autoLoadScores', false);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should render table rows', () => {
|
||||
const rows = fixture.nativeElement.querySelectorAll('.finding-row');
|
||||
expect(rows.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should render bucket summary chips', () => {
|
||||
const chips = fixture.nativeElement.querySelectorAll('.bucket-chip');
|
||||
expect(chips.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should render severity badges', () => {
|
||||
const badges = fixture.nativeElement.querySelectorAll('.severity-badge');
|
||||
expect(badges.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should render status badges', () => {
|
||||
const badges = fixture.nativeElement.querySelectorAll('.status-badge');
|
||||
expect(badges.length).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
EvidenceWeightedScoreResult,
|
||||
ScoreBucket,
|
||||
ScoreFlag,
|
||||
BUCKET_DISPLAY,
|
||||
getBucketForScore,
|
||||
} from '../../core/api/scoring.models';
|
||||
import { ScoringService, SCORING_API, MockScoringApi } from '../../core/services/scoring.service';
|
||||
import {
|
||||
ScorePillComponent,
|
||||
ScoreBadgeComponent,
|
||||
ScoreBreakdownPopoverComponent,
|
||||
} from '../../shared/components/score';
|
||||
|
||||
/**
|
||||
* Finding model for display in the list.
|
||||
*/
|
||||
export interface Finding {
|
||||
/** Unique finding ID (CVE@PURL format) */
|
||||
id: string;
|
||||
/** CVE or advisory ID */
|
||||
advisoryId: string;
|
||||
/** Affected package name */
|
||||
packageName: string;
|
||||
/** Affected package version */
|
||||
packageVersion: string;
|
||||
/** Original severity from advisory */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low' | 'unknown';
|
||||
/** Finding status */
|
||||
status: 'open' | 'in_progress' | 'fixed' | 'excepted';
|
||||
/** Published date */
|
||||
publishedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finding with computed score.
|
||||
*/
|
||||
export interface ScoredFinding extends Finding {
|
||||
/** Evidence-weighted score result */
|
||||
score?: EvidenceWeightedScoreResult;
|
||||
/** Whether score is loading */
|
||||
scoreLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort options for findings list.
|
||||
*/
|
||||
export type FindingsSortField = 'score' | 'severity' | 'advisoryId' | 'packageName' | 'publishedAt';
|
||||
export type FindingsSortDirection = 'asc' | 'desc';
|
||||
|
||||
/**
|
||||
* Filter options for findings list.
|
||||
*/
|
||||
export interface FindingsFilter {
|
||||
/** Filter by bucket */
|
||||
bucket?: ScoreBucket | null;
|
||||
/** Filter by flags (any match) */
|
||||
flags?: ScoreFlag[];
|
||||
/** Filter by severity */
|
||||
severity?: ('critical' | 'high' | 'medium' | 'low')[];
|
||||
/** Filter by status */
|
||||
status?: ('open' | 'in_progress' | 'fixed' | 'excepted')[];
|
||||
/** Search text (matches advisory ID, package name) */
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings list component with EWS score integration.
|
||||
*
|
||||
* Displays a list of findings with:
|
||||
* - Score pills showing evidence-weighted score
|
||||
* - Score badges for active flags
|
||||
* - Score breakdown popover on click
|
||||
* - Sorting by score, severity, date
|
||||
* - Filtering by bucket and flags
|
||||
*
|
||||
* @example
|
||||
* <app-findings-list
|
||||
* [findings]="findings"
|
||||
* (findingSelect)="onSelect($event)"
|
||||
* />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-findings-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ScorePillComponent,
|
||||
ScoreBadgeComponent,
|
||||
ScoreBreakdownPopoverComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: SCORING_API, useClass: MockScoringApi },
|
||||
ScoringService,
|
||||
],
|
||||
templateUrl: './findings-list.component.html',
|
||||
styleUrls: ['./findings-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FindingsListComponent {
|
||||
private readonly scoringService = inject(ScoringService);
|
||||
|
||||
/** Input findings to display */
|
||||
readonly findings = input<Finding[]>([]);
|
||||
|
||||
/** Whether to auto-load scores */
|
||||
readonly autoLoadScores = input(true);
|
||||
|
||||
/** Emits when a finding is selected */
|
||||
readonly findingSelect = output<ScoredFinding>();
|
||||
|
||||
/** Emits when bulk selection changes */
|
||||
readonly selectionChange = output<string[]>();
|
||||
|
||||
/** Scored findings with EWS data */
|
||||
readonly scoredFindings = signal<ScoredFinding[]>([]);
|
||||
|
||||
/** Currently selected finding IDs (for bulk actions) */
|
||||
readonly selectedIds = signal<Set<string>>(new Set());
|
||||
|
||||
/** Sort configuration */
|
||||
readonly sortField = signal<FindingsSortField>('score');
|
||||
readonly sortDirection = signal<FindingsSortDirection>('desc');
|
||||
|
||||
/** Filter configuration */
|
||||
readonly filter = signal<FindingsFilter>({});
|
||||
|
||||
/** Active popover finding ID */
|
||||
readonly activePopoverId = signal<string | null>(null);
|
||||
|
||||
/** Popover anchor element */
|
||||
readonly popoverAnchor = signal<HTMLElement | null>(null);
|
||||
|
||||
/** Bucket options for filter dropdown */
|
||||
readonly bucketOptions = BUCKET_DISPLAY;
|
||||
|
||||
/** Flag options for filter checkboxes */
|
||||
readonly flagOptions: { flag: ScoreFlag; label: string }[] = [
|
||||
{ flag: 'live-signal', label: 'Live Signal' },
|
||||
{ flag: 'proven-path', label: 'Proven Path' },
|
||||
{ flag: 'vendor-na', label: 'Vendor N/A' },
|
||||
{ flag: 'speculative', label: 'Speculative' },
|
||||
];
|
||||
|
||||
/** Filtered and sorted findings */
|
||||
readonly displayFindings = computed(() => {
|
||||
let results = [...this.scoredFindings()];
|
||||
|
||||
// Apply filters
|
||||
const f = this.filter();
|
||||
|
||||
if (f.bucket) {
|
||||
results = results.filter((r) => r.score?.bucket === f.bucket);
|
||||
}
|
||||
|
||||
if (f.flags && f.flags.length > 0) {
|
||||
results = results.filter((r) =>
|
||||
f.flags!.some((flag) => r.score?.flags.includes(flag))
|
||||
);
|
||||
}
|
||||
|
||||
if (f.severity && f.severity.length > 0) {
|
||||
results = results.filter((r) => f.severity!.includes(r.severity as any));
|
||||
}
|
||||
|
||||
if (f.status && f.status.length > 0) {
|
||||
results = results.filter((r) => f.status!.includes(r.status));
|
||||
}
|
||||
|
||||
if (f.search && f.search.trim()) {
|
||||
const searchLower = f.search.toLowerCase().trim();
|
||||
results = results.filter(
|
||||
(r) =>
|
||||
r.advisoryId.toLowerCase().includes(searchLower) ||
|
||||
r.packageName.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
const field = this.sortField();
|
||||
const dir = this.sortDirection() === 'asc' ? 1 : -1;
|
||||
|
||||
results.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
|
||||
switch (field) {
|
||||
case 'score':
|
||||
cmp = (a.score?.score ?? 0) - (b.score?.score ?? 0);
|
||||
break;
|
||||
case 'severity':
|
||||
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3, unknown: 4 };
|
||||
cmp = (sevOrder[a.severity] ?? 4) - (sevOrder[b.severity] ?? 4);
|
||||
break;
|
||||
case 'advisoryId':
|
||||
cmp = a.advisoryId.localeCompare(b.advisoryId);
|
||||
break;
|
||||
case 'packageName':
|
||||
cmp = a.packageName.localeCompare(b.packageName);
|
||||
break;
|
||||
case 'publishedAt':
|
||||
cmp = (a.publishedAt ?? '').localeCompare(b.publishedAt ?? '');
|
||||
break;
|
||||
}
|
||||
|
||||
return cmp * dir;
|
||||
});
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
/** Count by bucket for summary */
|
||||
readonly bucketCounts = computed(() => {
|
||||
const counts: Record<ScoreBucket, number> = {
|
||||
ActNow: 0,
|
||||
ScheduleNext: 0,
|
||||
Investigate: 0,
|
||||
Watchlist: 0,
|
||||
};
|
||||
|
||||
for (const finding of this.scoredFindings()) {
|
||||
if (finding.score) {
|
||||
counts[finding.score.bucket]++;
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
});
|
||||
|
||||
/** Selection count */
|
||||
readonly selectionCount = computed(() => this.selectedIds().size);
|
||||
|
||||
/** All selected */
|
||||
readonly allSelected = computed(() => {
|
||||
const displayed = this.displayFindings();
|
||||
const selected = this.selectedIds();
|
||||
return displayed.length > 0 && displayed.every((f) => selected.has(f.id));
|
||||
});
|
||||
|
||||
/** Active popover score result */
|
||||
readonly activePopoverScore = computed(() => {
|
||||
const id = this.activePopoverId();
|
||||
if (!id) return null;
|
||||
return this.scoredFindings().find((f) => f.id === id)?.score ?? null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Load scores when findings change
|
||||
effect(() => {
|
||||
const findings = this.findings();
|
||||
if (findings.length > 0 && this.autoLoadScores()) {
|
||||
this.loadScores(findings);
|
||||
} else {
|
||||
this.scoredFindings.set(
|
||||
findings.map((f) => ({ ...f, scoreLoading: false }))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Load scores for all findings */
|
||||
private async loadScores(findings: Finding[]): Promise<void> {
|
||||
// Initialize with loading state
|
||||
this.scoredFindings.set(
|
||||
findings.map((f) => ({ ...f, scoreLoading: true }))
|
||||
);
|
||||
|
||||
// Batch load scores
|
||||
const ids = findings.map((f) => f.id);
|
||||
|
||||
try {
|
||||
const result = await this.scoringService
|
||||
.calculateScores(ids, { includeBreakdown: true })
|
||||
.toPromise();
|
||||
|
||||
if (result) {
|
||||
// Map scores to findings
|
||||
const scoreMap = new Map(result.results.map((r) => [r.findingId, r]));
|
||||
|
||||
this.scoredFindings.set(
|
||||
findings.map((f) => ({
|
||||
...f,
|
||||
score: scoreMap.get(f.id),
|
||||
scoreLoading: false,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Mark all as loaded (failed)
|
||||
this.scoredFindings.set(
|
||||
findings.map((f) => ({ ...f, scoreLoading: false }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Set sort field (toggles direction if same field) */
|
||||
setSort(field: FindingsSortField): void {
|
||||
if (this.sortField() === field) {
|
||||
this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
this.sortField.set(field);
|
||||
this.sortDirection.set(field === 'score' ? 'desc' : 'asc');
|
||||
}
|
||||
}
|
||||
|
||||
/** Set bucket filter */
|
||||
setBucketFilter(bucket: ScoreBucket | null): void {
|
||||
this.filter.update((f) => ({ ...f, bucket }));
|
||||
}
|
||||
|
||||
/** Toggle flag filter */
|
||||
toggleFlagFilter(flag: ScoreFlag): void {
|
||||
this.filter.update((f) => {
|
||||
const flags = new Set(f.flags ?? []);
|
||||
if (flags.has(flag)) {
|
||||
flags.delete(flag);
|
||||
} else {
|
||||
flags.add(flag);
|
||||
}
|
||||
return { ...f, flags: [...flags] };
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if flag is in filter */
|
||||
isFlagFiltered(flag: ScoreFlag): boolean {
|
||||
return this.filter().flags?.includes(flag) ?? false;
|
||||
}
|
||||
|
||||
/** Set search filter */
|
||||
setSearch(search: string): void {
|
||||
this.filter.update((f) => ({ ...f, search }));
|
||||
}
|
||||
|
||||
/** Clear all filters */
|
||||
clearFilters(): void {
|
||||
this.filter.set({});
|
||||
}
|
||||
|
||||
/** Toggle finding selection */
|
||||
toggleSelection(id: string): void {
|
||||
this.selectedIds.update((ids) => {
|
||||
const newIds = new Set(ids);
|
||||
if (newIds.has(id)) {
|
||||
newIds.delete(id);
|
||||
} else {
|
||||
newIds.add(id);
|
||||
}
|
||||
return newIds;
|
||||
});
|
||||
this.selectionChange.emit([...this.selectedIds()]);
|
||||
}
|
||||
|
||||
/** Toggle all visible findings */
|
||||
toggleSelectAll(): void {
|
||||
const displayed = this.displayFindings();
|
||||
const selected = this.selectedIds();
|
||||
|
||||
if (this.allSelected()) {
|
||||
// Deselect all displayed
|
||||
this.selectedIds.update((ids) => {
|
||||
const newIds = new Set(ids);
|
||||
displayed.forEach((f) => newIds.delete(f.id));
|
||||
return newIds;
|
||||
});
|
||||
} else {
|
||||
// Select all displayed
|
||||
this.selectedIds.update((ids) => {
|
||||
const newIds = new Set(ids);
|
||||
displayed.forEach((f) => newIds.add(f.id));
|
||||
return newIds;
|
||||
});
|
||||
}
|
||||
this.selectionChange.emit([...this.selectedIds()]);
|
||||
}
|
||||
|
||||
/** Clear selection */
|
||||
clearSelection(): void {
|
||||
this.selectedIds.set(new Set());
|
||||
this.selectionChange.emit([]);
|
||||
}
|
||||
|
||||
/** Handle finding row click */
|
||||
onFindingClick(finding: ScoredFinding): void {
|
||||
this.findingSelect.emit(finding);
|
||||
}
|
||||
|
||||
/** Handle score pill click - show popover */
|
||||
onScoreClick(finding: ScoredFinding, event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.activePopoverId() === finding.id) {
|
||||
// Toggle off
|
||||
this.activePopoverId.set(null);
|
||||
this.popoverAnchor.set(null);
|
||||
} else {
|
||||
// Show popover
|
||||
this.activePopoverId.set(finding.id);
|
||||
this.popoverAnchor.set(event.target as HTMLElement);
|
||||
}
|
||||
}
|
||||
|
||||
/** Close popover */
|
||||
closePopover(): void {
|
||||
this.activePopoverId.set(null);
|
||||
this.popoverAnchor.set(null);
|
||||
}
|
||||
|
||||
/** Check if finding is selected */
|
||||
isSelected(id: string): boolean {
|
||||
return this.selectedIds().has(id);
|
||||
}
|
||||
|
||||
/** Get severity class */
|
||||
getSeverityClass(severity: string): string {
|
||||
return `severity-${severity}`;
|
||||
}
|
||||
|
||||
/** Get sort icon */
|
||||
getSortIcon(field: FindingsSortField): string {
|
||||
if (this.sortField() !== field) return '';
|
||||
return this.sortDirection() === 'asc' ? '\u25B2' : '\u25BC';
|
||||
}
|
||||
}
|
||||
1
src/Web/StellaOps.Web/src/app/features/findings/index.ts
Normal file
1
src/Web/StellaOps.Web/src/app/features/findings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FindingsListComponent, Finding, ScoredFinding, FindingsFilter, FindingsSortField, FindingsSortDirection } from './findings-list.component';
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Score components barrel export.
|
||||
*/
|
||||
export { ScorePillComponent, ScorePillSize } from './score-pill.component';
|
||||
export {
|
||||
ScoreBreakdownPopoverComponent,
|
||||
PopoverPosition,
|
||||
} from './score-breakdown-popover.component';
|
||||
export { ScoreBadgeComponent, ScoreBadgeSize } from './score-badge.component';
|
||||
export { ScoreHistoryChartComponent } from './score-history-chart.component';
|
||||
@@ -0,0 +1,16 @@
|
||||
<span
|
||||
class="score-badge"
|
||||
[class]="sizeClasses()"
|
||||
[class.pulse]="shouldPulse()"
|
||||
[class.icon-only]="!showLabel()"
|
||||
[style.backgroundColor]="displayInfo().backgroundColor"
|
||||
[style.color]="displayInfo().textColor"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
[attr.title]="showTooltip() ? displayInfo().description : null"
|
||||
role="status"
|
||||
>
|
||||
<span class="badge-icon" aria-hidden="true">{{ displayInfo().icon }}</span>
|
||||
@if (showLabel()) {
|
||||
<span class="badge-label">{{ displayInfo().label }}</span>
|
||||
}
|
||||
</span>
|
||||
@@ -0,0 +1,114 @@
|
||||
.score-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 500;
|
||||
border-radius: 16px;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
// Size variants
|
||||
.badge-sm {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
|
||||
.badge-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.icon-only {
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-md {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
|
||||
.badge-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&.icon-only {
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.badge-label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
// Pulse animation for live signal
|
||||
.pulse {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: inherit;
|
||||
background: inherit;
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
// High contrast mode
|
||||
@media (prefers-contrast: high) {
|
||||
.score-badge {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
// Reduced motion
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.score-badge {
|
||||
transition: none;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode adjustments
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.score-badge {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ScoreBadgeComponent } from './score-badge.component';
|
||||
import { ScoreFlag } from '../../../core/api/scoring.models';
|
||||
|
||||
describe('ScoreBadgeComponent', () => {
|
||||
let component: ScoreBadgeComponent;
|
||||
let fixture: ComponentFixture<ScoreBadgeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ScoreBadgeComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ScoreBadgeComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('live-signal badge', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('type', 'live-signal' as ScoreFlag);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display Live Signal label', () => {
|
||||
const label = fixture.nativeElement.querySelector('.badge-label');
|
||||
expect(label.textContent.trim()).toBe('Live Signal');
|
||||
});
|
||||
|
||||
it('should have green background', () => {
|
||||
expect(component.displayInfo().backgroundColor).toBe('#059669');
|
||||
});
|
||||
|
||||
it('should have white text', () => {
|
||||
expect(component.displayInfo().textColor).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
it('should have pulse animation', () => {
|
||||
expect(component.shouldPulse()).toBe(true);
|
||||
const badge = fixture.nativeElement.querySelector('.score-badge');
|
||||
expect(badge.classList.contains('pulse')).toBe(true);
|
||||
});
|
||||
|
||||
it('should display green circle icon', () => {
|
||||
const icon = fixture.nativeElement.querySelector('.badge-icon');
|
||||
expect(icon.textContent).toBe('\u{1F7E2}'); // green circle emoji
|
||||
});
|
||||
});
|
||||
|
||||
describe('proven-path badge', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('type', 'proven-path' as ScoreFlag);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display Proven Path label', () => {
|
||||
const label = fixture.nativeElement.querySelector('.badge-label');
|
||||
expect(label.textContent.trim()).toBe('Proven Path');
|
||||
});
|
||||
|
||||
it('should have blue background', () => {
|
||||
expect(component.displayInfo().backgroundColor).toBe('#2563EB');
|
||||
});
|
||||
|
||||
it('should not have pulse animation', () => {
|
||||
expect(component.shouldPulse()).toBe(false);
|
||||
});
|
||||
|
||||
it('should display checkmark icon', () => {
|
||||
const icon = fixture.nativeElement.querySelector('.badge-icon');
|
||||
expect(icon.textContent).toBe('\u2713');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vendor-na badge', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('type', 'vendor-na' as ScoreFlag);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display Vendor N/A label', () => {
|
||||
const label = fixture.nativeElement.querySelector('.badge-label');
|
||||
expect(label.textContent.trim()).toBe('Vendor N/A');
|
||||
});
|
||||
|
||||
it('should have gray background', () => {
|
||||
expect(component.displayInfo().backgroundColor).toBe('#6B7280');
|
||||
});
|
||||
|
||||
it('should display strikethrough icon', () => {
|
||||
const icon = fixture.nativeElement.querySelector('.badge-icon');
|
||||
expect(icon.textContent).toBe('\u2298');
|
||||
});
|
||||
});
|
||||
|
||||
describe('speculative badge', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('type', 'speculative' as ScoreFlag);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display Speculative label', () => {
|
||||
const label = fixture.nativeElement.querySelector('.badge-label');
|
||||
expect(label.textContent.trim()).toBe('Speculative');
|
||||
});
|
||||
|
||||
it('should have orange background', () => {
|
||||
expect(component.displayInfo().backgroundColor).toBe('#F97316');
|
||||
});
|
||||
|
||||
it('should have black text', () => {
|
||||
expect(component.displayInfo().textColor).toBe('#000000');
|
||||
});
|
||||
|
||||
it('should display question mark icon', () => {
|
||||
const icon = fixture.nativeElement.querySelector('.badge-icon');
|
||||
expect(icon.textContent).toBe('?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('size variants', () => {
|
||||
it('should apply sm size class', () => {
|
||||
fixture.componentRef.setInput('type', 'live-signal' as ScoreFlag);
|
||||
fixture.componentRef.setInput('size', 'sm');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.sizeClasses()).toBe('badge-sm');
|
||||
});
|
||||
|
||||
it('should apply md size class by default', () => {
|
||||
fixture.componentRef.setInput('type', 'live-signal' as ScoreFlag);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.sizeClasses()).toBe('badge-md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltip', () => {
|
||||
it('should show tooltip when showTooltip is true', () => {
|
||||
fixture.componentRef.setInput('type', 'live-signal' as ScoreFlag);
|
||||
fixture.componentRef.setInput('showTooltip', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.score-badge');
|
||||
expect(badge.getAttribute('title')).toContain('runtime signals');
|
||||
});
|
||||
|
||||
it('should not show tooltip when showTooltip is false', () => {
|
||||
fixture.componentRef.setInput('type', 'live-signal' as ScoreFlag);
|
||||
fixture.componentRef.setInput('showTooltip', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.score-badge');
|
||||
expect(badge.getAttribute('title')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('icon-only mode', () => {
|
||||
it('should hide label when showLabel is false', () => {
|
||||
fixture.componentRef.setInput('type', 'live-signal' as ScoreFlag);
|
||||
fixture.componentRef.setInput('showLabel', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const label = fixture.nativeElement.querySelector('.badge-label');
|
||||
expect(label).toBeNull();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.score-badge');
|
||||
expect(badge.classList.contains('icon-only')).toBe(true);
|
||||
});
|
||||
|
||||
it('should show label by default', () => {
|
||||
fixture.componentRef.setInput('type', 'live-signal' as ScoreFlag);
|
||||
fixture.detectChanges();
|
||||
|
||||
const label = fixture.nativeElement.querySelector('.badge-label');
|
||||
expect(label).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have status role', () => {
|
||||
fixture.componentRef.setInput('type', 'live-signal' as ScoreFlag);
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.score-badge');
|
||||
expect(badge.getAttribute('role')).toBe('status');
|
||||
});
|
||||
|
||||
it('should have aria-label with description', () => {
|
||||
fixture.componentRef.setInput('type', 'live-signal' as ScoreFlag);
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.score-badge');
|
||||
expect(badge.getAttribute('aria-label')).toContain('Live Signal');
|
||||
expect(badge.getAttribute('aria-label')).toContain('runtime signals');
|
||||
});
|
||||
|
||||
it('should hide icon from assistive technology', () => {
|
||||
fixture.componentRef.setInput('type', 'live-signal' as ScoreFlag);
|
||||
fixture.detectChanges();
|
||||
|
||||
const icon = fixture.nativeElement.querySelector('.badge-icon');
|
||||
expect(icon.getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { FLAG_DISPLAY, ScoreFlag, FlagDisplayInfo } from '../../../core/api/scoring.models';
|
||||
|
||||
/**
|
||||
* Size variants for the score badge.
|
||||
*/
|
||||
export type ScoreBadgeSize = 'sm' | 'md';
|
||||
|
||||
/**
|
||||
* Score badge component displaying flag indicators.
|
||||
*
|
||||
* Renders a colored badge with icon and label for score flags:
|
||||
* - **Live Signal** (green with pulse): Active runtime signals detected
|
||||
* - **Proven Path** (blue with checkmark): Verified reachability path
|
||||
* - **Vendor N/A** (gray with strikethrough): Vendor marked not affected
|
||||
* - **Speculative** (orange with question): Unconfirmed evidence
|
||||
*
|
||||
* @example
|
||||
* <stella-score-badge type="live-signal" size="md" />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stella-score-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './score-badge.component.html',
|
||||
styleUrls: ['./score-badge.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ScoreBadgeComponent {
|
||||
/** Badge type based on score flag */
|
||||
readonly type = input.required<ScoreFlag>();
|
||||
|
||||
/** Size variant */
|
||||
readonly size = input<ScoreBadgeSize>('md');
|
||||
|
||||
/** Whether to show tooltip */
|
||||
readonly showTooltip = input(true);
|
||||
|
||||
/** Whether to show the label text (icon-only mode) */
|
||||
readonly showLabel = input(true);
|
||||
|
||||
/** Get display info for the flag type */
|
||||
readonly displayInfo = computed((): FlagDisplayInfo => {
|
||||
return FLAG_DISPLAY[this.type()];
|
||||
});
|
||||
|
||||
/** CSS classes for size */
|
||||
readonly sizeClasses = computed(() => {
|
||||
const sizeMap: Record<ScoreBadgeSize, string> = {
|
||||
sm: 'badge-sm',
|
||||
md: 'badge-md',
|
||||
};
|
||||
return sizeMap[this.size()];
|
||||
});
|
||||
|
||||
/** ARIA label for accessibility */
|
||||
readonly ariaLabel = computed(() => {
|
||||
const info = this.displayInfo();
|
||||
return `${info.label}: ${info.description}`;
|
||||
});
|
||||
|
||||
/** Whether this badge type should pulse (live-signal) */
|
||||
readonly shouldPulse = computed(() => {
|
||||
return this.type() === 'live-signal';
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<div
|
||||
#popover
|
||||
class="score-breakdown-popover"
|
||||
[style.top.px]="position().top"
|
||||
[style.left.px]="position().left"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Evidence score breakdown"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="popover-header">
|
||||
<div class="score-summary">
|
||||
<span class="score-value" [style.color]="bucketInfo().backgroundColor">
|
||||
{{ scoreResult().score }}
|
||||
</span>
|
||||
<span class="score-max">/100</span>
|
||||
</div>
|
||||
<div class="bucket-info">
|
||||
<span
|
||||
class="bucket-badge"
|
||||
[style.backgroundColor]="bucketInfo().backgroundColor"
|
||||
[style.color]="bucketInfo().textColor"
|
||||
>
|
||||
{{ bucketInfo().label }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn"
|
||||
aria-label="Close breakdown"
|
||||
(click)="close.emit()"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Dimension Bars -->
|
||||
<section class="dimensions-section" aria-label="Evidence dimensions">
|
||||
<h3 class="section-title">Dimensions</h3>
|
||||
<div class="dimension-list">
|
||||
@for (dim of dimensions(); track dim.key) {
|
||||
<div class="dimension-row" [class.subtractive]="dim.isSubtractive">
|
||||
<span class="dimension-label">{{ dim.label }}</span>
|
||||
<div class="dimension-bar-container">
|
||||
<div
|
||||
class="dimension-bar"
|
||||
[class.subtractive]="dim.isSubtractive"
|
||||
[style.width]="getBarWidth(dim.value)"
|
||||
[attr.aria-valuenow]="dim.percentage"
|
||||
[attr.aria-valuemin]="0"
|
||||
[attr.aria-valuemax]="100"
|
||||
role="progressbar"
|
||||
></div>
|
||||
</div>
|
||||
<span class="dimension-value">{{ formatValue(dim.value) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Flags -->
|
||||
@if (flags().length > 0) {
|
||||
<section class="flags-section" aria-label="Score flags">
|
||||
<h3 class="section-title">Flags</h3>
|
||||
<div class="flag-list">
|
||||
@for (flag of flags(); track flag.flag) {
|
||||
<div
|
||||
class="flag-badge"
|
||||
[style.backgroundColor]="flag.backgroundColor"
|
||||
[style.color]="flag.textColor"
|
||||
[attr.title]="flag.description"
|
||||
>
|
||||
<span class="flag-icon">{{ flag.icon }}</span>
|
||||
<span class="flag-label">{{ flag.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Guardrails -->
|
||||
@if (hasGuardrails()) {
|
||||
<section class="guardrails-section" aria-label="Applied guardrails">
|
||||
<h3 class="section-title">Guardrails Applied</h3>
|
||||
<ul class="guardrail-list">
|
||||
@for (guardrail of appliedGuardrails(); track guardrail) {
|
||||
<li class="guardrail-item">{{ guardrail }}</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Explanations -->
|
||||
@if (scoreResult().explanations.length > 0) {
|
||||
<section class="explanations-section" aria-label="Score explanations">
|
||||
<h3 class="section-title">Factors</h3>
|
||||
<ul class="explanation-list">
|
||||
@for (explanation of scoreResult().explanations; track explanation) {
|
||||
<li class="explanation-item">{{ explanation }}</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="popover-footer">
|
||||
<span class="policy-info" [attr.title]="scoreResult().policyDigest">
|
||||
Policy: {{ scoreResult().policyDigest.substring(0, 16) }}...
|
||||
</span>
|
||||
<span class="calculated-at">
|
||||
Calculated: {{ scoreResult().calculatedAt | date:'short' }}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -0,0 +1,321 @@
|
||||
.score-breakdown-popover {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: 360px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
// Header
|
||||
.popover-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.score-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-max {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.bucket-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bucket-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 24px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Section styling
|
||||
.section-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
// Dimensions
|
||||
.dimensions-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.dimension-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dimension-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr 40px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dimension-label {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dimension-bar-container {
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dimension-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&.subtractive {
|
||||
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||
}
|
||||
}
|
||||
|
||||
.dimension-value {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #6b7280;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dimension-row.subtractive {
|
||||
.dimension-label::before {
|
||||
content: '-';
|
||||
margin-right: 2px;
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
// Flags
|
||||
.flags-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.flag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.flag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 16px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.flag-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flag-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Guardrails
|
||||
.guardrails-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.guardrail-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.guardrail-item {
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
line-height: 1.5;
|
||||
|
||||
&::marker {
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
// Explanations
|
||||
.explanations-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.explanation-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.explanation-item {
|
||||
font-size: 12px;
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
.popover-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
background: #f9fafb;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.policy-info {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.score-breakdown-popover {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.popover-header,
|
||||
.popover-footer {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.popover-header,
|
||||
.dimensions-section,
|
||||
.flags-section,
|
||||
.explanations-section {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.score-max,
|
||||
.dimension-value {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dimension-label,
|
||||
.explanation-item {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dimension-bar-container {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: #9ca3af;
|
||||
|
||||
&:hover {
|
||||
background-color: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
}
|
||||
|
||||
.guardrails-section {
|
||||
background: #451a03;
|
||||
}
|
||||
|
||||
.guardrail-item {
|
||||
color: #fcd34d;
|
||||
}
|
||||
}
|
||||
|
||||
// High contrast mode
|
||||
@media (prefers-contrast: high) {
|
||||
.score-breakdown-popover {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.dimension-bar {
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
// Reduced motion
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dimension-bar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile responsive
|
||||
@media (max-width: 400px) {
|
||||
.score-breakdown-popover {
|
||||
width: calc(100vw - 16px);
|
||||
left: 8px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ScoreBreakdownPopoverComponent } from './score-breakdown-popover.component';
|
||||
import { EvidenceWeightedScoreResult } from '../../../core/api/scoring.models';
|
||||
|
||||
describe('ScoreBreakdownPopoverComponent', () => {
|
||||
let component: ScoreBreakdownPopoverComponent;
|
||||
let fixture: ComponentFixture<ScoreBreakdownPopoverComponent>;
|
||||
|
||||
const mockScoreResult: EvidenceWeightedScoreResult = {
|
||||
findingId: 'CVE-2024-1234@pkg:deb/debian/curl@7.64.0-4',
|
||||
score: 78,
|
||||
bucket: 'ScheduleNext',
|
||||
inputs: {
|
||||
rch: 0.85,
|
||||
rts: 0.4,
|
||||
bkp: 0.0,
|
||||
xpl: 0.7,
|
||||
src: 0.8,
|
||||
mit: 0.1,
|
||||
},
|
||||
weights: {
|
||||
rch: 0.3,
|
||||
rts: 0.25,
|
||||
bkp: 0.15,
|
||||
xpl: 0.15,
|
||||
src: 0.1,
|
||||
mit: 0.1,
|
||||
},
|
||||
flags: ['live-signal', 'proven-path'],
|
||||
explanations: [
|
||||
'Static reachability: path to vulnerable sink (confidence: 85%)',
|
||||
'Runtime: 3 observations in last 24 hours',
|
||||
'EPSS: 0.8% probability (High band)',
|
||||
],
|
||||
caps: {
|
||||
speculativeCap: false,
|
||||
notAffectedCap: false,
|
||||
runtimeFloor: false,
|
||||
},
|
||||
policyDigest: 'sha256:abc123def456789012345678901234567890abcdef1234567890abcdef12345678',
|
||||
calculatedAt: '2025-12-26T10:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ScoreBreakdownPopoverComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ScoreBreakdownPopoverComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('scoreResult', mockScoreResult);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('score display', () => {
|
||||
it('should display the score value', () => {
|
||||
const scoreElement = fixture.nativeElement.querySelector('.score-value');
|
||||
expect(scoreElement.textContent.trim()).toBe('78');
|
||||
});
|
||||
|
||||
it('should display the bucket label', () => {
|
||||
const bucketElement = fixture.nativeElement.querySelector('.bucket-badge');
|
||||
expect(bucketElement.textContent.trim()).toBe('Schedule Next');
|
||||
});
|
||||
|
||||
it('should apply correct bucket color', () => {
|
||||
expect(component.bucketInfo().backgroundColor).toBe('#F59E0B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dimensions', () => {
|
||||
it('should render all six dimensions', () => {
|
||||
const dimensions = fixture.nativeElement.querySelectorAll('.dimension-row');
|
||||
expect(dimensions.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should display dimension labels correctly', () => {
|
||||
const labels = fixture.nativeElement.querySelectorAll('.dimension-label');
|
||||
const labelTexts = Array.from(labels).map((el: any) => el.textContent.trim());
|
||||
|
||||
expect(labelTexts).toContain('Reachability');
|
||||
expect(labelTexts).toContain('Runtime');
|
||||
expect(labelTexts).toContain('Backport');
|
||||
expect(labelTexts).toContain('Exploit');
|
||||
expect(labelTexts).toContain('Source Trust');
|
||||
expect(labelTexts).toContain('Mitigations');
|
||||
});
|
||||
|
||||
it('should show correct values for dimensions', () => {
|
||||
const values = fixture.nativeElement.querySelectorAll('.dimension-value');
|
||||
const valueTexts = Array.from(values).map((el: any) => el.textContent.trim());
|
||||
|
||||
expect(valueTexts).toContain('0.85');
|
||||
expect(valueTexts).toContain('0.40');
|
||||
expect(valueTexts).toContain('0.00');
|
||||
});
|
||||
|
||||
it('should mark mitigations as subtractive', () => {
|
||||
const mitigationsRow = fixture.nativeElement.querySelector('.dimension-row.subtractive');
|
||||
expect(mitigationsRow).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('flags', () => {
|
||||
it('should render active flags', () => {
|
||||
const flags = fixture.nativeElement.querySelectorAll('.flag-badge');
|
||||
expect(flags.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should display correct flag labels', () => {
|
||||
const flagLabels = fixture.nativeElement.querySelectorAll('.flag-label');
|
||||
const labelTexts = Array.from(flagLabels).map((el: any) => el.textContent.trim());
|
||||
|
||||
expect(labelTexts).toContain('Live Signal');
|
||||
expect(labelTexts).toContain('Proven Path');
|
||||
});
|
||||
|
||||
it('should not render flags section when no flags', () => {
|
||||
fixture.componentRef.setInput('scoreResult', {
|
||||
...mockScoreResult,
|
||||
flags: [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const flagsSection = fixture.nativeElement.querySelector('.flags-section');
|
||||
expect(flagsSection).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardrails', () => {
|
||||
it('should not show guardrails section when none applied', () => {
|
||||
const guardrailsSection = fixture.nativeElement.querySelector('.guardrails-section');
|
||||
expect(guardrailsSection).toBeNull();
|
||||
});
|
||||
|
||||
it('should show guardrails section when caps applied', () => {
|
||||
fixture.componentRef.setInput('scoreResult', {
|
||||
...mockScoreResult,
|
||||
caps: {
|
||||
speculativeCap: true,
|
||||
notAffectedCap: false,
|
||||
runtimeFloor: false,
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const guardrailsSection = fixture.nativeElement.querySelector('.guardrails-section');
|
||||
expect(guardrailsSection).toBeTruthy();
|
||||
|
||||
const guardrailItem = guardrailsSection.querySelector('.guardrail-item');
|
||||
expect(guardrailItem.textContent).toContain('Speculative cap');
|
||||
});
|
||||
|
||||
it('should show multiple guardrails when multiple applied', () => {
|
||||
fixture.componentRef.setInput('scoreResult', {
|
||||
...mockScoreResult,
|
||||
caps: {
|
||||
speculativeCap: true,
|
||||
notAffectedCap: true,
|
||||
runtimeFloor: true,
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const guardrailItems = fixture.nativeElement.querySelectorAll('.guardrail-item');
|
||||
expect(guardrailItems.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explanations', () => {
|
||||
it('should render explanations list', () => {
|
||||
const explanations = fixture.nativeElement.querySelectorAll('.explanation-item');
|
||||
expect(explanations.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should display explanation text', () => {
|
||||
const firstExplanation = fixture.nativeElement.querySelector('.explanation-item');
|
||||
expect(firstExplanation.textContent).toContain('Static reachability');
|
||||
});
|
||||
|
||||
it('should not render explanations section when empty', () => {
|
||||
fixture.componentRef.setInput('scoreResult', {
|
||||
...mockScoreResult,
|
||||
explanations: [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const explanationsSection = fixture.nativeElement.querySelector('.explanations-section');
|
||||
expect(explanationsSection).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer', () => {
|
||||
it('should display truncated policy digest', () => {
|
||||
const policyInfo = fixture.nativeElement.querySelector('.policy-info');
|
||||
expect(policyInfo.textContent).toContain('sha256:abc123def4');
|
||||
});
|
||||
|
||||
it('should display calculation timestamp', () => {
|
||||
const calculatedAt = fixture.nativeElement.querySelector('.calculated-at');
|
||||
expect(calculatedAt.textContent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('should emit close on Escape key', () => {
|
||||
const closeSpy = jest.spyOn(component.close, 'emit');
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Escape' });
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('close button', () => {
|
||||
it('should emit close when close button clicked', () => {
|
||||
const closeSpy = jest.spyOn(component.close, 'emit');
|
||||
|
||||
const closeBtn = fixture.nativeElement.querySelector('.close-btn');
|
||||
closeBtn.click();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have dialog role', () => {
|
||||
const popover = fixture.nativeElement.querySelector('.score-breakdown-popover');
|
||||
expect(popover.getAttribute('role')).toBe('dialog');
|
||||
});
|
||||
|
||||
it('should have aria-modal attribute', () => {
|
||||
const popover = fixture.nativeElement.querySelector('.score-breakdown-popover');
|
||||
expect(popover.getAttribute('aria-modal')).toBe('true');
|
||||
});
|
||||
|
||||
it('should have aria-label', () => {
|
||||
const popover = fixture.nativeElement.querySelector('.score-breakdown-popover');
|
||||
expect(popover.getAttribute('aria-label')).toBe('Evidence score breakdown');
|
||||
});
|
||||
|
||||
it('should have progressbar role on dimension bars', () => {
|
||||
const bars = fixture.nativeElement.querySelectorAll('.dimension-bar');
|
||||
bars.forEach((bar: Element) => {
|
||||
expect(bar.getAttribute('role')).toBe('progressbar');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting', () => {
|
||||
it('should format dimension values to 2 decimal places', () => {
|
||||
expect(component.formatValue(0.85)).toBe('0.85');
|
||||
expect(component.formatValue(0.123456)).toBe('0.12');
|
||||
expect(component.formatValue(0)).toBe('0.00');
|
||||
expect(component.formatValue(1)).toBe('1.00');
|
||||
});
|
||||
|
||||
it('should calculate correct bar widths', () => {
|
||||
expect(component.getBarWidth(0.85)).toBe('85%');
|
||||
expect(component.getBarWidth(0.5)).toBe('50%');
|
||||
expect(component.getBarWidth(0)).toBe('0%');
|
||||
expect(component.getBarWidth(1)).toBe('100%');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
EvidenceWeightedScoreResult,
|
||||
EvidenceInputs,
|
||||
SCORE_DIMENSIONS,
|
||||
FLAG_DISPLAY,
|
||||
getBucketForScore,
|
||||
ScoreFlag,
|
||||
} from '../../../core/api/scoring.models';
|
||||
|
||||
/**
|
||||
* Popover position relative to anchor.
|
||||
*/
|
||||
export type PopoverPosition = 'top' | 'bottom' | 'left' | 'right' | 'auto';
|
||||
|
||||
/**
|
||||
* Score breakdown popover component.
|
||||
*
|
||||
* Displays a detailed breakdown of an evidence-weighted score including:
|
||||
* - Overall score and bucket
|
||||
* - Horizontal bar chart for each dimension
|
||||
* - Active flags with icons
|
||||
* - Human-readable explanations
|
||||
* - Guardrail indicators
|
||||
*
|
||||
* @example
|
||||
* <stella-score-breakdown-popover
|
||||
* [scoreResult]="scoreResult"
|
||||
* [anchorElement]="pillElement"
|
||||
* (close)="onClose()"
|
||||
* />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stella-score-breakdown-popover',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './score-breakdown-popover.component.html',
|
||||
styleUrls: ['./score-breakdown-popover.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ScoreBreakdownPopoverComponent {
|
||||
/** Full score result from API */
|
||||
readonly scoreResult = input.required<EvidenceWeightedScoreResult>();
|
||||
|
||||
/** Anchor element for positioning */
|
||||
readonly anchorElement = input<HTMLElement | null>(null);
|
||||
|
||||
/** Preferred position (auto will use smart placement) */
|
||||
readonly preferredPosition = input<PopoverPosition>('auto');
|
||||
|
||||
/** Emits when popover should close */
|
||||
readonly close = output<void>();
|
||||
|
||||
/** Reference to popover container */
|
||||
readonly popoverRef = viewChild<ElementRef>('popover');
|
||||
|
||||
/** Current computed position */
|
||||
readonly position = signal<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
|
||||
/** Computed bucket info */
|
||||
readonly bucketInfo = computed(() => getBucketForScore(this.scoreResult().score));
|
||||
|
||||
/** Sorted dimensions for display */
|
||||
readonly dimensions = computed(() => {
|
||||
const inputs = this.scoreResult().inputs;
|
||||
const weights = this.scoreResult().weights;
|
||||
|
||||
return SCORE_DIMENSIONS.map((dim) => ({
|
||||
...dim,
|
||||
value: inputs[dim.key],
|
||||
weight: weights[dim.key],
|
||||
percentage: inputs[dim.key] * 100,
|
||||
weightedValue: inputs[dim.key] * weights[dim.key] * 100,
|
||||
}));
|
||||
});
|
||||
|
||||
/** Active flags with display info */
|
||||
readonly flags = computed(() => {
|
||||
return this.scoreResult().flags.map((flag) => FLAG_DISPLAY[flag]);
|
||||
});
|
||||
|
||||
/** Whether any guardrails were applied */
|
||||
readonly hasGuardrails = computed(() => {
|
||||
const caps = this.scoreResult().caps;
|
||||
return caps.speculativeCap || caps.notAffectedCap || caps.runtimeFloor;
|
||||
});
|
||||
|
||||
/** List of applied guardrails */
|
||||
readonly appliedGuardrails = computed(() => {
|
||||
const caps = this.scoreResult().caps;
|
||||
const guardrails: string[] = [];
|
||||
|
||||
if (caps.speculativeCap) {
|
||||
guardrails.push('Speculative cap applied (max 45)');
|
||||
}
|
||||
if (caps.notAffectedCap) {
|
||||
guardrails.push('Not-affected cap applied (max 15)');
|
||||
}
|
||||
if (caps.runtimeFloor) {
|
||||
guardrails.push('Runtime floor applied (min 60)');
|
||||
}
|
||||
|
||||
return guardrails;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Update position when anchor changes
|
||||
effect(() => {
|
||||
const anchor = this.anchorElement();
|
||||
if (anchor) {
|
||||
this.updatePosition(anchor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle Escape key to close */
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
/** Handle click outside to close */
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(event: MouseEvent): void {
|
||||
const popover = this.popoverRef()?.nativeElement;
|
||||
const anchor = this.anchorElement();
|
||||
|
||||
if (popover && !popover.contains(event.target as Node)) {
|
||||
// Don't close if clicking the anchor (toggle behavior)
|
||||
if (anchor && anchor.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
|
||||
/** Update popover position based on anchor */
|
||||
private updatePosition(anchor: HTMLElement): void {
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Estimate popover size (will be refined after render)
|
||||
const popoverWidth = 360;
|
||||
const popoverHeight = 400;
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
const pref = this.preferredPosition();
|
||||
const position = pref === 'auto' ? this.calculateBestPosition(anchorRect, popoverWidth, popoverHeight) : pref;
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
top = anchorRect.top - popoverHeight - 8;
|
||||
left = anchorRect.left + anchorRect.width / 2 - popoverWidth / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
top = anchorRect.bottom + 8;
|
||||
left = anchorRect.left + anchorRect.width / 2 - popoverWidth / 2;
|
||||
break;
|
||||
case 'left':
|
||||
top = anchorRect.top + anchorRect.height / 2 - popoverHeight / 2;
|
||||
left = anchorRect.left - popoverWidth - 8;
|
||||
break;
|
||||
case 'right':
|
||||
top = anchorRect.top + anchorRect.height / 2 - popoverHeight / 2;
|
||||
left = anchorRect.right + 8;
|
||||
break;
|
||||
}
|
||||
|
||||
// Clamp to viewport
|
||||
left = Math.max(8, Math.min(left, viewportWidth - popoverWidth - 8));
|
||||
top = Math.max(8, Math.min(top, viewportHeight - popoverHeight - 8));
|
||||
|
||||
this.position.set({ top, left });
|
||||
}
|
||||
|
||||
/** Calculate best position based on available space */
|
||||
private calculateBestPosition(
|
||||
anchorRect: DOMRect,
|
||||
popoverWidth: number,
|
||||
popoverHeight: number
|
||||
): PopoverPosition {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const spaceAbove = anchorRect.top;
|
||||
const spaceBelow = viewportHeight - anchorRect.bottom;
|
||||
const spaceLeft = anchorRect.left;
|
||||
const spaceRight = viewportWidth - anchorRect.right;
|
||||
|
||||
// Prefer bottom if there's enough space
|
||||
if (spaceBelow >= popoverHeight + 8) {
|
||||
return 'bottom';
|
||||
}
|
||||
// Then try top
|
||||
if (spaceAbove >= popoverHeight + 8) {
|
||||
return 'top';
|
||||
}
|
||||
// Then try right
|
||||
if (spaceRight >= popoverWidth + 8) {
|
||||
return 'right';
|
||||
}
|
||||
// Then try left
|
||||
if (spaceLeft >= popoverWidth + 8) {
|
||||
return 'left';
|
||||
}
|
||||
|
||||
// Default to bottom and let clamping handle overflow
|
||||
return 'bottom';
|
||||
}
|
||||
|
||||
/** Format dimension value for display */
|
||||
formatValue(value: number): string {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
/** Get bar width style for dimension */
|
||||
getBarWidth(value: number): string {
|
||||
return `${Math.abs(value) * 100}%`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<div class="score-history-chart">
|
||||
<!-- Date range selector -->
|
||||
@if (showRangeSelector()) {
|
||||
<div class="date-range-selector" role="group" aria-label="Date range filter">
|
||||
<div class="range-presets">
|
||||
@for (option of dateRangeOptions; track option.preset) {
|
||||
@if (option.preset !== 'custom') {
|
||||
<button
|
||||
type="button"
|
||||
class="range-preset-btn"
|
||||
[class.active]="selectedPreset() === option.preset"
|
||||
(click)="onPresetSelect(option.preset)"
|
||||
[attr.aria-pressed]="selectedPreset() === option.preset"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="range-preset-btn"
|
||||
[class.active]="selectedPreset() === 'custom'"
|
||||
(click)="toggleCustomPicker()"
|
||||
[attr.aria-pressed]="selectedPreset() === 'custom'"
|
||||
[attr.aria-expanded]="showCustomPicker()"
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Custom date picker -->
|
||||
@if (showCustomPicker()) {
|
||||
<div class="custom-date-picker">
|
||||
<label class="date-field">
|
||||
<span class="date-label">From</span>
|
||||
<input
|
||||
type="date"
|
||||
class="date-input"
|
||||
[ngModel]="customStartDate()"
|
||||
(ngModelChange)="onCustomStartChange($event)"
|
||||
[max]="customEndDate() || todayString"
|
||||
/>
|
||||
</label>
|
||||
<span class="date-separator">-</span>
|
||||
<label class="date-field">
|
||||
<span class="date-label">To</span>
|
||||
<input
|
||||
type="date"
|
||||
class="date-input"
|
||||
[ngModel]="customEndDate()"
|
||||
(ngModelChange)="onCustomEndChange($event)"
|
||||
[min]="customStartDate()"
|
||||
[max]="todayString"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="apply-btn"
|
||||
(click)="applyCustomRange()"
|
||||
[disabled]="!customStartDate() || !customEndDate()"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Chart container -->
|
||||
<div class="chart-container" [style.height.px]="height()">
|
||||
<svg
|
||||
[attr.width]="chartWidth()"
|
||||
[attr.height]="height()"
|
||||
class="chart-svg"
|
||||
role="img"
|
||||
aria-label="Score history chart"
|
||||
>
|
||||
<!-- Bucket bands -->
|
||||
@if (showBands()) {
|
||||
<g class="bucket-bands">
|
||||
@for (band of bucketBands(); track band.bucket) {
|
||||
<rect
|
||||
[attr.x]="padding.left"
|
||||
[attr.y]="band.y"
|
||||
[attr.width]="innerWidth()"
|
||||
[attr.height]="band.height"
|
||||
[attr.fill]="band.backgroundColor"
|
||||
opacity="0.1"
|
||||
/>
|
||||
<text
|
||||
[attr.x]="padding.left + 4"
|
||||
[attr.y]="band.y + 14"
|
||||
class="band-label"
|
||||
[style.fill]="band.backgroundColor"
|
||||
>
|
||||
{{ band.label }}
|
||||
</text>
|
||||
}
|
||||
</g>
|
||||
}
|
||||
|
||||
<!-- Grid lines -->
|
||||
@if (showGrid()) {
|
||||
<g class="grid-lines">
|
||||
@for (tick of yTicks(); track tick.value) {
|
||||
<line
|
||||
[attr.x1]="padding.left"
|
||||
[attr.y1]="tick.y"
|
||||
[attr.x2]="padding.left + innerWidth()"
|
||||
[attr.y2]="tick.y"
|
||||
class="grid-line"
|
||||
/>
|
||||
}
|
||||
</g>
|
||||
}
|
||||
|
||||
<!-- Area under line -->
|
||||
<path
|
||||
[attr.d]="areaPath()"
|
||||
class="chart-area"
|
||||
fill="url(#areaGradient)"
|
||||
/>
|
||||
|
||||
<!-- Gradient definition -->
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3B82F6" stop-opacity="0.3" />
|
||||
<stop offset="100%" stop-color="#3B82F6" stop-opacity="0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Line -->
|
||||
<path
|
||||
[attr.d]="linePath()"
|
||||
class="chart-line"
|
||||
fill="none"
|
||||
stroke="#3B82F6"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Data points -->
|
||||
<g class="data-points">
|
||||
@for (point of dataPoints(); track point.entry.calculatedAt) {
|
||||
<g
|
||||
class="data-point"
|
||||
[attr.transform]="'translate(' + point.x + ',' + point.y + ')'"
|
||||
(mouseenter)="onPointEnter(point)"
|
||||
(mouseleave)="onPointLeave()"
|
||||
(click)="onPointClick(point)"
|
||||
(keydown.enter)="onPointClick(point)"
|
||||
(keydown.space)="onPointClick(point)"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'Score ' + point.entry.score + ' on ' + formatTooltipDate(point.entry.calculatedAt)"
|
||||
role="button"
|
||||
>
|
||||
<!-- Outer ring for visibility -->
|
||||
<circle
|
||||
r="8"
|
||||
class="point-hitarea"
|
||||
fill="transparent"
|
||||
/>
|
||||
<!-- Point circle -->
|
||||
<circle
|
||||
r="5"
|
||||
[attr.fill]="getPointColor(point.entry.score)"
|
||||
class="point-circle"
|
||||
/>
|
||||
<!-- Inner indicator for trigger type -->
|
||||
<text
|
||||
class="point-indicator"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
font-size="6"
|
||||
fill="white"
|
||||
>
|
||||
{{ getTriggerIcon(point.entry.trigger) }}
|
||||
</text>
|
||||
</g>
|
||||
}
|
||||
</g>
|
||||
|
||||
<!-- Y-axis -->
|
||||
<g class="y-axis">
|
||||
<line
|
||||
[attr.x1]="padding.left"
|
||||
[attr.y1]="padding.top"
|
||||
[attr.x2]="padding.left"
|
||||
[attr.y2]="padding.top + innerHeight()"
|
||||
class="axis-line"
|
||||
/>
|
||||
@for (tick of yTicks(); track tick.value) {
|
||||
<g [attr.transform]="'translate(' + padding.left + ',' + tick.y + ')'">
|
||||
<line x1="-4" y1="0" x2="0" y2="0" class="tick-line" />
|
||||
<text x="-8" y="0" class="tick-label" text-anchor="end" dominant-baseline="central">
|
||||
{{ tick.value }}
|
||||
</text>
|
||||
</g>
|
||||
}
|
||||
</g>
|
||||
|
||||
<!-- X-axis -->
|
||||
<g class="x-axis">
|
||||
<line
|
||||
[attr.x1]="padding.left"
|
||||
[attr.y1]="padding.top + innerHeight()"
|
||||
[attr.x2]="padding.left + innerWidth()"
|
||||
[attr.y2]="padding.top + innerHeight()"
|
||||
class="axis-line"
|
||||
/>
|
||||
@for (tick of xTicks(); track tick.time.getTime()) {
|
||||
<g [attr.transform]="'translate(' + tick.x + ',' + (padding.top + innerHeight()) + ')'">
|
||||
<line x1="0" y1="0" x2="0" y2="4" class="tick-line" />
|
||||
<text x="0" y="16" class="tick-label" text-anchor="middle">
|
||||
{{ formatDate(tick.time) }}
|
||||
</text>
|
||||
</g>
|
||||
}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Tooltip -->
|
||||
@if (hoveredPoint(); as point) {
|
||||
<div
|
||||
class="chart-tooltip"
|
||||
[style.left.px]="point.x + 12"
|
||||
[style.top.px]="point.y - 10"
|
||||
>
|
||||
<div class="tooltip-score">
|
||||
<span class="score-value" [style.color]="getPointColor(point.entry.score)">
|
||||
{{ point.entry.score }}
|
||||
</span>
|
||||
<span class="score-bucket">{{ point.entry.bucket }}</span>
|
||||
</div>
|
||||
<div class="tooltip-date">
|
||||
{{ formatTooltipDate(point.entry.calculatedAt) }}
|
||||
</div>
|
||||
<div class="tooltip-trigger">
|
||||
{{ getTriggerLabel(point.entry.trigger) }}
|
||||
</div>
|
||||
@if (point.entry.changedFactors.length > 0) {
|
||||
<div class="tooltip-factors">
|
||||
Changed: {{ point.entry.changedFactors.join(', ') }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="chart-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-icon filled"></span>
|
||||
<span class="legend-label">Evidence Update</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-icon empty"></span>
|
||||
<span class="legend-label">Policy Change</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-icon diamond"></span>
|
||||
<span class="legend-label">Scheduled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,231 @@
|
||||
.score-history-chart {
|
||||
position: relative;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
// Bucket bands
|
||||
.band-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
// Grid lines
|
||||
.grid-line {
|
||||
stroke: #e5e7eb;
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4, 4;
|
||||
}
|
||||
|
||||
// Axis styling
|
||||
.axis-line {
|
||||
stroke: #9ca3af;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.tick-line {
|
||||
stroke: #9ca3af;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.tick-label {
|
||||
font-size: 11px;
|
||||
fill: #6b7280;
|
||||
}
|
||||
|
||||
// Chart line
|
||||
.chart-line {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Data points
|
||||
.data-point {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: scale(1.3);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible .point-circle {
|
||||
stroke: #1f2937;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.point-circle {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
|
||||
.point-hitarea {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.point-indicator {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
.chart-tooltip {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
padding: 10px 12px;
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
pointer-events: none;
|
||||
min-width: 140px;
|
||||
|
||||
// Arrow
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
top: 10px;
|
||||
border: 6px solid transparent;
|
||||
border-right-color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-score {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-score .score-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tooltip-score .score-bucket {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.tooltip-date {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-trigger {
|
||||
font-size: 11px;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.tooltip-factors {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid #374151;
|
||||
}
|
||||
|
||||
// Legend
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-icon {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
&.filled {
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.empty {
|
||||
border: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&.diamond {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: #3b82f6;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.grid-line {
|
||||
stroke: #374151;
|
||||
}
|
||||
|
||||
.axis-line,
|
||||
.tick-line {
|
||||
stroke: #6b7280;
|
||||
}
|
||||
|
||||
.tick-label {
|
||||
fill: #9ca3af;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
// Reduced motion
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.data-point {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.point-circle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 480px) {
|
||||
.chart-legend {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chart-tooltip {
|
||||
min-width: 120px;
|
||||
font-size: 11px;
|
||||
|
||||
.tooltip-score .score-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ScoreHistoryChartComponent } from './score-history-chart.component';
|
||||
import { ScoreHistoryEntry } from '../../../core/api/scoring.models';
|
||||
|
||||
describe('ScoreHistoryChartComponent', () => {
|
||||
let component: ScoreHistoryChartComponent;
|
||||
let fixture: ComponentFixture<ScoreHistoryChartComponent>;
|
||||
|
||||
const mockHistory: ScoreHistoryEntry[] = [
|
||||
{
|
||||
score: 45,
|
||||
bucket: 'Investigate',
|
||||
policyDigest: 'sha256:abc123',
|
||||
calculatedAt: '2025-01-01T10:00:00Z',
|
||||
trigger: 'scheduled',
|
||||
changedFactors: [],
|
||||
},
|
||||
{
|
||||
score: 60,
|
||||
bucket: 'Investigate',
|
||||
policyDigest: 'sha256:abc123',
|
||||
calculatedAt: '2025-01-05T10:00:00Z',
|
||||
trigger: 'evidence_update',
|
||||
changedFactors: ['rch'],
|
||||
},
|
||||
{
|
||||
score: 75,
|
||||
bucket: 'ScheduleNext',
|
||||
policyDigest: 'sha256:abc123',
|
||||
calculatedAt: '2025-01-10T10:00:00Z',
|
||||
trigger: 'evidence_update',
|
||||
changedFactors: ['rts', 'xpl'],
|
||||
},
|
||||
{
|
||||
score: 78,
|
||||
bucket: 'ScheduleNext',
|
||||
policyDigest: 'sha256:def456',
|
||||
calculatedAt: '2025-01-15T10:00:00Z',
|
||||
trigger: 'policy_change',
|
||||
changedFactors: [],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ScoreHistoryChartComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ScoreHistoryChartComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('data processing', () => {
|
||||
it('should sort history entries by date (oldest first)', () => {
|
||||
fixture.componentRef.setInput('history', mockHistory);
|
||||
fixture.detectChanges();
|
||||
|
||||
const sorted = component.sortedHistory();
|
||||
expect(sorted[0].calculatedAt).toBe('2025-01-01T10:00:00Z');
|
||||
expect(sorted[sorted.length - 1].calculatedAt).toBe('2025-01-15T10:00:00Z');
|
||||
});
|
||||
|
||||
it('should calculate data points for each history entry', () => {
|
||||
fixture.componentRef.setInput('history', mockHistory);
|
||||
fixture.detectChanges();
|
||||
|
||||
const points = component.dataPoints();
|
||||
expect(points.length).toBe(4);
|
||||
|
||||
// Each point should have x, y coordinates
|
||||
points.forEach((point) => {
|
||||
expect(point.x).toBeGreaterThan(0);
|
||||
expect(point.y).toBeGreaterThan(0);
|
||||
expect(point.entry).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty history', () => {
|
||||
fixture.componentRef.setInput('history', []);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.dataPoints().length).toBe(0);
|
||||
expect(component.linePath()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chart rendering', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('history', mockHistory);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should render SVG element', () => {
|
||||
const svg = fixture.nativeElement.querySelector('svg');
|
||||
expect(svg).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render data points', () => {
|
||||
const points = fixture.nativeElement.querySelectorAll('.data-point');
|
||||
expect(points.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should render chart line', () => {
|
||||
const line = fixture.nativeElement.querySelector('.chart-line');
|
||||
expect(line).toBeTruthy();
|
||||
expect(line.getAttribute('d')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render bucket bands when showBands is true', () => {
|
||||
fixture.componentRef.setInput('showBands', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const bands = fixture.nativeElement.querySelectorAll('.bucket-bands rect');
|
||||
expect(bands.length).toBe(4); // 4 buckets
|
||||
});
|
||||
|
||||
it('should not render bucket bands when showBands is false', () => {
|
||||
fixture.componentRef.setInput('showBands', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const bands = fixture.nativeElement.querySelector('.bucket-bands');
|
||||
expect(bands).toBeNull();
|
||||
});
|
||||
|
||||
it('should render grid lines when showGrid is true', () => {
|
||||
fixture.componentRef.setInput('showGrid', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const gridLines = fixture.nativeElement.querySelectorAll('.grid-line');
|
||||
expect(gridLines.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render legend', () => {
|
||||
const legend = fixture.nativeElement.querySelector('.chart-legend');
|
||||
expect(legend).toBeTruthy();
|
||||
|
||||
const legendItems = fixture.nativeElement.querySelectorAll('.legend-item');
|
||||
expect(legendItems.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dimensions', () => {
|
||||
it('should use default height', () => {
|
||||
fixture.componentRef.setInput('history', mockHistory);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.height()).toBe(200);
|
||||
});
|
||||
|
||||
it('should use custom height', () => {
|
||||
fixture.componentRef.setInput('history', mockHistory);
|
||||
fixture.componentRef.setInput('height', 300);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.height()).toBe(300);
|
||||
});
|
||||
|
||||
it('should use default width when auto', () => {
|
||||
fixture.componentRef.setInput('history', mockHistory);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.chartWidth()).toBe(600);
|
||||
});
|
||||
|
||||
it('should use custom width', () => {
|
||||
fixture.componentRef.setInput('history', mockHistory);
|
||||
fixture.componentRef.setInput('width', 800);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.chartWidth()).toBe(800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interaction', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('history', mockHistory);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show tooltip on point hover', () => {
|
||||
const point = fixture.nativeElement.querySelector('.data-point');
|
||||
point.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
fixture.detectChanges();
|
||||
|
||||
const tooltip = fixture.nativeElement.querySelector('.chart-tooltip');
|
||||
expect(tooltip).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should hide tooltip on point leave', () => {
|
||||
const point = fixture.nativeElement.querySelector('.data-point');
|
||||
point.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
fixture.detectChanges();
|
||||
|
||||
point.dispatchEvent(new MouseEvent('mouseleave'));
|
||||
fixture.detectChanges();
|
||||
|
||||
const tooltip = fixture.nativeElement.querySelector('.chart-tooltip');
|
||||
expect(tooltip).toBeNull();
|
||||
});
|
||||
|
||||
it('should emit pointClick on point click', () => {
|
||||
const clickSpy = jest.spyOn(component.pointClick, 'emit');
|
||||
const point = fixture.nativeElement.querySelector('.data-point');
|
||||
point.click();
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger icons', () => {
|
||||
it('should return correct icon for evidence_update', () => {
|
||||
expect(component.getTriggerIcon('evidence_update')).toBe('\u25CF');
|
||||
});
|
||||
|
||||
it('should return correct icon for policy_change', () => {
|
||||
expect(component.getTriggerIcon('policy_change')).toBe('\u25CB');
|
||||
});
|
||||
|
||||
it('should return correct icon for scheduled', () => {
|
||||
expect(component.getTriggerIcon('scheduled')).toBe('\u25C6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger labels', () => {
|
||||
it('should return correct label for evidence_update', () => {
|
||||
expect(component.getTriggerLabel('evidence_update')).toBe('Evidence Update');
|
||||
});
|
||||
|
||||
it('should return correct label for policy_change', () => {
|
||||
expect(component.getTriggerLabel('policy_change')).toBe('Policy Change');
|
||||
});
|
||||
|
||||
it('should return correct label for scheduled', () => {
|
||||
expect(component.getTriggerLabel('scheduled')).toBe('Scheduled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('history', mockHistory);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should have aria-label on SVG', () => {
|
||||
const svg = fixture.nativeElement.querySelector('svg');
|
||||
expect(svg.getAttribute('aria-label')).toBe('Score history chart');
|
||||
});
|
||||
|
||||
it('should have role=img on SVG', () => {
|
||||
const svg = fixture.nativeElement.querySelector('svg');
|
||||
expect(svg.getAttribute('role')).toBe('img');
|
||||
});
|
||||
|
||||
it('should have aria-label on data points', () => {
|
||||
const points = fixture.nativeElement.querySelectorAll('.data-point');
|
||||
points.forEach((point: Element) => {
|
||||
expect(point.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have tabindex on data points', () => {
|
||||
const points = fixture.nativeElement.querySelectorAll('.data-point');
|
||||
points.forEach((point: Element) => {
|
||||
expect(point.getAttribute('tabindex')).toBe('0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('color mapping', () => {
|
||||
it('should return correct color for score in ActNow bucket', () => {
|
||||
expect(component.getPointColor(95)).toBe('#DC2626');
|
||||
});
|
||||
|
||||
it('should return correct color for score in ScheduleNext bucket', () => {
|
||||
expect(component.getPointColor(78)).toBe('#F59E0B');
|
||||
});
|
||||
|
||||
it('should return correct color for score in Investigate bucket', () => {
|
||||
expect(component.getPointColor(55)).toBe('#3B82F6');
|
||||
});
|
||||
|
||||
it('should return correct color for score in Watchlist bucket', () => {
|
||||
expect(component.getPointColor(25)).toBe('#6B7280');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
ScoreHistoryEntry,
|
||||
BUCKET_DISPLAY,
|
||||
getBucketForScore,
|
||||
ScoreChangeTrigger,
|
||||
} from '../../../core/api/scoring.models';
|
||||
|
||||
/**
|
||||
* Date range preset options.
|
||||
*/
|
||||
export type DateRangePreset = '7d' | '30d' | '90d' | '1y' | 'all' | 'custom';
|
||||
|
||||
/**
|
||||
* Date range preset configuration.
|
||||
*/
|
||||
export interface DateRangeOption {
|
||||
preset: DateRangePreset;
|
||||
label: string;
|
||||
days?: number;
|
||||
}
|
||||
|
||||
/** Available date range presets */
|
||||
export const DATE_RANGE_OPTIONS: DateRangeOption[] = [
|
||||
{ preset: '7d', label: 'Last 7 days', days: 7 },
|
||||
{ preset: '30d', label: 'Last 30 days', days: 30 },
|
||||
{ preset: '90d', label: 'Last 90 days', days: 90 },
|
||||
{ preset: '1y', label: 'Last year', days: 365 },
|
||||
{ preset: 'all', label: 'All time' },
|
||||
{ preset: 'custom', label: 'Custom range' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Data point for chart rendering.
|
||||
*/
|
||||
interface ChartDataPoint {
|
||||
entry: ScoreHistoryEntry;
|
||||
x: number;
|
||||
y: number;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip data for hover display.
|
||||
*/
|
||||
interface TooltipData {
|
||||
entry: ScoreHistoryEntry;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score history chart component.
|
||||
*
|
||||
* Displays a timeline visualization of score changes with:
|
||||
* - Line chart showing score over time
|
||||
* - Colored bucket bands (background regions)
|
||||
* - Data points with change type indicators
|
||||
* - Hover tooltips with change details
|
||||
*
|
||||
* @example
|
||||
* <stella-score-history-chart
|
||||
* [history]="scoreHistory"
|
||||
* [height]="200"
|
||||
* (pointClick)="onPointClick($event)"
|
||||
* />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stella-score-history-chart',
|
||||
standalone: true,
|
||||
imports: [CommonModule, DatePipe, FormsModule],
|
||||
templateUrl: './score-history-chart.component.html',
|
||||
styleUrls: ['./score-history-chart.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ScoreHistoryChartComponent {
|
||||
/** History entries to display */
|
||||
readonly history = input.required<ScoreHistoryEntry[]>();
|
||||
|
||||
/** Chart width (auto if not specified) */
|
||||
readonly width = input<number | 'auto'>('auto');
|
||||
|
||||
/** Chart height */
|
||||
readonly height = input<number>(200);
|
||||
|
||||
/** Whether to show bucket bands */
|
||||
readonly showBands = input(true);
|
||||
|
||||
/** Whether to show grid lines */
|
||||
readonly showGrid = input(true);
|
||||
|
||||
/** Whether to show date range selector */
|
||||
readonly showRangeSelector = input(true);
|
||||
|
||||
/** Default date range preset */
|
||||
readonly defaultRange = input<DateRangePreset>('30d');
|
||||
|
||||
/** Emits when a data point is clicked */
|
||||
readonly pointClick = output<ScoreHistoryEntry>();
|
||||
|
||||
/** Emits when date range changes */
|
||||
readonly rangeChange = output<{ start: Date | null; end: Date | null }>();
|
||||
|
||||
/** Chart padding */
|
||||
readonly padding = { top: 20, right: 20, bottom: 40, left: 40 };
|
||||
|
||||
/** Currently hovered point */
|
||||
readonly hoveredPoint = signal<TooltipData | null>(null);
|
||||
|
||||
/** Bucket display configuration */
|
||||
readonly buckets = BUCKET_DISPLAY;
|
||||
|
||||
/** Available date range options */
|
||||
readonly dateRangeOptions = DATE_RANGE_OPTIONS;
|
||||
|
||||
/** Selected date range preset */
|
||||
readonly selectedPreset = signal<DateRangePreset>('30d');
|
||||
|
||||
/** Custom start date (for custom range) */
|
||||
readonly customStartDate = signal<string>('');
|
||||
|
||||
/** Custom end date (for custom range) */
|
||||
readonly customEndDate = signal<string>('');
|
||||
|
||||
/** Whether custom date picker is open */
|
||||
readonly showCustomPicker = signal(false);
|
||||
|
||||
/** Computed chart width (number) */
|
||||
readonly chartWidth = computed(() => {
|
||||
const w = this.width();
|
||||
return w === 'auto' ? 600 : w;
|
||||
});
|
||||
|
||||
/** Computed inner dimensions */
|
||||
readonly innerWidth = computed(() =>
|
||||
this.chartWidth() - this.padding.left - this.padding.right
|
||||
);
|
||||
|
||||
readonly innerHeight = computed(() =>
|
||||
this.height() - this.padding.top - this.padding.bottom
|
||||
);
|
||||
|
||||
/** Computed date filter range based on preset */
|
||||
readonly dateFilterRange = computed((): { start: Date | null; end: Date | null } => {
|
||||
const preset = this.selectedPreset();
|
||||
const now = new Date();
|
||||
|
||||
if (preset === 'all') {
|
||||
return { start: null, end: null };
|
||||
}
|
||||
|
||||
if (preset === 'custom') {
|
||||
const startStr = this.customStartDate();
|
||||
const endStr = this.customEndDate();
|
||||
return {
|
||||
start: startStr ? new Date(startStr) : null,
|
||||
end: endStr ? new Date(endStr) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const option = DATE_RANGE_OPTIONS.find((o) => o.preset === preset);
|
||||
if (option?.days) {
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - option.days);
|
||||
return { start, end: now };
|
||||
}
|
||||
|
||||
return { start: null, end: null };
|
||||
});
|
||||
|
||||
/** Sorted history entries (oldest first) */
|
||||
readonly sortedHistory = computed(() => {
|
||||
return [...this.history()].sort(
|
||||
(a, b) => new Date(a.calculatedAt).getTime() - new Date(b.calculatedAt).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
/** Filtered history entries based on date range */
|
||||
readonly filteredHistory = computed(() => {
|
||||
const entries = this.sortedHistory();
|
||||
const { start, end } = this.dateFilterRange();
|
||||
|
||||
if (!start && !end) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
return entries.filter((entry) => {
|
||||
const entryDate = new Date(entry.calculatedAt);
|
||||
if (start && entryDate < start) return false;
|
||||
if (end && entryDate > end) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
/** Time range for x-axis */
|
||||
readonly timeRange = computed(() => {
|
||||
const entries = this.filteredHistory();
|
||||
if (entries.length === 0) {
|
||||
const now = Date.now();
|
||||
return { min: now - 86400000, max: now };
|
||||
}
|
||||
|
||||
const times = entries.map((e) => new Date(e.calculatedAt).getTime());
|
||||
const min = Math.min(...times);
|
||||
const max = Math.max(...times);
|
||||
|
||||
// Add some padding to time range
|
||||
const range = max - min || 86400000;
|
||||
return { min: min - range * 0.05, max: max + range * 0.05 };
|
||||
});
|
||||
|
||||
/** Chart data points with coordinates */
|
||||
readonly dataPoints = computed((): ChartDataPoint[] => {
|
||||
const entries = this.filteredHistory();
|
||||
const { min, max } = this.timeRange();
|
||||
const timeSpan = max - min || 1;
|
||||
|
||||
return entries.map((entry) => {
|
||||
const time = new Date(entry.calculatedAt).getTime();
|
||||
const x = this.padding.left + ((time - min) / timeSpan) * this.innerWidth();
|
||||
const y = this.padding.top + ((100 - entry.score) / 100) * this.innerHeight();
|
||||
|
||||
return { entry, x, y, date: new Date(entry.calculatedAt) };
|
||||
});
|
||||
});
|
||||
|
||||
/** SVG path for the line */
|
||||
readonly linePath = computed(() => {
|
||||
const points = this.dataPoints();
|
||||
if (points.length === 0) return '';
|
||||
|
||||
return points
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
/** SVG path for the area under the line */
|
||||
readonly areaPath = computed(() => {
|
||||
const points = this.dataPoints();
|
||||
if (points.length === 0) return '';
|
||||
|
||||
const bottom = this.padding.top + this.innerHeight();
|
||||
const firstX = points[0].x;
|
||||
const lastX = points[points.length - 1].x;
|
||||
|
||||
return `${this.linePath()} L ${lastX} ${bottom} L ${firstX} ${bottom} Z`;
|
||||
});
|
||||
|
||||
/** Bucket band rectangles */
|
||||
readonly bucketBands = computed(() => {
|
||||
return BUCKET_DISPLAY.map((bucket) => {
|
||||
const yTop = this.padding.top + ((100 - bucket.maxScore) / 100) * this.innerHeight();
|
||||
const yBottom = this.padding.top + ((100 - bucket.minScore) / 100) * this.innerHeight();
|
||||
|
||||
return {
|
||||
...bucket,
|
||||
y: yTop,
|
||||
height: yBottom - yTop,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** Y-axis tick values */
|
||||
readonly yTicks = computed(() => {
|
||||
return [0, 25, 50, 75, 100].map((value) => ({
|
||||
value,
|
||||
y: this.padding.top + ((100 - value) / 100) * this.innerHeight(),
|
||||
}));
|
||||
});
|
||||
|
||||
/** X-axis tick values */
|
||||
readonly xTicks = computed(() => {
|
||||
const { min, max } = this.timeRange();
|
||||
const tickCount = 5;
|
||||
const step = (max - min) / (tickCount - 1);
|
||||
|
||||
return Array.from({ length: tickCount }, (_, i) => {
|
||||
const time = min + i * step;
|
||||
const x = this.padding.left + ((time - min) / (max - min)) * this.innerWidth();
|
||||
|
||||
return {
|
||||
time: new Date(time),
|
||||
x,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** Get trigger icon for data point */
|
||||
getTriggerIcon(trigger: ScoreChangeTrigger): string {
|
||||
switch (trigger) {
|
||||
case 'evidence_update':
|
||||
return '\u25CF'; // filled circle
|
||||
case 'policy_change':
|
||||
return '\u25CB'; // empty circle
|
||||
case 'scheduled':
|
||||
return '\u25C6'; // diamond
|
||||
default:
|
||||
return '\u25CF';
|
||||
}
|
||||
}
|
||||
|
||||
/** Get point color based on bucket */
|
||||
getPointColor(score: number): string {
|
||||
return getBucketForScore(score).backgroundColor;
|
||||
}
|
||||
|
||||
/** Handle point hover */
|
||||
onPointEnter(point: ChartDataPoint): void {
|
||||
this.hoveredPoint.set({
|
||||
entry: point.entry,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle point leave */
|
||||
onPointLeave(): void {
|
||||
this.hoveredPoint.set(null);
|
||||
}
|
||||
|
||||
/** Handle point click */
|
||||
onPointClick(point: ChartDataPoint): void {
|
||||
this.pointClick.emit(point.entry);
|
||||
}
|
||||
|
||||
/** Format date for display */
|
||||
formatDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/** Format tooltip date */
|
||||
formatTooltipDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
/** Get trigger label */
|
||||
getTriggerLabel(trigger: ScoreChangeTrigger): string {
|
||||
switch (trigger) {
|
||||
case 'evidence_update':
|
||||
return 'Evidence Update';
|
||||
case 'policy_change':
|
||||
return 'Policy Change';
|
||||
case 'scheduled':
|
||||
return 'Scheduled';
|
||||
default:
|
||||
return trigger;
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle preset selection */
|
||||
onPresetSelect(preset: DateRangePreset): void {
|
||||
this.selectedPreset.set(preset);
|
||||
|
||||
if (preset === 'custom') {
|
||||
this.showCustomPicker.set(true);
|
||||
// Initialize custom dates if not set
|
||||
if (!this.customStartDate()) {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
this.customStartDate.set(thirtyDaysAgo.toISOString().slice(0, 10));
|
||||
}
|
||||
if (!this.customEndDate()) {
|
||||
this.customEndDate.set(new Date().toISOString().slice(0, 10));
|
||||
}
|
||||
} else {
|
||||
this.showCustomPicker.set(false);
|
||||
}
|
||||
|
||||
this.emitRangeChange();
|
||||
}
|
||||
|
||||
/** Handle custom start date change */
|
||||
onCustomStartChange(value: string): void {
|
||||
this.customStartDate.set(value);
|
||||
this.emitRangeChange();
|
||||
}
|
||||
|
||||
/** Handle custom end date change */
|
||||
onCustomEndChange(value: string): void {
|
||||
this.customEndDate.set(value);
|
||||
this.emitRangeChange();
|
||||
}
|
||||
|
||||
/** Apply custom date range */
|
||||
applyCustomRange(): void {
|
||||
this.showCustomPicker.set(false);
|
||||
this.emitRangeChange();
|
||||
}
|
||||
|
||||
/** Close custom picker without applying */
|
||||
closeCustomPicker(): void {
|
||||
// Reset to previous non-custom preset if no dates set
|
||||
if (!this.customStartDate() && !this.customEndDate()) {
|
||||
this.selectedPreset.set('30d');
|
||||
}
|
||||
this.showCustomPicker.set(false);
|
||||
}
|
||||
|
||||
/** Emit range change event */
|
||||
private emitRangeChange(): void {
|
||||
this.rangeChange.emit(this.dateFilterRange());
|
||||
}
|
||||
|
||||
/** Check if preset is selected */
|
||||
isPresetSelected(preset: DateRangePreset): boolean {
|
||||
return this.selectedPreset() === preset;
|
||||
}
|
||||
|
||||
/** Get display label for current range */
|
||||
getCurrentRangeLabel(): string {
|
||||
const preset = this.selectedPreset();
|
||||
const option = DATE_RANGE_OPTIONS.find((o) => o.preset === preset);
|
||||
if (option) {
|
||||
return option.label;
|
||||
}
|
||||
return 'Select range';
|
||||
}
|
||||
|
||||
/** Format ISO date string for input */
|
||||
formatInputDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Get entry count for display */
|
||||
getEntryCount(): number {
|
||||
return this.filteredHistory().length;
|
||||
}
|
||||
|
||||
/** Get total entry count */
|
||||
getTotalEntryCount(): number {
|
||||
return this.sortedHistory().length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<span
|
||||
class="score-pill"
|
||||
[class]="sizeClasses()"
|
||||
[class.interactive]="interactive()"
|
||||
[style.backgroundColor]="backgroundColor()"
|
||||
[style.color]="textColor()"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
[attr.role]="interactive() ? 'button' : 'status'"
|
||||
[attr.tabindex]="interactive() ? 0 : null"
|
||||
[attr.title]="showTooltip() ? bucketLabel() + ': ' + bucketDescription() : null"
|
||||
(click)="onClick($event)"
|
||||
(keydown)="onKeydown($event)"
|
||||
>
|
||||
{{ score() }}
|
||||
</span>
|
||||
@@ -0,0 +1,71 @@
|
||||
.score-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
|
||||
&.interactive {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Size variants
|
||||
.pill-sm {
|
||||
min-width: 24px;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.pill-md {
|
||||
min-width: 32px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.pill-lg {
|
||||
min-width: 40px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
// High contrast mode support
|
||||
@media (prefers-contrast: high) {
|
||||
.score-pill {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
// Reduced motion support
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.score-pill {
|
||||
transition: none;
|
||||
|
||||
&.interactive:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ScorePillComponent } from './score-pill.component';
|
||||
|
||||
describe('ScorePillComponent', () => {
|
||||
let component: ScorePillComponent;
|
||||
let fixture: ComponentFixture<ScorePillComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ScorePillComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ScorePillComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('bucket coloring', () => {
|
||||
it('should show red background for ActNow bucket (90-100)', () => {
|
||||
fixture.componentRef.setInput('score', 95);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.bucketInfo().bucket).toBe('ActNow');
|
||||
expect(component.backgroundColor()).toBe('#DC2626');
|
||||
expect(component.textColor()).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
it('should show amber background for ScheduleNext bucket (70-89)', () => {
|
||||
fixture.componentRef.setInput('score', 78);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.bucketInfo().bucket).toBe('ScheduleNext');
|
||||
expect(component.backgroundColor()).toBe('#F59E0B');
|
||||
expect(component.textColor()).toBe('#000000');
|
||||
});
|
||||
|
||||
it('should show blue background for Investigate bucket (40-69)', () => {
|
||||
fixture.componentRef.setInput('score', 55);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.bucketInfo().bucket).toBe('Investigate');
|
||||
expect(component.backgroundColor()).toBe('#3B82F6');
|
||||
expect(component.textColor()).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
it('should show gray background for Watchlist bucket (0-39)', () => {
|
||||
fixture.componentRef.setInput('score', 25);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.bucketInfo().bucket).toBe('Watchlist');
|
||||
expect(component.backgroundColor()).toBe('#6B7280');
|
||||
expect(component.textColor()).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
it('should handle boundary scores correctly', () => {
|
||||
// Test boundary at 90
|
||||
fixture.componentRef.setInput('score', 90);
|
||||
fixture.detectChanges();
|
||||
expect(component.bucketInfo().bucket).toBe('ActNow');
|
||||
|
||||
// Test boundary at 89
|
||||
fixture.componentRef.setInput('score', 89);
|
||||
fixture.detectChanges();
|
||||
expect(component.bucketInfo().bucket).toBe('ScheduleNext');
|
||||
|
||||
// Test boundary at 70
|
||||
fixture.componentRef.setInput('score', 70);
|
||||
fixture.detectChanges();
|
||||
expect(component.bucketInfo().bucket).toBe('ScheduleNext');
|
||||
|
||||
// Test boundary at 69
|
||||
fixture.componentRef.setInput('score', 69);
|
||||
fixture.detectChanges();
|
||||
expect(component.bucketInfo().bucket).toBe('Investigate');
|
||||
|
||||
// Test boundary at 40
|
||||
fixture.componentRef.setInput('score', 40);
|
||||
fixture.detectChanges();
|
||||
expect(component.bucketInfo().bucket).toBe('Investigate');
|
||||
|
||||
// Test boundary at 39
|
||||
fixture.componentRef.setInput('score', 39);
|
||||
fixture.detectChanges();
|
||||
expect(component.bucketInfo().bucket).toBe('Watchlist');
|
||||
});
|
||||
|
||||
it('should handle edge cases (0 and 100)', () => {
|
||||
fixture.componentRef.setInput('score', 0);
|
||||
fixture.detectChanges();
|
||||
expect(component.bucketInfo().bucket).toBe('Watchlist');
|
||||
|
||||
fixture.componentRef.setInput('score', 100);
|
||||
fixture.detectChanges();
|
||||
expect(component.bucketInfo().bucket).toBe('ActNow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('size variants', () => {
|
||||
it('should apply sm size class', () => {
|
||||
fixture.componentRef.setInput('score', 50);
|
||||
fixture.componentRef.setInput('size', 'sm');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.sizeClasses()).toBe('pill-sm');
|
||||
});
|
||||
|
||||
it('should apply md size class by default', () => {
|
||||
fixture.componentRef.setInput('score', 50);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.sizeClasses()).toBe('pill-md');
|
||||
});
|
||||
|
||||
it('should apply lg size class', () => {
|
||||
fixture.componentRef.setInput('score', 50);
|
||||
fixture.componentRef.setInput('size', 'lg');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.sizeClasses()).toBe('pill-lg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have correct aria-label', () => {
|
||||
fixture.componentRef.setInput('score', 78);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.ariaLabel()).toBe('Evidence score 78 out of 100, bucket: Schedule Next');
|
||||
});
|
||||
|
||||
it('should have button role when interactive', () => {
|
||||
fixture.componentRef.setInput('score', 50);
|
||||
fixture.componentRef.setInput('interactive', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const pill = fixture.nativeElement.querySelector('.score-pill');
|
||||
expect(pill.getAttribute('role')).toBe('button');
|
||||
expect(pill.getAttribute('tabindex')).toBe('0');
|
||||
});
|
||||
|
||||
it('should have status role when not interactive', () => {
|
||||
fixture.componentRef.setInput('score', 50);
|
||||
fixture.componentRef.setInput('interactive', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const pill = fixture.nativeElement.querySelector('.score-pill');
|
||||
expect(pill.getAttribute('role')).toBe('status');
|
||||
expect(pill.getAttribute('tabindex')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('click handling', () => {
|
||||
it('should emit pillClick when clicked in interactive mode', () => {
|
||||
fixture.componentRef.setInput('score', 75);
|
||||
fixture.componentRef.setInput('interactive', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const emitSpy = jest.spyOn(component.pillClick, 'emit');
|
||||
const pill = fixture.nativeElement.querySelector('.score-pill');
|
||||
pill.click();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(75);
|
||||
});
|
||||
|
||||
it('should not emit pillClick when not interactive', () => {
|
||||
fixture.componentRef.setInput('score', 75);
|
||||
fixture.componentRef.setInput('interactive', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const emitSpy = jest.spyOn(component.pillClick, 'emit');
|
||||
const pill = fixture.nativeElement.querySelector('.score-pill');
|
||||
pill.click();
|
||||
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit pillClick on Enter key', () => {
|
||||
fixture.componentRef.setInput('score', 75);
|
||||
fixture.componentRef.setInput('interactive', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const emitSpy = jest.spyOn(component.pillClick, 'emit');
|
||||
const pill = fixture.nativeElement.querySelector('.score-pill');
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter' });
|
||||
pill.dispatchEvent(event);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(75);
|
||||
});
|
||||
|
||||
it('should emit pillClick on Space key', () => {
|
||||
fixture.componentRef.setInput('score', 75);
|
||||
fixture.componentRef.setInput('interactive', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const emitSpy = jest.spyOn(component.pillClick, 'emit');
|
||||
const pill = fixture.nativeElement.querySelector('.score-pill');
|
||||
const event = new KeyboardEvent('keydown', { key: ' ' });
|
||||
pill.dispatchEvent(event);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltip', () => {
|
||||
it('should show tooltip when showTooltip is true', () => {
|
||||
fixture.componentRef.setInput('score', 78);
|
||||
fixture.componentRef.setInput('showTooltip', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const pill = fixture.nativeElement.querySelector('.score-pill');
|
||||
expect(pill.getAttribute('title')).toContain('Schedule Next');
|
||||
});
|
||||
|
||||
it('should not show tooltip when showTooltip is false', () => {
|
||||
fixture.componentRef.setInput('score', 78);
|
||||
fixture.componentRef.setInput('showTooltip', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const pill = fixture.nativeElement.querySelector('.score-pill');
|
||||
expect(pill.getAttribute('title')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('display', () => {
|
||||
it('should display the score value', () => {
|
||||
fixture.componentRef.setInput('score', 42);
|
||||
fixture.detectChanges();
|
||||
|
||||
const pill = fixture.nativeElement.querySelector('.score-pill');
|
||||
expect(pill.textContent.trim()).toBe('42');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { getBucketForScore, ScoreBucket } from '../../../core/api/scoring.models';
|
||||
|
||||
/**
|
||||
* Size variants for the score pill.
|
||||
*/
|
||||
export type ScorePillSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Compact score display component with bucket-based color coding.
|
||||
*
|
||||
* Displays a 0-100 score in a colored pill. The background color
|
||||
* is determined by the score bucket:
|
||||
* - ActNow (90-100): Red
|
||||
* - ScheduleNext (70-89): Amber
|
||||
* - Investigate (40-69): Blue
|
||||
* - Watchlist (0-39): Gray
|
||||
*
|
||||
* @example
|
||||
* <stella-score-pill [score]="78" size="md" (pillClick)="onScoreClick($event)" />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stella-score-pill',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './score-pill.component.html',
|
||||
styleUrls: ['./score-pill.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ScorePillComponent {
|
||||
/** Score value (0-100) */
|
||||
readonly score = input.required<number>();
|
||||
|
||||
/** Size variant */
|
||||
readonly size = input<ScorePillSize>('md');
|
||||
|
||||
/** Whether to show bucket tooltip on hover */
|
||||
readonly showTooltip = input(true);
|
||||
|
||||
/** Whether the pill is interactive (shows pointer cursor, emits click) */
|
||||
readonly interactive = input(true);
|
||||
|
||||
/** Emits when pill is clicked */
|
||||
readonly pillClick = output<number>();
|
||||
|
||||
/** Computed bucket information based on score */
|
||||
readonly bucketInfo = computed(() => getBucketForScore(this.score()));
|
||||
|
||||
/** Computed bucket label */
|
||||
readonly bucketLabel = computed(() => this.bucketInfo().label);
|
||||
|
||||
/** Computed bucket description */
|
||||
readonly bucketDescription = computed(() => this.bucketInfo().description);
|
||||
|
||||
/** Computed background color */
|
||||
readonly backgroundColor = computed(() => this.bucketInfo().backgroundColor);
|
||||
|
||||
/** Computed text color */
|
||||
readonly textColor = computed(() => this.bucketInfo().textColor);
|
||||
|
||||
/** Computed CSS classes for size variant */
|
||||
readonly sizeClasses = computed(() => {
|
||||
const sizeMap: Record<ScorePillSize, string> = {
|
||||
sm: 'pill-sm',
|
||||
md: 'pill-md',
|
||||
lg: 'pill-lg',
|
||||
};
|
||||
return sizeMap[this.size()];
|
||||
});
|
||||
|
||||
/** ARIA label for accessibility */
|
||||
readonly ariaLabel = computed(() => {
|
||||
const scoreVal = this.score();
|
||||
const bucket = this.bucketLabel();
|
||||
return `Evidence score ${scoreVal} out of 100, bucket: ${bucket}`;
|
||||
});
|
||||
|
||||
/** Handle pill click */
|
||||
onClick(event: MouseEvent): void {
|
||||
if (this.interactive()) {
|
||||
event.stopPropagation();
|
||||
this.pillClick.emit(this.score());
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle keyboard activation */
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (this.interactive() && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
this.pillClick.emit(this.score());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FindingsListComponent, Finding } from '../../app/features/findings/findings-list.component';
|
||||
import { SCORING_API, MockScoringApi } from '../../app/core/services/scoring.service';
|
||||
|
||||
const mockFindings: Finding[] = [
|
||||
{
|
||||
id: 'CVE-2024-1234@pkg:npm/lodash@4.17.20',
|
||||
advisoryId: 'CVE-2024-1234',
|
||||
packageName: 'lodash',
|
||||
packageVersion: '4.17.20',
|
||||
severity: 'critical',
|
||||
status: 'open',
|
||||
publishedAt: '2024-01-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'CVE-2024-5678@pkg:npm/express@4.18.0',
|
||||
advisoryId: 'CVE-2024-5678',
|
||||
packageName: 'express',
|
||||
packageVersion: '4.18.0',
|
||||
severity: 'high',
|
||||
status: 'in_progress',
|
||||
publishedAt: '2024-02-20T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'GHSA-abc123@pkg:pypi/requests@2.25.0',
|
||||
advisoryId: 'GHSA-abc123',
|
||||
packageName: 'requests',
|
||||
packageVersion: '2.25.0',
|
||||
severity: 'medium',
|
||||
status: 'fixed',
|
||||
publishedAt: '2024-03-10T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'CVE-2023-9999@pkg:deb/debian/openssl@1.1.1',
|
||||
advisoryId: 'CVE-2023-9999',
|
||||
packageName: 'openssl',
|
||||
packageVersion: '1.1.1',
|
||||
severity: 'low',
|
||||
status: 'excepted',
|
||||
publishedAt: '2023-12-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'CVE-2024-8888@pkg:npm/axios@1.4.0',
|
||||
advisoryId: 'CVE-2024-8888',
|
||||
packageName: 'axios',
|
||||
packageVersion: '1.4.0',
|
||||
severity: 'high',
|
||||
status: 'open',
|
||||
publishedAt: '2024-04-05T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'GHSA-xyz789@pkg:npm/webpack@5.88.0',
|
||||
advisoryId: 'GHSA-xyz789',
|
||||
packageName: 'webpack',
|
||||
packageVersion: '5.88.0',
|
||||
severity: 'medium',
|
||||
status: 'in_progress',
|
||||
publishedAt: '2024-05-01T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<FindingsListComponent> = {
|
||||
title: 'Findings/FindingsList',
|
||||
component: FindingsListComponent,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [FormsModule],
|
||||
providers: [{ provide: SCORING_API, useClass: MockScoringApi }],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
A comprehensive findings list component with Evidence-Weighted Score (EWS) integration.
|
||||
|
||||
## Features
|
||||
|
||||
- **Score Pills**: Display calculated EWS scores with bucket-based coloring
|
||||
- **Score Badges**: Show active flags (live-signal, proven-path, vendor-na, speculative)
|
||||
- **Score Popover**: Click on score pill to see full breakdown
|
||||
- **Bucket Filtering**: Filter findings by priority bucket (Act Now, Schedule Next, Investigate, Watchlist)
|
||||
- **Flag Filtering**: Filter findings by active flags
|
||||
- **Search**: Search by advisory ID or package name
|
||||
- **Sorting**: Sort by score, severity, advisory ID, or package name
|
||||
- **Bulk Selection**: Select multiple findings for batch operations
|
||||
|
||||
## Score Buckets
|
||||
|
||||
| Bucket | Score Range | Color | Action |
|
||||
|--------|-------------|-------|--------|
|
||||
| Act Now | 90-100 | Red | Immediate action required |
|
||||
| Schedule Next | 70-89 | Amber | High priority, schedule soon |
|
||||
| Investigate | 40-69 | Blue | Medium priority, investigate |
|
||||
| Watchlist | 0-39 | Gray | Monitor for changes |
|
||||
|
||||
## Usage
|
||||
|
||||
\`\`\`html
|
||||
<app-findings-list
|
||||
[findings]="findings"
|
||||
[autoLoadScores]="true"
|
||||
(findingSelect)="onFindingSelect($event)"
|
||||
(selectionChange)="onSelectionChange($event)"
|
||||
/>
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
argTypes: {
|
||||
findings: {
|
||||
description: 'Array of findings to display',
|
||||
control: 'object',
|
||||
},
|
||||
autoLoadScores: {
|
||||
description: 'Whether to automatically load scores when findings change',
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<FindingsListComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
findings: mockFindings,
|
||||
autoLoadScores: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Default findings list with auto-loaded scores. Scores are calculated via the mock scoring API.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutScores: Story = {
|
||||
args: {
|
||||
findings: mockFindings,
|
||||
autoLoadScores: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Findings list without score loading. Score column shows dashes for unscored findings.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
findings: [],
|
||||
autoLoadScores: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Empty findings list shows a placeholder message.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleFinding: Story = {
|
||||
args: {
|
||||
findings: [mockFindings[0]],
|
||||
autoLoadScores: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Findings list with a single finding.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyFindings: Story = {
|
||||
args: {
|
||||
findings: [
|
||||
...mockFindings,
|
||||
{
|
||||
id: 'CVE-2024-1111@pkg:npm/react@18.2.0',
|
||||
advisoryId: 'CVE-2024-1111',
|
||||
packageName: 'react',
|
||||
packageVersion: '18.2.0',
|
||||
severity: 'medium',
|
||||
status: 'open',
|
||||
publishedAt: '2024-06-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'CVE-2024-2222@pkg:npm/next@14.0.0',
|
||||
advisoryId: 'CVE-2024-2222',
|
||||
packageName: 'next',
|
||||
packageVersion: '14.0.0',
|
||||
severity: 'high',
|
||||
status: 'in_progress',
|
||||
publishedAt: '2024-06-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'GHSA-def456@pkg:pypi/django@4.2.0',
|
||||
advisoryId: 'GHSA-def456',
|
||||
packageName: 'django',
|
||||
packageVersion: '4.2.0',
|
||||
severity: 'critical',
|
||||
status: 'open',
|
||||
publishedAt: '2024-07-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'CVE-2024-3333@pkg:npm/typescript@5.2.0',
|
||||
advisoryId: 'CVE-2024-3333',
|
||||
packageName: 'typescript',
|
||||
packageVersion: '5.2.0',
|
||||
severity: 'low',
|
||||
status: 'excepted',
|
||||
publishedAt: '2024-07-10T10:00:00Z',
|
||||
},
|
||||
],
|
||||
autoLoadScores: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Findings list with many items to demonstrate scrolling and bucket distribution.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CriticalOnly: Story = {
|
||||
args: {
|
||||
findings: mockFindings.filter((f) => f.severity === 'critical'),
|
||||
autoLoadScores: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Findings list showing only critical severity findings.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenOnly: Story = {
|
||||
args: {
|
||||
findings: mockFindings.filter((f) => f.status === 'open'),
|
||||
autoLoadScores: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Findings list showing only open (unfixed) findings.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Interactive story with actions
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
findings: mockFindings,
|
||||
autoLoadScores: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
Interactive findings list demonstrating all features:
|
||||
|
||||
1. **Click on bucket chips** to filter by priority
|
||||
2. **Type in search box** to filter by advisory ID or package name
|
||||
3. **Check flag filters** to show only findings with specific flags
|
||||
4. **Click column headers** to sort (click again to reverse)
|
||||
5. **Click checkboxes** to select findings for bulk actions
|
||||
6. **Click on a score pill** to see the full breakdown popover
|
||||
7. **Click on a row** to select a finding (triggers findingSelect event)
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
337
src/Web/StellaOps.Web/src/stories/score/score-badge.stories.ts
Normal file
337
src/Web/StellaOps.Web/src/stories/score/score-badge.stories.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { argsToTemplate } from '@storybook/angular';
|
||||
import { ScoreBadgeComponent } from '../../app/shared/components/score/score-badge.component';
|
||||
|
||||
const meta: Meta<ScoreBadgeComponent> = {
|
||||
title: 'Score/ScoreBadge',
|
||||
component: ScoreBadgeComponent,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
Score badge component displaying evidence flags with icons and labels.
|
||||
|
||||
Each badge type represents a specific score characteristic:
|
||||
- **Live Signal** (green, pulse animation): Active runtime signals detected from deployed environments
|
||||
- **Proven Path** (blue, checkmark): Verified reachability path to vulnerable code
|
||||
- **Vendor N/A** (gray, strikethrough): Vendor has marked this vulnerability as not affected
|
||||
- **Speculative** (orange, question mark): Evidence is speculative or unconfirmed
|
||||
|
||||
Use these badges alongside score pills to provide additional context about evidence quality.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['live-signal', 'proven-path', 'vendor-na', 'speculative'],
|
||||
description: 'The flag type to display',
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md'],
|
||||
description: 'Size variant of the badge',
|
||||
},
|
||||
showTooltip: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show description tooltip on hover',
|
||||
},
|
||||
showLabel: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show the label text (false = icon-only mode)',
|
||||
},
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<stella-score-badge ${argsToTemplate(args)} />`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<ScoreBadgeComponent>;
|
||||
|
||||
// Default
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
type: 'live-signal',
|
||||
size: 'md',
|
||||
showTooltip: true,
|
||||
showLabel: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Live Signal
|
||||
export const LiveSignal: Story = {
|
||||
args: {
|
||||
type: 'live-signal',
|
||||
size: 'md',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Indicates active runtime signals detected from deployed environments. Features a pulse animation to draw attention.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Proven Path
|
||||
export const ProvenPath: Story = {
|
||||
args: {
|
||||
type: 'proven-path',
|
||||
size: 'md',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Indicates a verified reachability path to vulnerable code has been confirmed through static or dynamic analysis.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Vendor N/A
|
||||
export const VendorNA: Story = {
|
||||
args: {
|
||||
type: 'vendor-na',
|
||||
size: 'md',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Indicates the vendor has marked this vulnerability as not affecting their product, typically through VEX or CSAF.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Speculative
|
||||
export const Speculative: Story = {
|
||||
args: {
|
||||
type: 'speculative',
|
||||
size: 'md',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Indicates evidence is speculative or unconfirmed. Score will be capped when this flag is present.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// All types comparison
|
||||
export const AllTypes: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
|
||||
<stella-score-badge type="live-signal" />
|
||||
<stella-score-badge type="proven-path" />
|
||||
<stella-score-badge type="vendor-na" />
|
||||
<stella-score-badge type="speculative" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'All four badge types displayed together for comparison.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Size comparison
|
||||
export const SizeComparison: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<span style="width: 80px; font-size: 14px; color: #666;">Small:</span>
|
||||
<stella-score-badge type="live-signal" size="sm" />
|
||||
<stella-score-badge type="proven-path" size="sm" />
|
||||
<stella-score-badge type="vendor-na" size="sm" />
|
||||
<stella-score-badge type="speculative" size="sm" />
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<span style="width: 80px; font-size: 14px; color: #666;">Medium:</span>
|
||||
<stella-score-badge type="live-signal" size="md" />
|
||||
<stella-score-badge type="proven-path" size="md" />
|
||||
<stella-score-badge type="vendor-na" size="md" />
|
||||
<stella-score-badge type="speculative" size="md" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Comparison of small and medium size variants.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Icon-only mode
|
||||
export const IconOnly: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<stella-score-badge type="live-signal" [showLabel]="false" />
|
||||
<stella-score-badge type="proven-path" [showLabel]="false" />
|
||||
<stella-score-badge type="vendor-na" [showLabel]="false" />
|
||||
<stella-score-badge type="speculative" [showLabel]="false" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Icon-only mode for compact displays. Hover for tooltip with full description.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Icon-only size comparison
|
||||
export const IconOnlySizes: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<span style="width: 80px; font-size: 14px; color: #666;">Small:</span>
|
||||
<stella-score-badge type="live-signal" size="sm" [showLabel]="false" />
|
||||
<stella-score-badge type="proven-path" size="sm" [showLabel]="false" />
|
||||
<stella-score-badge type="vendor-na" size="sm" [showLabel]="false" />
|
||||
<stella-score-badge type="speculative" size="sm" [showLabel]="false" />
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<span style="width: 80px; font-size: 14px; color: #666;">Medium:</span>
|
||||
<stella-score-badge type="live-signal" size="md" [showLabel]="false" />
|
||||
<stella-score-badge type="proven-path" size="md" [showLabel]="false" />
|
||||
<stella-score-badge type="vendor-na" size="md" [showLabel]="false" />
|
||||
<stella-score-badge type="speculative" size="md" [showLabel]="false" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Icon-only badges in both size variants.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// In table context
|
||||
export const InTableContext: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<table style="border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 14px; width: 100%;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid #e5e7eb;">
|
||||
<th style="padding: 12px; text-align: left;">Finding</th>
|
||||
<th style="padding: 12px; text-align: left;">Flags</th>
|
||||
<th style="padding: 12px; text-align: left;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 12px;">CVE-2024-1234</td>
|
||||
<td style="padding: 12px;">
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<stella-score-badge type="live-signal" size="sm" />
|
||||
<stella-score-badge type="proven-path" size="sm" />
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding: 12px;">Critical</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 12px;">CVE-2024-5678</td>
|
||||
<td style="padding: 12px;">
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<stella-score-badge type="proven-path" size="sm" />
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding: 12px;">High</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 12px;">GHSA-abc123</td>
|
||||
<td style="padding: 12px;">
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<stella-score-badge type="speculative" size="sm" />
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding: 12px;">Medium</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px;">CVE-2023-9999</td>
|
||||
<td style="padding: 12px;">
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<stella-score-badge type="vendor-na" size="sm" />
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding: 12px;">Low</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Score badges in a findings table context.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Combined with score pill
|
||||
export const WithScorePill: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<span style="
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
background: #DC2626;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
">92</span>
|
||||
<stella-score-badge type="live-signal" size="sm" />
|
||||
<stella-score-badge type="proven-path" size="sm" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Badges displayed alongside a score pill.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Without tooltip
|
||||
export const WithoutTooltip: Story = {
|
||||
args: {
|
||||
type: 'live-signal',
|
||||
showTooltip: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Badge without tooltip. Use when description is shown elsewhere.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,413 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { ScoreBreakdownPopoverComponent } from '../../app/shared/components/score/score-breakdown-popover.component';
|
||||
import { EvidenceWeightedScoreResult } from '../../app/core/api/scoring.models';
|
||||
|
||||
const createMockScore = (
|
||||
overrides: Partial<EvidenceWeightedScoreResult> = {}
|
||||
): EvidenceWeightedScoreResult => ({
|
||||
findingId: 'CVE-2024-1234@pkg:deb/debian/curl@7.64.0-4',
|
||||
score: 78,
|
||||
bucket: 'ScheduleNext',
|
||||
inputs: {
|
||||
rch: 0.85,
|
||||
rts: 0.4,
|
||||
bkp: 0.0,
|
||||
xpl: 0.7,
|
||||
src: 0.8,
|
||||
mit: 0.1,
|
||||
},
|
||||
weights: {
|
||||
rch: 0.3,
|
||||
rts: 0.25,
|
||||
bkp: 0.15,
|
||||
xpl: 0.15,
|
||||
src: 0.1,
|
||||
mit: 0.1,
|
||||
},
|
||||
flags: ['live-signal', 'proven-path'],
|
||||
explanations: [
|
||||
'Static reachability: path to vulnerable sink (confidence: 85%)',
|
||||
'Runtime: 3 observations in last 24 hours',
|
||||
'EPSS: 0.8% probability (High band)',
|
||||
'Source: Distro VEX signed (trust: 80%)',
|
||||
'Mitigations: seccomp profile active',
|
||||
],
|
||||
caps: {
|
||||
speculativeCap: false,
|
||||
notAffectedCap: false,
|
||||
runtimeFloor: false,
|
||||
},
|
||||
policyDigest: 'sha256:abc123def456789012345678901234567890abcdef1234567890abcdef12345678',
|
||||
calculatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const meta: Meta<ScoreBreakdownPopoverComponent> = {
|
||||
title: 'Score/ScoreBreakdownPopover',
|
||||
component: ScoreBreakdownPopoverComponent,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
Detailed score breakdown popover showing all evidence dimensions, flags, and explanations.
|
||||
|
||||
The popover displays:
|
||||
- **Header**: Overall score with bucket classification
|
||||
- **Dimensions**: Horizontal bar chart for all six evidence dimensions
|
||||
- **Flags**: Active score flags (Live Signal, Proven Path, etc.)
|
||||
- **Guardrails**: Any applied caps or floors
|
||||
- **Explanations**: Human-readable factors contributing to the score
|
||||
- **Footer**: Policy digest and calculation timestamp
|
||||
|
||||
Use this component when users click on a score pill to see the full breakdown.
|
||||
`,
|
||||
},
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
...story,
|
||||
template: `
|
||||
<div style="min-height: 500px; display: flex; align-items: flex-start; justify-content: center; padding: 20px;">
|
||||
${story.template}
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<ScoreBreakdownPopoverComponent>;
|
||||
|
||||
// Default story
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
scoreResult: createMockScore(),
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<stella-score-breakdown-popover
|
||||
[scoreResult]="scoreResult"
|
||||
style="position: static;"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
// High score (Act Now)
|
||||
export const HighScore: Story = {
|
||||
args: {
|
||||
scoreResult: createMockScore({
|
||||
score: 95,
|
||||
bucket: 'ActNow',
|
||||
inputs: {
|
||||
rch: 0.95,
|
||||
rts: 0.9,
|
||||
bkp: 0.0,
|
||||
xpl: 0.95,
|
||||
src: 0.85,
|
||||
mit: 0.05,
|
||||
},
|
||||
flags: ['live-signal', 'proven-path'],
|
||||
caps: {
|
||||
speculativeCap: false,
|
||||
notAffectedCap: false,
|
||||
runtimeFloor: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<stella-score-breakdown-popover
|
||||
[scoreResult]="scoreResult"
|
||||
style="position: static;"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A high-priority finding with runtime floor applied.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Low score (Watchlist)
|
||||
export const LowScore: Story = {
|
||||
args: {
|
||||
scoreResult: createMockScore({
|
||||
score: 15,
|
||||
bucket: 'Watchlist',
|
||||
inputs: {
|
||||
rch: 0.1,
|
||||
rts: 0.0,
|
||||
bkp: 0.0,
|
||||
xpl: 0.2,
|
||||
src: 0.6,
|
||||
mit: 0.3,
|
||||
},
|
||||
flags: ['vendor-na'],
|
||||
explanations: [
|
||||
'No reachability path detected',
|
||||
'No runtime signals',
|
||||
'EPSS: 0.01% probability (Low band)',
|
||||
'Vendor has marked as not affected',
|
||||
],
|
||||
caps: {
|
||||
speculativeCap: false,
|
||||
notAffectedCap: true,
|
||||
runtimeFloor: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<stella-score-breakdown-popover
|
||||
[scoreResult]="scoreResult"
|
||||
style="position: static;"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A low-priority finding with vendor N/A flag and not-affected cap.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Speculative finding
|
||||
export const SpeculativeFinding: Story = {
|
||||
args: {
|
||||
scoreResult: createMockScore({
|
||||
score: 42,
|
||||
bucket: 'Investigate',
|
||||
inputs: {
|
||||
rch: 0.5,
|
||||
rts: 0.0,
|
||||
bkp: 0.0,
|
||||
xpl: 0.4,
|
||||
src: 0.5,
|
||||
mit: 0.0,
|
||||
},
|
||||
flags: ['speculative'],
|
||||
explanations: [
|
||||
'Static reachability: speculative path (low confidence)',
|
||||
'No runtime signals available',
|
||||
'Source: NVD advisory (moderate trust)',
|
||||
],
|
||||
caps: {
|
||||
speculativeCap: true,
|
||||
notAffectedCap: false,
|
||||
runtimeFloor: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<stella-score-breakdown-popover
|
||||
[scoreResult]="scoreResult"
|
||||
style="position: static;"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A speculative finding with the speculative cap applied.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// All flags active
|
||||
export const AllFlags: Story = {
|
||||
args: {
|
||||
scoreResult: createMockScore({
|
||||
score: 65,
|
||||
bucket: 'Investigate',
|
||||
flags: ['live-signal', 'proven-path', 'vendor-na', 'speculative'],
|
||||
}),
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<stella-score-breakdown-popover
|
||||
[scoreResult]="scoreResult"
|
||||
style="position: static;"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Example showing all possible flags (unusual in practice).',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// No flags
|
||||
export const NoFlags: Story = {
|
||||
args: {
|
||||
scoreResult: createMockScore({
|
||||
flags: [],
|
||||
explanations: [
|
||||
'No reachability analysis available',
|
||||
'No runtime signals',
|
||||
'Source: Generic NVD advisory',
|
||||
],
|
||||
}),
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<stella-score-breakdown-popover
|
||||
[scoreResult]="scoreResult"
|
||||
style="position: static;"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Finding with no special flags - basic score calculation.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// All guardrails applied
|
||||
export const AllGuardrails: Story = {
|
||||
args: {
|
||||
scoreResult: createMockScore({
|
||||
caps: {
|
||||
speculativeCap: true,
|
||||
notAffectedCap: true,
|
||||
runtimeFloor: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<stella-score-breakdown-popover
|
||||
[scoreResult]="scoreResult"
|
||||
style="position: static;"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Example showing all guardrails applied (unusual combination).',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Minimal explanations
|
||||
export const MinimalExplanations: Story = {
|
||||
args: {
|
||||
scoreResult: createMockScore({
|
||||
explanations: ['Basic vulnerability assessment'],
|
||||
}),
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<stella-score-breakdown-popover
|
||||
[scoreResult]="scoreResult"
|
||||
style="position: static;"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Finding with minimal explanation text.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Maximum dimension values
|
||||
export const MaxDimensions: Story = {
|
||||
args: {
|
||||
scoreResult: createMockScore({
|
||||
score: 100,
|
||||
bucket: 'ActNow',
|
||||
inputs: {
|
||||
rch: 1.0,
|
||||
rts: 1.0,
|
||||
bkp: 1.0,
|
||||
xpl: 1.0,
|
||||
src: 1.0,
|
||||
mit: 0.0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<stella-score-breakdown-popover
|
||||
[scoreResult]="scoreResult"
|
||||
style="position: static;"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Maximum possible score with all dimensions at 1.0.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// High mitigations
|
||||
export const HighMitigations: Story = {
|
||||
args: {
|
||||
scoreResult: createMockScore({
|
||||
score: 35,
|
||||
bucket: 'Watchlist',
|
||||
inputs: {
|
||||
rch: 0.7,
|
||||
rts: 0.3,
|
||||
bkp: 0.0,
|
||||
xpl: 0.5,
|
||||
src: 0.6,
|
||||
mit: 0.8,
|
||||
},
|
||||
explanations: [
|
||||
'Static reachability: path exists but mitigated',
|
||||
'Mitigations: seccomp, AppArmor, network isolation active',
|
||||
'Runtime isolation reduces exploitability',
|
||||
],
|
||||
}),
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<stella-score-breakdown-popover
|
||||
[scoreResult]="scoreResult"
|
||||
style="position: static;"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Finding with high mitigation score reducing overall priority.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,377 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { ScoreHistoryChartComponent } from '../../app/shared/components/score/score-history-chart.component';
|
||||
import { ScoreHistoryEntry, ScoreBucket, ScoreChangeTrigger } from '../../app/core/api/scoring.models';
|
||||
|
||||
// Helper to generate mock history
|
||||
function generateMockHistory(
|
||||
count: number,
|
||||
options: {
|
||||
startScore?: number;
|
||||
volatility?: number;
|
||||
startDate?: Date;
|
||||
daysSpan?: number;
|
||||
} = {}
|
||||
): ScoreHistoryEntry[] {
|
||||
const {
|
||||
startScore = 50,
|
||||
volatility = 15,
|
||||
startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
daysSpan = 30,
|
||||
} = options;
|
||||
|
||||
const triggers: ScoreChangeTrigger[] = ['evidence_update', 'policy_change', 'scheduled'];
|
||||
const factors = ['rch', 'rts', 'bkp', 'xpl', 'src', 'mit'];
|
||||
|
||||
const history: ScoreHistoryEntry[] = [];
|
||||
let currentScore = startScore;
|
||||
const msPerEntry = (daysSpan * 24 * 60 * 60 * 1000) / (count - 1);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Random score change
|
||||
const change = (Math.random() - 0.5) * volatility * 2;
|
||||
currentScore = Math.max(0, Math.min(100, currentScore + change));
|
||||
const score = Math.round(currentScore);
|
||||
|
||||
const bucket: ScoreBucket =
|
||||
score >= 90
|
||||
? 'ActNow'
|
||||
: score >= 70
|
||||
? 'ScheduleNext'
|
||||
: score >= 40
|
||||
? 'Investigate'
|
||||
: 'Watchlist';
|
||||
|
||||
const trigger = triggers[Math.floor(Math.random() * triggers.length)];
|
||||
const changedFactors =
|
||||
trigger === 'evidence_update'
|
||||
? factors.slice(0, Math.floor(Math.random() * 3) + 1)
|
||||
: [];
|
||||
|
||||
history.push({
|
||||
score,
|
||||
bucket,
|
||||
policyDigest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
calculatedAt: new Date(startDate.getTime() + i * msPerEntry).toISOString(),
|
||||
trigger,
|
||||
changedFactors,
|
||||
});
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
const meta: Meta<ScoreHistoryChartComponent> = {
|
||||
title: 'Score/ScoreHistoryChart',
|
||||
component: ScoreHistoryChartComponent,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
Timeline visualization showing how a finding's evidence-weighted score has changed over time.
|
||||
|
||||
Features:
|
||||
- **Line chart**: Shows score progression with area fill
|
||||
- **Bucket bands**: Colored background regions showing priority thresholds
|
||||
- **Data points**: Interactive markers with trigger type indicators
|
||||
- **Tooltips**: Detailed information on hover including trigger type and changed factors
|
||||
- **Legend**: Explains the different trigger type markers
|
||||
|
||||
Use this component in finding detail views to show score history.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
width: {
|
||||
control: { type: 'select' },
|
||||
options: ['auto', 400, 600, 800],
|
||||
description: 'Chart width (auto or fixed pixels)',
|
||||
},
|
||||
height: {
|
||||
control: { type: 'range', min: 100, max: 400, step: 20 },
|
||||
description: 'Chart height in pixels',
|
||||
},
|
||||
showBands: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show bucket background bands',
|
||||
},
|
||||
showGrid: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show grid lines',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<ScoreHistoryChartComponent>;
|
||||
|
||||
// Default story
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(10, { startScore: 50, volatility: 12 }),
|
||||
height: 200,
|
||||
showBands: true,
|
||||
showGrid: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Upward trend
|
||||
export const UpwardTrend: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(8, { startScore: 30, volatility: 8 }).map((entry, i, arr) => ({
|
||||
...entry,
|
||||
score: Math.min(100, 30 + i * 8 + Math.random() * 5),
|
||||
bucket:
|
||||
30 + i * 8 >= 90
|
||||
? 'ActNow'
|
||||
: 30 + i * 8 >= 70
|
||||
? 'ScheduleNext'
|
||||
: 30 + i * 8 >= 40
|
||||
? 'Investigate'
|
||||
: 'Watchlist',
|
||||
})),
|
||||
height: 200,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Score trending upward over time, indicating increasing priority.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Downward trend
|
||||
export const DownwardTrend: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(8, { startScore: 85, volatility: 8 }).map((entry, i) => ({
|
||||
...entry,
|
||||
score: Math.max(0, 85 - i * 10 + Math.random() * 5),
|
||||
bucket:
|
||||
85 - i * 10 >= 90
|
||||
? 'ActNow'
|
||||
: 85 - i * 10 >= 70
|
||||
? 'ScheduleNext'
|
||||
: 85 - i * 10 >= 40
|
||||
? 'Investigate'
|
||||
: 'Watchlist',
|
||||
})),
|
||||
height: 200,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Score trending downward over time, indicating decreasing priority (e.g., mitigations applied).',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Stable score
|
||||
export const StableScore: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(10, { startScore: 75, volatility: 3 }),
|
||||
height: 200,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Relatively stable score with minor fluctuations.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// High volatility
|
||||
export const HighVolatility: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(12, { startScore: 50, volatility: 25 }),
|
||||
height: 200,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Highly volatile score indicating frequently changing evidence.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Few data points
|
||||
export const FewDataPoints: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(3, { startScore: 60, volatility: 10 }),
|
||||
height: 200,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Minimal history with only a few data points.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Many data points
|
||||
export const ManyDataPoints: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(30, { startScore: 50, volatility: 10, daysSpan: 90 }),
|
||||
height: 250,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Extended history with many data points over 90 days.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Without bucket bands
|
||||
export const NoBands: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(10, { startScore: 50, volatility: 15 }),
|
||||
height: 200,
|
||||
showBands: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Chart without bucket background bands for a cleaner look.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Without grid lines
|
||||
export const NoGrid: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(10, { startScore: 50, volatility: 15 }),
|
||||
height: 200,
|
||||
showGrid: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Chart without grid lines.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Minimal chart
|
||||
export const Minimal: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(8, { startScore: 60, volatility: 10 }),
|
||||
height: 150,
|
||||
showBands: false,
|
||||
showGrid: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Minimal chart without bands or grid for compact displays.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Tall chart
|
||||
export const TallChart: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(10, { startScore: 50, volatility: 15 }),
|
||||
height: 350,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Taller chart for better visibility of score changes.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Wide chart
|
||||
export const WideChart: Story = {
|
||||
args: {
|
||||
history: generateMockHistory(15, { startScore: 50, volatility: 12, daysSpan: 60 }),
|
||||
width: 800,
|
||||
height: 200,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Wider chart for more horizontal detail.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Single entry
|
||||
export const SingleEntry: Story = {
|
||||
args: {
|
||||
history: [
|
||||
{
|
||||
score: 78,
|
||||
bucket: 'ScheduleNext',
|
||||
policyDigest: 'sha256:abc123',
|
||||
calculatedAt: new Date().toISOString(),
|
||||
trigger: 'evidence_update',
|
||||
changedFactors: ['rch', 'xpl'],
|
||||
},
|
||||
],
|
||||
height: 200,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Chart with only a single data point.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Critical finding history
|
||||
export const CriticalFinding: Story = {
|
||||
args: {
|
||||
history: [
|
||||
{ score: 65, bucket: 'Investigate', policyDigest: 'sha256:abc', calculatedAt: '2025-01-01T10:00:00Z', trigger: 'scheduled', changedFactors: [] },
|
||||
{ score: 72, bucket: 'ScheduleNext', policyDigest: 'sha256:abc', calculatedAt: '2025-01-05T10:00:00Z', trigger: 'evidence_update', changedFactors: ['xpl'] },
|
||||
{ score: 78, bucket: 'ScheduleNext', policyDigest: 'sha256:abc', calculatedAt: '2025-01-08T10:00:00Z', trigger: 'evidence_update', changedFactors: ['rch'] },
|
||||
{ score: 85, bucket: 'ScheduleNext', policyDigest: 'sha256:abc', calculatedAt: '2025-01-10T10:00:00Z', trigger: 'evidence_update', changedFactors: ['rts'] },
|
||||
{ score: 92, bucket: 'ActNow', policyDigest: 'sha256:abc', calculatedAt: '2025-01-12T10:00:00Z', trigger: 'evidence_update', changedFactors: ['rts', 'xpl'] },
|
||||
{ score: 95, bucket: 'ActNow', policyDigest: 'sha256:abc', calculatedAt: '2025-01-14T10:00:00Z', trigger: 'evidence_update', changedFactors: ['rch'] },
|
||||
],
|
||||
height: 200,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Finding that has escalated to critical priority over time.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Resolved finding history
|
||||
export const ResolvedFinding: Story = {
|
||||
args: {
|
||||
history: [
|
||||
{ score: 88, bucket: 'ScheduleNext', policyDigest: 'sha256:abc', calculatedAt: '2025-01-01T10:00:00Z', trigger: 'scheduled', changedFactors: [] },
|
||||
{ score: 82, bucket: 'ScheduleNext', policyDigest: 'sha256:abc', calculatedAt: '2025-01-05T10:00:00Z', trigger: 'evidence_update', changedFactors: ['bkp'] },
|
||||
{ score: 65, bucket: 'Investigate', policyDigest: 'sha256:abc', calculatedAt: '2025-01-08T10:00:00Z', trigger: 'evidence_update', changedFactors: ['mit'] },
|
||||
{ score: 45, bucket: 'Investigate', policyDigest: 'sha256:abc', calculatedAt: '2025-01-10T10:00:00Z', trigger: 'evidence_update', changedFactors: ['mit', 'bkp'] },
|
||||
{ score: 28, bucket: 'Watchlist', policyDigest: 'sha256:abc', calculatedAt: '2025-01-12T10:00:00Z', trigger: 'evidence_update', changedFactors: ['mit'] },
|
||||
{ score: 15, bucket: 'Watchlist', policyDigest: 'sha256:def', calculatedAt: '2025-01-14T10:00:00Z', trigger: 'policy_change', changedFactors: [] },
|
||||
],
|
||||
height: 200,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Finding that has been resolved through mitigations and backports.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
349
src/Web/StellaOps.Web/src/stories/score/score-pill.stories.ts
Normal file
349
src/Web/StellaOps.Web/src/stories/score/score-pill.stories.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { argsToTemplate } from '@storybook/angular';
|
||||
import { ScorePillComponent } from '../../app/shared/components/score/score-pill.component';
|
||||
|
||||
const meta: Meta<ScorePillComponent> = {
|
||||
title: 'Score/ScorePill',
|
||||
component: ScorePillComponent,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
Compact score display component with bucket-based color coding.
|
||||
|
||||
The score pill displays a 0-100 evidence-weighted score with color coding based on the priority bucket:
|
||||
- **Act Now** (90-100): Red - Critical priority, immediate action required
|
||||
- **Schedule Next** (70-89): Amber - High priority, schedule for next sprint
|
||||
- **Investigate** (40-69): Blue - Medium priority, investigate when possible
|
||||
- **Watchlist** (0-39): Gray - Low priority, monitor for changes
|
||||
|
||||
The component supports three size variants and is fully accessible with ARIA labels and keyboard navigation.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
score: {
|
||||
control: { type: 'range', min: 0, max: 100, step: 1 },
|
||||
description: 'The evidence-weighted score (0-100)',
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['sm', 'md', 'lg'],
|
||||
description: 'Size variant of the pill',
|
||||
},
|
||||
showTooltip: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show bucket tooltip on hover',
|
||||
},
|
||||
interactive: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the pill is clickable',
|
||||
},
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<stella-score-pill ${argsToTemplate(args)} />`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<ScorePillComponent>;
|
||||
|
||||
// Default story
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
score: 78,
|
||||
size: 'md',
|
||||
showTooltip: true,
|
||||
interactive: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Bucket examples
|
||||
export const ActNow: Story = {
|
||||
args: {
|
||||
score: 95,
|
||||
size: 'md',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Score in the Act Now bucket (90-100). Red background indicates critical priority.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ScheduleNext: Story = {
|
||||
args: {
|
||||
score: 78,
|
||||
size: 'md',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Score in the Schedule Next bucket (70-89). Amber background indicates high priority.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Investigate: Story = {
|
||||
args: {
|
||||
score: 52,
|
||||
size: 'md',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Score in the Investigate bucket (40-69). Blue background indicates medium priority.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Watchlist: Story = {
|
||||
args: {
|
||||
score: 23,
|
||||
size: 'md',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Score in the Watchlist bucket (0-39). Gray background indicates low priority.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Size variants
|
||||
export const SmallSize: Story = {
|
||||
args: {
|
||||
score: 78,
|
||||
size: 'sm',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Small size variant (24x20px, 12px font). Use in compact layouts like tables.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MediumSize: Story = {
|
||||
args: {
|
||||
score: 78,
|
||||
size: 'md',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Medium size variant (32x24px, 14px font). Default size for most use cases.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeSize: Story = {
|
||||
args: {
|
||||
score: 78,
|
||||
size: 'lg',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Large size variant (40x28px, 16px font). Use for emphasis in dashboards.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// All sizes comparison
|
||||
export const AllSizes: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div style="display: flex; gap: 16px; align-items: center;">
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="78" size="sm" />
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #666;">Small</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="78" size="md" />
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #666;">Medium</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="78" size="lg" />
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #666;">Large</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Comparison of all three size variants side by side.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// All buckets comparison
|
||||
export const AllBuckets: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div style="display: flex; gap: 16px; align-items: center;">
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="95" />
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #666;">Act Now</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="78" />
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #666;">Schedule Next</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="52" />
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #666;">Investigate</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="23" />
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #666;">Watchlist</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'All four bucket categories with their respective colors.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Non-interactive
|
||||
export const NonInteractive: Story = {
|
||||
args: {
|
||||
score: 78,
|
||||
interactive: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Non-interactive pill without hover effects or click handling. Use for display-only contexts.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Without tooltip
|
||||
export const WithoutTooltip: Story = {
|
||||
args: {
|
||||
score: 78,
|
||||
showTooltip: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Pill without tooltip. Use when bucket info is shown elsewhere.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Boundary scores
|
||||
export const BoundaryScores: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div style="display: grid; grid-template-columns: repeat(4, auto); gap: 12px; align-items: center;">
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="100" />
|
||||
<div style="margin-top: 4px; font-size: 11px; color: #666;">100 (Max)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="90" />
|
||||
<div style="margin-top: 4px; font-size: 11px; color: #666;">90 (ActNow min)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="89" />
|
||||
<div style="margin-top: 4px; font-size: 11px; color: #666;">89 (ScheduleNext max)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="70" />
|
||||
<div style="margin-top: 4px; font-size: 11px; color: #666;">70 (ScheduleNext min)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="69" />
|
||||
<div style="margin-top: 4px; font-size: 11px; color: #666;">69 (Investigate max)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="40" />
|
||||
<div style="margin-top: 4px; font-size: 11px; color: #666;">40 (Investigate min)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="39" />
|
||||
<div style="margin-top: 4px; font-size: 11px; color: #666;">39 (Watchlist max)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<stella-score-pill [score]="0" />
|
||||
<div style="margin-top: 4px; font-size: 11px; color: #666;">0 (Min)</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Scores at bucket boundaries to verify correct color transitions.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// In a table context
|
||||
export const InTableContext: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<table style="border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 14px;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid #e5e7eb;">
|
||||
<th style="padding: 12px; text-align: left;">Finding</th>
|
||||
<th style="padding: 12px; text-align: center;">Score</th>
|
||||
<th style="padding: 12px; text-align: left;">Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 12px;">CVE-2024-1234</td>
|
||||
<td style="padding: 12px; text-align: center;"><stella-score-pill [score]="92" size="sm" /></td>
|
||||
<td style="padding: 12px;">Critical</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 12px;">CVE-2024-5678</td>
|
||||
<td style="padding: 12px; text-align: center;"><stella-score-pill [score]="78" size="sm" /></td>
|
||||
<td style="padding: 12px;">High</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 12px;">GHSA-abc123</td>
|
||||
<td style="padding: 12px; text-align: center;"><stella-score-pill [score]="45" size="sm" /></td>
|
||||
<td style="padding: 12px;">Medium</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px;">CVE-2023-9999</td>
|
||||
<td style="padding: 12px; text-align: center;"><stella-score-pill [score]="23" size="sm" /></td>
|
||||
<td style="padding: 12px;">Low</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Score pills in a findings table context using small size variant.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user