Refactor code structure and optimize performance across multiple modules

This commit is contained in:
StellaOps Bot
2025-12-26 20:03:22 +02:00
parent c786faae84
commit b4fc66feb6
3353 changed files with 88254 additions and 1590657 deletions

View File

@@ -78,3 +78,310 @@ export interface AdvisoryAiJobEvent {
readonly message?: string;
}
// ============================================================================
// Explanation API Models (ZASTAVA-15/16/17/18)
// ============================================================================
export type ExplanationType = 'What' | 'Why' | 'Evidence' | 'Counterfactual' | 'Full';
export type ExplanationAuthority = 'EvidenceBacked' | 'Suggestion';
export type EvidenceType = 'advisory' | 'sbom' | 'reachability' | 'runtime' | 'vex' | 'patch';
export interface ExplanationRequest {
readonly findingId: string;
readonly artifactDigest: string;
readonly scope: string;
readonly scopeId: string;
readonly explanationType: ExplanationType;
readonly vulnerabilityId: string;
readonly componentPurl: string;
readonly plainLanguage?: boolean;
readonly maxLength?: number;
}
export interface ExplanationCitation {
readonly claimText: string;
readonly evidenceId: string;
readonly evidenceType: EvidenceType;
readonly verified: boolean;
readonly evidenceExcerpt: string;
}
export interface ExplanationSummary {
readonly line1: string;
readonly line2: string;
readonly line3: string;
}
export interface ExplanationResult {
readonly explanationId: string;
readonly content: string;
readonly summary: ExplanationSummary;
readonly citations: readonly ExplanationCitation[];
readonly confidenceScore: number;
readonly citationRate: number;
readonly authority: ExplanationAuthority;
readonly evidenceRefs: readonly string[];
readonly modelId: string;
readonly promptTemplateVersion: string;
readonly inputHashes: readonly string[];
readonly generatedAt: string;
readonly outputHash: string;
}
export interface ExplanationReplayResult {
readonly original: ExplanationResult;
readonly replayed: ExplanationResult;
readonly identical: boolean;
readonly similarity: number;
readonly divergenceDetails?: ExplanationDivergence;
}
export interface ExplanationDivergence {
readonly diverged: boolean;
readonly similarity: number;
readonly originalHash: string;
readonly replayedHash: string;
readonly divergencePoints: readonly DivergencePoint[];
readonly likelyCause: string;
}
export interface DivergencePoint {
readonly position: number;
readonly original: string;
readonly replayed: string;
}
// ============================================================================
// Remediation API Models (REMEDY-22/23/24)
// ============================================================================
export type RemediationPlanStatus = 'draft' | 'validated' | 'approved' | 'in_progress' | 'completed' | 'failed';
export type RemediationStepType = 'upgrade' | 'patch' | 'config' | 'workaround' | 'vex_document';
export interface RemediationPlanRequest {
readonly findingId: string;
readonly artifactDigest: string;
readonly scope: string;
readonly scopeId: string;
readonly vulnerabilityId: string;
readonly componentPurl: string;
readonly preferredStrategy?: 'upgrade' | 'patch' | 'workaround';
readonly scmProvider?: string;
}
export interface RemediationStep {
readonly stepId: string;
readonly type: RemediationStepType;
readonly title: string;
readonly description: string;
readonly command?: string;
readonly filePath?: string;
readonly diff?: string;
readonly riskLevel: 'low' | 'medium' | 'high';
readonly breakingChange: boolean;
readonly order: number;
}
export interface RemediationPlan {
readonly planId: string;
readonly findingId: string;
readonly vulnerabilityId: string;
readonly componentPurl: string;
readonly status: RemediationPlanStatus;
readonly strategy: string;
readonly summary: ExplanationSummary;
readonly steps: readonly RemediationStep[];
readonly estimatedImpact: RemediationImpact;
readonly attestation?: RemediationAttestation;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface RemediationImpact {
readonly breakingChanges: number;
readonly filesAffected: number;
readonly dependenciesAffected: number;
readonly testCoverage: number;
readonly riskScore: number;
}
export interface RemediationAttestation {
readonly attestationId: string;
readonly predicateType: string;
readonly signed: boolean;
readonly signatureKeyId?: string;
}
export type PullRequestStatus = 'draft' | 'open' | 'merged' | 'closed';
export type CiCheckStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped';
export interface PullRequestInfo {
readonly prId: string;
readonly prNumber: number;
readonly title: string;
readonly url: string;
readonly status: PullRequestStatus;
readonly scmProvider: string;
readonly repository: string;
readonly sourceBranch: string;
readonly targetBranch: string;
readonly createdAt: string;
readonly updatedAt: string;
readonly ciChecks: readonly CiCheck[];
readonly reviewStatus: ReviewStatus;
}
export interface CiCheck {
readonly name: string;
readonly status: CiCheckStatus;
readonly url?: string;
readonly startedAt?: string;
readonly completedAt?: string;
}
export interface ReviewStatus {
readonly required: number;
readonly approved: number;
readonly changesRequested: number;
readonly reviewers: readonly ReviewerInfo[];
}
export interface ReviewerInfo {
readonly id: string;
readonly name: string;
readonly decision?: 'approved' | 'changes_requested' | 'pending';
}
// ============================================================================
// Policy Studio AI Models (POLICY-20/21/22/23/24)
// ============================================================================
export type PolicyIntentType =
| 'OverrideRule'
| 'EscalationRule'
| 'ExceptionCondition'
| 'MergePrecedence'
| 'ThresholdRule'
| 'ScopeRestriction';
export type RuleDisposition = 'Block' | 'Warn' | 'Allow' | 'Review' | 'Escalate';
export interface PolicyParseRequest {
readonly input: string;
readonly scope?: string;
}
export interface PolicyCondition {
readonly field: string;
readonly operator: string;
readonly value: unknown;
readonly connector: 'and' | 'or' | null;
}
export interface PolicyAction {
readonly actionType: string;
readonly parameters: Record<string, unknown>;
}
export interface PolicyIntent {
readonly intentId: string;
readonly intentType: PolicyIntentType;
readonly originalInput: string;
readonly conditions: readonly PolicyCondition[];
readonly actions: readonly PolicyAction[];
readonly scope: string;
readonly scopeId: string | null;
readonly priority: number;
readonly confidence: number;
readonly alternatives: readonly PolicyIntent[] | null;
readonly clarifyingQuestions: readonly string[] | null;
}
export interface PolicyParseResult {
readonly intent: PolicyIntent;
readonly success: boolean;
readonly modelId: string;
readonly parsedAt: string;
}
export interface GeneratedRule {
readonly ruleId: string;
readonly name: string;
readonly description: string;
readonly latticeExpression: string;
readonly conditions: readonly PolicyCondition[];
readonly disposition: RuleDisposition;
readonly priority: number;
readonly scope: string;
readonly enabled: boolean;
}
export interface PolicyGenerateResult {
readonly rules: readonly GeneratedRule[];
readonly success: boolean;
readonly warnings: readonly string[];
readonly intentId: string;
readonly generatedAt: string;
}
export interface RuleConflict {
readonly ruleId1: string;
readonly ruleId2: string;
readonly description: string;
readonly suggestedResolution: string;
readonly severity: 'error' | 'warning';
}
export interface PolicyValidateResult {
readonly valid: boolean;
readonly conflicts: readonly RuleConflict[];
readonly unreachableConditions: readonly string[];
readonly potentialLoops: readonly string[];
readonly coverage: number;
}
export type TestCaseType = 'positive' | 'negative' | 'boundary' | 'conflict' | 'manual';
export interface PolicyTestCase {
readonly testId: string;
readonly type: TestCaseType;
readonly description: string;
readonly input: Record<string, unknown>;
readonly expectedDisposition?: RuleDisposition;
readonly possibleDispositions?: readonly RuleDisposition[];
readonly matchedRuleId?: string;
readonly shouldNotMatch?: string;
readonly conflictingRules?: readonly string[];
}
export interface PolicyTestResult {
readonly testId: string;
readonly passed: boolean;
readonly actualDisposition?: RuleDisposition;
readonly matchedRule?: string;
readonly error?: string;
}
export interface PolicyCompileRequest {
readonly rules: readonly GeneratedRule[];
readonly bundleName: string;
readonly version: string;
readonly sign: boolean;
}
export interface PolicyBundleResult {
readonly bundleId: string;
readonly bundleName: string;
readonly version: string;
readonly ruleCount: number;
readonly digest: string;
readonly signed: boolean;
readonly signatureKeyId?: string;
readonly compiledAt: string;
readonly downloadUrl: string;
}

View File

@@ -92,6 +92,50 @@ export interface EntropyEvidence {
readonly downloadUrl?: string; // URL to entropy.report.json
}
// Binary Evidence models for SPRINT_20251226_014_BINIDX (SCANINT-17,18,19)
export type BinaryFixStatus = 'fixed' | 'vulnerable' | 'not_affected' | 'wontfix' | 'unknown';
export interface BinaryIdentity {
readonly format: 'elf' | 'pe' | 'macho';
readonly buildId?: string;
readonly fileSha256: string;
readonly architecture: string;
readonly binaryKey: string;
readonly path?: string;
}
export interface BinaryFixStatusInfo {
readonly state: BinaryFixStatus;
readonly fixedVersion?: string;
readonly method: 'changelog' | 'patch_analysis' | 'advisory';
readonly confidence: number;
}
export interface BinaryVulnMatch {
readonly cveId: string;
readonly method: 'buildid_catalog' | 'fingerprint_match' | 'range_match';
readonly confidence: number;
readonly vulnerablePurl: string;
readonly fixStatus?: BinaryFixStatusInfo;
readonly similarity?: number;
readonly matchedFunction?: string;
}
export interface BinaryFinding {
readonly identity: BinaryIdentity;
readonly layerDigest: string;
readonly matches: readonly BinaryVulnMatch[];
}
export interface BinaryEvidence {
readonly binaries: readonly BinaryFinding[];
readonly scanId: string;
readonly scannedAt: string;
readonly distro?: string;
readonly release?: string;
}
export interface ScanDetail {
readonly scanId: string;
readonly imageDigest: string;
@@ -99,4 +143,5 @@ export interface ScanDetail {
readonly attestation?: ScanAttestationStatus;
readonly determinism?: DeterminismEvidence;
readonly entropy?: EntropyEvidence;
readonly binaryEvidence?: BinaryEvidence;
}

View File

@@ -218,6 +218,21 @@ export class AuthorityAuthService {
}
}
/**
* Returns the current session info for fresh-auth checks.
*/
getSession(): { authenticationTime?: string } | null {
const session = this.sessionStore.session();
if (!session) {
return null;
}
return {
authenticationTime: session.authenticationTimeEpochMs
? new Date(session.authenticationTimeEpochMs).toISOString()
: undefined,
};
}
private async exchangeCodeForTokens(
code: string,
codeVerifier: string

View File

@@ -97,6 +97,7 @@ export const StellaOpsScopes = {
AUTHORITY_TOKENS_REVOKE: 'authority:tokens.revoke',
AUTHORITY_BRANDING_READ: 'authority:branding.read',
AUTHORITY_BRANDING_WRITE: 'authority:branding.write',
AUTHORITY_AUDIT_READ: 'authority:audit.read',
// Scheduler scopes
SCHEDULER_READ: 'scheduler:read',
@@ -122,9 +123,6 @@ export const StellaOpsScopes = {
EXCEPTIONS_READ: 'exceptions:read',
EXCEPTIONS_WRITE: 'exceptions:write',
// Graph admin scope
GRAPH_ADMIN: 'graph:admin',
// Findings scope
FINDINGS_READ: 'findings:read',
} as const;
@@ -310,6 +308,7 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
'authority:tokens.revoke': 'Revoke Tokens',
'authority:branding.read': 'View Branding',
'authority:branding.write': 'Manage Branding',
'authority:audit.read': 'View Audit Log',
// Scheduler scope labels
'scheduler:read': 'View Scheduler Jobs',
'scheduler:operate': 'Operate Scheduler',
@@ -329,8 +328,6 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
// Exception scope labels
'exceptions:read': 'View Exceptions',
'exceptions:write': 'Create Exceptions',
// Graph admin scope label
'graph:admin': 'Administer Graph',
// Findings scope label
'findings:read': 'View Policy Findings',
};

View File

@@ -0,0 +1,327 @@
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Auto-fix button component for triggering AI-assisted remediation planning.
*
* @task REMEDY-22
*
* Usage:
* ```html
* <stellaops-autofix-button
* [findingId]="finding.id"
* [vulnerabilityId]="finding.vulnId"
* [componentPurl]="finding.purl"
* [disabled]="isLoading"
* (generatePlan)="onGeneratePlan($event)">
* </stellaops-autofix-button>
* ```
*/
@Component({
selector: 'stellaops-autofix-button',
standalone: true,
imports: [CommonModule],
template: `
<div class="autofix-container">
<button
type="button"
class="autofix-button"
[class.loading]="loading()"
[class.has-plan]="hasPlan"
[disabled]="disabled || loading()"
[attr.aria-busy]="loading()"
[attr.aria-label]="ariaLabel()"
(click)="onButtonClick()">
@if (loading()) {
<span class="spinner" aria-hidden="true"></span>
<span class="label">{{ loadingLabel }}</span>
} @else if (hasPlan) {
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<polyline points="9 15 12 18 15 15"/>
<line x1="12" y1="12" x2="12" y2="18"/>
</svg>
<span class="label">View Plan</span>
} @else {
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<span class="label">{{ label }}</span>
}
</button>
@if (showStrategyDropdown && !loading() && !hasPlan) {
<div class="strategy-dropdown">
<button
type="button"
class="dropdown-trigger"
[attr.aria-expanded]="dropdownOpen()"
(click)="toggleDropdown()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
@if (dropdownOpen()) {
<ul class="dropdown-menu" role="menu">
<li role="menuitem">
<button type="button" (click)="selectStrategy('upgrade')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="17 11 12 6 7 11"/>
<line x1="12" y1="18" x2="12" y2="6"/>
</svg>
Upgrade Dependency
</button>
</li>
<li role="menuitem">
<button type="button" (click)="selectStrategy('patch')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
Apply Patch
</button>
</li>
<li role="menuitem">
<button type="button" (click)="selectStrategy('workaround')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
Workaround
</button>
</li>
</ul>
}
</div>
}
</div>
`,
styles: [`
.autofix-container {
display: inline-flex;
position: relative;
}
.autofix-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
border: 1px solid var(--color-success-border, #6ee7b7);
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
}
.autofix-button:hover:not(:disabled) {
background: var(--color-success-hover, #a7f3d0);
border-color: var(--color-success-border-hover, #34d399);
}
.autofix-button:focus-visible {
outline: 2px solid var(--color-focus-ring, #10b981);
outline-offset: 2px;
}
.autofix-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.autofix-button.loading {
background: var(--color-success-loading, #bbf7d0);
}
.autofix-button.has-plan {
color: var(--color-primary-text, #1e40af);
background: var(--color-primary-bg, #eff6ff);
border-color: var(--color-primary-border, #bfdbfe);
}
.autofix-button.has-plan:hover:not(:disabled) {
background: var(--color-primary-hover, #dbeafe);
}
.icon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
}
.label {
white-space: nowrap;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.strategy-dropdown {
position: relative;
}
.dropdown-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 100%;
padding: 0;
margin-left: -1px;
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
border: 1px solid var(--color-success-border, #6ee7b7);
border-left: none;
border-radius: 0 0.375rem 0.375rem 0;
cursor: pointer;
transition: background 0.15s;
}
.dropdown-trigger:hover {
background: var(--color-success-hover, #a7f3d0);
}
.dropdown-trigger svg {
width: 1rem;
height: 1rem;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
z-index: 50;
min-width: 12rem;
margin: 0;
padding: 0.25rem;
list-style: none;
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.dropdown-menu li button {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
text-align: left;
color: var(--color-text-primary, #111827);
background: transparent;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
.dropdown-menu li button:hover {
background: var(--color-hover, #f3f4f6);
}
.dropdown-menu li button svg {
width: 1rem;
height: 1rem;
color: var(--color-text-secondary, #6b7280);
}
`]
})
export class AutofixButtonComponent {
@Input() findingId = '';
@Input() vulnerabilityId = '';
@Input() componentPurl = '';
@Input() artifactDigest = '';
@Input() scope = 'service';
@Input() scopeId = '';
@Input() scmProvider = '';
@Input() disabled = false;
@Input() hasPlan = false;
@Input() showStrategyDropdown = true;
@Input() label = 'Auto-fix';
@Input() loadingLabel = 'Planning...';
@Output() readonly generatePlan = new EventEmitter<GeneratePlanRequestEvent>();
@Output() readonly viewPlan = new EventEmitter<void>();
readonly loading = signal(false);
readonly dropdownOpen = signal(false);
readonly selectedStrategy = signal<'upgrade' | 'patch' | 'workaround' | null>(null);
readonly ariaLabel = computed(() => {
if (this.loading()) {
return `Generating remediation plan for ${this.vulnerabilityId}`;
}
if (this.hasPlan) {
return `View remediation plan for ${this.vulnerabilityId}`;
}
return `Generate auto-fix plan for ${this.vulnerabilityId}`;
});
onButtonClick(): void {
if (this.disabled || this.loading()) return;
if (this.hasPlan) {
this.viewPlan.emit();
return;
}
this.emitGeneratePlan(this.selectedStrategy() ?? undefined);
}
toggleDropdown(): void {
this.dropdownOpen.update(v => !v);
}
selectStrategy(strategy: 'upgrade' | 'patch' | 'workaround'): void {
this.selectedStrategy.set(strategy);
this.dropdownOpen.set(false);
this.emitGeneratePlan(strategy);
}
private emitGeneratePlan(strategy?: 'upgrade' | 'patch' | 'workaround'): void {
this.loading.set(true);
this.generatePlan.emit({
findingId: this.findingId,
vulnerabilityId: this.vulnerabilityId,
componentPurl: this.componentPurl,
artifactDigest: this.artifactDigest,
scope: this.scope,
scopeId: this.scopeId,
scmProvider: this.scmProvider,
preferredStrategy: strategy,
onComplete: () => this.loading.set(false),
onError: () => this.loading.set(false),
});
}
}
export interface GeneratePlanRequestEvent {
readonly findingId: string;
readonly vulnerabilityId: string;
readonly componentPurl: string;
readonly artifactDigest: string;
readonly scope: string;
readonly scopeId: string;
readonly scmProvider: string;
readonly preferredStrategy?: 'upgrade' | 'patch' | 'workaround';
readonly onComplete: () => void;
readonly onError: () => void;
}

View File

@@ -0,0 +1,463 @@
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-ai.models';
/**
* Evidence drill-down component for expanding citation details.
*
* @task ZASTAVA-17
*
* Displays expanded evidence node detail when a citation is clicked:
* - Evidence type badge
* - Full claim text
* - Evidence excerpt
* - Verification status
* - Link to source
*/
@Component({
selector: 'stellaops-evidence-drilldown',
standalone: true,
imports: [CommonModule],
template: `
@if (citation) {
<aside
class="evidence-drilldown"
[class.expanded]="expanded()"
role="complementary"
[attr.aria-label]="'Evidence details for ' + citation.claimText">
<header class="drilldown-header">
<div class="header-content">
<span class="evidence-type-badge" [class]="'type-' + citation.evidenceType">
{{ evidenceTypeLabel(citation.evidenceType) }}
</span>
<h4 class="claim-title">{{ citation.claimText }}</h4>
</div>
<button
type="button"
class="close-btn"
aria-label="Close evidence details"
(click)="onClose()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</header>
<div class="drilldown-content">
<!-- Verification Status -->
<div class="verification-status" [class.verified]="citation.verified">
@if (citation.verified) {
<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span>Verified against source</span>
} @else {
<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>Unverified - requires review</span>
}
</div>
<!-- Evidence Excerpt -->
<section class="evidence-excerpt">
<h5 class="section-label">Evidence Excerpt</h5>
<div class="excerpt-content">
<pre>{{ citation.evidenceExcerpt }}</pre>
</div>
</section>
<!-- Evidence Reference -->
<section class="evidence-reference">
<h5 class="section-label">Reference</h5>
<div class="reference-id">
<code>{{ citation.evidenceId }}</code>
<button
type="button"
class="copy-btn"
title="Copy reference ID"
(click)="copyToClipboard(citation.evidenceId)">
@if (copied()) {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
} @else {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
}
</button>
</div>
</section>
<!-- Evidence Type Info -->
<section class="evidence-type-info">
<h5 class="section-label">Evidence Type</h5>
<p class="type-description">{{ evidenceTypeDescription(citation.evidenceType) }}</p>
</section>
<!-- Actions -->
<footer class="drilldown-actions">
<button
type="button"
class="action-btn secondary"
(click)="viewSource.emit(citation)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
View Source
</button>
<button
type="button"
class="action-btn primary"
(click)="addToReport.emit(citation)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
Add to Report
</button>
</footer>
</div>
</aside>
}
`,
styles: [`
.evidence-drilldown {
position: relative;
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
overflow: hidden;
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-0.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.drilldown-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.header-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.evidence-type-badge {
display: inline-flex;
align-self: flex-start;
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
border-radius: 0.25rem;
}
.evidence-type-badge.type-advisory {
color: #7c3aed;
background: #ede9fe;
}
.evidence-type-badge.type-sbom {
color: #0891b2;
background: #cffafe;
}
.evidence-type-badge.type-reachability {
color: #ca8a04;
background: #fef9c3;
}
.evidence-type-badge.type-runtime {
color: #ea580c;
background: #ffedd5;
}
.evidence-type-badge.type-vex {
color: #059669;
background: #d1fae5;
}
.evidence-type-badge.type-patch {
color: #4f46e5;
background: #e0e7ff;
}
.claim-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
line-height: 1.4;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
flex-shrink: 0;
background: transparent;
border: none;
border-radius: 0.375rem;
cursor: pointer;
color: var(--color-text-secondary, #6b7280);
transition: background 0.15s;
}
.close-btn:hover {
background: var(--color-hover, #e5e7eb);
}
.close-btn svg {
width: 1.25rem;
height: 1.25rem;
}
.drilldown-content {
padding: 1rem;
}
.verification-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
margin-bottom: 1rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 0.375rem;
color: var(--color-warning-text, #92400e);
background: var(--color-warning-bg, #fef3c7);
}
.verification-status.verified {
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
}
.status-icon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
}
.section-label {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary, #6b7280);
}
.evidence-excerpt {
margin-bottom: 1rem;
}
.excerpt-content {
padding: 0.75rem;
background: var(--color-code-bg, #f3f4f6);
border-radius: 0.375rem;
overflow-x: auto;
}
.excerpt-content pre {
margin: 0;
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text-primary, #111827);
}
.evidence-reference {
margin-bottom: 1rem;
}
.reference-id {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-code-bg, #f3f4f6);
border-radius: 0.375rem;
}
.reference-id code {
flex: 1;
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: var(--color-text-primary, #111827);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
flex-shrink: 0;
background: transparent;
border: none;
border-radius: 0.25rem;
cursor: pointer;
color: var(--color-text-secondary, #6b7280);
}
.copy-btn:hover {
background: var(--color-hover, #e5e7eb);
}
.copy-btn svg {
width: 1rem;
height: 1rem;
}
.evidence-type-info {
margin-bottom: 1rem;
}
.type-description {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-secondary, #6b7280);
line-height: 1.5;
}
.drilldown-actions {
display: flex;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.action-btn svg {
width: 1rem;
height: 1rem;
}
.action-btn.secondary {
color: var(--color-text-primary, #374151);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
}
.action-btn.secondary:hover {
background: var(--color-hover, #f9fafb);
}
.action-btn.primary {
color: var(--color-primary-contrast, #ffffff);
background: var(--color-primary, #3b82f6);
border: 1px solid var(--color-primary, #3b82f6);
}
.action-btn.primary:hover {
background: var(--color-primary-hover, #2563eb);
border-color: var(--color-primary-hover, #2563eb);
}
`]
})
export class EvidenceDrilldownComponent {
@Input() citation: ExplanationCitation | null = null;
@Output() readonly close = new EventEmitter<void>();
@Output() readonly viewSource = new EventEmitter<ExplanationCitation>();
@Output() readonly addToReport = new EventEmitter<ExplanationCitation>();
readonly expanded = signal(true);
readonly copied = signal(false);
onClose(): void {
this.close.emit();
}
async copyToClipboard(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
}
}
evidenceTypeLabel(type: EvidenceType | string): string {
const labels: Record<string, string> = {
advisory: 'Security Advisory',
sbom: 'SBOM Component',
reachability: 'Reachability Analysis',
runtime: 'Runtime Signal',
vex: 'VEX Statement',
patch: 'Patch Information',
};
return labels[type] || type;
}
evidenceTypeDescription(type: EvidenceType | string): string {
const descriptions: Record<string, string> = {
advisory: 'Data from vulnerability advisories (NVD, GHSA, vendor bulletins) describing the security issue.',
sbom: 'Software Bill of Materials showing the component\'s presence in your artifact.',
reachability: 'Static or dynamic analysis proving whether vulnerable code paths are actually reachable.',
runtime: 'Live observations from runtime systems like WAF logs, network policies, or telemetry.',
vex: 'Vendor Exploitability eXchange statement declaring exploitability status.',
patch: 'Information about available fixes, upgrades, or patches from package registries.',
};
return descriptions[type] || 'Evidence from external data source.';
}
}

View File

@@ -0,0 +1,165 @@
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Explain button component for triggering AI explanation generation.
*
* @task ZASTAVA-15
*
* Usage:
* ```html
* <stellaops-explain-button
* [findingId]="finding.id"
* [vulnerabilityId]="finding.vulnId"
* [componentPurl]="finding.purl"
* [disabled]="isLoading"
* (explain)="onExplainRequested($event)">
* </stellaops-explain-button>
* ```
*/
@Component({
selector: 'stellaops-explain-button',
standalone: true,
imports: [CommonModule],
template: `
<button
type="button"
class="explain-button"
[class.loading]="loading()"
[class.compact]="compact"
[disabled]="disabled || loading()"
[attr.aria-busy]="loading()"
[attr.aria-label]="ariaLabel()"
(click)="onButtonClick()">
@if (loading()) {
<span class="spinner" aria-hidden="true"></span>
<span class="label">{{ loadingLabel }}</span>
} @else {
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span class="label">{{ label }}</span>
}
</button>
`,
styles: [`
.explain-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-primary-text, #1e40af);
background: var(--color-primary-bg, #eff6ff);
border: 1px solid var(--color-primary-border, #bfdbfe);
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
}
.explain-button:hover:not(:disabled) {
background: var(--color-primary-hover, #dbeafe);
border-color: var(--color-primary-border-hover, #93c5fd);
}
.explain-button:focus-visible {
outline: 2px solid var(--color-focus-ring, #3b82f6);
outline-offset: 2px;
}
.explain-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.explain-button.compact {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.explain-button.compact .icon {
width: 1rem;
height: 1rem;
}
.explain-button.loading {
background: var(--color-primary-loading, #e0e7ff);
}
.icon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
}
.label {
white-space: nowrap;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`]
})
export class ExplainButtonComponent {
@Input() findingId = '';
@Input() vulnerabilityId = '';
@Input() componentPurl = '';
@Input() artifactDigest = '';
@Input() scope = 'service';
@Input() scopeId = '';
@Input() disabled = false;
@Input() compact = false;
@Input() label = 'Explain';
@Input() loadingLabel = 'Explaining...';
@Output() readonly explain = new EventEmitter<ExplainRequestEvent>();
readonly loading = signal(false);
readonly ariaLabel = computed(() =>
this.loading()
? `Generating explanation for ${this.vulnerabilityId}`
: `Explain vulnerability ${this.vulnerabilityId}`
);
onButtonClick(): void {
if (this.disabled || this.loading()) return;
this.loading.set(true);
this.explain.emit({
findingId: this.findingId,
vulnerabilityId: this.vulnerabilityId,
componentPurl: this.componentPurl,
artifactDigest: this.artifactDigest,
scope: this.scope,
scopeId: this.scopeId,
onComplete: () => this.loading.set(false),
onError: () => this.loading.set(false),
});
}
}
export interface ExplainRequestEvent {
readonly findingId: string;
readonly vulnerabilityId: string;
readonly componentPurl: string;
readonly artifactDigest: string;
readonly scope: string;
readonly scopeId: string;
readonly onComplete: () => void;
readonly onError: () => void;
}

View File

@@ -0,0 +1,580 @@
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type {
ExplanationResult,
ExplanationCitation,
ExplanationAuthority,
ExplanationType,
} from '../../core/api/advisory-ai.models';
/**
* Explanation panel showing AI-generated explanations with evidence citations.
*
* @task ZASTAVA-16
*
* Displays:
* - 3-line summary (what/why/action)
* - Full explanation content with citations
* - Confidence indicator
* - Authority badge (Evidence-backed vs Suggestion)
* - Linked evidence nodes
*/
@Component({
selector: 'stellaops-explanation-panel',
standalone: true,
imports: [CommonModule],
template: `
<article class="explanation-panel" [class.loading]="loading" [class.collapsed]="collapsed()">
<header class="panel-header">
<div class="header-left">
<h3 class="title">
<svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
AI Explanation
</h3>
@if (explanation) {
<span class="authority-badge" [class]="authorityClass()">
{{ authorityLabel() }}
</span>
}
</div>
<div class="header-right">
@if (explanation) {
<span class="confidence" [attr.title]="'Confidence: ' + (explanation.confidenceScore * 100).toFixed(0) + '%'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="confidence-icon">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
{{ (explanation.confidenceScore * 100).toFixed(0) }}%
</span>
}
<button
type="button"
class="collapse-btn"
[attr.aria-expanded]="!collapsed()"
(click)="toggleCollapsed()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@if (collapsed()) {
<polyline points="6 9 12 15 18 9"/>
} @else {
<polyline points="18 15 12 9 6 15"/>
}
</svg>
</button>
</div>
</header>
@if (!collapsed()) {
<div class="panel-content">
@if (loading) {
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Generating explanation...</p>
</div>
} @else if (error) {
<div class="error-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="error-icon">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<p>{{ error }}</p>
<button type="button" class="retry-btn" (click)="retry.emit()">Retry</button>
</div>
} @else if (explanation) {
<!-- 3-Line Summary -->
<section class="summary-section">
<div class="summary-line summary-what">
<span class="summary-label">What:</span>
<span class="summary-text">{{ explanation.summary.line1 }}</span>
</div>
<div class="summary-line summary-why">
<span class="summary-label">Why:</span>
<span class="summary-text">{{ explanation.summary.line2 }}</span>
</div>
<div class="summary-line summary-action">
<span class="summary-label">Action:</span>
<span class="summary-text">{{ explanation.summary.line3 }}</span>
</div>
</section>
<!-- Plain Language Toggle -->
@if (showPlainLanguageToggle) {
<div class="plain-language-toggle">
<label class="toggle-label">
<input
type="checkbox"
[checked]="plainLanguage()"
(change)="togglePlainLanguage()">
<span class="toggle-text">Explain like I'm new</span>
</label>
</div>
}
<!-- Full Explanation -->
<section class="explanation-content">
<div class="content-text" [innerHTML]="formattedContent()"></div>
</section>
<!-- Citations -->
@if (explanation.citations.length > 0) {
<section class="citations-section">
<h4 class="citations-title">
Evidence Citations
<span class="citation-rate">({{ (explanation.citationRate * 100).toFixed(0) }}% cited)</span>
</h4>
<ul class="citations-list">
@for (citation of explanation.citations; track citation.evidenceId) {
<li class="citation-item" [class.verified]="citation.verified">
<button
type="button"
class="citation-btn"
(click)="onCitationClick(citation)">
<span class="citation-type" [class]="'type-' + citation.evidenceType">
{{ evidenceTypeLabel(citation.evidenceType) }}
</span>
<span class="citation-claim">{{ citation.claimText }}</span>
@if (citation.verified) {
<svg class="verified-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
}
</button>
</li>
}
</ul>
</section>
}
<!-- Metadata Footer -->
<footer class="panel-footer">
<span class="model-info" title="Model used for generation">
{{ explanation.modelId }}
</span>
<span class="timestamp">
Generated {{ formatTimestamp(explanation.generatedAt) }}
</span>
</footer>
} @else {
<div class="empty-state">
<p>No explanation available. Click "Explain" to generate one.</p>
</div>
}
</div>
}
</article>
`,
styles: [`
.explanation-panel {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.explanation-panel.collapsed .panel-header {
border-bottom: none;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.title-icon {
width: 1.125rem;
height: 1.125rem;
color: var(--color-primary, #3b82f6);
}
.authority-badge {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
}
.authority-badge.evidence-backed {
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
}
.authority-badge.suggestion {
color: var(--color-warning-text, #92400e);
background: var(--color-warning-bg, #fef3c7);
}
.confidence {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--color-text-secondary, #6b7280);
}
.confidence-icon {
width: 1rem;
height: 1rem;
color: var(--color-success, #10b981);
}
.collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
background: transparent;
border: none;
border-radius: 0.25rem;
cursor: pointer;
color: var(--color-text-secondary, #6b7280);
}
.collapse-btn:hover {
background: var(--color-hover, #f3f4f6);
}
.collapse-btn svg {
width: 1.25rem;
height: 1.25rem;
}
.panel-content {
padding: 1rem;
}
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
color: var(--color-text-secondary, #6b7280);
}
.loading-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border, #e5e7eb);
border-top-color: var(--color-primary, #3b82f6);
border-radius: 50%;
animation: spin 0.75s linear infinite;
margin-bottom: 0.75rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-state {
color: var(--color-error-text, #991b1b);
}
.error-icon {
width: 2rem;
height: 2rem;
margin-bottom: 0.5rem;
color: var(--color-error, #ef4444);
}
.retry-btn {
margin-top: 0.75rem;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
color: var(--color-primary-text, #1e40af);
background: var(--color-primary-bg, #eff6ff);
border: 1px solid var(--color-primary-border, #bfdbfe);
border-radius: 0.375rem;
cursor: pointer;
}
.summary-section {
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--color-surface-alt, #f9fafb);
border-radius: 0.375rem;
}
.summary-line {
display: flex;
gap: 0.5rem;
padding: 0.25rem 0;
}
.summary-label {
flex-shrink: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
min-width: 3.5rem;
}
.summary-text {
font-size: 0.875rem;
color: var(--color-text-primary, #111827);
}
.plain-language-toggle {
margin-bottom: 1rem;
}
.toggle-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-secondary, #6b7280);
}
.explanation-content {
margin-bottom: 1rem;
}
.content-text {
font-size: 0.875rem;
line-height: 1.6;
color: var(--color-text-primary, #111827);
}
.content-text :deep(h2) {
margin: 1rem 0 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.content-text :deep(p) {
margin: 0.5rem 0;
}
.content-text :deep(code) {
padding: 0.125rem 0.25rem;
font-size: 0.8125rem;
background: var(--color-code-bg, #f3f4f6);
border-radius: 0.25rem;
}
.citations-section {
border-top: 1px solid var(--color-border, #e5e7eb);
padding-top: 1rem;
}
.citations-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.citation-rate {
font-weight: 400;
color: var(--color-text-secondary, #6b7280);
}
.citations-list {
list-style: none;
margin: 0;
padding: 0;
}
.citation-item {
margin-bottom: 0.5rem;
}
.citation-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem;
text-align: left;
background: transparent;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
}
.citation-btn:hover {
background: var(--color-hover, #f9fafb);
}
.citation-type {
flex-shrink: 0;
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
border-radius: 0.25rem;
}
.citation-type.type-advisory {
color: #7c3aed;
background: #ede9fe;
}
.citation-type.type-sbom {
color: #0891b2;
background: #cffafe;
}
.citation-type.type-reachability {
color: #ca8a04;
background: #fef9c3;
}
.citation-type.type-runtime {
color: #ea580c;
background: #ffedd5;
}
.citation-type.type-vex {
color: #059669;
background: #d1fae5;
}
.citation-type.type-patch {
color: #4f46e5;
background: #e0e7ff;
}
.citation-claim {
flex: 1;
font-size: 0.8125rem;
color: var(--color-text-primary, #111827);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.verified-icon {
flex-shrink: 0;
width: 1rem;
height: 1rem;
color: var(--color-success, #10b981);
}
.panel-footer {
display: flex;
justify-content: space-between;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border, #e5e7eb);
font-size: 0.75rem;
color: var(--color-text-tertiary, #9ca3af);
}
`]
})
export class ExplanationPanelComponent {
@Input() explanation: ExplanationResult | null = null;
@Input() loading = false;
@Input() error: string | null = null;
@Input() showPlainLanguageToggle = true;
@Output() readonly retry = new EventEmitter<void>();
@Output() readonly citationClick = new EventEmitter<ExplanationCitation>();
@Output() readonly plainLanguageChange = new EventEmitter<boolean>();
readonly collapsed = signal(false);
readonly plainLanguage = signal(false);
readonly authorityClass = computed(() =>
this.explanation?.authority === 'EvidenceBacked' ? 'evidence-backed' : 'suggestion'
);
readonly authorityLabel = computed(() =>
this.explanation?.authority === 'EvidenceBacked' ? 'Evidence-backed' : 'AI suggestion'
);
readonly formattedContent = computed(() => {
if (!this.explanation) return '';
// Convert markdown-like content to HTML
return this.explanation.content
.replace(/## (.+)/g, '<h2>$1</h2>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\n\n/g, '</p><p>')
.replace(/^/, '<p>')
.replace(/$/, '</p>');
});
toggleCollapsed(): void {
this.collapsed.update(v => !v);
}
togglePlainLanguage(): void {
this.plainLanguage.update(v => !v);
this.plainLanguageChange.emit(this.plainLanguage());
}
onCitationClick(citation: ExplanationCitation): void {
this.citationClick.emit(citation);
}
evidenceTypeLabel(type: string): string {
const labels: Record<string, string> = {
advisory: 'Advisory',
sbom: 'SBOM',
reachability: 'Reach',
runtime: 'Runtime',
vex: 'VEX',
patch: 'Patch',
};
return labels[type] || type;
}
formatTimestamp(iso: string): string {
try {
const date = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
return date.toLocaleDateString();
} catch {
return iso;
}
}
}

View File

@@ -0,0 +1,201 @@
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Plain language toggle component for switching between technical and beginner-friendly explanations.
*
* @task ZASTAVA-18
*
* "Explain like I'm new" toggle that expands jargon to plain language
* and simplifies technical explanations for non-expert users.
*/
@Component({
selector: 'stellaops-plain-language-toggle',
standalone: true,
imports: [CommonModule],
template: `
<div class="plain-language-toggle" [class.compact]="compact">
<label class="toggle-container">
<span class="toggle-switch" [class.active]="enabled()">
<input
type="checkbox"
class="toggle-input"
[checked]="enabled()"
[disabled]="disabled"
(change)="onToggle()"
role="switch"
[attr.aria-checked]="enabled()"
[attr.aria-label]="ariaLabel">
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</span>
<span class="toggle-label">
<span class="label-text">{{ label }}</span>
@if (showDescription && !compact) {
<span class="label-description">{{ description }}</span>
}
</span>
</label>
@if (enabled() && showBadge) {
<span class="active-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="badge-icon">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
Beginner mode
</span>
}
</div>
`,
styles: [`
.plain-language-toggle {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.plain-language-toggle.compact {
gap: 0.5rem;
}
.toggle-container {
display: flex;
align-items: center;
gap: 0.625rem;
cursor: pointer;
}
.toggle-switch {
position: relative;
display: inline-flex;
}
.toggle-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.toggle-track {
display: inline-flex;
align-items: center;
width: 2.5rem;
height: 1.375rem;
padding: 0.125rem;
background: var(--color-toggle-off, #d1d5db);
border-radius: 9999px;
transition: background 0.2s ease;
}
.toggle-switch.active .toggle-track {
background: var(--color-primary, #3b82f6);
}
.toggle-thumb {
width: 1.125rem;
height: 1.125rem;
background: var(--color-surface, #ffffff);
border-radius: 50%;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
transition: transform 0.2s ease;
}
.toggle-switch.active .toggle-thumb {
transform: translateX(1.125rem);
}
.toggle-input:focus-visible + .toggle-track {
outline: 2px solid var(--color-focus-ring, #3b82f6);
outline-offset: 2px;
}
.toggle-input:disabled + .toggle-track {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-label {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.plain-language-toggle.compact .toggle-label {
flex-direction: row;
gap: 0;
}
.label-text {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary, #111827);
}
.plain-language-toggle.compact .label-text {
font-size: 0.8125rem;
}
.label-description {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
}
.active-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-info-text, #1e40af);
background: var(--color-info-bg, #dbeafe);
border-radius: 9999px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.badge-icon {
width: 0.875rem;
height: 0.875rem;
}
`]
})
export class PlainLanguageToggleComponent {
@Input() label = 'Explain like I\'m new';
@Input() description = 'Simplify technical jargon for beginners';
@Input() ariaLabel = 'Toggle plain language explanations';
@Input() disabled = false;
@Input() compact = false;
@Input() showDescription = true;
@Input() showBadge = true;
@Input() initialValue = false;
@Output() readonly toggled = new EventEmitter<boolean>();
readonly enabled = signal(false);
ngOnInit(): void {
this.enabled.set(this.initialValue);
}
onToggle(): void {
if (this.disabled) return;
this.enabled.update(v => !v);
this.toggled.emit(this.enabled());
}
}

View File

@@ -0,0 +1,646 @@
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type {
PullRequestInfo,
PullRequestStatus,
CiCheck,
CiCheckStatus,
} from '../../core/api/advisory-ai.models';
/**
* Pull request tracker component for monitoring remediation PRs.
*
* @task REMEDY-24
*
* Displays:
* - PR status and metadata
* - CI check statuses
* - Review status
* - Actions (view, merge, close)
*/
@Component({
selector: 'stellaops-pr-tracker',
standalone: true,
imports: [CommonModule],
template: `
@if (pullRequest) {
<article class="pr-tracker" [class]="'status-' + pullRequest.status">
<header class="pr-header">
<div class="pr-title-row">
<span class="pr-number">#{{ pullRequest.prNumber }}</span>
<h4 class="pr-title">{{ pullRequest.title }}</h4>
<span class="pr-status-badge" [class]="pullRequest.status">
{{ statusLabel() }}
</span>
</div>
<div class="pr-meta">
<span class="meta-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/>
</svg>
{{ pullRequest.repository }}
</span>
<span class="meta-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="6" y1="3" x2="6" y2="15"/>
<circle cx="18" cy="6" r="3"/>
<circle cx="6" cy="18" r="3"/>
<path d="M18 9a9 9 0 0 1-9 9"/>
</svg>
{{ pullRequest.sourceBranch }} → {{ pullRequest.targetBranch }}
</span>
<span class="meta-item scm-provider">
{{ pullRequest.scmProvider }}
</span>
</div>
</header>
<div class="pr-content">
<!-- CI Checks -->
<section class="ci-checks-section">
<h5 class="section-title">
CI Checks
<span class="check-summary" [class]="ciSummaryClass()">
{{ passedChecks() }}/{{ pullRequest.ciChecks.length }} passed
</span>
</h5>
<ul class="checks-list">
@for (check of pullRequest.ciChecks; track check.name) {
<li class="check-item">
<span class="check-status" [class]="check.status">
@switch (check.status) {
@case ('passed') {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
}
@case ('failed') {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
}
@case ('running') {
<span class="spinner"></span>
}
@case ('pending') {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
</svg>
}
@case ('skipped') {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/>
<line x1="15" y1="9" x2="22" y2="9"/>
</svg>
}
}
</span>
<span class="check-name">{{ check.name }}</span>
@if (check.url) {
<a [href]="check.url" target="_blank" rel="noopener" class="check-link">
View
</a>
}
</li>
}
</ul>
</section>
<!-- Review Status -->
<section class="review-section">
<h5 class="section-title">
Review Status
<span class="review-summary" [class]="reviewSummaryClass()">
{{ pullRequest.reviewStatus.approved }}/{{ pullRequest.reviewStatus.required }} approved
</span>
</h5>
<div class="reviewers-list">
@for (reviewer of pullRequest.reviewStatus.reviewers; track reviewer.id) {
<div class="reviewer-item" [class]="reviewer.decision || 'pending'">
<span class="reviewer-avatar">
{{ reviewer.name.charAt(0).toUpperCase() }}
</span>
<span class="reviewer-name">{{ reviewer.name }}</span>
<span class="reviewer-decision">
@switch (reviewer.decision) {
@case ('approved') {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="decision-icon approved">
<polyline points="20 6 9 17 4 12"/>
</svg>
Approved
}
@case ('changes_requested') {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="decision-icon changes">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
Changes requested
}
@default {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="decision-icon pending">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
Pending
}
}
</span>
</div>
}
</div>
</section>
<!-- Timeline -->
<section class="timeline-section">
<div class="timeline-item">
<span class="timeline-label">Created</span>
<span class="timeline-value">{{ formatDate(pullRequest.createdAt) }}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Updated</span>
<span class="timeline-value">{{ formatDate(pullRequest.updatedAt) }}</span>
</div>
</section>
</div>
<footer class="pr-actions">
<a
[href]="pullRequest.url"
target="_blank"
rel="noopener"
class="action-btn secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
View on {{ pullRequest.scmProvider }}
</a>
@if (pullRequest.status === 'open') {
@if (canMerge()) {
<button
type="button"
class="action-btn primary"
(click)="merge.emit()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="18" cy="18" r="3"/>
<circle cx="6" cy="6" r="3"/>
<path d="M6 21V9a9 9 0 0 0 9 9"/>
</svg>
Merge
</button>
}
<button
type="button"
class="action-btn danger"
(click)="close.emit()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
Close
</button>
}
</footer>
</article>
}
`,
styles: [`
.pr-tracker {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
}
.pr-tracker.status-merged {
border-color: var(--color-merged-border, #a78bfa);
}
.pr-tracker.status-closed {
border-color: var(--color-error-border, #fca5a5);
}
.pr-header {
padding: 1rem;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.pr-title-row {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.5rem;
}
.pr-number {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
}
.pr-title {
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pr-status-badge {
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 9999px;
text-transform: capitalize;
}
.pr-status-badge.draft {
color: var(--color-text-secondary, #6b7280);
background: var(--color-surface, #f3f4f6);
}
.pr-status-badge.open {
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
}
.pr-status-badge.merged {
color: var(--color-merged-text, #5b21b6);
background: var(--color-merged-bg, #ede9fe);
}
.pr-status-badge.closed {
color: var(--color-error-text, #991b1b);
background: var(--color-error-bg, #fee2e2);
}
.pr-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--color-text-secondary, #6b7280);
}
.meta-item svg {
width: 0.875rem;
height: 0.875rem;
}
.meta-item.scm-provider {
padding: 0.125rem 0.5rem;
background: var(--color-surface, #f3f4f6);
border-radius: 0.25rem;
font-weight: 500;
text-transform: capitalize;
}
.pr-content {
padding: 1rem;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.check-summary,
.review-summary {
font-weight: 500;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
}
.check-summary.all-passed,
.review-summary.approved {
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
}
.check-summary.some-failed {
color: var(--color-error-text, #991b1b);
background: var(--color-error-bg, #fee2e2);
}
.check-summary.in-progress,
.review-summary.pending {
color: var(--color-warning-text, #92400e);
background: var(--color-warning-bg, #fef3c7);
}
.ci-checks-section {
margin-bottom: 1rem;
}
.checks-list {
margin: 0;
padding: 0;
list-style: none;
}
.check-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
.check-item:last-child {
border-bottom: none;
}
.check-status {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
}
.check-status svg {
width: 1rem;
height: 1rem;
}
.check-status.passed {
color: var(--color-success, #10b981);
}
.check-status.failed {
color: var(--color-error, #ef4444);
}
.check-status.running {
color: var(--color-warning, #f59e0b);
}
.check-status.pending {
color: var(--color-text-secondary, #9ca3af);
}
.check-status.skipped {
color: var(--color-text-tertiary, #d1d5db);
}
.check-status .spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.check-name {
flex: 1;
font-size: 0.8125rem;
color: var(--color-text-primary, #111827);
}
.check-link {
font-size: 0.75rem;
color: var(--color-primary, #3b82f6);
text-decoration: none;
}
.check-link:hover {
text-decoration: underline;
}
.review-section {
margin-bottom: 1rem;
}
.reviewers-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reviewer-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem;
background: var(--color-surface-alt, #f9fafb);
border-radius: 0.375rem;
}
.reviewer-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-primary-contrast, #ffffff);
background: var(--color-primary, #3b82f6);
border-radius: 50%;
}
.reviewer-name {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary, #111827);
}
.reviewer-decision {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
}
.decision-icon {
width: 0.875rem;
height: 0.875rem;
}
.decision-icon.approved {
color: var(--color-success, #10b981);
}
.decision-icon.changes {
color: var(--color-warning, #f59e0b);
}
.decision-icon.pending {
color: var(--color-text-secondary, #9ca3af);
}
.timeline-section {
display: flex;
gap: 1.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.timeline-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.timeline-label {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
color: var(--color-text-secondary, #6b7280);
}
.timeline-value {
font-size: 0.8125rem;
color: var(--color-text-primary, #111827);
}
.pr-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
text-decoration: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.action-btn svg {
width: 0.875rem;
height: 0.875rem;
}
.action-btn.secondary {
color: var(--color-text-primary, #374151);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
}
.action-btn.secondary:hover {
background: var(--color-hover, #f9fafb);
}
.action-btn.primary {
color: var(--color-merged-contrast, #ffffff);
background: var(--color-merged, #8b5cf6);
border: 1px solid var(--color-merged, #8b5cf6);
}
.action-btn.primary:hover {
background: var(--color-merged-hover, #7c3aed);
border-color: var(--color-merged-hover, #7c3aed);
}
.action-btn.danger {
color: var(--color-error-text, #991b1b);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-error-border, #fca5a5);
}
.action-btn.danger:hover {
background: var(--color-error-bg, #fee2e2);
}
`]
})
export class PrTrackerComponent {
@Input() pullRequest: PullRequestInfo | null = null;
@Output() readonly merge = new EventEmitter<void>();
@Output() readonly close = new EventEmitter<void>();
@Output() readonly refresh = new EventEmitter<void>();
readonly statusLabel = computed(() => {
if (!this.pullRequest) return '';
const labels: Record<PullRequestStatus, string> = {
draft: 'Draft',
open: 'Open',
merged: 'Merged',
closed: 'Closed',
};
return labels[this.pullRequest.status] || this.pullRequest.status;
});
readonly passedChecks = computed(() => {
if (!this.pullRequest) return 0;
return this.pullRequest.ciChecks.filter(c => c.status === 'passed').length;
});
readonly ciSummaryClass = computed(() => {
if (!this.pullRequest) return '';
const checks = this.pullRequest.ciChecks;
const passed = checks.filter(c => c.status === 'passed').length;
const failed = checks.filter(c => c.status === 'failed').length;
if (failed > 0) return 'some-failed';
if (passed === checks.length) return 'all-passed';
return 'in-progress';
});
readonly reviewSummaryClass = computed(() => {
if (!this.pullRequest) return '';
const { approved, required } = this.pullRequest.reviewStatus;
if (approved >= required) return 'approved';
return 'pending';
});
readonly canMerge = computed(() => {
if (!this.pullRequest) return false;
const allChecksPassed = this.pullRequest.ciChecks.every(
c => c.status === 'passed' || c.status === 'skipped'
);
const hasEnoughApprovals =
this.pullRequest.reviewStatus.approved >= this.pullRequest.reviewStatus.required;
return allChecksPassed && hasEnoughApprovals;
});
formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
}
}

View File

@@ -0,0 +1,778 @@
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type {
RemediationPlan,
RemediationStep,
RemediationPlanStatus,
} from '../../core/api/advisory-ai.models';
/**
* Remediation plan preview component showing AI-generated fix steps.
*
* @task REMEDY-23
*
* Displays:
* - 3-line summary (what/impact/action)
* - Step-by-step remediation instructions
* - Code diffs for file changes
* - Impact assessment
* - Approve/Create PR actions
*/
@Component({
selector: 'stellaops-remediation-plan-preview',
standalone: true,
imports: [CommonModule],
template: `
<article class="remediation-plan" [class.loading]="loading">
<header class="plan-header">
<div class="header-left">
<h3 class="title">
<svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
Remediation Plan
</h3>
@if (plan) {
<span class="status-badge" [class]="statusClass()">
{{ statusLabel() }}
</span>
}
</div>
@if (plan) {
<span class="strategy-badge">{{ plan.strategy }}</span>
}
</header>
<div class="plan-content">
@if (loading) {
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Generating remediation plan...</p>
</div>
} @else if (error) {
<div class="error-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="error-icon">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<p>{{ error }}</p>
<button type="button" class="retry-btn" (click)="retry.emit()">Retry</button>
</div>
} @else if (plan) {
<!-- Summary Section -->
<section class="summary-section">
<div class="summary-line">
<span class="summary-label">Fix:</span>
<span class="summary-text">{{ plan.summary.line1 }}</span>
</div>
<div class="summary-line">
<span class="summary-label">Impact:</span>
<span class="summary-text">{{ plan.summary.line2 }}</span>
</div>
<div class="summary-line">
<span class="summary-label">Action:</span>
<span class="summary-text">{{ plan.summary.line3 }}</span>
</div>
</section>
<!-- Impact Assessment -->
<section class="impact-section">
<h4 class="section-title">Impact Assessment</h4>
<div class="impact-grid">
<div class="impact-item" [class.warning]="plan.estimatedImpact.breakingChanges > 0">
<span class="impact-value">{{ plan.estimatedImpact.breakingChanges }}</span>
<span class="impact-label">Breaking Changes</span>
</div>
<div class="impact-item">
<span class="impact-value">{{ plan.estimatedImpact.filesAffected }}</span>
<span class="impact-label">Files Affected</span>
</div>
<div class="impact-item">
<span class="impact-value">{{ plan.estimatedImpact.dependenciesAffected }}</span>
<span class="impact-label">Dependencies</span>
</div>
<div class="impact-item" [class.good]="plan.estimatedImpact.testCoverage >= 80">
<span class="impact-value">{{ plan.estimatedImpact.testCoverage }}%</span>
<span class="impact-label">Test Coverage</span>
</div>
</div>
<div class="risk-score">
<span class="risk-label">Risk Score:</span>
<div class="risk-bar">
<div
class="risk-fill"
[class.low]="plan.estimatedImpact.riskScore <= 3"
[class.medium]="plan.estimatedImpact.riskScore > 3 && plan.estimatedImpact.riskScore <= 7"
[class.high]="plan.estimatedImpact.riskScore > 7"
[style.width.%]="plan.estimatedImpact.riskScore * 10">
</div>
</div>
<span class="risk-value">{{ plan.estimatedImpact.riskScore }}/10</span>
</div>
</section>
<!-- Steps Section -->
<section class="steps-section">
<h4 class="section-title">Remediation Steps</h4>
<ol class="steps-list">
@for (step of plan.steps; track step.stepId; let i = $index) {
<li class="step-item" [class.expanded]="expandedSteps().has(step.stepId)">
<button
type="button"
class="step-header"
(click)="toggleStep(step.stepId)">
<span class="step-number">{{ i + 1 }}</span>
<span class="step-type" [class]="'type-' + step.type">
{{ stepTypeLabel(step.type) }}
</span>
<span class="step-title">{{ step.title }}</span>
@if (step.breakingChange) {
<span class="breaking-badge">Breaking</span>
}
<span class="step-risk" [class]="'risk-' + step.riskLevel">
{{ step.riskLevel }}
</span>
<svg class="expand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@if (expandedSteps().has(step.stepId)) {
<polyline points="18 15 12 9 6 15"/>
} @else {
<polyline points="6 9 12 15 18 9"/>
}
</svg>
</button>
@if (expandedSteps().has(step.stepId)) {
<div class="step-content">
<p class="step-description">{{ step.description }}</p>
@if (step.command) {
<div class="step-command">
<span class="command-label">Command:</span>
<pre><code>{{ step.command }}</code></pre>
<button
type="button"
class="copy-btn"
(click)="copyCommand(step.command)">
Copy
</button>
</div>
}
@if (step.diff) {
<div class="step-diff">
<span class="diff-label">Changes ({{ step.filePath }}):</span>
<pre class="diff-content">{{ step.diff }}</pre>
</div>
}
</div>
}
</li>
}
</ol>
</section>
<!-- Actions -->
<footer class="plan-actions">
@if (plan.status === 'draft') {
<button
type="button"
class="action-btn secondary"
(click)="dismiss.emit()">
Dismiss
</button>
<button
type="button"
class="action-btn secondary"
(click)="editPlan.emit()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
Edit Plan
</button>
<button
type="button"
class="action-btn primary"
(click)="createPr.emit()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="18" cy="18" r="3"/>
<circle cx="6" cy="6" r="3"/>
<path d="M13 6h3a2 2 0 0 1 2 2v7"/>
<line x1="6" y1="9" x2="6" y2="21"/>
</svg>
Create Pull Request
</button>
} @else if (plan.status === 'validated') {
<button
type="button"
class="action-btn primary"
(click)="approvePlan.emit()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Approve & Create PR
</button>
}
</footer>
} @else {
<div class="empty-state">
<p>No remediation plan available. Click "Auto-fix" to generate one.</p>
</div>
}
</div>
</article>
`,
styles: [`
.remediation-plan {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
}
.plan-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.title-icon {
width: 1.125rem;
height: 1.125rem;
color: var(--color-success, #10b981);
}
.status-badge {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
}
.status-badge.draft {
color: var(--color-info-text, #1e40af);
background: var(--color-info-bg, #dbeafe);
}
.status-badge.validated {
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
}
.status-badge.in_progress {
color: var(--color-warning-text, #92400e);
background: var(--color-warning-bg, #fef3c7);
}
.status-badge.completed {
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
}
.status-badge.failed {
color: var(--color-error-text, #991b1b);
background: var(--color-error-bg, #fee2e2);
}
.strategy-badge {
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.25rem;
text-transform: capitalize;
}
.plan-content {
padding: 1rem;
}
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
color: var(--color-text-secondary, #6b7280);
}
.loading-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border, #e5e7eb);
border-top-color: var(--color-success, #10b981);
border-radius: 50%;
animation: spin 0.75s linear infinite;
margin-bottom: 0.75rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-state {
color: var(--color-error-text, #991b1b);
}
.error-icon {
width: 2rem;
height: 2rem;
margin-bottom: 0.5rem;
color: var(--color-error, #ef4444);
}
.retry-btn {
margin-top: 0.75rem;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
color: var(--color-primary-text, #1e40af);
background: var(--color-primary-bg, #eff6ff);
border: 1px solid var(--color-primary-border, #bfdbfe);
border-radius: 0.375rem;
cursor: pointer;
}
.summary-section {
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--color-surface-alt, #f9fafb);
border-radius: 0.375rem;
}
.summary-line {
display: flex;
gap: 0.5rem;
padding: 0.25rem 0;
}
.summary-label {
flex-shrink: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
min-width: 3.5rem;
}
.summary-text {
font-size: 0.875rem;
color: var(--color-text-primary, #111827);
}
.section-title {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.impact-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--color-surface-alt, #f9fafb);
border-radius: 0.375rem;
}
.impact-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
.impact-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.impact-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text-primary, #111827);
}
.impact-item.warning .impact-value {
color: var(--color-warning, #f59e0b);
}
.impact-item.good .impact-value {
color: var(--color-success, #10b981);
}
.impact-label {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
}
.risk-score {
display: flex;
align-items: center;
gap: 0.75rem;
}
.risk-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
}
.risk-bar {
flex: 1;
height: 0.5rem;
background: var(--color-border, #e5e7eb);
border-radius: 9999px;
overflow: hidden;
}
.risk-fill {
height: 100%;
border-radius: 9999px;
transition: width 0.3s ease;
}
.risk-fill.low {
background: var(--color-success, #10b981);
}
.risk-fill.medium {
background: var(--color-warning, #f59e0b);
}
.risk-fill.high {
background: var(--color-error, #ef4444);
}
.risk-value {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.steps-section {
margin-bottom: 1rem;
}
.steps-list {
margin: 0;
padding: 0;
list-style: none;
}
.step-item {
margin-bottom: 0.5rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.375rem;
overflow: hidden;
}
.step-header {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.75rem;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
}
.step-header:hover {
background: var(--color-hover, #f9fafb);
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-primary-text, #1e40af);
background: var(--color-primary-bg, #eff6ff);
border-radius: 50%;
}
.step-type {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
border-radius: 0.25rem;
}
.step-type.type-upgrade {
color: #059669;
background: #d1fae5;
}
.step-type.type-patch {
color: #7c3aed;
background: #ede9fe;
}
.step-type.type-config {
color: #0891b2;
background: #cffafe;
}
.step-type.type-workaround {
color: #ca8a04;
background: #fef9c3;
}
.step-type.type-vex_document {
color: #4f46e5;
background: #e0e7ff;
}
.step-title {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary, #111827);
}
.breaking-badge {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-error-text, #991b1b);
background: var(--color-error-bg, #fee2e2);
border-radius: 0.25rem;
}
.step-risk {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
border-radius: 0.25rem;
text-transform: capitalize;
}
.step-risk.risk-low {
color: #065f46;
background: #d1fae5;
}
.step-risk.risk-medium {
color: #92400e;
background: #fef3c7;
}
.step-risk.risk-high {
color: #991b1b;
background: #fee2e2;
}
.expand-icon {
width: 1.25rem;
height: 1.25rem;
color: var(--color-text-secondary, #6b7280);
}
.step-content {
padding: 0 0.75rem 0.75rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.step-description {
margin: 0.75rem 0;
font-size: 0.875rem;
color: var(--color-text-secondary, #6b7280);
line-height: 1.5;
}
.step-command,
.step-diff {
margin-top: 0.75rem;
}
.command-label,
.diff-label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
}
.step-command pre,
.step-diff pre {
margin: 0;
padding: 0.75rem;
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: var(--color-code-bg, #1f2937);
color: var(--color-code-text, #e5e7eb);
border-radius: 0.375rem;
overflow-x: auto;
}
.step-command {
position: relative;
}
.copy-btn {
position: absolute;
top: 0.375rem;
right: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: var(--color-code-text, #e5e7eb);
background: var(--color-code-btn, #374151);
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
.copy-btn:hover {
background: var(--color-code-btn-hover, #4b5563);
}
.diff-content {
white-space: pre-wrap;
}
.plan-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.action-btn svg {
width: 1rem;
height: 1rem;
}
.action-btn.secondary {
color: var(--color-text-primary, #374151);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
}
.action-btn.secondary:hover {
background: var(--color-hover, #f9fafb);
}
.action-btn.primary {
color: var(--color-primary-contrast, #ffffff);
background: var(--color-success, #10b981);
border: 1px solid var(--color-success, #10b981);
}
.action-btn.primary:hover {
background: var(--color-success-hover, #059669);
border-color: var(--color-success-hover, #059669);
}
`]
})
export class RemediationPlanPreviewComponent {
@Input() plan: RemediationPlan | null = null;
@Input() loading = false;
@Input() error: string | null = null;
@Output() readonly retry = new EventEmitter<void>();
@Output() readonly dismiss = new EventEmitter<void>();
@Output() readonly editPlan = new EventEmitter<void>();
@Output() readonly approvePlan = new EventEmitter<void>();
@Output() readonly createPr = new EventEmitter<void>();
readonly expandedSteps = signal<Set<string>>(new Set());
readonly statusClass = computed(() => {
if (!this.plan) return '';
return this.plan.status.replace('_', '-');
});
readonly statusLabel = computed(() => {
if (!this.plan) return '';
const labels: Record<RemediationPlanStatus, string> = {
draft: 'Draft',
validated: 'Validated',
approved: 'Approved',
in_progress: 'In Progress',
completed: 'Completed',
failed: 'Failed',
};
return labels[this.plan.status] || this.plan.status;
});
toggleStep(stepId: string): void {
this.expandedSteps.update(set => {
const newSet = new Set(set);
if (newSet.has(stepId)) {
newSet.delete(stepId);
} else {
newSet.add(stepId);
}
return newSet;
});
}
stepTypeLabel(type: string): string {
const labels: Record<string, string> = {
upgrade: 'Upgrade',
patch: 'Patch',
config: 'Config',
workaround: 'Workaround',
vex_document: 'VEX',
};
return labels[type] || type;
}
async copyCommand(command: string): Promise<void> {
try {
await navigator.clipboard.writeText(command);
} catch {
// Fallback
const textarea = document.createElement('textarea');
textarea.value = command;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
}
}

View File

@@ -0,0 +1,151 @@
// -----------------------------------------------------------------------------
// compare-view.e2e.spec.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-32 — E2E tests: full comparison workflow
// -----------------------------------------------------------------------------
import { test, expect, Page } from '@playwright/test';
test.describe('Smart-Diff Compare View', () => {
let page: Page;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
// Navigate to compare view with test scan IDs
await page.goto('/compare/sha256:abc123?baseline=sha256:def456');
});
test.afterEach(async () => {
await page.close();
});
test.describe('Baseline Selection', () => {
test('should display baseline selector', async () => {
await expect(page.locator('.target-selector')).toBeVisible();
await expect(page.locator('mat-select')).toBeVisible();
});
test('should show baseline presets', async () => {
await page.click('mat-select');
await expect(page.locator('mat-option')).toHaveCount(4);
await expect(page.locator('mat-option').first()).toContainText('Last Green Build');
});
test('should update comparison when baseline changes', async () => {
await page.click('mat-select');
await page.click('mat-option:has-text("Previous Release")');
// Wait for delta to recalculate
await expect(page.locator('.delta-summary')).toBeVisible();
});
});
test.describe('Delta Summary', () => {
test('should display delta counts', async () => {
await expect(page.locator('.summary-chip.added')).toBeVisible();
await expect(page.locator('.summary-chip.removed')).toBeVisible();
await expect(page.locator('.summary-chip.changed')).toBeVisible();
});
test('should show correct count format', async () => {
const addedChip = page.locator('.summary-chip.added');
await expect(addedChip).toContainText(/\+\d+ added/);
});
});
test.describe('Three-Pane Layout', () => {
test('should display all three panes', async () => {
await expect(page.locator('.categories-pane')).toBeVisible();
await expect(page.locator('.items-pane')).toBeVisible();
await expect(page.locator('.evidence-pane')).toBeVisible();
});
test('should highlight category on click', async () => {
const categoryItem = page.locator('.categories-pane mat-list-item').first();
await categoryItem.click();
await expect(categoryItem).toHaveClass(/selected/);
});
test('should filter items when category selected', async () => {
const initialItemCount = await page.locator('.items-pane mat-list-item').count();
await page.locator('.categories-pane mat-list-item').first().click();
const filteredItemCount = await page.locator('.items-pane mat-list-item').count();
expect(filteredItemCount).toBeLessThanOrEqual(initialItemCount);
});
test('should display evidence when item selected', async () => {
await page.locator('.items-pane mat-list-item').first().click();
await expect(page.locator('.evidence-pane .evidence-header')).toBeVisible();
});
});
test.describe('View Mode Toggle', () => {
test('should toggle between side-by-side and unified views', async () => {
// Default is side-by-side
await page.locator('.items-pane mat-list-item').first().click();
await expect(page.locator('.side-by-side')).toBeVisible();
// Toggle to unified
await page.click('button[mattooltip="Toggle view mode"]');
await expect(page.locator('.unified')).toBeVisible();
});
});
test.describe('Export', () => {
test('should open export menu', async () => {
await page.click('button:has-text("Export")');
await expect(page.locator('mat-menu')).toBeVisible();
});
test('should have JSON export option', async () => {
await page.click('button:has-text("Export")');
await expect(page.locator('button:has-text("JSON Report")')).toBeVisible();
});
});
test.describe('Trust Indicators', () => {
test('should display trust indicators component', async () => {
await expect(page.locator('stella-trust-indicators')).toBeVisible();
});
});
test.describe('Keyboard Navigation', () => {
test('should navigate items with arrow keys', async () => {
await page.locator('.items-pane').focus();
await page.keyboard.press('ArrowDown');
const firstItem = page.locator('.items-pane mat-list-item').first();
await expect(firstItem).toHaveClass(/selected/);
});
test('should select item with Enter key', async () => {
await page.locator('.items-pane').focus();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(page.locator('.evidence-pane .evidence-header')).toBeVisible();
});
});
test.describe('Accessibility', () => {
test('should have proper ARIA labels', async () => {
await expect(page.locator('[role="listbox"]')).toBeVisible();
await expect(page.locator('[role="option"]')).toHaveCount.greaterThan(0);
});
test('should announce changes to screen readers', async () => {
const liveRegion = page.locator('#stella-sr-announcer');
// Live region may or may not exist initially
});
});
test.describe('Degraded Mode', () => {
test('should show banner when signature verification fails', async () => {
// Navigate to comparison with failed signature
await page.goto('/compare/sha256:abc123?baseline=sha256:def456&mock_sig_fail=true');
await expect(page.locator('stella-degraded-mode-banner')).toBeVisible();
});
});
});

View File

@@ -0,0 +1,286 @@
// -----------------------------------------------------------------------------
// compare.integration.spec.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-33 — Integration tests: API service calls and response handling
// -----------------------------------------------------------------------------
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { CompareService } from '../services/compare.service';
import { DeltaComputeService } from '../services/delta-compute.service';
import { CompareExportService } from '../services/compare-export.service';
import { UserPreferencesService } from '../services/user-preferences.service';
describe('Compare Feature Integration', () => {
let compareService: CompareService;
let deltaService: DeltaComputeService;
let exportService: CompareExportService;
let prefsService: UserPreferencesService;
let httpMock: HttpTestingController;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule,
RouterTestingModule,
NoopAnimationsModule
],
providers: [
CompareService,
DeltaComputeService,
CompareExportService,
UserPreferencesService
]
});
compareService = TestBed.inject(CompareService);
deltaService = TestBed.inject(DeltaComputeService);
exportService = TestBed.inject(CompareExportService);
prefsService = TestBed.inject(UserPreferencesService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
localStorage.clear();
});
describe('Full Comparison Workflow', () => {
it('should complete full comparison workflow', fakeAsync(() => {
// 1. Get baseline recommendations
let recommendations: any;
compareService.getBaselineRecommendations('sha256:abc123').subscribe(r => {
recommendations = r;
});
httpMock.expectOne('/api/v1/compare/baselines/sha256:abc123').flush({
recommended: 'sha256:def456',
candidates: [
{ digest: 'sha256:def456', label: 'Last Green', score: 0.95 },
{ digest: 'sha256:ghi789', label: 'Previous Release', score: 0.8 }
],
rationale: 'Selected based on policy P-2024-001'
});
tick();
expect(recommendations.recommended).toBe('sha256:def456');
// 2. Initialize comparison session
let session: any;
compareService.initSession({
currentDigest: 'sha256:abc123',
baselineDigest: 'sha256:def456'
}).subscribe(s => {
session = s;
});
httpMock.expectOne('/api/v1/compare/sessions').flush({
id: 'session-123',
currentDigest: 'sha256:abc123',
baselineDigest: 'sha256:def456',
status: 'ready'
});
tick();
expect(session.id).toBe('session-123');
// 3. Compute delta
let delta: any;
deltaService.computeDelta('session-123').subscribe(d => {
delta = d;
});
httpMock.expectOne('/api/v1/compare/sessions/session-123/delta').flush({
sessionId: 'session-123',
baselineDigest: 'sha256:def456',
currentDigest: 'sha256:abc123',
items: [
{
id: 'item-1',
category: 'sbom',
status: 'added',
finding: { cveId: 'CVE-2024-001', packageName: 'lodash', severity: 'high', priorityScore: 8.5 },
current: { status: 'affected', confidence: 0.9 }
}
],
summary: { added: 1, removed: 0, changed: 0, unchanged: 10 },
computedAt: new Date().toISOString()
});
tick();
expect(delta.items.length).toBe(1);
expect(delta.summary.added).toBe(1);
// 4. Verify filtering works
deltaService.setFilter({ categories: ['sbom'] });
expect(deltaService.filteredItems().length).toBe(1);
deltaService.setFilter({ categories: ['vex'] });
expect(deltaService.filteredItems().length).toBe(0);
}));
});
describe('API Error Handling', () => {
it('should handle baseline recommendations failure', fakeAsync(() => {
let error: any;
compareService.getBaselineRecommendations('sha256:invalid').subscribe({
error: e => error = e
});
httpMock.expectOne('/api/v1/compare/baselines/sha256:invalid').flush(
{ error: 'Scan not found' },
{ status: 404, statusText: 'Not Found' }
);
tick();
expect(error).toBeTruthy();
}));
it('should handle delta computation failure', fakeAsync(() => {
let error: any;
deltaService.computeDelta('invalid-session').subscribe({
error: e => error = e
});
httpMock.expectOne('/api/v1/compare/sessions/invalid-session/delta').flush(
{ error: 'Session expired' },
{ status: 410, statusText: 'Gone' }
);
tick();
expect(error).toBeTruthy();
}));
});
describe('User Preferences Persistence', () => {
it('should persist preferences across service instances', () => {
prefsService.setRole('audit');
prefsService.setViewMode('unified');
prefsService.setExplainMode(true);
// Simulate page reload by creating new service instance
const newPrefsService = new UserPreferencesService();
expect(newPrefsService.role()).toBe('audit');
expect(newPrefsService.viewMode()).toBe('unified');
expect(newPrefsService.explainMode()).toBe(true);
});
});
describe('Export Integration', () => {
it('should generate valid JSON export', async () => {
const mockTarget = { id: 'target-1', label: 'Current', digest: 'sha256:abc123' };
const mockBaseline = { id: 'baseline-1', label: 'Baseline', digest: 'sha256:def456' };
const mockCategories = [
{ id: 'sbom', name: 'SBOM', added: 1, removed: 0, changed: 0, icon: 'package' }
];
const mockItems = [
{
id: 'item-1',
category: 'sbom',
changeType: 'added',
title: 'CVE-2024-001',
severity: 'high'
}
];
// Create a spy on URL.createObjectURL
const createObjectURLSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:test');
const revokeObjectURLSpy = spyOn(URL, 'revokeObjectURL');
await exportService.exportJson(
mockTarget as any,
mockBaseline as any,
mockCategories as any,
mockItems as any
);
expect(createObjectURLSpy).toHaveBeenCalled();
expect(revokeObjectURLSpy).toHaveBeenCalled();
});
it('should generate valid Markdown export', async () => {
const mockTarget = { id: 'target-1', label: 'Current', digest: 'sha256:abc123' };
const mockBaseline = { id: 'baseline-1', label: 'Baseline', digest: 'sha256:def456' };
const mockCategories = [
{ id: 'sbom', name: 'SBOM', added: 1, removed: 0, changed: 0 }
];
const mockItems = [
{
id: 'item-1',
category: 'sbom',
changeType: 'added',
title: 'CVE-2024-001',
severity: 'high'
}
];
const createObjectURLSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:test');
const revokeObjectURLSpy = spyOn(URL, 'revokeObjectURL');
await exportService.exportMarkdown(
mockTarget as any,
mockBaseline as any,
mockCategories as any,
mockItems as any
);
expect(createObjectURLSpy).toHaveBeenCalled();
});
});
describe('Delta Caching', () => {
it('should cache delta results to avoid duplicate API calls', fakeAsync(() => {
const sessionId = 'cache-test-session';
const mockDelta = {
sessionId,
baselineDigest: 'sha256:abc',
currentDigest: 'sha256:def',
items: [],
summary: { added: 0, removed: 0, changed: 0, unchanged: 0 },
computedAt: new Date().toISOString()
};
// First call
deltaService.computeDelta(sessionId).subscribe();
httpMock.expectOne(`/api/v1/compare/sessions/${sessionId}/delta`).flush(mockDelta);
tick();
// Second call should use cache
let result: any;
deltaService.computeDelta(sessionId).subscribe(r => result = r);
tick();
// No new HTTP request should be made
httpMock.expectNone(`/api/v1/compare/sessions/${sessionId}/delta`);
expect(result.sessionId).toBe(sessionId);
}));
it('should invalidate cache on session change', fakeAsync(() => {
const session1 = 'session-1';
const session2 = 'session-2';
const mockDelta = (id: string) => ({
sessionId: id,
baselineDigest: 'sha256:abc',
currentDigest: 'sha256:def',
items: [],
summary: { added: 0, removed: 0, changed: 0, unchanged: 0 },
computedAt: new Date().toISOString()
});
// First session
deltaService.computeDelta(session1).subscribe();
httpMock.expectOne(`/api/v1/compare/sessions/${session1}/delta`).flush(mockDelta(session1));
tick();
// Different session should make new request
deltaService.computeDelta(session2).subscribe();
httpMock.expectOne(`/api/v1/compare/sessions/${session2}/delta`).flush(mockDelta(session2));
tick();
}));
});
});

View File

@@ -0,0 +1,175 @@
// -----------------------------------------------------------------------------
// delta-compute.service.spec.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-31 — Unit tests for all new components
// -----------------------------------------------------------------------------
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { DeltaComputeService, DeltaItem, DeltaResult, DeltaFilter } from '../services/delta-compute.service';
describe('DeltaComputeService', () => {
let service: DeltaComputeService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [DeltaComputeService]
});
service = TestBed.inject(DeltaComputeService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
describe('computeDelta', () => {
it('should compute delta between two scan sessions', () => {
const sessionId = 'session-123';
const mockResult: DeltaResult = {
sessionId,
baselineDigest: 'sha256:abc',
currentDigest: 'sha256:def',
items: [],
summary: { added: 0, removed: 0, changed: 0, unchanged: 0 },
computedAt: new Date().toISOString()
};
service.computeDelta(sessionId).subscribe(result => {
expect(result.sessionId).toBe(sessionId);
});
const req = httpMock.expectOne(`/api/v1/compare/sessions/${sessionId}/delta`);
expect(req.request.method).toBe('GET');
req.flush(mockResult);
});
it('should cache delta results', () => {
const sessionId = 'session-456';
const mockResult: DeltaResult = {
sessionId,
baselineDigest: 'sha256:abc',
currentDigest: 'sha256:def',
items: [],
summary: { added: 0, removed: 0, changed: 0, unchanged: 0 },
computedAt: new Date().toISOString()
};
// First call
service.computeDelta(sessionId).subscribe();
httpMock.expectOne(`/api/v1/compare/sessions/${sessionId}/delta`).flush(mockResult);
// Second call should use cache (no new HTTP request)
service.computeDelta(sessionId).subscribe(result => {
expect(result.sessionId).toBe(sessionId);
});
httpMock.expectNone(`/api/v1/compare/sessions/${sessionId}/delta`);
});
});
describe('filtering', () => {
it('should filter items by category', () => {
const items: DeltaItem[] = [
createMockItem('1', 'sbom', 'added'),
createMockItem('2', 'vex', 'changed'),
createMockItem('3', 'sbom', 'removed')
];
service['_items'].set(items);
service.setFilter({ categories: ['sbom'] });
const filtered = service.filteredItems();
expect(filtered.length).toBe(2);
expect(filtered.every(i => i.category === 'sbom')).toBe(true);
});
it('should filter items by status', () => {
const items: DeltaItem[] = [
createMockItem('1', 'sbom', 'added'),
createMockItem('2', 'vex', 'changed'),
createMockItem('3', 'sbom', 'added')
];
service['_items'].set(items);
service.setFilter({ statuses: ['added'] });
const filtered = service.filteredItems();
expect(filtered.length).toBe(2);
expect(filtered.every(i => i.status === 'added')).toBe(true);
});
it('should filter items by severity', () => {
const items: DeltaItem[] = [
createMockItem('1', 'sbom', 'added', 'critical'),
createMockItem('2', 'vex', 'changed', 'low'),
createMockItem('3', 'sbom', 'removed', 'critical')
];
service['_items'].set(items);
service.setFilter({ severities: ['critical'] });
const filtered = service.filteredItems();
expect(filtered.length).toBe(2);
expect(filtered.every(i => i.finding.severity === 'critical')).toBe(true);
});
it('should clear filter', () => {
const items: DeltaItem[] = [
createMockItem('1', 'sbom', 'added'),
createMockItem('2', 'vex', 'changed'),
createMockItem('3', 'sbom', 'removed')
];
service['_items'].set(items);
service.setFilter({ categories: ['sbom'] });
expect(service.filteredItems().length).toBe(2);
service.clearFilter();
expect(service.filteredItems().length).toBe(3);
});
});
describe('categoryCounts', () => {
it('should count items per category', () => {
const items: DeltaItem[] = [
createMockItem('1', 'sbom', 'added'),
createMockItem('2', 'sbom', 'changed'),
createMockItem('3', 'vex', 'added'),
createMockItem('4', 'reachability', 'removed'),
createMockItem('5', 'reachability', 'removed')
];
service['_items'].set(items);
const counts = service.categoryCounts();
expect(counts.sbom).toBe(2);
expect(counts.vex).toBe(1);
expect(counts.reachability).toBe(2);
expect(counts.policy).toBe(0);
expect(counts.unknowns).toBe(0);
});
});
});
function createMockItem(
id: string,
category: 'sbom' | 'reachability' | 'vex' | 'policy' | 'unknowns',
status: 'added' | 'removed' | 'changed' | 'unchanged',
severity: 'critical' | 'high' | 'medium' | 'low' | 'none' = 'medium'
): DeltaItem {
return {
id,
category,
status,
finding: {
cveId: `CVE-2024-${id}`,
packageName: `pkg-${id}`,
severity,
priorityScore: severity === 'critical' ? 9.5 : severity === 'high' ? 7.5 : 5.0
},
current: { status: 'affected', confidence: 0.9 },
baseline: status === 'added' ? undefined : { status: 'not_affected', confidence: 0.8 }
};
}

View File

@@ -0,0 +1,147 @@
// -----------------------------------------------------------------------------
// envelope-hashes.component.spec.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-31 — Unit tests for all new components
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Clipboard } from '@angular/cdk/clipboard';
import { EnvelopeHashesComponent, EvidenceEnvelope, EnvelopeHash } from '../components/envelope-hashes/envelope-hashes.component';
describe('EnvelopeHashesComponent', () => {
let component: EnvelopeHashesComponent;
let fixture: ComponentFixture<EnvelopeHashesComponent>;
let clipboardSpy: jasmine.SpyObj<Clipboard>;
let snackBarSpy: jasmine.SpyObj<MatSnackBar>;
const mockEnvelope: EvidenceEnvelope = {
payloadHash: {
label: 'Payload',
algorithm: 'sha256',
digest: 'abc123def456789012345678901234567890abcdef123456789012345678901234',
verified: true
},
signatureHash: {
label: 'Signature',
algorithm: 'sha256',
digest: 'sig123def456789012345678901234567890abcdef123456789012345678901234',
verified: true
},
envelopeHash: {
label: 'Envelope',
algorithm: 'sha256',
digest: 'env123def456789012345678901234567890abcdef123456789012345678901234',
verified: false
}
};
beforeEach(async () => {
clipboardSpy = jasmine.createSpyObj('Clipboard', ['copy']);
snackBarSpy = jasmine.createSpyObj('MatSnackBar', ['open']);
await TestBed.configureTestingModule({
imports: [EnvelopeHashesComponent, NoopAnimationsModule],
providers: [
{ provide: Clipboard, useValue: clipboardSpy },
{ provide: MatSnackBar, useValue: snackBarSpy }
]
}).compileComponents();
fixture = TestBed.createComponent(EnvelopeHashesComponent);
component = fixture.componentInstance;
});
describe('getHashDisplay', () => {
it('should truncate long hashes', () => {
const hash: EnvelopeHash = {
label: 'Test',
algorithm: 'sha256',
digest: 'abc123def456789012345678901234567890abcdef123456789012345678901234'
};
const display = component.getHashDisplay(hash);
expect(display).toBe('sha256:abc123de...78901234');
});
it('should not truncate short hashes', () => {
const hash: EnvelopeHash = {
label: 'Test',
algorithm: 'sha256',
digest: 'abc123'
};
const display = component.getHashDisplay(hash);
expect(display).toBe('sha256:abc123');
});
it('should return dash for undefined hash', () => {
expect(component.getHashDisplay(undefined)).toBe('—');
});
});
describe('getVerificationIcon', () => {
it('should return verified icon for verified hash', () => {
const hash: EnvelopeHash = {
label: 'Test',
algorithm: 'sha256',
digest: 'abc',
verified: true
};
expect(component.getVerificationIcon(hash)).toBe('verified');
});
it('should return gpp_bad icon for invalid hash', () => {
const hash: EnvelopeHash = {
label: 'Test',
algorithm: 'sha256',
digest: 'abc',
verified: false
};
expect(component.getVerificationIcon(hash)).toBe('gpp_bad');
});
it('should return pending icon for unverified hash', () => {
const hash: EnvelopeHash = {
label: 'Test',
algorithm: 'sha256',
digest: 'abc'
};
expect(component.getVerificationIcon(hash)).toBe('pending');
});
});
describe('copyHash', () => {
it('should copy hash to clipboard', () => {
const hash: EnvelopeHash = {
label: 'Payload',
algorithm: 'sha256',
digest: 'abc123'
};
component.copyHash(hash);
expect(clipboardSpy.copy).toHaveBeenCalledWith('sha256:abc123');
expect(snackBarSpy.open).toHaveBeenCalledWith('Payload hash copied', 'OK', { duration: 2000 });
});
it('should not copy undefined hash', () => {
component.copyHash(undefined);
expect(clipboardSpy.copy).not.toHaveBeenCalled();
});
});
describe('copyAllHashes', () => {
it('should copy all hashes to clipboard', () => {
fixture.componentRef.setInput('envelope', mockEnvelope);
fixture.detectChanges();
component.copyAllHashes();
expect(clipboardSpy.copy).toHaveBeenCalled();
const copiedText = clipboardSpy.copy.calls.mostRecent().args[0];
expect(copiedText).toContain('Payload:');
expect(copiedText).toContain('Signature:');
expect(copiedText).toContain('Envelope:');
});
});
});

View File

@@ -0,0 +1,161 @@
// -----------------------------------------------------------------------------
// keyboard-navigation.directive.spec.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-31 — Unit tests for all new components
// -----------------------------------------------------------------------------
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { KeyboardNavigationDirective, KeyboardNavigationEvent, announceToScreenReader } from '../directives/keyboard-navigation.directive';
@Component({
standalone: true,
imports: [KeyboardNavigationDirective],
template: `
<div
stellaKeyboardNav
(navigationEvent)="onNavEvent($event)"
>
Test element
</div>
`
})
class TestHostComponent {
lastEvent: KeyboardNavigationEvent | null = null;
onNavEvent(event: KeyboardNavigationEvent): void {
this.lastEvent = event;
}
}
describe('KeyboardNavigationDirective', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
let element: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent]
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
element = fixture.nativeElement.querySelector('[stellaKeyboardNav]');
fixture.detectChanges();
});
describe('keyboard events', () => {
it('should emit next on ArrowDown', () => {
const event = new KeyboardEvent('keydown', { key: 'ArrowDown' });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('next');
});
it('should emit previous on ArrowUp', () => {
const event = new KeyboardEvent('keydown', { key: 'ArrowUp' });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('previous');
});
it('should emit next on j (vim-style)', () => {
const event = new KeyboardEvent('keydown', { key: 'j' });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('next');
});
it('should emit previous on k (vim-style)', () => {
const event = new KeyboardEvent('keydown', { key: 'k' });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('previous');
});
it('should emit select on Enter', () => {
const event = new KeyboardEvent('keydown', { key: 'Enter' });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('select');
});
it('should emit select on Space', () => {
const event = new KeyboardEvent('keydown', { key: ' ' });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('select');
});
it('should emit escape on Escape', () => {
const event = new KeyboardEvent('keydown', { key: 'Escape' });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('escape');
});
it('should emit copy on c', () => {
const event = new KeyboardEvent('keydown', { key: 'c' });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('copy');
});
it('should not emit copy on Ctrl+C', () => {
host.lastEvent = null;
const event = new KeyboardEvent('keydown', { key: 'c', ctrlKey: true });
element.dispatchEvent(event);
expect(host.lastEvent).toBeNull();
});
it('should emit export on Ctrl+E', () => {
const event = new KeyboardEvent('keydown', { key: 'e', ctrlKey: true });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('export');
});
it('should emit focus-categories on Alt+1', () => {
const event = new KeyboardEvent('keydown', { key: '1', altKey: true });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('focus-categories');
});
it('should emit focus-items on Alt+2', () => {
const event = new KeyboardEvent('keydown', { key: '2', altKey: true });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('focus-items');
});
it('should emit focus-proof on Alt+3', () => {
const event = new KeyboardEvent('keydown', { key: '3', altKey: true });
element.dispatchEvent(event);
expect(host.lastEvent?.action).toBe('focus-proof');
});
});
describe('tabindex', () => {
it('should add tabindex=0 to element', () => {
expect(element.getAttribute('tabindex')).toBe('0');
});
});
});
describe('announceToScreenReader', () => {
afterEach(() => {
const announcer = document.getElementById('stella-sr-announcer');
if (announcer) {
announcer.remove();
}
});
it('should create live region if not exists', () => {
announceToScreenReader('Test message');
const region = document.getElementById('stella-sr-announcer');
expect(region).toBeTruthy();
expect(region?.getAttribute('role')).toBe('status');
expect(region?.getAttribute('aria-live')).toBe('polite');
});
it('should update live region with message', () => {
announceToScreenReader('Test message');
const region = document.getElementById('stella-sr-announcer');
expect(region?.textContent).toBe('Test message');
});
it('should support assertive priority', () => {
announceToScreenReader('Urgent message', 'assertive');
const region = document.getElementById('stella-sr-announcer');
expect(region?.getAttribute('aria-live')).toBe('assertive');
});
});

View File

@@ -0,0 +1,125 @@
// -----------------------------------------------------------------------------
// user-preferences.service.spec.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-31 — Unit tests for all new components
// -----------------------------------------------------------------------------
import { TestBed } from '@angular/core/testing';
import { UserPreferencesService, ViewRole, ViewMode } from '../services/user-preferences.service';
describe('UserPreferencesService', () => {
let service: UserPreferencesService;
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
TestBed.configureTestingModule({
providers: [UserPreferencesService]
});
service = TestBed.inject(UserPreferencesService);
});
afterEach(() => {
localStorage.clear();
});
describe('initial state', () => {
it('should have default preferences', () => {
expect(service.role()).toBe('developer');
expect(service.viewMode()).toBe('side-by-side');
expect(service.explainMode()).toBe(false);
expect(service.showUnchanged()).toBe(false);
expect(service.changedNeighborhoodOnly()).toBe(true);
expect(service.maxGraphNodes()).toBe(25);
});
it('should load preferences from localStorage', () => {
localStorage.setItem('stellaops.compare.preferences', JSON.stringify({
role: 'audit',
viewMode: 'unified',
explainMode: true
}));
// Create new service instance to test loading
const newService = new UserPreferencesService();
expect(newService.role()).toBe('audit');
expect(newService.viewMode()).toBe('unified');
expect(newService.explainMode()).toBe(true);
});
});
describe('setRole', () => {
it('should update role', () => {
service.setRole('security');
expect(service.role()).toBe('security');
});
it('should persist role to localStorage', () => {
service.setRole('audit');
TestBed.flushEffects();
const stored = JSON.parse(localStorage.getItem('stellaops.compare.preferences') || '{}');
expect(stored.role).toBe('audit');
});
});
describe('setViewMode', () => {
it('should update viewMode', () => {
service.setViewMode('unified');
expect(service.viewMode()).toBe('unified');
});
});
describe('setExplainMode', () => {
it('should toggle explainMode', () => {
expect(service.explainMode()).toBe(false);
service.setExplainMode(true);
expect(service.explainMode()).toBe(true);
});
});
describe('setShowUnchanged', () => {
it('should toggle showUnchanged', () => {
expect(service.showUnchanged()).toBe(false);
service.setShowUnchanged(true);
expect(service.showUnchanged()).toBe(true);
});
});
describe('setPanelSize', () => {
it('should update panel sizes', () => {
service.setPanelSize('categories', 250);
expect(service.panelSizes().categories).toBe(250);
});
});
describe('toggleSection', () => {
it('should collapse section', () => {
expect(service.collapsedSections()).not.toContain('evidence');
service.toggleSection('evidence');
expect(service.collapsedSections()).toContain('evidence');
});
it('should expand collapsed section', () => {
service.toggleSection('evidence');
expect(service.collapsedSections()).toContain('evidence');
service.toggleSection('evidence');
expect(service.collapsedSections()).not.toContain('evidence');
});
});
describe('reset', () => {
it('should reset all preferences to defaults', () => {
service.setRole('audit');
service.setViewMode('unified');
service.setExplainMode(true);
service.reset();
expect(service.role()).toBe('developer');
expect(service.viewMode()).toBe('side-by-side');
expect(service.explainMode()).toBe(false);
});
});
});

View File

@@ -0,0 +1,147 @@
// -----------------------------------------------------------------------------
// baseline-selector.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-04 — BaselineSelectorComponent with dropdown and rationale display
// -----------------------------------------------------------------------------
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { BaselineRationale, BaselineRecommendation } from '../services/compare.service';
@Component({
selector: 'app-baseline-selector',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="baseline-selector">
<label class="baseline-selector__label">
<span class="baseline-selector__label-text">Compare against:</span>
<select
class="baseline-selector__dropdown"
[ngModel]="selectedDigest()"
(ngModelChange)="onSelectionChange($event)"
[attr.aria-describedby]="rationaleId">
@if (!rationale()?.alternatives?.length) {
<option value="">No baselines available</option>
}
@for (alt of rationale()?.alternatives; track alt.digest) {
<option [value]="alt.digest" [class.primary]="alt.isPrimary">
{{ alt.label }} ({{ alt.scanDate | date:'short' }})
@if (alt.isPrimary) { ★ }
</option>
}
</select>
</label>
@if (rationale()?.selectionReason) {
<div class="baseline-selector__rationale" [id]="rationaleId">
<span class="baseline-selector__rationale-icon">💡</span>
<span class="baseline-selector__rationale-text">{{ rationale()?.selectionReason }}</span>
</div>
}
@if (showConfidence() && selectedBaseline()) {
<div class="baseline-selector__confidence">
<span class="baseline-selector__confidence-label">Confidence:</span>
<span class="baseline-selector__confidence-value"
[class]="confidenceClass()">
{{ (selectedBaseline()?.confidenceScore ?? 0) * 100 | number:'1.0-0' }}%
</span>
</div>
}
</div>
`,
styles: [`
.baseline-selector {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg-secondary, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
min-width: 300px;
}
.baseline-selector__label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.baseline-selector__label-text {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.baseline-selector__dropdown {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary, #fff);
cursor: pointer;
}
.baseline-selector__dropdown:focus {
outline: none;
border-color: var(--primary, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.baseline-selector__dropdown option.primary { font-weight: 600; }
.baseline-selector__rationale {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem;
background: #f0f9ff;
border-radius: 4px;
font-size: 0.8125rem;
color: #0369a1;
}
.baseline-selector__rationale-icon { flex-shrink: 0; }
.baseline-selector__confidence {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.baseline-selector__confidence-label { color: var(--text-muted, #6b7280); }
.baseline-selector__confidence-value {
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 4px;
}
.baseline-selector__confidence-value.high { background: #dcfce7; color: #15803d; }
.baseline-selector__confidence-value.medium { background: #fef3c7; color: #92400e; }
.baseline-selector__confidence-value.low { background: #fee2e2; color: #dc2626; }
`],
})
export class BaselineSelectorComponent {
readonly rationale = input<BaselineRationale | null>(null);
readonly showConfidence = input(true);
readonly baselineChange = output<string>();
readonly rationaleId = `rationale-${Math.random().toString(36).slice(2, 9)}`;
readonly selectedDigest = computed(() => this.rationale()?.selectedDigest ?? '');
readonly selectedBaseline = computed((): BaselineRecommendation | null => {
const r = this.rationale();
if (!r) return null;
return r.alternatives.find(a => a.digest === r.selectedDigest) ?? null;
});
readonly confidenceClass = computed(() => {
const score = this.selectedBaseline()?.confidenceScore ?? 0;
if (score >= 0.8) return 'high';
if (score >= 0.5) return 'medium';
return 'low';
});
onSelectionChange(digest: string): void {
if (digest) {
this.baselineChange.emit(digest);
}
}
}

View File

@@ -0,0 +1,128 @@
// -----------------------------------------------------------------------------
// categories-pane.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-12 — CategoriesPaneComponent with counts
// -----------------------------------------------------------------------------
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DeltaCategory } from '../services/delta-compute.service';
interface CategoryInfo {
key: DeltaCategory;
label: string;
icon: string;
description: string;
}
@Component({
selector: 'app-categories-pane',
standalone: true,
imports: [CommonModule],
template: `
<div class="categories-pane">
<div class="categories-pane__header">
<span class="categories-pane__title">Categories</span>
@if (selectedCategory()) {
<button class="categories-pane__clear" (click)="categorySelect.emit(null)">Clear</button>
}
</div>
<ul class="categories-pane__list" role="listbox">
@for (cat of categories; track cat.key) {
<li
class="categories-pane__item"
[class.selected]="selectedCategory() === cat.key"
[class.empty]="!counts()?.[cat.key]"
role="option"
[attr.aria-selected]="selectedCategory() === cat.key"
(click)="onSelect(cat.key)"
(keydown.enter)="onSelect(cat.key)"
tabindex="0">
<span class="categories-pane__icon">{{ cat.icon }}</span>
<span class="categories-pane__label">{{ cat.label }}</span>
<span class="categories-pane__count">{{ counts()?.[cat.key] ?? 0 }}</span>
</li>
}
</ul>
</div>
`,
styles: [`
.categories-pane {
display: flex;
flex-direction: column;
height: 100%;
}
.categories-pane__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.categories-pane__title {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
color: var(--text-muted, #6b7280);
}
.categories-pane__clear {
font-size: 0.75rem;
color: var(--primary, #3b82f6);
background: none;
border: none;
cursor: pointer;
}
.categories-pane__list {
list-style: none;
margin: 0;
padding: 0.5rem;
flex: 1;
overflow-y: auto;
}
.categories-pane__item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.categories-pane__item:hover { background: var(--bg-hover, #f3f4f6); }
.categories-pane__item.selected { background: var(--primary, #3b82f6); color: white; }
.categories-pane__item.empty { opacity: 0.5; }
.categories-pane__icon { font-size: 1rem; }
.categories-pane__label { flex: 1; font-size: 0.875rem; }
.categories-pane__count {
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
.categories-pane__item.selected .categories-pane__count {
background: rgba(255, 255, 255, 0.2);
}
`],
})
export class CategoriesPaneComponent {
readonly counts = input<Record<DeltaCategory, number> | null>(null);
readonly selectedCategory = input<DeltaCategory | null>(null);
readonly categorySelect = output<DeltaCategory | null>();
readonly categories: CategoryInfo[] = [
{ key: 'sbom', label: 'SBOM', icon: '📦', description: 'Package and dependency changes' },
{ key: 'reachability', label: 'Reachability', icon: '🔗', description: 'Call path and execution flow' },
{ key: 'vex', label: 'VEX', icon: '📋', description: 'Exploitability statements' },
{ key: 'policy', label: 'Policy', icon: '⚖️', description: 'Policy rule violations' },
{ key: 'unknowns', label: 'Unknowns', icon: '❓', description: 'Items requiring triage' },
];
onSelect(category: DeltaCategory): void {
if (this.selectedCategory() === category) {
this.categorySelect.emit(null);
} else {
this.categorySelect.emit(category);
}
}
}

View File

@@ -0,0 +1,252 @@
// -----------------------------------------------------------------------------
// compare-view.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-03 — CompareViewComponent container with signals-based state management
// -----------------------------------------------------------------------------
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CompareService } from '../services/compare.service';
import { DeltaComputeService } from '../services/delta-compute.service';
import { BaselineSelectorComponent } from './baseline-selector.component';
import { TrustIndicatorsComponent } from './trust-indicators.component';
import { DeltaSummaryStripComponent } from './delta-summary-strip.component';
import { ThreePaneLayoutComponent } from './three-pane-layout.component';
import { ExportActionsComponent } from './export-actions.component';
export type UserRole = 'developer' | 'security' | 'audit';
@Component({
selector: 'app-compare-view',
standalone: true,
imports: [
CommonModule,
RouterModule,
BaselineSelectorComponent,
TrustIndicatorsComponent,
DeltaSummaryStripComponent,
ThreePaneLayoutComponent,
ExportActionsComponent,
],
template: `
<div class="compare-view" [class.compare-view--loading]="loading()">
<header class="compare-view__header">
<div class="compare-view__title">
<h1>Compare Scans</h1>
@if (session()?.current) {
<span class="compare-view__image-ref">{{ session()?.current?.imageRef }}</span>
}
</div>
<div class="compare-view__actions">
<div class="compare-view__role-toggle">
@for (role of roles; track role) {
<button
[class.active]="currentRole() === role"
(click)="setRole(role)"
[attr.aria-pressed]="currentRole() === role">
{{ role | titlecase }}
</button>
}
</div>
<app-export-actions />
</div>
</header>
@if (error()) {
<div class="compare-view__error" role="alert">
<span>⚠</span> {{ error() }}
</div>
}
@if (loading()) {
<div class="compare-view__loading">
<div class="spinner"></div>
<span>Loading comparison...</span>
</div>
}
@if (session() && !loading()) {
<section class="compare-view__content">
<div class="compare-view__meta-row">
<app-baseline-selector
[rationale]="session()?.rationale"
(baselineChange)="onBaselineChange($event)" />
<app-trust-indicators
[current]="session()?.current"
[baseline]="session()?.baseline"
[policyDrift]="policyDrift()" />
</div>
<app-delta-summary-strip [summary]="deltaSummary()" />
<app-three-pane-layout [role]="currentRole()" [explainMode]="explainMode()" />
</section>
}
@if (!session() && !loading() && !error()) {
<div class="compare-view__empty">
<p>No scan selected for comparison.</p>
<a routerLink="/scans">Browse scans</a>
</div>
}
<button
class="compare-view__explain-toggle"
[class.active]="explainMode()"
(click)="toggleExplainMode()"
title="Explain like I'm new"
aria-label="Toggle plain language explanations">
💡
</button>
</div>
`,
styles: [`
.compare-view {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
background: var(--bg-primary, #fafafa);
}
.compare-view--loading { opacity: 0.7; pointer-events: none; }
.compare-view__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.compare-view__title { display: flex; align-items: baseline; gap: 1rem; }
.compare-view__title h1 { margin: 0; font-size: 1.5rem; font-weight: 600; }
.compare-view__image-ref { font-size: 0.875rem; color: var(--text-muted, #6b7280); font-family: monospace; }
.compare-view__actions { display: flex; gap: 1rem; align-items: center; }
.compare-view__role-toggle {
display: flex;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color, #e5e7eb);
}
.compare-view__role-toggle button {
padding: 0.5rem 1rem;
border: none;
background: var(--bg-secondary, #fff);
cursor: pointer;
font-size: 0.875rem;
transition: background 0.15s;
}
.compare-view__role-toggle button:not(:last-child) { border-right: 1px solid var(--border-color, #e5e7eb); }
.compare-view__role-toggle button:hover { background: var(--bg-hover, #f3f4f6); }
.compare-view__role-toggle button.active { background: var(--primary, #3b82f6); color: white; }
.compare-view__error {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.75rem 1rem; margin-bottom: 1rem;
background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; color: #dc2626;
}
.compare-view__loading {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 1rem; padding: 3rem; color: var(--text-muted, #6b7280);
}
.spinner {
width: 24px; height: 24px;
border: 3px solid var(--border-color, #e5e7eb);
border-top-color: var(--primary, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.compare-view__content { flex: 1; display: flex; flex-direction: column; gap: 1rem; overflow: hidden; }
.compare-view__meta-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.compare-view__empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 1rem; padding: 3rem; color: var(--text-muted, #6b7280);
}
.compare-view__empty a { color: var(--primary, #3b82f6); text-decoration: none; }
.compare-view__empty a:hover { text-decoration: underline; }
.compare-view__explain-toggle {
position: fixed; bottom: 1.5rem; right: 1.5rem;
width: 48px; height: 48px; border-radius: 50%; border: none;
background: var(--bg-secondary, #fff);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
font-size: 1.25rem; cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.compare-view__explain-toggle:hover { transform: scale(1.05); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
.compare-view__explain-toggle.active { background: var(--primary, #3b82f6); }
`],
})
export class CompareViewComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly compareService = inject(CompareService);
private readonly deltaService = inject(DeltaComputeService);
readonly roles: UserRole[] = ['developer', 'security', 'audit'];
private readonly _currentRole = signal<UserRole>('developer');
private readonly _explainMode = signal(false);
readonly session = this.compareService.currentSession;
readonly loading = this.compareService.loading;
readonly error = this.compareService.error;
readonly policyDrift = this.compareService.policyDrift;
readonly deltaSummary = this.deltaService.summary;
readonly currentRole = computed(() => this._currentRole());
readonly explainMode = computed(() => this._explainMode());
constructor() {
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const currentDigest = params.get('currentDigest');
const baselineDigest = params.get('baselineDigest') ?? undefined;
if (currentDigest) {
this.compareService.initSession({ currentDigest, baselineDigest }).subscribe({
next: (session) => this.deltaService.computeDelta(session.id).subscribe(),
});
}
});
this.loadPreferences();
}
ngOnInit(): void {}
ngOnDestroy(): void {
this.compareService.clearSession();
this.deltaService.clear();
}
setRole(role: UserRole): void {
this._currentRole.set(role);
this.savePreferences();
}
toggleExplainMode(): void {
this._explainMode.update((v) => !v);
this.savePreferences();
}
onBaselineChange(digest: string): void {
this.compareService.selectBaseline(digest).subscribe({
next: (session) => {
this.deltaService.invalidateCache(session.id);
this.deltaService.computeDelta(session.id).subscribe();
},
});
}
private loadPreferences(): void {
try {
const prefs = localStorage.getItem('compare-prefs');
if (prefs) {
const parsed = JSON.parse(prefs);
if (parsed.role) this._currentRole.set(parsed.role);
if (parsed.explainMode !== undefined) this._explainMode.set(parsed.explainMode);
}
} catch { /* ignore */ }
}
private savePreferences(): void {
try {
localStorage.setItem('compare-prefs', JSON.stringify({
role: this._currentRole(),
explainMode: this._explainMode(),
}));
} catch { /* ignore */ }
}
}

View File

@@ -0,0 +1,42 @@
<div
class="degraded-banner"
[class]="bannerClass()"
role="alert"
aria-live="polite"
*ngIf="primaryReason() as reason"
>
<mat-icon class="degraded-banner__icon">{{ getIcon() }}</mat-icon>
<div class="degraded-banner__content">
<span class="degraded-banner__title">
{{ reason.severity === 'error' ? 'Verification Failed' : 'Degraded Mode' }}
</span>
<span class="degraded-banner__message">{{ reason.message }}</span>
<span class="degraded-banner__details" *ngIf="reason.details">
{{ reason.details }}
</span>
<span class="degraded-banner__additional" *ngIf="additionalCount() > 0">
+{{ additionalCount() }} more {{ additionalCount() === 1 ? 'issue' : 'issues' }}
</span>
</div>
<div class="degraded-banner__actions">
<button
mat-stroked-button
(click)="retry()"
class="retry-btn"
*ngIf="reason.code === 'signature_invalid' || reason.code === 'offline'"
>
<mat-icon>refresh</mat-icon>
Retry
</button>
<button
mat-icon-button
(click)="dismiss()"
aria-label="Dismiss warning"
*ngIf="isDismissible()"
>
<mat-icon>close</mat-icon>
</button>
</div>
</div>

View File

@@ -0,0 +1,103 @@
.degraded-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
&--warning {
background: var(--amber-50, #fffbeb);
border: 1px solid var(--amber-300, #fcd34d);
color: var(--amber-900, #78350f);
.degraded-banner__icon {
color: var(--amber-600, #d97706);
}
}
&--error {
background: var(--red-50, #fef2f2);
border: 1px solid var(--red-300, #fca5a5);
color: var(--red-900, #7f1d1d);
.degraded-banner__icon {
color: var(--red-600, #dc2626);
}
}
&__icon {
flex-shrink: 0;
font-size: 24px;
width: 24px;
height: 24px;
}
&__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
&__title {
font-weight: 600;
font-size: 14px;
}
&__message {
font-size: 13px;
opacity: 0.9;
}
&__details {
font-size: 12px;
opacity: 0.7;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
&__additional {
font-size: 12px;
opacity: 0.7;
margin-top: 4px;
}
&__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.retry-btn {
font-size: 12px;
padding: 4px 12px;
height: 32px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin-right: 4px;
}
}
}
}
// Compact variant for smaller spaces
:host(.compact) .degraded-banner {
padding: 8px 12px;
&__icon {
font-size: 20px;
width: 20px;
height: 20px;
}
&__title {
font-size: 13px;
}
&__message {
font-size: 12px;
}
}

View File

@@ -0,0 +1,77 @@
// -----------------------------------------------------------------------------
// degraded-mode-banner.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-29 — Degraded mode: warning banner when signature verification fails
// -----------------------------------------------------------------------------
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
export interface DegradedModeReason {
code: 'signature_invalid' | 'signature_missing' | 'feed_stale' | 'policy_mismatch' | 'offline';
message: string;
severity: 'warning' | 'error';
details?: string;
}
@Component({
selector: 'stella-degraded-mode-banner',
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatButtonModule
],
templateUrl: './degraded-mode-banner.component.html',
styleUrls: ['./degraded-mode-banner.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DegradedModeBannerComponent {
reasons = input<DegradedModeReason[]>([]);
dismissed = output<void>();
retryRequested = output<void>();
isDismissible = input(true);
primaryReason = computed(() => {
const r = this.reasons();
// Errors take priority over warnings
const errors = r.filter(x => x.severity === 'error');
if (errors.length > 0) return errors[0];
return r[0] ?? null;
});
additionalCount = computed(() => {
return Math.max(0, this.reasons().length - 1);
});
bannerClass = computed(() => {
const reason = this.primaryReason();
if (!reason) return '';
return `degraded-banner--${reason.severity}`;
});
getIcon(): string {
const reason = this.primaryReason();
if (!reason) return 'info';
const icons: Record<DegradedModeReason['code'], string> = {
signature_invalid: 'gpp_bad',
signature_missing: 'no_encryption',
feed_stale: 'schedule',
policy_mismatch: 'policy',
offline: 'cloud_off'
};
return icons[reason.code] ?? 'warning';
}
dismiss(): void {
this.dismissed.emit();
}
retry(): void {
this.retryRequested.emit();
}
}

View File

@@ -0,0 +1,129 @@
// -----------------------------------------------------------------------------
// delta-summary-strip.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-10 — DeltaSummaryStripComponent: [+N added] [-N removed] [~N changed]
// -----------------------------------------------------------------------------
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DeltaSummary } from '../services/delta-compute.service';
@Component({
selector: 'app-delta-summary-strip',
standalone: true,
imports: [CommonModule],
template: `
<div class="delta-summary-strip" role="status" aria-live="polite">
<div class="delta-summary-strip__counts">
<span class="delta-badge delta-badge--added" [class.empty]="!summary()?.added">
<span class="delta-badge__icon">+</span>
<span class="delta-badge__count">{{ summary()?.added ?? 0 }}</span>
<span class="delta-badge__label">added</span>
</span>
<span class="delta-badge delta-badge--removed" [class.empty]="!summary()?.removed">
<span class="delta-badge__icon"></span>
<span class="delta-badge__count">{{ summary()?.removed ?? 0 }}</span>
<span class="delta-badge__label">removed</span>
</span>
<span class="delta-badge delta-badge--changed" [class.empty]="!summary()?.changed">
<span class="delta-badge__icon">~</span>
<span class="delta-badge__count">{{ summary()?.changed ?? 0 }}</span>
<span class="delta-badge__label">changed</span>
</span>
@if (showUnchanged()) {
<span class="delta-badge delta-badge--unchanged">
<span class="delta-badge__icon">=</span>
<span class="delta-badge__count">{{ summary()?.unchanged ?? 0 }}</span>
<span class="delta-badge__label">unchanged</span>
</span>
}
</div>
<div class="delta-summary-strip__total">
<span class="delta-summary-strip__total-label">Total findings:</span>
<span class="delta-summary-strip__total-count">{{ totalCount() }}</span>
</div>
</div>
`,
styles: [`
.delta-summary-strip {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-secondary, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
}
.delta-summary-strip__counts {
display: flex;
gap: 0.75rem;
}
.delta-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.15s, transform 0.15s;
}
.delta-badge:hover:not(.empty) { transform: scale(1.02); }
.delta-badge.empty { opacity: 0.5; }
.delta-badge__icon {
font-weight: 700;
font-size: 1rem;
}
.delta-badge__count {
font-variant-numeric: tabular-nums;
min-width: 1.5ch;
text-align: center;
}
.delta-badge__label {
font-size: 0.75rem;
font-weight: 400;
}
.delta-badge--added {
background: #dcfce7;
color: #15803d;
border: 1px solid #86efac;
}
.delta-badge--removed {
background: #fee2e2;
color: #dc2626;
border: 1px solid #fca5a5;
}
.delta-badge--changed {
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.delta-badge--unchanged {
background: #f3f4f6;
color: #6b7280;
border: 1px solid #d1d5db;
}
.delta-summary-strip__total {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.delta-summary-strip__total-label { color: var(--text-muted, #6b7280); }
.delta-summary-strip__total-count { font-weight: 600; }
`],
})
export class DeltaSummaryStripComponent {
readonly summary = input<DeltaSummary | null>(null);
readonly showUnchanged = input(false);
readonly totalCount = computed(() => {
const s = this.summary();
if (!s) return 0;
return s.added + s.removed + s.changed + (this.showUnchanged() ? s.unchanged : 0);
});
}

View File

@@ -0,0 +1,107 @@
<div class="envelope-hashes" *ngIf="envelope() as env">
<div class="envelope-hashes__header">
<mat-icon>fingerprint</mat-icon>
<span class="title">Content-Addressed Hashes</span>
<button
mat-icon-button
(click)="copyAllHashes()"
matTooltip="Copy all hashes"
>
<mat-icon>content_copy</mat-icon>
</button>
</div>
<div class="envelope-hashes__list">
<!-- Payload Hash -->
<div class="hash-row">
<div class="hash-label">
<mat-icon
[class]="getVerificationClass(env.payloadHash)"
[matTooltip]="env.payloadHash?.verified ? 'Verified' : 'Not verified'"
>
{{ getVerificationIcon(env.payloadHash) }}
</mat-icon>
<span>{{ env.payloadHash?.label || 'Payload' }}</span>
</div>
<div class="hash-value">
<code>{{ getHashDisplay(env.payloadHash) }}</code>
<button
mat-icon-button
(click)="copyHash(env.payloadHash)"
matTooltip="Copy hash"
>
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
<!-- Signature Hash -->
<div class="hash-row" *ngIf="env.signatureHash">
<div class="hash-label">
<mat-icon
[class]="getVerificationClass(env.signatureHash)"
[matTooltip]="env.signatureHash?.verified ? 'Verified' : 'Not verified'"
>
{{ getVerificationIcon(env.signatureHash) }}
</mat-icon>
<span>{{ env.signatureHash?.label || 'Signature' }}</span>
</div>
<div class="hash-value">
<code>{{ getHashDisplay(env.signatureHash) }}</code>
<button
mat-icon-button
(click)="copyHash(env.signatureHash)"
matTooltip="Copy hash"
>
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
<!-- Envelope Hash -->
<div class="hash-row">
<div class="hash-label">
<mat-icon
[class]="getVerificationClass(env.envelopeHash)"
[matTooltip]="env.envelopeHash?.verified ? 'Verified' : 'Not verified'"
>
{{ getVerificationIcon(env.envelopeHash) }}
</mat-icon>
<span>{{ env.envelopeHash?.label || 'Envelope' }}</span>
</div>
<div class="hash-value">
<code>{{ getHashDisplay(env.envelopeHash) }}</code>
<button
mat-icon-button
(click)="copyHash(env.envelopeHash)"
matTooltip="Copy hash"
>
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
<!-- Attestation Hash -->
<div class="hash-row" *ngIf="env.attestationHash">
<div class="hash-label">
<mat-icon
[class]="getVerificationClass(env.attestationHash)"
[matTooltip]="env.attestationHash?.verified ? 'Verified' : 'Not verified'"
>
{{ getVerificationIcon(env.attestationHash) }}
</mat-icon>
<span>{{ env.attestationHash?.label || 'Attestation' }}</span>
</div>
<div class="hash-value">
<code>{{ getHashDisplay(env.attestationHash) }}</code>
<button
mat-icon-button
(click)="copyHash(env.attestationHash)"
matTooltip="Copy hash"
>
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,119 @@
.envelope-hashes {
background: var(--surface-card, #f8fafc);
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 8px;
padding: 12px;
&__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color, #e2e8f0);
mat-icon {
color: var(--text-secondary, #64748b);
font-size: 18px;
width: 18px;
height: 18px;
}
.title {
flex: 1;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary, #64748b);
letter-spacing: 0.5px;
}
button {
width: 28px;
height: 28px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
&__list {
display: flex;
flex-direction: column;
gap: 8px;
}
}
.hash-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: var(--surface-ground, #ffffff);
border-radius: 4px;
.hash-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary, #1e293b);
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
&.verified {
color: var(--green-600, #16a34a);
}
&.invalid {
color: var(--red-600, #dc2626);
}
&.pending {
color: var(--amber-600, #d97706);
}
&.unknown {
color: var(--gray-400, #9ca3af);
}
}
}
.hash-value {
display: flex;
align-items: center;
gap: 4px;
code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 11px;
color: var(--text-secondary, #64748b);
background: var(--surface-hover, #f1f5f9);
padding: 2px 6px;
border-radius: 3px;
}
button {
width: 24px;
height: 24px;
opacity: 0;
transition: opacity 0.15s;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
}
&:hover .hash-value button {
opacity: 1;
}
}

View File

@@ -0,0 +1,93 @@
// -----------------------------------------------------------------------------
// envelope-hashes.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-18 — EnvelopeHashesComponent: display content-addressed hashes
// -----------------------------------------------------------------------------
import { Component, ChangeDetectionStrategy, input, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Clipboard } from '@angular/cdk/clipboard';
export interface EnvelopeHash {
label: string;
algorithm: 'sha256' | 'sha384' | 'sha512';
digest: string;
verified?: boolean;
verificationTimestamp?: Date;
}
export interface EvidenceEnvelope {
payloadHash: EnvelopeHash;
signatureHash?: EnvelopeHash;
envelopeHash: EnvelopeHash;
attestationHash?: EnvelopeHash;
}
@Component({
selector: 'stella-envelope-hashes',
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatTooltipModule,
MatButtonModule
],
templateUrl: './envelope-hashes.component.html',
styleUrls: ['./envelope-hashes.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EnvelopeHashesComponent {
private readonly snackBar = inject(MatSnackBar);
private readonly clipboard = inject(Clipboard);
envelope = input<EvidenceEnvelope>();
getHashDisplay(hash: EnvelopeHash | undefined): string {
if (!hash) return '—';
const short = hash.digest.length > 16
? `${hash.digest.slice(0, 8)}...${hash.digest.slice(-8)}`
: hash.digest;
return `${hash.algorithm}:${short}`;
}
getVerificationIcon(hash: EnvelopeHash | undefined): string {
if (!hash) return 'help_outline';
if (hash.verified === undefined) return 'pending';
return hash.verified ? 'verified' : 'gpp_bad';
}
getVerificationClass(hash: EnvelopeHash | undefined): string {
if (!hash) return 'unknown';
if (hash.verified === undefined) return 'pending';
return hash.verified ? 'verified' : 'invalid';
}
copyHash(hash: EnvelopeHash | undefined): void {
if (!hash) return;
const fullHash = `${hash.algorithm}:${hash.digest}`;
this.clipboard.copy(fullHash);
this.snackBar.open(`${hash.label} hash copied`, 'OK', { duration: 2000 });
}
copyAllHashes(): void {
const env = this.envelope();
if (!env) return;
const hashes = [
env.payloadHash,
env.signatureHash,
env.envelopeHash,
env.attestationHash
]
.filter((h): h is EnvelopeHash => h !== undefined)
.map(h => `${h.label}: ${h.algorithm}:${h.digest}`)
.join('\n');
this.clipboard.copy(hashes);
this.snackBar.open('All hashes copied', 'OK', { duration: 2000 });
}
}

View File

@@ -0,0 +1,60 @@
<div class="export-actions">
<!-- Copy Replay Command -->
<button
mat-stroked-button
(click)="copyReplayCommand()"
matTooltip="Copy command to reproduce this comparison offline"
class="export-btn"
>
<mat-icon>terminal</mat-icon>
<span>Copy Replay</span>
</button>
<!-- Copy Audit Bundle -->
<button
mat-stroked-button
(click)="copyAuditBundle()"
matTooltip="Copy reproducibility metadata for audit records"
class="export-btn"
>
<mat-icon>content_copy</mat-icon>
<span>Copy Audit Bundle</span>
</button>
<!-- Export Menu -->
<button
mat-flat-button
color="primary"
[matMenuTriggerFor]="exportMenu"
[disabled]="exporting()"
class="export-btn export-btn--primary"
>
<mat-spinner *ngIf="exporting()" diameter="18"></mat-spinner>
<mat-icon *ngIf="!exporting()">download</mat-icon>
<span>{{ exporting() ? 'Exporting...' : 'Export' }}</span>
</button>
<mat-menu #exportMenu="matMenu">
<button mat-menu-item (click)="requestExport('json')">
<mat-icon>{{ getFormatIcon('json') }}</mat-icon>
<span>{{ getFormatLabel('json') }}</span>
</button>
<button mat-menu-item (click)="requestExport('markdown')">
<mat-icon>{{ getFormatIcon('markdown') }}</mat-icon>
<span>{{ getFormatLabel('markdown') }}</span>
</button>
<button mat-menu-item (click)="requestExport('pdf')">
<mat-icon>{{ getFormatIcon('pdf') }}</mat-icon>
<span>{{ getFormatLabel('pdf') }}</span>
</button>
<button mat-menu-item (click)="requestExport('csv')">
<mat-icon>{{ getFormatIcon('csv') }}</mat-icon>
<span>{{ getFormatLabel('csv') }}</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="requestExport('sarif')">
<mat-icon>{{ getFormatIcon('sarif') }}</mat-icon>
<span>{{ getFormatLabel('sarif') }}</span>
</button>
</mat-menu>
</div>

View File

@@ -0,0 +1,41 @@
.export-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.export-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
mat-spinner {
margin-right: 4px;
}
&--primary {
min-width: 100px;
}
}
// Responsive: stack buttons on small screens
@media (max-width: 600px) {
.export-actions {
flex-direction: column;
align-items: stretch;
.export-btn {
width: 100%;
justify-content: center;
}
}
}

View File

@@ -0,0 +1,119 @@
// -----------------------------------------------------------------------------
// export-actions.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-20 — ExportActionsComponent: copy replay command, download evidence pack
// Task: SDIFF-26 — "Copy audit bundle" one-click export as JSON attachment
// -----------------------------------------------------------------------------
import { Component, ChangeDetectionStrategy, input, output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Clipboard } from '@angular/cdk/clipboard';
export interface ExportContext {
baseDigest: string;
targetDigest: string;
feedSnapshotHash: string;
policyHash: string;
sessionId: string;
}
export type ExportFormat = 'json' | 'markdown' | 'pdf' | 'csv' | 'sarif';
@Component({
selector: 'stella-export-actions',
standalone: true,
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatTooltipModule,
MatProgressSpinnerModule
],
templateUrl: './export-actions.component.html',
styleUrls: ['./export-actions.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExportActionsComponent {
private readonly snackBar = inject(MatSnackBar);
private readonly clipboard = inject(Clipboard);
context = input<ExportContext>();
exporting = input(false);
exportRequested = output<ExportFormat>();
copyReplayCommand(): void {
const ctx = this.context();
if (!ctx) {
this.snackBar.open('No comparison context available', 'OK', { duration: 2000 });
return;
}
const command = `stellaops smart-diff replay \\
--base ${ctx.baseDigest} \\
--target ${ctx.targetDigest} \\
--feed-snapshot ${ctx.feedSnapshotHash} \\
--policy ${ctx.policyHash}`;
this.clipboard.copy(command);
this.snackBar.open('Replay command copied to clipboard', 'OK', { duration: 2000 });
}
copyAuditBundle(): void {
const ctx = this.context();
if (!ctx) {
this.snackBar.open('No comparison context available', 'OK', { duration: 2000 });
return;
}
const bundle = {
version: '1.0',
exportedAt: new Date().toISOString(),
comparison: {
base: ctx.baseDigest,
target: ctx.targetDigest,
sessionId: ctx.sessionId
},
reproducibility: {
feedSnapshot: ctx.feedSnapshotHash,
policy: ctx.policyHash,
replayCommand: `stellaops smart-diff replay --base ${ctx.baseDigest} --target ${ctx.targetDigest} --feed-snapshot ${ctx.feedSnapshotHash} --policy ${ctx.policyHash}`
}
};
this.clipboard.copy(JSON.stringify(bundle, null, 2));
this.snackBar.open('Audit bundle copied to clipboard', 'OK', { duration: 2000 });
}
requestExport(format: ExportFormat): void {
this.exportRequested.emit(format);
}
getFormatIcon(format: ExportFormat): string {
const icons: Record<ExportFormat, string> = {
json: 'data_object',
markdown: 'description',
pdf: 'picture_as_pdf',
csv: 'table_chart',
sarif: 'security'
};
return icons[format];
}
getFormatLabel(format: ExportFormat): string {
const labels: Record<ExportFormat, string> = {
json: 'JSON Report',
markdown: 'Markdown',
pdf: 'PDF Document',
csv: 'CSV Spreadsheet',
sarif: 'SARIF (Static Analysis)'
};
return labels[format];
}
}

View File

@@ -0,0 +1,46 @@
<div class="graph-mini-map">
<div class="mini-map__header">
<span class="mini-map__title">Graph Overview</span>
<div class="mini-map__stats">
<span class="stat" matTooltip="Visible nodes">
{{ visibleNodes() }}/{{ totalNodes() }}
</span>
</div>
</div>
<canvas
#miniMapCanvas
class="mini-map__canvas"
(click)="onCanvasClick($event)"
></canvas>
<div class="mini-map__legend">
<span class="legend-item legend-item--entry">
<span class="dot"></span> Entry ({{ nodeStats().entries }})
</span>
<span class="legend-item legend-item--sink">
<span class="dot"></span> Sink ({{ nodeStats().sinks }})
</span>
<span class="legend-item legend-item--changed">
<span class="dot"></span> Changed ({{ nodeStats().changed }})
</span>
</div>
<div class="mini-map__controls">
<mat-slide-toggle
[checked]="changedNeighborhoodOnly()"
(change)="toggleNeighborhood()"
labelPosition="before"
>
Changed neighborhood only
</mat-slide-toggle>
<button
mat-icon-button
(click)="onResetView()"
matTooltip="Reset view"
>
<mat-icon>fit_screen</mat-icon>
</button>
</div>
</div>

View File

@@ -0,0 +1,87 @@
.graph-mini-map {
display: flex;
flex-direction: column;
background: var(--surface-card, #ffffff);
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 8px;
padding: 12px;
gap: 8px;
}
.mini-map__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.mini-map__title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary, #64748b);
letter-spacing: 0.5px;
}
.mini-map__stats {
.stat {
font-size: 12px;
color: var(--text-secondary, #64748b);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
}
.mini-map__canvas {
width: 100%;
height: 120px;
border-radius: 4px;
cursor: crosshair;
border: 1px solid var(--border-color, #e2e8f0);
}
.mini-map__legend {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary, #64748b);
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
&--entry .dot {
background: #22c55e;
}
&--sink .dot {
background: #ef4444;
}
&--changed .dot {
background: #f59e0b;
}
}
.mini-map__controls {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
border-top: 1px solid var(--border-color, #e2e8f0);
mat-slide-toggle {
font-size: 12px;
::ng-deep .mdc-form-field {
font-size: 12px;
}
}
}

View File

@@ -0,0 +1,176 @@
// -----------------------------------------------------------------------------
// graph-mini-map.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-30 — "Changed neighborhood only" default with mini-map for large graphs
// -----------------------------------------------------------------------------
import { Component, ChangeDetectionStrategy, input, output, computed, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
export interface GraphNode {
id: string;
label: string;
type: 'entry' | 'sink' | 'intermediate' | 'changed';
x?: number;
y?: number;
}
export interface GraphViewport {
x: number;
y: number;
width: number;
height: number;
}
@Component({
selector: 'stella-graph-mini-map',
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
MatSlideToggleModule,
MatTooltipModule
],
templateUrl: './graph-mini-map.component.html',
styleUrls: ['./graph-mini-map.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GraphMiniMapComponent implements AfterViewInit, OnDestroy {
@ViewChild('miniMapCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
nodes = input<GraphNode[]>([]);
viewport = input<GraphViewport>({ x: 0, y: 0, width: 100, height: 100 });
changedNeighborhoodOnly = input(true);
totalNodes = input(0);
visibleNodes = input(0);
viewportChange = output<GraphViewport>();
neighborhoodToggle = output<boolean>();
resetView = output<void>();
private ctx: CanvasRenderingContext2D | null = null;
private resizeObserver: ResizeObserver | null = null;
nodeStats = computed(() => {
const all = this.nodes();
return {
total: all.length,
changed: all.filter(n => n.type === 'changed').length,
entries: all.filter(n => n.type === 'entry').length,
sinks: all.filter(n => n.type === 'sink').length
};
});
ngAfterViewInit(): void {
const canvas = this.canvasRef?.nativeElement;
if (canvas) {
this.ctx = canvas.getContext('2d');
this.resizeObserver = new ResizeObserver(() => this.draw());
this.resizeObserver.observe(canvas);
this.draw();
}
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
}
private draw(): void {
const canvas = this.canvasRef?.nativeElement;
if (!canvas || !this.ctx) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
const width = rect.width;
const height = rect.height;
// Clear
this.ctx.fillStyle = '#f8fafc';
this.ctx.fillRect(0, 0, width, height);
const nodes = this.nodes();
if (nodes.length === 0) return;
// Calculate bounds
const xs = nodes.map(n => n.x ?? 0);
const ys = nodes.map(n => n.y ?? 0);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const graphWidth = maxX - minX || 1;
const graphHeight = maxY - minY || 1;
// Scale to fit
const padding = 8;
const scaleX = (width - padding * 2) / graphWidth;
const scaleY = (height - padding * 2) / graphHeight;
const scale = Math.min(scaleX, scaleY);
// Draw nodes
for (const node of nodes) {
const x = padding + ((node.x ?? 0) - minX) * scale;
const y = padding + ((node.y ?? 0) - minY) * scale;
this.ctx.beginPath();
this.ctx.arc(x, y, 3, 0, Math.PI * 2);
switch (node.type) {
case 'entry':
this.ctx.fillStyle = '#22c55e';
break;
case 'sink':
this.ctx.fillStyle = '#ef4444';
break;
case 'changed':
this.ctx.fillStyle = '#f59e0b';
break;
default:
this.ctx.fillStyle = '#94a3b8';
}
this.ctx.fill();
}
// Draw viewport rectangle
const vp = this.viewport();
const vpX = padding + (vp.x - minX) * scale;
const vpY = padding + (vp.y - minY) * scale;
const vpW = vp.width * scale;
const vpH = vp.height * scale;
this.ctx.strokeStyle = '#3b82f6';
this.ctx.lineWidth = 2;
this.ctx.strokeRect(vpX, vpY, vpW, vpH);
this.ctx.fillStyle = 'rgba(59, 130, 246, 0.1)';
this.ctx.fillRect(vpX, vpY, vpW, vpH);
}
onCanvasClick(event: MouseEvent): void {
const canvas = this.canvasRef?.nativeElement;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// TODO: Convert to graph coordinates and emit viewport change
console.log('Mini-map clicked at', x, y);
}
toggleNeighborhood(): void {
this.neighborhoodToggle.emit(!this.changedNeighborhoodOnly());
}
onResetView(): void {
this.resetView.emit();
}
}

View File

@@ -0,0 +1,188 @@
// -----------------------------------------------------------------------------
// items-pane.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-13 — ItemsPaneComponent with virtual scrolling
// Task: SDIFF-14 — Priority score display with color-coded severity
// Task: SDIFF-23 — Micro-interaction: hover badge explaining "why it changed"
// -----------------------------------------------------------------------------
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { DeltaItem, DeltaStatus } from '../services/delta-compute.service';
@Component({
selector: 'app-items-pane',
standalone: true,
imports: [CommonModule, ScrollingModule],
template: `
<div class="items-pane">
<div class="items-pane__header">
<span class="items-pane__title">Findings ({{ items()?.length ?? 0 }})</span>
<input
type="search"
class="items-pane__search"
placeholder="Search CVE or package..."
(input)="onSearch($event)" />
</div>
<cdk-virtual-scroll-viewport itemSize="56" class="items-pane__viewport">
<div
*cdkVirtualFor="let item of items(); trackBy: trackItem"
class="items-pane__item"
[class.selected]="selectedItem()?.id === item.id"
[class]="'items-pane__item--' + item.status"
(click)="itemSelect.emit(item)"
(keydown.enter)="itemSelect.emit(item)"
tabindex="0"
role="option"
[attr.aria-selected]="selectedItem()?.id === item.id">
<div class="items-pane__item-status">
<span class="items-pane__status-icon">{{ statusIcon(item.status) }}</span>
</div>
<div class="items-pane__item-content">
<div class="items-pane__item-primary">
<span class="items-pane__cve">{{ item.finding.cveId }}</span>
<span class="items-pane__severity" [class]="item.finding.severity">
{{ item.finding.severity | uppercase }}
</span>
</div>
<div class="items-pane__item-secondary">
<span class="items-pane__package">{{ item.finding.packageName }}</span>
</div>
</div>
<div class="items-pane__item-score">
<span
class="items-pane__priority"
[class]="priorityClass(item.finding.priorityScore)"
[title]="'Priority: ' + item.finding.priorityScore">
{{ item.finding.priorityScore }}
</span>
</div>
@if (item.changeReason && explainMode()) {
<div class="items-pane__change-reason">
{{ item.changeReason }}
</div>
}
</div>
</cdk-virtual-scroll-viewport>
</div>
`,
styles: [`
.items-pane { display: flex; flex-direction: column; height: 100%; }
.items-pane__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
gap: 0.75rem;
}
.items-pane__title {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
color: var(--text-muted, #6b7280);
white-space: nowrap;
}
.items-pane__search {
flex: 1;
max-width: 200px;
padding: 0.375rem 0.625rem;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
}
.items-pane__viewport { flex: 1; }
.items-pane__item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
cursor: pointer;
transition: background 0.15s;
}
.items-pane__item:hover { background: var(--bg-hover, #f3f4f6); }
.items-pane__item.selected { background: #eff6ff; border-left: 3px solid var(--primary, #3b82f6); }
.items-pane__item--added { border-left: 3px solid #22c55e; }
.items-pane__item--removed { border-left: 3px solid #ef4444; }
.items-pane__item--changed { border-left: 3px solid #f59e0b; }
.items-pane__item-status { width: 24px; text-align: center; }
.items-pane__status-icon { font-size: 1rem; }
.items-pane__item-content { flex: 1; min-width: 0; }
.items-pane__item-primary { display: flex; align-items: center; gap: 0.5rem; }
.items-pane__cve { font-weight: 500; font-size: 0.875rem; }
.items-pane__severity {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-weight: 600;
}
.items-pane__severity.critical { background: #7f1d1d; color: white; }
.items-pane__severity.high { background: #dc2626; color: white; }
.items-pane__severity.medium { background: #f59e0b; color: white; }
.items-pane__severity.low { background: #3b82f6; color: white; }
.items-pane__severity.none { background: #6b7280; color: white; }
.items-pane__item-secondary { font-size: 0.75rem; color: var(--text-muted, #6b7280); }
.items-pane__package { font-family: monospace; }
.items-pane__item-score { text-align: right; }
.items-pane__priority {
display: inline-block;
min-width: 2rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
}
.items-pane__priority.critical { background: #fee2e2; color: #dc2626; }
.items-pane__priority.high { background: #fef3c7; color: #92400e; }
.items-pane__priority.medium { background: #fef9c3; color: #854d0e; }
.items-pane__priority.low { background: #dbeafe; color: #1d4ed8; }
.items-pane__change-reason {
font-size: 0.6875rem;
color: #0369a1;
background: #f0f9ff;
padding: 0.25rem 0.5rem;
border-radius: 4px;
margin-top: 0.25rem;
}
`],
})
export class ItemsPaneComponent {
readonly items = input<DeltaItem[]>([]);
readonly selectedItem = input<DeltaItem | null>(null);
readonly explainMode = input(false);
readonly itemSelect = output<DeltaItem>();
private searchTerm = '';
statusIcon(status: DeltaStatus): string {
switch (status) {
case 'added': return '+';
case 'removed': return '';
case 'changed': return '~';
default: return '=';
}
}
priorityClass(score: number): string {
if (score >= 9) return 'critical';
if (score >= 7) return 'high';
if (score >= 4) return 'medium';
return 'low';
}
trackItem(_: number, item: DeltaItem): string {
return item.id;
}
onSearch(event: Event): void {
this.searchTerm = (event.target as HTMLInputElement).value;
}
}

View File

@@ -0,0 +1,199 @@
// -----------------------------------------------------------------------------
// proof-pane.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-15 — ProofPaneComponent container for evidence details
// Task: SDIFF-16 — WitnessPathComponent: entry→sink call path visualization
// Task: SDIFF-17 — VexMergeExplanationComponent
// Task: SDIFF-18 — EnvelopeHashesComponent
// -----------------------------------------------------------------------------
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DeltaItem } from '../services/delta-compute.service';
@Component({
selector: 'app-proof-pane',
standalone: true,
imports: [CommonModule],
template: `
<div class="proof-pane">
<div class="proof-pane__header">
<span class="proof-pane__title">Evidence</span>
</div>
@if (!item()) {
<div class="proof-pane__empty">
<span class="proof-pane__empty-icon">📋</span>
<p>Select a finding to view evidence</p>
</div>
} @else {
<div class="proof-pane__content">
<!-- Finding Summary -->
<section class="proof-section">
<h3 class="proof-section__title">Finding</h3>
<div class="proof-section__body">
<div class="proof-field">
<span class="proof-field__label">CVE</span>
<span class="proof-field__value proof-field__value--mono">{{ item()?.finding.cveId }}</span>
</div>
<div class="proof-field">
<span class="proof-field__label">Package</span>
<span class="proof-field__value proof-field__value--mono">{{ item()?.finding.packageName }}</span>
</div>
<div class="proof-field">
<span class="proof-field__label">Severity</span>
<span class="proof-field__value proof-field__value--badge" [class]="item()?.finding.severity">
{{ item()?.finding.severity | uppercase }}
</span>
</div>
</div>
</section>
<!-- Status Comparison -->
<section class="proof-section">
<h3 class="proof-section__title">Status Change</h3>
<div class="proof-section__body proof-section__body--comparison">
@if (item()?.baseline) {
<div class="proof-comparison__side proof-comparison__side--baseline">
<span class="proof-comparison__label">Baseline</span>
<span class="proof-comparison__status">{{ item()?.baseline?.status }}</span>
<span class="proof-comparison__confidence">
{{ (item()?.baseline?.confidence ?? 0) * 100 | number:'1.0-0' }}% confidence
</span>
</div>
<div class="proof-comparison__arrow">→</div>
}
<div class="proof-comparison__side proof-comparison__side--current">
<span class="proof-comparison__label">Current</span>
<span class="proof-comparison__status">{{ item()?.current.status }}</span>
<span class="proof-comparison__confidence">
{{ (item()?.current.confidence ?? 0) * 100 | number:'1.0-0' }}% confidence
</span>
</div>
</div>
</section>
@if (item()?.changeReason) {
<section class="proof-section">
<h3 class="proof-section__title">Why It Changed</h3>
<div class="proof-section__body">
<p class="proof-explanation" [class.explain-mode]="explainMode()">
{{ item()?.changeReason }}
</p>
@if (explainMode()) {
<p class="proof-explanation--plain">
{{ plainExplanation() }}
</p>
}
</div>
</section>
}
<!-- Evidence Details based on role -->
@if (role() === 'audit') {
<section class="proof-section">
<h3 class="proof-section__title">Audit Trail</h3>
<div class="proof-section__body">
<div class="proof-field">
<span class="proof-field__label">Evidence Hash</span>
<code class="proof-field__value proof-field__value--mono">sha256:abc123...</code>
</div>
<div class="proof-field">
<span class="proof-field__label">Timestamp</span>
<span class="proof-field__value">{{ now | date:'medium' }}</span>
</div>
</div>
</section>
}
</div>
}
</div>
`,
styles: [`
.proof-pane { display: flex; flex-direction: column; height: 100%; }
.proof-pane__header {
padding: 0.75rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.proof-pane__title {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
color: var(--text-muted, #6b7280);
}
.proof-pane__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--text-muted, #6b7280);
}
.proof-pane__empty-icon { font-size: 2rem; opacity: 0.5; }
.proof-pane__content { flex: 1; overflow-y: auto; padding: 0.75rem; }
.proof-section { margin-bottom: 1rem; }
.proof-section__title {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted, #6b7280);
margin-bottom: 0.5rem;
}
.proof-section__body {
background: var(--bg-primary, #f9fafb);
border-radius: 6px;
padding: 0.75rem;
}
.proof-section__body--comparison {
display: flex;
align-items: center;
gap: 0.75rem;
}
.proof-field { display: flex; justify-content: space-between; margin-bottom: 0.375rem; }
.proof-field:last-child { margin-bottom: 0; }
.proof-field__label { font-size: 0.75rem; color: var(--text-muted, #6b7280); }
.proof-field__value { font-size: 0.8125rem; font-weight: 500; }
.proof-field__value--mono { font-family: monospace; font-size: 0.75rem; }
.proof-field__value--badge {
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.625rem;
}
.proof-field__value--badge.critical { background: #7f1d1d; color: white; }
.proof-field__value--badge.high { background: #dc2626; color: white; }
.proof-field__value--badge.medium { background: #f59e0b; color: white; }
.proof-field__value--badge.low { background: #3b82f6; color: white; }
.proof-comparison__side { flex: 1; text-align: center; }
.proof-comparison__label { display: block; font-size: 0.6875rem; color: var(--text-muted, #6b7280); }
.proof-comparison__status { display: block; font-weight: 600; font-size: 0.875rem; }
.proof-comparison__confidence { display: block; font-size: 0.6875rem; color: var(--text-muted, #6b7280); }
.proof-comparison__arrow { font-size: 1.25rem; color: var(--text-muted, #6b7280); }
.proof-explanation { font-size: 0.8125rem; line-height: 1.5; margin: 0; }
.proof-explanation--plain {
margin-top: 0.5rem;
padding: 0.5rem;
background: #f0f9ff;
border-radius: 4px;
font-size: 0.8125rem;
color: #0369a1;
}
`],
})
export class ProofPaneComponent {
readonly item = input<DeltaItem | null>(null);
readonly explainMode = input(false);
readonly role = input<'developer' | 'security' | 'audit'>('developer');
readonly now = new Date();
readonly plainExplanation = computed(() => {
const reason = this.item()?.changeReason;
if (!reason) return '';
// Simple jargon expansion for explain mode
return reason
.replace(/VEX/g, 'Vulnerability Exploitability eXchange (a statement about whether a vulnerability is actually exploitable)')
.replace(/SBOM/g, 'Software Bill of Materials (list of software components)')
.replace(/CVE/g, 'Common Vulnerabilities and Exposures (a standardized vulnerability identifier)');
});
}

View File

@@ -0,0 +1,112 @@
// -----------------------------------------------------------------------------
// three-pane-layout.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-11 — ThreePaneLayoutComponent responsive container
// -----------------------------------------------------------------------------
import { Component, input, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DeltaComputeService, DeltaCategory, DeltaItem } from '../services/delta-compute.service';
import { CategoriesPaneComponent } from './categories-pane.component';
import { ItemsPaneComponent } from './items-pane.component';
import { ProofPaneComponent } from './proof-pane.component';
@Component({
selector: 'app-three-pane-layout',
standalone: true,
imports: [CommonModule, CategoriesPaneComponent, ItemsPaneComponent, ProofPaneComponent],
template: `
<div class="three-pane-layout" [class.three-pane-layout--explain]="explainMode()">
<!-- Categories Pane -->
<div class="three-pane-layout__pane three-pane-layout__pane--categories">
<app-categories-pane
[counts]="categoryCounts()"
[selectedCategory]="selectedCategory()"
(categorySelect)="onCategorySelect($event)" />
</div>
<!-- Items Pane -->
<div class="three-pane-layout__pane three-pane-layout__pane--items">
<app-items-pane
[items]="filteredItems()"
[selectedItem]="selectedItem()"
[explainMode]="explainMode()"
(itemSelect)="onItemSelect($event)" />
</div>
<!-- Proof Pane -->
<div class="three-pane-layout__pane three-pane-layout__pane--proof">
<app-proof-pane
[item]="selectedItem()"
[explainMode]="explainMode()"
[role]="role()" />
</div>
</div>
`,
styles: [`
.three-pane-layout {
display: grid;
grid-template-columns: 200px 1fr 400px;
gap: 1rem;
flex: 1;
overflow: hidden;
min-height: 0;
}
@media (max-width: 1200px) {
.three-pane-layout {
grid-template-columns: 180px 1fr 320px;
}
}
@media (max-width: 900px) {
.three-pane-layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}
.three-pane-layout__pane--categories {
max-height: 120px;
overflow-x: auto;
}
.three-pane-layout__pane--proof {
max-height: 300px;
}
}
.three-pane-layout__pane {
background: var(--bg-secondary, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.three-pane-layout--explain .three-pane-layout__pane {
border-color: #bfdbfe;
}
`],
})
export class ThreePaneLayoutComponent {
readonly role = input<'developer' | 'security' | 'audit'>('developer');
readonly explainMode = input(false);
private readonly deltaService = inject(DeltaComputeService);
private readonly _selectedCategory = signal<DeltaCategory | null>(null);
private readonly _selectedItem = signal<DeltaItem | null>(null);
readonly categoryCounts = this.deltaService.categoryCounts;
readonly filteredItems = this.deltaService.filteredItems;
readonly selectedCategory = computed(() => this._selectedCategory());
readonly selectedItem = computed(() => this._selectedItem());
onCategorySelect(category: DeltaCategory | null): void {
this._selectedCategory.set(category);
this._selectedItem.set(null);
if (category) {
this.deltaService.setFilter({ categories: [category] });
} else {
this.deltaService.clearFilter();
}
}
onItemSelect(item: DeltaItem): void {
this._selectedItem.set(item);
}
}

View File

@@ -0,0 +1,175 @@
// -----------------------------------------------------------------------------
// trust-indicators.component.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-06 — TrustIndicatorsComponent showing determinism hash, policy version, feed snapshot
// -----------------------------------------------------------------------------
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScanDigest } from '../services/compare.service';
@Component({
selector: 'app-trust-indicators',
standalone: true,
imports: [CommonModule],
template: `
<div class="trust-indicators">
<div class="trust-indicators__title">Trust Indicators</div>
<div class="trust-indicators__grid">
<!-- Determinism Hash -->
<div class="trust-indicator">
<span class="trust-indicator__label">Determinism Hash</span>
<div class="trust-indicator__value trust-indicator__value--mono">
<span class="trust-indicator__hash">{{ shortHash(current()?.determinismHash) }}</span>
<button class="trust-indicator__copy" (click)="copyHash()" title="Copy full hash">📋</button>
</div>
</div>
<!-- Signature Status -->
<div class="trust-indicator">
<span class="trust-indicator__label">Signature</span>
<div class="trust-indicator__value" [class]="signatureClass()">
<span class="trust-indicator__icon">{{ signatureIcon() }}</span>
<span>{{ signatureText() }}</span>
</div>
</div>
<!-- Policy Version -->
<div class="trust-indicator">
<span class="trust-indicator__label">Policy Version</span>
<div class="trust-indicator__value">
<span>{{ current()?.policyVersion }}</span>
@if (policyDrift()) {
<span class="trust-indicator__drift" title="Policy changed since baseline">⚠️ Drift</span>
}
</div>
</div>
<!-- Feed Snapshot -->
<div class="trust-indicator">
<span class="trust-indicator__label">Feed Snapshot</span>
<div class="trust-indicator__value trust-indicator__value--mono">
{{ shortHash(current()?.feedSnapshotId) }}
</div>
</div>
</div>
@if (policyDrift()) {
<div class="trust-indicators__warning" role="alert">
<span class="trust-indicators__warning-icon">⚠️</span>
<span>Policy version differs between scans. Results may not be directly comparable.</span>
</div>
}
</div>
`,
styles: [`
.trust-indicators {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg-secondary, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
flex: 1;
}
.trust-indicators__title {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.trust-indicators__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
}
.trust-indicator {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.trust-indicator__label {
font-size: 0.6875rem;
color: var(--text-muted, #6b7280);
}
.trust-indicator__value {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
}
.trust-indicator__value--mono { font-family: monospace; font-size: 0.75rem; }
.trust-indicator__hash { max-width: 100px; overflow: hidden; text-overflow: ellipsis; }
.trust-indicator__copy {
padding: 0.125rem 0.25rem;
border: none;
background: transparent;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
}
.trust-indicator__copy:hover { opacity: 1; }
.trust-indicator__icon { font-size: 0.875rem; }
.trust-indicator__value.valid { color: #15803d; }
.trust-indicator__value.invalid { color: #dc2626; }
.trust-indicator__value.missing { color: #92400e; }
.trust-indicator__value.unknown { color: #6b7280; }
.trust-indicator__drift {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: #fef3c7;
color: #92400e;
border-radius: 4px;
}
.trust-indicators__warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: #fef3c7;
border-radius: 4px;
font-size: 0.8125rem;
color: #92400e;
}
`],
})
export class TrustIndicatorsComponent {
readonly current = input<ScanDigest | null>(null);
readonly baseline = input<ScanDigest | null>(null);
readonly policyDrift = input(false);
readonly signatureClass = computed(() => this.current()?.signatureStatus ?? 'unknown');
readonly signatureIcon = computed(() => {
switch (this.current()?.signatureStatus) {
case 'valid': return '✓';
case 'invalid': return '✗';
case 'missing': return '?';
default: return '—';
}
});
readonly signatureText = computed(() => {
switch (this.current()?.signatureStatus) {
case 'valid': return 'Valid';
case 'invalid': return 'Invalid';
case 'missing': return 'Missing';
default: return 'Unknown';
}
});
shortHash(hash: string | undefined): string {
if (!hash) return '—';
return hash.slice(0, 12) + '...';
}
copyHash(): void {
const hash = this.current()?.determinismHash;
if (hash) {
navigator.clipboard.writeText(hash);
}
}
}

View File

@@ -0,0 +1,137 @@
// -----------------------------------------------------------------------------
// keyboard-navigation.directive.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-27 — Keyboard navigation: Tab/Arrow/Enter/Escape/C shortcuts
// Task: SDIFF-28 — ARIA labels and screen reader live regions
// -----------------------------------------------------------------------------
import { Directive, ElementRef, HostListener, output, inject, input, OnInit } from '@angular/core';
export interface KeyboardNavigationEvent {
action: 'next' | 'previous' | 'select' | 'escape' | 'copy' | 'export' | 'focus-categories' | 'focus-items' | 'focus-proof';
originalEvent: KeyboardEvent;
}
@Directive({
selector: '[stellaKeyboardNav]',
standalone: true
})
export class KeyboardNavigationDirective implements OnInit {
private readonly el = inject(ElementRef);
enabled = input(true, { alias: 'stellaKeyboardNav' });
navigationEvent = output<KeyboardNavigationEvent>();
ngOnInit(): void {
// Set tabindex if not already set
if (!this.el.nativeElement.hasAttribute('tabindex')) {
this.el.nativeElement.setAttribute('tabindex', '0');
}
}
@HostListener('keydown', ['$event'])
onKeyDown(event: KeyboardEvent): void {
if (!this.enabled()) return;
let action: KeyboardNavigationEvent['action'] | null = null;
switch (event.key) {
case 'ArrowDown':
case 'j': // Vim-style
action = 'next';
break;
case 'ArrowUp':
case 'k': // Vim-style
action = 'previous';
break;
case 'Enter':
case ' ': // Space
action = 'select';
break;
case 'Escape':
action = 'escape';
break;
case 'c':
case 'C':
if (event.ctrlKey || event.metaKey) {
// Don't intercept Ctrl+C (system copy)
return;
}
action = 'copy';
break;
case 'e':
case 'E':
if (event.ctrlKey || event.metaKey) {
action = 'export';
}
break;
case '1':
if (event.altKey) {
action = 'focus-categories';
}
break;
case '2':
if (event.altKey) {
action = 'focus-items';
}
break;
case '3':
if (event.altKey) {
action = 'focus-proof';
}
break;
default:
return;
}
if (action) {
event.preventDefault();
event.stopPropagation();
this.navigationEvent.emit({ action, originalEvent: event });
}
}
}
/**
* Helper functions for managing ARIA live regions
*/
export function announceToScreenReader(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
const liveRegion = document.getElementById('stella-sr-announcer') || createLiveRegion();
liveRegion.setAttribute('aria-live', priority);
liveRegion.textContent = message;
// Clear after announcement
setTimeout(() => {
liveRegion.textContent = '';
}, 1000);
}
function createLiveRegion(): HTMLElement {
const region = document.createElement('div');
region.id = 'stella-sr-announcer';
region.setAttribute('role', 'status');
region.setAttribute('aria-live', 'polite');
region.setAttribute('aria-atomic', 'true');
region.style.cssText = `
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
`;
document.body.appendChild(region);
return region;
}

View File

@@ -1,11 +1,28 @@
// Components
// Components - Material Design (subdirectory structure)
export * from './components/compare-view/compare-view.component';
export * from './components/actionables-panel/actionables-panel.component';
export * from './components/trust-indicators/trust-indicators.component';
export * from './components/witness-path/witness-path.component';
export * from './components/vex-merge-explanation/vex-merge-explanation.component';
export * from './components/baseline-rationale/baseline-rationale.component';
export * from './components/envelope-hashes/envelope-hashes.component';
export * from './components/export-actions/export-actions.component';
export * from './components/degraded-mode-banner/degraded-mode-banner.component';
export * from './components/graph-mini-map/graph-mini-map.component';
// Components - Inline templates (flat file structure)
export * from './components/baseline-selector.component';
export * from './components/delta-summary-strip.component';
export * from './components/three-pane-layout.component';
export * from './components/categories-pane.component';
export * from './components/items-pane.component';
export * from './components/proof-pane.component';
// Services
export * from './services/compare.service';
export * from './services/compare-export.service';
export * from './services/delta-compute.service';
export * from './services/user-preferences.service';
// Directives
export * from './directives/keyboard-navigation.directive';

View File

@@ -48,6 +48,43 @@ export interface CompareSession {
createdAt: string;
}
/**
* Compare target (current or baseline scan).
*/
export interface CompareTarget {
digest: string;
imageRef: string;
scanDate: string;
label: string;
}
/**
* Delta category for grouping changes.
*/
export type DeltaCategory = 'added' | 'removed' | 'changed' | 'unchanged';
/**
* Delta item representing a difference between scans.
*/
export interface DeltaItem {
id: string;
category: DeltaCategory;
component: string;
cve?: string;
currentSeverity?: string;
baselineSeverity?: string;
description: string;
}
/**
* Evidence pane for displaying side-by-side comparison.
*/
export interface EvidencePane {
digest: string;
data: Record<string, unknown>;
loading: boolean;
}
@Injectable({ providedIn: 'root' })
export class CompareService {
private readonly http = inject(HttpClient);
@@ -79,7 +116,7 @@ export class CompareService {
*/
getBaselineRecommendations(scanDigest: string): Observable<BaselineRationale> {
return this.http
.get<BaselineRationale>(\`\${this.baseUrl}/baselines/\${scanDigest}\`)
.get<BaselineRationale>(`${this.baseUrl}/baselines/${scanDigest}`)
.pipe(
catchError(() =>
of({
@@ -99,7 +136,7 @@ export class CompareService {
this._loading.set(true);
this._error.set(null);
return this.http.post<CompareSession>(\`\${this.baseUrl}/sessions\`, request).pipe(
return this.http.post<CompareSession>(`${this.baseUrl}/sessions`, request).pipe(
tap((session) => {
this._currentSession.set(session);
this._loading.set(false);
@@ -123,7 +160,7 @@ export class CompareService {
this._loading.set(true);
return this.http
.patch<CompareSession>(\`\${this.baseUrl}/sessions/\${session.id}/baseline\`, {
.patch<CompareSession>(`${this.baseUrl}/sessions/${session.id}/baseline`, {
baselineDigest,
})
.pipe(
@@ -143,7 +180,7 @@ export class CompareService {
* Fetches scan digest details.
*/
getScanDigest(digest: string): Observable<ScanDigest> {
return this.http.get<ScanDigest>(\`\${this.baseUrl}/scans/\${digest}\`);
return this.http.get<ScanDigest>(`${this.baseUrl}/scans/${digest}`);
}
/**
@@ -153,4 +190,34 @@ export class CompareService {
this._currentSession.set(null);
this._error.set(null);
}
/**
* Gets a compare target by digest.
*/
getTarget(digest: string): Observable<CompareTarget> {
return this.http.get<CompareTarget>(`${this.baseUrl}/targets/${digest}`);
}
/**
* Gets baseline rationale for a digest.
*/
getBaselineRationale(digest: string): Observable<BaselineRationale> {
return this.getBaselineRecommendations(digest);
}
/**
* Computes delta between current and baseline.
*/
computeDelta(currentDigest: string, baselineDigest: string): Observable<DeltaItem[]> {
return this.http.get<DeltaItem[]>(
`${this.baseUrl}/delta?current=${currentDigest}&baseline=${baselineDigest}`
);
}
/**
* Gets evidence for a specific delta item.
*/
getItemEvidence(itemId: string): Observable<EvidencePane[]> {
return this.http.get<EvidencePane[]>(`${this.baseUrl}/evidence/${itemId}`);
}
}

View File

@@ -138,7 +138,7 @@ export class DeltaComputeService {
this._loading.set(true);
const request$ = this.http
.get<DeltaResult>(\`\${this.baseUrl}/sessions/\${sessionId}/delta\`)
.get<DeltaResult>(`${this.baseUrl}/sessions/${sessionId}/delta`)
.pipe(
tap((result) => {
this._currentDelta.set(result);

View File

@@ -0,0 +1,135 @@
// -----------------------------------------------------------------------------
// user-preferences.service.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-22 — User preference persistence for role and panel states
// -----------------------------------------------------------------------------
import { Injectable, signal, computed, effect } from '@angular/core';
export type ViewRole = 'developer' | 'security' | 'audit';
export type ViewMode = 'side-by-side' | 'unified';
export interface ComparePreferences {
role: ViewRole;
viewMode: ViewMode;
explainMode: boolean;
showUnchanged: boolean;
panelSizes: {
categories: number;
items: number;
proof: number;
};
collapsedSections: string[];
changedNeighborhoodOnly: boolean;
maxGraphNodes: number;
}
const STORAGE_KEY = 'stellaops.compare.preferences';
const DEFAULT_PREFERENCES: ComparePreferences = {
role: 'developer',
viewMode: 'side-by-side',
explainMode: false,
showUnchanged: false,
panelSizes: {
categories: 200,
items: 400,
proof: 400
},
collapsedSections: [],
changedNeighborhoodOnly: true,
maxGraphNodes: 25
};
@Injectable({
providedIn: 'root'
})
export class UserPreferencesService {
private readonly _preferences = signal<ComparePreferences>(this.load());
// Expose individual preferences as computed signals
readonly role = computed(() => this._preferences().role);
readonly viewMode = computed(() => this._preferences().viewMode);
readonly explainMode = computed(() => this._preferences().explainMode);
readonly showUnchanged = computed(() => this._preferences().showUnchanged);
readonly panelSizes = computed(() => this._preferences().panelSizes);
readonly collapsedSections = computed(() => this._preferences().collapsedSections);
readonly changedNeighborhoodOnly = computed(() => this._preferences().changedNeighborhoodOnly);
readonly maxGraphNodes = computed(() => this._preferences().maxGraphNodes);
// Full preferences object
readonly preferences = computed(() => this._preferences());
constructor() {
// Auto-persist on changes
effect(() => {
const prefs = this._preferences();
this.persist(prefs);
});
}
setRole(role: ViewRole): void {
this._preferences.update(p => ({ ...p, role }));
}
setViewMode(viewMode: ViewMode): void {
this._preferences.update(p => ({ ...p, viewMode }));
}
setExplainMode(explainMode: boolean): void {
this._preferences.update(p => ({ ...p, explainMode }));
}
setShowUnchanged(showUnchanged: boolean): void {
this._preferences.update(p => ({ ...p, showUnchanged }));
}
setPanelSize(panel: keyof ComparePreferences['panelSizes'], size: number): void {
this._preferences.update(p => ({
...p,
panelSizes: { ...p.panelSizes, [panel]: size }
}));
}
toggleSection(sectionId: string): void {
this._preferences.update(p => {
const collapsed = p.collapsedSections.includes(sectionId)
? p.collapsedSections.filter(id => id !== sectionId)
: [...p.collapsedSections, sectionId];
return { ...p, collapsedSections: collapsed };
});
}
setChangedNeighborhoodOnly(value: boolean): void {
this._preferences.update(p => ({ ...p, changedNeighborhoodOnly: value }));
}
setMaxGraphNodes(value: number): void {
this._preferences.update(p => ({ ...p, maxGraphNodes: value }));
}
reset(): void {
this._preferences.set({ ...DEFAULT_PREFERENCES });
}
private load(): ComparePreferences {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return { ...DEFAULT_PREFERENCES, ...parsed };
}
} catch {
// Ignore parse errors, use defaults
}
return { ...DEFAULT_PREFERENCES };
}
private persist(prefs: ComparePreferences): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// Ignore storage errors (quota exceeded, private mode, etc.)
}
}
}

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ConsoleAdminApiService, AuditEvent } from '../services/console-admin-api.service';
import { AuthService } from '../../../core/auth/auth.service';
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes } from '../../../core/auth/scopes';
@Component({
@@ -547,7 +547,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
})
export class AuditLogComponent implements OnInit {
private readonly api = inject(ConsoleAdminApiService);
private readonly auth = inject(AuthService);
private readonly auth = inject(AUTH_SERVICE);
events: AuditEvent[] = [];
filteredEvents: AuditEvent[] = [];
@@ -584,13 +584,13 @@ export class AuditLogComponent implements OnInit {
this.isLoading = true;
this.error = null;
this.api.getAuditLog().subscribe({
next: (response) => {
this.api.listAuditEvents().subscribe({
next: (response: { events: AuditEvent[] }) => {
this.events = response.events;
this.applyFilters();
this.isLoading = false;
},
error: (err) => {
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to load audit log: ' + (err.error?.message || err.message);
this.isLoading = false;
}
@@ -603,16 +603,16 @@ export class AuditLogComponent implements OnInit {
return false;
}
if (this.filterActor && !event.actor.toLowerCase().includes(this.filterActor.toLowerCase())) {
if (this.filterActor && (!event.actor || !event.actor.toLowerCase().includes(this.filterActor.toLowerCase()))) {
return false;
}
if (this.filterTenantId && !event.tenantId.includes(this.filterTenantId)) {
if (this.filterTenantId && (!event.tenantId || !event.tenantId.includes(this.filterTenantId))) {
return false;
}
if (this.filterStartDate) {
const eventDate = new Date(event.timestamp);
const eventDate = new Date(event.timestamp ?? event.occurredAt);
const startDate = new Date(this.filterStartDate);
if (eventDate < startDate) {
return false;
@@ -620,7 +620,7 @@ export class AuditLogComponent implements OnInit {
}
if (this.filterEndDate) {
const eventDate = new Date(event.timestamp);
const eventDate = new Date(event.timestamp ?? event.occurredAt);
const endDate = new Date(this.filterEndDate);
if (eventDate > endDate) {
return false;

View File

@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { BrandingService, BrandingConfiguration } from '../../../core/branding/branding.service';
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
import { AuthService } from '../../../core/auth/auth.service';
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes } from '../../../core/auth/scopes';
interface ThemeToken {
@@ -526,7 +526,7 @@ export class BrandingEditorComponent implements OnInit {
private readonly http = inject(HttpClient);
private readonly brandingService = inject(BrandingService);
private readonly freshAuth = inject(FreshAuthService);
private readonly auth = inject(AuthService);
private readonly auth = inject(AUTH_SERVICE);
isLoading = false;
isSaving = false;

View File

@@ -1,9 +1,9 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ConsoleAdminApiService, ClientResponse } from '../services/console-admin-api.service';
import { ConsoleAdminApiService, Client } from '../services/console-admin-api.service';
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
import { AuthService } from '../../../core/auth/auth.service';
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes } from '../../../core/auth/scopes';
@Component({
@@ -451,14 +451,14 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
export class ClientsListComponent implements OnInit {
private readonly api = inject(ConsoleAdminApiService);
private readonly freshAuth = inject(FreshAuthService);
private readonly auth = inject(AuthService);
private readonly auth = inject(AUTH_SERVICE);
clients: ClientResponse[] = [];
clients: Client[] = [];
isLoading = false;
error: string | null = null;
isCreating = false;
editingClient: ClientResponse | null = null;
editingClient: Client | null = null;
isSaving = false;
newClientSecret: string | null = null;
@@ -509,16 +509,16 @@ export class ClientsListComponent implements OnInit {
};
}
editClient(client: ClientResponse): void {
editClient(client: Client): void {
this.isCreating = false;
this.editingClient = client;
this.newClientSecret = null;
this.formData = {
clientId: client.clientId,
clientName: client.clientName,
tenantId: client.tenantId,
clientName: client.clientName ?? client.displayName ?? '',
tenantId: client.tenantId ?? '',
grantTypesInput: client.grantTypes.join(','),
redirectUrisInput: client.redirectUris.join(','),
redirectUrisInput: (client.redirectUris ?? []).join(','),
scopesInput: client.scopes.join(',')
};
}
@@ -557,11 +557,13 @@ export class ClientsListComponent implements OnInit {
scopes
}).subscribe({
next: (response) => {
this.clients.push(response.client);
this.newClientSecret = response.clientSecret;
if (response.client) {
this.clients.push(response.client);
}
this.newClientSecret = response.clientSecret ?? null;
this.isSaving = false;
},
error: (err) => {
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to create client: ' + (err.error?.message || err.message);
this.isSaving = false;
}
@@ -589,13 +591,13 @@ export class ClientsListComponent implements OnInit {
}).subscribe({
next: (response) => {
const index = this.clients.findIndex(c => c.clientId === this.editingClient!.clientId);
if (index !== -1) {
if (index !== -1 && response.client) {
this.clients[index] = response.client;
}
this.cancelForm();
this.isSaving = false;
},
error: (err) => {
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to update client: ' + (err.error?.message || err.message);
this.isSaving = false;
}
@@ -610,13 +612,13 @@ export class ClientsListComponent implements OnInit {
const freshAuthOk = await this.freshAuth.requireFreshAuth('Rotate client secret requires fresh authentication');
if (!freshAuthOk) return;
this.api.rotateClientSecret(clientId).subscribe({
next: (response) => {
this.newClientSecret = response.clientSecret;
this.api.rotateClient(clientId).subscribe({
next: (response: { newSecret: string }) => {
this.newClientSecret = response.newSecret;
this.isCreating = false;
this.editingClient = null;
},
error: (err) => {
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to rotate client secret: ' + (err.error?.message || err.message);
}
});
@@ -633,7 +635,7 @@ export class ClientsListComponent implements OnInit {
client.status = 'disabled';
}
},
error: (err) => {
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to disable client: ' + (err.error?.message || err.message);
}
});
@@ -650,7 +652,7 @@ export class ClientsListComponent implements OnInit {
client.status = 'active';
}
},
error: (err) => {
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to enable client: ' + (err.error?.message || err.message);
}
});

View File

@@ -1,10 +1,10 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ConsoleAdminApiService, RoleResponse } from '../services/console-admin-api.service';
import { ConsoleAdminApiService, Role } from '../services/console-admin-api.service';
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
import { AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes, SCOPE_LABELS } from '../../../core/auth/scopes';
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes, ScopeLabels } from '../../../core/auth/scopes';
interface RoleBundle {
module: string;
@@ -97,11 +97,11 @@ interface RoleBundle {
<div class="admin-form">
<h2>{{ isCreating ? 'Create Custom Role' : 'Edit Custom Role' }}</h2>
<div class="form-group">
<label for="roleName">Role Name</label>
<label for="roleId">Role Name</label>
<input
id="roleName"
id="roleId"
type="text"
[(ngModel)]="formData.roleName"
[(ngModel)]="formData.roleId"
[disabled]="!isCreating"
placeholder="role/custom-analyst"
required>
@@ -111,7 +111,7 @@ interface RoleBundle {
<input
id="description"
type="text"
[(ngModel)]="formData.description"
[(ngModel)]="formData.displayName"
placeholder="Custom analyst role"
required>
</div>
@@ -162,10 +162,10 @@ interface RoleBundle {
</tr>
</thead>
<tbody>
@for (role of customRoles; track role.roleName) {
@for (role of customRoles; track role.roleId) {
<tr>
<td><code>{{ role.roleName }}</code></td>
<td>{{ role.description }}</td>
<td><code>{{ role.roleId }}</code></td>
<td>{{ role.displayName }}</td>
<td>
<div class="scope-badges">
@for (scope of role.scopes; track scope) {
@@ -184,7 +184,7 @@ interface RoleBundle {
</button>
<button
class="btn-sm btn-danger"
(click)="deleteRole(role.roleName)"
(click)="deleteRole(role.roleId)"
title="Delete role">
Delete
</button>
@@ -560,22 +560,22 @@ interface RoleBundle {
export class RolesListComponent implements OnInit {
private readonly api = inject(ConsoleAdminApiService);
private readonly freshAuth = inject(FreshAuthService);
private readonly auth = inject(AuthService);
private readonly auth = inject(AUTH_SERVICE);
activeTab: 'catalog' | 'custom' = 'catalog';
catalogFilter = '';
customRoles: RoleResponse[] = [];
customRoles: Role[] = [];
isLoading = false;
error: string | null = null;
isCreating = false;
editingRole: RoleResponse | null = null;
editingRole: Role | null = null;
isSaving = false;
formData = {
roleName: '',
description: '',
roleId: '',
displayName: '',
selectedScopes: [] as string[]
};
@@ -642,7 +642,7 @@ export class RolesListComponent implements OnInit {
{ module: 'Release', tier: 'admin', role: 'role/release-admin', scopes: ['release:read', 'release:create', 'release:write', 'aoc:verify'], description: 'Full release administration' },
];
readonly availableScopes = Object.keys(SCOPE_LABELS).sort();
readonly availableScopes = Object.keys(ScopeLabels).sort();
get canWrite(): boolean {
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_ROLES_WRITE);
@@ -665,7 +665,7 @@ export class RolesListComponent implements OnInit {
}
getScopeLabel(scope: string): string {
return SCOPE_LABELS[scope] || scope;
return (ScopeLabels as Record<string, string>)[scope] || scope;
}
loadCustomRoles(): void {
@@ -688,18 +688,18 @@ export class RolesListComponent implements OnInit {
this.isCreating = true;
this.editingRole = null;
this.formData = {
roleName: '',
description: '',
roleId: '',
displayName: '',
selectedScopes: []
};
}
editRole(role: RoleResponse): void {
editRole(role: Role): void {
this.isCreating = false;
this.editingRole = role;
this.formData = {
roleName: role.roleName,
description: role.description,
roleId: role.roleId,
displayName: role.displayName,
selectedScopes: [...role.scopes]
};
}
@@ -708,8 +708,8 @@ export class RolesListComponent implements OnInit {
this.isCreating = false;
this.editingRole = null;
this.formData = {
roleName: '',
description: '',
roleId: '',
displayName: '',
selectedScopes: []
};
}
@@ -731,16 +731,17 @@ export class RolesListComponent implements OnInit {
this.error = null;
this.api.createRole({
roleName: this.formData.roleName,
description: this.formData.description,
roleId: this.formData.roleId,
displayName: this.formData.displayName,
scopes: this.formData.selectedScopes
}).subscribe({
next: (response) => {
this.customRoles.push(response.role);
next: () => {
// Reload roles to get the full role object
this.loadCustomRoles();
this.cancelForm();
this.isSaving = false;
},
error: (err) => {
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to create role: ' + (err.error?.message || err.message);
this.isSaving = false;
}
@@ -756,43 +757,32 @@ export class RolesListComponent implements OnInit {
this.isSaving = true;
this.error = null;
this.api.updateRole(this.editingRole.roleName, {
description: this.formData.description,
scopes: this.formData.selectedScopes
this.api.updateRole(this.editingRole.roleId, {
displayName: this.formData.displayName || undefined,
scopes: this.formData.selectedScopes.length > 0 ? this.formData.selectedScopes : undefined
}).subscribe({
next: (response) => {
const index = this.customRoles.findIndex(r => r.roleName === this.editingRole!.roleName);
if (index !== -1) {
this.customRoles[index] = response.role;
}
next: () => {
// Reload roles to get updated data
this.loadCustomRoles();
this.cancelForm();
this.isSaving = false;
},
error: (err) => {
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to update role: ' + (err.error?.message || err.message);
this.isSaving = false;
}
});
}
async deleteRole(roleName: string): Promise<void> {
if (!confirm(`Are you sure you want to delete role "${roleName}"? This cannot be undone.`)) {
async deleteRole(roleId: string): Promise<void> {
if (!confirm(`Are you sure you want to delete this role? This cannot be undone.`)) {
return;
}
const freshAuthOk = await this.freshAuth.requireFreshAuth('Delete custom role requires fresh authentication');
if (!freshAuthOk) return;
this.api.deleteRole(roleName).subscribe({
next: () => {
const index = this.customRoles.findIndex(r => r.roleName === roleName);
if (index !== -1) {
this.customRoles.splice(index, 1);
}
},
error: (err) => {
this.error = 'Failed to delete role: ' + (err.error?.message || err.message);
}
});
// Note: deleteRole API not yet implemented - show message for now
this.error = 'Role deletion is not yet implemented in the backend.';
}
}

View File

@@ -38,8 +38,9 @@ export class ConsoleAdminApiService {
// ========== USERS ==========
listUsers(tenantId?: string): Observable<UsersResponse> {
const params = tenantId ? { tenantId } : {};
return this.http.get<UsersResponse>(`${this.baseUrl}/users`, { params });
return tenantId
? this.http.get<UsersResponse>(`${this.baseUrl}/users`, { params: { tenantId } })
: this.http.get<UsersResponse>(`${this.baseUrl}/users`);
}
createUser(request: CreateUserRequest): Observable<{ userId: string }> {
@@ -82,23 +83,32 @@ export class ConsoleAdminApiService {
return this.http.get<ClientsResponse>(`${this.baseUrl}/clients`);
}
createClient(request: CreateClientRequest): Observable<{ clientId: string }> {
return this.http.post<{ clientId: string }>(`${this.baseUrl}/clients`, request);
createClient(request: CreateClientRequest): Observable<CreateClientResponse> {
return this.http.post<CreateClientResponse>(`${this.baseUrl}/clients`, request);
}
updateClient(clientId: string, request: UpdateClientRequest): Observable<void> {
return this.http.patch<void>(`${this.baseUrl}/clients/${clientId}`, request);
updateClient(clientId: string, request: UpdateClientRequest): Observable<UpdateClientResponse> {
return this.http.patch<UpdateClientResponse>(`${this.baseUrl}/clients/${clientId}`, request);
}
rotateClient(clientId: string): Observable<{ newSecret: string }> {
return this.http.post<{ newSecret: string }>(`${this.baseUrl}/clients/${clientId}/rotate`, {});
}
disableClient(clientId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/clients/${clientId}/disable`, {});
}
enableClient(clientId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/clients/${clientId}/enable`, {});
}
// ========== TOKENS ==========
listTokens(tenantId?: string): Observable<TokensResponse> {
const params = tenantId ? { tenantId } : {};
return this.http.get<TokensResponse>(`${this.baseUrl}/tokens`, { params });
return tenantId
? this.http.get<TokensResponse>(`${this.baseUrl}/tokens`, { params: { tenantId } })
: this.http.get<TokensResponse>(`${this.baseUrl}/tokens`);
}
revokeTokens(request: RevokeTokensRequest): Observable<{ revokedCount: number }> {
@@ -108,8 +118,9 @@ export class ConsoleAdminApiService {
// ========== AUDIT ==========
listAuditEvents(tenantId?: string): Observable<AuditEventsResponse> {
const params = tenantId ? { tenantId } : {};
return this.http.get<AuditEventsResponse>(`${this.baseUrl}/audit`, { params });
return tenantId
? this.http.get<AuditEventsResponse>(`${this.baseUrl}/audit`, { params: { tenantId } })
: this.http.get<AuditEventsResponse>(`${this.baseUrl}/audit`);
}
}
@@ -148,6 +159,9 @@ export interface User {
displayName?: string;
enabled: boolean;
roles: string[];
// Additional UI-expected properties
status?: 'active' | 'disabled';
tenantId?: string;
}
export interface CreateUserRequest {
@@ -198,18 +212,39 @@ export interface Client {
grantTypes: string[];
scopes: string[];
enabled: boolean;
// Additional UI-expected properties
clientName?: string;
tenantId?: string;
redirectUris?: string[];
status?: 'active' | 'disabled';
}
export interface CreateClientRequest {
clientId: string;
displayName: string;
clientName?: string;
displayName?: string;
grantTypes: string[];
scopes: string[];
tenantId?: string;
redirectUris?: string[];
}
export interface CreateClientResponse {
clientId: string;
clientSecret?: string;
client?: Client;
}
export interface UpdateClientRequest {
displayName?: string;
clientName?: string;
scopes?: string[];
grantTypes?: string[];
redirectUris?: string[];
}
export interface UpdateClientResponse {
client?: Client;
}
export interface TokensResponse {
@@ -224,6 +259,10 @@ export interface Token {
issuedAt: string;
expiresAt: string;
revoked: boolean;
// Additional UI-expected properties
status?: 'active' | 'expired' | 'revoked';
tokenType?: 'access_token' | 'refresh_token';
tenantId?: string;
}
export interface RevokeTokensRequest {
@@ -242,4 +281,12 @@ export interface AuditEvent {
subject?: string;
tenant?: string;
reason?: string;
// Additional UI-expected properties
id?: string;
timestamp?: string;
actor?: string;
tenantId?: string;
resourceType?: string;
resourceId?: string;
metadata?: Record<string, unknown>;
}

View File

@@ -1,9 +1,9 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ConsoleAdminApiService, TokenResponse } from '../services/console-admin-api.service';
import { ConsoleAdminApiService, Token } from '../services/console-admin-api.service';
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
import { AuthService } from '../../../core/auth/auth.service';
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes } from '../../../core/auth/scopes';
@Component({
@@ -78,8 +78,8 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
<tr [class.revoked]="token.status === 'revoked'" [class.expired]="token.status === 'expired'">
<td><code class="token-id">{{ formatTokenId(token.tokenId) }}</code></td>
<td>
<span class="type-badge" [class]="'type-' + token.tokenType">
{{ formatTokenType(token.tokenType) }}
<span class="type-badge" [class]="'type-' + (token.tokenType ?? 'unknown')">
{{ formatTokenType(token.tokenType ?? 'unknown') }}
</span>
</td>
<td>{{ token.subject }}</td>
@@ -94,7 +94,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
</td>
<td>
<div class="action-buttons">
@if (canWrite && token.status === 'active') {
@if (canRevoke && token.status === 'active') {
<button
class="btn-sm btn-danger"
(click)="revokeToken(token.tokenId)"
@@ -111,7 +111,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
@if (tokens.length > 0) {
<div class="bulk-actions">
@if (canWrite) {
@if (canRevoke) {
<button
class="btn-danger"
(click)="revokeAllExpired()"
@@ -373,17 +373,17 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
export class TokensListComponent implements OnInit {
private readonly api = inject(ConsoleAdminApiService);
private readonly freshAuth = inject(FreshAuthService);
private readonly auth = inject(AuthService);
private readonly auth = inject(AUTH_SERVICE);
tokens: TokenResponse[] = [];
tokens: Token[] = [];
isLoading = false;
error: string | null = null;
filterStatus = '';
filterTokenType = '';
get canWrite(): boolean {
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_TOKENS_WRITE);
get canRevoke(): boolean {
return this.auth.hasScope(StellaOpsScopes.AUTHORITY_TOKENS_REVOKE);
}
ngOnInit(): void {
@@ -406,7 +406,7 @@ export class TokensListComponent implements OnInit {
});
}
applyFilters(tokens: TokenResponse[]): TokenResponse[] {
applyFilters(tokens: Token[]): Token[] {
let filtered = tokens;
if (this.filterStatus) {
@@ -448,14 +448,14 @@ export class TokensListComponent implements OnInit {
const freshAuthOk = await this.freshAuth.requireFreshAuth('Revoke token requires fresh authentication');
if (!freshAuthOk) return;
this.api.revokeToken(tokenId).subscribe({
this.api.revokeTokens({ tokenIds: [tokenId] }).subscribe({
next: () => {
const token = this.tokens.find(t => t.tokenId === tokenId);
if (token) {
token.status = 'revoked';
}
},
error: (err) => {
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to revoke token: ' + (err.error?.message || err.message);
}
});
@@ -471,26 +471,19 @@ export class TokensListComponent implements OnInit {
if (!freshAuthOk) return;
const expiredTokens = this.tokens.filter(t => t.status === 'expired');
let revoked = 0;
let failed = 0;
const tokenIds = expiredTokens.map(t => t.tokenId);
for (const token of expiredTokens) {
this.api.revokeToken(token.tokenId).subscribe({
next: () => {
token.status = 'revoked';
revoked++;
},
error: () => {
failed++;
this.api.revokeTokens({ tokenIds }).subscribe({
next: (response) => {
expiredTokens.forEach(t => t.status = 'revoked');
if (response.revokedCount < tokenIds.length) {
this.error = `Revoked ${response.revokedCount} of ${tokenIds.length} tokens`;
}
});
}
setTimeout(() => {
if (failed > 0) {
this.error = `Revoked ${revoked} tokens, ${failed} failed`;
},
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to revoke tokens: ' + (err.error?.message || err.message);
}
}, 1000);
});
}
async revokeBySubject(): Promise<void> {
@@ -510,16 +503,15 @@ export class TokensListComponent implements OnInit {
const freshAuthOk = await this.freshAuth.requireFreshAuth('Bulk revoke tokens requires fresh authentication');
if (!freshAuthOk) return;
for (const token of matchingTokens) {
this.api.revokeToken(token.tokenId).subscribe({
next: () => {
token.status = 'revoked';
},
error: (err) => {
this.error = 'Failed to revoke some tokens: ' + (err.error?.message || err.message);
}
});
}
const tokenIds = matchingTokens.map(t => t.tokenId);
this.api.revokeTokens({ tokenIds }).subscribe({
next: () => {
matchingTokens.forEach(t => t.status = 'revoked');
},
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to revoke some tokens: ' + (err.error?.message || err.message);
}
});
}
async revokeByClient(): Promise<void> {
@@ -539,15 +531,14 @@ export class TokensListComponent implements OnInit {
const freshAuthOk = await this.freshAuth.requireFreshAuth('Bulk revoke tokens requires fresh authentication');
if (!freshAuthOk) return;
for (const token of matchingTokens) {
this.api.revokeToken(token.tokenId).subscribe({
next: () => {
token.status = 'revoked';
},
error: (err) => {
this.error = 'Failed to revoke some tokens: ' + (err.error?.message || err.message);
}
});
}
const tokenIds = matchingTokens.map(t => t.tokenId);
this.api.revokeTokens({ tokenIds }).subscribe({
next: () => {
matchingTokens.forEach(t => t.status = 'revoked');
},
error: (err: { error?: { message?: string }; message?: string }) => {
this.error = 'Failed to revoke some tokens: ' + (err.error?.message || err.message);
}
});
}
}

View File

@@ -1,9 +1,9 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ConsoleAdminApiService, UserResponse } from '../services/console-admin-api.service';
import { ConsoleAdminApiService, User } from '../services/console-admin-api.service';
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
import { AuthService } from '../../../core/auth/auth.service';
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes } from '../../../core/auth/scopes';
@Component({
@@ -96,7 +96,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
</tr>
</thead>
<tbody>
@for (user of users; track user.userId) {
@for (user of users; track user.id) {
<tr [class.disabled]="user.status === 'disabled'">
<td>{{ user.email }}</td>
<td>{{ user.displayName }}</td>
@@ -125,14 +125,14 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
@if (user.status === 'active') {
<button
class="btn-sm btn-warning"
(click)="disableUser(user.userId)"
(click)="disableUser(user.id)"
title="Disable user">
Disable
</button>
} @else {
<button
class="btn-sm btn-success"
(click)="enableUser(user.userId)"
(click)="enableUser(user.id)"
title="Enable user">
Enable
</button>
@@ -364,14 +364,14 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
export class UsersListComponent implements OnInit {
private readonly api = inject(ConsoleAdminApiService);
private readonly freshAuth = inject(FreshAuthService);
private readonly auth = inject(AuthService);
private readonly auth = inject(AUTH_SERVICE);
users: UserResponse[] = [];
users: User[] = [];
isLoading = false;
error: string | null = null;
isCreating = false;
editingUser: UserResponse | null = null;
editingUser: User | null = null;
isSaving = false;
formData = {
@@ -416,13 +416,13 @@ export class UsersListComponent implements OnInit {
};
}
editUser(user: UserResponse): void {
editUser(user: User): void {
this.isCreating = false;
this.editingUser = user;
this.formData = {
email: user.email,
displayName: user.displayName,
tenantId: user.tenantId,
displayName: user.displayName ?? '',
tenantId: user.tenantId ?? '',
rolesInput: user.roles.join(',')
};
}
@@ -451,13 +451,14 @@ export class UsersListComponent implements OnInit {
.filter(r => r.length > 0);
this.api.createUser({
username: this.formData.email.split('@')[0], // Derive username from email
email: this.formData.email,
displayName: this.formData.displayName,
tenantId: this.formData.tenantId,
roles
displayName: this.formData.displayName || undefined,
roles: roles.length > 0 ? roles : undefined
}).subscribe({
next: (response) => {
this.users.push(response.user);
// Reload users list to get the full user object
this.loadUsers();
this.cancelForm();
this.isSaving = false;
},
@@ -482,15 +483,13 @@ export class UsersListComponent implements OnInit {
.map(r => r.trim())
.filter(r => r.length > 0);
this.api.updateUser(this.editingUser.userId, {
displayName: this.formData.displayName,
roles
this.api.updateUser(this.editingUser.id, {
displayName: this.formData.displayName || undefined,
roles: roles.length > 0 ? roles : undefined
}).subscribe({
next: (response) => {
const index = this.users.findIndex(u => u.userId === this.editingUser!.userId);
if (index !== -1) {
this.users[index] = response.user;
}
next: () => {
// Reload users list to get updated user data
this.loadUsers();
this.cancelForm();
this.isSaving = false;
},
@@ -507,7 +506,7 @@ export class UsersListComponent implements OnInit {
this.api.disableUser(userId).subscribe({
next: () => {
const user = this.users.find(u => u.userId === userId);
const user = this.users.find(u => u.id === userId);
if (user) {
user.status = 'disabled';
}
@@ -524,7 +523,7 @@ export class UsersListComponent implements OnInit {
this.api.enableUser(userId).subscribe({
next: () => {
const user = this.users.find(u => u.userId === userId);
const user = this.users.find(u => u.id === userId);
if (user) {
user.status = 'active';
}

View File

@@ -0,0 +1,450 @@
/**
* AI Risk Drivers Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Tasks: AIUX-35, AIUX-36, AIUX-37, AIUX-38
*
* Executive dashboard AI integration:
* - No generative narrative by default
* - Top 3 risk drivers with evidence links
* - Top 3 bottlenecks (missing evidence, etc.)
* - Risk/noise trends (deterministic)
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AiAuthorityBadgeComponent } from '../../shared/components/ai/ai-authority-badge.component';
/**
* Risk driver identified by AI analysis.
*/
export interface RiskDriver {
/** Driver ID */
id: string;
/** Short description (max 1 sentence) */
description: string;
/** Number of findings affected */
findingsCount: number;
/** Severity distribution */
severityBreakdown: {
critical: number;
high: number;
medium: number;
low: number;
};
/** Evidence link URL or path */
evidenceLink: string;
/** Evidence description */
evidenceDescription: string;
}
/**
* Bottleneck identified by AI analysis.
*/
export interface Bottleneck {
/** Bottleneck ID */
id: string;
/** Short description */
description: string;
/** Percentage affected */
percentageAffected: number;
/** Category */
category: 'missing_runtime' | 'missing_reachability' | 'pending_vex' | 'stale_scan' | 'other';
/** Suggested action */
suggestedAction?: string;
}
/**
* Trend data point.
*/
export interface TrendPoint {
/** Date */
date: string;
/** Value */
value: number;
}
/**
* Risk and noise trends.
*/
export interface RiskTrends {
/** Risk trend (deterministic, from policy) */
riskTrend: TrendPoint[];
/** Noise reduction (% confirmed not exploitable) */
noiseReductionTrend: TrendPoint[];
/** Current noise reduction percentage */
currentNoiseReduction: number;
}
/**
* Dashboard AI data.
*/
export interface DashboardAiData {
/** Top risk drivers */
riskDrivers: RiskDriver[];
/** Top bottlenecks */
bottlenecks: Bottleneck[];
/** Trend data */
trends: RiskTrends;
}
@Component({
selector: 'stella-ai-risk-drivers',
standalone: true,
imports: [CommonModule, AiAuthorityBadgeComponent],
template: `
<div class="ai-risk-drivers">
<!-- Risk Drivers Section -->
<section class="ai-risk-drivers__section">
<header class="ai-risk-drivers__section-header">
<h3 class="ai-risk-drivers__section-title">Top 3 Risk Drivers</h3>
<stella-ai-authority-badge authority="evidence-backed" />
</header>
@if (data().riskDrivers.length > 0) {
<ol class="ai-risk-drivers__list">
@for (driver of topRiskDrivers(); track driver.id; let i = $index) {
<li class="ai-risk-drivers__item"
(click)="onDriverClick(driver)">
<span class="ai-risk-drivers__rank">{{ i + 1 }}</span>
<div class="ai-risk-drivers__content">
<span class="ai-risk-drivers__description">{{ driver.description }}</span>
<span class="ai-risk-drivers__stats">
{{ driver.findingsCount }} findings
({{ driver.severityBreakdown.critical }}C /
{{ driver.severityBreakdown.high }}H)
</span>
</div>
<button class="ai-risk-drivers__evidence-link"
(click)="onEvidenceClick($event, driver)">
View evidence
</button>
</li>
}
</ol>
} @else {
<div class="ai-risk-drivers__empty">
No significant risk drivers identified
</div>
}
</section>
<!-- Bottlenecks Section -->
<section class="ai-risk-drivers__section">
<header class="ai-risk-drivers__section-header">
<h3 class="ai-risk-drivers__section-title">Top 3 Bottlenecks</h3>
<stella-ai-authority-badge authority="evidence-backed" />
</header>
@if (data().bottlenecks.length > 0) {
<ol class="ai-risk-drivers__list">
@for (bottleneck of topBottlenecks(); track bottleneck.id; let i = $index) {
<li class="ai-risk-drivers__item ai-risk-drivers__item--bottleneck">
<span class="ai-risk-drivers__rank">{{ i + 1 }}</span>
<div class="ai-risk-drivers__content">
<span class="ai-risk-drivers__description">
{{ bottleneck.description }}
</span>
<span class="ai-risk-drivers__percentage">
{{ bottleneck.percentageAffected }}% of criticals
</span>
</div>
@if (bottleneck.suggestedAction) {
<button class="ai-risk-drivers__action"
(click)="onBottleneckAction(bottleneck)">
{{ getActionLabel(bottleneck.category) }}
</button>
}
</li>
}
</ol>
} @else {
<div class="ai-risk-drivers__empty">
No bottlenecks identified
</div>
}
</section>
<!-- Trends Section -->
<section class="ai-risk-drivers__section">
<header class="ai-risk-drivers__section-header">
<h3 class="ai-risk-drivers__section-title">Trends</h3>
<span class="ai-risk-drivers__deterministic-badge">Deterministic</span>
</header>
<div class="ai-risk-drivers__trends">
<div class="ai-risk-drivers__trend-item">
<span class="ai-risk-drivers__trend-label">Risk Trend</span>
<span class="ai-risk-drivers__trend-value" [class]="riskTrendClass()">
{{ riskTrendIndicator() }}
</span>
</div>
<div class="ai-risk-drivers__trend-item">
<span class="ai-risk-drivers__trend-label">Noise Reduction</span>
<span class="ai-risk-drivers__trend-value ai-risk-drivers__trend-value--positive">
{{ data().trends.currentNoiseReduction }}% confirmed not exploitable
</span>
</div>
</div>
</section>
</div>
`,
styles: [`
.ai-risk-drivers {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.ai-risk-drivers__section {
padding: 1rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.ai-risk-drivers__section-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.ai-risk-drivers__section-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: #111827;
}
.ai-risk-drivers__deterministic-badge {
padding: 0.125rem 0.5rem;
background: #dbeafe;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
color: #1e40af;
}
.ai-risk-drivers__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ai-risk-drivers__item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #f9fafb;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease;
}
.ai-risk-drivers__item:hover {
background: #f3f4f6;
}
.ai-risk-drivers__rank {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
background: #4f46e5;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.ai-risk-drivers__item--bottleneck .ai-risk-drivers__rank {
background: #f59e0b;
}
.ai-risk-drivers__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.ai-risk-drivers__description {
font-size: 0.875rem;
color: #111827;
}
.ai-risk-drivers__stats,
.ai-risk-drivers__percentage {
font-size: 0.75rem;
color: #6b7280;
}
.ai-risk-drivers__evidence-link,
.ai-risk-drivers__action {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.75rem;
color: #4f46e5;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.ai-risk-drivers__evidence-link:hover,
.ai-risk-drivers__action:hover {
background: #eef2ff;
border-color: #a5b4fc;
}
.ai-risk-drivers__empty {
padding: 1rem;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
font-style: italic;
}
.ai-risk-drivers__trends {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ai-risk-drivers__trend-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f9fafb;
border-radius: 4px;
}
.ai-risk-drivers__trend-label {
font-size: 0.8125rem;
color: #6b7280;
}
.ai-risk-drivers__trend-value {
font-size: 0.875rem;
font-weight: 500;
}
.ai-risk-drivers__trend-value--increasing {
color: #dc2626;
}
.ai-risk-drivers__trend-value--decreasing {
color: #16a34a;
}
.ai-risk-drivers__trend-value--stable {
color: #6b7280;
}
.ai-risk-drivers__trend-value--positive {
color: #16a34a;
}
`]
})
export class AiRiskDriversComponent {
/**
* Dashboard AI data.
*/
readonly data = input.required<DashboardAiData>();
/**
* Risk driver clicked.
*/
readonly driverClick = output<RiskDriver>();
/**
* Evidence link clicked.
*/
readonly evidenceClick = output<RiskDriver>();
/**
* Bottleneck action clicked.
*/
readonly bottleneckAction = output<Bottleneck>();
/**
* Top 3 risk drivers.
*/
readonly topRiskDrivers = computed(() =>
this.data().riskDrivers.slice(0, 3)
);
/**
* Top 3 bottlenecks.
*/
readonly topBottlenecks = computed(() =>
this.data().bottlenecks.slice(0, 3)
);
/**
* Risk trend class.
*/
readonly riskTrendClass = computed(() => {
const trend = this.data().trends.riskTrend;
if (trend.length < 2) return 'ai-risk-drivers__trend-value--stable';
const recent = trend[trend.length - 1].value;
const previous = trend[trend.length - 2].value;
if (recent > previous) return 'ai-risk-drivers__trend-value--increasing';
if (recent < previous) return 'ai-risk-drivers__trend-value--decreasing';
return 'ai-risk-drivers__trend-value--stable';
});
/**
* Risk trend indicator text.
*/
readonly riskTrendIndicator = computed(() => {
const trend = this.data().trends.riskTrend;
if (trend.length < 2) return 'Stable';
const recent = trend[trend.length - 1].value;
const previous = trend[trend.length - 2].value;
const diff = recent - previous;
const pct = Math.abs(Math.round((diff / previous) * 100));
if (diff > 0) return `\u25B2 ${pct}% increase`;
if (diff < 0) return `\u25BC ${pct}% decrease`;
return 'Stable';
});
/**
* Get action button label for bottleneck category.
*/
getActionLabel(category: Bottleneck['category']): string {
switch (category) {
case 'missing_runtime': return 'Setup signals';
case 'missing_reachability': return 'Run analysis';
case 'pending_vex': return 'Review VEX';
case 'stale_scan': return 'Rescan';
default: return 'View details';
}
}
onDriverClick(driver: RiskDriver): void {
this.driverClick.emit(driver);
}
onEvidenceClick(event: Event, driver: RiskDriver): void {
event.stopPropagation();
this.evidenceClick.emit(driver);
}
onBottleneckAction(bottleneck: Bottleneck): void {
this.bottleneckAction.emit(bottleneck);
}
}

View File

@@ -0,0 +1,261 @@
/**
* AI Chip Row Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Tasks: AIUX-25, AIUX-26, AIUX-27, AIUX-28, AIUX-29
*
* Extends FindingsListComponent row with AI chips.
* Rules:
* - Max 2 AI chips per row
* - Priority: Reachable Path > Fix Available > Needs Evidence > Exploitability
* - Hover shows 3-line preview tooltip
* - Click opens finding detail with AI panel
* - HARD RULE: No full AI paragraphs in list view
*/
import { Component, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AiExploitabilityChipComponent } from '../../shared/components/ai/ai-exploitability-chip.component';
import { AiFixChipComponent, FixState } from '../../shared/components/ai/ai-fix-chip.component';
import { AiNeedsEvidenceChipComponent, EvidenceType } from '../../shared/components/ai/ai-needs-evidence-chip.component';
/**
* AI insight data for a finding row.
*/
export interface FindingAiInsight {
/** Whether reachable path exists */
hasReachablePath: boolean;
/** Reachability hop count */
reachabilityHops?: number;
/** Fix availability state */
fixState: FixState;
/** Whether PR is ready */
fixPrReady: boolean;
/** Evidence needed type */
evidenceNeeded?: EvidenceType;
/** Evidence needed description */
evidenceDescription?: string;
/** Exploitability level */
exploitability: 'likely' | 'unlikely' | 'confirmed' | 'unknown';
/** 3-line summary for tooltip */
summary?: {
line1: string;
line2: string;
line3: string;
};
}
/**
* Policy outcome for priority determination.
*/
export type PolicyState = 'BLOCK' | 'WARN' | 'ALLOW' | 'UNKNOWN';
/**
* AI chip to display.
*/
interface AiChipDisplay {
type: 'reachable' | 'fix' | 'evidence' | 'exploitability';
priority: number;
}
@Component({
selector: 'stella-ai-chip-row',
standalone: true,
imports: [
CommonModule,
AiExploitabilityChipComponent,
AiFixChipComponent,
AiNeedsEvidenceChipComponent
],
template: `
<div class="ai-chip-row"
(mouseenter)="showTooltip.set(true)"
(mouseleave)="showTooltip.set(false)">
@for (chip of displayChips(); track chip.type) {
@switch (chip.type) {
@case ('reachable') {
<stella-ai-exploitability-chip
[reachable]="true"
[hopCount]="insight().reachabilityHops ?? 0"
[exploitability]="insight().exploitability"
(click)="onChipClick($event, 'reachable')"
/>
}
@case ('fix') {
<stella-ai-fix-chip
[state]="insight().fixState"
[prReady]="insight().fixPrReady"
[target]="findingId()"
[compact]="true"
(fix)="onChipClick($event, 'fix')"
/>
}
@case ('evidence') {
<stella-ai-needs-evidence-chip
[evidenceType]="insight().evidenceNeeded!"
[needed]="insight().evidenceDescription ?? ''"
[compact]="true"
(gatherEvidence)="onChipClick($event, 'evidence')"
/>
}
@case ('exploitability') {
<stella-ai-exploitability-chip
[reachable]="false"
[exploitability]="insight().exploitability"
(click)="onChipClick($event, 'exploitability')"
/>
}
}
}
<!-- Tooltip (3-line preview) -->
@if (showTooltip() && insight().summary) {
<div class="ai-chip-row__tooltip">
<p class="ai-chip-row__tooltip-line">{{ insight().summary!.line1 }}</p>
<p class="ai-chip-row__tooltip-line">{{ insight().summary!.line2 }}</p>
<p class="ai-chip-row__tooltip-line">{{ insight().summary!.line3 }}</p>
</div>
}
</div>
`,
styles: [`
.ai-chip-row {
position: relative;
display: flex;
gap: 0.375rem;
align-items: center;
}
.ai-chip-row__tooltip {
position: absolute;
bottom: 100%;
left: 0;
z-index: 100;
min-width: 250px;
max-width: 350px;
padding: 0.625rem;
background: #1f2937;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
pointer-events: none;
margin-bottom: 0.5rem;
}
.ai-chip-row__tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 1rem;
border: 6px solid transparent;
border-top-color: #1f2937;
}
.ai-chip-row__tooltip-line {
margin: 0;
font-size: 0.75rem;
line-height: 1.4;
color: #f3f4f6;
}
.ai-chip-row__tooltip-line + .ai-chip-row__tooltip-line {
margin-top: 0.25rem;
}
`]
})
export class AiChipRowComponent {
/**
* Finding ID.
*/
readonly findingId = input.required<string>();
/**
* Policy state for priority determination.
*/
readonly policyState = input<PolicyState>('UNKNOWN');
/**
* Severity for priority determination.
*/
readonly severity = input<'critical' | 'high' | 'medium' | 'low'>('medium');
/**
* AI insight data.
*/
readonly insight = input.required<FindingAiInsight>();
/**
* Chip clicked - opens finding detail.
*/
readonly chipClick = output<{ findingId: string; openPanel: string }>();
/**
* Tooltip visibility state.
*/
readonly showTooltip = signal(false);
/**
* Determine which chips to display (max 2).
* Priority based on policy state and severity:
* - BLOCK: Reachable Path > Fix Available
* - WARN: Exploitability > Fix Available
* - Critical/High: Reachable Path > Needs Evidence
* - Medium/Low: Exploitability only (1 chip)
*/
readonly displayChips = computed((): AiChipDisplay[] => {
const ins = this.insight();
const policy = this.policyState();
const sev = this.severity();
const chips: AiChipDisplay[] = [];
// Determine available chips with priorities
if (ins.hasReachablePath) {
chips.push({ type: 'reachable', priority: 1 });
}
if (ins.fixState !== 'unavailable') {
chips.push({ type: 'fix', priority: 2 });
}
if (ins.evidenceNeeded) {
chips.push({ type: 'evidence', priority: 3 });
}
if (ins.exploitability !== 'unknown') {
chips.push({ type: 'exploitability', priority: 4 });
}
// Sort by priority
chips.sort((a, b) => a.priority - b.priority);
// Apply display rules based on policy and severity
if (policy === 'BLOCK') {
// Reachable Path > Fix Available
return chips.filter(c => c.type === 'reachable' || c.type === 'fix').slice(0, 2);
} else if (policy === 'WARN') {
// Exploitability > Fix Available
const result: AiChipDisplay[] = [];
const exploitability = chips.find(c => c.type === 'exploitability');
const fix = chips.find(c => c.type === 'fix');
if (exploitability) result.push(exploitability);
if (fix) result.push(fix);
return result.slice(0, 2);
} else if (sev === 'critical' || sev === 'high') {
// Reachable Path > Needs Evidence
const result: AiChipDisplay[] = [];
const reachable = chips.find(c => c.type === 'reachable');
const evidence = chips.find(c => c.type === 'evidence');
if (reachable) result.push(reachable);
if (evidence) result.push(evidence);
return result.slice(0, 2);
} else {
// Medium/Low: Exploitability only (1 chip)
const exploitability = chips.find(c => c.type === 'exploitability');
return exploitability ? [exploitability] : [];
}
});
onChipClick(event: Event, panel: string): void {
event.stopPropagation();
this.chipClick.emit({
findingId: this.findingId(),
openPanel: panel
});
}
}

View File

@@ -0,0 +1,579 @@
/**
* Evidence Panel Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-15
*
* Authoritative evidence panel showing:
* - Reachability graph
* - Runtime evidence
* - VEX claims (vendor, distro, merged)
* - Patch information
*/
import { Component, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Reachability path hop.
*/
export interface ReachabilityHop {
/** Function/method name */
name: string;
/** File location */
file?: string;
/** Line number */
line?: number;
}
/**
* Runtime observation.
*/
export interface RuntimeObservation {
/** Where observed */
location: string;
/** Observation type */
type: 'loaded' | 'invoked' | 'executed';
/** When observed */
observedAt: string;
/** Confidence level */
confidence: 'high' | 'medium' | 'low';
}
/**
* VEX claim from a source.
*/
export interface VexClaim {
/** Source of the claim */
source: 'vendor' | 'distro' | 'internal' | 'community';
/** Source name */
sourceName: string;
/** VEX status */
status: 'affected' | 'not_affected' | 'fixed' | 'under_investigation';
/** Justification if not_affected */
justification?: string;
/** When the claim was made */
claimDate: string;
}
/**
* Patch information.
*/
export interface PatchInfo {
/** Fixed version */
fixedVersion: string;
/** Patch URL */
patchUrl?: string;
/** Release date */
releaseDate: string;
/** Upgrade path available */
upgradePathAvailable: boolean;
/** Breaking changes */
breakingChanges?: string[];
}
/**
* Evidence panel data.
*/
export interface EvidenceData {
/** Reachability path */
reachability?: {
/** Path from entry to vulnerable function */
path: ReachabilityHop[];
/** Total hops */
hopCount: number;
/** Whether path is confirmed via static analysis */
confirmed: boolean;
};
/** Runtime observations */
runtime?: RuntimeObservation[];
/** VEX claims */
vex?: {
/** Individual claims */
claims: VexClaim[];
/** Merged status */
mergedStatus: 'affected' | 'not_affected' | 'fixed' | 'under_investigation';
};
/** Patch information */
patches?: PatchInfo[];
}
@Component({
selector: 'stella-evidence-panel',
standalone: true,
imports: [CommonModule],
template: `
<div class="evidence-panel">
<header class="evidence-panel__header">
<div class="evidence-panel__header-left">
<h3 class="evidence-panel__title">Evidence</h3>
<span class="evidence-panel__subtitle">(authoritative)</span>
</div>
<button class="evidence-panel__toggle" (click)="onToggle()">
{{ collapsed() ? 'Expand' : 'Collapse' }} {{ collapsed() ? '\u25BC' : '\u25B2' }}
</button>
</header>
@if (!collapsed()) {
<div class="evidence-panel__content">
<!-- Reachability Section -->
@if (data().reachability) {
<section class="evidence-panel__section">
<h4 class="evidence-panel__section-title">
Reachability
@if (data().reachability!.confirmed) {
<span class="evidence-panel__badge evidence-panel__badge--confirmed">Confirmed</span>
}
</h4>
<div class="evidence-panel__reachability">
<div class="evidence-panel__path">
@for (hop of data().reachability!.path; track $index; let last = $last) {
<div class="evidence-panel__hop">
<code class="evidence-panel__hop-name" (click)="onNodeClick('reachability', hop.name)">
{{ hop.name }}
</code>
@if (hop.file) {
<span class="evidence-panel__hop-location">
{{ hop.file }}@if (hop.line) {:{{ hop.line }}}
</span>
}
@if (!last) {
<span class="evidence-panel__hop-arrow">\u2192</span>
}
</div>
}
</div>
<div class="evidence-panel__hop-count">
{{ data().reachability!.hopCount }} hops from entry
</div>
</div>
</section>
}
<!-- Runtime Section -->
@if (data().runtime && data().runtime!.length > 0) {
<section class="evidence-panel__section">
<h4 class="evidence-panel__section-title">Runtime Evidence</h4>
<ul class="evidence-panel__runtime-list">
@for (obs of data().runtime; track $index) {
<li class="evidence-panel__runtime-item" (click)="onNodeClick('runtime', obs.location)">
<span class="evidence-panel__runtime-type" [class]="'evidence-panel__runtime-type--' + obs.type">
{{ obs.type }}
</span>
<span class="evidence-panel__runtime-location">{{ obs.location }}</span>
<span class="evidence-panel__runtime-date">({{ formatDate(obs.observedAt) }})</span>
<span class="evidence-panel__runtime-confidence" [class]="'evidence-panel__runtime-confidence--' + obs.confidence">
{{ obs.confidence }}
</span>
</li>
}
</ul>
</section>
}
<!-- VEX Section -->
@if (data().vex) {
<section class="evidence-panel__section">
<h4 class="evidence-panel__section-title">
VEX Claims
<span class="evidence-panel__vex-merged" [class]="'evidence-panel__vex-merged--' + data().vex!.mergedStatus">
Merged: {{ formatVexStatus(data().vex!.mergedStatus) }}
</span>
</h4>
<ul class="evidence-panel__vex-list">
@for (claim of data().vex!.claims; track $index) {
<li class="evidence-panel__vex-item" (click)="onNodeClick('vex', claim.sourceName)">
<span class="evidence-panel__vex-source">{{ claim.sourceName }} ({{ claim.source }})</span>
<span class="evidence-panel__vex-status" [class]="'evidence-panel__vex-status--' + claim.status">
{{ formatVexStatus(claim.status) }}
</span>
@if (claim.justification) {
<span class="evidence-panel__vex-justification">{{ claim.justification }}</span>
}
</li>
}
</ul>
</section>
}
<!-- Patches Section -->
@if (data().patches && data().patches!.length > 0) {
<section class="evidence-panel__section">
<h4 class="evidence-panel__section-title">Available Patches</h4>
<ul class="evidence-panel__patch-list">
@for (patch of data().patches; track $index) {
<li class="evidence-panel__patch-item">
<span class="evidence-panel__patch-version">{{ patch.fixedVersion }}</span>
<span class="evidence-panel__patch-date">({{ formatDate(patch.releaseDate) }})</span>
@if (patch.upgradePathAvailable) {
<span class="evidence-panel__patch-upgrade">Upgrade path available</span>
}
@if (patch.breakingChanges && patch.breakingChanges.length > 0) {
<span class="evidence-panel__patch-breaking">Breaking changes</span>
}
</li>
}
</ul>
</section>
}
<!-- Empty state -->
@if (!hasAnyEvidence()) {
<div class="evidence-panel__empty">
No evidence collected yet
</div>
}
</div>
}
</div>
`,
styles: [`
.evidence-panel {
padding: 1rem;
}
.evidence-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.evidence-panel__header-left {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.evidence-panel__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: #111827;
}
.evidence-panel__subtitle {
font-size: 0.6875rem;
color: #6b7280;
}
.evidence-panel__toggle {
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s ease;
}
.evidence-panel__toggle:hover {
background: #f3f4f6;
color: #111827;
}
.evidence-panel__content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.evidence-panel__section {
padding: 0.75rem;
background: #f9fafb;
border-radius: 6px;
}
.evidence-panel__section-title {
margin: 0 0 0.5rem 0;
font-size: 0.8125rem;
font-weight: 600;
color: #374151;
display: flex;
align-items: center;
gap: 0.5rem;
}
.evidence-panel__badge {
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.6875rem;
font-weight: 500;
}
.evidence-panel__badge--confirmed {
background: #dcfce7;
color: #166534;
}
.evidence-panel__reachability {
font-size: 0.875rem;
}
.evidence-panel__path {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
.evidence-panel__hop {
display: flex;
align-items: center;
gap: 0.25rem;
}
.evidence-panel__hop-name {
padding: 0.125rem 0.375rem;
background: #e0e7ff;
border-radius: 3px;
font-size: 0.75rem;
color: #3730a3;
cursor: pointer;
}
.evidence-panel__hop-name:hover {
background: #c7d2fe;
}
.evidence-panel__hop-location {
font-size: 0.6875rem;
color: #6b7280;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.evidence-panel__hop-arrow {
color: #9ca3af;
font-size: 0.875rem;
}
.evidence-panel__hop-count {
font-size: 0.75rem;
color: #6b7280;
}
.evidence-panel__runtime-list,
.evidence-panel__vex-list,
.evidence-panel__patch-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.evidence-panel__runtime-item,
.evidence-panel__vex-item,
.evidence-panel__patch-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
padding: 0.375rem;
background: white;
border-radius: 4px;
cursor: pointer;
}
.evidence-panel__runtime-item:hover,
.evidence-panel__vex-item:hover {
background: #f0f9ff;
}
.evidence-panel__runtime-type {
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
}
.evidence-panel__runtime-type--loaded {
background: #fef3c7;
color: #92400e;
}
.evidence-panel__runtime-type--invoked {
background: #fee2e2;
color: #991b1b;
}
.evidence-panel__runtime-type--executed {
background: #fecaca;
color: #7f1d1d;
}
.evidence-panel__runtime-location {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: #374151;
}
.evidence-panel__runtime-date {
font-size: 0.6875rem;
color: #9ca3af;
}
.evidence-panel__runtime-confidence {
margin-left: auto;
padding: 0.125rem 0.25rem;
font-size: 0.625rem;
border-radius: 2px;
}
.evidence-panel__runtime-confidence--high {
background: #dcfce7;
color: #166534;
}
.evidence-panel__runtime-confidence--medium {
background: #fef3c7;
color: #92400e;
}
.evidence-panel__runtime-confidence--low {
background: #f3f4f6;
color: #6b7280;
}
.evidence-panel__vex-source {
font-weight: 500;
color: #374151;
}
.evidence-panel__vex-merged,
.evidence-panel__vex-status {
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.evidence-panel__vex-merged--affected,
.evidence-panel__vex-status--affected {
background: #fecaca;
color: #991b1b;
}
.evidence-panel__vex-merged--not_affected,
.evidence-panel__vex-status--not_affected {
background: #dcfce7;
color: #166534;
}
.evidence-panel__vex-merged--fixed,
.evidence-panel__vex-status--fixed {
background: #dbeafe;
color: #1e40af;
}
.evidence-panel__vex-merged--under_investigation,
.evidence-panel__vex-status--under_investigation {
background: #fef3c7;
color: #92400e;
}
.evidence-panel__vex-justification {
width: 100%;
font-size: 0.75rem;
font-style: italic;
color: #6b7280;
margin-top: 0.25rem;
}
.evidence-panel__patch-version {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 500;
color: #374151;
}
.evidence-panel__patch-date {
font-size: 0.6875rem;
color: #9ca3af;
}
.evidence-panel__patch-upgrade {
padding: 0.125rem 0.375rem;
background: #dcfce7;
border-radius: 3px;
font-size: 0.6875rem;
color: #166534;
}
.evidence-panel__patch-breaking {
padding: 0.125rem 0.375rem;
background: #fef3c7;
border-radius: 3px;
font-size: 0.6875rem;
color: #92400e;
}
.evidence-panel__empty {
padding: 1rem;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
font-style: italic;
}
`]
})
export class EvidencePanelComponent {
/**
* Evidence data.
*/
readonly data = input.required<EvidenceData>();
/**
* Whether panel is collapsed.
*/
readonly collapsed = input(false);
/**
* Collapse state toggled.
*/
readonly collapseToggle = output<void>();
/**
* Evidence node clicked for drill-down.
*/
readonly evidenceNodeClick = output<{ type: string; id: string }>();
/**
* Whether there is any evidence.
*/
readonly hasAnyEvidence = computed(() => {
const d = this.data();
return !!(
d.reachability ||
(d.runtime && d.runtime.length > 0) ||
d.vex ||
(d.patches && d.patches.length > 0)
);
});
onToggle(): void {
this.collapseToggle.emit();
}
onNodeClick(type: string, id: string): void {
this.evidenceNodeClick.emit({ type, id });
}
formatDate(dateStr: string): string {
try {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
} catch {
return dateStr;
}
}
formatVexStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
}

View File

@@ -0,0 +1,230 @@
/**
* Finding Detail Layout Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-13
*
* Defines the 3-panel stacked layout for finding detail:
* 1. Verdict Panel (authoritative) - policy outcome
* 2. Evidence Panel (authoritative) - reachability, runtime, VEX
* 3. AI Assist Panel (assistant) - explanations, suggestions
*/
import { Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { VerdictPanelComponent, VerdictData } from './verdict-panel.component';
import { EvidencePanelComponent, EvidenceData } from './evidence-panel.component';
import { AiAssistPanelComponent, AiAssistData } from '../../../shared/components/ai/ai-assist-panel.component';
import { AiSummaryCitation } from '../../../shared/components/ai/ai-summary.component';
import { ExplainContext } from '../../../shared/components/ai/ai-explain-chip.component';
import { EvidenceType } from '../../../shared/components/ai/ai-needs-evidence-chip.component';
/**
* Complete finding detail data combining all panels.
*/
export interface FindingDetailData {
/** Unique finding ID */
findingId: string;
/** Vulnerability ID (CVE or advisory) */
vulnerabilityId: string;
/** Affected component PURL */
componentPurl: string;
/** Verdict data */
verdict: VerdictData;
/** Evidence data */
evidence: EvidenceData;
/** AI assist data (optional, loaded async) */
aiAssist?: AiAssistData | null;
}
@Component({
selector: 'stella-finding-detail-layout',
standalone: true,
imports: [
CommonModule,
VerdictPanelComponent,
EvidencePanelComponent,
AiAssistPanelComponent
],
template: `
<div class="finding-detail-layout">
<header class="finding-detail-layout__header">
<h2 class="finding-detail-layout__title">{{ data().vulnerabilityId }}</h2>
<span class="finding-detail-layout__subtitle">{{ data().componentPurl }}</span>
</header>
<div class="finding-detail-layout__panels">
<!-- Panel 1: Verdict (Authoritative) -->
<section class="finding-detail-layout__panel finding-detail-layout__panel--verdict">
<stella-verdict-panel
[data]="data().verdict"
[vulnerabilityId]="data().vulnerabilityId"
(verdictExplain)="onVerdictExplain()"
/>
</section>
<!-- Panel 2: Evidence (Authoritative, Collapsible) -->
<section class="finding-detail-layout__panel finding-detail-layout__panel--evidence">
<stella-evidence-panel
[data]="data().evidence"
[collapsed]="evidenceCollapsed()"
(collapseToggle)="onEvidenceCollapseToggle()"
(evidenceNodeClick)="onEvidenceNodeClick($event)"
/>
</section>
<!-- Panel 3: AI Assist (Non-Authoritative) -->
<section class="finding-detail-layout__panel finding-detail-layout__panel--ai">
<stella-ai-assist-panel
[data]="data().aiAssist ?? null"
[vulnerabilityId]="data().vulnerabilityId"
[componentPurl]="data().componentPurl"
(explain)="onExplain($event)"
(fix)="onFix($event)"
(draftVex)="onDraftVex($event)"
(gatherEvidence)="onGatherEvidence($event)"
(citationClicked)="onCitationClick($event)"
/>
</section>
</div>
</div>
`,
styles: [`
.finding-detail-layout {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.finding-detail-layout__header {
margin-bottom: 0.5rem;
}
.finding-detail-layout__title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.finding-detail-layout__subtitle {
font-size: 0.875rem;
color: #6b7280;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.finding-detail-layout__panels {
display: flex;
flex-direction: column;
gap: 1rem;
}
.finding-detail-layout__panel {
border-radius: 8px;
}
/* Verdict panel - most prominent (authoritative) */
.finding-detail-layout__panel--verdict {
background: white;
border: 2px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* Evidence panel - secondary (authoritative) */
.finding-detail-layout__panel--evidence {
background: white;
border: 1px solid #e5e7eb;
}
/* AI panel - tertiary (non-authoritative, visually subordinate) */
.finding-detail-layout__panel--ai {
background: rgba(249, 250, 251, 0.5);
border: 1px dashed #d1d5db;
}
`]
})
export class FindingDetailLayoutComponent {
/**
* Complete finding detail data.
*/
readonly data = input.required<FindingDetailData>();
/**
* Whether evidence panel is collapsed.
*/
readonly evidenceCollapsed = input(false);
/**
* Verdict explanation requested.
*/
readonly verdictExplain = output<void>();
/**
* Evidence panel collapse toggled.
*/
readonly evidenceCollapseToggle = output<boolean>();
/**
* Evidence node clicked for drill-down.
*/
readonly evidenceNodeClick = output<{ type: string; id: string }>();
/**
* AI explain action triggered.
*/
readonly explain = output<{ context: ExplainContext; subject: string }>();
/**
* AI fix action triggered.
*/
readonly fix = output<{ target: string; prReady: boolean }>();
/**
* AI draft VEX action triggered.
*/
readonly draftVex = output<{ vulnerabilityId: string; proposedStatus: string }>();
/**
* AI gather evidence action triggered.
*/
readonly gatherEvidence = output<{ evidenceType: EvidenceType; needed: string }>();
/**
* Citation clicked for drill-down.
*/
readonly citationClick = output<AiSummaryCitation>();
onVerdictExplain(): void {
this.verdictExplain.emit();
}
onEvidenceCollapseToggle(): void {
this.evidenceCollapseToggle.emit(!this.evidenceCollapsed());
}
onEvidenceNodeClick(event: { type: string; id: string }): void {
this.evidenceNodeClick.emit(event);
}
onExplain(event: { context: ExplainContext; subject: string }): void {
this.explain.emit(event);
}
onFix(event: { target: string; prReady: boolean }): void {
this.fix.emit(event);
}
onDraftVex(event: { vulnerabilityId: string; proposedStatus: string }): void {
this.draftVex.emit(event);
}
onGatherEvidence(event: { evidenceType: EvidenceType; needed: string }): void {
this.gatherEvidence.emit(event);
}
onCitationClick(citation: AiSummaryCitation): void {
this.citationClick.emit(citation);
}
}

View File

@@ -0,0 +1,8 @@
/**
* Finding Detail Components.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
*/
export * from './finding-detail-layout.component';
export * from './verdict-panel.component';
export * from './evidence-panel.component';

View File

@@ -0,0 +1,474 @@
/**
* Verdict Panel Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Task: AIUX-14
*
* Authoritative verdict panel showing:
* - Policy outcome (BLOCK/WARN/ALLOW)
* - Severity
* - SLA information
* - Scope (where it applies)
* - "What would change verdict" hint
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Policy outcome from trust lattice evaluation.
*/
export type PolicyOutcome = 'BLOCK' | 'WARN' | 'ALLOW' | 'UNKNOWN';
/**
* Severity level.
*/
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
/**
* Reachability confirmation status.
*/
export type ReachabilityStatus = 'confirmed' | 'unconfirmed' | 'not_reachable' | 'unknown';
/**
* Verdict panel data.
*/
export interface VerdictData {
/** Policy outcome */
outcome: PolicyOutcome;
/** Original severity */
severity: Severity;
/** Effective severity (after policy adjustments) */
effectiveSeverity?: Severity;
/** SLA information */
sla?: {
/** Days remaining */
daysRemaining: number;
/** Due date */
dueDate: string;
/** Whether overdue */
overdue: boolean;
};
/** Reachability status */
reachability: ReachabilityStatus;
/** Scope (environments, services) */
scope?: {
environments: string[];
services: string[];
};
/** What would change the verdict */
verdictChangeHint?: string;
/** Policy rule that produced this outcome */
policyRule?: string;
}
@Component({
selector: 'stella-verdict-panel',
standalone: true,
imports: [CommonModule],
template: `
<div class="verdict-panel">
<header class="verdict-panel__header">
<h3 class="verdict-panel__title">Verdict</h3>
<span class="verdict-panel__subtitle">(authoritative)</span>
</header>
<div class="verdict-panel__content">
<div class="verdict-panel__primary">
<!-- Outcome Badge -->
<span class="verdict-panel__outcome" [class]="outcomeClass()">
{{ data().outcome }}
</span>
<!-- Severity Badge -->
<span class="verdict-panel__severity" [class]="severityClass()">
{{ displaySeverity() }}
</span>
<!-- Reachability Badge -->
<span class="verdict-panel__reachability" [class]="reachabilityClass()">
{{ reachabilityLabel() }}
</span>
</div>
<!-- SLA Information -->
@if (data().sla) {
<div class="verdict-panel__sla" [class.verdict-panel__sla--overdue]="data().sla!.overdue">
<span class="verdict-panel__sla-label">SLA:</span>
@if (data().sla!.overdue) {
<span class="verdict-panel__sla-value verdict-panel__sla-value--overdue">
Overdue by {{ Math.abs(data().sla!.daysRemaining) }} days
</span>
} @else {
<span class="verdict-panel__sla-value">
{{ data().sla!.daysRemaining }} days remaining
</span>
}
</div>
}
<!-- Scope -->
@if (data().scope) {
<div class="verdict-panel__scope">
@if (data().scope!.environments.length > 0) {
<div class="verdict-panel__scope-row">
<span class="verdict-panel__scope-label">Environments:</span>
<span class="verdict-panel__scope-values">
@for (env of data().scope!.environments; track env) {
<span class="verdict-panel__scope-tag">{{ env }}</span>
}
</span>
</div>
}
@if (data().scope!.services.length > 0) {
<div class="verdict-panel__scope-row">
<span class="verdict-panel__scope-label">Services:</span>
<span class="verdict-panel__scope-values">
@for (svc of data().scope!.services; track svc) {
<span class="verdict-panel__scope-tag">{{ svc }}</span>
}
</span>
</div>
}
</div>
}
<!-- What would change verdict -->
@if (data().verdictChangeHint) {
<div class="verdict-panel__hint">
<span class="verdict-panel__hint-label">What would change verdict:</span>
<span class="verdict-panel__hint-value">{{ data().verdictChangeHint }}</span>
<button class="verdict-panel__hint-explain" (click)="onExplainClick()">
Explain
</button>
</div>
}
<!-- Policy rule reference -->
@if (data().policyRule) {
<div class="verdict-panel__rule">
<span class="verdict-panel__rule-label">Policy:</span>
<code class="verdict-panel__rule-value">{{ data().policyRule }}</code>
</div>
}
</div>
</div>
`,
styles: [`
.verdict-panel {
padding: 1rem;
}
.verdict-panel__header {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.verdict-panel__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: #111827;
}
.verdict-panel__subtitle {
font-size: 0.6875rem;
color: #6b7280;
}
.verdict-panel__content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.verdict-panel__primary {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.verdict-panel__outcome {
padding: 0.375rem 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
}
.verdict-panel__outcome--block {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.verdict-panel__outcome--warn {
background: #fffbeb;
color: #d97706;
border: 1px solid #fde68a;
}
.verdict-panel__outcome--allow {
background: #f0fdf4;
color: #16a34a;
border: 1px solid #bbf7d0;
}
.verdict-panel__outcome--unknown {
background: #f3f4f6;
color: #6b7280;
border: 1px solid #d1d5db;
}
.verdict-panel__severity {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 500;
text-transform: capitalize;
}
.verdict-panel__severity--critical {
background: #7f1d1d;
color: white;
}
.verdict-panel__severity--high {
background: #dc2626;
color: white;
}
.verdict-panel__severity--medium {
background: #f59e0b;
color: white;
}
.verdict-panel__severity--low {
background: #3b82f6;
color: white;
}
.verdict-panel__severity--unknown {
background: #6b7280;
color: white;
}
.verdict-panel__reachability {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 500;
}
.verdict-panel__reachability--confirmed {
background: #fef2f2;
color: #dc2626;
}
.verdict-panel__reachability--unconfirmed {
background: #fefce8;
color: #ca8a04;
}
.verdict-panel__reachability--not_reachable {
background: #f0fdf4;
color: #16a34a;
}
.verdict-panel__reachability--unknown {
background: #f3f4f6;
color: #6b7280;
}
.verdict-panel__sla {
display: flex;
align-items: baseline;
gap: 0.375rem;
font-size: 0.875rem;
}
.verdict-panel__sla-label {
color: #6b7280;
font-weight: 500;
}
.verdict-panel__sla-value {
color: #111827;
}
.verdict-panel__sla-value--overdue {
color: #dc2626;
font-weight: 600;
}
.verdict-panel__sla--overdue {
padding: 0.5rem;
background: #fef2f2;
border-radius: 4px;
}
.verdict-panel__scope {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
}
.verdict-panel__scope-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.verdict-panel__scope-label {
color: #6b7280;
font-weight: 500;
min-width: 100px;
}
.verdict-panel__scope-values {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.verdict-panel__scope-tag {
padding: 0.125rem 0.375rem;
background: #e5e7eb;
border-radius: 3px;
font-size: 0.75rem;
color: #374151;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.verdict-panel__hint {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.375rem;
padding: 0.625rem;
background: #f0f9ff;
border-radius: 4px;
font-size: 0.875rem;
}
.verdict-panel__hint-label {
color: #0369a1;
font-weight: 500;
}
.verdict-panel__hint-value {
color: #0c4a6e;
}
.verdict-panel__hint-explain {
margin-left: auto;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid #0284c7;
border-radius: 4px;
color: #0284c7;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.verdict-panel__hint-explain:hover {
background: #0284c7;
color: white;
}
.verdict-panel__rule {
display: flex;
align-items: baseline;
gap: 0.375rem;
font-size: 0.8125rem;
}
.verdict-panel__rule-label {
color: #6b7280;
}
.verdict-panel__rule-value {
padding: 0.125rem 0.375rem;
background: #f3f4f6;
border-radius: 3px;
font-size: 0.75rem;
color: #374151;
}
`]
})
export class VerdictPanelComponent {
protected readonly Math = Math;
/**
* Verdict data.
*/
readonly data = input.required<VerdictData>();
/**
* Vulnerability ID for context.
*/
readonly vulnerabilityId = input<string>('');
/**
* Explain verdict clicked.
*/
readonly verdictExplain = output<void>();
/**
* Outcome CSS class.
*/
readonly outcomeClass = computed(() => {
const outcome = this.data().outcome.toLowerCase();
return `verdict-panel__outcome--${outcome}`;
});
/**
* Severity CSS class.
*/
readonly severityClass = computed(() => {
const severity = this.data().effectiveSeverity ?? this.data().severity;
return `verdict-panel__severity--${severity}`;
});
/**
* Display severity (effective if different from original).
*/
readonly displaySeverity = computed(() => {
const original = this.data().severity;
const effective = this.data().effectiveSeverity;
if (effective && effective !== original) {
return `${effective} (was ${original})`;
}
return original;
});
/**
* Reachability CSS class.
*/
readonly reachabilityClass = computed(() => {
const status = this.data().reachability;
return `verdict-panel__reachability--${status}`;
});
/**
* Reachability display label.
*/
readonly reachabilityLabel = computed(() => {
const status = this.data().reachability;
switch (status) {
case 'confirmed': return 'Reachable: Confirmed';
case 'unconfirmed': return 'Reachable: Unconfirmed';
case 'not_reachable': return 'Not Reachable';
case 'unknown': return 'Reachability: Unknown';
default: return status;
}
});
onExplainClick(): void {
this.verdictExplain.emit();
}
}

View File

@@ -0,0 +1,647 @@
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { RuleConflict, PolicyValidateResult } from '../../../core/api/advisory-ai.models';
/**
* Conflict visualizer for policy rule conflicts.
*
* @task POLICY-23
*
* Features:
* - Highlight conflicting rules
* - Show resolution suggestions
* - Allow quick fixes
*/
@Component({
selector: 'stellaops-conflict-visualizer',
standalone: true,
imports: [CommonModule],
template: `
<section class="conflict-panel" [class.has-conflicts]="hasConflicts()">
<header class="panel-header">
<h3 class="title">
<svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
Validation Results
</h3>
@if (validateResult) {
<span class="status-badge" [class]="validateResult.valid ? 'valid' : 'invalid'">
{{ validateResult.valid ? 'Valid' : 'Issues Found' }}
</span>
}
</header>
<div class="panel-content">
@if (!validateResult) {
<div class="empty-state">
<p>Run validation to check for conflicts.</p>
</div>
} @else if (validateResult.valid) {
<div class="valid-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="valid-icon">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<h4>All Rules Valid</h4>
<p>No conflicts, unreachable conditions, or loops detected.</p>
<div class="coverage-info">
<span class="coverage-label">Coverage:</span>
<div class="coverage-bar">
<div class="coverage-fill" [style.width.%]="validateResult.coverage * 100"></div>
</div>
<span class="coverage-value">{{ (validateResult.coverage * 100).toFixed(0) }}%</span>
</div>
</div>
} @else {
<!-- Conflicts -->
@if (validateResult.conflicts.length > 0) {
<section class="conflicts-section">
<h4 class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
Conflicts ({{ validateResult.conflicts.length }})
</h4>
<ul class="conflicts-list">
@for (conflict of validateResult.conflicts; track conflict.ruleId1 + conflict.ruleId2) {
<li class="conflict-item" [class]="conflict.severity">
<div class="conflict-header">
<span class="conflict-rules">
<code>{{ conflict.ruleId1 }}</code>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="conflict-icon">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
<code>{{ conflict.ruleId2 }}</code>
</span>
<span class="conflict-severity" [class]="conflict.severity">
{{ conflict.severity }}
</span>
</div>
<p class="conflict-description">{{ conflict.description }}</p>
<div class="conflict-resolution">
<span class="resolution-label">Suggested fix:</span>
<span class="resolution-text">{{ conflict.suggestedResolution }}</span>
</div>
<div class="conflict-actions">
<button
type="button"
class="fix-btn"
(click)="applyFix.emit(conflict)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
Apply Fix
</button>
<button
type="button"
class="view-btn"
(click)="viewConflict.emit(conflict)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
View Rules
</button>
</div>
</li>
}
</ul>
</section>
}
<!-- Unreachable Conditions -->
@if (validateResult.unreachableConditions.length > 0) {
<section class="unreachable-section">
<h4 class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
Unreachable Conditions ({{ validateResult.unreachableConditions.length }})
</h4>
<ul class="unreachable-list">
@for (condition of validateResult.unreachableConditions; track condition) {
<li class="unreachable-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
<line x1="12" y1="2" x2="12" y2="12"/>
</svg>
<span>{{ condition }}</span>
</li>
}
</ul>
</section>
}
<!-- Potential Loops -->
@if (validateResult.potentialLoops.length > 0) {
<section class="loops-section">
<h4 class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
Potential Loops ({{ validateResult.potentialLoops.length }})
</h4>
<ul class="loops-list">
@for (loop of validateResult.potentialLoops; track loop) {
<li class="loop-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>{{ loop }}</span>
</li>
}
</ul>
</section>
}
<!-- Coverage -->
<section class="coverage-section">
<h4 class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
Test Coverage
</h4>
<div class="coverage-display">
<div class="coverage-bar large">
<div
class="coverage-fill"
[class.low]="validateResult.coverage < 0.6"
[class.medium]="validateResult.coverage >= 0.6 && validateResult.coverage < 0.8"
[class.high]="validateResult.coverage >= 0.8"
[style.width.%]="validateResult.coverage * 100">
</div>
</div>
<span class="coverage-value">{{ (validateResult.coverage * 100).toFixed(0) }}%</span>
</div>
@if (validateResult.coverage < 0.8) {
<p class="coverage-warning">
Coverage is below 80%. Consider adding more test cases.
</p>
}
</section>
}
</div>
@if (validateResult && !validateResult.valid) {
<footer class="panel-actions">
<button
type="button"
class="action-btn secondary"
(click)="ignoreAll.emit()">
Ignore All
</button>
<button
type="button"
class="action-btn primary"
(click)="fixAll.emit()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
Fix All Issues
</button>
</footer>
}
</section>
`,
styles: [`
.conflict-panel {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
}
.conflict-panel.has-conflicts {
border-color: var(--color-warning-border, #fcd34d);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.title-icon {
width: 1.125rem;
height: 1.125rem;
color: var(--color-warning, #f59e0b);
}
.status-badge {
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 9999px;
}
.status-badge.valid {
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
}
.status-badge.invalid {
color: var(--color-error-text, #991b1b);
background: var(--color-error-bg, #fee2e2);
}
.panel-content {
padding: 1rem;
}
.empty-state {
padding: 2rem;
text-align: center;
color: var(--color-text-secondary, #6b7280);
}
.valid-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
text-align: center;
}
.valid-icon {
width: 3rem;
height: 3rem;
margin-bottom: 0.75rem;
color: var(--color-success, #10b981);
}
.valid-state h4 {
margin: 0 0 0.5rem;
font-size: 1rem;
color: var(--color-text-primary, #111827);
}
.valid-state p {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary, #6b7280);
}
.coverage-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-radius: 0.375rem;
}
.coverage-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
}
.coverage-bar {
flex: 1;
height: 0.5rem;
max-width: 10rem;
background: var(--color-border, #e5e7eb);
border-radius: 9999px;
overflow: hidden;
}
.coverage-bar.large {
height: 0.75rem;
max-width: 100%;
}
.coverage-fill {
height: 100%;
background: var(--color-success, #10b981);
border-radius: 9999px;
transition: width 0.3s ease;
}
.coverage-fill.low {
background: var(--color-error, #ef4444);
}
.coverage-fill.medium {
background: var(--color-warning, #f59e0b);
}
.coverage-fill.high {
background: var(--color-success, #10b981);
}
.coverage-value {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.section-title svg {
width: 1rem;
height: 1rem;
}
.conflicts-section {
margin-bottom: 1.5rem;
}
.conflicts-section .section-title svg {
color: var(--color-error, #ef4444);
}
.conflicts-list {
margin: 0;
padding: 0;
list-style: none;
}
.conflict-item {
margin-bottom: 0.75rem;
padding: 0.75rem;
background: var(--color-surface-alt, #f9fafb);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.375rem;
}
.conflict-item.error {
border-left: 3px solid var(--color-error, #ef4444);
}
.conflict-item.warning {
border-left: 3px solid var(--color-warning, #f59e0b);
}
.conflict-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.conflict-rules {
display: flex;
align-items: center;
gap: 0.5rem;
}
.conflict-rules code {
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
background: var(--color-code-bg, #f3f4f6);
border-radius: 0.25rem;
}
.conflict-icon {
width: 1rem;
height: 1rem;
color: var(--color-error, #ef4444);
}
.conflict-severity {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
border-radius: 0.25rem;
}
.conflict-severity.error {
color: #991b1b;
background: #fee2e2;
}
.conflict-severity.warning {
color: #92400e;
background: #fef3c7;
}
.conflict-description {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
color: var(--color-text-primary, #111827);
}
.conflict-resolution {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
margin-bottom: 0.75rem;
background: var(--color-info-bg, #dbeafe);
border-radius: 0.25rem;
font-size: 0.8125rem;
}
.resolution-label {
flex-shrink: 0;
font-weight: 500;
color: var(--color-info-text, #1e40af);
}
.resolution-text {
color: var(--color-info-text, #1e40af);
}
.conflict-actions {
display: flex;
gap: 0.5rem;
}
.fix-btn,
.view-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.25rem;
cursor: pointer;
}
.fix-btn {
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
border: 1px solid var(--color-success-border, #6ee7b7);
}
.fix-btn:hover {
background: var(--color-success-hover, #a7f3d0);
}
.view-btn {
color: var(--color-text-primary, #374151);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
}
.view-btn:hover {
background: var(--color-hover, #f9fafb);
}
.fix-btn svg,
.view-btn svg {
width: 0.875rem;
height: 0.875rem;
}
.unreachable-section,
.loops-section {
margin-bottom: 1.5rem;
}
.unreachable-section .section-title svg {
color: var(--color-warning, #f59e0b);
}
.loops-section .section-title svg {
color: var(--color-info, #3b82f6);
}
.unreachable-list,
.loops-list {
margin: 0;
padding: 0;
list-style: none;
}
.unreachable-item,
.loop-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.8125rem;
color: var(--color-text-primary, #111827);
background: var(--color-surface-alt, #f9fafb);
border-radius: 0.25rem;
}
.unreachable-item svg,
.loop-item svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
color: var(--color-text-secondary, #6b7280);
}
.coverage-section {
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.coverage-section .section-title svg {
color: var(--color-success, #10b981);
}
.coverage-display {
display: flex;
align-items: center;
gap: 0.75rem;
}
.coverage-warning {
margin: 0.75rem 0 0;
padding: 0.5rem;
font-size: 0.8125rem;
color: var(--color-warning-text, #92400e);
background: var(--color-warning-bg, #fef3c7);
border-radius: 0.25rem;
}
.panel-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.action-btn svg {
width: 1rem;
height: 1rem;
}
.action-btn.secondary {
color: var(--color-text-primary, #374151);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
}
.action-btn.secondary:hover {
background: var(--color-hover, #f9fafb);
}
.action-btn.primary {
color: var(--color-success-contrast, #ffffff);
background: var(--color-success, #10b981);
border: 1px solid var(--color-success, #10b981);
}
.action-btn.primary:hover {
background: var(--color-success-hover, #059669);
border-color: var(--color-success-hover, #059669);
}
`]
})
export class ConflictVisualizerComponent {
@Input() validateResult: PolicyValidateResult | null = null;
@Output() readonly applyFix = new EventEmitter<RuleConflict>();
@Output() readonly viewConflict = new EventEmitter<RuleConflict>();
@Output() readonly fixAll = new EventEmitter<void>();
@Output() readonly ignoreAll = new EventEmitter<void>();
readonly hasConflicts = computed(() => {
if (!this.validateResult) return false;
return !this.validateResult.valid;
});
}

View File

@@ -0,0 +1,661 @@
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { GeneratedRule, PolicyGenerateResult, RuleDisposition } from '../../../core/api/advisory-ai.models';
/**
* Live rule preview component showing generated lattice rules.
*
* @task POLICY-21
*
* Features:
* - Show generated rules as user types
* - Syntax highlighting for lattice expressions
* - Rule editing and deletion
* - Validation warnings
*/
@Component({
selector: 'stellaops-live-rule-preview',
standalone: true,
imports: [CommonModule],
template: `
<section class="rule-preview-panel" [class.loading]="loading">
<header class="panel-header">
<h3 class="title">
<svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
Generated Rules
@if (generateResult && generateResult.rules.length > 0) {
<span class="rule-count">{{ generateResult.rules.length }}</span>
}
</h3>
@if (generateResult && generateResult.warnings.length > 0) {
<span class="warnings-badge">
{{ generateResult.warnings.length }} warning{{ generateResult.warnings.length > 1 ? 's' : '' }}
</span>
}
</header>
<div class="panel-content">
@if (loading) {
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Generating rules...</p>
</div>
} @else if (generateResult && generateResult.rules.length > 0) {
<!-- Warnings -->
@if (generateResult.warnings.length > 0) {
<div class="warnings-section">
@for (warning of generateResult.warnings; track warning) {
<div class="warning-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span>{{ warning }}</span>
</div>
}
</div>
}
<!-- Rules List -->
<ul class="rules-list">
@for (rule of generateResult.rules; track rule.ruleId; let i = $index) {
<li class="rule-item" [class.expanded]="expandedRules().has(rule.ruleId)">
<div class="rule-header" (click)="toggleRule(rule.ruleId)">
<span class="rule-order">{{ i + 1 }}</span>
<span class="rule-name">{{ rule.name }}</span>
<span class="rule-disposition" [class]="'disposition-' + rule.disposition.toLowerCase()">
{{ rule.disposition }}
</span>
<span class="rule-priority">P{{ rule.priority }}</span>
<label class="rule-enabled" (click)="$event.stopPropagation()">
<input
type="checkbox"
[checked]="rule.enabled"
(change)="toggleEnabled.emit({ ruleId: rule.ruleId, enabled: !rule.enabled })">
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
<button
type="button"
class="delete-btn"
title="Delete rule"
(click)="$event.stopPropagation(); deleteRule.emit(rule.ruleId)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
<svg class="expand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@if (expandedRules().has(rule.ruleId)) {
<polyline points="18 15 12 9 6 15"/>
} @else {
<polyline points="6 9 12 15 18 9"/>
}
</svg>
</div>
@if (expandedRules().has(rule.ruleId)) {
<div class="rule-content">
<p class="rule-description">{{ rule.description }}</p>
<!-- Lattice Expression -->
<div class="lattice-section">
<h5 class="section-label">Lattice Expression</h5>
<div class="lattice-expression">
<code [innerHTML]="highlightExpression(rule.latticeExpression)"></code>
</div>
</div>
<!-- Conditions -->
<div class="conditions-section">
<h5 class="section-label">Conditions</h5>
<ul class="conditions-list">
@for (condition of rule.conditions; track condition.field) {
<li class="condition-item">
<span class="condition-field">{{ condition.field }}</span>
<span class="condition-operator">{{ condition.operator }}</span>
<span class="condition-value">{{ formatValue(condition.value) }}</span>
</li>
}
</ul>
</div>
<!-- Actions -->
<div class="rule-actions">
<button
type="button"
class="edit-btn"
(click)="editRule.emit(rule)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
Edit
</button>
<button
type="button"
class="duplicate-btn"
(click)="duplicateRule.emit(rule)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
Duplicate
</button>
</div>
</div>
}
</li>
}
</ul>
} @else {
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="empty-icon">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
<p>No rules generated yet.</p>
<p class="empty-hint">Enter a policy description to generate lattice rules.</p>
</div>
}
</div>
@if (generateResult && generateResult.rules.length > 0) {
<footer class="panel-actions">
<button
type="button"
class="action-btn secondary"
(click)="clearRules.emit()">
Clear All
</button>
<button
type="button"
class="action-btn primary"
(click)="validateRules.emit()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 11 12 14 22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
Validate Rules
</button>
</footer>
}
</section>
`,
styles: [`
.rule-preview-panel {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.title-icon {
width: 1.125rem;
height: 1.125rem;
color: var(--color-primary, #3b82f6);
}
.rule-count {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
background: var(--color-primary-bg, #eff6ff);
border-radius: 9999px;
}
.warnings-badge {
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-warning-text, #92400e);
background: var(--color-warning-bg, #fef3c7);
border-radius: 9999px;
}
.panel-content {
padding: 1rem;
max-height: 30rem;
overflow-y: auto;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
color: var(--color-text-secondary, #6b7280);
}
.loading-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border, #e5e7eb);
border-top-color: var(--color-primary, #3b82f6);
border-radius: 50%;
animation: spin 0.75s linear infinite;
margin-bottom: 0.75rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-icon {
width: 3rem;
height: 3rem;
margin-bottom: 0.75rem;
color: var(--color-border, #d1d5db);
}
.empty-hint {
font-size: 0.8125rem;
color: var(--color-text-tertiary, #9ca3af);
}
.warnings-section {
margin-bottom: 1rem;
}
.warning-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
font-size: 0.8125rem;
color: var(--color-warning-text, #92400e);
background: var(--color-warning-bg, #fef3c7);
border-radius: 0.375rem;
}
.warning-item svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.rules-list {
margin: 0;
padding: 0;
list-style: none;
}
.rule-item {
margin-bottom: 0.5rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.375rem;
overflow: hidden;
}
.rule-header {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem;
cursor: pointer;
background: var(--color-surface, #ffffff);
transition: background 0.15s;
}
.rule-header:hover {
background: var(--color-hover, #f9fafb);
}
.rule-order {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
background: var(--color-surface-alt, #f3f4f6);
border-radius: 50%;
}
.rule-name {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary, #111827);
}
.rule-disposition {
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
border-radius: 0.25rem;
}
.rule-disposition.disposition-block {
color: #991b1b;
background: #fee2e2;
}
.rule-disposition.disposition-warn {
color: #92400e;
background: #fef3c7;
}
.rule-disposition.disposition-allow {
color: #065f46;
background: #d1fae5;
}
.rule-disposition.disposition-review {
color: #1e40af;
background: #dbeafe;
}
.rule-disposition.disposition-escalate {
color: #7c3aed;
background: #ede9fe;
}
.rule-priority {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
}
.rule-enabled {
display: inline-flex;
cursor: pointer;
}
.rule-enabled input {
display: none;
}
.toggle-track {
display: inline-flex;
align-items: center;
width: 2rem;
height: 1.125rem;
padding: 0.125rem;
background: var(--color-toggle-off, #d1d5db);
border-radius: 9999px;
transition: background 0.2s;
}
.rule-enabled input:checked + .toggle-track {
background: var(--color-success, #10b981);
}
.toggle-thumb {
width: 0.875rem;
height: 0.875rem;
background: var(--color-surface, #ffffff);
border-radius: 50%;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
transition: transform 0.2s;
}
.rule-enabled input:checked + .toggle-track .toggle-thumb {
transform: translateX(0.875rem);
}
.delete-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
background: transparent;
border: none;
border-radius: 0.25rem;
cursor: pointer;
color: var(--color-text-secondary, #9ca3af);
}
.delete-btn:hover {
color: var(--color-error, #ef4444);
background: var(--color-error-bg, #fee2e2);
}
.delete-btn svg {
width: 1rem;
height: 1rem;
}
.expand-icon {
width: 1.25rem;
height: 1.25rem;
color: var(--color-text-secondary, #6b7280);
}
.rule-content {
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface-alt, #f9fafb);
}
.rule-description {
margin: 0 0 1rem;
font-size: 0.8125rem;
color: var(--color-text-secondary, #6b7280);
}
.section-label {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary, #6b7280);
}
.lattice-section {
margin-bottom: 1rem;
}
.lattice-expression {
padding: 0.75rem;
background: var(--color-code-bg, #1f2937);
border-radius: 0.375rem;
overflow-x: auto;
}
.lattice-expression code {
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: var(--color-code-text, #e5e7eb);
white-space: pre;
}
.lattice-expression :deep(.atom) {
color: #93c5fd;
}
.lattice-expression :deep(.operator) {
color: #f472b6;
}
.lattice-expression :deep(.disposition) {
color: #34d399;
}
.lattice-expression :deep(.condition) {
color: #fbbf24;
}
.conditions-section {
margin-bottom: 1rem;
}
.conditions-list {
margin: 0;
padding: 0;
list-style: none;
}
.condition-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0;
font-size: 0.8125rem;
}
.condition-field {
font-weight: 500;
color: var(--color-text-primary, #111827);
}
.condition-operator {
color: var(--color-text-secondary, #6b7280);
}
.condition-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: var(--color-primary, #3b82f6);
}
.rule-actions {
display: flex;
gap: 0.5rem;
}
.edit-btn,
.duplicate-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-primary, #374151);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.25rem;
cursor: pointer;
}
.edit-btn:hover,
.duplicate-btn:hover {
background: var(--color-hover, #f9fafb);
}
.edit-btn svg,
.duplicate-btn svg {
width: 0.875rem;
height: 0.875rem;
}
.panel-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.action-btn svg {
width: 1rem;
height: 1rem;
}
.action-btn.secondary {
color: var(--color-text-primary, #374151);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
}
.action-btn.secondary:hover {
background: var(--color-hover, #f9fafb);
}
.action-btn.primary {
color: var(--color-primary-contrast, #ffffff);
background: var(--color-primary, #3b82f6);
border: 1px solid var(--color-primary, #3b82f6);
}
.action-btn.primary:hover {
background: var(--color-primary-hover, #2563eb);
border-color: var(--color-primary-hover, #2563eb);
}
`]
})
export class LiveRulePreviewComponent {
@Input() generateResult: PolicyGenerateResult | null = null;
@Input() loading = false;
@Output() readonly editRule = new EventEmitter<GeneratedRule>();
@Output() readonly deleteRule = new EventEmitter<string>();
@Output() readonly duplicateRule = new EventEmitter<GeneratedRule>();
@Output() readonly toggleEnabled = new EventEmitter<{ ruleId: string; enabled: boolean }>();
@Output() readonly validateRules = new EventEmitter<void>();
@Output() readonly clearRules = new EventEmitter<void>();
readonly expandedRules = signal<Set<string>>(new Set());
toggleRule(ruleId: string): void {
this.expandedRules.update(set => {
const newSet = new Set(set);
if (newSet.has(ruleId)) {
newSet.delete(ruleId);
} else {
newSet.add(ruleId);
}
return newSet;
});
}
highlightExpression(expression: string): string {
// Simple syntax highlighting for lattice expressions
return expression
.replace(/\b(Present|Applies|Reachable|Mitigated|Fixed|Misattributed)\b/g, '<span class="atom">$1</span>')
.replace(/([∧∨¬→])/g, '<span class="operator">$1</span>')
.replace(/\b(Block|Warn|Allow|Review|Escalate)\b/g, '<span class="disposition">$1</span>')
.replace(/(\w+)=([^\s∧]+)/g, '<span class="condition">$1=$2</span>');
}
formatValue(value: unknown): string {
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'string') return `"${value}"`;
return String(value);
}
}

View File

@@ -0,0 +1,843 @@
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type {
PolicyTestCase,
PolicyTestResult,
TestCaseType,
RuleDisposition,
} from '../../../core/api/advisory-ai.models';
/**
* Test case panel for policy validation.
*
* @task POLICY-22
*
* Features:
* - Show auto-generated test cases
* - Allow manual test case additions
* - Run validation and show results
* - Filter by test type
*/
@Component({
selector: 'stellaops-test-case-panel',
standalone: true,
imports: [CommonModule],
template: `
<section class="test-panel">
<header class="panel-header">
<h3 class="title">
<svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
Test Cases
@if (testCases.length > 0) {
<span class="test-count">{{ testCases.length }}</span>
}
</h3>
@if (testResults.length > 0) {
<span class="results-summary" [class]="resultsSummaryClass()">
{{ passedCount() }}/{{ testResults.length }} passed
</span>
}
</header>
<!-- Filters -->
<div class="filters-bar">
<div class="type-filters">
@for (type of testTypes; track type.value) {
<button
type="button"
class="filter-btn"
[class.active]="activeFilter() === type.value"
(click)="activeFilter.set(type.value)">
{{ type.label }}
<span class="filter-count">{{ countByType(type.value) }}</span>
</button>
}
</div>
<button
type="button"
class="add-test-btn"
(click)="showAddForm.set(true)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Add Test
</button>
</div>
<div class="panel-content">
@if (running) {
<div class="running-state">
<div class="running-spinner"></div>
<p>Running {{ testCases.length }} test cases...</p>
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="runProgress()"></div>
</div>
</div>
}
<!-- Add Test Form -->
@if (showAddForm()) {
<div class="add-test-form">
<h4 class="form-title">Add Manual Test Case</h4>
<div class="form-row">
<label>
Description
<input
type="text"
[(ngModel)]="newTest.description"
placeholder="Describe what this test validates">
</label>
</div>
<div class="form-row">
<label>
Type
<select [(ngModel)]="newTest.type">
<option value="positive">Positive</option>
<option value="negative">Negative</option>
<option value="boundary">Boundary</option>
<option value="manual">Manual</option>
</select>
</label>
<label>
Expected Disposition
<select [(ngModel)]="newTest.expectedDisposition">
<option value="Block">Block</option>
<option value="Warn">Warn</option>
<option value="Allow">Allow</option>
<option value="Review">Review</option>
<option value="Escalate">Escalate</option>
</select>
</label>
</div>
<div class="form-row">
<label>
Input JSON
<textarea
[(ngModel)]="newTest.inputJson"
placeholder='{"severity": "critical", "reachable": true}'
rows="3">
</textarea>
</label>
</div>
<div class="form-actions">
<button type="button" class="cancel-btn" (click)="showAddForm.set(false)">
Cancel
</button>
<button type="button" class="save-btn" (click)="addManualTest()">
Add Test Case
</button>
</div>
</div>
}
<!-- Test Cases List -->
@if (filteredTests().length > 0) {
<ul class="tests-list">
@for (test of filteredTests(); track test.testId) {
<li class="test-item" [class]="testResultClass(test.testId)">
<div class="test-header">
<span class="test-type" [class]="'type-' + test.type">
{{ testTypeLabel(test.type) }}
</span>
<span class="test-description">{{ test.description }}</span>
@if (getResult(test.testId); as result) {
<span class="test-result" [class]="result.passed ? 'passed' : 'failed'">
@if (result.passed) {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Passed
} @else {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
Failed
}
</span>
}
<button
type="button"
class="delete-test-btn"
title="Delete test"
(click)="deleteTest.emit(test.testId)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
<div class="test-details">
<div class="detail-row">
<span class="detail-label">Input:</span>
<code class="detail-value">{{ formatInput(test.input) }}</code>
</div>
<div class="detail-row">
<span class="detail-label">Expected:</span>
<span class="expected-disposition" [class]="'disposition-' + (test.expectedDisposition || 'any')?.toLowerCase()">
{{ test.expectedDisposition || 'Any' }}
</span>
@if (test.matchedRuleId) {
<span class="matched-rule">→ {{ test.matchedRuleId }}</span>
}
@if (test.shouldNotMatch) {
<span class="should-not-match">≠ {{ test.shouldNotMatch }}</span>
}
</div>
@if (getResult(test.testId); as result) {
@if (!result.passed && result.error) {
<div class="error-row">
<span class="error-label">Error:</span>
<span class="error-message">{{ result.error }}</span>
</div>
}
@if (result.actualDisposition && result.actualDisposition !== test.expectedDisposition) {
<div class="actual-row">
<span class="actual-label">Actual:</span>
<span class="actual-disposition" [class]="'disposition-' + result.actualDisposition.toLowerCase()">
{{ result.actualDisposition }}
</span>
</div>
}
}
</div>
</li>
}
</ul>
} @else if (!showAddForm()) {
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="empty-icon">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<p>No test cases yet.</p>
<p class="empty-hint">Test cases will be auto-generated after rules are created.</p>
</div>
}
</div>
@if (testCases.length > 0) {
<footer class="panel-actions">
<button
type="button"
class="action-btn secondary"
[disabled]="running"
(click)="regenerateTests.emit()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
Regenerate
</button>
<button
type="button"
class="action-btn primary"
[disabled]="running"
(click)="runTests.emit()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Run All Tests
</button>
</footer>
}
</section>
`,
styles: [`
.test-panel {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.title-icon {
width: 1.125rem;
height: 1.125rem;
color: var(--color-primary, #3b82f6);
}
.test-count {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
background: var(--color-primary-bg, #eff6ff);
border-radius: 9999px;
}
.results-summary {
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
}
.results-summary.all-passed {
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
}
.results-summary.some-failed {
color: var(--color-error-text, #991b1b);
background: var(--color-error-bg, #fee2e2);
}
.filters-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.type-filters {
display: flex;
gap: 0.375rem;
}
.filter-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
color: var(--color-text-secondary, #6b7280);
background: transparent;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.25rem;
cursor: pointer;
}
.filter-btn:hover {
background: var(--color-hover, #f9fafb);
}
.filter-btn.active {
color: var(--color-primary-text, #1e40af);
background: var(--color-primary-bg, #eff6ff);
border-color: var(--color-primary-border, #bfdbfe);
}
.filter-count {
padding: 0 0.375rem;
font-size: 0.6875rem;
background: var(--color-surface-alt, #f3f4f6);
border-radius: 9999px;
}
.filter-btn.active .filter-count {
background: var(--color-primary-border, #bfdbfe);
}
.add-test-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-primary-text, #1e40af);
background: var(--color-primary-bg, #eff6ff);
border: 1px solid var(--color-primary-border, #bfdbfe);
border-radius: 0.25rem;
cursor: pointer;
}
.add-test-btn:hover {
background: var(--color-primary-hover, #dbeafe);
}
.add-test-btn svg {
width: 0.875rem;
height: 0.875rem;
}
.panel-content {
padding: 1rem;
max-height: 25rem;
overflow-y: auto;
}
.running-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
text-align: center;
}
.running-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border, #e5e7eb);
border-top-color: var(--color-primary, #3b82f6);
border-radius: 50%;
animation: spin 0.75s linear infinite;
margin-bottom: 0.75rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.progress-bar {
width: 100%;
max-width: 15rem;
height: 0.375rem;
margin-top: 0.75rem;
background: var(--color-border, #e5e7eb);
border-radius: 9999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-primary, #3b82f6);
transition: width 0.3s ease;
}
.add-test-form {
padding: 1rem;
margin-bottom: 1rem;
background: var(--color-surface-alt, #f9fafb);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.375rem;
}
.form-title {
margin: 0 0 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.form-row {
display: flex;
gap: 1rem;
margin-bottom: 0.75rem;
}
.form-row label {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
}
.form-row input,
.form-row select,
.form-row textarea {
padding: 0.5rem;
font-size: 0.875rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.25rem;
}
.form-row textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.cancel-btn,
.save-btn {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 0.25rem;
cursor: pointer;
}
.cancel-btn {
color: var(--color-text-secondary, #6b7280);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
}
.save-btn {
color: var(--color-primary-contrast, #ffffff);
background: var(--color-primary, #3b82f6);
border: 1px solid var(--color-primary, #3b82f6);
}
.tests-list {
margin: 0;
padding: 0;
list-style: none;
}
.test-item {
margin-bottom: 0.5rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.375rem;
overflow: hidden;
}
.test-item.passed {
border-color: var(--color-success-border, #6ee7b7);
}
.test-item.failed {
border-color: var(--color-error-border, #fca5a5);
}
.test-header {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
background: var(--color-surface, #ffffff);
}
.test-type {
flex-shrink: 0;
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
border-radius: 0.25rem;
}
.test-type.type-positive {
color: #065f46;
background: #d1fae5;
}
.test-type.type-negative {
color: #991b1b;
background: #fee2e2;
}
.test-type.type-boundary {
color: #92400e;
background: #fef3c7;
}
.test-type.type-conflict {
color: #7c3aed;
background: #ede9fe;
}
.test-type.type-manual {
color: #0891b2;
background: #cffafe;
}
.test-description {
flex: 1;
font-size: 0.8125rem;
color: var(--color-text-primary, #111827);
}
.test-result {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
}
.test-result.passed {
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
}
.test-result.failed {
color: var(--color-error-text, #991b1b);
background: var(--color-error-bg, #fee2e2);
}
.test-result svg {
width: 0.75rem;
height: 0.75rem;
}
.delete-test-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
background: transparent;
border: none;
border-radius: 0.25rem;
cursor: pointer;
color: var(--color-text-tertiary, #9ca3af);
}
.delete-test-btn:hover {
color: var(--color-error, #ef4444);
background: var(--color-error-bg, #fee2e2);
}
.delete-test-btn svg {
width: 0.875rem;
height: 0.875rem;
}
.test-details {
padding: 0.5rem 0.75rem;
background: var(--color-surface-alt, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
font-size: 0.8125rem;
}
.detail-row,
.error-row,
.actual-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.detail-label,
.error-label,
.actual-label {
flex-shrink: 0;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
}
.detail-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.75rem;
color: var(--color-text-primary, #111827);
}
.expected-disposition,
.actual-disposition {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
border-radius: 0.25rem;
}
.disposition-block { color: #991b1b; background: #fee2e2; }
.disposition-warn { color: #92400e; background: #fef3c7; }
.disposition-allow { color: #065f46; background: #d1fae5; }
.disposition-review { color: #1e40af; background: #dbeafe; }
.disposition-escalate { color: #7c3aed; background: #ede9fe; }
.matched-rule,
.should-not-match {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
}
.error-row {
color: var(--color-error-text, #991b1b);
}
.error-message {
font-size: 0.75rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
text-align: center;
color: var(--color-text-secondary, #6b7280);
}
.empty-icon {
width: 3rem;
height: 3rem;
margin-bottom: 0.75rem;
color: var(--color-border, #d1d5db);
}
.empty-hint {
font-size: 0.8125rem;
color: var(--color-text-tertiary, #9ca3af);
}
.panel-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn svg {
width: 1rem;
height: 1rem;
}
.action-btn.secondary {
color: var(--color-text-primary, #374151);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
}
.action-btn.secondary:hover:not(:disabled) {
background: var(--color-hover, #f9fafb);
}
.action-btn.primary {
color: var(--color-primary-contrast, #ffffff);
background: var(--color-primary, #3b82f6);
border: 1px solid var(--color-primary, #3b82f6);
}
.action-btn.primary:hover:not(:disabled) {
background: var(--color-primary-hover, #2563eb);
border-color: var(--color-primary-hover, #2563eb);
}
`]
})
export class TestCasePanelComponent {
@Input() testCases: PolicyTestCase[] = [];
@Input() testResults: PolicyTestResult[] = [];
@Input() running = false;
@Input() runProgress = signal(0);
@Output() readonly runTests = new EventEmitter<void>();
@Output() readonly regenerateTests = new EventEmitter<void>();
@Output() readonly addTest = new EventEmitter<PolicyTestCase>();
@Output() readonly deleteTest = new EventEmitter<string>();
readonly showAddForm = signal(false);
readonly activeFilter = signal<TestCaseType | 'all'>('all');
readonly testTypes: { value: TestCaseType | 'all'; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'positive', label: 'Positive' },
{ value: 'negative', label: 'Negative' },
{ value: 'boundary', label: 'Boundary' },
{ value: 'conflict', label: 'Conflict' },
{ value: 'manual', label: 'Manual' },
];
newTest = {
description: '',
type: 'manual' as TestCaseType,
expectedDisposition: 'Block' as RuleDisposition,
inputJson: '',
};
readonly filteredTests = computed(() => {
const filter = this.activeFilter();
if (filter === 'all') return this.testCases;
return this.testCases.filter(t => t.type === filter);
});
readonly passedCount = computed(() =>
this.testResults.filter(r => r.passed).length
);
readonly resultsSummaryClass = computed(() => {
const passed = this.passedCount();
const total = this.testResults.length;
return passed === total ? 'all-passed' : 'some-failed';
});
countByType(type: TestCaseType | 'all'): number {
if (type === 'all') return this.testCases.length;
return this.testCases.filter(t => t.type === type).length;
}
getResult(testId: string): PolicyTestResult | undefined {
return this.testResults.find(r => r.testId === testId);
}
testResultClass(testId: string): string {
const result = this.getResult(testId);
if (!result) return '';
return result.passed ? 'passed' : 'failed';
}
testTypeLabel(type: TestCaseType): string {
const labels: Record<TestCaseType, string> = {
positive: 'Positive',
negative: 'Negative',
boundary: 'Boundary',
conflict: 'Conflict',
manual: 'Manual',
};
return labels[type] || type;
}
formatInput(input: Record<string, unknown>): string {
return JSON.stringify(input);
}
addManualTest(): void {
try {
const input = JSON.parse(this.newTest.inputJson || '{}');
const testCase: PolicyTestCase = {
testId: `test-manual-${Date.now()}`,
type: this.newTest.type,
description: this.newTest.description,
input,
expectedDisposition: this.newTest.expectedDisposition,
};
this.addTest.emit(testCase);
this.showAddForm.set(false);
this.newTest = {
description: '',
type: 'manual',
expectedDisposition: 'Block',
inputJson: '',
};
} catch (e) {
// Handle JSON parse error
console.error('Invalid JSON input:', e);
}
}
}

View File

@@ -0,0 +1,625 @@
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { PolicyVersion, PolicyPackStatus } from '../models/policy.models';
/**
* Version history component for policy packs.
*
* @task POLICY-24
*
* Features:
* - Show policy version history
* - Diff between versions
* - Restore previous versions
* - Status timeline
*/
@Component({
selector: 'stellaops-version-history',
standalone: true,
imports: [CommonModule],
template: `
<section class="version-history-panel">
<header class="panel-header">
<h3 class="title">
<svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
Version History
</h3>
@if (versions.length > 0) {
<span class="version-count">{{ versions.length }} versions</span>
}
</header>
<div class="panel-content">
@if (versions.length === 0) {
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="empty-icon">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<p>No version history available.</p>
<p class="empty-hint">Versions will appear here after you save changes.</p>
</div>
} @else {
<!-- Version Comparison -->
@if (compareMode()) {
<div class="compare-bar">
<span class="compare-label">Comparing:</span>
<span class="compare-versions">
<code>{{ selectedVersions()[0] }}</code>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
<code>{{ selectedVersions()[1] }}</code>
</span>
<button type="button" class="clear-compare-btn" (click)="clearComparison()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
}
<!-- Version Timeline -->
<ul class="versions-timeline">
@for (version of versions; track version.version; let i = $index) {
<li
class="version-item"
[class.current]="version.isCurrent"
[class.selected]="selectedVersions().includes(version.version)">
<div class="version-line">
<div class="version-dot" [class]="statusClass(version.status)"></div>
@if (i < versions.length - 1) {
<div class="version-connector"></div>
}
</div>
<div class="version-content">
<div class="version-header">
<span class="version-number">
v{{ version.version }}
@if (version.isCurrent) {
<span class="current-badge">Current</span>
}
</span>
<span class="version-status" [class]="statusClass(version.status)">
{{ statusLabel(version.status) }}
</span>
</div>
<p class="version-description">{{ version.changeDescription }}</p>
<div class="version-meta">
<span class="meta-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
{{ version.createdBy }}
</span>
<span class="meta-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
{{ formatDate(version.createdAt) }}
</span>
<span class="meta-item digest">
<code>{{ shortDigest(version.digest) }}</code>
</span>
</div>
<div class="version-actions">
@if (!version.isCurrent) {
<button
type="button"
class="action-btn"
(click)="restore.emit(version.version)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
Restore
</button>
}
<button
type="button"
class="action-btn"
[class.active]="selectedVersions().includes(version.version)"
(click)="toggleVersionSelect(version.version)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 3h5v5"/>
<path d="M8 21H3v-5"/>
<line x1="21" y1="3" x2="14" y2="10"/>
<line x1="3" y1="21" x2="10" y2="14"/>
</svg>
{{ selectedVersions().includes(version.version) ? 'Selected' : 'Compare' }}
</button>
<button
type="button"
class="action-btn"
(click)="viewDetails.emit(version)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
View
</button>
</div>
</div>
</li>
}
</ul>
}
</div>
@if (compareMode()) {
<footer class="panel-actions">
<button
type="button"
class="action-btn secondary"
(click)="clearComparison()">
Cancel
</button>
<button
type="button"
class="action-btn primary"
(click)="showDiff.emit({ from: selectedVersions()[0], to: selectedVersions()[1] })">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 3h5v5"/>
<path d="M8 21H3v-5"/>
<line x1="21" y1="3" x2="14" y2="10"/>
<line x1="3" y1="21" x2="10" y2="14"/>
</svg>
Show Diff
</button>
</footer>
}
</section>
`,
styles: [`
.version-history-panel {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.title-icon {
width: 1.125rem;
height: 1.125rem;
color: var(--color-primary, #3b82f6);
}
.version-count {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
}
.panel-content {
padding: 1rem;
max-height: 25rem;
overflow-y: auto;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
text-align: center;
color: var(--color-text-secondary, #6b7280);
}
.empty-icon {
width: 3rem;
height: 3rem;
margin-bottom: 0.75rem;
color: var(--color-border, #d1d5db);
}
.empty-hint {
font-size: 0.8125rem;
color: var(--color-text-tertiary, #9ca3af);
}
.compare-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
margin-bottom: 1rem;
background: var(--color-primary-bg, #eff6ff);
border: 1px solid var(--color-primary-border, #bfdbfe);
border-radius: 0.375rem;
}
.compare-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-primary-text, #1e40af);
}
.compare-versions {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.compare-versions code {
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
background: var(--color-surface, #ffffff);
border-radius: 0.25rem;
}
.compare-versions svg {
width: 1rem;
height: 1rem;
color: var(--color-primary-text, #1e40af);
}
.clear-compare-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
background: transparent;
border: none;
border-radius: 0.25rem;
cursor: pointer;
color: var(--color-primary-text, #1e40af);
}
.clear-compare-btn:hover {
background: var(--color-primary-hover, #dbeafe);
}
.clear-compare-btn svg {
width: 1rem;
height: 1rem;
}
.versions-timeline {
margin: 0;
padding: 0;
list-style: none;
}
.version-item {
display: flex;
gap: 1rem;
}
.version-line {
display: flex;
flex-direction: column;
align-items: center;
width: 1.5rem;
}
.version-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
border: 2px solid;
background: var(--color-surface, #ffffff);
z-index: 1;
}
.version-dot.draft {
border-color: var(--color-text-secondary, #6b7280);
}
.version-dot.pending_review,
.version-dot.in_review {
border-color: var(--color-warning, #f59e0b);
}
.version-dot.approved,
.version-dot.active {
border-color: var(--color-success, #10b981);
background: var(--color-success, #10b981);
}
.version-dot.rejected {
border-color: var(--color-error, #ef4444);
}
.version-dot.shadow {
border-color: var(--color-info, #3b82f6);
}
.version-dot.deprecated {
border-color: var(--color-text-tertiary, #9ca3af);
}
.version-connector {
flex: 1;
width: 2px;
min-height: 2rem;
background: var(--color-border, #e5e7eb);
}
.version-content {
flex: 1;
padding-bottom: 1.5rem;
}
.version-item:last-child .version-content {
padding-bottom: 0;
}
.version-item.current .version-content {
padding: 0.75rem;
margin: -0.75rem 0 0.75rem;
background: var(--color-success-bg, #d1fae5);
border-radius: 0.375rem;
}
.version-item.selected .version-content {
padding: 0.75rem;
margin: -0.75rem 0 0.75rem;
background: var(--color-primary-bg, #eff6ff);
border-radius: 0.375rem;
}
.version-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.375rem;
}
.version-number {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.current-badge {
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
border-radius: 0.25rem;
}
.version-status {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: capitalize;
border-radius: 0.25rem;
}
.version-status.draft {
color: #6b7280;
background: #f3f4f6;
}
.version-status.pending_review,
.version-status.in_review {
color: #92400e;
background: #fef3c7;
}
.version-status.approved,
.version-status.active {
color: #065f46;
background: #d1fae5;
}
.version-status.rejected {
color: #991b1b;
background: #fee2e2;
}
.version-status.shadow {
color: #1e40af;
background: #dbeafe;
}
.version-status.deprecated {
color: #6b7280;
background: #f3f4f6;
}
.version-description {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--color-text-secondary, #6b7280);
}
.version-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-tertiary, #9ca3af);
}
.meta-item svg {
width: 0.75rem;
height: 0.75rem;
}
.meta-item.digest code {
padding: 0.125rem 0.25rem;
font-size: 0.6875rem;
background: var(--color-code-bg, #f3f4f6);
border-radius: 0.25rem;
}
.version-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.25rem;
cursor: pointer;
}
.action-btn:hover {
background: var(--color-hover, #f9fafb);
color: var(--color-text-primary, #111827);
}
.action-btn.active {
color: var(--color-primary-text, #1e40af);
background: var(--color-primary-bg, #eff6ff);
border-color: var(--color-primary-border, #bfdbfe);
}
.action-btn svg {
width: 0.75rem;
height: 0.75rem;
}
.panel-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
}
.panel-actions .action-btn {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.panel-actions .action-btn.secondary {
color: var(--color-text-primary, #374151);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
}
.panel-actions .action-btn.primary {
color: var(--color-primary-contrast, #ffffff);
background: var(--color-primary, #3b82f6);
border: 1px solid var(--color-primary, #3b82f6);
}
.panel-actions .action-btn.primary:hover {
background: var(--color-primary-hover, #2563eb);
border-color: var(--color-primary-hover, #2563eb);
}
`]
})
export class VersionHistoryComponent {
@Input() versions: PolicyVersion[] = [];
@Output() readonly restore = new EventEmitter<string>();
@Output() readonly showDiff = new EventEmitter<{ from: string; to: string }>();
@Output() readonly viewDetails = new EventEmitter<PolicyVersion>();
readonly selectedVersions = signal<string[]>([]);
readonly compareMode = computed(() => this.selectedVersions().length === 2);
toggleVersionSelect(version: string): void {
this.selectedVersions.update(selected => {
if (selected.includes(version)) {
return selected.filter(v => v !== version);
}
if (selected.length >= 2) {
return [selected[1], version];
}
return [...selected, version];
});
}
clearComparison(): void {
this.selectedVersions.set([]);
}
statusClass(status: PolicyPackStatus): string {
return status.replace('_', '-');
}
statusLabel(status: PolicyPackStatus): string {
const labels: Record<PolicyPackStatus, string> = {
draft: 'Draft',
pending_review: 'Pending Review',
in_review: 'In Review',
approved: 'Approved',
rejected: 'Rejected',
active: 'Active',
shadow: 'Shadow',
deprecated: 'Deprecated',
};
return labels[status] || status;
}
formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch {
return iso;
}
}
shortDigest(digest: string): string {
if (!digest) return '';
if (digest.startsWith('sha256:')) {
return digest.substring(7, 15) + '...';
}
return digest.substring(0, 8) + '...';
}
}

View File

@@ -0,0 +1,764 @@
import { Component, EventEmitter, Input, Output, signal, computed, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import type { PolicyParseResult, PolicyIntent } from '../../../core/api/advisory-ai.models';
/**
* Natural language input panel for Policy Studio.
*
* @task POLICY-20
*
* Features:
* - Natural language input with autocomplete for policy entities
* - Parse intent as user types (debounced)
* - Show clarifying questions when intent is ambiguous
* - Display confidence indicator
*/
@Component({
selector: 'stellaops-policy-nl-input',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<section class="nl-input-panel">
<header class="panel-header">
<h3 class="title">
<svg class="title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Describe Your Policy
</h3>
@if (parseResult && parseResult.success) {
<span class="confidence-badge" [class]="confidenceClass()">
{{ (parseResult.intent.confidence * 100).toFixed(0) }}% confident
</span>
}
</header>
<div class="input-container">
<div class="input-wrapper">
<textarea
#inputRef
class="nl-input"
[value]="inputText()"
[placeholder]="placeholder"
[disabled]="disabled"
[rows]="rows"
(input)="onInput($event)"
(keydown)="onKeydown($event)"
(focus)="onFocus()"
(blur)="onBlur()">
</textarea>
@if (loading()) {
<div class="input-loading">
<span class="spinner"></span>
</div>
}
</div>
<!-- Autocomplete suggestions -->
@if (showSuggestions() && suggestions().length > 0) {
<ul class="suggestions-list" role="listbox">
@for (suggestion of suggestions(); track suggestion.value; let i = $index) {
<li
role="option"
class="suggestion-item"
[class.selected]="i === selectedSuggestionIndex()"
(click)="applySuggestion(suggestion)"
(mouseenter)="selectedSuggestionIndex.set(i)">
<span class="suggestion-type" [class]="'type-' + suggestion.type">
{{ suggestion.type }}
</span>
<span class="suggestion-value">{{ suggestion.value }}</span>
<span class="suggestion-desc">{{ suggestion.description }}</span>
</li>
}
</ul>
}
</div>
<!-- Examples -->
@if (showExamples && !inputText()) {
<div class="examples-section">
<span class="examples-label">Try:</span>
<div class="examples-list">
@for (example of examples; track example) {
<button
type="button"
class="example-btn"
(click)="applyExample(example)">
"{{ example }}"
</button>
}
</div>
</div>
}
<!-- Parse Result -->
@if (parseResult && parseResult.success) {
<div class="parse-result">
<!-- Intent Type Badge -->
<div class="result-row">
<span class="result-label">Intent:</span>
<span class="intent-badge" [class]="'type-' + parseResult.intent.intentType">
{{ intentTypeLabel(parseResult.intent.intentType) }}
</span>
</div>
<!-- Clarifying Questions -->
@if (parseResult.intent.clarifyingQuestions && parseResult.intent.clarifyingQuestions.length > 0) {
<div class="clarifying-questions">
<h4 class="questions-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
Clarify your intent:
</h4>
<ul class="questions-list">
@for (question of parseResult.intent.clarifyingQuestions; track question) {
<li class="question-item">{{ question }}</li>
}
</ul>
</div>
}
<!-- Alternative Interpretations -->
@if (parseResult.intent.alternatives && parseResult.intent.alternatives.length > 0) {
<div class="alternatives-section">
<h4 class="alternatives-title">Alternative interpretations:</h4>
<div class="alternatives-list">
@for (alt of parseResult.intent.alternatives; track alt.intentId) {
<button
type="button"
class="alternative-btn"
(click)="selectAlternative.emit(alt)">
<span class="alt-type">{{ intentTypeLabel(alt.intentType) }}</span>
<span class="alt-conditions">{{ formatConditions(alt) }}</span>
</button>
}
</div>
</div>
}
</div>
}
<!-- Actions -->
<footer class="panel-actions">
<button
type="button"
class="action-btn secondary"
[disabled]="!inputText()"
(click)="clear.emit()">
Clear
</button>
<button
type="button"
class="action-btn primary"
[disabled]="!parseResult || !parseResult.success || loading()"
(click)="generateRules.emit(parseResult!)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</svg>
Generate Rules
</button>
</footer>
</section>
`,
styles: [`
.nl-input-panel {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-primary, #111827);
}
.title-icon {
width: 1.125rem;
height: 1.125rem;
color: var(--color-primary, #3b82f6);
}
.confidence-badge {
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
}
.confidence-badge.high {
color: var(--color-success-text, #065f46);
background: var(--color-success-bg, #d1fae5);
}
.confidence-badge.medium {
color: var(--color-warning-text, #92400e);
background: var(--color-warning-bg, #fef3c7);
}
.confidence-badge.low {
color: var(--color-error-text, #991b1b);
background: var(--color-error-bg, #fee2e2);
}
.input-container {
position: relative;
padding: 1rem;
}
.input-wrapper {
position: relative;
}
.nl-input {
width: 100%;
padding: 0.75rem;
font-size: 0.9375rem;
font-family: inherit;
line-height: 1.5;
color: var(--color-text-primary, #111827);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
resize: vertical;
transition: border-color 0.15s, box-shadow 0.15s;
}
.nl-input:focus {
outline: none;
border-color: var(--color-primary, #3b82f6);
box-shadow: 0 0 0 3px var(--color-primary-ring, rgba(59, 130, 246, 0.15));
}
.nl-input:disabled {
background: var(--color-surface-alt, #f9fafb);
cursor: not-allowed;
}
.nl-input::placeholder {
color: var(--color-text-tertiary, #9ca3af);
}
.input-loading {
position: absolute;
top: 0.75rem;
right: 0.75rem;
}
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid var(--color-border, #e5e7eb);
border-top-color: var(--color-primary, #3b82f6);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.suggestions-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 50;
margin: 0.25rem 0 0;
padding: 0.25rem;
list-style: none;
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
max-height: 15rem;
overflow-y: auto;
}
.suggestion-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: 0.25rem;
}
.suggestion-item:hover,
.suggestion-item.selected {
background: var(--color-hover, #f3f4f6);
}
.suggestion-type {
flex-shrink: 0;
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
border-radius: 0.25rem;
}
.suggestion-type.type-severity {
color: #dc2626;
background: #fee2e2;
}
.suggestion-type.type-scope {
color: #0891b2;
background: #cffafe;
}
.suggestion-type.type-action {
color: #7c3aed;
background: #ede9fe;
}
.suggestion-type.type-condition {
color: #059669;
background: #d1fae5;
}
.suggestion-value {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary, #111827);
}
.suggestion-desc {
flex: 1;
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
text-align: right;
}
.examples-section {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0 1rem 1rem;
}
.examples-label {
font-size: 0.8125rem;
color: var(--color-text-secondary, #6b7280);
padding-top: 0.25rem;
}
.examples-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.example-btn {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-style: italic;
color: var(--color-primary-text, #1e40af);
background: var(--color-primary-bg, #eff6ff);
border: 1px solid var(--color-primary-border, #bfdbfe);
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.15s;
}
.example-btn:hover {
background: var(--color-primary-hover, #dbeafe);
}
.parse-result {
padding: 1rem;
margin: 0 1rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-radius: 0.375rem;
}
.result-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.result-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
}
.intent-badge {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 0.25rem;
}
.intent-badge.type-OverrideRule {
color: #dc2626;
background: #fee2e2;
}
.intent-badge.type-EscalationRule {
color: #ea580c;
background: #ffedd5;
}
.intent-badge.type-ExceptionCondition {
color: #059669;
background: #d1fae5;
}
.intent-badge.type-MergePrecedence {
color: #7c3aed;
background: #ede9fe;
}
.intent-badge.type-ThresholdRule {
color: #0891b2;
background: #cffafe;
}
.intent-badge.type-ScopeRestriction {
color: #4f46e5;
background: #e0e7ff;
}
.clarifying-questions {
margin-top: 1rem;
padding: 0.75rem;
background: var(--color-warning-bg, #fef3c7);
border: 1px solid var(--color-warning-border, #fcd34d);
border-radius: 0.375rem;
}
.questions-title {
display: flex;
align-items: center;
gap: 0.375rem;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-warning-text, #92400e);
}
.questions-title svg {
width: 1rem;
height: 1rem;
}
.questions-list {
margin: 0;
padding-left: 1.25rem;
}
.question-item {
font-size: 0.8125rem;
color: var(--color-warning-text, #92400e);
margin-bottom: 0.25rem;
}
.alternatives-section {
margin-top: 1rem;
}
.alternatives-title {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
}
.alternatives-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.alternative-btn {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.625rem;
text-align: left;
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
}
.alternative-btn:hover {
background: var(--color-hover, #f9fafb);
}
.alt-type {
flex-shrink: 0;
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-primary-text, #1e40af);
background: var(--color-primary-bg, #eff6ff);
border-radius: 0.25rem;
}
.alt-conditions {
font-size: 0.8125rem;
color: var(--color-text-primary, #111827);
}
.panel-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn svg {
width: 1rem;
height: 1rem;
}
.action-btn.secondary {
color: var(--color-text-primary, #374151);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
}
.action-btn.secondary:hover:not(:disabled) {
background: var(--color-hover, #f9fafb);
}
.action-btn.primary {
color: var(--color-primary-contrast, #ffffff);
background: var(--color-primary, #3b82f6);
border: 1px solid var(--color-primary, #3b82f6);
}
.action-btn.primary:hover:not(:disabled) {
background: var(--color-primary-hover, #2563eb);
border-color: var(--color-primary-hover, #2563eb);
}
`]
})
export class PolicyNlInputComponent {
@ViewChild('inputRef') inputRef!: ElementRef<HTMLTextAreaElement>;
@Input() placeholder = 'Describe your policy in plain English, e.g., "Block all critical vulnerabilities in production unless VEX says not affected"';
@Input() disabled = false;
@Input() rows = 3;
@Input() showExamples = true;
@Input() parseResult: PolicyParseResult | null = null;
@Input() examples: string[] = [
'Block all critical CVEs in production',
'Escalate CVSS 9.0 or above to security team',
'Allow if vendor VEX says not affected',
];
@Output() readonly inputChange = new EventEmitter<string>();
@Output() readonly parse = new EventEmitter<string>();
@Output() readonly generateRules = new EventEmitter<PolicyParseResult>();
@Output() readonly selectAlternative = new EventEmitter<PolicyIntent>();
@Output() readonly clear = new EventEmitter<void>();
readonly inputText = signal('');
readonly loading = signal(false);
readonly showSuggestions = signal(false);
readonly selectedSuggestionIndex = signal(0);
readonly suggestions = signal<PolicySuggestion[]>([]);
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
readonly confidenceClass = computed(() => {
if (!this.parseResult) return '';
const confidence = this.parseResult.intent.confidence;
if (confidence >= 0.8) return 'high';
if (confidence >= 0.6) return 'medium';
return 'low';
});
onInput(event: Event): void {
const value = (event.target as HTMLTextAreaElement).value;
this.inputText.set(value);
this.inputChange.emit(value);
// Debounce parse
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
if (value.trim()) {
this.loading.set(true);
this.parse.emit(value);
}
}, 500);
// Check for autocomplete triggers
this.checkForSuggestions(value);
}
onKeydown(event: KeyboardEvent): void {
if (!this.showSuggestions() || this.suggestions().length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.selectedSuggestionIndex.update(i =>
Math.min(i + 1, this.suggestions().length - 1)
);
break;
case 'ArrowUp':
event.preventDefault();
this.selectedSuggestionIndex.update(i => Math.max(i - 1, 0));
break;
case 'Enter':
if (this.showSuggestions()) {
event.preventDefault();
const suggestion = this.suggestions()[this.selectedSuggestionIndex()];
if (suggestion) {
this.applySuggestion(suggestion);
}
}
break;
case 'Escape':
this.showSuggestions.set(false);
break;
}
}
onFocus(): void {
if (this.suggestions().length > 0) {
this.showSuggestions.set(true);
}
}
onBlur(): void {
// Delay to allow click on suggestions
setTimeout(() => this.showSuggestions.set(false), 200);
}
applySuggestion(suggestion: PolicySuggestion): void {
const currentText = this.inputText();
const lastWordStart = currentText.lastIndexOf(' ') + 1;
const newText = currentText.substring(0, lastWordStart) + suggestion.value + ' ';
this.inputText.set(newText);
this.inputChange.emit(newText);
this.showSuggestions.set(false);
this.inputRef.nativeElement.focus();
}
applyExample(example: string): void {
this.inputText.set(example);
this.inputChange.emit(example);
this.loading.set(true);
this.parse.emit(example);
}
intentTypeLabel(type: string): string {
const labels: Record<string, string> = {
OverrideRule: 'Override Rule',
EscalationRule: 'Escalation Rule',
ExceptionCondition: 'Exception',
MergePrecedence: 'Merge Precedence',
ThresholdRule: 'Threshold Rule',
ScopeRestriction: 'Scope Restriction',
};
return labels[type] || type;
}
formatConditions(intent: PolicyIntent): string {
return intent.conditions
.map(c => `${c.field} ${c.operator} ${c.value}`)
.join(' AND ');
}
setLoading(loading: boolean): void {
this.loading.set(loading);
}
private checkForSuggestions(text: string): void {
const lastWord = text.split(/\s+/).pop()?.toLowerCase() || '';
if (lastWord.length < 2) {
this.suggestions.set([]);
this.showSuggestions.set(false);
return;
}
const allSuggestions: PolicySuggestion[] = [
// Severities
{ type: 'severity', value: 'critical', description: 'CVSS 9.0-10.0' },
{ type: 'severity', value: 'high', description: 'CVSS 7.0-8.9' },
{ type: 'severity', value: 'medium', description: 'CVSS 4.0-6.9' },
{ type: 'severity', value: 'low', description: 'CVSS 0.1-3.9' },
// Scopes
{ type: 'scope', value: 'production', description: 'Production environment' },
{ type: 'scope', value: 'staging', description: 'Staging environment' },
{ type: 'scope', value: 'development', description: 'Development environment' },
// Actions
{ type: 'action', value: 'block', description: 'Fail the build/gate' },
{ type: 'action', value: 'warn', description: 'Warning only' },
{ type: 'action', value: 'allow', description: 'Pass with no action' },
{ type: 'action', value: 'escalate', description: 'Escalate to security team' },
// Conditions
{ type: 'condition', value: 'VEX', description: 'Vendor exploitability statement' },
{ type: 'condition', value: 'reachable', description: 'Code reachability' },
{ type: 'condition', value: 'exploitable', description: 'Known exploit exists' },
{ type: 'condition', value: 'KEV', description: 'In CISA KEV catalog' },
];
const filtered = allSuggestions.filter(
s => s.value.toLowerCase().startsWith(lastWord)
);
this.suggestions.set(filtered);
this.selectedSuggestionIndex.set(0);
this.showSuggestions.set(filtered.length > 0);
}
}
interface PolicySuggestion {
readonly type: 'severity' | 'scope' | 'action' | 'condition';
readonly value: string;
readonly description: string;
}

View File

@@ -36,13 +36,13 @@
</div>
<div class="summary-stat">
<span class="stat-label">Verified</span>
<span class="stat-value verified">{{ proofChain()?.summary.verifiedCount }}</span>
<span class="stat-value verified">{{ proofChain()?.summary?.verifiedCount }}</span>
</div>
<div class="summary-stat">
<span class="stat-label">Unverified</span>
<span class="stat-value unverified">{{ proofChain()?.summary.unverifiedCount }}</span>
<span class="stat-value unverified">{{ proofChain()?.summary?.unverifiedCount }}</span>
</div>
@if (proofChain()?.summary.hasRekorAnchoring) {
@if (proofChain()?.summary?.hasRekorAnchoring) {
<div class="summary-badge">
<span class="badge rekor-badge">Rekor Anchored</span>
</div>

View File

@@ -0,0 +1,783 @@
// -----------------------------------------------------------------------------
// binary-evidence-panel.component.ts
// Sprint: SPRINT_20251226_014_BINIDX
// Task: SCANINT-17, SCANINT-18, SCANINT-19 - Binary Evidence UI
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
BinaryEvidence,
BinaryFinding,
BinaryVulnMatch,
BinaryFixStatus,
} from '../../core/api/scanner.models';
/**
* Panel component displaying binary vulnerability evidence from scanner results.
* Shows binaries found in the image with their Build-IDs, vulnerability matches,
* and backport status badges.
*
* SCANINT-17: Add "Binary Evidence" tab to scan results UI
* SCANINT-18: Display "Backported & Safe" badge for fixed binaries
* SCANINT-19: Display "Affected & Reachable" badge for vulnerable binaries
*/
@Component({
selector: 'app-binary-evidence-panel',
standalone: true,
imports: [CommonModule],
template: `
<div class="binary-evidence-panel" [class.has-findings]="hasBinaries()">
<!-- Summary Header -->
<div class="panel-header">
<h3 class="panel-title">
<span class="panel-icon" aria-hidden="true">&#128190;</span>
Binary Evidence
</h3>
<div class="summary-badges">
@if (safeCount() > 0) {
<span class="badge badge--safe" aria-label="Backported and safe binaries">
<span class="badge-icon">&#10003;</span>
{{ safeCount() }} Backported & Safe
</span>
}
@if (vulnerableCount() > 0) {
<span class="badge badge--vulnerable" aria-label="Affected and reachable binaries">
<span class="badge-icon">&#9888;</span>
{{ vulnerableCount() }} Affected & Reachable
</span>
}
@if (unknownCount() > 0) {
<span class="badge badge--unknown" aria-label="Binaries with unknown status">
<span class="badge-icon">?</span>
{{ unknownCount() }} Unknown
</span>
}
</div>
</div>
<!-- Distribution Info -->
@if (evidence()?.distro) {
<div class="distro-info">
<span class="distro-label">Distribution:</span>
<span class="distro-value">{{ evidence()?.distro }}{{ evidence()?.release ? ' ' + evidence()?.release : '' }}</span>
</div>
}
<!-- Binary List -->
@if (hasBinaries()) {
<div class="binary-list">
@for (binary of evidence()?.binaries ?? []; track binary.identity.binaryKey) {
<div
class="binary-card"
[class]="getBinaryStatusClass(binary)"
role="article"
[attr.aria-label]="'Binary ' + (binary.identity.path ?? binary.identity.binaryKey)"
>
<!-- Binary Header -->
<button
type="button"
class="binary-header"
(click)="toggleBinary(binary.identity.binaryKey)"
[attr.aria-expanded]="isExpanded(binary.identity.binaryKey)"
>
<div class="binary-status-indicator">
@switch (getBinaryOverallStatus(binary)) {
@case ('fixed') {
<span class="status-icon status-icon--safe" aria-label="Backported & Safe">&#10003;</span>
}
@case ('vulnerable') {
<span class="status-icon status-icon--vulnerable" aria-label="Affected & Reachable">&#9888;</span>
}
@case ('not_affected') {
<span class="status-icon status-icon--safe" aria-label="Not Affected">&#10003;</span>
}
@default {
<span class="status-icon status-icon--unknown" aria-label="Unknown Status">?</span>
}
}
</div>
<div class="binary-info">
<span class="binary-path">{{ binary.identity.path ?? binary.identity.binaryKey }}</span>
<span class="binary-meta">
{{ binary.identity.format | uppercase }} |
{{ binary.identity.architecture }}
@if (binary.identity.buildId) {
| Build-ID: {{ truncateHash(binary.identity.buildId, 12) }}
}
</span>
</div>
<div class="binary-match-count">
{{ binary.matches.length }} {{ binary.matches.length === 1 ? 'match' : 'matches' }}
</div>
<span class="expand-icon" aria-hidden="true">
{{ isExpanded(binary.identity.binaryKey) ? '&#9650;' : '&#9660;' }}
</span>
</button>
<!-- Expanded Details -->
@if (isExpanded(binary.identity.binaryKey)) {
<div class="binary-details">
<!-- Binary Identity -->
<section class="details-section">
<h4 class="section-title">Identity</h4>
<dl class="identity-details">
<dt>SHA256:</dt>
<dd><code>{{ truncateHash(binary.identity.fileSha256, 24) }}</code></dd>
@if (binary.identity.buildId) {
<dt>Build-ID:</dt>
<dd><code>{{ binary.identity.buildId }}</code></dd>
}
<dt>Layer:</dt>
<dd><code>{{ truncateHash(binary.layerDigest, 20) }}</code></dd>
</dl>
</section>
<!-- Vulnerability Matches -->
@if (binary.matches.length > 0) {
<section class="details-section">
<h4 class="section-title">Vulnerability Matches</h4>
<ul class="match-list">
@for (match of binary.matches; track match.cveId) {
<li class="match-item" [class]="getMatchStatusClass(match)">
<div class="match-header">
<!-- SCANINT-18/19: Status Badge -->
@switch (match.fixStatus?.state) {
@case ('fixed') {
<span class="match-badge match-badge--safe">Backported & Safe</span>
}
@case ('vulnerable') {
<span class="match-badge match-badge--vulnerable">Affected & Reachable</span>
}
@case ('not_affected') {
<span class="match-badge match-badge--safe">Not Affected</span>
}
@case ('wontfix') {
<span class="match-badge match-badge--wontfix">Won't Fix</span>
}
@default {
<span class="match-badge match-badge--unknown">Status Unknown</span>
}
}
<span class="match-cve">{{ match.cveId }}</span>
</div>
<div class="match-details">
<div class="match-row">
<span class="match-label">Package:</span>
<span class="match-value">{{ match.vulnerablePurl }}</span>
</div>
<div class="match-row">
<span class="match-label">Method:</span>
<span class="match-value match-method">{{ formatMethod(match.method) }}</span>
<span class="match-confidence" [class]="getConfidenceClass(match.confidence)">
{{ (match.confidence * 100).toFixed(0) }}% confidence
</span>
</div>
@if (match.similarity !== undefined) {
<div class="match-row">
<span class="match-label">Similarity:</span>
<span class="match-value">{{ (match.similarity * 100).toFixed(1) }}%</span>
</div>
}
@if (match.matchedFunction) {
<div class="match-row">
<span class="match-label">Function:</span>
<code class="match-value">{{ match.matchedFunction }}</code>
</div>
}
@if (match.fixStatus?.fixedVersion) {
<div class="match-row">
<span class="match-label">Fixed in:</span>
<span class="match-value match-fixed-version">{{ match.fixStatus?.fixedVersion }}</span>
</div>
}
</div>
<!-- Drill-down link -->
<button
type="button"
class="match-drilldown"
(click)="onViewProofChain(binary, match)"
[attr.aria-label]="'View proof chain for ' + match.cveId"
>
View Proof Chain &#8594;
</button>
</li>
}
</ul>
</section>
}
</div>
}
</div>
}
</div>
} @else {
<p class="no-binaries">
No binaries with vulnerability evidence found in this scan.
</p>
}
</div>
`,
styles: [`
.binary-evidence-panel {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
padding: 1rem;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.panel-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #111827;
}
.panel-icon {
font-size: 1.25rem;
}
.summary-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
&--safe {
background: #dcfce7;
color: #15803d;
}
&--vulnerable {
background: #fee2e2;
color: #dc2626;
}
&--unknown {
background: #f3f4f6;
color: #6b7280;
}
}
.badge-icon {
font-weight: 700;
}
.distro-info {
padding: 0.5rem 1rem;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
font-size: 0.8125rem;
}
.distro-label {
color: #6b7280;
margin-right: 0.5rem;
}
.distro-value {
color: #374151;
font-weight: 500;
}
.binary-list {
padding: 0.75rem;
}
.binary-card {
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: 0.5rem;
overflow: hidden;
transition: border-color 0.15s;
&:last-child {
margin-bottom: 0;
}
&.status-safe {
border-color: #86efac;
}
&.status-vulnerable {
border-color: #fca5a5;
}
&.status-unknown {
border-color: #d1d5db;
}
}
.binary-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
transition: background-color 0.15s;
&:hover {
background: #f9fafb;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
.status-safe & {
background: #f0fdf4;
&:hover { background: #dcfce7; }
}
.status-vulnerable & {
background: #fef2f2;
&:hover { background: #fee2e2; }
}
}
.binary-status-indicator {
flex-shrink: 0;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
font-size: 0.875rem;
font-weight: 700;
&--safe {
background: #22c55e;
color: #fff;
}
&--vulnerable {
background: #ef4444;
color: #fff;
}
&--unknown {
background: #6b7280;
color: #fff;
}
}
.binary-info {
flex: 1;
min-width: 0;
}
.binary-path {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #111827;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.binary-meta {
display: block;
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.125rem;
}
.binary-match-count {
flex-shrink: 0;
padding: 0.25rem 0.5rem;
background: #f3f4f6;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
color: #374151;
}
.expand-icon {
flex-shrink: 0;
font-size: 0.75rem;
color: #6b7280;
}
.binary-details {
padding: 1rem;
background: #f9fafb;
border-top: 1px solid #e5e7eb;
}
.details-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.identity-details {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
margin: 0;
font-size: 0.8125rem;
dt {
color: #6b7280;
}
dd {
margin: 0;
color: #111827;
code {
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.75rem;
background: #fff;
padding: 0.125rem 0.375rem;
border: 1px solid #e5e7eb;
border-radius: 2px;
}
}
}
.match-list {
list-style: none;
margin: 0;
padding: 0;
}
.match-item {
padding: 0.75rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
&.match-safe {
border-color: #86efac;
}
&.match-vulnerable {
border-color: #fca5a5;
}
&.match-wontfix {
border-color: #fcd34d;
}
}
.match-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.match-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
&--safe {
background: #dcfce7;
color: #15803d;
}
&--vulnerable {
background: #fee2e2;
color: #dc2626;
}
&--wontfix {
background: #fef3c7;
color: #d97706;
}
&--unknown {
background: #f3f4f6;
color: #6b7280;
}
}
.match-cve {
font-size: 0.875rem;
font-weight: 600;
color: #111827;
}
.match-details {
font-size: 0.8125rem;
}
.match-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
.match-label {
color: #6b7280;
white-space: nowrap;
}
.match-value {
color: #374151;
word-break: break-all;
}
.match-method {
font-weight: 500;
}
.match-confidence {
margin-left: auto;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
&.confidence-high {
background: #dcfce7;
color: #15803d;
}
&.confidence-medium {
background: #fef3c7;
color: #d97706;
}
&.confidence-low {
background: #fee2e2;
color: #dc2626;
}
}
.match-fixed-version {
font-weight: 600;
color: #15803d;
}
.match-drilldown {
display: block;
width: 100%;
margin-top: 0.75rem;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 4px;
background: #fff;
font-size: 0.8125rem;
font-weight: 500;
color: #3b82f6;
cursor: pointer;
text-align: center;
transition: background-color 0.15s, border-color 0.15s;
&:hover {
background: #eff6ff;
border-color: #93c5fd;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
.no-binaries {
padding: 2rem;
margin: 0;
text-align: center;
color: #6b7280;
font-size: 0.875rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BinaryEvidencePanelComponent {
readonly evidence = input<BinaryEvidence | null>(null);
/** Emitted when user clicks "View Proof Chain" for a CVE match */
readonly viewProofChain = output<{ binary: BinaryFinding; match: BinaryVulnMatch }>();
private readonly expandedBinaries = signal<Set<string>>(new Set());
readonly hasBinaries = computed(() => {
const binaries = this.evidence()?.binaries;
return binaries !== undefined && binaries.length > 0;
});
readonly safeCount = computed(() => {
return this.countByStatus(['fixed', 'not_affected']);
});
readonly vulnerableCount = computed(() => {
return this.countByStatus(['vulnerable']);
});
readonly unknownCount = computed(() => {
return this.countByStatus(['unknown', 'wontfix', undefined]);
});
private countByStatus(statuses: (BinaryFixStatus | undefined)[]): number {
const binaries = this.evidence()?.binaries ?? [];
return binaries.filter(b => {
const status = this.getBinaryOverallStatus(b);
return statuses.includes(status);
}).length;
}
getBinaryOverallStatus(binary: BinaryFinding): BinaryFixStatus {
if (binary.matches.length === 0) {
return 'not_affected';
}
// If any match is vulnerable, the binary is vulnerable
const hasVulnerable = binary.matches.some(
m => m.fixStatus?.state === 'vulnerable' || m.fixStatus === undefined
);
if (hasVulnerable) {
return 'vulnerable';
}
// If all matches are fixed or not_affected, the binary is safe
const allFixed = binary.matches.every(
m => m.fixStatus?.state === 'fixed' || m.fixStatus?.state === 'not_affected'
);
if (allFixed) {
return 'fixed';
}
return 'unknown';
}
getBinaryStatusClass(binary: BinaryFinding): string {
const status = this.getBinaryOverallStatus(binary);
switch (status) {
case 'fixed':
case 'not_affected':
return 'status-safe';
case 'vulnerable':
return 'status-vulnerable';
default:
return 'status-unknown';
}
}
getMatchStatusClass(match: BinaryVulnMatch): string {
switch (match.fixStatus?.state) {
case 'fixed':
case 'not_affected':
return 'match-safe';
case 'vulnerable':
return 'match-vulnerable';
case 'wontfix':
return 'match-wontfix';
default:
return 'match-unknown';
}
}
getConfidenceClass(confidence: number): string {
if (confidence >= 0.8) return 'confidence-high';
if (confidence >= 0.5) return 'confidence-medium';
return 'confidence-low';
}
formatMethod(method: string): string {
switch (method) {
case 'buildid_catalog':
return 'Build-ID Catalog';
case 'fingerprint_match':
return 'Fingerprint Match';
case 'range_match':
return 'Version Range';
default:
return method;
}
}
isExpanded(binaryKey: string): boolean {
return this.expandedBinaries().has(binaryKey);
}
toggleBinary(binaryKey: string): void {
this.expandedBinaries.update(set => {
const newSet = new Set(set);
if (newSet.has(binaryKey)) {
newSet.delete(binaryKey);
} else {
newSet.add(binaryKey);
}
return newSet;
});
}
truncateHash(hash: string, length: number): string {
if (hash.length <= length) return hash;
return hash.slice(0, length) + '...';
}
onViewProofChain(binary: BinaryFinding, match: BinaryVulnMatch): void {
this.viewProofChain.emit({ binary, match });
}
}

View File

@@ -77,6 +77,22 @@
}
</section>
<!-- Binary Evidence Section -->
<!-- Sprint: SPRINT_20251226_014_BINIDX (SCANINT-17,18,19) -->
<section class="binary-evidence-section">
<h2>Binary Evidence</h2>
@if (scan().binaryEvidence) {
<app-binary-evidence-panel
[evidence]="scan().binaryEvidence ?? null"
(viewProofChain)="onViewBinaryProofChain($event)"
/>
} @else {
<p class="binary-empty">
No binary vulnerability evidence available for this scan.
</p>
}
</section>
<!-- Reachability Drift Section -->
<!-- Sprint: SPRINT_3600_0004_0001_ui_evidence_chain (UI-010) -->
<section class="reachability-drift-section">

View File

@@ -118,6 +118,27 @@
margin: 0;
}
// Binary Evidence Section
// Sprint: SPRINT_20251226_014_BINIDX (SCANINT-17,18,19)
.binary-evidence-section {
border: 1px solid #1f2933;
border-radius: 8px;
padding: 1.25rem;
background: #111827;
h2 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
color: #e2e8f0;
}
}
.binary-empty {
font-style: italic;
color: #94a3b8;
margin: 0;
}
// Reachability Drift Section
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain (UI-010)
.reachability-drift-section {

View File

@@ -11,9 +11,10 @@ import { ScanAttestationPanelComponent } from './scan-attestation-panel.componen
import { DeterminismBadgeComponent } from './determinism-badge.component';
import { EntropyPanelComponent } from './entropy-panel.component';
import { EntropyPolicyBannerComponent } from './entropy-policy-banner.component';
import { BinaryEvidencePanelComponent } from './binary-evidence-panel.component';
import { PathViewerComponent } from '../reachability/components/path-viewer/path-viewer.component';
import { RiskDriftCardComponent } from '../reachability/components/risk-drift-card/risk-drift-card.component';
import { ScanDetail } from '../../core/api/scanner.models';
import { ScanDetail, BinaryFinding, BinaryVulnMatch } from '../../core/api/scanner.models';
import {
scanDetailWithFailedAttestation,
scanDetailWithVerifiedAttestation,
@@ -36,6 +37,7 @@ const SCENARIO_MAP: Record<Scenario, ScanDetail> = {
DeterminismBadgeComponent,
EntropyPanelComponent,
EntropyPolicyBannerComponent,
BinaryEvidencePanelComponent,
PathViewerComponent,
RiskDriftCardComponent,
],
@@ -101,4 +103,13 @@ export class ScanDetailPageComponent {
console.log('Sink clicked:', sink);
// TODO: Navigate to sink details or expand path view
}
/**
* Handle proof chain view request from binary evidence panel.
* Sprint: SPRINT_20251226_014_BINIDX (SCANINT-17)
*/
onViewBinaryProofChain(event: { binary: BinaryFinding; match: BinaryVulnMatch }): void {
console.log('View proof chain for binary:', event.binary.identity.path, 'CVE:', event.match.cveId);
// TODO: Navigate to proof chain detail view or open modal
}
}

View File

@@ -0,0 +1,527 @@
/**
* AI Preferences Component.
* Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
* Tasks: AIUX-30, AIUX-31, AIUX-32, AIUX-33, AIUX-34
*
* User settings panel for AI features:
* - Verbosity: Minimal / Standard / Detailed
* - Surface toggles: UI, PR comments, notifications
* - Per-team notification opt-in
*/
import { Component, input, output, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
/**
* AI verbosity level.
*/
export type AiVerbosity = 'minimal' | 'standard' | 'detailed';
/**
* AI surface visibility settings.
*/
export interface AiSurfaceSettings {
/** Show AI in UI */
showInUi: boolean;
/** Include AI in PR comments */
showInPrComments: boolean;
/** Include AI in notifications */
showInNotifications: boolean;
}
/**
* Team notification opt-in settings.
*/
export interface TeamAiNotificationSettings {
/** Team ID */
teamId: string;
/** Team name */
teamName: string;
/** Whether AI notifications are enabled for this team */
enabled: boolean;
}
/**
* Complete AI preferences.
*/
export interface AiPreferences {
/** Verbosity level */
verbosity: AiVerbosity;
/** Surface settings */
surfaces: AiSurfaceSettings;
/** Team notification settings */
teamNotifications: TeamAiNotificationSettings[];
}
/**
* Default preferences.
*/
export const DEFAULT_AI_PREFERENCES: AiPreferences = {
verbosity: 'standard',
surfaces: {
showInUi: true,
showInPrComments: false,
showInNotifications: false
},
teamNotifications: []
};
@Component({
selector: 'stella-ai-preferences',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="ai-preferences">
<header class="ai-preferences__header">
<h2 class="ai-preferences__title">AI Assistant Preferences</h2>
<p class="ai-preferences__description">
Configure how AI assistance appears across StellaOps
</p>
</header>
<div class="ai-preferences__sections">
<!-- Verbosity Section -->
<section class="ai-preferences__section">
<h3 class="ai-preferences__section-title">Verbosity</h3>
<p class="ai-preferences__section-description">
Control how much detail AI explanations provide
</p>
<div class="ai-preferences__verbosity-options">
@for (option of verbosityOptions; track option.value) {
<label class="ai-preferences__radio-option"
[class.ai-preferences__radio-option--selected]="currentPreferences().verbosity === option.value">
<input type="radio"
name="verbosity"
[value]="option.value"
[checked]="currentPreferences().verbosity === option.value"
(change)="onVerbosityChange(option.value)" />
<span class="ai-preferences__radio-label">{{ option.label }}</span>
<span class="ai-preferences__radio-description">{{ option.description }}</span>
</label>
}
</div>
</section>
<!-- Surfaces Section -->
<section class="ai-preferences__section">
<h3 class="ai-preferences__section-title">Where to Show AI</h3>
<p class="ai-preferences__section-description">
Choose where AI assistance appears
</p>
<div class="ai-preferences__toggle-options">
<label class="ai-preferences__toggle-option">
<span class="ai-preferences__toggle-text">
<span class="ai-preferences__toggle-label">Show in UI</span>
<span class="ai-preferences__toggle-description">
Display AI chips and panels in the web interface
</span>
</span>
<input type="checkbox"
[checked]="currentPreferences().surfaces.showInUi"
(change)="onSurfaceChange('showInUi', $event)" />
</label>
<label class="ai-preferences__toggle-option">
<span class="ai-preferences__toggle-text">
<span class="ai-preferences__toggle-label">Include in PR Comments</span>
<span class="ai-preferences__toggle-description">
Add AI insights to pull request comments
</span>
</span>
<input type="checkbox"
[checked]="currentPreferences().surfaces.showInPrComments"
(change)="onSurfaceChange('showInPrComments', $event)" />
</label>
<label class="ai-preferences__toggle-option">
<span class="ai-preferences__toggle-text">
<span class="ai-preferences__toggle-label">Include in Notifications</span>
<span class="ai-preferences__toggle-description">
Add AI summaries to email and chat notifications
</span>
</span>
<input type="checkbox"
[checked]="currentPreferences().surfaces.showInNotifications"
(change)="onSurfaceChange('showInNotifications', $event)" />
</label>
</div>
</section>
<!-- Team Notifications Section -->
@if (currentPreferences().teamNotifications.length > 0) {
<section class="ai-preferences__section">
<h3 class="ai-preferences__section-title">Team AI Notifications</h3>
<p class="ai-preferences__section-description">
Opt-in to AI notifications for specific teams (default: off)
</p>
<div class="ai-preferences__team-list">
@for (team of currentPreferences().teamNotifications; track team.teamId) {
<label class="ai-preferences__team-option">
<input type="checkbox"
[checked]="team.enabled"
(change)="onTeamNotificationChange(team.teamId, $event)" />
<span class="ai-preferences__team-name">{{ team.teamName }}</span>
</label>
}
</div>
</section>
}
<!-- Actions -->
<div class="ai-preferences__actions">
<button class="ai-preferences__button ai-preferences__button--secondary"
(click)="onReset()">
Reset to Defaults
</button>
<button class="ai-preferences__button ai-preferences__button--primary"
[disabled]="!hasChanges()"
(click)="onSave()">
Save Preferences
</button>
</div>
</div>
</div>
`,
styles: [`
.ai-preferences {
max-width: 600px;
padding: 1.5rem;
}
.ai-preferences__header {
margin-bottom: 1.5rem;
}
.ai-preferences__title {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.ai-preferences__description {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
}
.ai-preferences__sections {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.ai-preferences__section {
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
}
.ai-preferences__section-title {
margin: 0 0 0.25rem 0;
font-size: 0.9375rem;
font-weight: 600;
color: #111827;
}
.ai-preferences__section-description {
margin: 0 0 1rem 0;
font-size: 0.8125rem;
color: #6b7280;
}
.ai-preferences__verbosity-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ai-preferences__radio-option {
display: flex;
flex-direction: column;
padding: 0.75rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-preferences__radio-option:hover {
border-color: #d1d5db;
}
.ai-preferences__radio-option--selected {
border-color: #4f46e5;
background: #eef2ff;
}
.ai-preferences__radio-option input {
position: absolute;
opacity: 0;
}
.ai-preferences__radio-label {
font-size: 0.875rem;
font-weight: 500;
color: #111827;
}
.ai-preferences__radio-description {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.25rem;
}
.ai-preferences__toggle-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ai-preferences__toggle-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
}
.ai-preferences__toggle-option:hover {
border-color: #d1d5db;
}
.ai-preferences__toggle-text {
display: flex;
flex-direction: column;
}
.ai-preferences__toggle-label {
font-size: 0.875rem;
font-weight: 500;
color: #111827;
}
.ai-preferences__toggle-description {
font-size: 0.75rem;
color: #6b7280;
}
.ai-preferences__toggle-option input[type="checkbox"] {
width: 2.5rem;
height: 1.25rem;
appearance: none;
background: #d1d5db;
border-radius: 9999px;
position: relative;
cursor: pointer;
transition: background 0.2s ease;
}
.ai-preferences__toggle-option input[type="checkbox"]::after {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: 1rem;
height: 1rem;
background: white;
border-radius: 50%;
transition: transform 0.2s ease;
}
.ai-preferences__toggle-option input[type="checkbox"]:checked {
background: #4f46e5;
}
.ai-preferences__toggle-option input[type="checkbox"]:checked::after {
transform: translateX(1.25rem);
}
.ai-preferences__team-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ai-preferences__team-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
cursor: pointer;
}
.ai-preferences__team-name {
font-size: 0.875rem;
color: #374151;
}
.ai-preferences__actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.ai-preferences__button {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-preferences__button--secondary {
background: white;
border: 1px solid #d1d5db;
color: #374151;
}
.ai-preferences__button--secondary:hover {
background: #f3f4f6;
}
.ai-preferences__button--primary {
background: #4f46e5;
border: 1px solid #4f46e5;
color: white;
}
.ai-preferences__button--primary:hover:not(:disabled) {
background: #4338ca;
}
.ai-preferences__button--primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`]
})
export class AiPreferencesComponent {
/**
* Initial preferences.
*/
readonly initialPreferences = input<AiPreferences>(DEFAULT_AI_PREFERENCES);
/**
* Available teams for notification settings.
*/
readonly teams = input<{ teamId: string; teamName: string }[]>([]);
/**
* Preferences changed and saved.
*/
readonly save = output<AiPreferences>();
/**
* Current preferences state.
*/
readonly currentPreferences = signal<AiPreferences>(DEFAULT_AI_PREFERENCES);
/**
* Original preferences for comparison.
*/
private originalPreferences = DEFAULT_AI_PREFERENCES;
/**
* Verbosity options.
*/
readonly verbosityOptions: { value: AiVerbosity; label: string; description: string }[] = [
{
value: 'minimal',
label: 'Minimal',
description: 'Show only action chips, no explanations'
},
{
value: 'standard',
label: 'Standard',
description: '3-line summaries with expand option'
},
{
value: 'detailed',
label: 'Detailed',
description: 'Full explanations shown by default'
}
];
constructor() {
effect(() => {
const initial = this.initialPreferences();
const teamList = this.teams();
// Merge team notifications with available teams
const teamNotifications = teamList.map(t => {
const existing = initial.teamNotifications.find(tn => tn.teamId === t.teamId);
return {
teamId: t.teamId,
teamName: t.teamName,
enabled: existing?.enabled ?? false
};
});
const prefs = { ...initial, teamNotifications };
this.currentPreferences.set(prefs);
this.originalPreferences = prefs;
});
}
/**
* Check if there are unsaved changes.
*/
hasChanges(): boolean {
const current = this.currentPreferences();
const original = this.originalPreferences;
return (
current.verbosity !== original.verbosity ||
current.surfaces.showInUi !== original.surfaces.showInUi ||
current.surfaces.showInPrComments !== original.surfaces.showInPrComments ||
current.surfaces.showInNotifications !== original.surfaces.showInNotifications ||
current.teamNotifications.some((t, i) =>
t.enabled !== original.teamNotifications[i]?.enabled
)
);
}
onVerbosityChange(value: AiVerbosity): void {
this.currentPreferences.update(p => ({ ...p, verbosity: value }));
}
onSurfaceChange(key: keyof AiSurfaceSettings, event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
this.currentPreferences.update(p => ({
...p,
surfaces: { ...p.surfaces, [key]: checked }
}));
}
onTeamNotificationChange(teamId: string, event: Event): void {
const enabled = (event.target as HTMLInputElement).checked;
this.currentPreferences.update(p => ({
...p,
teamNotifications: p.teamNotifications.map(t =>
t.teamId === teamId ? { ...t, enabled } : t
)
}));
}
onReset(): void {
this.currentPreferences.set({ ...DEFAULT_AI_PREFERENCES });
}
onSave(): void {
const prefs = this.currentPreferences();
this.originalPreferences = prefs;
this.save.emit(prefs);
}
}

View File

@@ -0,0 +1,169 @@
// -----------------------------------------------------------------------------
// triage-canvas.component.spec.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Task: TRIAGE-33 — Unit tests for all triage components
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { TriageCanvasComponent } from '../components/triage-canvas/triage-canvas.component';
import { VulnerabilityListService } from '../services/vulnerability-list.service';
import { AdvisoryAiService } from '../services/advisory-ai.service';
import { VexDecisionService } from '../services/vex-decision.service';
describe('TriageCanvasComponent', () => {
let component: TriageCanvasComponent;
let fixture: ComponentFixture<TriageCanvasComponent>;
let vulnServiceSpy: jasmine.SpyObj<VulnerabilityListService>;
let aiServiceSpy: jasmine.SpyObj<AdvisoryAiService>;
let vexServiceSpy: jasmine.SpyObj<VexDecisionService>;
const mockVulnerabilities = [
{
id: 'vuln-1',
cveId: 'CVE-2024-1234',
title: 'Test Vulnerability 1',
description: 'Test description',
severity: 'critical' as const,
cvssScore: 9.8,
isKev: true,
hasExploit: true,
hasFixAvailable: true,
affectedPackages: [],
references: [],
publishedAt: '2024-01-01T00:00:00Z',
modifiedAt: '2024-01-02T00:00:00Z',
},
{
id: 'vuln-2',
cveId: 'CVE-2024-5678',
title: 'Test Vulnerability 2',
description: 'Test description 2',
severity: 'high' as const,
cvssScore: 7.5,
isKev: false,
hasExploit: false,
hasFixAvailable: false,
affectedPackages: [],
references: [],
publishedAt: '2024-01-03T00:00:00Z',
modifiedAt: '2024-01-04T00:00:00Z',
},
];
beforeEach(async () => {
vulnServiceSpy = jasmine.createSpyObj('VulnerabilityListService', [
'loadVulnerabilities',
'selectVulnerability',
'updateFilter',
'clearFilter',
'loadMore',
], {
items: jasmine.createSpy().and.returnValue(mockVulnerabilities),
loading: jasmine.createSpy().and.returnValue(false),
error: jasmine.createSpy().and.returnValue(null),
total: jasmine.createSpy().and.returnValue(2),
filter: jasmine.createSpy().and.returnValue({}),
selectedItem: jasmine.createSpy().and.returnValue(null),
hasMore: jasmine.createSpy().and.returnValue(false),
severityCounts: jasmine.createSpy().and.returnValue({ critical: 1, high: 1, medium: 0, low: 0, none: 0 }),
});
vulnServiceSpy.loadVulnerabilities.and.returnValue(of({ items: mockVulnerabilities, total: 2, page: 1, pageSize: 25, hasMore: false }));
aiServiceSpy = jasmine.createSpyObj('AdvisoryAiService', [
'getRecommendations',
'requestAnalysis',
'getCachedRecommendations',
], {
loading: jasmine.createSpy().and.returnValue(new Set()),
});
vexServiceSpy = jasmine.createSpyObj('VexDecisionService', [
'getDecisionsForVuln',
'createDecision',
], {
loading: jasmine.createSpy().and.returnValue(false),
error: jasmine.createSpy().and.returnValue(null),
});
vexServiceSpy.getDecisionsForVuln.and.returnValue(of([]));
await TestBed.configureTestingModule({
imports: [TriageCanvasComponent],
providers: [
provideRouter([]),
{ provide: VulnerabilityListService, useValue: vulnServiceSpy },
{ provide: AdvisoryAiService, useValue: aiServiceSpy },
{ provide: VexDecisionService, useValue: vexServiceSpy },
],
}).compileComponents();
fixture = TestBed.createComponent(TriageCanvasComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load vulnerabilities on init', () => {
expect(vulnServiceSpy.loadVulnerabilities).toHaveBeenCalled();
});
it('should display vulnerability count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('2 vulnerabilities');
});
it('should toggle severity filter', () => {
component.toggleSeverityFilter('critical');
expect(vulnServiceSpy.updateFilter).toHaveBeenCalled();
});
it('should toggle KEV filter', () => {
component.toggleKevFilter();
expect(vulnServiceSpy.updateFilter).toHaveBeenCalled();
});
it('should toggle layout mode', () => {
const initialMode = component.layout().mode;
component.toggleLayoutMode();
expect(component.layout().mode).not.toBe(initialMode);
});
it('should select vulnerability', () => {
const vuln = mockVulnerabilities[0];
component.selectVulnerability(vuln as any);
expect(vulnServiceSpy.selectVulnerability).toHaveBeenCalledWith(vuln.id);
});
it('should handle keyboard navigation', () => {
const event = new KeyboardEvent('keydown', { key: 'j' });
component.handleKeyDown(event);
// Should not throw
});
it('should toggle bulk selection', () => {
expect(component.bulkSelection().length).toBe(0);
component.toggleBulkSelection('vuln-1');
expect(component.bulkSelection()).toContain('vuln-1');
component.toggleBulkSelection('vuln-1');
expect(component.bulkSelection()).not.toContain('vuln-1');
});
it('should clear bulk selection', () => {
component.toggleBulkSelection('vuln-1');
component.toggleBulkSelection('vuln-2');
expect(component.bulkSelection().length).toBe(2);
component.clearBulkSelection();
expect(component.bulkSelection().length).toBe(0);
});
it('should format VEX status correctly', () => {
expect(component.formatVexStatus('not_affected')).toBe('Not Affected');
expect(component.formatVexStatus('affected_mitigated')).toBe('Mitigated');
expect(component.formatVexStatus('fixed')).toBe('Fixed');
});
});

View File

@@ -0,0 +1,187 @@
// -----------------------------------------------------------------------------
// triage-workflow.e2e.spec.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Task: TRIAGE-34 — E2E tests: complete triage workflow
// -----------------------------------------------------------------------------
import { test, expect } from '@playwright/test';
test.describe('Triage Workflow E2E', () => {
test.beforeEach(async ({ page }) => {
// Navigate to triage canvas
await page.goto('/triage');
// Wait for data to load
await page.waitForSelector('.triage-canvas');
});
test('should display vulnerability list', async ({ page }) => {
// Wait for vulnerabilities to load
await page.waitForSelector('.vuln-card');
// Verify list is populated
const vulnCards = page.locator('.vuln-card');
await expect(vulnCards.first()).toBeVisible();
});
test('should filter by severity', async ({ page }) => {
// Click critical filter chip
await page.click('.filter-chip:has-text("Critical")');
// Verify filter is active
await expect(page.locator('.filter-chip--active:has-text("Critical")')).toBeVisible();
// Verify only critical vulns shown
const sevBadges = page.locator('.vuln-card__severity');
const count = await sevBadges.count();
for (let i = 0; i < count; i++) {
await expect(sevBadges.nth(i)).toContainText('CRIT');
}
});
test('should filter by KEV', async ({ page }) => {
// Toggle KEV filter
await page.click('.filter-toggle:has-text("KEV")');
// Verify KEV badges visible on all items
const vulnCards = page.locator('.vuln-card');
const count = await vulnCards.count();
for (let i = 0; i < count; i++) {
const card = vulnCards.nth(i);
await expect(card.locator('.vuln-card__badge--kev')).toBeVisible();
}
});
test('should select vulnerability and show details', async ({ page }) => {
// Click first vulnerability
await page.click('.vuln-card:first-child');
// Verify detail pane is visible
await expect(page.locator('.triage-canvas__detail-pane')).toBeVisible();
// Verify CVE ID is displayed
await expect(page.locator('.detail-header__cve')).toBeVisible();
});
test('should navigate tabs in detail view', async ({ page }) => {
// Select a vulnerability first
await page.click('.vuln-card:first-child');
// Click each tab and verify content changes
const tabs = ['Overview', 'Reachability', 'AI Analysis', 'VEX History', 'Evidence'];
for (const tab of tabs) {
await page.click(`.detail-tab:has-text("${tab}")`);
await expect(page.locator(`.detail-tab--active:has-text("${tab}")`)).toBeVisible();
}
});
test('should perform quick triage action', async ({ page }) => {
// Select vulnerability
await page.click('.vuln-card:first-child');
// Click "Mark Not Affected" in detail actions
await page.click('button:has-text("Mark Not Affected")');
// Verify action was triggered (check for status update or modal)
// This depends on implementation - could be a notification, status change, etc.
});
test('should request AI analysis', async ({ page }) => {
// Select vulnerability
await page.click('.vuln-card:first-child');
// Navigate to AI tab
await page.click('.detail-tab:has-text("AI Analysis")');
// Click analyze button if available
const analyzeBtn = page.locator('button:has-text("Analyze"), button:has-text("Request Analysis")');
if (await analyzeBtn.isVisible()) {
await analyzeBtn.click();
// Verify loading state or results
}
});
test('should bulk select vulnerabilities', async ({ page }) => {
// Check first two vulnerability checkboxes
const checkboxes = page.locator('.vuln-card__checkbox');
await checkboxes.nth(0).check();
await checkboxes.nth(1).check();
// Verify bulk action bar appears
await expect(page.locator('.bulk-action-bar, :has-text("selected")')).toBeVisible();
});
test('should use keyboard navigation', async ({ page }) => {
// Focus the list
await page.click('.vuln-card:first-child');
// Press j to go to next
await page.keyboard.press('j');
// Verify second item is now selected
const secondCard = page.locator('.vuln-card:nth-child(2)');
await expect(secondCard).toHaveClass(/selected|focused/);
// Press k to go back
await page.keyboard.press('k');
// Verify first item is selected again
const firstCard = page.locator('.vuln-card:first-child');
await expect(firstCard).toHaveClass(/selected|focused/);
});
test('should copy replay command', async ({ page }) => {
// Select vulnerability
await page.click('.vuln-card:first-child');
// Navigate to evidence tab
await page.click('.detail-tab:has-text("Evidence")');
// Click copy button for replay command
const copyBtn = page.locator('.evidence-card:has-text("Replay") button:has-text("Copy")');
if (await copyBtn.isVisible()) {
await copyBtn.click();
// Note: Cannot easily verify clipboard in E2E, but can check for success indication
}
});
test('should resize panes', async ({ page }) => {
// Find resize handle
const handle = page.locator('.triage-canvas__resize-handle');
if (await handle.isVisible()) {
// Get initial width
const listPane = page.locator('.triage-canvas__list-pane');
const initialWidth = await listPane.evaluate(el => el.getBoundingClientRect().width);
// Drag handle
await handle.hover();
await page.mouse.down();
await page.mouse.move(100, 0);
await page.mouse.up();
// Verify width changed
const newWidth = await listPane.evaluate(el => el.getBoundingClientRect().width);
expect(newWidth).not.toBe(initialWidth);
}
});
test('should toggle layout mode', async ({ page }) => {
// Find toggle button
const toggleBtn = page.locator('button:has-text("Expand"), button:has-text("Split")');
if (await toggleBtn.isVisible()) {
// Click to toggle
await toggleBtn.click();
// Verify layout changed (class or visibility)
const canvas = page.locator('.triage-canvas');
const hasDetailClass = await canvas.evaluate(el =>
el.classList.contains('triage-canvas--detail') ||
el.classList.contains('triage-canvas--split')
);
expect(hasDetailClass).toBeTruthy();
}
});
});

View File

@@ -0,0 +1,405 @@
// -----------------------------------------------------------------------------
// triage.integration.spec.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Task: TRIAGE-35 — Integration tests: VulnExplorer and AdvisoryAI API calls
// -----------------------------------------------------------------------------
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { firstValueFrom } from 'rxjs';
import { VulnerabilityListService } from '../services/vulnerability-list.service';
import { AdvisoryAiService } from '../services/advisory-ai.service';
import { VexDecisionService } from '../services/vex-decision.service';
describe('Triage Services Integration', () => {
let httpMock: HttpTestingController;
let vulnService: VulnerabilityListService;
let aiService: AdvisoryAiService;
let vexService: VexDecisionService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
VulnerabilityListService,
AdvisoryAiService,
VexDecisionService,
],
});
httpMock = TestBed.inject(HttpTestingController);
vulnService = TestBed.inject(VulnerabilityListService);
aiService = TestBed.inject(AdvisoryAiService);
vexService = TestBed.inject(VexDecisionService);
});
afterEach(() => {
httpMock.verify();
});
describe('VulnerabilityListService', () => {
it('should fetch vulnerabilities from API', async () => {
const mockResponse = {
items: [
{
id: 'vuln-1',
cveId: 'CVE-2024-1234',
title: 'Test Vulnerability',
severity: 'critical',
cvssScore: 9.8,
},
],
total: 1,
page: 1,
pageSize: 25,
hasMore: false,
};
const promise = firstValueFrom(vulnService.loadVulnerabilities());
const req = httpMock.expectOne(req =>
req.url.includes('/api/v1/vulnerabilities') &&
req.method === 'GET'
);
req.flush(mockResponse);
const result = await promise;
expect(result.items.length).toBe(1);
expect(result.items[0].cveId).toBe('CVE-2024-1234');
});
it('should apply severity filter to request', async () => {
vulnService.updateFilter({ severities: ['critical', 'high'] });
const promise = firstValueFrom(vulnService.loadVulnerabilities());
const req = httpMock.expectOne(req =>
req.url.includes('/api/v1/vulnerabilities') &&
req.params.get('severities') === 'critical,high'
);
req.flush({ items: [], total: 0, page: 1, pageSize: 25, hasMore: false });
await promise;
});
it('should apply KEV filter to request', async () => {
vulnService.updateFilter({ isKev: true });
const promise = firstValueFrom(vulnService.loadVulnerabilities());
const req = httpMock.expectOne(req =>
req.url.includes('/api/v1/vulnerabilities') &&
req.params.get('isKev') === 'true'
);
req.flush({ items: [], total: 0, page: 1, pageSize: 25, hasMore: false });
await promise;
});
it('should load more with incremented page', async () => {
// First load
vulnService.loadVulnerabilities().subscribe();
const req1 = httpMock.expectOne(req => req.params.get('page') === '1');
req1.flush({ items: [{ id: '1' }], total: 50, page: 1, pageSize: 25, hasMore: true });
// Load more
const promise = firstValueFrom(vulnService.loadMore());
const req2 = httpMock.expectOne(req => req.params.get('page') === '2');
req2.flush({ items: [{ id: '2' }], total: 50, page: 2, pageSize: 25, hasMore: false });
await promise;
expect(vulnService.items().length).toBe(2);
});
it('should get single vulnerability by ID', async () => {
const mockVuln = { id: 'vuln-1', cveId: 'CVE-2024-1234' };
const promise = firstValueFrom(vulnService.getVulnerabilityById('vuln-1'));
const req = httpMock.expectOne('/api/v1/vulnerabilities/vuln-1');
req.flush(mockVuln);
const result = await promise;
expect(result.id).toBe('vuln-1');
});
});
describe('AdvisoryAiService', () => {
it('should fetch recommendations for vulnerability', async () => {
const mockRecs = [
{
id: 'rec-1',
type: 'triage_action',
confidence: 0.95,
title: 'Mark as not affected',
description: 'Based on analysis...',
reasoning: 'The code path is not reachable',
sources: ['static analysis'],
},
];
const promise = firstValueFrom(aiService.getRecommendations('vuln-1'));
const req = httpMock.expectOne('/api/v1/advisory/recommendations/vuln-1');
req.flush(mockRecs);
const result = await promise;
expect(result.length).toBe(1);
expect(result[0].confidence).toBe(0.95);
});
it('should request analysis and poll for results', async () => {
const taskId = 'task-123';
// Start analysis
const promise = firstValueFrom(aiService.requestAnalysis('vuln-1', { vulnId: 'vuln-1' }));
// Expect plan request
const planReq = httpMock.expectOne('/api/v1/advisory/plan');
expect(planReq.request.method).toBe('POST');
planReq.flush({ taskId });
// Expect task status poll (completed immediately for test)
const statusReq = httpMock.expectOne(`/api/v1/advisory/tasks/${taskId}`);
statusReq.flush({
taskId,
status: 'completed',
result: [{ id: 'rec-1', type: 'triage_action', confidence: 0.9 }],
});
const result = await promise;
expect(result.status).toBe('completed');
expect(result.result?.length).toBe(1);
});
it('should fetch similar vulnerabilities', async () => {
const mockSimilar = [
{
vulnId: 'vuln-2',
cveId: 'CVE-2024-5678',
similarity: 0.85,
reason: 'Similar affected function',
vexDecision: 'not_affected',
},
];
const promise = firstValueFrom(aiService.getSimilarVulnerabilities('vuln-1', 5));
const req = httpMock.expectOne(req =>
req.url.includes('/api/v1/advisory/similar/vuln-1') &&
req.params.get('limit') === '5'
);
req.flush(mockSimilar);
const result = await promise;
expect(result.length).toBe(1);
expect(result[0].similarity).toBe(0.85);
});
it('should get explanation for question', async () => {
const mockExplanation = {
question: 'Why is this reachable?',
answer: 'The function is called from...',
confidence: 0.88,
sources: ['call graph analysis'],
};
const promise = firstValueFrom(aiService.getExplanation('vuln-1', 'Why is this reachable?'));
const req = httpMock.expectOne('/api/v1/advisory/explain');
expect(req.request.method).toBe('POST');
expect(req.request.body.vulnId).toBe('vuln-1');
expect(req.request.body.question).toBe('Why is this reachable?');
req.flush(mockExplanation);
const result = await promise;
expect(result.confidence).toBe(0.88);
});
});
describe('VexDecisionService', () => {
it('should fetch decisions for vulnerability', async () => {
const mockDecisions = [
{
id: 'decision-1',
vulnId: 'vuln-1',
status: 'not_affected',
justification: 'Code not reachable',
evidenceRefs: [],
scope: {},
signedAsAttestation: false,
createdBy: 'user@example.com',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
];
const promise = firstValueFrom(vexService.getDecisionsForVuln('vuln-1'));
const req = httpMock.expectOne(req =>
req.url.includes('/api/v1/vex/decisions') &&
req.params.get('vulnId') === 'vuln-1'
);
req.flush(mockDecisions);
const result = await promise;
expect(result.length).toBe(1);
expect(result[0].status).toBe('not_affected');
});
it('should create new decision', async () => {
const mockDecision = {
id: 'decision-new',
vulnId: 'vuln-1',
status: 'not_affected',
justification: 'Test justification',
evidenceRefs: [],
scope: {},
signedAsAttestation: false,
createdBy: 'user@example.com',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
const promise = firstValueFrom(vexService.createDecision({
vulnId: 'vuln-1',
status: 'not_affected',
justification: 'Test justification',
}));
const req = httpMock.expectOne('/api/v1/vex/decisions');
expect(req.request.method).toBe('POST');
expect(req.request.body.vulnId).toBe('vuln-1');
expect(req.request.body.status).toBe('not_affected');
req.flush(mockDecision);
const result = await promise;
expect(result.id).toBe('decision-new');
});
it('should create bulk decisions', async () => {
const mockDecisions = [
{ id: 'd1', vulnId: 'vuln-1', status: 'not_affected' },
{ id: 'd2', vulnId: 'vuln-2', status: 'not_affected' },
];
const promise = firstValueFrom(vexService.createBulkDecisions({
vulnIds: ['vuln-1', 'vuln-2'],
status: 'not_affected',
justification: 'Bulk action',
}));
const req = httpMock.expectOne('/api/v1/vex/decisions/bulk');
expect(req.request.method).toBe('POST');
expect(req.request.body.vulnIds.length).toBe(2);
req.flush(mockDecisions);
const result = await promise;
expect(result.length).toBe(2);
});
it('should get decision history', async () => {
const mockHistory = [
{
decision: { id: 'd1', vulnId: 'vuln-1', status: 'not_affected' },
isActive: true,
},
{
decision: { id: 'd0', vulnId: 'vuln-1', status: 'under_investigation' },
supersededBy: 'd1',
isActive: false,
},
];
const promise = firstValueFrom(vexService.getDecisionHistory('vuln-1'));
const req = httpMock.expectOne(req =>
req.url.includes('/api/v1/vex/decisions/history') &&
req.params.get('vulnId') === 'vuln-1'
);
req.flush(mockHistory);
const result = await promise;
expect(result.length).toBe(2);
expect(result[0].isActive).toBe(true);
expect(result[1].isActive).toBe(false);
});
it('should supersede existing decision', async () => {
const mockNewDecision = {
id: 'decision-new',
vulnId: 'vuln-1',
status: 'fixed',
supersedes: 'decision-old',
};
const promise = firstValueFrom(vexService.supersedeDecision('decision-old', {
vulnId: 'vuln-1',
status: 'fixed',
justification: 'Fix applied',
}));
const req = httpMock.expectOne('/api/v1/vex/decisions');
expect(req.request.body.supersedes).toBe('decision-old');
req.flush(mockNewDecision);
const result = await promise;
expect(result.supersedes).toBe('decision-old');
});
});
describe('Cross-service integration', () => {
it('should correlate vulnerability with VEX decisions', async () => {
// Load vulnerabilities
vulnService.loadVulnerabilities().subscribe();
const vulnReq = httpMock.expectOne(req => req.url.includes('/api/v1/vulnerabilities'));
vulnReq.flush({
items: [{ id: 'vuln-1', cveId: 'CVE-2024-1234', vexStatus: 'not_affected' }],
total: 1,
page: 1,
pageSize: 25,
hasMore: false,
});
// Load VEX decisions
vexService.getDecisionsForVuln('vuln-1').subscribe();
const vexReq = httpMock.expectOne(req =>
req.url.includes('/api/v1/vex/decisions') &&
req.params.get('vulnId') === 'vuln-1'
);
vexReq.flush([
{ id: 'd1', vulnId: 'vuln-1', status: 'not_affected', justification: 'Not reachable' },
]);
// Verify correlation
const vuln = vulnService.items()[0];
expect(vuln.vexStatus).toBe('not_affected');
});
it('should load AI recommendations after selecting vulnerability', async () => {
// Load vulnerability
vulnService.loadVulnerabilities().subscribe();
const vulnReq = httpMock.expectOne(req => req.url.includes('/api/v1/vulnerabilities'));
vulnReq.flush({
items: [{ id: 'vuln-1', cveId: 'CVE-2024-1234' }],
total: 1,
page: 1,
pageSize: 25,
hasMore: false,
});
// Select and load AI recommendations
vulnService.selectVulnerability('vuln-1');
aiService.getRecommendations('vuln-1').subscribe();
const aiReq = httpMock.expectOne('/api/v1/advisory/recommendations/vuln-1');
aiReq.flush([
{ id: 'rec-1', type: 'triage_action', confidence: 0.95, title: 'Mark not affected' },
]);
// Verify recommendations cached
const cached = aiService.getCachedRecommendations('vuln-1');
expect(cached?.length).toBe(1);
});
});
});

View File

@@ -0,0 +1,880 @@
// -----------------------------------------------------------------------------
// ai-recommendation-panel.component.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Tasks: TRIAGE-14 — AiRecommendationPanel: AdvisoryAI suggestions for current vuln
// TRIAGE-15 — "Why is this reachable?" AI-generated explanation
// TRIAGE-16 — Suggested VEX justification from AI analysis
// TRIAGE-17 — Similar vulnerabilities suggestion based on AI clustering
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
OnChanges,
SimpleChanges,
} from '@angular/core';
import { Subscription } from 'rxjs';
import {
AdvisoryAiService,
type AiRecommendation,
type AiExplanation,
type SimilarVulnerability,
type SuggestedAction,
} from '../../services/advisory-ai.service';
export interface ApplySuggestionEvent {
recommendation: AiRecommendation;
action: SuggestedAction;
}
@Component({
selector: 'app-ai-recommendation-panel',
standalone: true,
imports: [CommonModule],
template: `
<div class="ai-panel">
<!-- Header -->
<div class="ai-panel__header">
<div class="ai-panel__title">
<span class="ai-icon">🤖</span>
<h3>AI Analysis</h3>
</div>
<div class="ai-panel__actions">
@if (!loading() && recommendations().length === 0) {
<button class="analyze-btn" (click)="requestAnalysis()">
Analyze
</button>
} @else if (!loading()) {
<button class="refresh-btn" (click)="requestAnalysis()" title="Re-analyze">
</button>
}
</div>
</div>
<!-- Loading State -->
@if (loading()) {
<div class="ai-panel__loading">
<div class="spinner"></div>
<p>Analyzing vulnerability...</p>
<p class="loading-hint">This may take a few moments</p>
</div>
}
<!-- Recommendations -->
@if (!loading() && recommendations().length > 0) {
<div class="ai-panel__recommendations">
@for (rec of recommendations(); track rec.id) {
<article class="recommendation-card" [class.recommendation-card--high-confidence]="rec.confidence >= 0.8">
<header class="recommendation-card__header">
<span class="recommendation-type type--{{ rec.type }}">
{{ formatRecType(rec.type) }}
</span>
<span class="recommendation-confidence">
<span class="confidence-bar">
<span class="confidence-fill" [style.width.%]="rec.confidence * 100"></span>
</span>
{{ (rec.confidence * 100).toFixed(0) }}%
</span>
</header>
<h4 class="recommendation-card__title">{{ rec.title }}</h4>
<p class="recommendation-card__description">{{ rec.description }}</p>
@if (rec.suggestedAction) {
<div class="recommendation-card__action">
<button
class="apply-btn"
(click)="onApplySuggestion(rec)"
>
Apply: {{ formatActionType(rec.suggestedAction.type) }}
</button>
@if (rec.suggestedAction.suggestedJustification) {
<p class="suggested-justification">
<strong>Suggested justification:</strong>
{{ rec.suggestedAction.suggestedJustification }}
</p>
}
</div>
}
<details class="recommendation-card__details">
<summary>View reasoning & sources</summary>
<div class="details-content">
<p class="reasoning">{{ rec.reasoning }}</p>
@if (rec.sources.length > 0) {
<ul class="sources">
@for (source of rec.sources; track source) {
<li>{{ source }}</li>
}
</ul>
}
</div>
</details>
</article>
}
</div>
}
<!-- Reachability Explanation -->
@if (showReachability() && reachabilityExplanation()) {
<section class="ai-section">
<h4 class="ai-section__title">
<span class="section-icon">🔍</span>
Why is this reachable?
</h4>
<div class="explanation-card">
<p class="explanation-answer">{{ reachabilityExplanation()!.answer }}</p>
<div class="explanation-confidence">
Confidence: {{ (reachabilityExplanation()!.confidence * 100).toFixed(0) }}%
</div>
@if (reachabilityExplanation()!.sources.length > 0) {
<details class="explanation-sources">
<summary>Sources</summary>
<ul>
@for (source of reachabilityExplanation()!.sources; track source) {
<li>{{ source }}</li>
}
</ul>
</details>
}
</div>
</section>
}
<!-- VEX Justification Suggestion -->
@if (vexSuggestion()) {
<section class="ai-section">
<h4 class="ai-section__title">
<span class="section-icon">📝</span>
Suggested VEX Justification
</h4>
<div class="vex-suggestion-card">
<p class="vex-suggestion-text">{{ vexSuggestion()!.answer }}</p>
<div class="vex-suggestion-meta">
<span class="confidence-badge">
{{ (vexSuggestion()!.confidence * 100).toFixed(0) }}% confident
</span>
</div>
<button class="use-suggestion-btn" (click)="onUseVexSuggestion()">
Use This Justification
</button>
</div>
</section>
}
<!-- Similar Vulnerabilities -->
@if (similarVulns().length > 0) {
<section class="ai-section">
<h4 class="ai-section__title">
<span class="section-icon">🔗</span>
Similar Vulnerabilities
</h4>
<div class="similar-vulns">
@for (similar of similarVulns(); track similar.vulnId) {
<article
class="similar-card"
[class.similar-card--decided]="similar.vexDecision"
(click)="onViewSimilar(similar)"
>
<div class="similar-card__header">
<span class="similar-card__cve">{{ similar.cveId }}</span>
<span class="similarity-score">
{{ (similar.similarity * 100).toFixed(0) }}% similar
</span>
</div>
<p class="similar-card__reason">{{ similar.reason }}</p>
@if (similar.vexDecision) {
<span class="similar-card__vex vex--{{ similar.vexDecision }}">
VEX: {{ formatVexStatus(similar.vexDecision) }}
</span>
}
</article>
}
</div>
<p class="similar-hint">
Click to view how similar vulnerabilities were triaged
</p>
</section>
}
<!-- Empty State -->
@if (!loading() && recommendations().length === 0 && !reachabilityExplanation() && similarVulns().length === 0) {
<div class="ai-panel__empty">
<span class="empty-icon">🤖</span>
<p>No AI analysis available yet</p>
<button class="analyze-btn" (click)="requestAnalysis()">
Start Analysis
</button>
</div>
}
<!-- Ask AI -->
<div class="ai-panel__ask">
<input
type="text"
class="ask-input"
placeholder="Ask AI about this vulnerability..."
[value]="customQuestion()"
(input)="onQuestionInput($event)"
(keydown.enter)="askCustomQuestion()"
/>
<button
class="ask-btn"
[disabled]="!customQuestion() || askingQuestion()"
(click)="askCustomQuestion()"
>
@if (askingQuestion()) {
...
} @else {
Ask
}
</button>
</div>
<!-- Custom Answer -->
@if (customAnswer()) {
<div class="custom-answer">
<p class="custom-answer__question">{{ customAnswer()!.question }}</p>
<p class="custom-answer__answer">{{ customAnswer()!.answer }}</p>
<span class="custom-answer__confidence">
{{ (customAnswer()!.confidence * 100).toFixed(0) }}% confidence
</span>
</div>
}
</div>
`,
styles: [`
.ai-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--surface-card, #fff);
}
.ai-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--surface-border, #e5e7eb);
}
.ai-panel__title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ai-panel__title h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.ai-icon {
font-size: 1.25rem;
}
.analyze-btn {
padding: 0.375rem 0.875rem;
border: none;
border-radius: 0.375rem;
background: var(--primary-color, #3b82f6);
color: #fff;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
}
.analyze-btn:hover {
background: var(--primary-600, #2563eb);
}
.refresh-btn {
width: 32px;
height: 32px;
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.375rem;
background: transparent;
font-size: 1rem;
cursor: pointer;
transition: all 0.15s ease;
}
.refresh-btn:hover {
border-color: var(--primary-color, #3b82f6);
background: var(--primary-50, #eff6ff);
}
/* Loading */
.ai-panel__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--surface-border, #e5e7eb);
border-top-color: var(--primary-color, #3b82f6);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-hint {
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
margin-top: 0.25rem;
}
/* Recommendations */
.ai-panel__recommendations {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.recommendation-card {
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--surface-ground, #f9fafb);
}
.recommendation-card--high-confidence {
border-color: var(--primary-200, #bfdbfe);
background: var(--primary-50, #eff6ff);
}
.recommendation-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.recommendation-type {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.type--triage_action { background: #dbeafe; color: #1d4ed8; }
.type--vex_suggestion { background: #dcfce7; color: #166534; }
.type--mitigation { background: #fef3c7; color: #92400e; }
.type--investigation { background: #f3e8ff; color: #7c3aed; }
.recommendation-confidence {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
}
.confidence-bar {
width: 40px;
height: 4px;
background: var(--surface-border, #e5e7eb);
border-radius: 2px;
overflow: hidden;
}
.confidence-fill {
display: block;
height: 100%;
background: var(--primary-color, #3b82f6);
transition: width 0.3s ease;
}
.recommendation-card__title {
margin: 0 0 0.375rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-color, #111827);
}
.recommendation-card__description {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
color: var(--text-color-secondary, #4b5563);
line-height: 1.5;
}
.recommendation-card__action {
margin-bottom: 0.75rem;
}
.apply-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 0.375rem;
background: var(--primary-color, #3b82f6);
color: #fff;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
}
.apply-btn:hover {
background: var(--primary-600, #2563eb);
}
.suggested-justification {
margin: 0.5rem 0 0;
padding: 0.5rem;
background: var(--surface-card, #fff);
border-radius: 0.25rem;
font-size: 0.75rem;
color: var(--text-color-secondary, #4b5563);
}
.recommendation-card__details {
font-size: 0.75rem;
}
.recommendation-card__details summary {
color: var(--primary-color, #3b82f6);
cursor: pointer;
}
.details-content {
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--surface-card, #fff);
border-radius: 0.25rem;
}
.reasoning {
margin: 0 0 0.5rem;
color: var(--text-color-secondary, #4b5563);
}
.sources {
margin: 0;
padding-left: 1rem;
color: var(--text-color-secondary, #6b7280);
}
/* AI Sections */
.ai-section {
padding: 1rem;
border-top: 1px solid var(--surface-border, #e5e7eb);
}
.ai-section__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
}
.section-icon {
font-size: 1rem;
}
.explanation-card,
.vex-suggestion-card {
padding: 0.75rem;
background: var(--surface-ground, #f9fafb);
border-radius: 0.375rem;
}
.explanation-answer,
.vex-suggestion-text {
margin: 0 0 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
}
.explanation-confidence,
.confidence-badge {
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
}
.explanation-sources {
margin-top: 0.5rem;
font-size: 0.75rem;
}
.explanation-sources summary {
color: var(--primary-color, #3b82f6);
cursor: pointer;
}
.explanation-sources ul {
margin: 0.5rem 0 0;
padding-left: 1rem;
}
.use-suggestion-btn {
margin-top: 0.75rem;
padding: 0.375rem 0.75rem;
border: 1px solid var(--primary-color, #3b82f6);
border-radius: 0.375rem;
background: transparent;
color: var(--primary-color, #3b82f6);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.use-suggestion-btn:hover {
background: var(--primary-50, #eff6ff);
}
/* Similar Vulnerabilities */
.similar-vulns {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.similar-card {
padding: 0.75rem;
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--surface-card, #fff);
cursor: pointer;
transition: all 0.15s ease;
}
.similar-card:hover {
border-color: var(--primary-color, #3b82f6);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.similar-card--decided {
border-left: 3px solid var(--green-500, #22c55e);
}
.similar-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.similar-card__cve {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-color, #111827);
}
.similarity-score {
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
}
.similar-card__reason {
margin: 0;
font-size: 0.75rem;
color: var(--text-color-secondary, #4b5563);
}
.similar-card__vex {
display: inline-block;
margin-top: 0.375rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 500;
}
.vex--not_affected { background: #dcfce7; color: #166534; }
.vex--affected_mitigated { background: #dbeafe; color: #1d4ed8; }
.vex--affected_unmitigated { background: #fecaca; color: #991b1b; }
.vex--fixed { background: #dcfce7; color: #166534; }
.vex--under_investigation { background: #fef3c7; color: #92400e; }
.similar-hint {
margin: 0.75rem 0 0;
font-size: 0.6875rem;
color: var(--text-color-secondary, #6b7280);
font-style: italic;
}
/* Empty State */
.ai-panel__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
}
.empty-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
opacity: 0.5;
}
.ai-panel__empty p {
margin: 0 0 1rem;
color: var(--text-color-secondary, #6b7280);
}
/* Ask AI */
.ai-panel__ask {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--surface-border, #e5e7eb);
background: var(--surface-ground, #f9fafb);
}
.ask-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.375rem;
font-size: 0.8125rem;
outline: none;
transition: border-color 0.15s ease;
}
.ask-input:focus {
border-color: var(--primary-color, #3b82f6);
}
.ask-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
background: var(--primary-color, #3b82f6);
color: #fff;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
}
.ask-btn:hover:not(:disabled) {
background: var(--primary-600, #2563eb);
}
.ask-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Custom Answer */
.custom-answer {
padding: 1rem;
margin: 0.75rem 1rem;
background: var(--surface-ground, #f9fafb);
border-radius: 0.375rem;
border-left: 3px solid var(--primary-color, #3b82f6);
}
.custom-answer__question {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--primary-color, #3b82f6);
}
.custom-answer__answer {
margin: 0 0 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
}
.custom-answer__confidence {
font-size: 0.6875rem;
color: var(--text-color-secondary, #6b7280);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AiRecommendationPanelComponent implements OnChanges {
private readonly aiService = inject(AdvisoryAiService);
private subscriptions: Subscription[] = [];
// Inputs
readonly vulnId = input.required<string>();
readonly showReachability = input<boolean>(true);
readonly autoAnalyze = input<boolean>(false);
// Outputs
readonly suggestionApplied = output<ApplySuggestionEvent>();
readonly vexSuggestionUsed = output<string>();
readonly similarVulnSelected = output<SimilarVulnerability>();
// State
readonly recommendations = signal<AiRecommendation[]>([]);
readonly reachabilityExplanation = signal<AiExplanation | null>(null);
readonly vexSuggestion = signal<AiExplanation | null>(null);
readonly similarVulns = signal<SimilarVulnerability[]>([]);
readonly customQuestion = signal('');
readonly customAnswer = signal<AiExplanation | null>(null);
readonly askingQuestion = signal(false);
readonly loading = computed(() => this.aiService.loading().has(this.vulnId()));
ngOnChanges(changes: SimpleChanges): void {
if (changes['vulnId'] && this.vulnId()) {
this.loadCachedData();
if (this.autoAnalyze()) {
this.requestAnalysis();
}
}
}
requestAnalysis(): void {
const vulnId = this.vulnId();
if (!vulnId) return;
// Request main analysis
this.subscriptions.push(
this.aiService.requestAnalysis(vulnId, {
vulnId,
includeReachability: this.showReachability(),
includeVexHistory: true,
}).subscribe({
next: (task) => {
if (task.result) {
this.recommendations.set(task.result);
}
},
})
);
// Load similar vulnerabilities
this.subscriptions.push(
this.aiService.getSimilarVulnerabilities(vulnId).subscribe({
next: (vulns) => this.similarVulns.set(vulns),
})
);
// Load reachability explanation if applicable
if (this.showReachability()) {
this.subscriptions.push(
this.aiService.getReachabilityExplanation(vulnId).subscribe({
next: (explanation) => this.reachabilityExplanation.set(explanation),
})
);
}
// Load VEX suggestion
this.subscriptions.push(
this.aiService.getSuggestedJustification(vulnId).subscribe({
next: (suggestion) => this.vexSuggestion.set(suggestion),
})
);
}
askCustomQuestion(): void {
const question = this.customQuestion().trim();
const vulnId = this.vulnId();
if (!question || !vulnId) return;
this.askingQuestion.set(true);
this.subscriptions.push(
this.aiService.getExplanation(vulnId, question).subscribe({
next: (answer) => {
this.customAnswer.set(answer);
this.askingQuestion.set(false);
},
error: () => {
this.askingQuestion.set(false);
},
})
);
}
onQuestionInput(event: Event): void {
this.customQuestion.set((event.target as HTMLInputElement).value);
}
onApplySuggestion(rec: AiRecommendation): void {
if (rec.suggestedAction) {
this.suggestionApplied.emit({ recommendation: rec, action: rec.suggestedAction });
}
}
onUseVexSuggestion(): void {
const suggestion = this.vexSuggestion();
if (suggestion) {
this.vexSuggestionUsed.emit(suggestion.answer);
}
}
onViewSimilar(similar: SimilarVulnerability): void {
this.similarVulnSelected.emit(similar);
}
formatRecType(type: string): string {
const map: Record<string, string> = {
'triage_action': 'Triage',
'vex_suggestion': 'VEX',
'mitigation': 'Mitigation',
'investigation': 'Investigate',
};
return map[type] ?? type;
}
formatActionType(type: string): string {
const map: Record<string, string> = {
'mark_not_affected': 'Mark Not Affected',
'mark_affected': 'Mark Affected',
'investigate': 'Investigate',
'apply_fix': 'Apply Fix',
'accept_risk': 'Accept Risk',
};
return map[type] ?? type;
}
formatVexStatus(status: string): string {
const map: Record<string, string> = {
'not_affected': 'Not Affected',
'affected_mitigated': 'Mitigated',
'affected_unmitigated': 'Affected',
'fixed': 'Fixed',
'under_investigation': 'Investigating',
};
return map[status] ?? status;
}
private loadCachedData(): void {
const cached = this.aiService.getCachedRecommendations(this.vulnId());
if (cached) {
this.recommendations.set(cached);
} else {
this.recommendations.set([]);
}
// Reset other state
this.reachabilityExplanation.set(null);
this.vexSuggestion.set(null);
this.similarVulns.set([]);
this.customAnswer.set(null);
}
}

View File

@@ -0,0 +1,720 @@
// -----------------------------------------------------------------------------
// bulk-action-modal.component.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Tasks: TRIAGE-27 — Bulk triage: select multiple vulns, apply same VEX decision
// TRIAGE-28 — Bulk action confirmation modal with impact summary
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
signal,
computed,
} from '@angular/core';
import { VexDecisionService, type VexStatus, type VexJustificationType } from '../../services/vex-decision.service';
import type { Vulnerability } from '../../services/vulnerability-list.service';
export interface BulkActionRequest {
vulnIds: string[];
action: BulkActionType;
vexStatus?: VexStatus;
justificationType?: VexJustificationType;
justification?: string;
}
export type BulkActionType = 'mark_not_affected' | 'mark_affected' | 'request_analysis' | 'defer' | 'custom_vex';
@Component({
selector: 'app-bulk-action-modal',
standalone: true,
imports: [CommonModule],
template: `
<div class="modal-overlay" (click)="onOverlayClick($event)">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<!-- Header -->
<header class="modal__header">
<h2 id="modal-title">Bulk Triage Action</h2>
<button class="close-btn" (click)="onClose()" aria-label="Close">×</button>
</header>
<!-- Content -->
<div class="modal__content">
<!-- Impact Summary -->
<div class="impact-summary">
<div class="impact-stat">
<span class="impact-value">{{ vulnerabilities().length }}</span>
<span class="impact-label">Vulnerabilities</span>
</div>
<div class="impact-stat">
<span class="impact-value">{{ affectedPackagesCount() }}</span>
<span class="impact-label">Affected Packages</span>
</div>
<div class="impact-stat">
<span class="impact-value">{{ criticalCount() }}</span>
<span class="impact-label">Critical</span>
</div>
<div class="impact-stat">
<span class="impact-value">{{ highCount() }}</span>
<span class="impact-label">High</span>
</div>
</div>
<!-- Severity Breakdown -->
<div class="severity-breakdown">
<h4>Severity Distribution</h4>
<div class="severity-bars">
@for (sev of severityBreakdown(); track sev.severity) {
<div class="severity-bar">
<span class="severity-label severity--{{ sev.severity }}">{{ sev.severity }}</span>
<div class="bar-track">
<div
class="bar-fill severity-fill--{{ sev.severity }}"
[style.width.%]="(sev.count / vulnerabilities().length) * 100"
></div>
</div>
<span class="severity-count">{{ sev.count }}</span>
</div>
}
</div>
</div>
<!-- Action Selection -->
<div class="action-selection">
<h4>Select Action</h4>
<div class="action-options">
@for (action of actionOptions; track action.value) {
<label
class="action-option"
[class.action-option--selected]="selectedAction() === action.value"
>
<input
type="radio"
name="action"
[value]="action.value"
[checked]="selectedAction() === action.value"
(change)="selectAction(action.value)"
/>
<div class="action-content">
<span class="action-icon">{{ action.icon }}</span>
<div class="action-text">
<span class="action-name">{{ action.label }}</span>
<span class="action-desc">{{ action.description }}</span>
</div>
</div>
</label>
}
</div>
</div>
<!-- Custom VEX Options -->
@if (selectedAction() === 'custom_vex') {
<div class="vex-options">
<div class="form-group">
<label for="vex-status">VEX Status</label>
<select
id="vex-status"
class="form-select"
[value]="vexStatus()"
(change)="onVexStatusChange($event)"
>
@for (status of statusOptions; track status.value) {
<option [value]="status.value">{{ status.label }}</option>
}
</select>
</div>
<div class="form-group">
<label for="justification-type">Justification Type</label>
<select
id="justification-type"
class="form-select"
[value]="justificationType()"
(change)="onJustificationTypeChange($event)"
>
@for (jt of justificationTypes; track jt.value) {
<option [value]="jt.value">{{ jt.label }}</option>
}
</select>
</div>
<div class="form-group">
<label for="justification">Justification</label>
<textarea
id="justification"
class="form-textarea"
rows="3"
placeholder="Provide justification for this decision..."
[value]="justification()"
(input)="onJustificationInput($event)"
></textarea>
</div>
</div>
}
<!-- Affected Vulnerabilities Preview -->
<details class="vuln-preview">
<summary>
View affected vulnerabilities ({{ vulnerabilities().length }})
</summary>
<div class="vuln-list">
@for (vuln of vulnerabilities(); track vuln.id) {
<div class="vuln-item">
<span class="vuln-severity severity--{{ vuln.severity }}">
{{ vuln.severity | slice:0:1 | uppercase }}
</span>
<span class="vuln-cve">{{ vuln.cveId }}</span>
<span class="vuln-title">{{ vuln.title }}</span>
</div>
}
</div>
</details>
<!-- Warning -->
@if (showWarning()) {
<div class="warning-banner">
<span class="warning-icon">⚠️</span>
<p>{{ warningMessage() }}</p>
</div>
}
</div>
<!-- Footer -->
<footer class="modal__footer">
<button class="btn btn--secondary" (click)="onClose()">Cancel</button>
<button
class="btn btn--primary"
[disabled]="!canSubmit() || loading()"
(click)="onSubmit()"
>
@if (loading()) {
Applying...
} @else {
Apply to {{ vulnerabilities().length }} Vulnerabilities
}
</button>
</footer>
</div>
</div>
`,
styles: [`
.modal-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
padding: 1rem;
}
.modal {
width: 100%;
max-width: 600px;
max-height: 90vh;
display: flex;
flex-direction: column;
background: var(--surface-card, #fff);
border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.modal__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--surface-border, #e5e7eb);
}
.modal__header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.close-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 0.375rem;
background: transparent;
font-size: 1.5rem;
color: var(--text-color-secondary, #6b7280);
cursor: pointer;
transition: all 0.15s ease;
}
.close-btn:hover {
background: var(--surface-ground, #f3f4f6);
color: var(--text-color, #111827);
}
.modal__content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
/* Impact Summary */
.impact-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.impact-stat {
text-align: center;
padding: 1rem;
background: var(--surface-ground, #f9fafb);
border-radius: 0.5rem;
}
.impact-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-color, #111827);
}
.impact-label {
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
}
/* Severity Breakdown */
.severity-breakdown {
margin-bottom: 1.5rem;
}
.severity-breakdown h4 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
}
.severity-bars {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.severity-bar {
display: flex;
align-items: center;
gap: 0.75rem;
}
.severity-label {
width: 60px;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
text-align: center;
}
.severity--critical { background: #fecaca; color: #991b1b; }
.severity--high { background: #fed7aa; color: #9a3412; }
.severity--medium { background: #fef08a; color: #854d0e; }
.severity--low { background: #bbf7d0; color: #166534; }
.severity--none { background: #e5e7eb; color: #374151; }
.bar-track {
flex: 1;
height: 8px;
background: var(--surface-border, #e5e7eb);
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
transition: width 0.3s ease;
}
.severity-fill--critical { background: #ef4444; }
.severity-fill--high { background: #f97316; }
.severity-fill--medium { background: #eab308; }
.severity-fill--low { background: #22c55e; }
.severity-fill--none { background: #9ca3af; }
.severity-count {
width: 30px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color, #111827);
text-align: right;
}
/* Action Selection */
.action-selection {
margin-bottom: 1.5rem;
}
.action-selection h4 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
}
.action-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.action-option {
display: flex;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.15s ease;
}
.action-option:hover {
border-color: var(--primary-color, #3b82f6);
}
.action-option--selected {
border-color: var(--primary-color, #3b82f6);
background: var(--primary-50, #eff6ff);
}
.action-option input {
margin-right: 0.75rem;
}
.action-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.action-icon {
font-size: 1.25rem;
}
.action-text {
display: flex;
flex-direction: column;
}
.action-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-color, #111827);
}
.action-desc {
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
}
/* VEX Options */
.vex-options {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--surface-ground, #f9fafb);
border-radius: 0.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-color, #111827);
}
.form-select,
.form-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.375rem;
font-size: 0.875rem;
outline: none;
transition: border-color 0.15s ease;
}
.form-select:focus,
.form-textarea:focus {
border-color: var(--primary-color, #3b82f6);
box-shadow: 0 0 0 3px var(--primary-100, #dbeafe);
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
/* Vulnerability Preview */
.vuln-preview {
margin-bottom: 1.5rem;
}
.vuln-preview summary {
padding: 0.5rem 0;
font-size: 0.875rem;
color: var(--primary-color, #3b82f6);
cursor: pointer;
}
.vuln-list {
max-height: 200px;
overflow-y: auto;
padding: 0.75rem;
background: var(--surface-ground, #f9fafb);
border-radius: 0.375rem;
}
.vuln-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0;
border-bottom: 1px solid var(--surface-border, #e5e7eb);
}
.vuln-item:last-child {
border-bottom: none;
}
.vuln-severity {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
}
.vuln-cve {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-color, #111827);
}
.vuln-title {
flex: 1;
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Warning Banner */
.warning-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 0.375rem;
}
.warning-icon {
font-size: 1rem;
}
.warning-banner p {
margin: 0;
font-size: 0.8125rem;
color: #92400e;
}
/* Footer */
.modal__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--surface-border, #e5e7eb);
background: var(--surface-ground, #f9fafb);
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.btn--primary {
background: var(--primary-color, #3b82f6);
color: #fff;
}
.btn--primary:hover:not(:disabled) {
background: var(--primary-600, #2563eb);
}
.btn--primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn--secondary {
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e5e7eb);
color: var(--text-color, #111827);
}
.btn--secondary:hover {
background: var(--surface-ground, #f3f4f6);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BulkActionModalComponent {
private readonly vexService = inject(VexDecisionService);
// Inputs
readonly vulnerabilities = input.required<Vulnerability[]>();
// Outputs
readonly closed = output<void>();
readonly submitted = output<BulkActionRequest>();
// State
readonly selectedAction = signal<BulkActionType>('mark_not_affected');
readonly vexStatus = signal<VexStatus>('not_affected');
readonly justificationType = signal<VexJustificationType>('vulnerable_code_not_in_execute_path');
readonly justification = signal('');
readonly loading = signal(false);
readonly actionOptions: { value: BulkActionType; label: string; description: string; icon: string }[] = [
{ value: 'mark_not_affected', label: 'Mark Not Affected', description: 'Mark all as not affected (requires justification)', icon: '✓' },
{ value: 'mark_affected', label: 'Mark Affected', description: 'Mark all as affected and unmitigated', icon: '⚠' },
{ value: 'request_analysis', label: 'Request AI Analysis', description: 'Queue all for AI analysis', icon: '🤖' },
{ value: 'defer', label: 'Defer Triage', description: 'Mark for later review', icon: '⏰' },
{ value: 'custom_vex', label: 'Custom VEX Decision', description: 'Apply custom VEX status', icon: '📝' },
];
readonly statusOptions = this.vexService.getStatusOptions();
readonly justificationTypes = this.vexService.getJustificationTypes();
// Computed
readonly criticalCount = computed(() =>
this.vulnerabilities().filter(v => v.severity === 'critical').length
);
readonly highCount = computed(() =>
this.vulnerabilities().filter(v => v.severity === 'high').length
);
readonly affectedPackagesCount = computed(() => {
const packages = new Set<string>();
for (const v of this.vulnerabilities()) {
for (const pkg of v.affectedPackages) {
packages.add(pkg.purl);
}
}
return packages.size;
});
readonly severityBreakdown = computed(() => {
const counts: Record<string, number> = {};
for (const v of this.vulnerabilities()) {
counts[v.severity] = (counts[v.severity] ?? 0) + 1;
}
return ['critical', 'high', 'medium', 'low', 'none']
.filter(s => counts[s])
.map(s => ({ severity: s, count: counts[s] }));
});
readonly showWarning = computed(() => {
return this.criticalCount() > 0 && this.selectedAction() === 'mark_not_affected';
});
readonly warningMessage = computed(() => {
if (this.criticalCount() > 0 && this.selectedAction() === 'mark_not_affected') {
return `You are about to mark ${this.criticalCount()} critical vulnerability(ies) as not affected. Please ensure proper justification.`;
}
return '';
});
readonly canSubmit = computed(() => {
if (this.selectedAction() === 'custom_vex' || this.selectedAction() === 'mark_not_affected') {
return this.justification().trim().length > 0;
}
return true;
});
selectAction(action: BulkActionType): void {
this.selectedAction.set(action);
}
onVexStatusChange(event: Event): void {
this.vexStatus.set((event.target as HTMLSelectElement).value as VexStatus);
}
onJustificationTypeChange(event: Event): void {
this.justificationType.set((event.target as HTMLSelectElement).value as VexJustificationType);
}
onJustificationInput(event: Event): void {
this.justification.set((event.target as HTMLTextAreaElement).value);
}
onOverlayClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
this.onClose();
}
}
onClose(): void {
if (!this.loading()) {
this.closed.emit();
}
}
onSubmit(): void {
const request: BulkActionRequest = {
vulnIds: this.vulnerabilities().map(v => v.id),
action: this.selectedAction(),
};
if (this.selectedAction() === 'custom_vex') {
request.vexStatus = this.vexStatus();
request.justificationType = this.justificationType();
request.justification = this.justification();
} else if (this.selectedAction() === 'mark_not_affected') {
request.vexStatus = 'not_affected';
request.justification = this.justification();
} else if (this.selectedAction() === 'mark_affected') {
request.vexStatus = 'affected_unmitigated';
}
this.submitted.emit(request);
}
}

View File

@@ -0,0 +1,60 @@
// -----------------------------------------------------------------------------
// index.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Export all triage canvas components
// -----------------------------------------------------------------------------
// Main Canvas
export { TriageCanvasComponent } from './triage-canvas/triage-canvas.component';
// List & Filters
export {
TriageListComponent,
type QuickAction,
type FilterChange,
} from './triage-list/triage-list.component';
// AI Integration
export {
AiRecommendationPanelComponent,
type ApplySuggestionEvent,
} from './ai-recommendation-panel/ai-recommendation-panel.component';
// VEX History
export { VexHistoryComponent } from './vex-history/vex-history.component';
// Reachability
export {
ReachabilityContextComponent,
type ReachabilityStatus,
type CallGraphNode,
type CallGraphEdge,
type CallGraphPath,
type ReachabilityData,
} from './reachability-context/reachability-context.component';
// Bulk Actions
export {
BulkActionModalComponent,
type BulkActionRequest,
type BulkActionType,
} from './bulk-action-modal/bulk-action-modal.component';
// Triage Queue
export {
TriageQueueComponent,
type QueueSortMode,
type QueueItem,
type TriageDecision,
} from './triage-queue/triage-queue.component';
// Re-export existing components
export { KeyboardHelpComponent } from './keyboard-help/keyboard-help.component';
export { DecisionDrawerComponent } from './decision-drawer/decision-drawer.component';
export { EvidencePillsComponent } from './evidence-pills/evidence-pills.component';
export { GatedBucketsComponent } from './gated-buckets/gated-buckets.component';
export { GatingExplainerComponent } from './gating-explainer/gating-explainer.component';
export { VexTrustDisplayComponent } from './vex-trust-display/vex-trust-display.component';
export { ReplayCommandComponent } from './replay-command/replay-command.component';
export { VerdictLadderComponent } from './verdict-ladder/verdict-ladder.component';
export { CaseHeaderComponent } from './case-header/case-header.component';

View File

@@ -0,0 +1,784 @@
// -----------------------------------------------------------------------------
// reachability-context.component.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Tasks: TRIAGE-12 — ReachabilityContextComponent: call graph slice from entry to vulnerability
// TRIAGE-13 — Reachability confidence band using existing ConfidenceBadge
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
input,
output,
signal,
computed,
} from '@angular/core';
export type ReachabilityStatus = 'reachable' | 'unreachable' | 'unknown' | 'partial';
export interface CallGraphNode {
id: string;
label: string;
type: 'entry' | 'intermediate' | 'vulnerable';
file?: string;
line?: number;
confidence: number;
}
export interface CallGraphEdge {
from: string;
to: string;
callType: 'direct' | 'indirect' | 'virtual';
}
export interface CallGraphPath {
nodes: CallGraphNode[];
edges: CallGraphEdge[];
confidence: number;
}
export interface ReachabilityData {
status: ReachabilityStatus;
confidence: number;
paths: CallGraphPath[];
entryPoints: string[];
vulnerableFunction?: string;
analysisMethod: 'static' | 'dynamic' | 'hybrid';
analysisTimestamp: string;
}
@Component({
selector: 'app-reachability-context',
standalone: true,
imports: [CommonModule],
template: `
<div class="reachability-context">
<!-- Header -->
<header class="reachability-header">
<div class="reachability-status">
<span class="status-badge status--{{ data()?.status ?? 'unknown' }}">
{{ formatStatus(data()?.status ?? 'unknown') }}
</span>
<span class="confidence-badge">
<span class="confidence-icon">●</span>
{{ (data()?.confidence ?? 0) * 100 | number:'1.0-0' }}% confidence
</span>
</div>
<div class="view-controls">
<button
class="view-btn"
[class.view-btn--active]="viewMode() === 'paths'"
(click)="setViewMode('paths')"
>
Paths
</button>
<button
class="view-btn"
[class.view-btn--active]="viewMode() === 'graph'"
(click)="setViewMode('graph')"
>
Graph
</button>
<button
class="view-btn"
[class.view-btn--active]="viewMode() === 'text'"
(click)="setViewMode('text')"
>
Text
</button>
</div>
</header>
<!-- Confidence Band -->
@if (data()) {
<div class="confidence-band">
<div class="confidence-track">
<div
class="confidence-fill"
[style.width.%]="data()!.confidence * 100"
[class.confidence-fill--high]="data()!.confidence >= 0.8"
[class.confidence-fill--medium]="data()!.confidence >= 0.5 && data()!.confidence < 0.8"
[class.confidence-fill--low]="data()!.confidence < 0.5"
></div>
</div>
<div class="confidence-labels">
<span>Low</span>
<span>Medium</span>
<span>High</span>
</div>
</div>
}
<!-- Content -->
<div class="reachability-content">
@switch (viewMode()) {
@case ('paths') {
@if (data()?.paths.length) {
<div class="paths-view">
@for (path of data()!.paths; track $index; let pathIdx = $index) {
<article class="call-path" [class.call-path--expanded]="expandedPath() === pathIdx">
<button
class="path-header"
(click)="togglePath(pathIdx)"
>
<span class="path-number">Path {{ pathIdx + 1 }}</span>
<span class="path-confidence">
{{ (path.confidence * 100).toFixed(0) }}% confidence
</span>
<span class="path-length">{{ path.nodes.length }} calls</span>
<span class="expand-icon">{{ expandedPath() === pathIdx ? '' : '+' }}</span>
</button>
@if (expandedPath() === pathIdx) {
<div class="path-nodes">
@for (node of path.nodes; track node.id; let nodeIdx = $index) {
<div class="path-node">
<!-- Connector -->
@if (nodeIdx > 0) {
<div class="node-connector">
<div class="connector-line"></div>
<span class="connector-type">
{{ path.edges[nodeIdx - 1]?.callType ?? 'call' }}
</span>
</div>
}
<!-- Node Content -->
<div class="node-content node-type--{{ node.type }}">
<div class="node-header">
<span class="node-type-badge">{{ node.type }}</span>
<span class="node-label">{{ node.label }}</span>
</div>
@if (node.file) {
<div class="node-location">
<code>{{ node.file }}{{ node.line ? ':' + node.line : '' }}</code>
<button
class="location-btn"
(click)="onNavigateToSource(node)"
title="Open in editor"
>
</button>
</div>
}
<div class="node-confidence">
<span class="confidence-bar">
<span
class="confidence-bar-fill"
[style.width.%]="node.confidence * 100"
></span>
</span>
<span>{{ (node.confidence * 100).toFixed(0) }}%</span>
</div>
</div>
</div>
}
</div>
}
</article>
}
</div>
} @else {
<div class="empty-state">
<p>No call paths available</p>
</div>
}
}
@case ('graph') {
<div class="graph-view">
<div class="graph-placeholder">
<svg class="graph-svg" viewBox="0 0 400 200">
<!-- Entry Point -->
<circle cx="50" cy="100" r="20" class="graph-node graph-node--entry" />
<text x="50" y="140" class="graph-label">Entry</text>
<!-- Intermediate Nodes -->
<circle cx="150" cy="60" r="15" class="graph-node graph-node--intermediate" />
<circle cx="150" cy="140" r="15" class="graph-node graph-node--intermediate" />
<circle cx="250" cy="100" r="15" class="graph-node graph-node--intermediate" />
<!-- Vulnerable Node -->
<circle cx="350" cy="100" r="20" class="graph-node graph-node--vulnerable" />
<text x="350" y="140" class="graph-label">Vuln</text>
<!-- Edges -->
<line x1="70" y1="90" x2="135" y2="65" class="graph-edge" />
<line x1="70" y1="110" x2="135" y2="135" class="graph-edge" />
<line x1="165" y1="65" x2="235" y2="95" class="graph-edge" />
<line x1="165" y1="135" x2="235" y2="105" class="graph-edge" />
<line x1="265" y1="100" x2="330" y2="100" class="graph-edge graph-edge--vulnerable" />
</svg>
<p class="graph-hint">Interactive graph visualization</p>
<button class="graph-btn" (click)="openFullGraph()">
Open Full Graph View
</button>
</div>
</div>
}
@case ('text') {
<div class="text-view">
@if (data()?.paths.length) {
<pre class="text-proof">{{ generateTextualProof() }}</pre>
<button class="copy-btn" (click)="copyTextProof()">
Copy Proof
</button>
} @else {
<div class="empty-state">
<p>No textual proof available</p>
</div>
}
</div>
}
}
</div>
<!-- Analysis Info -->
@if (data()) {
<footer class="analysis-info">
<span class="analysis-method">
Analysis: {{ data()!.analysisMethod | titlecase }}
</span>
<span class="analysis-time">
{{ data()!.analysisTimestamp | date:'medium' }}
</span>
</footer>
}
</div>
`,
styles: [`
.reachability-context {
display: flex;
flex-direction: column;
height: 100%;
background: var(--surface-card, #fff);
}
.reachability-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--surface-border, #e5e7eb);
}
.reachability-status {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status--reachable { background: #fecaca; color: #991b1b; }
.status--unreachable { background: #dcfce7; color: #166534; }
.status--unknown { background: #f3f4f6; color: #374151; }
.status--partial { background: #fef3c7; color: #92400e; }
.confidence-badge {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--text-color-secondary, #6b7280);
}
.confidence-icon {
font-size: 0.5rem;
color: var(--primary-color, #3b82f6);
}
.view-controls {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--surface-ground, #f3f4f6);
border-radius: 0.375rem;
}
.view-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 0.25rem;
background: transparent;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-color-secondary, #6b7280);
cursor: pointer;
transition: all 0.15s ease;
}
.view-btn:hover {
color: var(--text-color, #111827);
}
.view-btn--active {
background: var(--surface-card, #fff);
color: var(--primary-color, #3b82f6);
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
/* Confidence Band */
.confidence-band {
padding: 0.75rem 1rem;
background: var(--surface-ground, #f9fafb);
border-bottom: 1px solid var(--surface-border, #e5e7eb);
}
.confidence-track {
height: 8px;
background: var(--surface-border, #e5e7eb);
border-radius: 4px;
overflow: hidden;
}
.confidence-fill {
height: 100%;
transition: width 0.3s ease;
border-radius: 4px;
}
.confidence-fill--high { background: #22c55e; }
.confidence-fill--medium { background: #f59e0b; }
.confidence-fill--low { background: #ef4444; }
.confidence-labels {
display: flex;
justify-content: space-between;
margin-top: 0.375rem;
font-size: 0.625rem;
color: var(--text-color-secondary, #9ca3af);
}
/* Content */
.reachability-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* Paths View */
.paths-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.call-path {
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
}
.call-path--expanded {
border-color: var(--primary-200, #bfdbfe);
}
.path-header {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: none;
background: var(--surface-ground, #f9fafb);
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.path-header:hover {
background: var(--surface-hover, #f3f4f6);
}
.call-path--expanded .path-header {
background: var(--primary-50, #eff6ff);
}
.path-number {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-color, #111827);
}
.path-confidence,
.path-length {
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
}
.expand-icon {
margin-left: auto;
font-size: 1rem;
font-weight: 600;
color: var(--text-color-secondary, #6b7280);
}
.path-nodes {
padding: 1rem;
}
.path-node {
position: relative;
}
.node-connector {
display: flex;
align-items: center;
padding: 0.25rem 0 0.25rem 1.5rem;
}
.connector-line {
position: absolute;
left: 0.75rem;
top: 0;
bottom: 50%;
width: 2px;
background: var(--surface-border, #e5e7eb);
}
.connector-type {
padding: 0.125rem 0.375rem;
background: var(--surface-ground, #f3f4f6);
border-radius: 0.25rem;
font-size: 0.5625rem;
font-weight: 500;
color: var(--text-color-secondary, #6b7280);
text-transform: uppercase;
}
.node-content {
padding: 0.75rem;
margin-left: 1.5rem;
background: var(--surface-ground, #f9fafb);
border-radius: 0.375rem;
border-left: 3px solid var(--surface-border, #e5e7eb);
}
.node-type--entry {
border-left-color: #22c55e;
}
.node-type--vulnerable {
border-left-color: #ef4444;
}
.node-type--intermediate {
border-left-color: #3b82f6;
}
.node-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.375rem;
}
.node-type-badge {
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.5625rem;
font-weight: 600;
text-transform: uppercase;
background: var(--surface-border, #e5e7eb);
color: var(--text-color-secondary, #6b7280);
}
.node-type--entry .node-type-badge {
background: #dcfce7;
color: #166534;
}
.node-type--vulnerable .node-type-badge {
background: #fecaca;
color: #991b1b;
}
.node-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-color, #111827);
}
.node-location {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.375rem;
}
.node-location code {
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
}
.location-btn {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--surface-card, #fff);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.location-btn:hover {
border-color: var(--primary-color, #3b82f6);
color: var(--primary-color, #3b82f6);
}
.node-confidence {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.6875rem;
color: var(--text-color-secondary, #6b7280);
}
.confidence-bar {
width: 40px;
height: 4px;
background: var(--surface-border, #e5e7eb);
border-radius: 2px;
overflow: hidden;
}
.confidence-bar-fill {
height: 100%;
background: var(--primary-color, #3b82f6);
}
/* Graph View */
.graph-view {
height: 100%;
display: flex;
flex-direction: column;
}
.graph-placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.graph-svg {
width: 100%;
max-width: 400px;
height: auto;
margin-bottom: 1rem;
}
.graph-node {
fill: var(--surface-ground, #f3f4f6);
stroke: var(--surface-border, #e5e7eb);
stroke-width: 2;
}
.graph-node--entry {
fill: #dcfce7;
stroke: #22c55e;
}
.graph-node--vulnerable {
fill: #fecaca;
stroke: #ef4444;
}
.graph-node--intermediate {
fill: #dbeafe;
stroke: #3b82f6;
}
.graph-edge {
stroke: var(--surface-border, #e5e7eb);
stroke-width: 2;
}
.graph-edge--vulnerable {
stroke: #ef4444;
stroke-dasharray: 4;
}
.graph-label {
font-size: 10px;
text-anchor: middle;
fill: var(--text-color-secondary, #6b7280);
}
.graph-hint {
margin: 0 0 1rem;
font-size: 0.8125rem;
color: var(--text-color-secondary, #6b7280);
}
.graph-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--primary-color, #3b82f6);
border-radius: 0.375rem;
background: transparent;
color: var(--primary-color, #3b82f6);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.graph-btn:hover {
background: var(--primary-50, #eff6ff);
}
/* Text View */
.text-view {
height: 100%;
display: flex;
flex-direction: column;
}
.text-proof {
flex: 1;
margin: 0;
padding: 1rem;
background: var(--surface-ground, #f9fafb);
border-radius: 0.375rem;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
line-height: 1.6;
overflow-x: auto;
white-space: pre-wrap;
}
.copy-btn {
margin-top: 0.75rem;
padding: 0.5rem 1rem;
border: 1px solid var(--primary-color, #3b82f6);
border-radius: 0.375rem;
background: transparent;
color: var(--primary-color, #3b82f6);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
align-self: flex-start;
}
.copy-btn:hover {
background: var(--primary-50, #eff6ff);
}
/* Empty State */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 2rem;
text-align: center;
color: var(--text-color-secondary, #6b7280);
}
/* Analysis Info */
.analysis-info {
display: flex;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--surface-ground, #f9fafb);
border-top: 1px solid var(--surface-border, #e5e7eb);
font-size: 0.6875rem;
color: var(--text-color-secondary, #6b7280);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReachabilityContextComponent {
// Inputs
readonly data = input<ReachabilityData | null>(null);
// Outputs
readonly navigateToSource = output<CallGraphNode>();
readonly openGraphView = output<void>();
// State
readonly viewMode = signal<'paths' | 'graph' | 'text'>('paths');
readonly expandedPath = signal<number | null>(0);
setViewMode(mode: 'paths' | 'graph' | 'text'): void {
this.viewMode.set(mode);
}
togglePath(index: number): void {
this.expandedPath.set(this.expandedPath() === index ? null : index);
}
formatStatus(status: ReachabilityStatus): string {
const map: Record<ReachabilityStatus, string> = {
'reachable': 'Reachable',
'unreachable': 'Not Reachable',
'unknown': 'Unknown',
'partial': 'Partially Reachable',
};
return map[status] ?? status;
}
onNavigateToSource(node: CallGraphNode): void {
this.navigateToSource.emit(node);
}
openFullGraph(): void {
this.openGraphView.emit();
}
generateTextualProof(): string {
const data = this.data();
if (!data?.paths.length) return 'No reachability data available';
const lines: string[] = [
`Reachability Analysis Report`,
`===========================`,
`Status: ${this.formatStatus(data.status)}`,
`Confidence: ${(data.confidence * 100).toFixed(1)}%`,
`Analysis Method: ${data.analysisMethod}`,
`Timestamp: ${data.analysisTimestamp}`,
``,
`Entry Points:`,
...data.entryPoints.map(ep => ` - ${ep}`),
``,
`Vulnerable Function: ${data.vulnerableFunction ?? 'Unknown'}`,
``,
`Call Paths (${data.paths.length} found):`,
];
data.paths.forEach((path, idx) => {
lines.push(``, `Path ${idx + 1} (${(path.confidence * 100).toFixed(0)}% confidence):`);
path.nodes.forEach((node, nodeIdx) => {
const indent = ' '.repeat(nodeIdx + 1);
const arrow = nodeIdx > 0 ? '→ ' : '';
lines.push(`${indent}${arrow}${node.label}`);
if (node.file) {
lines.push(`${indent} at ${node.file}${node.line ? ':' + node.line : ''}`);
}
});
});
return lines.join('\n');
}
copyTextProof(): void {
const proof = this.generateTextualProof();
navigator.clipboard.writeText(proof);
}
}

View File

@@ -0,0 +1,801 @@
// -----------------------------------------------------------------------------
// triage-queue.component.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Tasks: TRIAGE-29 — TriageQueueComponent: prioritized queue for triage workflow
// TRIAGE-30 — Auto-advance to next item after triage decision
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
OnChanges,
SimpleChanges,
} from '@angular/core';
import type { Vulnerability } from '../../services/vulnerability-list.service';
export type QueueSortMode = 'priority' | 'severity' | 'age' | 'epss';
export interface QueueItem {
vulnerability: Vulnerability;
priority: number;
reason: string;
timeInQueue: number; // seconds
}
export interface TriageDecision {
vulnId: string;
action: 'triaged' | 'deferred' | 'skipped';
}
@Component({
selector: 'app-triage-queue',
standalone: true,
imports: [CommonModule],
template: `
<div class="triage-queue">
<!-- Queue Header -->
<header class="queue-header">
<div class="queue-title">
<h3>Triage Queue</h3>
<span class="queue-count">{{ queue().length }} remaining</span>
</div>
<div class="queue-controls">
<select
class="sort-select"
[value]="sortMode()"
(change)="onSortChange($event)"
>
<option value="priority">Priority (KEV × Severity × Reachability)</option>
<option value="severity">Severity</option>
<option value="age">Age (Oldest First)</option>
<option value="epss">EPSS Score</option>
</select>
<label class="auto-advance-toggle">
<input
type="checkbox"
[checked]="autoAdvance()"
(change)="toggleAutoAdvance()"
/>
Auto-advance
</label>
</div>
</header>
<!-- Progress Bar -->
<div class="queue-progress">
<div class="progress-track">
<div
class="progress-fill"
[style.width.%]="progressPercent()"
></div>
</div>
<span class="progress-text">
{{ completedCount() }} / {{ totalCount() }} triaged
</span>
</div>
<!-- Current Item -->
@if (currentItem()) {
<div class="current-item">
<div class="current-badge">Now Triaging</div>
<article class="vuln-card vuln-card--current">
<div class="vuln-header">
<span class="vuln-severity severity--{{ currentItem()!.vulnerability.severity }}">
{{ currentItem()!.vulnerability.severity | uppercase }}
</span>
<span class="vuln-cve">{{ currentItem()!.vulnerability.cveId }}</span>
@if (currentItem()!.vulnerability.isKev) {
<span class="vuln-badge badge--kev">KEV</span>
}
@if (currentItem()!.vulnerability.hasExploit) {
<span class="vuln-badge badge--exploit">EXP</span>
}
</div>
<h4 class="vuln-title">{{ currentItem()!.vulnerability.title }}</h4>
<div class="vuln-meta">
<span>CVSS {{ currentItem()!.vulnerability.cvssScore }}</span>
@if (currentItem()!.vulnerability.epssScore) {
<span>EPSS {{ (currentItem()!.vulnerability.epssScore * 100).toFixed(1) }}%</span>
}
@if (currentItem()!.vulnerability.reachabilityStatus) {
<span class="reachability--{{ currentItem()!.vulnerability.reachabilityStatus }}">
{{ currentItem()!.vulnerability.reachabilityStatus }}
</span>
}
</div>
<div class="priority-reason">
<strong>Priority:</strong> {{ currentItem()!.reason }}
</div>
<div class="queue-actions">
<button
class="action-btn action-btn--primary"
(click)="onTriageAction('triaged')"
>
✓ Triaged
</button>
<button
class="action-btn action-btn--secondary"
(click)="onTriageAction('deferred')"
>
⏰ Defer
</button>
<button
class="action-btn action-btn--ghost"
(click)="onTriageAction('skipped')"
>
⏭ Skip
</button>
</div>
</article>
<!-- Keyboard Hint -->
<div class="keyboard-hint">
<span>Press <kbd>T</kbd> triaged</span>
<span><kbd>D</kbd> defer</span>
<span><kbd>S</kbd> skip</span>
</div>
</div>
} @else {
<div class="queue-complete">
<span class="complete-icon">🎉</span>
<h4>Queue Complete!</h4>
<p>All vulnerabilities have been triaged.</p>
</div>
}
<!-- Upcoming Items -->
@if (upcomingItems().length > 0) {
<div class="upcoming-section">
<h4 class="upcoming-title">Up Next</h4>
<div class="upcoming-list">
@for (item of upcomingItems(); track item.vulnerability.id; let idx = $index) {
<div
class="upcoming-item"
[class.upcoming-item--clickable]="true"
(click)="onJumpToItem(item)"
>
<span class="upcoming-position">{{ idx + 2 }}</span>
<span class="upcoming-severity severity--{{ item.vulnerability.severity }}">
{{ item.vulnerability.severity | slice:0:1 | uppercase }}
</span>
<span class="upcoming-cve">{{ item.vulnerability.cveId }}</span>
<span class="upcoming-reason">{{ item.reason }}</span>
</div>
}
</div>
</div>
}
<!-- Recently Triaged -->
@if (recentlyTriaged().length > 0) {
<div class="recent-section">
<h4 class="recent-title">Recently Triaged</h4>
<div class="recent-list">
@for (item of recentlyTriaged(); track item.vulnId) {
<div class="recent-item">
<span class="recent-action action--{{ item.action }}">
{{ formatAction(item.action) }}
</span>
<span class="recent-cve">{{ item.vulnId }}</span>
<button class="undo-btn" (click)="onUndo(item)">Undo</button>
</div>
}
</div>
</div>
}
<!-- Session Stats -->
<footer class="session-stats">
<div class="stat">
<span class="stat-value">{{ sessionStats().triaged }}</span>
<span class="stat-label">Triaged</span>
</div>
<div class="stat">
<span class="stat-value">{{ sessionStats().deferred }}</span>
<span class="stat-label">Deferred</span>
</div>
<div class="stat">
<span class="stat-value">{{ sessionStats().skipped }}</span>
<span class="stat-label">Skipped</span>
</div>
<div class="stat">
<span class="stat-value">{{ formatDuration(sessionStats().totalTimeSeconds) }}</span>
<span class="stat-label">Session Time</span>
</div>
</footer>
</div>
`,
styles: [`
.triage-queue {
display: flex;
flex-direction: column;
height: 100%;
background: var(--surface-card, #fff);
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--surface-border, #e5e7eb);
}
.queue-title {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.queue-title h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.queue-count {
font-size: 0.8125rem;
color: var(--text-color-secondary, #6b7280);
}
.queue-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.sort-select {
padding: 0.375rem 0.75rem;
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.375rem;
font-size: 0.75rem;
outline: none;
}
.auto-advance-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
cursor: pointer;
}
/* Progress */
.queue-progress {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--surface-ground, #f9fafb);
}
.progress-track {
flex: 1;
height: 8px;
background: var(--surface-border, #e5e7eb);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary-color, #3b82f6);
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
white-space: nowrap;
}
/* Current Item */
.current-item {
padding: 1rem;
border-bottom: 1px solid var(--surface-border, #e5e7eb);
}
.current-badge {
display: inline-block;
margin-bottom: 0.75rem;
padding: 0.25rem 0.75rem;
background: var(--primary-color, #3b82f6);
color: #fff;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.vuln-card--current {
padding: 1rem;
background: var(--primary-50, #eff6ff);
border: 1px solid var(--primary-200, #bfdbfe);
border-radius: 0.5rem;
}
.vuln-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.vuln-severity {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
}
.severity--critical { background: #fecaca; color: #991b1b; }
.severity--high { background: #fed7aa; color: #9a3412; }
.severity--medium { background: #fef08a; color: #854d0e; }
.severity--low { background: #bbf7d0; color: #166534; }
.severity--none { background: #e5e7eb; color: #374151; }
.vuln-cve {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-color, #111827);
}
.vuln-badge {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.5625rem;
font-weight: 600;
}
.badge--kev { background: #fecaca; color: #991b1b; }
.badge--exploit { background: #fed7aa; color: #9a3412; }
.vuln-title {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 400;
color: var(--text-color, #111827);
}
.vuln-meta {
display: flex;
gap: 0.75rem;
margin-bottom: 0.75rem;
font-size: 0.75rem;
color: var(--text-color-secondary, #6b7280);
}
.reachability--reachable { color: #dc2626; font-weight: 500; }
.reachability--unreachable { color: #16a34a; }
.reachability--unknown { color: #6b7280; }
.priority-reason {
margin-bottom: 1rem;
padding: 0.5rem;
background: var(--surface-card, #fff);
border-radius: 0.25rem;
font-size: 0.75rem;
color: var(--text-color-secondary, #4b5563);
}
.queue-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
flex: 1;
padding: 0.625rem;
border: none;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn--primary {
background: var(--primary-color, #3b82f6);
color: #fff;
}
.action-btn--primary:hover {
background: var(--primary-600, #2563eb);
}
.action-btn--secondary {
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e5e7eb);
color: var(--text-color, #111827);
}
.action-btn--secondary:hover {
background: var(--surface-ground, #f3f4f6);
}
.action-btn--ghost {
background: transparent;
color: var(--text-color-secondary, #6b7280);
}
.action-btn--ghost:hover {
background: var(--surface-ground, #f3f4f6);
color: var(--text-color, #111827);
}
.keyboard-hint {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 0.75rem;
font-size: 0.6875rem;
color: var(--text-color-secondary, #9ca3af);
}
.keyboard-hint kbd {
padding: 0.125rem 0.375rem;
background: var(--surface-ground, #f3f4f6);
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.25rem;
font-family: inherit;
font-size: 0.625rem;
}
/* Queue Complete */
.queue-complete {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
}
.complete-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.queue-complete h4 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: var(--text-color, #111827);
}
.queue-complete p {
margin: 0;
color: var(--text-color-secondary, #6b7280);
}
/* Upcoming */
.upcoming-section,
.recent-section {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border, #e5e7eb);
}
.upcoming-title,
.recent-title {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary, #6b7280);
text-transform: uppercase;
}
.upcoming-list,
.recent-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.upcoming-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem;
border-radius: 0.25rem;
}
.upcoming-item--clickable:hover {
background: var(--surface-ground, #f3f4f6);
cursor: pointer;
}
.upcoming-position {
width: 20px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary, #9ca3af);
}
.upcoming-severity {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
font-size: 0.5rem;
font-weight: 700;
}
.upcoming-cve {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-color, #111827);
}
.upcoming-reason {
flex: 1;
font-size: 0.6875rem;
color: var(--text-color-secondary, #9ca3af);
text-align: right;
}
/* Recent */
.recent-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem;
}
.recent-action {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.5625rem;
font-weight: 600;
text-transform: uppercase;
}
.action--triaged { background: #dcfce7; color: #166534; }
.action--deferred { background: #fef3c7; color: #92400e; }
.action--skipped { background: #f3f4f6; color: #6b7280; }
.recent-cve {
flex: 1;
font-size: 0.75rem;
color: var(--text-color, #111827);
}
.undo-btn {
padding: 0.125rem 0.375rem;
border: none;
border-radius: 0.25rem;
background: transparent;
color: var(--primary-color, #3b82f6);
font-size: 0.6875rem;
cursor: pointer;
}
.undo-btn:hover {
background: var(--primary-50, #eff6ff);
}
/* Session Stats */
.session-stats {
display: flex;
justify-content: space-around;
padding: 0.75rem;
background: var(--surface-ground, #f9fafb);
border-top: 1px solid var(--surface-border, #e5e7eb);
margin-top: auto;
}
.stat {
text-align: center;
}
.stat-value {
display: block;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-color, #111827);
}
.stat-label {
font-size: 0.625rem;
color: var(--text-color-secondary, #6b7280);
text-transform: uppercase;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TriageQueueComponent implements OnChanges {
// Inputs
readonly vulnerabilities = input.required<Vulnerability[]>();
// Outputs
readonly decisionMade = output<TriageDecision>();
readonly itemSelected = output<Vulnerability>();
readonly undoRequested = output<TriageDecision>();
// State
readonly sortMode = signal<QueueSortMode>('priority');
readonly autoAdvance = signal(true);
readonly completedItems = signal<TriageDecision[]>([]);
readonly currentIndex = signal(0);
readonly queue = computed<QueueItem[]>(() => {
const vulns = this.vulnerabilities();
const completed = new Set(this.completedItems().map(c => c.vulnId));
// Filter out completed
const remaining = vulns.filter(v => !completed.has(v.id));
// Calculate priority and sort
const items: QueueItem[] = remaining.map(v => ({
vulnerability: v,
priority: this.calculatePriority(v),
reason: this.getPriorityReason(v),
timeInQueue: 0,
}));
return this.sortQueue(items, this.sortMode());
});
readonly currentItem = computed(() => {
const q = this.queue();
return q.length > 0 ? q[0] : null;
});
readonly upcomingItems = computed(() => {
return this.queue().slice(1, 4);
});
readonly recentlyTriaged = computed(() => {
return this.completedItems().slice(-3).reverse();
});
readonly totalCount = computed(() => this.vulnerabilities().length);
readonly completedCount = computed(() => this.completedItems().length);
readonly progressPercent = computed(() => {
const total = this.totalCount();
if (total === 0) return 0;
return (this.completedCount() / total) * 100;
});
readonly sessionStats = computed(() => {
const items = this.completedItems();
return {
triaged: items.filter(i => i.action === 'triaged').length,
deferred: items.filter(i => i.action === 'deferred').length,
skipped: items.filter(i => i.action === 'skipped').length,
totalTimeSeconds: 0, // Would track actual session time
};
});
ngOnChanges(changes: SimpleChanges): void {
if (changes['vulnerabilities']) {
// Reset when vulnerabilities change
this.completedItems.set([]);
this.currentIndex.set(0);
}
}
onSortChange(event: Event): void {
this.sortMode.set((event.target as HTMLSelectElement).value as QueueSortMode);
}
toggleAutoAdvance(): void {
this.autoAdvance.update(v => !v);
}
onTriageAction(action: TriageDecision['action']): void {
const current = this.currentItem();
if (!current) return;
const decision: TriageDecision = {
vulnId: current.vulnerability.id,
action,
};
this.completedItems.update(items => [...items, decision]);
this.decisionMade.emit(decision);
// Auto-advance happens automatically since queue is computed
}
onJumpToItem(item: QueueItem): void {
this.itemSelected.emit(item.vulnerability);
}
onUndo(decision: TriageDecision): void {
this.completedItems.update(items =>
items.filter(i => i.vulnId !== decision.vulnId)
);
this.undoRequested.emit(decision);
}
formatAction(action: string): string {
const map: Record<string, string> = {
'triaged': '✓',
'deferred': '⏰',
'skipped': '⏭',
};
return map[action] ?? action;
}
formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
private calculatePriority(v: Vulnerability): number {
let score = 0;
// KEV multiplier (highest priority)
if (v.isKev) score += 1000;
// Severity
const severityScores: Record<string, number> = {
critical: 400,
high: 300,
medium: 200,
low: 100,
none: 0,
};
score += severityScores[v.severity] ?? 0;
// Reachability
if (v.reachabilityStatus === 'reachable') score += 200;
else if (v.reachabilityStatus === 'partial') score += 100;
// Exploit available
if (v.hasExploit) score += 150;
// EPSS score
if (v.epssScore) score += v.epssScore * 100;
return score;
}
private getPriorityReason(v: Vulnerability): string {
const reasons: string[] = [];
if (v.isKev) reasons.push('KEV');
if (v.severity === 'critical') reasons.push('Critical');
else if (v.severity === 'high') reasons.push('High severity');
if (v.reachabilityStatus === 'reachable') reasons.push('Reachable');
if (v.hasExploit) reasons.push('Exploit available');
return reasons.join(' • ') || 'Standard priority';
}
private sortQueue(items: QueueItem[], mode: QueueSortMode): QueueItem[] {
return [...items].sort((a, b) => {
switch (mode) {
case 'priority':
return b.priority - a.priority;
case 'severity':
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3, none: 4 };
return (sevOrder[a.vulnerability.severity] ?? 5) - (sevOrder[b.vulnerability.severity] ?? 5);
case 'age':
return new Date(a.vulnerability.publishedAt).getTime() - new Date(b.vulnerability.publishedAt).getTime();
case 'epss':
return (b.vulnerability.epssScore ?? 0) - (a.vulnerability.epssScore ?? 0);
default:
return b.priority - a.priority;
}
});
}
}

View File

@@ -0,0 +1,812 @@
// -----------------------------------------------------------------------------
// vex-history.component.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Tasks: TRIAGE-25 — VexHistoryComponent: timeline of VEX decisions for current vuln
// TRIAGE-26 — "Supersedes" relationship visualization in history
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
OnChanges,
SimpleChanges,
} from '@angular/core';
import { Subscription } from 'rxjs';
import {
VexDecisionService,
type VexDecision,
type VexHistoryEntry,
type VexStatus,
} from '../../services/vex-decision.service';
interface TimelineNode {
decision: VexDecision;
isActive: boolean;
supersededBy?: VexDecision;
supersedes?: VexDecision;
depth: number;
}
@Component({
selector: 'app-vex-history',
standalone: true,
imports: [CommonModule],
template: `
<div class="vex-history">
<!-- Header -->
<header class="vex-history__header">
<h4 class="vex-history__title">VEX Decision History</h4>
<button class="create-btn" (click)="onCreateNew()">
+ New Decision
</button>
</header>
<!-- Loading -->
@if (loading()) {
<div class="vex-history__loading">
<div class="spinner"></div>
<p>Loading history...</p>
</div>
}
<!-- Timeline -->
@if (!loading() && timeline().length > 0) {
<div class="timeline">
@for (node of timeline(); track node.decision.id; let idx = $index) {
<article
class="timeline-node"
[class.timeline-node--active]="node.isActive"
[class.timeline-node--superseded]="!node.isActive"
[style.--depth]="node.depth"
>
<!-- Connector Line -->
@if (idx > 0) {
<div class="timeline-connector">
@if (node.supersedes) {
<span class="connector-label">supersedes</span>
}
</div>
}
<!-- Node Marker -->
<div class="timeline-marker">
<div class="marker-dot" [class.marker-dot--active]="node.isActive"></div>
</div>
<!-- Node Content -->
<div class="timeline-content">
<!-- Status Badge -->
<div class="node-header">
<span class="status-badge status--{{ node.decision.status }}">
{{ formatStatus(node.decision.status) }}
</span>
@if (node.isActive) {
<span class="active-badge">Current</span>
}
@if (node.decision.signedAsAttestation) {
<span class="signed-badge" title="Signed as attestation">🔐</span>
}
</div>
<!-- Justification -->
@if (node.decision.justificationType) {
<div class="justification-type">
{{ formatJustificationType(node.decision.justificationType) }}
</div>
}
<p class="justification-text">{{ node.decision.justification }}</p>
<!-- Evidence References -->
@if (node.decision.evidenceRefs.length > 0) {
<div class="evidence-refs">
@for (ref of node.decision.evidenceRefs; track ref.url) {
<a
class="evidence-link"
[href]="ref.url"
target="_blank"
rel="noopener noreferrer"
>
<span class="evidence-type">{{ ref.type }}</span>
{{ ref.title || ref.url }}
</a>
}
</div>
}
<!-- Scope -->
@if (hasScope(node.decision)) {
<div class="scope-info">
<span class="scope-label">Scope:</span>
@if (node.decision.scope.projectIds?.length) {
<span class="scope-item">
{{ node.decision.scope.projectIds.length }} projects
</span>
}
@if (node.decision.scope.environmentIds?.length) {
<span class="scope-item">
{{ node.decision.scope.environmentIds.length }} environments
</span>
}
@if (node.decision.scope.packagePurls?.length) {
<span class="scope-item">
{{ node.decision.scope.packagePurls.length }} packages
</span>
}
</div>
}
<!-- Validity Window -->
@if (node.decision.validityWindow) {
<div class="validity-info">
@if (node.decision.validityWindow.notBefore) {
<span>From: {{ node.decision.validityWindow.notBefore | date:'short' }}</span>
}
@if (node.decision.validityWindow.notAfter) {
<span>Until: {{ node.decision.validityWindow.notAfter | date:'short' }}</span>
}
</div>
}
<!-- Metadata -->
<div class="node-meta">
<span class="meta-author">{{ node.decision.createdBy }}</span>
<span class="meta-separator">•</span>
<time class="meta-time">{{ node.decision.createdAt | date:'medium' }}</time>
</div>
<!-- Supersedes Indicator -->
@if (node.supersedes) {
<div class="supersedes-info">
<span class="supersedes-icon">↩</span>
Supersedes decision from {{ node.supersedes.createdAt | date:'short' }}
</div>
}
<!-- Superseded By Indicator -->
@if (node.supersededBy) {
<div class="superseded-by-info">
<span class="superseded-icon">↪</span>
Superseded by decision from {{ node.supersededBy.createdAt | date:'short' }}
</div>
}
<!-- Actions -->
@if (node.isActive) {
<div class="node-actions">
<button
class="action-btn"
(click)="onSupersede(node.decision)"
>
Supersede
</button>
<button
class="action-btn action-btn--secondary"
(click)="onViewDetails(node.decision)"
>
Details
</button>
</div>
}
</div>
</article>
}
</div>
<!-- Legend -->
<div class="timeline-legend">
<div class="legend-item">
<div class="legend-marker legend-marker--active"></div>
<span>Active Decision</span>
</div>
<div class="legend-item">
<div class="legend-marker legend-marker--superseded"></div>
<span>Superseded</span>
</div>
<div class="legend-item">
<span class="legend-icon">🔐</span>
<span>Signed Attestation</span>
</div>
</div>
}
<!-- Empty State -->
@if (!loading() && timeline().length === 0) {
<div class="vex-history__empty">
<span class="empty-icon">📋</span>
<p>No VEX decisions yet</p>
<p class="empty-hint">Create a decision to document the vulnerability's status</p>
<button class="create-btn create-btn--large" (click)="onCreateNew()">
Create First Decision
</button>
</div>
}
<!-- Error State -->
@if (error()) {
<div class="vex-history__error">
<span class="error-icon">⚠️</span>
<p>{{ error() }}</p>
<button class="retry-btn" (click)="reload()">Retry</button>
</div>
}
</div>
`,
styles: [`
.vex-history {
display: flex;
flex-direction: column;
height: 100%;
background: var(--surface-card, #fff);
}
.vex-history__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--surface-border, #e5e7eb);
}
.vex-history__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
}
.create-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 0.375rem;
background: var(--primary-color, #3b82f6);
color: #fff;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
}
.create-btn:hover {
background: var(--primary-600, #2563eb);
}
.create-btn--large {
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
}
/* Loading */
.vex-history__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--surface-border, #e5e7eb);
border-top-color: var(--primary-color, #3b82f6);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Timeline */
.timeline {
flex: 1;
overflow-y: auto;
padding: 1rem 1rem 1rem 2rem;
}
.timeline-node {
position: relative;
padding-left: 1.5rem;
padding-bottom: 1.5rem;
margin-left: calc(var(--depth, 0) * 1rem);
}
.timeline-node:last-child {
padding-bottom: 0;
}
.timeline-connector {
position: absolute;
left: 7px;
top: -1.5rem;
bottom: calc(100% - 1rem);
width: 2px;
background: var(--surface-border, #e5e7eb);
}
.connector-label {
position: absolute;
left: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.125rem 0.375rem;
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.25rem;
font-size: 0.5625rem;
color: var(--text-color-secondary, #6b7280);
white-space: nowrap;
}
.timeline-marker {
position: absolute;
left: 0;
top: 0.375rem;
}
.marker-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--surface-border, #e5e7eb);
border: 2px solid var(--surface-card, #fff);
box-shadow: 0 0 0 2px var(--surface-border, #e5e7eb);
}
.marker-dot--active {
background: var(--primary-color, #3b82f6);
box-shadow: 0 0 0 2px var(--primary-200, #bfdbfe);
}
.timeline-node--superseded {
opacity: 0.7;
}
.timeline-content {
padding: 0.75rem;
background: var(--surface-ground, #f9fafb);
border-radius: 0.5rem;
border: 1px solid var(--surface-border, #e5e7eb);
}
.timeline-node--active .timeline-content {
background: var(--primary-50, #eff6ff);
border-color: var(--primary-200, #bfdbfe);
}
.node-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.status-badge {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.status--not_affected { background: #dcfce7; color: #166534; }
.status--affected_mitigated { background: #dbeafe; color: #1d4ed8; }
.status--affected_unmitigated { background: #fecaca; color: #991b1b; }
.status--fixed { background: #dcfce7; color: #166534; }
.status--under_investigation { background: #fef3c7; color: #92400e; }
.active-badge {
padding: 0.125rem 0.375rem;
background: var(--primary-color, #3b82f6);
color: #fff;
border-radius: 0.25rem;
font-size: 0.5625rem;
font-weight: 600;
text-transform: uppercase;
}
.signed-badge {
font-size: 0.875rem;
}
.justification-type {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-color-secondary, #4b5563);
margin-bottom: 0.25rem;
}
.justification-text {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
color: var(--text-color, #111827);
line-height: 1.5;
}
.evidence-refs {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.75rem;
}
.evidence-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 0.25rem;
font-size: 0.6875rem;
color: var(--primary-color, #3b82f6);
text-decoration: none;
transition: all 0.15s ease;
}
.evidence-link:hover {
border-color: var(--primary-color, #3b82f6);
background: var(--primary-50, #eff6ff);
}
.evidence-type {
padding: 0.0625rem 0.25rem;
background: var(--surface-ground, #f3f4f6);
border-radius: 0.125rem;
font-size: 0.5625rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-color-secondary, #6b7280);
}
.scope-info,
.validity-info {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.6875rem;
color: var(--text-color-secondary, #6b7280);
}
.scope-label {
font-weight: 500;
}
.scope-item {
padding: 0.125rem 0.375rem;
background: var(--surface-card, #fff);
border-radius: 0.25rem;
}
.node-meta {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
color: var(--text-color-secondary, #6b7280);
}
.meta-separator {
color: var(--surface-border, #e5e7eb);
}
.supersedes-info,
.superseded-by-info {
margin-top: 0.5rem;
padding: 0.375rem 0.5rem;
background: var(--surface-card, #fff);
border-radius: 0.25rem;
font-size: 0.6875rem;
color: var(--text-color-secondary, #6b7280);
}
.supersedes-icon,
.superseded-icon {
margin-right: 0.25rem;
}
.node-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--surface-border, #e5e7eb);
}
.action-btn {
padding: 0.25rem 0.625rem;
border: 1px solid var(--primary-color, #3b82f6);
border-radius: 0.25rem;
background: transparent;
color: var(--primary-color, #3b82f6);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn:hover {
background: var(--primary-50, #eff6ff);
}
.action-btn--secondary {
border-color: var(--surface-border, #e5e7eb);
color: var(--text-color-secondary, #6b7280);
}
.action-btn--secondary:hover {
background: var(--surface-ground, #f9fafb);
border-color: var(--text-color-secondary, #6b7280);
}
/* Legend */
.timeline-legend {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: 0.75rem;
border-top: 1px solid var(--surface-border, #e5e7eb);
background: var(--surface-ground, #f9fafb);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
color: var(--text-color-secondary, #6b7280);
}
.legend-marker {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid var(--surface-card, #fff);
}
.legend-marker--active {
background: var(--primary-color, #3b82f6);
box-shadow: 0 0 0 2px var(--primary-200, #bfdbfe);
}
.legend-marker--superseded {
background: var(--surface-border, #e5e7eb);
box-shadow: 0 0 0 2px var(--surface-border, #e5e7eb);
}
.legend-icon {
font-size: 0.875rem;
}
/* Empty State */
.vex-history__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
}
.empty-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
opacity: 0.5;
}
.vex-history__empty p {
margin: 0;
color: var(--text-color-secondary, #6b7280);
}
.empty-hint {
margin-top: 0.375rem !important;
margin-bottom: 1rem !important;
font-size: 0.75rem;
}
/* Error State */
.vex-history__error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
}
.error-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.vex-history__error p {
margin: 0 0 1rem;
color: var(--red-600, #dc2626);
}
.retry-btn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--primary-color, #3b82f6);
border-radius: 0.375rem;
background: transparent;
color: var(--primary-color, #3b82f6);
font-size: 0.8125rem;
cursor: pointer;
}
.retry-btn:hover {
background: var(--primary-50, #eff6ff);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VexHistoryComponent implements OnChanges {
private readonly vexService = inject(VexDecisionService);
private subscriptions: Subscription[] = [];
// Inputs
readonly vulnId = input.required<string>();
// Outputs
readonly createRequested = output<void>();
readonly supersedeRequested = output<VexDecision>();
readonly detailsRequested = output<VexDecision>();
// State
readonly decisions = signal<VexDecision[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly timeline = computed<TimelineNode[]>(() => {
const decisions = this.decisions();
if (decisions.length === 0) return [];
// Build lookup maps
const byId = new Map(decisions.map(d => [d.id, d]));
const supersededByMap = new Map<string, VexDecision>();
for (const d of decisions) {
if (d.supersedes) {
supersededByMap.set(d.supersedes, d);
}
}
// Build timeline nodes
const nodes: TimelineNode[] = [];
const processed = new Set<string>();
// Sort by date descending
const sorted = [...decisions].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
for (const decision of sorted) {
if (processed.has(decision.id)) continue;
processed.add(decision.id);
const supersededBy = supersededByMap.get(decision.id);
const supersedes = decision.supersedes ? byId.get(decision.supersedes) : undefined;
const isActive = !supersededBy && this.isWithinValidityWindow(decision);
nodes.push({
decision,
isActive,
supersededBy,
supersedes,
depth: this.calculateDepth(decision, byId),
});
}
return nodes;
});
ngOnChanges(changes: SimpleChanges): void {
if (changes['vulnId'] && this.vulnId()) {
this.reload();
}
}
reload(): void {
const vulnId = this.vulnId();
if (!vulnId) return;
this.loading.set(true);
this.error.set(null);
this.subscriptions.push(
this.vexService.getDecisionsForVuln(vulnId).subscribe({
next: (decisions) => {
this.decisions.set(decisions);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load VEX history');
this.loading.set(false);
},
})
);
}
onCreateNew(): void {
this.createRequested.emit();
}
onSupersede(decision: VexDecision): void {
this.supersedeRequested.emit(decision);
}
onViewDetails(decision: VexDecision): void {
this.detailsRequested.emit(decision);
}
formatStatus(status: VexStatus): string {
const map: Record<VexStatus, string> = {
'not_affected': 'Not Affected',
'affected_mitigated': 'Mitigated',
'affected_unmitigated': 'Affected',
'fixed': 'Fixed',
'under_investigation': 'Investigating',
};
return map[status] ?? status;
}
formatJustificationType(type: string): string {
const map: Record<string, string> = {
'component_not_present': 'Component Not Present',
'vulnerable_code_not_present': 'Vulnerable Code Not Present',
'vulnerable_code_not_in_execute_path': 'Vulnerable Code Not Reachable',
'vulnerable_code_cannot_be_controlled_by_adversary': 'Cannot Be Exploited',
'inline_mitigations_already_exist': 'Mitigations Exist',
};
return map[type] ?? type;
}
hasScope(decision: VexDecision): boolean {
const scope = decision.scope;
return !!(
scope.projectIds?.length ||
scope.environmentIds?.length ||
scope.packagePurls?.length
);
}
private isWithinValidityWindow(decision: VexDecision): boolean {
if (!decision.validityWindow) return true;
const now = new Date();
const { notBefore, notAfter } = decision.validityWindow;
if (notBefore && new Date(notBefore) > now) return false;
if (notAfter && new Date(notAfter) < now) return false;
return true;
}
private calculateDepth(
decision: VexDecision,
byId: Map<string, VexDecision>
): number {
let depth = 0;
let current = decision;
while (current.supersedes) {
const superseded = byId.get(current.supersedes);
if (!superseded) break;
depth++;
current = superseded;
}
return depth;
}
}

View File

@@ -0,0 +1,182 @@
// -----------------------------------------------------------------------------
// advisory-ai.service.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Task: TRIAGE-03 — Create AdvisoryAiService consuming AdvisoryAI API endpoints
// -----------------------------------------------------------------------------
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap, switchMap, timer } from 'rxjs';
export interface AiRecommendation {
id: string;
type: 'triage_action' | 'vex_suggestion' | 'mitigation' | 'investigation';
confidence: number;
title: string;
description: string;
suggestedAction?: SuggestedAction;
reasoning: string;
sources: string[];
createdAt: string;
}
export interface SuggestedAction {
type: 'mark_not_affected' | 'mark_affected' | 'investigate' | 'apply_fix' | 'accept_risk';
vexStatus?: VexStatus;
justificationType?: VexJustificationType;
suggestedJustification?: string;
}
export type VexStatus = 'not_affected' | 'affected_mitigated' | 'affected_unmitigated' | 'fixed' | 'under_investigation';
export type VexJustificationType =
| 'component_not_present'
| 'vulnerable_code_not_present'
| 'vulnerable_code_not_in_execute_path'
| 'vulnerable_code_cannot_be_controlled_by_adversary'
| 'inline_mitigations_already_exist';
export interface AiExplanation {
question: string;
answer: string;
confidence: number;
sources: string[];
relatedVulns?: string[];
}
export interface AnalysisContext {
vulnId: string;
packagePurl?: string;
projectId?: string;
environmentId?: string;
includeReachability?: boolean;
includeVexHistory?: boolean;
}
export interface AnalysisTask {
taskId: string;
status: 'pending' | 'running' | 'completed' | 'failed';
progress?: number;
result?: AiRecommendation[];
error?: string;
}
export interface SimilarVulnerability {
vulnId: string;
cveId: string;
similarity: number;
reason: string;
vexDecision?: VexStatus;
}
@Injectable({ providedIn: 'root' })
export class AdvisoryAiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/advisory';
// State
private readonly _recommendations = signal<Map<string, AiRecommendation[]>>(new Map());
private readonly _loading = signal<Set<string>>(new Set());
private readonly _runningTasks = signal<Map<string, AnalysisTask>>(new Map());
// Computed
readonly loading = computed(() => this._loading());
getRecommendations(vulnId: string): Observable<AiRecommendation[]> {
return this.http.get<AiRecommendation[]>(`${this.baseUrl}/recommendations/${vulnId}`).pipe(
tap(recs => {
this._recommendations.update(map => {
const newMap = new Map(map);
newMap.set(vulnId, recs);
return newMap;
});
})
);
}
getCachedRecommendations(vulnId: string): AiRecommendation[] | undefined {
return this._recommendations().get(vulnId);
}
requestAnalysis(vulnId: string, context: AnalysisContext): Observable<AnalysisTask> {
this._loading.update(set => {
const newSet = new Set(set);
newSet.add(vulnId);
return newSet;
});
return this.http.post<{ taskId: string }>(`${this.baseUrl}/plan`, { vulnId, ...context }).pipe(
switchMap(({ taskId }) => this.pollTaskStatus(taskId)),
tap({
next: (task) => {
if (task.status === 'completed' && task.result) {
this._recommendations.update(map => {
const newMap = new Map(map);
newMap.set(vulnId, task.result!);
return newMap;
});
}
},
complete: () => {
this._loading.update(set => {
const newSet = new Set(set);
newSet.delete(vulnId);
return newSet;
});
},
error: () => {
this._loading.update(set => {
const newSet = new Set(set);
newSet.delete(vulnId);
return newSet;
});
}
})
);
}
getExplanation(vulnId: string, question: string): Observable<AiExplanation> {
return this.http.post<AiExplanation>(`${this.baseUrl}/explain`, { vulnId, question });
}
getSimilarVulnerabilities(vulnId: string, limit = 5): Observable<SimilarVulnerability[]> {
return this.http.get<SimilarVulnerability[]>(`${this.baseUrl}/similar/${vulnId}`, {
params: { limit }
});
}
getReachabilityExplanation(vulnId: string): Observable<AiExplanation> {
return this.getExplanation(vulnId, 'Why is this vulnerability reachable in my codebase?');
}
getSuggestedJustification(vulnId: string): Observable<AiExplanation> {
return this.getExplanation(vulnId, 'What is an appropriate VEX justification for this vulnerability?');
}
private pollTaskStatus(taskId: string): Observable<AnalysisTask> {
return new Observable(subscriber => {
const poll = () => {
this.http.get<AnalysisTask>(`${this.baseUrl}/tasks/${taskId}`).subscribe({
next: (task) => {
this._runningTasks.update(map => {
const newMap = new Map(map);
newMap.set(taskId, task);
return newMap;
});
if (task.status === 'completed' || task.status === 'failed') {
subscriber.next(task);
subscriber.complete();
} else {
// Poll again in 2 seconds
setTimeout(poll, 2000);
}
},
error: (err) => subscriber.error(err)
});
};
poll();
});
}
}

View File

@@ -0,0 +1,224 @@
// -----------------------------------------------------------------------------
// vex-decision.service.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Task: TRIAGE-04 — Create VexDecisionService for creating/updating VEX decisions
// -----------------------------------------------------------------------------
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
export type VexStatus = 'not_affected' | 'affected_mitigated' | 'affected_unmitigated' | 'fixed' | 'under_investigation';
export type VexJustificationType =
| 'component_not_present'
| 'vulnerable_code_not_present'
| 'vulnerable_code_not_in_execute_path'
| 'vulnerable_code_cannot_be_controlled_by_adversary'
| 'inline_mitigations_already_exist';
export interface VexDecision {
id: string;
vulnId: string;
status: VexStatus;
justificationType?: VexJustificationType;
justification: string;
evidenceRefs: EvidenceReference[];
scope: DecisionScope;
validityWindow?: ValidityWindow;
supersedes?: string;
signedAsAttestation: boolean;
attestationId?: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface EvidenceReference {
type: 'pr' | 'ticket' | 'document' | 'commit' | 'external';
url: string;
title?: string;
}
export interface DecisionScope {
projectIds?: string[];
environmentIds?: string[];
packagePurls?: string[];
}
export interface ValidityWindow {
notBefore?: string;
notAfter?: string;
}
export interface CreateVexDecisionRequest {
vulnId: string;
status: VexStatus;
justificationType?: VexJustificationType;
justification: string;
evidenceRefs?: EvidenceReference[];
scope?: DecisionScope;
validityWindow?: ValidityWindow;
supersedes?: string;
signAsAttestation?: boolean;
}
export interface BulkVexDecisionRequest {
vulnIds: string[];
status: VexStatus;
justificationType?: VexJustificationType;
justification: string;
evidenceRefs?: EvidenceReference[];
scope?: DecisionScope;
signAsAttestation?: boolean;
}
export interface VexHistoryEntry {
decision: VexDecision;
supersededBy?: string;
isActive: boolean;
}
@Injectable({ providedIn: 'root' })
export class VexDecisionService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/vex/decisions';
// State
private readonly _decisions = signal<Map<string, VexDecision[]>>(new Map());
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
// Computed
readonly loading = computed(() => this._loading());
readonly error = computed(() => this._error());
getDecisionsForVuln(vulnId: string): Observable<VexDecision[]> {
return this.http.get<VexDecision[]>(`${this.baseUrl}`, {
params: { vulnId }
}).pipe(
tap(decisions => {
this._decisions.update(map => {
const newMap = new Map(map);
newMap.set(vulnId, decisions);
return newMap;
});
})
);
}
getActiveDecision(vulnId: string): VexDecision | undefined {
const decisions = this._decisions().get(vulnId);
if (!decisions) return undefined;
// Find the most recent non-superseded decision
const now = new Date();
return decisions.find(d => {
if (d.validityWindow?.notBefore && new Date(d.validityWindow.notBefore) > now) return false;
if (d.validityWindow?.notAfter && new Date(d.validityWindow.notAfter) < now) return false;
return !decisions.some(other => other.supersedes === d.id);
});
}
getDecisionHistory(vulnId: string): Observable<VexHistoryEntry[]> {
return this.http.get<VexHistoryEntry[]>(`${this.baseUrl}/history`, {
params: { vulnId }
});
}
createDecision(request: CreateVexDecisionRequest): Observable<VexDecision> {
this._loading.set(true);
this._error.set(null);
return this.http.post<VexDecision>(this.baseUrl, request).pipe(
tap({
next: (decision) => {
this._decisions.update(map => {
const newMap = new Map(map);
const existing = newMap.get(request.vulnId) ?? [];
newMap.set(request.vulnId, [decision, ...existing]);
return newMap;
});
this._loading.set(false);
},
error: (err) => {
this._error.set(err.message || 'Failed to create VEX decision');
this._loading.set(false);
}
})
);
}
createBulkDecisions(request: BulkVexDecisionRequest): Observable<VexDecision[]> {
this._loading.set(true);
this._error.set(null);
return this.http.post<VexDecision[]>(`${this.baseUrl}/bulk`, request).pipe(
tap({
next: (decisions) => {
this._decisions.update(map => {
const newMap = new Map(map);
for (const decision of decisions) {
const existing = newMap.get(decision.vulnId) ?? [];
newMap.set(decision.vulnId, [decision, ...existing]);
}
return newMap;
});
this._loading.set(false);
},
error: (err) => {
this._error.set(err.message || 'Failed to create bulk VEX decisions');
this._loading.set(false);
}
})
);
}
updateDecision(id: string, update: Partial<CreateVexDecisionRequest>): Observable<VexDecision> {
return this.http.patch<VexDecision>(`${this.baseUrl}/${id}`, update);
}
supersedeDecision(id: string, newDecision: CreateVexDecisionRequest): Observable<VexDecision> {
return this.createDecision({ ...newDecision, supersedes: id });
}
getJustificationTypes(): { value: VexJustificationType; label: string; description: string }[] {
return [
{
value: 'component_not_present',
label: 'Component Not Present',
description: 'The affected component is not included in the product.'
},
{
value: 'vulnerable_code_not_present',
label: 'Vulnerable Code Not Present',
description: 'The vulnerable code is not present in the component version used.'
},
{
value: 'vulnerable_code_not_in_execute_path',
label: 'Vulnerable Code Not Reachable',
description: 'The vulnerable code cannot be executed in the product\'s context.'
},
{
value: 'vulnerable_code_cannot_be_controlled_by_adversary',
label: 'Cannot Be Exploited',
description: 'The vulnerable code cannot be controlled by an adversary.'
},
{
value: 'inline_mitigations_already_exist',
label: 'Mitigations Exist',
description: 'Inline mitigations are already applied that prevent exploitation.'
}
];
}
getStatusOptions(): { value: VexStatus; label: string; color: string }[] {
return [
{ value: 'not_affected', label: 'Not Affected', color: 'green' },
{ value: 'affected_mitigated', label: 'Affected (Mitigated)', color: 'blue' },
{ value: 'affected_unmitigated', label: 'Affected (Unmitigated)', color: 'red' },
{ value: 'fixed', label: 'Fixed', color: 'green' },
{ value: 'under_investigation', label: 'Under Investigation', color: 'orange' }
];
}
}

View File

@@ -0,0 +1,215 @@
// -----------------------------------------------------------------------------
// vulnerability-list.service.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Task: TRIAGE-02 — Create VulnerabilityListService consuming VulnExplorer API
// -----------------------------------------------------------------------------
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
export interface Vulnerability {
id: string;
cveId: string;
title: string;
description: string;
severity: 'critical' | 'high' | 'medium' | 'low' | 'none';
cvssScore: number;
cvssVector?: string;
epssScore?: number;
epssPercentile?: number;
isKev: boolean;
hasExploit: boolean;
hasFixAvailable: boolean;
affectedPackages: AffectedPackage[];
references: Reference[];
publishedAt: string;
modifiedAt: string;
reachabilityStatus?: 'reachable' | 'unreachable' | 'unknown' | 'partial';
triageStatus?: 'pending' | 'triaged' | 'deferred';
vexStatus?: VexStatus;
}
export interface AffectedPackage {
purl: string;
name: string;
version: string;
ecosystem: string;
fixedVersion?: string;
}
export interface Reference {
type: 'advisory' | 'article' | 'exploit' | 'patch' | 'vendor';
url: string;
source: string;
}
export type VexStatus = 'not_affected' | 'affected_mitigated' | 'affected_unmitigated' | 'fixed' | 'under_investigation';
export interface VulnerabilityListResponse {
items: Vulnerability[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
export interface VulnerabilityFilter {
severities?: ('critical' | 'high' | 'medium' | 'low' | 'none')[];
isKev?: boolean;
hasExploit?: boolean;
hasFixAvailable?: boolean;
reachabilityStatus?: ('reachable' | 'unreachable' | 'unknown' | 'partial')[];
triageStatus?: ('pending' | 'triaged' | 'deferred')[];
vexStatus?: VexStatus[];
searchText?: string;
projectId?: string;
environmentId?: string;
}
@Injectable({ providedIn: 'root' })
export class VulnerabilityListService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/vulnerabilities';
// State
private readonly _items = signal<Vulnerability[]>([]);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
private readonly _total = signal(0);
private readonly _page = signal(1);
private readonly _pageSize = signal(25);
private readonly _filter = signal<VulnerabilityFilter>({});
private readonly _selectedId = signal<string | null>(null);
// Computed
readonly items = computed(() => this._items());
readonly loading = computed(() => this._loading());
readonly error = computed(() => this._error());
readonly total = computed(() => this._total());
readonly page = computed(() => this._page());
readonly pageSize = computed(() => this._pageSize());
readonly filter = computed(() => this._filter());
readonly selectedItem = computed(() => {
const id = this._selectedId();
if (!id) return null;
return this._items().find(v => v.id === id) ?? null;
});
readonly hasMore = computed(() => {
return this._page() * this._pageSize() < this._total();
});
readonly severityCounts = computed(() => {
const items = this._items();
return {
critical: items.filter(v => v.severity === 'critical').length,
high: items.filter(v => v.severity === 'high').length,
medium: items.filter(v => v.severity === 'medium').length,
low: items.filter(v => v.severity === 'low').length,
none: items.filter(v => v.severity === 'none').length
};
});
loadVulnerabilities(): Observable<VulnerabilityListResponse> {
this._loading.set(true);
this._error.set(null);
const params = this.buildParams();
return this.http.get<VulnerabilityListResponse>(this.baseUrl, { params }).pipe(
tap({
next: (response) => {
this._items.set(response.items);
this._total.set(response.total);
this._loading.set(false);
},
error: (err) => {
this._error.set(err.message || 'Failed to load vulnerabilities');
this._loading.set(false);
}
})
);
}
loadMore(): Observable<VulnerabilityListResponse> {
if (!this.hasMore()) {
return new Observable(sub => sub.complete());
}
this._page.update(p => p + 1);
const params = this.buildParams();
return this.http.get<VulnerabilityListResponse>(this.baseUrl, { params }).pipe(
tap({
next: (response) => {
this._items.update(items => [...items, ...response.items]);
}
})
);
}
getVulnerabilityById(id: string): Observable<Vulnerability> {
return this.http.get<Vulnerability>(`${this.baseUrl}/${id}`);
}
setFilter(filter: VulnerabilityFilter): void {
this._filter.set(filter);
this._page.set(1);
}
updateFilter(partial: Partial<VulnerabilityFilter>): void {
this._filter.update(f => ({ ...f, ...partial }));
this._page.set(1);
}
clearFilter(): void {
this._filter.set({});
this._page.set(1);
}
selectVulnerability(id: string | null): void {
this._selectedId.set(id);
}
private buildParams(): HttpParams {
let params = new HttpParams()
.set('page', this._page())
.set('pageSize', this._pageSize());
const filter = this._filter();
if (filter.severities?.length) {
params = params.set('severities', filter.severities.join(','));
}
if (filter.isKev !== undefined) {
params = params.set('isKev', filter.isKev);
}
if (filter.hasExploit !== undefined) {
params = params.set('hasExploit', filter.hasExploit);
}
if (filter.hasFixAvailable !== undefined) {
params = params.set('hasFixAvailable', filter.hasFixAvailable);
}
if (filter.reachabilityStatus?.length) {
params = params.set('reachabilityStatus', filter.reachabilityStatus.join(','));
}
if (filter.triageStatus?.length) {
params = params.set('triageStatus', filter.triageStatus.join(','));
}
if (filter.vexStatus?.length) {
params = params.set('vexStatus', filter.vexStatus.join(','));
}
if (filter.searchText) {
params = params.set('q', filter.searchText);
}
if (filter.projectId) {
params = params.set('projectId', filter.projectId);
}
if (filter.environmentId) {
params = params.set('environmentId', filter.environmentId);
}
return params;
}
}

View File

@@ -33,9 +33,9 @@
Retry
</button>
</div>
} @else if (gatedBuckets(); as buckets) {
} @else if (gatedBuckets()) {
<app-gated-buckets
[summary]="buckets"
[summary]="gatedBuckets()!"
(bucketExpand)="onBucketExpand($event)"
(showAllChange)="onShowAllGated()"
/>
@@ -280,8 +280,8 @@
}
}
@if (verification.issues?.length) {
<span class="status-issues" title="{{ verification.issues.join(', ') }}">
{{ verification.issues.length }} issue(s)
<span class="status-issues" [attr.title]="verification.issues?.join(', ')">
{{ verification.issues?.length }} issue(s)
</span>
}
</div>

View File

@@ -0,0 +1,284 @@
// -----------------------------------------------------------------------------
// ai-chip-visual.e2e.spec.ts
// Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
// Task: AIUX-42 — Visual regression tests: chips don't overflow list rows
// -----------------------------------------------------------------------------
import { test, expect } from '@playwright/test';
test.describe('AI Chip Visual Regression', () => {
test.beforeEach(async ({ page }) => {
// Navigate to findings list
await page.goto('/findings');
await page.waitForSelector('.findings-list');
});
test('chips should not overflow finding row container', async ({ page }) => {
// Get all finding rows
const rows = page.locator('.finding-row');
const count = await rows.count();
for (let i = 0; i < Math.min(count, 10); i++) {
const row = rows.nth(i);
const rowBox = await row.boundingBox();
if (!rowBox) continue;
// Get all chips in this row
const chips = row.locator('.ai-chip');
const chipCount = await chips.count();
for (let j = 0; j < chipCount; j++) {
const chip = chips.nth(j);
const chipBox = await chip.boundingBox();
if (!chipBox) continue;
// Chip should be within row bounds
expect(chipBox.x).toBeGreaterThanOrEqual(rowBox.x);
expect(chipBox.x + chipBox.width).toBeLessThanOrEqual(rowBox.x + rowBox.width);
expect(chipBox.y).toBeGreaterThanOrEqual(rowBox.y);
expect(chipBox.y + chipBox.height).toBeLessThanOrEqual(rowBox.y + rowBox.height);
}
}
});
test('should display max 2 AI chips per row', async ({ page }) => {
// Get all finding rows
const rows = page.locator('.finding-row');
const count = await rows.count();
for (let i = 0; i < Math.min(count, 20); i++) {
const row = rows.nth(i);
const chips = row.locator('.ai-chip');
const chipCount = await chips.count();
// Max 2 chips per row (as per AIUX-25, AIUX-29)
expect(chipCount).toBeLessThanOrEqual(2);
}
});
test('chip text should not be truncated', async ({ page }) => {
// Get all AI chips
const chips = page.locator('.ai-chip');
const count = await chips.count();
for (let i = 0; i < Math.min(count, 20); i++) {
const chip = chips.nth(i);
const chipLabel = chip.locator('.ai-chip__label');
// Check for text-overflow: ellipsis not being applied
const hasEllipsis = await chipLabel.evaluate((el) => {
return el.scrollWidth > el.clientWidth;
});
// Chip labels should not overflow (3-5 words max)
expect(hasEllipsis).toBeFalsy();
}
});
test('chip icon should be visible', async ({ page }) => {
// Get all AI chips
const chips = page.locator('.ai-chip');
const count = await chips.count();
for (let i = 0; i < Math.min(count, 10); i++) {
const chip = chips.nth(i);
const icon = chip.locator('.ai-chip__icon');
// Icon should be visible if it exists
if (await icon.count() > 0) {
await expect(icon).toBeVisible();
}
}
});
test('chip hover state should work', async ({ page }) => {
const chip = page.locator('.ai-chip').first();
if (await chip.count() > 0) {
// Hover over chip
await chip.hover();
// Should have hover class or changed appearance
const hasHover = await chip.evaluate((el) => {
const styles = window.getComputedStyle(el);
return el.classList.contains('ai-chip--hover') ||
styles.cursor === 'pointer';
});
expect(hasHover).toBeTruthy();
}
});
test('chips should align properly in row', async ({ page }) => {
// Get first row with chips
const rowWithChips = page.locator('.finding-row:has(.ai-chip)').first();
if (await rowWithChips.count() > 0) {
const chips = rowWithChips.locator('.ai-chip');
const chipCount = await chips.count();
if (chipCount >= 2) {
const chip1Box = await chips.nth(0).boundingBox();
const chip2Box = await chips.nth(1).boundingBox();
if (chip1Box && chip2Box) {
// Chips should be vertically aligned (same y position, give or take 2px)
expect(Math.abs(chip1Box.y - chip2Box.y)).toBeLessThan(3);
// Chips should have gap between them
const gap = chip2Box.x - (chip1Box.x + chip1Box.width);
expect(gap).toBeGreaterThan(0);
}
}
}
});
test('screenshot: findings list with AI chips', async ({ page }) => {
// Wait for chips to render
await page.waitForSelector('.ai-chip');
// Take screenshot for visual comparison
await expect(page.locator('.findings-list')).toHaveScreenshot('findings-list-with-ai-chips.png', {
maxDiffPixels: 100,
});
});
test('screenshot: single finding row with chips', async ({ page }) => {
const rowWithChips = page.locator('.finding-row:has(.ai-chip)').first();
if (await rowWithChips.count() > 0) {
await expect(rowWithChips).toHaveScreenshot('finding-row-with-ai-chips.png', {
maxDiffPixels: 50,
});
}
});
});
test.describe('AI Authority Badge Visual', () => {
test('Evidence-backed badge should be green', async ({ page }) => {
await page.goto('/findings');
// Find evidence-backed badge
const badge = page.locator('.ai-authority-badge--evidence-backed').first();
if (await badge.count() > 0) {
const color = await badge.evaluate((el) => {
return window.getComputedStyle(el).backgroundColor;
});
// Should be greenish
expect(color).toMatch(/rgb\((?:0|[1-9]\d?|1\d\d|2[0-4]\d|25[0-5]),\s*(?:1[2-9]\d|2\d\d),/);
}
});
test('Suggestion badge should be amber/yellow', async ({ page }) => {
await page.goto('/findings');
// Find suggestion badge
const badge = page.locator('.ai-authority-badge--suggestion').first();
if (await badge.count() > 0) {
const color = await badge.evaluate((el) => {
return window.getComputedStyle(el).backgroundColor;
});
// Should be amberish/yellowish
expect(color).toMatch(/rgb\((?:2[0-4]\d|25[0-5]),\s*(?:1[5-9]\d|2[0-4]\d),/);
}
});
});
test.describe('AI Summary Expansion Visual', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/findings/CVE-2025-12345');
await page.waitForSelector('.finding-detail');
});
test('should display 3-line summary collapsed', async ({ page }) => {
const summary = page.locator('.ai-summary').first();
if (await summary.count() > 0) {
// Collapsed summary should be <= 3 lines
const lineCount = await summary.evaluate((el) => {
const lineHeight = parseFloat(window.getComputedStyle(el).lineHeight);
return Math.round(el.clientHeight / lineHeight);
});
expect(lineCount).toBeLessThanOrEqual(3);
}
});
test('should expand on click', async ({ page }) => {
const summary = page.locator('.ai-summary').first();
if (await summary.count() > 0) {
const collapsedHeight = (await summary.boundingBox())?.height || 0;
// Click expand button
const expandBtn = summary.locator('.ai-summary__expand');
if (await expandBtn.count() > 0) {
await expandBtn.click();
// Height should increase
const expandedHeight = (await summary.boundingBox())?.height || 0;
expect(expandedHeight).toBeGreaterThan(collapsedHeight);
}
}
});
test('screenshot: AI summary collapsed vs expanded', async ({ page }) => {
const summary = page.locator('.ai-summary').first();
if (await summary.count() > 0) {
// Screenshot collapsed
await expect(summary).toHaveScreenshot('ai-summary-collapsed.png', {
maxDiffPixels: 50,
});
// Expand
const expandBtn = summary.locator('.ai-summary__expand');
if (await expandBtn.count() > 0) {
await expandBtn.click();
// Screenshot expanded
await expect(summary).toHaveScreenshot('ai-summary-expanded.png', {
maxDiffPixels: 100,
});
}
}
});
});
test.describe('Responsive AI Chip Behavior', () => {
test('chips should adapt on narrow viewport', async ({ page }) => {
// Set narrow viewport
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/findings');
await page.waitForSelector('.findings-list');
// Chips should still be visible
const chips = page.locator('.ai-chip');
if (await chips.count() > 0) {
await expect(chips.first()).toBeVisible();
}
});
test('chips should hide on mobile viewport', async ({ page }) => {
// Set mobile viewport
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/findings');
await page.waitForSelector('.findings-list');
// Chips may be hidden or show fewer on mobile
const rows = page.locator('.finding-row').first();
const chips = rows.locator('.ai-chip');
const chipCount = await chips.count();
// Should have max 1 chip on mobile (or 0)
expect(chipCount).toBeLessThanOrEqual(1);
});
});

View File

@@ -0,0 +1,217 @@
// -----------------------------------------------------------------------------
// ask-stella.e2e.spec.ts
// Sprint: SPRINT_20251226_020_FE_ai_ux_patterns
// Task: AIUX-41 — E2E tests: Ask Stella flow from button to response
// -----------------------------------------------------------------------------
import { test, expect } from '@playwright/test';
test.describe('Ask Stella E2E', () => {
test.beforeEach(async ({ page }) => {
// Navigate to a page with Ask Stella button
await page.goto('/findings');
await page.waitForSelector('.findings-list');
});
test('should show Ask Stella button', async ({ page }) => {
// Verify Ask Stella button is visible
const askStellaBtn = page.locator('.ask-stella-button');
await expect(askStellaBtn).toBeVisible();
});
test('should open panel on button click', async ({ page }) => {
// Click Ask Stella button
await page.click('.ask-stella-button');
// Verify panel opens
await expect(page.locator('.ask-stella-panel')).toBeVisible();
});
test('should show suggested prompts', async ({ page }) => {
// Open Ask Stella panel
await page.click('.ask-stella-button');
// Verify suggested prompts are displayed
const suggestedPrompts = page.locator('.ask-stella-panel__prompt-chip');
await expect(suggestedPrompts.first()).toBeVisible();
// Verify at least 3 suggested prompts
const count = await suggestedPrompts.count();
expect(count).toBeGreaterThanOrEqual(3);
});
test('should show context chips', async ({ page }) => {
// Select a finding first to set context
await page.click('.finding-row:first-child');
// Open Ask Stella panel
await page.click('.ask-stella-button');
// Verify context chips are shown
const contextChips = page.locator('.ask-stella-panel__context-chip');
await expect(contextChips.first()).toBeVisible();
});
test('should submit suggested prompt and get response', async ({ page }) => {
// Open Ask Stella panel
await page.click('.ask-stella-button');
// Click a suggested prompt
await page.click('.ask-stella-panel__prompt-chip:first-child');
// Wait for loading indicator
await expect(page.locator('.ask-stella-panel__loading')).toBeVisible();
// Wait for response (mock should respond quickly in test)
await page.waitForSelector('.ask-stella-panel__response', { timeout: 10000 });
// Verify response is displayed
await expect(page.locator('.ask-stella-panel__response')).toBeVisible();
});
test('should show authority badge on response', async ({ page }) => {
// Open Ask Stella panel
await page.click('.ask-stella-button');
// Click a suggested prompt
await page.click('.ask-stella-panel__prompt-chip:first-child');
// Wait for response
await page.waitForSelector('.ask-stella-panel__response', { timeout: 10000 });
// Verify authority badge (Evidence-backed or Suggestion)
const authorityBadge = page.locator('.ai-authority-badge');
await expect(authorityBadge).toBeVisible();
});
test('should allow freeform input', async ({ page }) => {
// Open Ask Stella panel
await page.click('.ask-stella-button');
// Type in freeform input
const input = page.locator('.ask-stella-panel__input');
await input.fill('Why is this vulnerability critical?');
// Submit
await page.click('.ask-stella-panel__submit');
// Wait for response
await page.waitForSelector('.ask-stella-panel__response', { timeout: 10000 });
// Verify response
await expect(page.locator('.ask-stella-panel__response')).toBeVisible();
});
test('should close panel on escape key', async ({ page }) => {
// Open Ask Stella panel
await page.click('.ask-stella-button');
await expect(page.locator('.ask-stella-panel')).toBeVisible();
// Press Escape
await page.keyboard.press('Escape');
// Verify panel is closed
await expect(page.locator('.ask-stella-panel')).not.toBeVisible();
});
test('should close panel on outside click', async ({ page }) => {
// Open Ask Stella panel
await page.click('.ask-stella-button');
await expect(page.locator('.ask-stella-panel')).toBeVisible();
// Click outside panel
await page.click('body', { position: { x: 0, y: 0 } });
// Verify panel is closed
await expect(page.locator('.ask-stella-panel')).not.toBeVisible();
});
test('should show streaming response animation', async ({ page }) => {
// Open Ask Stella panel
await page.click('.ask-stella-button');
// Click a suggested prompt
await page.click('.ask-stella-panel__prompt-chip:first-child');
// Verify streaming animation during loading
const streamingIndicator = page.locator('.ask-stella-panel__streaming');
// May or may not be visible depending on response speed
// This is a soft check - if streaming is fast, it might not appear
});
test('should display citations in response', async ({ page }) => {
// Open Ask Stella panel
await page.click('.ask-stella-button');
// Click prompt that should include evidence
await page.click('.ask-stella-panel__prompt-chip:has-text("evidence")');
// Wait for response
await page.waitForSelector('.ask-stella-panel__response', { timeout: 10000 });
// Check for citations (may not always be present depending on mock)
const citations = page.locator('.ask-stella-panel__citation');
// Soft check - citations depend on the response
});
test('should navigate to evidence on citation click', async ({ page }) => {
// Open Ask Stella panel
await page.click('.ask-stella-button');
// Click prompt
await page.click('.ask-stella-panel__prompt-chip:first-child');
// Wait for response
await page.waitForSelector('.ask-stella-panel__response', { timeout: 10000 });
// If citations exist, click one
const citation = page.locator('.ask-stella-panel__citation').first();
if (await citation.isVisible()) {
await citation.click();
// Verify evidence drawer or navigation occurred
await expect(page.locator('.evidence-drawer, .evidence-panel')).toBeVisible();
}
});
});
test.describe('Ask Stella Context Scoping', () => {
test('should scope to finding when on finding detail', async ({ page }) => {
// Navigate to finding detail
await page.goto('/findings/CVE-2025-12345');
await page.waitForSelector('.finding-detail');
// Open Ask Stella
await page.click('.ask-stella-button');
// Verify CVE context chip
await expect(page.locator('.ask-stella-panel__context-chip:has-text("CVE-2025")')).toBeVisible();
});
test('should scope to build when on build view', async ({ page }) => {
// Navigate to build detail
await page.goto('/builds/12345');
await page.waitForSelector('.build-detail');
// Open Ask Stella
await page.click('.ask-stella-button');
// Verify build context shown
await expect(page.locator('.ask-stella-panel__context-chip')).toBeVisible();
});
test('should show relevant prompts for context', async ({ page }) => {
// Navigate to finding detail
await page.goto('/findings/CVE-2025-12345');
await page.waitForSelector('.finding-detail');
// Open Ask Stella
await page.click('.ask-stella-button');
// Verify vulnerability-specific prompts
const prompts = page.locator('.ask-stella-panel__prompt-chip');
const promptTexts = await prompts.allTextContents();
// Should have exploit-related prompts for vulnerabilities
expect(promptTexts.some(p => p.toLowerCase().includes('exploit'))).toBeTruthy();
});
});

View File

@@ -10,7 +10,7 @@
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { FindingEvidenceResponse } from '../../core/api/triage-evidence.models';
import type { FindingEvidenceResponse, VexStatus } from '../../core/api/triage-evidence.models';
import type { DecisionDigest, TrustScoreBreakdown, InputManifest, CacheSource } from '../../core/api/policy-engine.models';
import type { MerkleTree } from '../../core/api/proof.models';
import { ReachabilityChipComponent, ReachabilityState } from './reachability-chip.component';
@@ -96,10 +96,10 @@ export interface FindingDetail extends FindingEvidenceResponse {
<!-- Provenance Badge -->
@if (showProvenanceBadge()) {
<stellaops-provenance-badge
<stella-provenance-badge
[state]="provenanceState()"
[cacheDetails]="cacheDetails()"
[trustScore]="finding()?.trust_score"
[showTrustScore]="true"
[clickable]="true"
(clicked)="onViewProofTree()"
/>
@@ -130,7 +130,7 @@ export interface FindingDetail extends FindingEvidenceResponse {
<div class="finding-detail__stat">
<stella-score-breakdown
[explanation]="finding()?.score_explain"
[mode]="'full'"
[mode]="'expanded'"
/>
</div>
</div>
@@ -172,10 +172,10 @@ export interface FindingDetail extends FindingEvidenceResponse {
@if (hasTrustScore()) {
<div class="finding-detail__section finding-detail__section--trust">
<h2 class="finding-detail__section-title">Trust Score</h2>
<stellaops-trust-score-display
<stella-trust-score
[score]="finding()?.trust_score ?? 0"
[breakdown]="finding()?.trustScoreBreakdown ?? null"
[mode]="'donut'"
[breakdown]="finding()?.trustScoreBreakdown"
[mode]="'donut-only'"
[showBreakdown]="true"
/>
</div>
@@ -235,9 +235,9 @@ export interface FindingDetail extends FindingEvidenceResponse {
<dt>Source</dt>
<dd>{{ finding()?.vex?.source }}</dd>
}
@if (finding()?.vex?.published_at) {
<dt>Published</dt>
<dd>{{ formatDate(finding()?.vex?.published_at) }}</dd>
@if (finding()?.vex?.issued_at) {
<dt>Issued</dt>
<dd>{{ formatDate(finding()?.vex?.issued_at) }}</dd>
}
</dl>
} @else {
@@ -304,16 +304,18 @@ export interface FindingDetail extends FindingEvidenceResponse {
<div class="finding-detail__boundary">
<h3 class="finding-detail__subsection-title">Boundary Proof</h3>
<dl class="finding-detail__dl">
<dt>Boundary Type</dt>
<dd>{{ finding()?.boundary?.boundary_type ?? '—' }}</dd>
@if (finding()?.boundary?.container_id) {
<dt>Container</dt>
<dd><code>{{ finding()?.boundary?.container_id }}</code></dd>
<dt>Kind</dt>
<dd>{{ finding()?.boundary?.kind ?? '—' }}</dd>
@if (finding()?.boundary?.surface) {
<dt>Surface</dt>
<dd>{{ finding()?.boundary?.surface?.type ?? '—' }}</dd>
}
@if (finding()?.boundary?.namespace) {
<dt>Namespace</dt>
<dd>{{ finding()?.boundary?.namespace }}</dd>
@if (finding()?.boundary?.exposure) {
<dt>Exposure</dt>
<dd>{{ finding()?.boundary?.exposure?.level ?? '—' }}</dd>
}
<dt>Confidence</dt>
<dd>{{ finding()?.boundary?.confidence ?? 0 }}%</dd>
</dl>
</div>
}
@@ -782,8 +784,8 @@ export class FindingDetailComponent {
return 'unknown';
});
readonly vexStatus = computed(() => {
return this.finding()?.vex?.status ?? 'unknown';
readonly vexStatus = computed((): VexStatus | undefined => {
return this.finding()?.vex?.status;
});
readonly hasBoundary = computed(() => {
@@ -808,9 +810,9 @@ export class FindingDetailComponent {
return 'unknown';
});
readonly cacheDetails = computed((): CacheDetails | null => {
readonly cacheDetails = computed((): CacheDetails | undefined => {
const f = this.finding();
if (!f?.veri_key) return null;
if (!f?.veri_key) return undefined;
// Map to CacheSource type (none | inMemory | redis)
const source: CacheSource = f.cache_source === 'redis' ? 'redis' : f.cache_source === 'inMemory' ? 'inMemory' : 'none';

View File

@@ -92,7 +92,7 @@ export interface EvidenceChunk {
class="proof-tree__node-toggle"
(click)="veriKeyExpanded.set(!veriKeyExpanded())"
type="button"
aria-expanded="{{veriKeyExpanded()}}">
[attr.aria-expanded]="veriKeyExpanded()">
{{ veriKeyExpanded() ? '▼' : '▶' }}
</button>
<div class="proof-tree__node-content">
@@ -103,7 +103,7 @@ export interface EvidenceChunk {
</code>
<button
class="proof-tree__copy-btn"
(click)="copyToClipboard.emit(d.veriKey)"
(click)="onCopyVeriKey(d.veriKey)"
type="button"
title="Copy VeriKey">
📋
@@ -150,7 +150,7 @@ export interface EvidenceChunk {
class="proof-tree__node-toggle"
(click)="verdictsExpanded.set(!verdictsExpanded())"
type="button"
aria-expanded="{{verdictsExpanded()}}">
[attr.aria-expanded]="verdictsExpanded()">
{{ verdictsExpanded() ? '▼' : '▶' }}
</button>
<div class="proof-tree__node-content">
@@ -191,7 +191,7 @@ export interface EvidenceChunk {
class="proof-tree__node-toggle"
(click)="evidenceExpanded.set(!evidenceExpanded())"
type="button"
aria-expanded="{{evidenceExpanded()}}">
[attr.aria-expanded]="evidenceExpanded()">
{{ evidenceExpanded() ? '▼' : '▶' }}
</button>
<div class="proof-tree__node-content">
@@ -247,7 +247,7 @@ export interface EvidenceChunk {
class="proof-tree__node-toggle"
(click)="replayExpanded.set(!replayExpanded())"
type="button"
aria-expanded="{{replayExpanded()}}">
[attr.aria-expanded]="replayExpanded()">
{{ replayExpanded() ? '▼' : '▶' }}
</button>
<div class="proof-tree__node-content">
@@ -295,7 +295,7 @@ export interface EvidenceChunk {
class="proof-tree__node-toggle"
(click)="metadataExpanded.set(!metadataExpanded())"
type="button"
aria-expanded="{{metadataExpanded()}}">
[attr.aria-expanded]="metadataExpanded()">
{{ metadataExpanded() ? '▼' : '▶' }}
</button>
<div class="proof-tree__node-content">
@@ -874,10 +874,10 @@ export class ProofTreeComponent {
merkleTree = input<MerkleTree | null>(null);
/** Verdicts list to display (parsed from digest or provided separately). */
verdicts = input<VerdictEntry[]>([]);
verdicts = input<readonly VerdictEntry[]>([]);
/** Evidence chunks for lazy loading. */
evidenceChunks = input<EvidenceChunk[]>([]);
evidenceChunks = input<readonly EvidenceChunk[]>([]);
/** Whether verification is in progress. */
isVerifying = input<boolean>(false);
@@ -894,6 +894,15 @@ export class ProofTreeComponent {
/** Emitted when user clicks to load chunk by hash (from Merkle tree). */
loadChunkByHash = output<string>();
/** Emitted when user copies the VeriKey. */
copyVeriKey = output<string>();
/** Emitted when user copies a hash. */
copyHash = output<string>();
/** Emitted when user wants to download evidence. */
downloadEvidence = output<EvidenceChunk>();
/** Expansion states */
veriKeyExpanded = signal(true);
verdictsExpanded = signal(false);
@@ -1006,4 +1015,28 @@ export class ProofTreeComponent {
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}
/**
* Handle VeriKey copy.
*/
onCopyVeriKey(veriKey: string): void {
this.copyVeriKey.emit(veriKey);
this.copyToClipboard.emit(veriKey);
}
/**
* Handle hash copy.
*/
onCopyHash(hash: string): void {
this.copyHash.emit(hash);
this.copyToClipboard.emit(hash);
}
/**
* Handle evidence download.
*/
onDownloadEvidence(chunk: EvidenceChunk): void {
this.downloadEvidence.emit(chunk);
this.loadChunk.emit(chunk);
}
}

View File

@@ -123,7 +123,7 @@ export interface TimelineEvent {
<!-- Provenance Badge (for cache events) -->
@if (showProvenanceBadge()) {
<div class="timeline-event__provenance">
<stellaops-provenance-badge
<stella-provenance-badge
[state]="provenanceState()"
[cacheDetails]="cacheDetails()"
[showLabel]="false"
@@ -525,9 +525,9 @@ export class TimelineEventComponent {
return 'unknown';
});
readonly cacheDetails = computed((): CacheDetails | null => {
readonly cacheDetails = computed((): CacheDetails | undefined => {
const e = this.event();
if (!e || !e.veriKey) return null;
if (!e || !e.veriKey) return undefined;
// Map component's cacheSource ('none' | 'valkey' | 'postgres') to CacheSource type ('none' | 'inMemory' | 'redis')
const source: CacheSource =

View File

@@ -1,4 +1,9 @@
import { DeterminismEvidence, EntropyEvidence, ScanDetail } from '../core/api/scanner.models';
import {
BinaryEvidence,
DeterminismEvidence,
EntropyEvidence,
ScanDetail,
} from '../core/api/scanner.models';
// Mock determinism evidence for verified scan
const verifiedDeterminism: DeterminismEvidence = {
@@ -225,6 +230,170 @@ const failedEntropy: EntropyEvidence = {
downloadUrl: '/api/v1/scans/scan-failed-002/entropy',
};
// Mock binary evidence for verified scan - mixed safe/vulnerable
// Sprint: SPRINT_20251226_014_BINIDX (SCANINT-17,18,19)
const verifiedBinaryEvidence: BinaryEvidence = {
scanId: 'scan-verified-001',
scannedAt: '2025-10-20T18:22:00Z',
distro: 'debian',
release: 'bookworm',
binaries: [
{
identity: {
format: 'elf',
buildId: '8d8f09a0d7e2c1b3a5f4e6d8c0b2a4e6f8d0c2b4',
fileSha256: 'sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab',
architecture: 'x86_64',
binaryKey: 'openssl:1.1.1w-1',
path: '/usr/lib/x86_64-linux-gnu/libssl.so.1.1',
},
layerDigest: 'sha256:layer1abc123def456789012345678901234567890abcdef12345678901234',
matches: [
{
cveId: 'CVE-2023-5678',
method: 'buildid_catalog',
confidence: 0.95,
vulnerablePurl: 'pkg:deb/debian/openssl@1.1.1n-0+deb11u4',
fixStatus: {
state: 'fixed',
fixedVersion: '1.1.1w-1',
method: 'changelog',
confidence: 0.98,
},
},
{
cveId: 'CVE-2023-4807',
method: 'buildid_catalog',
confidence: 0.92,
vulnerablePurl: 'pkg:deb/debian/openssl@1.1.1n-0+deb11u4',
fixStatus: {
state: 'fixed',
fixedVersion: '1.1.1w-1',
method: 'patch_analysis',
confidence: 0.95,
},
},
],
},
{
identity: {
format: 'elf',
buildId: 'c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0',
fileSha256: 'sha256:1234abcd567890ef1234abcd567890ef1234abcd567890ef1234abcd567890ef',
architecture: 'x86_64',
binaryKey: 'zlib:1.2.13.dfsg-1',
path: '/usr/lib/x86_64-linux-gnu/libz.so.1.2.13',
},
layerDigest: 'sha256:layer2def456abc789012345678901234567890abcdef12345678901234',
matches: [
{
cveId: 'CVE-2022-37434',
method: 'fingerprint_match',
confidence: 0.78,
vulnerablePurl: 'pkg:deb/debian/zlib@1.2.11.dfsg-4',
similarity: 0.85,
matchedFunction: 'inflateGetHeader',
fixStatus: {
state: 'fixed',
fixedVersion: '1.2.13.dfsg-1',
method: 'advisory',
confidence: 0.99,
},
},
],
},
],
};
// Mock binary evidence for failed scan - contains vulnerable binaries
const failedBinaryEvidence: BinaryEvidence = {
scanId: 'scan-failed-002',
scannedAt: '2025-10-19T07:14:30Z',
distro: 'debian',
release: 'bullseye',
binaries: [
{
identity: {
format: 'elf',
buildId: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0',
fileSha256: 'sha256:vulnerable1234567890abcdef1234567890abcdef1234567890abcdef12345678',
architecture: 'x86_64',
binaryKey: 'curl:7.74.0-1.3+deb11u7',
path: '/usr/bin/curl',
},
layerDigest: 'sha256:base-layer-fail-001',
matches: [
{
cveId: 'CVE-2024-2398',
method: 'buildid_catalog',
confidence: 0.98,
vulnerablePurl: 'pkg:deb/debian/curl@7.74.0-1.3+deb11u6',
fixStatus: {
state: 'vulnerable',
method: 'advisory',
confidence: 0.99,
},
},
{
cveId: 'CVE-2023-38545',
method: 'buildid_catalog',
confidence: 0.96,
vulnerablePurl: 'pkg:deb/debian/curl@7.74.0-1.3+deb11u5',
fixStatus: {
state: 'vulnerable',
method: 'changelog',
confidence: 0.97,
},
},
],
},
{
identity: {
format: 'elf',
fileSha256: 'sha256:unknown1234567890abcdef1234567890abcdef1234567890abcdef12345678ab',
architecture: 'x86_64',
binaryKey: 'libpng:1.6.37-3',
path: '/usr/lib/x86_64-linux-gnu/libpng16.so.16.37.0',
},
layerDigest: 'sha256:packed-layer-fail-002',
matches: [
{
cveId: 'CVE-2019-7317',
method: 'range_match',
confidence: 0.55,
vulnerablePurl: 'pkg:deb/debian/libpng1.6@1.6.36-6',
// No fix status - unknown
},
],
},
{
identity: {
format: 'elf',
buildId: 'b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3',
fileSha256: 'sha256:safe1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab',
architecture: 'x86_64',
binaryKey: 'libc:2.31-13+deb11u8',
path: '/lib/x86_64-linux-gnu/libc.so.6',
},
layerDigest: 'sha256:base-layer-fail-001',
matches: [
{
cveId: 'CVE-2023-4911',
method: 'buildid_catalog',
confidence: 0.99,
vulnerablePurl: 'pkg:deb/debian/glibc@2.31-13+deb11u6',
fixStatus: {
state: 'fixed',
fixedVersion: '2.31-13+deb11u8',
method: 'changelog',
confidence: 0.99,
},
},
],
},
],
};
export const scanDetailWithVerifiedAttestation: ScanDetail = {
scanId: 'scan-verified-001',
imageDigest:
@@ -240,6 +409,7 @@ export const scanDetailWithVerifiedAttestation: ScanDetail = {
},
determinism: verifiedDeterminism,
entropy: verifiedEntropy,
binaryEvidence: verifiedBinaryEvidence,
};
export const scanDetailWithFailedAttestation: ScanDetail = {
@@ -256,4 +426,5 @@ export const scanDetailWithFailedAttestation: ScanDetail = {
},
determinism: failedDeterminism,
entropy: failedEntropy,
binaryEvidence: failedBinaryEvidence,
};