Refactor code structure and optimize performance across multiple modules
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
@@ -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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) + '...';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">💾</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">✓</span>
|
||||
{{ safeCount() }} Backported & Safe
|
||||
</span>
|
||||
}
|
||||
@if (vulnerableCount() > 0) {
|
||||
<span class="badge badge--vulnerable" aria-label="Affected and reachable binaries">
|
||||
<span class="badge-icon">⚠</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">✓</span>
|
||||
}
|
||||
@case ('vulnerable') {
|
||||
<span class="status-icon status-icon--vulnerable" aria-label="Affected & Reachable">⚠</span>
|
||||
}
|
||||
@case ('not_affected') {
|
||||
<span class="status-icon status-icon--safe" aria-label="Not Affected">✓</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) ? '▲' : '▼' }}
|
||||
</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 →
|
||||
</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 });
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user