sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View File

@@ -193,11 +193,203 @@ test.describe('Evidence Panel E2E', () => {
test('should expand attestation chain node on click', async ({ page }) => {
const node = page.locator('.chain-node').first();
await node.click();
await expect(node).toHaveClass(/chain-node--expanded/);
});
});
test.describe('Reachability tab', () => {
test.beforeEach(async ({ page }) => {
// Switch to Reachability tab
await page.click('[aria-controls="panel-reachability"]');
await page.waitForSelector('.reachability-tab');
});
test('should display status badge with correct state', async ({ page }) => {
const badge = page.locator('.status-badge');
await expect(badge).toBeVisible();
// Badge should have one of the status classes
const hasStatusClass = await badge.evaluate((el) =>
el.classList.contains('status-badge--reachable') ||
el.classList.contains('status-badge--unreachable') ||
el.classList.contains('status-badge--partial') ||
el.classList.contains('status-badge--unknown')
);
expect(hasStatusClass).toBe(true);
});
test('should display confidence percentage', async ({ page }) => {
const confidence = page.locator('.confidence-value');
await expect(confidence).toBeVisible();
// Should match percentage format (e.g., "85%")
await expect(confidence).toHaveText(/%$/);
});
test('should have View Full Graph button', async ({ page }) => {
const viewBtn = page.locator('.view-graph-btn');
await expect(viewBtn).toBeVisible();
await expect(viewBtn).toContainText('View Full Graph');
});
test('should emit event on View Full Graph click', async ({ page }) => {
// Set up navigation listener
const navigationPromise = page.waitForURL(/\/reachgraph\//);
await page.click('.view-graph-btn');
// Should navigate to full graph view
await navigationPromise;
});
test('should display analysis method info', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'reachable',
confidence: 0.85,
analysisMethod: 'static',
analysisTimestamp: '2026-01-10T12:00:00Z',
paths: [],
entryPoints: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const analysisInfo = page.locator('.analysis-info');
await expect(analysisInfo).toBeVisible();
await expect(analysisInfo).toContainText('Static Analysis');
});
test('should display entry points when available', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'reachable',
confidence: 0.75,
analysisMethod: 'hybrid',
entryPoints: ['main()', 'handleRequest()', 'processInput()'],
paths: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const entryPoints = page.locator('.entry-points');
await expect(entryPoints).toBeVisible();
const entryTags = page.locator('.entry-tag');
await expect(entryTags).toHaveCount(3);
});
test('should truncate entry points when more than 5', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'reachable',
confidence: 0.75,
entryPoints: ['a()', 'b()', 'c()', 'd()', 'e()', 'f()', 'g()'],
paths: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const entryTags = page.locator('.entry-tag');
await expect(entryTags).toHaveCount(5);
const moreIndicator = page.locator('.entry-more');
await expect(moreIndicator).toBeVisible();
await expect(moreIndicator).toContainText('+2 more');
});
test('should display path count when paths exist', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'reachable',
confidence: 0.90,
paths: [
{ nodes: ['a', 'b', 'vulnerable'] },
{ nodes: ['x', 'y', 'vulnerable'] },
],
entryPoints: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const pathCount = page.locator('.path-count');
await expect(pathCount).toBeVisible();
await expect(pathCount).toContainText('2 path(s) found');
});
test('should show empty state when no data', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify(null),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const emptyState = page.locator('.empty-state');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText('No reachability data available');
});
test('should display correct badge color for reachable status', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'reachable',
confidence: 0.95,
paths: [],
entryPoints: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const badge = page.locator('.status-badge');
await expect(badge).toHaveClass(/status-badge--reachable/);
await expect(badge).toContainText('Reachable');
});
test('should display correct badge color for unreachable status', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'unreachable',
confidence: 0.98,
paths: [],
entryPoints: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const badge = page.locator('.status-badge');
await expect(badge).toHaveClass(/status-badge--unreachable/);
await expect(badge).toContainText('Unreachable');
});
});
test.describe('Copy JSON functionality', () => {
test('should have copy JSON button in provenance tab', async ({ page }) => {
const copyBtn = page.locator('.copy-json-btn');

View File

@@ -0,0 +1,276 @@
/**
* @file playbook-suggestions.e2e.spec.ts
* @sprint SPRINT_20260107_006_005_FE (OM-FE-006)
* @description E2E tests for OpsMemory playbook suggestions in decision drawer.
*/
import { test, expect } from '@playwright/test';
test.describe('Playbook Suggestions in Decision Drawer', () => {
test.beforeEach(async ({ page }) => {
// Mock the OpsMemory API
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
suggestions: [
{
suggestedAction: 'accept_risk',
confidence: 0.85,
rationale: 'Similar situations resolved successfully with risk acceptance',
evidenceCount: 5,
matchingFactors: ['severity', 'reachability', 'componentType'],
evidence: [
{
memoryId: 'mem-abc123',
cveId: 'CVE-2023-44487',
action: 'accept_risk',
outcome: 'success',
resolutionTime: 'PT4H',
similarity: 0.92,
},
{
memoryId: 'mem-def456',
cveId: 'CVE-2023-12345',
action: 'accept_risk',
outcome: 'success',
resolutionTime: 'PT2H',
similarity: 0.87,
},
],
},
{
suggestedAction: 'target_fix',
confidence: 0.65,
rationale: 'Some similar situations required fixes',
evidenceCount: 2,
matchingFactors: ['severity'],
evidence: [
{
memoryId: 'mem-ghi789',
cveId: 'CVE-2023-99999',
action: 'target_fix',
outcome: 'success',
resolutionTime: 'P1DT4H',
similarity: 0.70,
},
],
},
],
situationHash: 'abc123def456',
}),
});
});
// Navigate to triage page (mock or real)
await page.goto('/triage/findings/test-finding-123');
});
test('playbook panel appears in decision drawer', async ({ page }) => {
// Open the decision drawer
await page.click('[data-testid="open-decision-drawer"]');
// Check playbook panel is visible
const playbookPanel = page.locator('stellaops-playbook-suggestion');
await expect(playbookPanel).toBeVisible();
// Check header text
await expect(playbookPanel.locator('.playbook-panel__title')).toContainText(
'Past Decisions'
);
});
test('shows suggestions with confidence badges', async ({ page }) => {
await page.click('[data-testid="open-decision-drawer"]');
// Wait for suggestions to load
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Check first suggestion
const firstSuggestion = page.locator('.playbook-suggestion').first();
await expect(firstSuggestion).toBeVisible();
// Check action badge
await expect(
firstSuggestion.locator('.playbook-suggestion__action')
).toContainText('Accept Risk');
// Check confidence
await expect(
firstSuggestion.locator('.playbook-suggestion__confidence')
).toContainText('85%');
});
test('clicking "Use This Approach" pre-fills decision form', async ({
page,
}) => {
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Click "Use This Approach" on first suggestion
await page.click('.playbook-btn--use');
// Verify form was pre-filled
const statusRadio = page.locator('input[name="status"][value="not_affected"]');
await expect(statusRadio).toBeChecked();
// Check reason notes contain suggestion context
const reasonText = page.locator('.reason-text');
await expect(reasonText).toContainText('similar past decisions');
});
test('expanding evidence details shows past decisions', async ({ page }) => {
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Click "Show Details" on first suggestion
await page.click('.playbook-btn--expand');
// Check evidence cards are visible
const evidenceCards = page.locator('stellaops-evidence-card');
await expect(evidenceCards).toHaveCount(2);
// Check evidence content
const firstCard = evidenceCards.first();
await expect(firstCard.locator('.evidence-card__cve')).toContainText(
'CVE-2023-44487'
);
await expect(firstCard.locator('.evidence-card__similarity')).toContainText(
'92%'
);
});
test('shows empty state when no suggestions', async ({ page }) => {
// Override route to return empty
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
suggestions: [],
situationHash: 'empty123',
}),
});
});
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Check empty state message
await expect(page.locator('.playbook-empty')).toContainText(
'No similar past decisions'
);
});
test('handles API errors gracefully', async ({ page }) => {
// Override route to return error
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Internal server error' }),
});
});
await page.click('[data-testid="open-decision-drawer"]');
// Wait for error state
await expect(page.locator('.playbook-error')).toBeVisible();
// Check retry button
const retryBtn = page.locator('.playbook-error__retry');
await expect(retryBtn).toBeVisible();
});
test('retry button refetches suggestions', async ({ page }) => {
let callCount = 0;
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
callCount++;
if (callCount === 1) {
await route.fulfill({ status: 500 });
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
suggestions: [
{
suggestedAction: 'accept_risk',
confidence: 0.85,
rationale: 'Test',
evidenceCount: 1,
matchingFactors: [],
evidence: [],
},
],
situationHash: 'retry123',
}),
});
}
});
await page.click('[data-testid="open-decision-drawer"]');
// Wait for error state
await expect(page.locator('.playbook-error')).toBeVisible();
// Click retry
await page.click('.playbook-error__retry');
// Should now show suggestions
await expect(page.locator('.playbook-suggestion')).toBeVisible();
});
test('keyboard navigation works', async ({ page }) => {
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Focus on playbook panel header
await page.focus('.playbook-panel__header');
// Press Enter to toggle (if collapsed)
await page.keyboard.press('Enter');
// Tab to first suggestion's "Use This Approach" button
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Press Enter to use suggestion
await page.keyboard.press('Enter');
// Verify form was pre-filled
const statusRadio = page.locator('input[name="status"][value="not_affected"]');
await expect(statusRadio).toBeChecked();
});
test('panel can be collapsed and expanded', async ({ page }) => {
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Panel content should be visible
await expect(page.locator('.playbook-panel__content')).toBeVisible();
// Click header to collapse
await page.click('.playbook-panel__header');
// Content should be hidden
await expect(page.locator('.playbook-panel__content')).not.toBeVisible();
// Click again to expand
await page.click('.playbook-panel__header');
// Content should be visible again
await expect(page.locator('.playbook-panel__content')).toBeVisible();
});
test('matching factors are displayed', async ({ page }) => {
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
const factors = page.locator('.playbook-suggestion__factor');
await expect(factors).toHaveCount(3);
await expect(factors.first()).toContainText('severity');
});
});

View File

@@ -0,0 +1,610 @@
// -----------------------------------------------------------------------------
// sbom-evidence.e2e.spec.ts
// Sprint: SPRINT_20260107_005_004_FE
// Task: UI-012 — E2E Tests for CycloneDX evidence and pedigree UI components
// -----------------------------------------------------------------------------
import { test, expect } from '@playwright/test';
/**
* E2E tests for SBOM Evidence and Pedigree UI components.
*
* Tests cover:
* - Evidence panel interaction
* - Pedigree timeline click-through
* - Diff viewer expand/collapse
* - Keyboard navigation
*
* Test data: Uses mock API responses intercepted via Playwright's route handler.
*/
test.describe('SBOM Evidence Components E2E', () => {
// Mock data for tests
const mockEvidence = {
identity: {
field: 'purl',
confidence: 0.95,
methods: [
{
technique: 'manifest-analysis',
confidence: 0.95,
value: 'package.json:42',
},
{
technique: 'hash-comparison',
confidence: 0.90,
value: 'sha256:abc123...',
},
],
},
occurrences: [
{ location: '/node_modules/lodash/index.js', line: 1 },
{ location: '/node_modules/lodash/lodash.min.js' },
{ location: '/node_modules/lodash/package.json', line: 42 },
],
licenses: [
{ license: { id: 'MIT' }, acknowledgement: 'declared' },
],
copyright: [
{ text: 'Copyright (c) JS Foundation and contributors' },
],
};
const mockPedigree = {
ancestors: [
{
type: 'library',
name: 'openssl',
version: '1.1.1n',
purl: 'pkg:generic/openssl@1.1.1n',
},
],
variants: [
{
type: 'library',
name: 'openssl',
version: '1.1.1n-0+deb11u5',
purl: 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5',
},
],
commits: [
{
uid: 'abc123def456789',
url: 'https://github.com/openssl/openssl/commit/abc123def456789',
author: {
name: 'John Doe',
email: 'john@example.com',
timestamp: '2024-01-15T10:30:00Z',
},
message: 'Fix buffer overflow in SSL handshake\n\nThis commit addresses CVE-2024-1234.',
},
],
patches: [
{
type: 'backport',
diff: {
url: 'https://github.com/openssl/openssl/commit/abc123.patch',
text: '--- a/ssl/ssl_lib.c\n+++ b/ssl/ssl_lib.c\n@@ -100,7 +100,7 @@\n- buffer[size] = data;\n+ if (size < MAX_SIZE) buffer[size] = data;',
},
resolves: [
{ id: 'CVE-2024-1234', type: 'security', name: 'Buffer overflow vulnerability' },
],
},
{
type: 'cherry-pick',
resolves: [
{ id: 'CVE-2024-5678', type: 'security' },
],
},
],
};
test.beforeEach(async ({ page }) => {
// Intercept API calls and return mock data
await page.route('**/api/sbom/evidence/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockEvidence),
});
});
await page.route('**/api/sbom/pedigree/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockPedigree),
});
});
// Navigate to component detail page
await page.goto('/sbom/components/pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
await page.waitForSelector('.component-detail-page');
});
// -------------------------------------------------------------------------
// Evidence Panel Tests
// -------------------------------------------------------------------------
test.describe('Evidence Panel', () => {
test('should display evidence panel with identity section', async ({ page }) => {
const panel = page.locator('.cdx-evidence-panel');
await expect(panel).toBeVisible();
const identitySection = panel.locator('.evidence-section--identity');
await expect(identitySection).toBeVisible();
});
test('should display confidence badge with correct tier', async ({ page }) => {
const badge = page.locator('.evidence-confidence-badge').first();
await expect(badge).toBeVisible();
// Should show green for 95% confidence (Tier 1)
await expect(badge).toHaveClass(/tier-1/);
});
test('should display occurrence count', async ({ page }) => {
const occurrenceHeader = page.locator('.evidence-section__title').filter({ hasText: 'Occurrences' });
await expect(occurrenceHeader).toContainText('(3)');
});
test('should list all occurrences', async ({ page }) => {
const occurrences = page.locator('.occurrence-item');
await expect(occurrences).toHaveCount(3);
});
test('should display license information', async ({ page }) => {
const licenseSection = page.locator('.evidence-section--licenses');
await expect(licenseSection).toBeVisible();
await expect(licenseSection).toContainText('MIT');
});
test('should display copyright information', async ({ page }) => {
const copyrightSection = page.locator('.evidence-section--copyright');
await expect(copyrightSection).toContainText('JS Foundation');
});
test('should collapse/expand sections on click', async ({ page }) => {
// Find a collapsible section header
const identityHeader = page.locator('.evidence-section__header').first();
const identityContent = page.locator('.evidence-section__content').first();
// Should be expanded by default
await expect(identityContent).toBeVisible();
// Click to collapse
await identityHeader.click();
await expect(identityContent).not.toBeVisible();
// Click to expand
await identityHeader.click();
await expect(identityContent).toBeVisible();
});
test('should open evidence drawer on occurrence click', async ({ page }) => {
const occurrence = page.locator('.occurrence-item').first();
await occurrence.click();
const drawer = page.locator('.evidence-detail-drawer');
await expect(drawer).toBeVisible();
});
});
// -------------------------------------------------------------------------
// Evidence Detail Drawer Tests
// -------------------------------------------------------------------------
test.describe('Evidence Detail Drawer', () => {
test.beforeEach(async ({ page }) => {
// Open the drawer by clicking an occurrence
await page.locator('.occurrence-item').first().click();
await page.waitForSelector('.evidence-detail-drawer');
});
test('should display detection method chain', async ({ page }) => {
const methodChain = page.locator('.method-chain');
await expect(methodChain).toBeVisible();
const methods = page.locator('.method-chain__item');
await expect(methods).toHaveCount(2);
});
test('should display technique labels correctly', async ({ page }) => {
const techniques = page.locator('.method-chain__technique');
await expect(techniques.first()).toContainText('Manifest Analysis');
});
test('should close on escape key', async ({ page }) => {
const drawer = page.locator('.evidence-detail-drawer');
await expect(drawer).toBeVisible();
await page.keyboard.press('Escape');
await expect(drawer).not.toBeVisible();
});
test('should close on backdrop click', async ({ page }) => {
const overlay = page.locator('.drawer-overlay');
await overlay.click({ position: { x: 10, y: 10 } }); // Click on overlay, not drawer
await expect(page.locator('.evidence-detail-drawer')).not.toBeVisible();
});
test('should copy evidence JSON to clipboard', async ({ page }) => {
const copyBtn = page.locator('.reference-card .copy-btn');
await copyBtn.click();
await expect(copyBtn).toContainText('Copied!');
// Verify clipboard content
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboardText).toContain('"field": "purl"');
});
});
// -------------------------------------------------------------------------
// Pedigree Timeline Tests
// -------------------------------------------------------------------------
test.describe('Pedigree Timeline', () => {
test('should display pedigree timeline', async ({ page }) => {
const timeline = page.locator('.pedigree-timeline');
await expect(timeline).toBeVisible();
});
test('should show all timeline nodes', async ({ page }) => {
const nodes = page.locator('.timeline-node');
await expect(nodes).toHaveCount(3); // ancestor + variant + current
});
test('should display stage labels', async ({ page }) => {
const stages = page.locator('.timeline-stage');
await expect(stages.filter({ hasText: 'Upstream' })).toBeVisible();
await expect(stages.filter({ hasText: 'Distro' })).toBeVisible();
await expect(stages.filter({ hasText: 'Local' })).toBeVisible();
});
test('should highlight current node', async ({ page }) => {
const currentNode = page.locator('.timeline-node--current');
await expect(currentNode).toBeVisible();
await expect(currentNode).toHaveClass(/highlighted/);
});
test('should show version differences', async ({ page }) => {
const ancestorVersion = page.locator('.timeline-node--ancestor .timeline-node__version');
const variantVersion = page.locator('.timeline-node--variant .timeline-node__version');
await expect(ancestorVersion).toContainText('1.1.1n');
await expect(variantVersion).toContainText('1.1.1n-0+deb11u5');
});
test('should emit event on node click', async ({ page }) => {
const ancestorNode = page.locator('.timeline-node--ancestor');
await ancestorNode.click();
// Should show some detail or navigation
// (implementation-specific behavior)
await expect(ancestorNode).toHaveClass(/selected/);
});
});
// -------------------------------------------------------------------------
// Patch List Tests
// -------------------------------------------------------------------------
test.describe('Patch List', () => {
test('should display patch count in header', async ({ page }) => {
const header = page.locator('.patch-list__title');
await expect(header).toContainText('Patches Applied (2)');
});
test('should show patch type badges', async ({ page }) => {
const backportBadge = page.locator('.patch-badge--backport');
const cherryPickBadge = page.locator('.patch-badge--cherry-pick');
await expect(backportBadge).toBeVisible();
await expect(cherryPickBadge).toBeVisible();
});
test('should display CVE tags', async ({ page }) => {
const cveTags = page.locator('.cve-tag');
await expect(cveTags.filter({ hasText: 'CVE-2024-1234' })).toBeVisible();
});
test('should show confidence badges', async ({ page }) => {
const badges = page.locator('.patch-item .evidence-confidence-badge');
await expect(badges).toHaveCount(2);
});
test('should expand patch details on click', async ({ page }) => {
const expandBtn = page.locator('.patch-expand-btn').first();
await expandBtn.click();
const details = page.locator('.patch-item__details').first();
await expect(details).toBeVisible();
});
test('should show resolved issues when expanded', async ({ page }) => {
const expandBtn = page.locator('.patch-expand-btn').first();
await expandBtn.click();
const resolvedList = page.locator('.resolved-list').first();
await expect(resolvedList).toBeVisible();
await expect(resolvedList).toContainText('CVE-2024-1234');
});
});
// -------------------------------------------------------------------------
// Diff Viewer Tests
// -------------------------------------------------------------------------
test.describe('Diff Viewer', () => {
test.beforeEach(async ({ page }) => {
// Open diff viewer by clicking View Diff button
const viewDiffBtn = page.locator('.patch-action-btn').first();
await viewDiffBtn.click();
await page.waitForSelector('.diff-viewer');
});
test('should display diff viewer modal', async ({ page }) => {
const viewer = page.locator('.diff-viewer');
await expect(viewer).toBeVisible();
});
test('should show unified view by default', async ({ page }) => {
const unifiedBtn = page.locator('.view-mode-btn').filter({ hasText: 'Unified' });
await expect(unifiedBtn).toHaveClass(/active/);
});
test('should switch to side-by-side view', async ({ page }) => {
const sideBySideBtn = page.locator('.view-mode-btn').filter({ hasText: 'Side-by-Side' });
await sideBySideBtn.click();
await expect(sideBySideBtn).toHaveClass(/active/);
const sideBySideContainer = page.locator('.diff-side-by-side');
await expect(sideBySideContainer).toBeVisible();
});
test('should display line numbers', async ({ page }) => {
const lineNumbers = page.locator('.line-number');
await expect(lineNumbers.first()).toBeVisible();
});
test('should highlight additions in green', async ({ page }) => {
const additions = page.locator('.diff-line--addition');
await expect(additions).toBeVisible();
});
test('should highlight deletions in red', async ({ page }) => {
const deletions = page.locator('.diff-line--deletion');
await expect(deletions).toBeVisible();
});
test('should copy diff on button click', async ({ page }) => {
const copyBtn = page.locator('.copy-diff-btn');
await copyBtn.click();
await expect(copyBtn).toContainText('Copied!');
});
test('should close diff viewer on close button', async ({ page }) => {
const closeBtn = page.locator('.diff-viewer .close-btn');
await closeBtn.click();
await expect(page.locator('.diff-viewer')).not.toBeVisible();
});
test('should close diff viewer on escape key', async ({ page }) => {
await page.keyboard.press('Escape');
await expect(page.locator('.diff-viewer')).not.toBeVisible();
});
});
// -------------------------------------------------------------------------
// Commit Info Tests
// -------------------------------------------------------------------------
test.describe('Commit Info', () => {
test('should display commit section', async ({ page }) => {
const commitSection = page.locator('.commits-list');
await expect(commitSection).toBeVisible();
});
test('should show short SHA', async ({ page }) => {
const sha = page.locator('.commit-sha__value');
await expect(sha).toContainText('abc123d'); // First 7 chars
});
test('should copy full SHA on click', async ({ page }) => {
const copyBtn = page.locator('.commit-sha__copy');
await copyBtn.click();
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboardText).toBe('abc123def456789');
});
test('should link to upstream repository', async ({ page }) => {
const link = page.locator('.commit-sha__link');
await expect(link).toHaveAttribute('href', 'https://github.com/openssl/openssl/commit/abc123def456789');
});
test('should display author information', async ({ page }) => {
const author = page.locator('.commit-identity__name');
await expect(author.first()).toContainText('John Doe');
});
test('should expand truncated commit message', async ({ page }) => {
// If message is truncated, there should be an expand button
const messageContainer = page.locator('.commit-message');
const expandBtn = messageContainer.locator('.expand-btn');
if (await expandBtn.isVisible()) {
const truncatedMessage = await messageContainer.locator('.message-content').textContent();
await expandBtn.click();
const fullMessage = await messageContainer.locator('.message-content').textContent();
expect(fullMessage?.length).toBeGreaterThan(truncatedMessage?.length ?? 0);
}
});
});
// -------------------------------------------------------------------------
// Keyboard Navigation Tests
// -------------------------------------------------------------------------
test.describe('Keyboard Navigation', () => {
test('should navigate evidence sections with Tab', async ({ page }) => {
// Focus the first focusable element in evidence panel
await page.locator('.cdx-evidence-panel').locator('button').first().focus();
// Tab through sections
await page.keyboard.press('Tab');
const focusedElement = page.locator(':focus');
await expect(focusedElement).toBeVisible();
});
test('should navigate timeline nodes with arrow keys', async ({ page }) => {
// Focus the timeline
await page.locator('.timeline-node').first().focus();
// Arrow right to next node
await page.keyboard.press('ArrowRight');
const focused = page.locator('.timeline-node:focus');
await expect(focused).toHaveClass(/variant|current/);
});
test('should expand/collapse patch with Enter', async ({ page }) => {
const expandBtn = page.locator('.patch-expand-btn').first();
await expandBtn.focus();
await page.keyboard.press('Enter');
const details = page.locator('.patch-item__details').first();
await expect(details).toBeVisible();
await page.keyboard.press('Enter');
await expect(details).not.toBeVisible();
});
test('should support screen reader announcements', async ({ page }) => {
// Verify ARIA attributes are present
const panel = page.locator('.cdx-evidence-panel');
await expect(panel).toHaveAttribute('aria-label', /Evidence/);
const timeline = page.locator('.pedigree-timeline');
await expect(timeline).toHaveAttribute('aria-label', /Pedigree/);
});
});
// -------------------------------------------------------------------------
// Empty State Tests
// -------------------------------------------------------------------------
test.describe('Empty States', () => {
test('should show empty state when no evidence', async ({ page }) => {
// Override route to return empty evidence
await page.route('**/api/sbom/evidence/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
await page.goto('/sbom/components/pkg:npm/empty-pkg@1.0.0');
await page.waitForSelector('.component-detail-page');
const emptyState = page.locator('.empty-state');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText('No Evidence Data');
});
test('should show empty state when no pedigree', async ({ page }) => {
await page.route('**/api/sbom/pedigree/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
await page.goto('/sbom/components/pkg:npm/no-pedigree@1.0.0');
await page.waitForSelector('.component-detail-page');
// Should not show pedigree timeline
const timeline = page.locator('.pedigree-timeline');
await expect(timeline).not.toBeVisible();
});
test('should show empty patch list message', async ({ page }) => {
await page.route('**/api/sbom/pedigree/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ patches: [] }),
});
});
await page.goto('/sbom/components/pkg:npm/no-patches@1.0.0');
await page.waitForSelector('.component-detail-page');
const emptyPatchMsg = page.locator('.patch-list__empty');
await expect(emptyPatchMsg).toBeVisible();
});
});
// -------------------------------------------------------------------------
// Error Handling Tests
// -------------------------------------------------------------------------
test.describe('Error Handling', () => {
test('should display error state on API failure', async ({ page }) => {
await page.route('**/api/sbom/evidence/**', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
});
});
await page.goto('/sbom/components/pkg:npm/error-pkg@1.0.0');
await page.waitForSelector('.component-detail-page');
const errorState = page.locator('.error-state');
await expect(errorState).toBeVisible();
});
test('should provide retry button on error', async ({ page }) => {
let callCount = 0;
await page.route('**/api/sbom/evidence/**', async (route) => {
callCount++;
if (callCount === 1) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
});
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockEvidence),
});
}
});
await page.goto('/sbom/components/pkg:npm/retry-pkg@1.0.0');
await page.waitForSelector('.error-state');
const retryBtn = page.locator('.retry-btn');
await retryBtn.click();
// Should now show evidence panel
await expect(page.locator('.cdx-evidence-panel')).toBeVisible();
});
test('should handle network timeout gracefully', async ({ page }) => {
await page.route('**/api/sbom/evidence/**', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 10000)); // Simulate timeout
await route.abort('timedout');
});
// Set shorter timeout for the page
await page.goto('/sbom/components/pkg:npm/timeout-pkg@1.0.0', { timeout: 5000 }).catch(() => {
// Expected to timeout
});
// Should show loading or error state
const loadingOrError = page.locator('.loading-state, .error-state');
await expect(loadingOrError).toBeVisible();
});
});
});

View File

@@ -102,6 +102,18 @@ import {
ExceptionEventsHttpClient,
MockExceptionEventsApiService,
} from './core/api/exception-events.client';
import {
EVIDENCE_PACK_API,
EVIDENCE_PACK_API_BASE_URL,
EvidencePackHttpClient,
MockEvidencePackClient,
} from './core/api/evidence-pack.client';
import {
AI_RUNS_API,
AI_RUNS_API_BASE_URL,
AiRunsHttpClient,
MockAiRunsClient,
} from './core/api/ai-runs.client';
export const appConfig: ApplicationConfig = {
providers: [
@@ -450,6 +462,48 @@ export const appConfig: ApplicationConfig = {
useFactory: (config: AppConfigService, http: ExceptionApiHttpClient, mock: MockExceptionApiService) =>
config.config.quickstartMode ? mock : http,
},
{
provide: EVIDENCE_PACK_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/v1/evidence-packs', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/v1/evidence-packs`;
}
},
},
EvidencePackHttpClient,
MockEvidencePackClient,
{
provide: EVIDENCE_PACK_API,
deps: [AppConfigService, EvidencePackHttpClient, MockEvidencePackClient],
useFactory: (config: AppConfigService, http: EvidencePackHttpClient, mock: MockEvidencePackClient) =>
config.config.quickstartMode ? mock : http,
},
{
provide: AI_RUNS_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/v1/runs', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/v1/runs`;
}
},
},
AiRunsHttpClient,
MockAiRunsClient,
{
provide: AI_RUNS_API,
deps: [AppConfigService, AiRunsHttpClient, MockAiRunsClient],
useFactory: (config: AppConfigService, http: AiRunsHttpClient, mock: MockAiRunsClient) =>
config.config.quickstartMode ? mock : http,
},
{
provide: CONSOLE_API_BASE_URL,
deps: [AppConfigService],

View File

@@ -464,6 +464,40 @@ export const routes: Routes = [
(m) => m.PatchMapComponent
),
},
// Evidence Packs (SPRINT_20260109_011_005)
{
path: 'evidence-packs',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/evidence-pack/evidence-pack-list.component').then(
(m) => m.EvidencePackListComponent
),
},
{
path: 'evidence-packs/:packId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/evidence-pack/evidence-pack-viewer.component').then(
(m) => m.EvidencePackViewerComponent
),
},
// AI Runs (SPRINT_20260109_011_003)
{
path: 'ai-runs',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/ai-runs/ai-runs-list.component').then(
(m) => m.AiRunsListComponent
),
},
{
path: 'ai-runs/:runId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/ai-runs/ai-run-viewer.component').then(
(m) => m.AiRunViewerComponent
),
},
// Fallback for unknown routes
{
path: '**',

View File

@@ -0,0 +1,524 @@
/**
* AI Runs API client.
* Sprint: SPRINT_20260109_011_003 Task: Frontend Unblock
*
* Provides access to AI Run endpoints for creating, managing,
* and auditing AI-assisted conversations and decisions.
*/
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, InjectionToken, inject } from '@angular/core';
import { Observable, of, delay, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { generateTraceId } from './trace.util';
import {
AiRun,
AiRunSummary,
AiRunStatus,
AiRunListResponse,
AiRunQuery,
AiRunQueryOptions,
CreateAiRunRequest,
AddTurnRequest,
ProposeActionRequest,
ApprovalDecision,
RunEvent,
RunArtifact,
RunAttestation,
} from './ai-runs.models';
// ========== API Interface ==========
export interface AiRunsApi {
/** Creates a new AI Run */
create(request: CreateAiRunRequest, options?: AiRunQueryOptions): Observable<AiRun>;
/** Gets an AI Run by ID */
get(runId: string, options?: AiRunQueryOptions): Observable<AiRun>;
/** Lists AI Runs with optional filters */
list(query?: AiRunQuery, options?: AiRunQueryOptions): Observable<AiRunListResponse>;
/** Gets the timeline for a run */
getTimeline(runId: string, options?: AiRunQueryOptions): Observable<RunEvent[]>;
/** Gets artifacts for a run */
getArtifacts(runId: string, options?: AiRunQueryOptions): Observable<RunArtifact[]>;
/** Adds a turn to a run */
addTurn(runId: string, request: AddTurnRequest, options?: AiRunQueryOptions): Observable<RunEvent>;
/** Proposes an action within a run */
proposeAction(runId: string, request: ProposeActionRequest, options?: AiRunQueryOptions): Observable<RunEvent>;
/** Approves or denies a pending action */
submitApproval(runId: string, actionId: string, decision: ApprovalDecision, options?: AiRunQueryOptions): Observable<RunEvent>;
/** Completes a run */
complete(runId: string, options?: AiRunQueryOptions): Observable<AiRun>;
/** Creates an attestation for the run */
createAttestation(runId: string, options?: AiRunQueryOptions): Observable<RunAttestation>;
/** Cancels a run */
cancel(runId: string, reason?: string, options?: AiRunQueryOptions): Observable<AiRun>;
}
// ========== DI Tokens ==========
export const AI_RUNS_API = new InjectionToken<AiRunsApi>('AI_RUNS_API');
export const AI_RUNS_API_BASE_URL = new InjectionToken<string>('AI_RUNS_API_BASE_URL');
// ========== HTTP Implementation ==========
@Injectable({ providedIn: 'root' })
export class AiRunsHttpClient implements AiRunsApi {
private readonly http = inject(HttpClient);
private readonly authSession = inject(AuthSessionStore);
private readonly baseUrl = inject(AI_RUNS_API_BASE_URL, { optional: true }) ?? '/v1/advisory-ai/runs';
create(request: CreateAiRunRequest, options: AiRunQueryOptions = {}): Observable<AiRun> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.post<AiRun>(this.baseUrl, request, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
get(runId: string, options: AiRunQueryOptions = {}): Observable<AiRun> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.get<AiRun>(`${this.baseUrl}/${runId}`, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
list(query: AiRunQuery = {}, options: AiRunQueryOptions = {}): Observable<AiRunListResponse> {
const traceId = options.traceId ?? generateTraceId();
const params = this.buildQueryParams(query);
return this.http
.get<AiRunListResponse>(this.baseUrl, { headers: this.buildHeaders(traceId), params })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
getTimeline(runId: string, options: AiRunQueryOptions = {}): Observable<RunEvent[]> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.get<RunEvent[]>(`${this.baseUrl}/${runId}/timeline`, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
getArtifacts(runId: string, options: AiRunQueryOptions = {}): Observable<RunArtifact[]> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.get<RunArtifact[]>(`${this.baseUrl}/${runId}/artifacts`, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
addTurn(runId: string, request: AddTurnRequest, options: AiRunQueryOptions = {}): Observable<RunEvent> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.post<RunEvent>(`${this.baseUrl}/${runId}/turns`, request, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
proposeAction(runId: string, request: ProposeActionRequest, options: AiRunQueryOptions = {}): Observable<RunEvent> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.post<RunEvent>(`${this.baseUrl}/${runId}/actions`, request, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
submitApproval(runId: string, actionId: string, decision: ApprovalDecision, options: AiRunQueryOptions = {}): Observable<RunEvent> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.post<RunEvent>(`${this.baseUrl}/${runId}/actions/${actionId}/approval`, decision, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
complete(runId: string, options: AiRunQueryOptions = {}): Observable<AiRun> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.post<AiRun>(`${this.baseUrl}/${runId}/complete`, {}, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
createAttestation(runId: string, options: AiRunQueryOptions = {}): Observable<RunAttestation> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.post<RunAttestation>(`${this.baseUrl}/${runId}/attestation`, {}, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
cancel(runId: string, reason?: string, options: AiRunQueryOptions = {}): Observable<AiRun> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.post<AiRun>(`${this.baseUrl}/${runId}/cancel`, { reason }, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
private buildHeaders(traceId: string): HttpHeaders {
const tenant = this.authSession.getActiveTenantId() || '';
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
Accept: 'application/json',
});
}
private buildQueryParams(query: AiRunQuery): Record<string, string> {
const params: Record<string, string> = {};
if (query.status) params['status'] = query.status;
if (query.userId) params['userId'] = query.userId;
if (query.conversationId) params['conversationId'] = query.conversationId;
if (query.fromDate) params['fromDate'] = query.fromDate;
if (query.toDate) params['toDate'] = query.toDate;
if (query.limit) params['limit'] = query.limit.toString();
if (query.offset) params['offset'] = query.offset.toString();
return params;
}
private mapError(err: unknown, traceId: string): Error {
return err instanceof Error
? new Error(`[${traceId}] AI Runs error: ${err.message}`)
: new Error(`[${traceId}] AI Runs error: Unknown error`);
}
}
// ========== Mock Implementation ==========
@Injectable({ providedIn: 'root' })
export class MockAiRunsClient implements AiRunsApi {
private runs: Map<string, AiRun> = new Map();
private eventCounter = 0;
constructor() {
this.initializeSampleData();
}
create(request: CreateAiRunRequest): Observable<AiRun> {
const runId = `run-${Date.now().toString(36)}`;
const now = new Date().toISOString();
const run: AiRun = {
runId,
tenantId: 'mock-tenant',
userId: 'user:alice@example.com',
conversationId: request.conversationId,
status: 'created',
createdAt: now,
updatedAt: now,
timeline: [this.createEvent('created', { kind: 'generic', description: 'Run created' })],
artifacts: [],
metadata: request.metadata,
};
this.runs.set(runId, run);
return of(run).pipe(delay(200));
}
get(runId: string): Observable<AiRun> {
const run = this.runs.get(runId);
if (!run) {
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
}
return of(run).pipe(delay(100));
}
list(query: AiRunQuery = {}): Observable<AiRunListResponse> {
let runs = Array.from(this.runs.values());
if (query.status) {
runs = runs.filter((r) => r.status === query.status);
}
if (query.userId) {
runs = runs.filter((r) => r.userId === query.userId);
}
if (query.conversationId) {
runs = runs.filter((r) => r.conversationId === query.conversationId);
}
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
const sliced = runs.slice(offset, offset + limit);
return of({
count: sliced.length,
runs: sliced.map((r) => this.toSummary(r)),
}).pipe(delay(150));
}
getTimeline(runId: string): Observable<RunEvent[]> {
const run = this.runs.get(runId);
if (!run) {
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
}
return of(run.timeline).pipe(delay(100));
}
getArtifacts(runId: string): Observable<RunArtifact[]> {
const run = this.runs.get(runId);
if (!run) {
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
}
return of(run.artifacts).pipe(delay(100));
}
addTurn(runId: string, request: AddTurnRequest): Observable<RunEvent> {
const run = this.runs.get(runId);
if (!run) {
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
}
const eventType = request.role === 'user' ? 'user_turn' : 'assistant_turn';
const event = this.createEvent(eventType, {
kind: request.role === 'user' ? 'user_turn' : 'assistant_turn',
turnId: `turn-${this.eventCounter}`,
message: request.content,
groundingScore: request.groundingScore,
citations: request.citations,
} as any);
run.timeline.push(event);
run.status = 'active';
run.updatedAt = new Date().toISOString();
return of(event).pipe(delay(200));
}
proposeAction(runId: string, request: ProposeActionRequest): Observable<RunEvent> {
const run = this.runs.get(runId);
if (!run) {
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
}
const event = this.createEvent('action_proposed', {
kind: 'action',
actionId: `action-${this.eventCounter}`,
actionType: request.actionType,
targetResource: request.targetResource,
description: request.description,
requiresApproval: true,
});
run.timeline.push(event);
run.status = 'pending_approval';
run.updatedAt = new Date().toISOString();
return of(event).pipe(delay(200));
}
submitApproval(runId: string, actionId: string, decision: ApprovalDecision): Observable<RunEvent> {
const run = this.runs.get(runId);
if (!run) {
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
}
const eventType = decision.decision === 'approved' ? 'approval_granted' : 'approval_denied';
const event = this.createEvent(eventType, {
kind: 'approval',
actionId,
decision: decision.decision,
approver: 'user:alice@example.com',
reason: decision.reason,
});
run.timeline.push(event);
run.status = decision.decision === 'approved' ? 'approved' : 'rejected';
run.updatedAt = new Date().toISOString();
return of(event).pipe(delay(300));
}
complete(runId: string): Observable<AiRun> {
const run = this.runs.get(runId);
if (!run) {
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
}
const event = this.createEvent('completed', {
kind: 'generic',
description: 'Run completed successfully',
});
run.timeline.push(event);
run.status = 'complete';
run.completedAt = new Date().toISOString();
run.updatedAt = run.completedAt;
return of(run).pipe(delay(200));
}
createAttestation(runId: string): Observable<RunAttestation> {
const run = this.runs.get(runId);
if (!run) {
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
}
const attestation: RunAttestation = {
attestationId: `attest-${runId}`,
contentDigest: `sha256:mock-${runId}`,
signedAt: new Date().toISOString(),
signatureKeyId: 'mock-signing-key',
envelope: {
payloadType: 'application/vnd.stellaops.ai-run+json',
payloadDigest: `sha256:mock-${runId}`,
signatureCount: 1,
},
};
run.attestation = attestation;
const event = this.createEvent('attestation_created', {
kind: 'attestation',
attestationId: attestation.attestationId,
type: 'ai-run',
contentDigest: attestation.contentDigest,
signed: true,
});
run.timeline.push(event);
return of(attestation).pipe(delay(300));
}
cancel(runId: string, reason?: string): Observable<AiRun> {
const run = this.runs.get(runId);
if (!run) {
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
}
run.status = 'cancelled';
run.completedAt = new Date().toISOString();
run.updatedAt = run.completedAt;
return of(run).pipe(delay(200));
}
private createEvent(type: RunEvent['type'], content: RunEvent['content']): RunEvent {
this.eventCounter++;
return {
eventId: `event-${this.eventCounter.toString().padStart(4, '0')}`,
type,
timestamp: new Date().toISOString(),
content,
};
}
private toSummary(run: AiRun): AiRunSummary {
return {
runId: run.runId,
tenantId: run.tenantId,
userId: run.userId,
status: run.status,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
completedAt: run.completedAt,
eventCount: run.timeline.length,
artifactCount: run.artifacts.length,
hasAttestation: !!run.attestation,
metadata: run.metadata,
};
}
private initializeSampleData(): void {
const sampleRun: AiRun = {
runId: 'run-sample-001',
tenantId: 'mock-tenant',
userId: 'user:alice@example.com',
conversationId: 'conv-sample-001',
status: 'complete',
createdAt: '2026-01-10T09:00:00Z',
updatedAt: '2026-01-10T09:15:00Z',
completedAt: '2026-01-10T09:15:00Z',
timeline: [
{
eventId: 'event-0001',
type: 'created',
timestamp: '2026-01-10T09:00:00Z',
content: { kind: 'generic', description: 'Run created' },
},
{
eventId: 'event-0002',
type: 'user_turn',
timestamp: '2026-01-10T09:01:00Z',
content: {
kind: 'user_turn',
turnId: 'turn-001',
message: 'Is CVE-2023-44487 affecting our api-gateway service?',
},
},
{
eventId: 'event-0003',
type: 'assistant_turn',
timestamp: '2026-01-10T09:02:00Z',
content: {
kind: 'assistant_turn',
turnId: 'turn-002',
message: 'Based on my analysis, CVE-2023-44487 (HTTP/2 Rapid Reset) affects the api-gateway service. The vulnerable http2 library version 1.0.0 is present in the SBOM [sbom:scan-abc123] and reachability analysis confirms the vulnerable function is reachable [reach:api-gateway:grpc.Server].',
groundingScore: 0.92,
citations: [
{ type: 'sbom', path: 'scan-abc123', resolvedUri: 'stella://sbom/scan-abc123' },
{ type: 'reach', path: 'api-gateway:grpc.Server', resolvedUri: 'stella://reach/api-gateway:grpc.Server' },
],
},
},
{
eventId: 'event-0004',
type: 'grounding_validated',
timestamp: '2026-01-10T09:02:01Z',
content: {
kind: 'grounding',
turnId: 'turn-002',
score: 0.92,
isAcceptable: true,
validLinks: 2,
totalClaims: 2,
groundedClaims: 2,
},
},
{
eventId: 'event-0005',
type: 'evidence_pack_created',
timestamp: '2026-01-10T09:02:02Z',
content: {
kind: 'evidence_pack',
packId: 'pack-sample-001',
claimCount: 2,
evidenceCount: 3,
contentDigest: 'sha256:abc123',
},
},
{
eventId: 'event-0006',
type: 'completed',
timestamp: '2026-01-10T09:15:00Z',
content: { kind: 'generic', description: 'Run completed successfully' },
},
],
artifacts: [
{
artifactId: 'pack-sample-001',
type: 'EvidencePack',
name: 'Evidence Pack - CVE-2023-44487',
contentDigest: 'sha256:abc123',
uri: 'stella://evidence-pack/pack-sample-001',
createdAt: '2026-01-10T09:02:02Z',
},
],
attestation: {
attestationId: 'attest-run-sample-001',
contentDigest: 'sha256:run-sample-001',
signedAt: '2026-01-10T09:15:01Z',
signatureKeyId: 'ai-run-signing-key',
envelope: {
payloadType: 'application/vnd.stellaops.ai-run+json',
payloadDigest: 'sha256:run-sample-001',
signatureCount: 1,
},
},
};
this.runs.set(sampleRun.runId, sampleRun);
}
}

View File

@@ -0,0 +1,229 @@
/**
* AI Runs API models.
* Sprint: SPRINT_20260109_011_003 Task: Frontend Unblock
*
* AI Runs are immutable records of AI-assisted conversations and decisions.
* They provide audit trails, reproducibility, and governance capabilities.
*/
// ========== Run Status ==========
export type AiRunStatus =
| 'created'
| 'active'
| 'pending_approval'
| 'approved'
| 'rejected'
| 'complete'
| 'cancelled';
// ========== Timeline Events ==========
export type RunEventType =
| 'created'
| 'user_turn'
| 'assistant_turn'
| 'grounding_validated'
| 'evidence_pack_created'
| 'action_proposed'
| 'approval_requested'
| 'approval_granted'
| 'approval_denied'
| 'action_executed'
| 'attestation_created'
| 'completed';
export interface RunEvent {
eventId: string;
type: RunEventType;
timestamp: string;
content: RunEventContent;
metadata?: Record<string, string>;
}
export type RunEventContent =
| UserTurnContent
| AssistantTurnContent
| GroundingContent
| EvidencePackContent
| ActionContent
| ApprovalContent
| AttestationContent
| GenericEventContent;
export interface UserTurnContent {
kind: 'user_turn';
turnId: string;
message: string;
attachments?: string[];
}
export interface AssistantTurnContent {
kind: 'assistant_turn';
turnId: string;
message: string;
groundingScore?: number;
citations?: Citation[];
}
export interface Citation {
type: string;
path: string;
resolvedUri?: string;
}
export interface GroundingContent {
kind: 'grounding';
turnId: string;
score: number;
isAcceptable: boolean;
validLinks: number;
totalClaims: number;
groundedClaims: number;
}
export interface EvidencePackContent {
kind: 'evidence_pack';
packId: string;
claimCount: number;
evidenceCount: number;
contentDigest: string;
}
export interface ActionContent {
kind: 'action';
actionId: string;
actionType: string;
targetResource: string;
description: string;
requiresApproval: boolean;
}
export interface ApprovalContent {
kind: 'approval';
actionId: string;
decision: 'approved' | 'denied';
approver: string;
reason?: string;
policyGate?: string;
}
export interface AttestationContent {
kind: 'attestation';
attestationId: string;
type: string;
contentDigest: string;
signed: boolean;
}
export interface GenericEventContent {
kind: 'generic';
description: string;
data?: Record<string, unknown>;
}
// ========== Run Artifacts ==========
export type RunArtifactType = 'EvidencePack' | 'VexDecision' | 'PolicyAction' | 'Attestation' | 'Custom';
export interface RunArtifact {
artifactId: string;
type: RunArtifactType;
name: string;
contentDigest: string;
uri: string;
createdAt: string;
metadata?: Record<string, string>;
}
// ========== AI Run ==========
export interface AiRun {
runId: string;
tenantId: string;
userId: string;
conversationId?: string;
status: AiRunStatus;
createdAt: string;
updatedAt: string;
completedAt?: string;
timeline: RunEvent[];
artifacts: RunArtifact[];
attestation?: RunAttestation;
metadata?: Record<string, string>;
}
export interface RunAttestation {
attestationId: string;
contentDigest: string;
signedAt?: string;
signatureKeyId?: string;
envelope?: DsseEnvelopeRef;
}
export interface DsseEnvelopeRef {
payloadType: string;
payloadDigest: string;
signatureCount: number;
}
// ========== Summary for List Views ==========
export interface AiRunSummary {
runId: string;
tenantId: string;
userId: string;
status: AiRunStatus;
createdAt: string;
updatedAt: string;
completedAt?: string;
eventCount: number;
artifactCount: number;
hasAttestation: boolean;
metadata?: Record<string, string>;
}
// ========== API Request/Response ==========
export interface CreateAiRunRequest {
conversationId?: string;
metadata?: Record<string, string>;
}
export interface AddTurnRequest {
role: 'user' | 'assistant';
content: string;
groundingScore?: number;
citations?: Citation[];
}
export interface ProposeActionRequest {
actionType: string;
targetResource: string;
description: string;
parameters?: Record<string, unknown>;
}
export interface ApprovalDecision {
decision: 'approved' | 'denied';
reason?: string;
}
export interface AiRunListResponse {
count: number;
runs: AiRunSummary[];
}
export interface AiRunQuery {
status?: AiRunStatus;
userId?: string;
conversationId?: string;
fromDate?: string;
toDate?: string;
limit?: number;
offset?: number;
}
export interface AiRunQueryOptions {
traceId?: string;
}

View File

@@ -0,0 +1,401 @@
/**
* Evidence Pack API client.
* Sprint: SPRINT_20260109_011_005 Task: EVPK-008 (Frontend Unblock)
*
* Provides access to Evidence Pack endpoints for creating, signing,
* verifying, and exporting evidence packs.
*/
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, InjectionToken, inject } from '@angular/core';
import { Observable, of, delay, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { generateTraceId } from './trace.util';
import {
EvidencePack,
SignedEvidencePack,
EvidencePackVerificationResult,
EvidencePackExportFormat,
CreateEvidencePackRequest,
EvidencePackListResponse,
EvidencePackQuery,
EvidencePackQueryOptions,
EvidencePackSummary,
DsseEnvelope,
EvidenceSubject,
EvidenceClaim,
EvidenceItem,
} from './evidence-pack.models';
// ========== API Interface ==========
export interface EvidencePackApi {
/** Creates a new evidence pack */
create(request: CreateEvidencePackRequest, options?: EvidencePackQueryOptions): Observable<EvidencePack>;
/** Gets an evidence pack by ID */
get(packId: string, options?: EvidencePackQueryOptions): Observable<EvidencePack>;
/** Lists evidence packs with optional filters */
list(query?: EvidencePackQuery, options?: EvidencePackQueryOptions): Observable<EvidencePackListResponse>;
/** Lists evidence packs for a specific run */
listByRun(runId: string, options?: EvidencePackQueryOptions): Observable<EvidencePackListResponse>;
/** Signs an evidence pack */
sign(packId: string, options?: EvidencePackQueryOptions): Observable<SignedEvidencePack>;
/** Verifies a signed evidence pack */
verify(packId: string, options?: EvidencePackQueryOptions): Observable<EvidencePackVerificationResult>;
/** Exports an evidence pack in the specified format */
export(packId: string, format: EvidencePackExportFormat, options?: EvidencePackQueryOptions): Observable<Blob>;
}
// ========== DI Tokens ==========
export const EVIDENCE_PACK_API = new InjectionToken<EvidencePackApi>('EVIDENCE_PACK_API');
export const EVIDENCE_PACK_API_BASE_URL = new InjectionToken<string>('EVIDENCE_PACK_API_BASE_URL');
// ========== HTTP Implementation ==========
@Injectable({ providedIn: 'root' })
export class EvidencePackHttpClient implements EvidencePackApi {
private readonly http = inject(HttpClient);
private readonly authSession = inject(AuthSessionStore);
private readonly baseUrl = inject(EVIDENCE_PACK_API_BASE_URL, { optional: true }) ?? '/v1/evidence-packs';
create(request: CreateEvidencePackRequest, options: EvidencePackQueryOptions = {}): Observable<EvidencePack> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.post<EvidencePack>(this.baseUrl, request, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
get(packId: string, options: EvidencePackQueryOptions = {}): Observable<EvidencePack> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.get<EvidencePack>(`${this.baseUrl}/${packId}`, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
list(query: EvidencePackQuery = {}, options: EvidencePackQueryOptions = {}): Observable<EvidencePackListResponse> {
const traceId = options.traceId ?? generateTraceId();
const params = this.buildQueryParams(query);
return this.http
.get<EvidencePackListResponse>(this.baseUrl, { headers: this.buildHeaders(traceId), params })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
listByRun(runId: string, options: EvidencePackQueryOptions = {}): Observable<EvidencePackListResponse> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.get<EvidencePackListResponse>(`/v1/runs/${runId}/evidence-packs`, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
sign(packId: string, options: EvidencePackQueryOptions = {}): Observable<SignedEvidencePack> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.post<SignedEvidencePack>(`${this.baseUrl}/${packId}/sign`, {}, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
verify(packId: string, options: EvidencePackQueryOptions = {}): Observable<EvidencePackVerificationResult> {
const traceId = options.traceId ?? generateTraceId();
return this.http
.post<EvidencePackVerificationResult>(`${this.baseUrl}/${packId}/verify`, {}, { headers: this.buildHeaders(traceId) })
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
export(packId: string, format: EvidencePackExportFormat, options: EvidencePackQueryOptions = {}): Observable<Blob> {
const traceId = options.traceId ?? generateTraceId();
const formatParam = format.toLowerCase();
return this.http
.get(`${this.baseUrl}/${packId}/export`, {
headers: this.buildHeaders(traceId),
params: { format: formatParam },
responseType: 'blob',
})
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
}
private buildHeaders(traceId: string): HttpHeaders {
const tenant = this.authSession.getActiveTenantId() || '';
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
Accept: 'application/json',
});
}
private buildQueryParams(query: EvidencePackQuery): Record<string, string> {
const params: Record<string, string> = {};
if (query.cveId) params['cveId'] = query.cveId;
if (query.runId) params['runId'] = query.runId;
if (query.limit) params['limit'] = query.limit.toString();
if (query.offset) params['offset'] = query.offset.toString();
return params;
}
private mapError(err: unknown, traceId: string): Error {
return err instanceof Error
? new Error(`[${traceId}] Evidence Pack error: ${err.message}`)
: new Error(`[${traceId}] Evidence Pack error: Unknown error`);
}
}
// ========== Mock Implementation ==========
@Injectable({ providedIn: 'root' })
export class MockEvidencePackClient implements EvidencePackApi {
private packs: Map<string, EvidencePack> = new Map();
private signedPacks: Map<string, SignedEvidencePack> = new Map();
constructor() {
// Initialize with sample data
this.initializeSampleData();
}
create(request: CreateEvidencePackRequest): Observable<EvidencePack> {
const packId = `pack-${Date.now().toString(36)}`;
const pack: EvidencePack = {
packId,
version: '1.0',
createdAt: new Date().toISOString(),
tenantId: 'mock-tenant',
subject: request.subject,
claims: request.claims.map((c, i) => ({ ...c, claimId: `claim-${i.toString().padStart(3, '0')}` })),
evidence: request.evidence.map((e, i) => ({ ...e, evidenceId: `ev-${i.toString().padStart(3, '0')}` })),
context: {
runId: request.runId,
conversationId: request.conversationId,
generatedBy: 'MockClient',
},
};
this.packs.set(packId, pack);
return of(pack).pipe(delay(200));
}
get(packId: string): Observable<EvidencePack> {
const pack = this.packs.get(packId);
if (!pack) {
return throwError(() => new Error(`Pack not found: ${packId}`)).pipe(delay(100));
}
return of(pack).pipe(delay(100));
}
list(query: EvidencePackQuery = {}): Observable<EvidencePackListResponse> {
let packs = Array.from(this.packs.values());
if (query.cveId) {
packs = packs.filter((p) => p.subject.cveId === query.cveId);
}
if (query.runId) {
packs = packs.filter((p) => p.context?.runId === query.runId);
}
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
const sliced = packs.slice(offset, offset + limit);
return of({
count: sliced.length,
packs: sliced.map((p) => this.toSummary(p)),
}).pipe(delay(150));
}
listByRun(runId: string): Observable<EvidencePackListResponse> {
return this.list({ runId });
}
sign(packId: string): Observable<SignedEvidencePack> {
const pack = this.packs.get(packId);
if (!pack) {
return throwError(() => new Error(`Pack not found: ${packId}`)).pipe(delay(100));
}
const signedPack: SignedEvidencePack = {
pack,
envelope: {
payloadType: 'application/vnd.stellaops.evidence-pack+json',
payload: btoa(JSON.stringify(pack)),
payloadDigest: `sha256:mock-${packId}`,
signatures: [{ keyId: 'mock-signing-key', sig: 'mock-signature-base64' }],
},
signedAt: new Date().toISOString(),
};
this.signedPacks.set(packId, signedPack);
return of(signedPack).pipe(delay(300));
}
verify(packId: string): Observable<EvidencePackVerificationResult> {
const signedPack = this.signedPacks.get(packId);
if (!signedPack) {
return of({
valid: false,
issues: ['Pack is not signed'],
evidenceResolutions: [],
}).pipe(delay(200));
}
return of({
valid: true,
packDigest: signedPack.envelope.payloadDigest,
signatureKeyId: signedPack.envelope.signatures[0]?.keyId,
issues: [],
evidenceResolutions: signedPack.pack.evidence.map((e) => ({
evidenceId: e.evidenceId,
uri: e.uri,
resolved: true,
digestMatches: true,
})),
}).pipe(delay(400));
}
export(packId: string, format: EvidencePackExportFormat): Observable<Blob> {
const pack = this.packs.get(packId);
if (!pack) {
return throwError(() => new Error(`Pack not found: ${packId}`)).pipe(delay(100));
}
let content: string;
let contentType: string;
switch (format) {
case 'Json':
content = JSON.stringify(pack, null, 2);
contentType = 'application/json';
break;
case 'Markdown':
content = this.toMarkdown(pack);
contentType = 'text/markdown';
break;
case 'Html':
content = `<html><body><pre>${this.toMarkdown(pack)}</pre></body></html>`;
contentType = 'text/html';
break;
default:
content = JSON.stringify(pack, null, 2);
contentType = 'application/json';
}
return of(new Blob([content], { type: contentType })).pipe(delay(200));
}
private toSummary(pack: EvidencePack): EvidencePackSummary {
return {
packId: pack.packId,
tenantId: pack.tenantId,
createdAt: pack.createdAt,
subjectType: pack.subject.type,
cveId: pack.subject.cveId,
claimCount: pack.claims.length,
evidenceCount: pack.evidence.length,
};
}
private toMarkdown(pack: EvidencePack): string {
let md = `# Evidence Pack: ${pack.packId}\n\n`;
md += `**Created:** ${pack.createdAt}\n`;
md += `**Subject:** ${pack.subject.type} - ${pack.subject.cveId || pack.subject.findingId || 'N/A'}\n\n`;
md += `## Claims (${pack.claims.length})\n\n`;
for (const claim of pack.claims) {
md += `### ${claim.claimId}: ${claim.text}\n`;
md += `- **Type:** ${claim.type}\n`;
md += `- **Status:** ${claim.status}\n`;
md += `- **Confidence:** ${(claim.confidence * 100).toFixed(0)}%\n\n`;
}
md += `## Evidence (${pack.evidence.length})\n\n`;
for (const evidence of pack.evidence) {
md += `### ${evidence.evidenceId}: ${evidence.type}\n`;
md += `- **URI:** \`${evidence.uri}\`\n`;
md += `- **Digest:** \`${evidence.digest}\`\n\n`;
}
return md;
}
private initializeSampleData(): void {
const samplePack: EvidencePack = {
packId: 'pack-sample-001',
version: '1.0',
createdAt: '2026-01-10T10:00:00Z',
tenantId: 'mock-tenant',
subject: {
type: 'Cve',
cveId: 'CVE-2023-44487',
component: 'pkg:npm/http2@1.0.0',
},
claims: [
{
claimId: 'claim-001',
text: 'Component is affected by CVE-2023-44487 (HTTP/2 Rapid Reset)',
type: 'VulnerabilityStatus',
status: 'affected',
confidence: 0.92,
evidenceIds: ['ev-001', 'ev-002'],
source: 'ai',
},
{
claimId: 'claim-002',
text: 'Vulnerable function is reachable from api-gateway',
type: 'Reachability',
status: 'reachable',
confidence: 0.88,
evidenceIds: ['ev-003'],
source: 'ai',
},
],
evidence: [
{
evidenceId: 'ev-001',
type: 'Sbom',
uri: 'stella://sbom/scan-2026-01-10-abc123',
digest: 'sha256:abc123...',
collectedAt: '2026-01-10T09:00:00Z',
snapshot: {
type: 'sbom',
data: { format: 'cyclonedx', version: '1.4', componentCount: 100 },
},
},
{
evidenceId: 'ev-002',
type: 'Vex',
uri: 'stella://vex/nvd:CVE-2023-44487',
digest: 'sha256:def456...',
collectedAt: '2026-01-10T09:05:00Z',
snapshot: {
type: 'vex',
data: { issuer: 'nvd', status: 'affected' },
},
},
{
evidenceId: 'ev-003',
type: 'Reachability',
uri: 'stella://reach/api-gateway:grpc.Server',
digest: 'sha256:ghi789...',
collectedAt: '2026-01-10T09:10:00Z',
snapshot: {
type: 'reachability',
data: { latticeState: 'ConfirmedReachable', confidence: 0.88 },
},
},
],
context: {
runId: 'run-sample-001',
conversationId: 'conv-sample-001',
userId: 'user:alice@example.com',
generatedBy: 'AdvisoryAI v2.1',
},
};
this.packs.set(samplePack.packId, samplePack);
}
}

View File

@@ -0,0 +1,178 @@
/**
* Evidence Pack API models.
* Sprint: SPRINT_20260109_011_005 Task: EVPK-008 (Frontend Unblock)
*/
// ========== Subject Types ==========
export type EvidenceSubjectType = 'Finding' | 'Cve' | 'Component' | 'Image' | 'Policy' | 'Custom';
export interface EvidenceSubject {
type: EvidenceSubjectType;
findingId?: string;
cveId?: string;
component?: string;
imageDigest?: string;
metadata?: Record<string, string>;
}
// ========== Claim Types ==========
export type ClaimType =
| 'VulnerabilityStatus'
| 'Reachability'
| 'FixAvailability'
| 'Severity'
| 'Exploitability'
| 'Compliance'
| 'Custom';
export interface EvidenceClaim {
claimId: string;
text: string;
type: ClaimType;
status: string;
confidence: number;
evidenceIds: string[];
source?: string;
}
// ========== Evidence Types ==========
export type EvidenceType =
| 'Sbom'
| 'Vex'
| 'Reachability'
| 'Runtime'
| 'Attestation'
| 'Advisory'
| 'Patch'
| 'Policy'
| 'OpsMemory'
| 'Custom';
export interface EvidenceSnapshot {
type: string;
data: Record<string, unknown>;
}
export interface EvidenceItem {
evidenceId: string;
type: EvidenceType;
uri: string;
digest: string;
collectedAt: string;
snapshot: EvidenceSnapshot;
metadata?: Record<string, string>;
}
// ========== Context ==========
export interface EvidencePackContext {
tenantId?: string;
runId?: string;
conversationId?: string;
userId?: string;
generatedBy?: string;
metadata?: Record<string, string>;
}
// ========== Pack ==========
export interface EvidencePack {
packId: string;
version: string;
createdAt: string;
tenantId: string;
subject: EvidenceSubject;
claims: EvidenceClaim[];
evidence: EvidenceItem[];
context?: EvidencePackContext;
contentDigest?: string;
}
// ========== Signed Pack ==========
export interface DsseSignature {
keyId: string;
sig: string;
}
export interface DsseEnvelope {
payloadType: string;
payload: string;
payloadDigest: string;
signatures: DsseSignature[];
}
export interface SignedEvidencePack {
pack: EvidencePack;
envelope: DsseEnvelope;
signedAt: string;
}
// ========== Verification ==========
export interface EvidenceResolutionResult {
evidenceId: string;
uri: string;
resolved: boolean;
digestMatches: boolean;
error?: string;
}
export interface EvidencePackVerificationResult {
valid: boolean;
packDigest?: string;
signatureKeyId?: string;
issues: string[];
evidenceResolutions: EvidenceResolutionResult[];
}
// ========== Export ==========
export type EvidencePackExportFormat = 'Json' | 'SignedJson' | 'Markdown' | 'Html' | 'Pdf';
export interface EvidencePackExport {
packId: string;
format: EvidencePackExportFormat;
content: Blob;
contentType: string;
fileName: string;
}
// ========== API Request/Response ==========
export interface CreateEvidencePackRequest {
subject: EvidenceSubject;
claims: Omit<EvidenceClaim, 'claimId'>[];
evidence: Omit<EvidenceItem, 'evidenceId'>[];
runId?: string;
conversationId?: string;
}
export interface EvidencePackListResponse {
count: number;
packs: EvidencePackSummary[];
}
export interface EvidencePackSummary {
packId: string;
tenantId: string;
createdAt: string;
subjectType: string;
cveId?: string;
claimCount: number;
evidenceCount: number;
}
export interface EvidencePackQuery {
cveId?: string;
runId?: string;
limit?: number;
offset?: number;
}
export interface EvidencePackQueryOptions {
traceId?: string;
}

View File

@@ -0,0 +1,931 @@
/**
* AI Run Viewer Component
* Sprint: SPRINT_20260109_011_003 Task: Frontend Unblock
*
* Displays AI Run details including timeline events, artifacts,
* and attestation status.
*/
import {
Component,
Input,
Output,
EventEmitter,
inject,
signal,
computed,
OnInit,
OnChanges,
SimpleChanges,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import {
AiRun,
RunEvent,
RunArtifact,
AiRunStatus,
RunEventContent,
UserTurnContent,
AssistantTurnContent,
GroundingContent,
EvidencePackContent,
ActionContent,
ApprovalContent,
AttestationContent,
} from '../../core/api/ai-runs.models';
import { AI_RUNS_API } from '../../core/api/ai-runs.client';
@Component({
selector: 'stellaops-ai-run-viewer',
standalone: true,
imports: [CommonModule],
template: `
<div class="ai-run-viewer" [class.loading]="loading()">
@if (loading()) {
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Loading AI run...</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)="loadRun()">Retry</button>
</div>
} @else if (run()) {
<!-- Header -->
<header class="run-header">
<div class="header-left">
<h2 class="run-title">AI Run</h2>
<span class="run-id">{{ run()!.runId }}</span>
</div>
<div class="header-right">
<span class="status-badge" [class]="'status-' + run()!.status">
{{ run()!.status }}
</span>
@if (run()!.attestation) {
<span class="attested-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<polyline points="9 12 12 15 15 9"/>
</svg>
Attested
</span>
}
</div>
</header>
<!-- Run Info -->
<section class="run-section info-section">
<dl class="info-grid">
<div class="info-item">
<dt>Created</dt>
<dd>{{ run()!.createdAt | date:'medium' }}</dd>
</div>
<div class="info-item">
<dt>Updated</dt>
<dd>{{ run()!.updatedAt | date:'medium' }}</dd>
</div>
@if (run()!.completedAt) {
<div class="info-item">
<dt>Completed</dt>
<dd>{{ run()!.completedAt | date:'medium' }}</dd>
</div>
}
<div class="info-item">
<dt>User</dt>
<dd>{{ run()!.userId }}</dd>
</div>
@if (run()!.conversationId) {
<div class="info-item">
<dt>Conversation</dt>
<dd class="monospace">{{ run()!.conversationId }}</dd>
</div>
}
</dl>
</section>
<!-- Timeline -->
<section class="run-section timeline-section">
<h3 class="section-title">
Timeline
<span class="count-badge">{{ run()!.timeline.length }}</span>
</h3>
<div class="timeline">
@for (event of run()!.timeline; track event.eventId) {
<div class="timeline-item" [class]="'event-' + event.type">
<div class="timeline-marker">
<div class="marker-dot"></div>
<div class="marker-line"></div>
</div>
<div class="timeline-content">
<div class="event-header">
<span class="event-type">{{ formatEventType(event.type) }}</span>
<span class="event-time">{{ event.timestamp | date:'shortTime' }}</span>
</div>
<div class="event-body">
@switch (event.content.kind) {
@case ('user_turn') {
<div class="turn-content user-turn">
<p class="turn-message">{{ asUserTurn(event.content).message }}</p>
</div>
}
@case ('assistant_turn') {
<div class="turn-content assistant-turn">
<p class="turn-message">{{ asAssistantTurn(event.content).message }}</p>
@if (asAssistantTurn(event.content).groundingScore !== undefined) {
<span class="grounding-score">
Grounding: {{ (asAssistantTurn(event.content).groundingScore! * 100).toFixed(0) }}%
</span>
}
</div>
}
@case ('grounding') {
<div class="grounding-content">
<span class="grounding-score" [class.acceptable]="asGrounding(event.content).isAcceptable">
Score: {{ (asGrounding(event.content).score * 100).toFixed(0) }}%
</span>
<span class="grounding-detail">
{{ asGrounding(event.content).groundedClaims }}/{{ asGrounding(event.content).totalClaims }} claims grounded
</span>
</div>
}
@case ('evidence_pack') {
<div class="evidence-pack-content">
<button
type="button"
class="pack-link"
(click)="onNavigateToEvidencePack(asEvidencePack(event.content).packId)">
{{ asEvidencePack(event.content).packId }}
</button>
<span class="pack-stats">
{{ asEvidencePack(event.content).claimCount }} claims,
{{ asEvidencePack(event.content).evidenceCount }} evidence items
</span>
</div>
}
@case ('action') {
<div class="action-content">
<span class="action-type">{{ asAction(event.content).actionType }}</span>
<p class="action-description">{{ asAction(event.content).description }}</p>
<span class="action-target">Target: {{ asAction(event.content).targetResource }}</span>
@if (asAction(event.content).requiresApproval) {
<span class="requires-approval">Requires Approval</span>
}
</div>
}
@case ('approval') {
<div class="approval-content" [class]="asApproval(event.content).decision">
<span class="approval-decision">{{ asApproval(event.content).decision }}</span>
<span class="approval-by">by {{ asApproval(event.content).approver }}</span>
@if (asApproval(event.content).reason) {
<p class="approval-reason">{{ asApproval(event.content).reason }}</p>
}
</div>
}
@case ('attestation') {
<div class="attestation-content">
<span class="attestation-type">{{ asAttestation(event.content).type }}</span>
<span class="attestation-id">{{ asAttestation(event.content).attestationId }}</span>
@if (asAttestation(event.content).signed) {
<span class="signed-indicator">Signed</span>
}
</div>
}
@default {
<div class="generic-content">
<p>{{ event.content | json }}</p>
</div>
}
}
</div>
</div>
</div>
}
</div>
</section>
<!-- Artifacts -->
@if (run()!.artifacts.length > 0) {
<section class="run-section artifacts-section">
<h3 class="section-title">
Artifacts
<span class="count-badge">{{ run()!.artifacts.length }}</span>
</h3>
<div class="artifacts-list">
@for (artifact of run()!.artifacts; track artifact.artifactId) {
<div class="artifact-card">
<div class="artifact-header">
<span class="artifact-type">{{ artifact.type }}</span>
<span class="artifact-date">{{ artifact.createdAt | date:'shortDate' }}</span>
</div>
<div class="artifact-body">
<span class="artifact-name">{{ artifact.name }}</span>
<code class="artifact-uri">{{ artifact.uri }}</code>
</div>
<div class="artifact-footer">
<code class="artifact-digest">{{ artifact.contentDigest }}</code>
</div>
</div>
}
</div>
</section>
}
<!-- Attestation -->
@if (run()!.attestation) {
<section class="run-section attestation-section">
<h3 class="section-title">Attestation</h3>
<dl class="info-grid">
<div class="info-item">
<dt>Attestation ID</dt>
<dd class="monospace">{{ run()!.attestation!.attestationId }}</dd>
</div>
<div class="info-item">
<dt>Content Digest</dt>
<dd class="monospace">{{ run()!.attestation!.contentDigest }}</dd>
</div>
@if (run()!.attestation!.signedAt) {
<div class="info-item">
<dt>Signed At</dt>
<dd>{{ run()!.attestation!.signedAt | date:'medium' }}</dd>
</div>
}
@if (run()!.attestation!.signatureKeyId) {
<div class="info-item">
<dt>Signature Key</dt>
<dd class="monospace">{{ run()!.attestation!.signatureKeyId }}</dd>
</div>
}
</dl>
</section>
}
<!-- Actions -->
@if (canApprove()) {
<section class="run-section actions-section">
<h3 class="section-title">Pending Approval</h3>
<div class="approval-actions">
<button
type="button"
class="approve-btn"
(click)="onApprove()"
[disabled]="processing()">
Approve
</button>
<button
type="button"
class="reject-btn"
(click)="onReject()"
[disabled]="processing()">
Reject
</button>
</div>
</section>
}
<!-- Footer -->
<footer class="run-footer">
<span class="footer-item">Tenant: {{ run()!.tenantId }}</span>
</footer>
} @else {
<div class="empty-state">
<p>No AI run loaded</p>
</div>
}
</div>
`,
styles: [`
.ai-run-viewer {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary, #fff);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.loading-state, .error-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-secondary, #666);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color, #e0e0e0);
border-top-color: var(--primary-color, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-icon {
width: 48px;
height: 48px;
color: var(--error-color, #ef4444);
margin-bottom: 1rem;
}
.retry-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--bg-secondary, #f9fafb);
cursor: pointer;
font-size: 0.875rem;
}
.run-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.run-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.run-id {
font-size: 0.75rem;
color: var(--text-secondary, #666);
background: var(--bg-secondary, #f9fafb);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: monospace;
}
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.status-created { background: #e0e7ff; color: #3730a3; }
.status-active { background: #dbeafe; color: #1e40af; }
.status-pending_approval { background: #fef3c7; color: #92400e; }
.status-approved { background: #dcfce7; color: #166534; }
.status-rejected { background: #fee2e2; color: #991b1b; }
.status-complete { background: #d1fae5; color: #065f46; }
.status-cancelled { background: #f3f4f6; color: #4b5563; }
.attested-badge {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #dcfce7;
color: #166534;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.attested-badge svg {
width: 14px;
height: 14px;
}
.run-section {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #111);
margin: 0 0 0.75rem 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.count-badge {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
background: var(--bg-secondary, #f9fafb);
color: var(--text-secondary, #666);
font-weight: 500;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin: 0;
}
.info-item dt {
font-size: 0.75rem;
color: var(--text-secondary, #666);
margin-bottom: 0.25rem;
}
.info-item dd {
margin: 0;
font-size: 0.875rem;
}
.monospace {
font-family: monospace;
font-size: 0.8125rem;
}
/* Timeline styles */
.timeline {
display: flex;
flex-direction: column;
}
.timeline-item {
display: flex;
gap: 1rem;
padding-bottom: 1rem;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-item:last-child .marker-line {
display: none;
}
.timeline-marker {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
}
.marker-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--primary-color, #3b82f6);
border: 2px solid var(--bg-primary, #fff);
box-shadow: 0 0 0 2px var(--primary-color, #3b82f6);
}
.marker-line {
flex: 1;
width: 2px;
background: var(--border-color, #e0e0e0);
margin-top: 4px;
}
.timeline-content {
flex: 1;
min-width: 0;
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.event-type {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
color: var(--primary-color, #3b82f6);
}
.event-time {
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.event-body {
padding: 0.75rem;
background: var(--bg-secondary, #f9fafb);
border-radius: 6px;
border: 1px solid var(--border-color, #e0e0e0);
}
.turn-content {
font-size: 0.875rem;
}
.turn-message {
margin: 0;
white-space: pre-wrap;
}
.user-turn {
border-left: 3px solid var(--info-color, #0ea5e9);
}
.assistant-turn {
border-left: 3px solid var(--primary-color, #3b82f6);
}
.grounding-score {
display: inline-block;
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
background: var(--bg-tertiary, #f3f4f6);
border-radius: 4px;
margin-top: 0.5rem;
}
.grounding-score.acceptable {
background: #dcfce7;
color: #166534;
}
.grounding-content, .evidence-pack-content, .action-content,
.approval-content, .attestation-content, .generic-content {
font-size: 0.875rem;
}
.pack-link {
background: none;
border: none;
padding: 0;
color: var(--primary-color, #3b82f6);
cursor: pointer;
font-family: monospace;
font-size: 0.8125rem;
}
.pack-link:hover {
text-decoration: underline;
}
.pack-stats {
display: block;
font-size: 0.75rem;
color: var(--text-secondary, #666);
margin-top: 0.25rem;
}
.action-type {
display: inline-block;
font-size: 0.75rem;
font-weight: 500;
padding: 0.125rem 0.375rem;
background: var(--bg-tertiary, #f3f4f6);
border-radius: 4px;
text-transform: uppercase;
}
.action-description {
margin: 0.5rem 0;
}
.action-target {
display: block;
font-size: 0.75rem;
color: var(--text-secondary, #666);
font-family: monospace;
}
.requires-approval {
display: inline-block;
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
background: #fef3c7;
color: #92400e;
border-radius: 4px;
margin-top: 0.5rem;
}
.approval-content {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.approval-content.approved .approval-decision {
background: #dcfce7;
color: #166534;
}
.approval-content.denied .approval-decision {
background: #fee2e2;
color: #991b1b;
}
.approval-decision {
font-size: 0.75rem;
font-weight: 500;
padding: 0.125rem 0.375rem;
border-radius: 4px;
text-transform: uppercase;
}
.approval-by {
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.approval-reason {
width: 100%;
margin: 0.5rem 0 0 0;
font-size: 0.8125rem;
}
.attestation-content {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.attestation-type, .attestation-id {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
background: var(--bg-tertiary, #f3f4f6);
border-radius: 4px;
}
.attestation-id {
font-family: monospace;
}
.signed-indicator {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
background: #dcfce7;
color: #166534;
border-radius: 4px;
}
/* Artifacts */
.artifacts-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.75rem;
}
.artifact-card {
padding: 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
background: var(--bg-secondary, #f9fafb);
}
.artifact-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.artifact-type {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
color: var(--primary-color, #3b82f6);
}
.artifact-date {
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.artifact-name {
display: block;
font-weight: 500;
margin-bottom: 0.25rem;
}
.artifact-uri {
display: block;
font-size: 0.75rem;
color: var(--text-secondary, #666);
word-break: break-all;
}
.artifact-footer {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.artifact-digest {
font-size: 0.6875rem;
color: var(--text-tertiary, #999);
word-break: break-all;
}
/* Actions */
.approval-actions {
display: flex;
gap: 0.75rem;
}
.approve-btn, .reject-btn {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
}
.approve-btn {
background: #16a34a;
color: #fff;
}
.approve-btn:hover:not(:disabled) {
background: #15803d;
}
.reject-btn {
background: #dc2626;
color: #fff;
}
.reject-btn:hover:not(:disabled) {
background: #b91c1c;
}
.approve-btn:disabled, .reject-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.run-footer {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.75rem 1.5rem;
background: var(--bg-secondary, #f9fafb);
border-top: 1px solid var(--border-color, #e0e0e0);
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
`],
})
export class AiRunViewerComponent implements OnInit, OnChanges {
private readonly api = inject(AI_RUNS_API);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
@Input() runId?: string;
@Input() initialRun?: AiRun;
@Output() navigateToEvidencePack = new EventEmitter<string>();
@Output() approved = new EventEmitter<string>();
@Output() rejected = new EventEmitter<string>();
readonly run = signal<AiRun | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly processing = signal(false);
readonly canApprove = computed(() => {
const r = this.run();
return r !== null && r.status === 'pending_approval';
});
ngOnInit(): void {
// Read runId from route params if not provided via Input
this.route.paramMap.subscribe((params) => {
const routeRunId = params.get('runId');
if (routeRunId && !this.runId) {
this.runId = routeRunId;
this.loadRun();
}
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['runId'] && this.runId) {
this.loadRun();
} else if (changes['initialRun'] && this.initialRun) {
this.run.set(this.initialRun);
}
}
loadRun(): void {
if (!this.runId) return;
this.loading.set(true);
this.error.set(null);
this.api.get(this.runId).subscribe({
next: (run) => {
this.run.set(run);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load AI run');
this.loading.set(false);
},
});
}
onApprove(): void {
const r = this.run();
if (!r) return;
this.processing.set(true);
this.api.submitApproval(r.runId, { decision: 'approved' }).subscribe({
next: (updated) => {
this.run.set(updated);
this.processing.set(false);
this.approved.emit(r.runId);
},
error: (err) => {
console.error('Failed to approve run:', err);
this.processing.set(false);
},
});
}
onReject(): void {
const r = this.run();
if (!r) return;
this.processing.set(true);
this.api.submitApproval(r.runId, { decision: 'denied', reason: 'Rejected by user' }).subscribe({
next: (updated) => {
this.run.set(updated);
this.processing.set(false);
this.rejected.emit(r.runId);
},
error: (err) => {
console.error('Failed to reject run:', err);
this.processing.set(false);
},
});
}
onNavigateToEvidencePack(packId: string): void {
this.navigateToEvidencePack.emit(packId);
this.router.navigate(['/evidence-packs', packId]);
}
formatEventType(type: string): string {
return type.replace(/_/g, ' ');
}
// Type guard helpers for template
asUserTurn(content: RunEventContent): UserTurnContent {
return content as UserTurnContent;
}
asAssistantTurn(content: RunEventContent): AssistantTurnContent {
return content as AssistantTurnContent;
}
asGrounding(content: RunEventContent): GroundingContent {
return content as GroundingContent;
}
asEvidencePack(content: RunEventContent): EvidencePackContent {
return content as EvidencePackContent;
}
asAction(content: RunEventContent): ActionContent {
return content as ActionContent;
}
asApproval(content: RunEventContent): ApprovalContent {
return content as ApprovalContent;
}
asAttestation(content: RunEventContent): AttestationContent {
return content as AttestationContent;
}
}

View File

@@ -0,0 +1,427 @@
/**
* AI Runs List Component
* Sprint: SPRINT_20260109_011_003 Task: Frontend Unblock
*
* Displays a list of AI runs with filtering by status and pagination.
*/
import {
Component,
Input,
Output,
EventEmitter,
inject,
signal,
OnInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import {
AiRunSummary,
AiRunStatus,
AiRunQuery,
} from '../../core/api/ai-runs.models';
import { AI_RUNS_API } from '../../core/api/ai-runs.client';
@Component({
selector: 'stellaops-ai-runs-list',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="ai-runs-list">
<!-- Header with filters -->
<header class="list-header">
<h2 class="list-title">AI Runs</h2>
<div class="filters">
<select
class="filter-select"
[(ngModel)]="filterStatus"
(ngModelChange)="onFilterChange()">
<option value="">All Statuses</option>
<option value="created">Created</option>
<option value="active">Active</option>
<option value="pending_approval">Pending Approval</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="complete">Complete</option>
<option value="cancelled">Cancelled</option>
</select>
<input
type="text"
class="filter-input"
placeholder="Filter by user..."
[(ngModel)]="filterUserId"
(ngModelChange)="onFilterChange()"
/>
</div>
</header>
<!-- List content -->
<div class="list-content">
@if (loading()) {
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Loading AI runs...</p>
</div>
} @else if (error()) {
<div class="error-state">
<p>{{ error() }}</p>
<button type="button" class="retry-btn" (click)="loadRuns()">Retry</button>
</div>
} @else if (runs().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"/>
<path d="M12 6v6l4 2"/>
</svg>
<p>No AI runs found</p>
</div>
} @else {
<div class="runs-table-container">
<table class="runs-table">
<thead>
<tr>
<th>Run ID</th>
<th>Status</th>
<th>User</th>
<th>Events</th>
<th>Artifacts</th>
<th>Attested</th>
<th>Created</th>
</tr>
</thead>
<tbody>
@for (run of runs(); track run.runId) {
<tr
class="run-row"
[class.selected]="selectedRunId === run.runId"
(click)="onSelect(run)">
<td class="run-id-cell">
<code>{{ run.runId.substring(0, 12) }}...</code>
</td>
<td>
<span class="status-badge" [class]="'status-' + run.status">
{{ run.status }}
</span>
</td>
<td class="user-cell">{{ run.userId }}</td>
<td class="count-cell">{{ run.eventCount }}</td>
<td class="count-cell">{{ run.artifactCount }}</td>
<td class="attested-cell">
@if (run.hasAttestation) {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="check-icon">
<polyline points="20 6 9 17 4 12"/>
</svg>
} @else {
<span class="no-attestation">-</span>
}
</td>
<td class="date-cell">{{ run.createdAt | date:'shortDate' }}</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Pagination -->
@if (totalCount() > pageSize) {
<div class="pagination">
<button
type="button"
class="page-btn"
[disabled]="currentPage() === 0"
(click)="goToPage(currentPage() - 1)">
Previous
</button>
<span class="page-info">
Page {{ currentPage() + 1 }} of {{ totalPages() }}
</span>
<button
type="button"
class="page-btn"
[disabled]="currentPage() >= totalPages() - 1"
(click)="goToPage(currentPage() + 1)">
Next
</button>
</div>
}
}
</div>
</div>
`,
styles: [`
.ai-runs-list {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary, #fff);
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.list-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.filters {
display: flex;
align-items: center;
gap: 0.75rem;
}
.filter-select, .filter-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
font-size: 0.875rem;
}
.filter-select:focus, .filter-input:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
}
.list-content {
flex: 1;
overflow: auto;
}
.loading-state, .error-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-secondary, #666);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color, #e0e0e0);
border-top-color: var(--primary-color, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-icon {
width: 48px;
height: 48px;
color: var(--text-tertiary, #999);
margin-bottom: 1rem;
}
.retry-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--bg-secondary, #f9fafb);
cursor: pointer;
}
.runs-table-container {
overflow-x: auto;
}
.runs-table {
width: 100%;
border-collapse: collapse;
}
.runs-table th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary, #666);
background: var(--bg-secondary, #f9fafb);
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.runs-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
font-size: 0.875rem;
}
.run-row {
cursor: pointer;
transition: background 0.15s ease;
}
.run-row:hover {
background: var(--bg-hover, #f3f4f6);
}
.run-row.selected {
background: rgba(59, 130, 246, 0.05);
}
.run-id-cell code {
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.status-created { background: #e0e7ff; color: #3730a3; }
.status-active { background: #dbeafe; color: #1e40af; }
.status-pending_approval { background: #fef3c7; color: #92400e; }
.status-approved { background: #dcfce7; color: #166534; }
.status-rejected { background: #fee2e2; color: #991b1b; }
.status-complete { background: #d1fae5; color: #065f46; }
.status-cancelled { background: #f3f4f6; color: #4b5563; }
.user-cell {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count-cell {
text-align: center;
color: var(--text-secondary, #666);
}
.attested-cell {
text-align: center;
}
.check-icon {
width: 18px;
height: 18px;
color: #16a34a;
}
.no-attestation {
color: var(--text-tertiary, #999);
}
.date-cell {
color: var(--text-secondary, #666);
white-space: nowrap;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
padding: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.page-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--bg-secondary, #f9fafb);
cursor: pointer;
font-size: 0.875rem;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-btn:not(:disabled):hover {
background: var(--bg-hover, #f3f4f6);
}
.page-info {
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
`],
})
export class AiRunsListComponent implements OnInit {
private readonly api = inject(AI_RUNS_API);
private readonly router = inject(Router);
@Input() selectedRunId?: string;
@Input() pageSize = 20;
@Output() runSelected = new EventEmitter<AiRunSummary>();
readonly runs = signal<AiRunSummary[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly totalCount = signal(0);
readonly currentPage = signal(0);
readonly totalPages = signal(0);
filterStatus = '';
filterUserId = '';
ngOnInit(): void {
this.loadRuns();
}
loadRuns(): void {
this.loading.set(true);
this.error.set(null);
const query: AiRunQuery = {
limit: this.pageSize,
offset: this.currentPage() * this.pageSize,
};
if (this.filterStatus) {
query.status = this.filterStatus as AiRunStatus;
}
if (this.filterUserId) {
query.userId = this.filterUserId;
}
this.api.list(query).subscribe({
next: (response) => {
this.runs.set(response.runs);
this.totalCount.set(response.count);
this.totalPages.set(Math.ceil(response.count / this.pageSize));
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load AI runs');
this.loading.set(false);
},
});
}
onFilterChange(): void {
this.currentPage.set(0);
this.loadRuns();
}
goToPage(page: number): void {
this.currentPage.set(page);
this.loadRuns();
}
onSelect(run: AiRunSummary): void {
this.runSelected.emit(run);
this.router.navigate(['/ai-runs', run.runId]);
}
}

View File

@@ -0,0 +1,7 @@
/**
* AI Runs Feature Module
* Sprint: SPRINT_20260109_011_003 Task: Frontend Unblock
*/
export * from './ai-run-viewer.component';
export * from './ai-runs-list.component';

View File

@@ -0,0 +1,414 @@
/**
* Evidence Pack List Component
* Sprint: SPRINT_20260109_011_005 Task: EVPK-008 (Frontend Unblock)
*
* Displays a list of evidence packs with filtering and pagination.
*/
import {
Component,
Input,
Output,
EventEmitter,
inject,
signal,
OnInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import {
EvidencePackSummary,
EvidencePackQuery,
} from '../../core/api/evidence-pack.models';
import { EVIDENCE_PACK_API } from '../../core/api/evidence-pack.client';
@Component({
selector: 'stellaops-evidence-pack-list',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="evidence-pack-list">
<!-- Header with filters -->
<header class="list-header">
<h2 class="list-title">Evidence Packs</h2>
<div class="filters">
<input
type="text"
class="filter-input"
placeholder="Filter by CVE ID..."
[(ngModel)]="filterCveId"
(ngModelChange)="onFilterChange()"
/>
@if (runId) {
<span class="run-filter">Run: {{ runId }}</span>
}
</div>
</header>
<!-- List content -->
<div class="list-content">
@if (loading()) {
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Loading evidence packs...</p>
</div>
} @else if (error()) {
<div class="error-state">
<p>{{ error() }}</p>
<button type="button" class="retry-btn" (click)="loadPacks()">Retry</button>
</div>
} @else if (packs().length === 0) {
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="empty-icon">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<p>No evidence packs found</p>
</div>
} @else {
<div class="pack-grid">
@for (pack of packs(); track pack.packId) {
<button
type="button"
class="pack-card"
[class.selected]="selectedPackId === pack.packId"
(click)="onSelect(pack)">
<div class="pack-card-header">
<span class="pack-subject-type">{{ pack.subjectType }}</span>
<span class="pack-date">{{ pack.createdAt | date:'shortDate' }}</span>
</div>
<div class="pack-card-body">
@if (pack.cveId) {
<span class="pack-cve">{{ pack.cveId }}</span>
} @else {
<span class="pack-id">{{ pack.packId.substring(0, 16) }}...</span>
}
</div>
<div class="pack-card-footer">
<span class="pack-stat">
<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"/>
</svg>
{{ pack.claimCount }} claims
</span>
<span class="pack-stat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 16v-4"/>
<path d="M12 8h.01"/>
</svg>
{{ pack.evidenceCount }} evidence
</span>
</div>
</button>
}
</div>
<!-- Pagination -->
@if (totalCount() > pageSize) {
<div class="pagination">
<button
type="button"
class="page-btn"
[disabled]="currentPage() === 0"
(click)="goToPage(currentPage() - 1)">
Previous
</button>
<span class="page-info">
Page {{ currentPage() + 1 }} of {{ totalPages() }}
</span>
<button
type="button"
class="page-btn"
[disabled]="currentPage() >= totalPages() - 1"
(click)="goToPage(currentPage() + 1)">
Next
</button>
</div>
}
}
</div>
</div>
`,
styles: [`
.evidence-pack-list {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary, #fff);
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.list-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.filters {
display: flex;
align-items: center;
gap: 0.75rem;
}
.filter-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
font-size: 0.875rem;
}
.filter-input:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
}
.run-filter {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: var(--bg-secondary, #f9fafb);
border-radius: 4px;
color: var(--text-secondary, #666);
}
.list-content {
flex: 1;
overflow: auto;
padding: 1rem 1.5rem;
}
.loading-state, .error-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-secondary, #666);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color, #e0e0e0);
border-top-color: var(--primary-color, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-icon {
width: 48px;
height: 48px;
color: var(--text-tertiary, #999);
margin-bottom: 1rem;
}
.retry-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--bg-secondary, #f9fafb);
cursor: pointer;
}
.pack-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.pack-card {
display: flex;
flex-direction: column;
padding: 1rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
background: var(--bg-secondary, #f9fafb);
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
}
.pack-card:hover {
border-color: var(--primary-color, #3b82f6);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
}
.pack-card.selected {
border-color: var(--primary-color, #3b82f6);
background: rgba(59, 130, 246, 0.05);
}
.pack-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.pack-subject-type {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
color: var(--primary-color, #3b82f6);
}
.pack-date {
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.pack-card-body {
flex: 1;
margin-bottom: 0.75rem;
}
.pack-cve {
font-size: 1rem;
font-weight: 600;
color: var(--warning-color, #f59e0b);
}
.pack-id {
font-size: 0.875rem;
font-family: monospace;
color: var(--text-secondary, #666);
}
.pack-card-footer {
display: flex;
gap: 1rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.pack-stat {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.pack-stat svg {
width: 14px;
height: 14px;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
padding: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
margin-top: 1rem;
}
.page-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--bg-secondary, #f9fafb);
cursor: pointer;
font-size: 0.875rem;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-btn:not(:disabled):hover {
background: var(--bg-hover, #f3f4f6);
}
.page-info {
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
`],
})
export class EvidencePackListComponent implements OnInit {
private readonly api = inject(EVIDENCE_PACK_API);
private readonly router = inject(Router);
@Input() runId?: string;
@Input() selectedPackId?: string;
@Input() pageSize = 20;
@Output() packSelected = new EventEmitter<EvidencePackSummary>();
readonly packs = signal<EvidencePackSummary[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly totalCount = signal(0);
readonly currentPage = signal(0);
readonly totalPages = signal(0);
filterCveId = '';
ngOnInit(): void {
this.loadPacks();
}
loadPacks(): void {
this.loading.set(true);
this.error.set(null);
const query: EvidencePackQuery = {
limit: this.pageSize,
offset: this.currentPage() * this.pageSize,
};
if (this.filterCveId) {
query.cveId = this.filterCveId;
}
const request$ = this.runId
? this.api.listByRun(this.runId)
: this.api.list(query);
request$.subscribe({
next: (response) => {
this.packs.set(response.packs);
this.totalCount.set(response.count);
this.totalPages.set(Math.ceil(response.count / this.pageSize));
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load evidence packs');
this.loading.set(false);
},
});
}
onFilterChange(): void {
this.currentPage.set(0);
this.loadPacks();
}
goToPage(page: number): void {
this.currentPage.set(page);
this.loadPacks();
}
onSelect(pack: EvidencePackSummary): void {
this.packSelected.emit(pack);
this.router.navigate(['/evidence-packs', pack.packId]);
}
}

View File

@@ -0,0 +1,869 @@
/**
* Evidence Pack Viewer Component
* Sprint: SPRINT_20260109_011_005 Task: EVPK-008 (Frontend Unblock)
*
* Displays Evidence Pack details including subject, claims, evidence items,
* and DSSE signature verification status.
*/
import {
Component,
Input,
Output,
EventEmitter,
inject,
signal,
computed,
OnChanges,
OnInit,
SimpleChanges,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import {
EvidencePack,
EvidenceClaim,
EvidenceItem,
SignedEvidencePack,
EvidencePackVerificationResult,
EvidencePackExportFormat,
} from '../../core/api/evidence-pack.models';
import { EVIDENCE_PACK_API } from '../../core/api/evidence-pack.client';
@Component({
selector: 'stellaops-evidence-pack-viewer',
standalone: true,
imports: [CommonModule],
template: `
<div class="evidence-pack-viewer" [class.loading]="loading()">
@if (loading()) {
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Loading evidence pack...</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)="loadPack()">Retry</button>
</div>
} @else if (pack()) {
<!-- Header -->
<header class="pack-header">
<div class="header-left">
<h2 class="pack-title">Evidence Pack</h2>
<span class="pack-id">{{ pack()!.packId }}</span>
</div>
<div class="header-actions">
@if (isSigned()) {
<span class="signed-badge verified">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<polyline points="9 12 12 15 15 9"/>
</svg>
Signed
</span>
} @else {
<span class="signed-badge unsigned">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Unsigned
</span>
}
<button type="button" class="action-btn" (click)="onSign()" [disabled]="isSigned() || signing()">
{{ signing() ? 'Signing...' : 'Sign Pack' }}
</button>
<button type="button" class="action-btn" (click)="onVerify()" [disabled]="verifying()">
{{ verifying() ? 'Verifying...' : 'Verify' }}
</button>
<div class="export-dropdown">
<button type="button" class="action-btn export-btn" (click)="toggleExportMenu()">
Export
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
@if (showExportMenu()) {
<div class="export-menu">
<button type="button" (click)="onExport('Json')">JSON</button>
<button type="button" (click)="onExport('SignedJson')">Signed JSON</button>
<button type="button" (click)="onExport('Markdown')">Markdown</button>
<button type="button" (click)="onExport('Html')">HTML</button>
</div>
}
</div>
</div>
</header>
<!-- Subject Section -->
<section class="pack-section subject-section">
<h3 class="section-title">Subject</h3>
<div class="subject-details">
<dl class="details-grid">
<div class="detail-item">
<dt>Type</dt>
<dd>{{ pack()!.subject.type }}</dd>
</div>
@if (pack()!.subject.cveId) {
<div class="detail-item">
<dt>CVE ID</dt>
<dd class="cve-id">{{ pack()!.subject.cveId }}</dd>
</div>
}
@if (pack()!.subject.findingId) {
<div class="detail-item">
<dt>Finding ID</dt>
<dd>{{ pack()!.subject.findingId }}</dd>
</div>
}
@if (pack()!.subject.component) {
<div class="detail-item">
<dt>Component</dt>
<dd class="monospace">{{ pack()!.subject.component }}</dd>
</div>
}
</dl>
</div>
</section>
<!-- Claims Section -->
<section class="pack-section claims-section">
<h3 class="section-title">
Claims
<span class="count-badge">{{ pack()!.claims.length }}</span>
</h3>
<div class="claims-list">
@for (claim of pack()!.claims; track claim.claimId) {
<div class="claim-card" [class]="'claim-' + claim.status">
<div class="claim-header">
<span class="claim-type">{{ claim.type }}</span>
<span class="claim-confidence" [title]="'Confidence: ' + (claim.confidence * 100).toFixed(0) + '%'">
{{ (claim.confidence * 100).toFixed(0) }}%
</span>
</div>
<p class="claim-text">{{ claim.text }}</p>
<div class="claim-meta">
<span class="claim-status">{{ claim.status }}</span>
@if (claim.source) {
<span class="claim-source">Source: {{ claim.source }}</span>
}
</div>
@if (claim.evidenceIds.length > 0) {
<div class="claim-evidence">
<span class="evidence-label">Evidence:</span>
@for (evId of claim.evidenceIds; track evId) {
<button
type="button"
class="evidence-link"
(click)="scrollToEvidence(evId)">
{{ evId }}
</button>
}
</div>
}
</div>
}
</div>
</section>
<!-- Evidence Section -->
<section class="pack-section evidence-section">
<h3 class="section-title">
Evidence Items
<span class="count-badge">{{ pack()!.evidence.length }}</span>
</h3>
<div class="evidence-list">
@for (ev of pack()!.evidence; track ev.evidenceId) {
<div class="evidence-card" [id]="'ev-' + ev.evidenceId">
<div class="evidence-header">
<span class="evidence-type">{{ ev.type }}</span>
<span class="evidence-id">{{ ev.evidenceId }}</span>
</div>
<div class="evidence-details">
<div class="detail-row">
<span class="detail-label">URI:</span>
<code class="detail-value uri">{{ ev.uri }}</code>
</div>
<div class="detail-row">
<span class="detail-label">Digest:</span>
<code class="detail-value digest">{{ ev.digest }}</code>
</div>
<div class="detail-row">
<span class="detail-label">Collected:</span>
<span class="detail-value">{{ ev.collectedAt | date:'medium' }}</span>
</div>
</div>
@if (ev.snapshot) {
<details class="snapshot-details">
<summary>Snapshot Data</summary>
<pre class="snapshot-json">{{ ev.snapshot | json }}</pre>
</details>
}
</div>
}
</div>
</section>
<!-- Verification Results -->
@if (verificationResult()) {
<section class="pack-section verification-section">
<h3 class="section-title">
Verification Result
@if (verificationResult()!.valid) {
<span class="result-badge valid">Valid</span>
} @else {
<span class="result-badge invalid">Invalid</span>
}
</h3>
<div class="verification-details">
@if (verificationResult()!.packDigest) {
<div class="detail-row">
<span class="detail-label">Pack Digest:</span>
<code class="detail-value">{{ verificationResult()!.packDigest }}</code>
</div>
}
@if (verificationResult()!.signatureKeyId) {
<div class="detail-row">
<span class="detail-label">Signing Key:</span>
<code class="detail-value">{{ verificationResult()!.signatureKeyId }}</code>
</div>
}
@if (verificationResult()!.issues.length > 0) {
<div class="issues-list">
<h4>Issues:</h4>
<ul>
@for (issue of verificationResult()!.issues; track issue) {
<li class="issue-item">{{ issue }}</li>
}
</ul>
</div>
}
</div>
</section>
}
<!-- Context Section -->
@if (pack()!.context) {
<section class="pack-section context-section">
<h3 class="section-title">Context</h3>
<dl class="details-grid">
@if (pack()!.context!.runId) {
<div class="detail-item">
<dt>Run ID</dt>
<dd>
<button type="button" class="link-btn" (click)="onNavigateToRun(pack()!.context!.runId!)">
{{ pack()!.context!.runId }}
</button>
</dd>
</div>
}
@if (pack()!.context!.conversationId) {
<div class="detail-item">
<dt>Conversation</dt>
<dd>{{ pack()!.context!.conversationId }}</dd>
</div>
}
@if (pack()!.context!.userId) {
<div class="detail-item">
<dt>User</dt>
<dd>{{ pack()!.context!.userId }}</dd>
</div>
}
@if (pack()!.context!.generatedBy) {
<div class="detail-item">
<dt>Generated By</dt>
<dd>{{ pack()!.context!.generatedBy }}</dd>
</div>
}
</dl>
</section>
}
<!-- Metadata -->
<footer class="pack-footer">
<span class="footer-item">Created: {{ pack()!.createdAt | date:'medium' }}</span>
<span class="footer-item">Version: {{ pack()!.version }}</span>
@if (pack()!.contentDigest) {
<span class="footer-item digest">{{ pack()!.contentDigest }}</span>
}
</footer>
} @else {
<div class="empty-state">
<p>No evidence pack loaded</p>
</div>
}
</div>
`,
styles: [`
.evidence-pack-viewer {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary, #fff);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.loading-state, .error-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-secondary, #666);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color, #e0e0e0);
border-top-color: var(--primary-color, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-icon {
width: 48px;
height: 48px;
color: var(--error-color, #ef4444);
margin-bottom: 1rem;
}
.retry-btn, .action-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--bg-secondary, #f9fafb);
cursor: pointer;
font-size: 0.875rem;
}
.retry-btn:hover, .action-btn:hover {
background: var(--bg-hover, #f3f4f6);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pack-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.pack-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.pack-id {
font-size: 0.75rem;
color: var(--text-secondary, #666);
background: var(--bg-secondary, #f9fafb);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: monospace;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.signed-badge {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.signed-badge svg {
width: 14px;
height: 14px;
}
.signed-badge.verified {
background: #dcfce7;
color: #166534;
}
.signed-badge.unsigned {
background: #fef3c7;
color: #92400e;
}
.export-dropdown {
position: relative;
}
.export-btn {
display: flex;
align-items: center;
gap: 0.25rem;
}
.export-btn svg {
width: 14px;
height: 14px;
}
.export-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.export-menu button {
display: block;
width: 100%;
padding: 0.5rem 1rem;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 0.875rem;
}
.export-menu button:hover {
background: var(--bg-hover, #f3f4f6);
}
.pack-section {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #111);
margin: 0 0 0.75rem 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.count-badge, .result-badge {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
font-weight: 500;
}
.count-badge {
background: var(--bg-secondary, #f9fafb);
color: var(--text-secondary, #666);
}
.result-badge.valid {
background: #dcfce7;
color: #166534;
}
.result-badge.invalid {
background: #fee2e2;
color: #991b1b;
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin: 0;
}
.detail-item dt {
font-size: 0.75rem;
color: var(--text-secondary, #666);
margin-bottom: 0.25rem;
}
.detail-item dd {
margin: 0;
font-size: 0.875rem;
}
.cve-id {
font-weight: 600;
color: var(--warning-color, #f59e0b);
}
.monospace {
font-family: monospace;
font-size: 0.8125rem;
}
.claims-list, .evidence-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.claim-card, .evidence-card {
padding: 0.75rem 1rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
background: var(--bg-secondary, #f9fafb);
}
.claim-header, .evidence-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.claim-type, .evidence-type {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
color: var(--primary-color, #3b82f6);
}
.evidence-id {
font-size: 0.75rem;
font-family: monospace;
color: var(--text-secondary, #666);
}
.claim-confidence {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #666);
}
.claim-text {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
line-height: 1.4;
}
.claim-meta {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.claim-status {
font-weight: 500;
}
.claim-evidence {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.evidence-label {
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.evidence-link {
font-size: 0.75rem;
font-family: monospace;
padding: 0.125rem 0.375rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--bg-primary, #fff);
cursor: pointer;
color: var(--primary-color, #3b82f6);
}
.evidence-link:hover {
background: var(--primary-color, #3b82f6);
color: #fff;
}
.evidence-details {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.detail-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.8125rem;
}
.detail-label {
flex-shrink: 0;
color: var(--text-secondary, #666);
}
.detail-value {
font-family: monospace;
word-break: break-all;
}
.detail-value.uri {
color: var(--primary-color, #3b82f6);
}
.detail-value.digest {
color: var(--text-secondary, #666);
font-size: 0.75rem;
}
.snapshot-details {
margin-top: 0.5rem;
}
.snapshot-details summary {
font-size: 0.75rem;
color: var(--primary-color, #3b82f6);
cursor: pointer;
}
.snapshot-json {
margin: 0.5rem 0 0 0;
padding: 0.5rem;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
font-size: 0.75rem;
overflow-x: auto;
}
.verification-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.issues-list h4 {
font-size: 0.875rem;
margin: 0.5rem 0 0.25rem 0;
color: var(--error-color, #ef4444);
}
.issues-list ul {
margin: 0;
padding-left: 1.25rem;
}
.issue-item {
font-size: 0.8125rem;
color: var(--error-color, #ef4444);
}
.link-btn {
background: none;
border: none;
padding: 0;
color: var(--primary-color, #3b82f6);
cursor: pointer;
font-family: monospace;
font-size: inherit;
}
.link-btn:hover {
text-decoration: underline;
}
.pack-footer {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.75rem 1.5rem;
background: var(--bg-secondary, #f9fafb);
border-top: 1px solid var(--border-color, #e0e0e0);
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.footer-item.digest {
font-family: monospace;
}
`],
})
export class EvidencePackViewerComponent implements OnInit, OnChanges {
private readonly api = inject(EVIDENCE_PACK_API);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
@Input() packId?: string;
@Input() initialPack?: EvidencePack;
@Output() navigateToRun = new EventEmitter<string>();
@Output() exported = new EventEmitter<{ format: EvidencePackExportFormat; blob: Blob }>();
readonly pack = signal<EvidencePack | null>(null);
readonly signedPack = signal<SignedEvidencePack | null>(null);
readonly verificationResult = signal<EvidencePackVerificationResult | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly signing = signal(false);
readonly verifying = signal(false);
readonly showExportMenu = signal(false);
readonly isSigned = computed(() => this.signedPack() !== null);
ngOnInit(): void {
// Read packId from route params if not provided via Input
this.route.paramMap.subscribe((params) => {
const routePackId = params.get('packId');
if (routePackId && !this.packId) {
this.packId = routePackId;
this.loadPack();
}
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['packId'] && this.packId) {
this.loadPack();
} else if (changes['initialPack'] && this.initialPack) {
this.pack.set(this.initialPack);
}
}
loadPack(): void {
if (!this.packId) return;
this.loading.set(true);
this.error.set(null);
this.api.get(this.packId).subscribe({
next: (pack) => {
this.pack.set(pack);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load evidence pack');
this.loading.set(false);
},
});
}
onSign(): void {
const p = this.pack();
if (!p) return;
this.signing.set(true);
this.api.sign(p.packId).subscribe({
next: (signed) => {
this.signedPack.set(signed);
this.signing.set(false);
},
error: (err) => {
console.error('Failed to sign pack:', err);
this.signing.set(false);
},
});
}
onVerify(): void {
const p = this.pack();
if (!p) return;
this.verifying.set(true);
this.api.verify(p.packId).subscribe({
next: (result) => {
this.verificationResult.set(result);
this.verifying.set(false);
},
error: (err) => {
console.error('Failed to verify pack:', err);
this.verifying.set(false);
},
});
}
toggleExportMenu(): void {
this.showExportMenu.update((v) => !v);
}
onExport(format: EvidencePackExportFormat): void {
const p = this.pack();
if (!p) return;
this.showExportMenu.set(false);
this.api.export(p.packId, format).subscribe({
next: (blob) => {
this.exported.emit({ format, blob });
this.downloadBlob(blob, `evidence-pack-${p.packId}.${this.getExtension(format)}`);
},
error: (err) => {
console.error('Failed to export pack:', err);
},
});
}
scrollToEvidence(evidenceId: string): void {
const el = document.getElementById(`ev-${evidenceId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight');
setTimeout(() => el.classList.remove('highlight'), 2000);
}
}
onNavigateToRun(runId: string): void {
this.navigateToRun.emit(runId);
this.router.navigate(['/ai-runs', runId]);
}
private downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
private getExtension(format: EvidencePackExportFormat): string {
switch (format) {
case 'Json':
case 'SignedJson':
return 'json';
case 'Markdown':
return 'md';
case 'Html':
return 'html';
case 'Pdf':
return 'pdf';
default:
return 'json';
}
}
}

View File

@@ -0,0 +1,7 @@
/**
* Evidence Pack Feature Module
* Sprint: SPRINT_20260109_011_005 Task: EVPK-008 (Frontend Unblock)
*/
export * from './evidence-pack-viewer.component';
export * from './evidence-pack-list.component';

View File

@@ -0,0 +1,158 @@
/**
* @file evidence-card.component.spec.ts
* @sprint SPRINT_20260107_006_005_FE (OM-FE-005)
* @description Unit tests for EvidenceCardComponent.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EvidenceCardComponent } from './evidence-card.component';
import { PlaybookEvidence } from '../../models/playbook.models';
describe('EvidenceCardComponent', () => {
let component: EvidenceCardComponent;
let fixture: ComponentFixture<EvidenceCardComponent>;
const mockEvidence: PlaybookEvidence = {
memoryId: 'mem-abc123',
cveId: 'CVE-2023-44487',
action: 'accept_risk',
outcome: 'success',
resolutionTime: 'PT4H',
similarity: 0.92,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EvidenceCardComponent],
}).compileComponents();
fixture = TestBed.createComponent(EvidenceCardComponent);
component = fixture.componentInstance;
});
it('should create', () => {
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
expect(component).toBeTruthy();
});
describe('display', () => {
beforeEach(() => {
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
});
it('should display CVE ID', () => {
const cve = fixture.nativeElement.querySelector('.evidence-card__cve');
expect(cve.textContent).toBe('CVE-2023-44487');
});
it('should display similarity percentage', () => {
const similarity = fixture.nativeElement.querySelector(
'.evidence-card__similarity'
);
expect(similarity.textContent).toContain('92%');
});
it('should display action label', () => {
expect(component.actionLabel()).toBe('Accept Risk');
});
it('should display outcome status', () => {
expect(component.outcomeDisplay().label).toBe('Successful');
expect(component.outcomeDisplay().color).toBe('success');
});
});
describe('resolution time formatting', () => {
it('should format hours', () => {
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
expect(component.formattedResolutionTime()).toBe('4h');
});
it('should format days and hours', () => {
const evidenceWithDays: PlaybookEvidence = {
...mockEvidence,
resolutionTime: 'P1DT4H',
};
fixture.componentRef.setInput('evidence', evidenceWithDays);
fixture.detectChanges();
expect(component.formattedResolutionTime()).toBe('1d 4h');
});
it('should format minutes', () => {
const evidenceWithMinutes: PlaybookEvidence = {
...mockEvidence,
resolutionTime: 'PT30M',
};
fixture.componentRef.setInput('evidence', evidenceWithMinutes);
fixture.detectChanges();
expect(component.formattedResolutionTime()).toBe('30m');
});
it('should return Immediate for very short durations', () => {
const evidenceImmediate: PlaybookEvidence = {
...mockEvidence,
resolutionTime: 'PT0S',
};
fixture.componentRef.setInput('evidence', evidenceImmediate);
fixture.detectChanges();
expect(component.formattedResolutionTime()).toBe('Immediate');
});
});
describe('outcome colors', () => {
it('should show success styling for successful outcomes', () => {
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
const card = fixture.nativeElement.querySelector('.evidence-card');
expect(card.classList.contains('evidence-card--success')).toBe(true);
});
it('should show warning styling for partial success', () => {
const partialEvidence: PlaybookEvidence = {
...mockEvidence,
outcome: 'partial_success',
};
fixture.componentRef.setInput('evidence', partialEvidence);
fixture.detectChanges();
const card = fixture.nativeElement.querySelector('.evidence-card');
expect(card.classList.contains('evidence-card--warning')).toBe(true);
});
it('should show error styling for failed outcomes', () => {
const failedEvidence: PlaybookEvidence = {
...mockEvidence,
outcome: 'failure',
};
fixture.componentRef.setInput('evidence', failedEvidence);
fixture.detectChanges();
const card = fixture.nativeElement.querySelector('.evidence-card');
expect(card.classList.contains('evidence-card--error')).toBe(true);
});
});
describe('view details', () => {
it('should emit viewDetails event when link clicked', () => {
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
const detailsSpy = jest.spyOn(component.viewDetails, 'emit');
component.onViewDetails();
expect(detailsSpy).toHaveBeenCalled();
});
it('should have accessible link', () => {
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.evidence-card__link');
expect(link.getAttribute('aria-label')).toContain('mem-abc123');
});
});
});

View File

@@ -0,0 +1,283 @@
/**
* @file evidence-card.component.ts
* @sprint SPRINT_20260107_006_005_FE (OM-FE-004)
* @description Component for displaying a single past decision evidence from OpsMemory.
*/
import {
Component,
ChangeDetectionStrategy,
input,
output,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
PlaybookEvidence,
getActionLabel,
getOutcomeDisplay,
} from '../../models/playbook.models';
/**
* Component to display individual past decision evidence.
* Shows CVE, action taken, outcome status, resolution time, and similarity score.
*/
@Component({
selector: 'stellaops-evidence-card',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="evidence-card"
[class.evidence-card--success]="outcomeDisplay().color === 'success'"
[class.evidence-card--warning]="outcomeDisplay().color === 'warning'"
[class.evidence-card--error]="outcomeDisplay().color === 'error'"
>
<div class="evidence-card__header">
<span class="evidence-card__cve">{{ evidence().cveId }}</span>
<span class="evidence-card__similarity">{{ similarityPercent() }}% similar</span>
</div>
<div class="evidence-card__body">
<div class="evidence-card__row">
<span class="evidence-card__label">Action:</span>
<span class="evidence-card__value">{{ actionLabel() }}</span>
</div>
<div class="evidence-card__row">
<span class="evidence-card__label">Outcome:</span>
<span
class="evidence-card__outcome"
[class]="'evidence-card__outcome--' + outcomeDisplay().color"
>
<span class="evidence-card__outcome-icon">
@switch (outcomeDisplay().color) {
@case ('success') {
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M10.28 2.28L4.5 8.06 2.22 5.78a.75.75 0 00-1.06 1.06l3 3a.75.75 0 001.06 0l6.5-6.5a.75.75 0 00-1.06-1.06z"/>
</svg>
}
@case ('error') {
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M3.28 2.22a.75.75 0 00-1.06 1.06L4.94 6 2.22 8.72a.75.75 0 101.06 1.06L6 7.06l2.72 2.72a.75.75 0 101.06-1.06L7.06 6l2.72-2.72a.75.75 0 00-1.06-1.06L6 4.94 3.28 2.22z"/>
</svg>
}
@default {
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<circle cx="6" cy="6" r="4"/>
</svg>
}
}
</span>
{{ outcomeDisplay().label }}
</span>
</div>
<div class="evidence-card__row">
<span class="evidence-card__label">Resolution:</span>
<span class="evidence-card__value">{{ formattedResolutionTime() }}</span>
</div>
</div>
<button
class="evidence-card__link"
(click)="onViewDetails()"
[attr.aria-label]="'View details for decision ' + evidence().memoryId"
>
View Original Decision
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M3.5 1.5a.75.75 0 00-.75.75v7.5c0 .414.336.75.75.75h5a.75.75 0 00.75-.75V6.25a.75.75 0 011.5 0v3.5A2.25 2.25 0 018.5 12h-5A2.25 2.25 0 011.25 9.75v-7.5A2.25 2.25 0 013.5 0h3.5a.75.75 0 010 1.5H3.5zm5.25-.25a.75.75 0 01.75-.75h2a.75.75 0 01.75.75v2a.75.75 0 01-1.5 0V2.56L7.28 6.03a.75.75 0 01-1.06-1.06l3.47-3.47H8.75a.75.75 0 01-.75-.75z"/>
</svg>
</button>
</div>
`,
styles: [
`
.evidence-card {
background: var(--surface-secondary, #f8f9fa);
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
border-left: 3px solid var(--border-default, #ddd);
}
.evidence-card--success {
border-left-color: var(--semantic-success, #2e7d32);
}
.evidence-card--warning {
border-left-color: var(--semantic-warning, #f57c00);
}
.evidence-card--error {
border-left-color: var(--semantic-error, #c62828);
}
.evidence-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.evidence-card__cve {
font-family: var(--font-mono, monospace);
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #333);
}
.evidence-card__similarity {
font-size: 12px;
padding: 2px 8px;
background: var(--accent-primary, #1976d2);
color: white;
border-radius: 10px;
}
.evidence-card__body {
display: flex;
flex-direction: column;
gap: 4px;
}
.evidence-card__row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.evidence-card__label {
color: var(--text-secondary, #666);
min-width: 70px;
}
.evidence-card__value {
color: var(--text-primary, #333);
}
.evidence-card__outcome {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.evidence-card__outcome--success {
background: var(--semantic-success-light, #e8f5e9);
color: var(--semantic-success, #2e7d32);
}
.evidence-card__outcome--warning {
background: var(--semantic-warning-light, #fff3e0);
color: var(--semantic-warning, #f57c00);
}
.evidence-card__outcome--error {
background: var(--semantic-error-light, #ffebee);
color: var(--semantic-error, #c62828);
}
.evidence-card__outcome--neutral {
background: var(--surface-secondary, #f5f5f5);
color: var(--text-secondary, #666);
}
.evidence-card__outcome-icon {
display: flex;
align-items: center;
}
.evidence-card__link {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 8px;
padding: 0;
background: none;
border: none;
color: var(--accent-primary, #1976d2);
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.evidence-card__link:hover {
text-decoration: underline;
}
`,
],
})
export class EvidenceCardComponent {
/** The evidence to display */
readonly evidence = input.required<PlaybookEvidence>();
/** Emits when user wants to view full details */
readonly viewDetails = output<void>();
/** Computed action label */
readonly actionLabel = computed(() => getActionLabel(this.evidence().action));
/** Computed outcome display */
readonly outcomeDisplay = computed(() =>
getOutcomeDisplay(this.evidence().outcome)
);
/** Similarity as percentage */
readonly similarityPercent = computed(() =>
Math.round(this.evidence().similarity * 100)
);
/** Formatted resolution time */
readonly formattedResolutionTime = computed(() => {
const duration = this.evidence().resolutionTime;
return this.formatIsoDuration(duration);
});
/**
* Emit view details event.
*/
onViewDetails(): void {
this.viewDetails.emit();
}
/**
* Format ISO 8601 duration to human-readable string.
* Handles formats like PT4H, PT30M, PT1H30M, P1D, etc.
*/
private formatIsoDuration(iso: string): string {
if (!iso || !iso.startsWith('P')) {
return iso || 'N/A';
}
const parts: string[] = [];
// Extract days
const dayMatch = iso.match(/(\d+)D/);
if (dayMatch) {
const days = parseInt(dayMatch[1], 10);
parts.push(`${days}d`);
}
// Extract hours
const hourMatch = iso.match(/(\d+)H/);
if (hourMatch) {
const hours = parseInt(hourMatch[1], 10);
parts.push(`${hours}h`);
}
// Extract minutes
const minMatch = iso.match(/(\d+)M/);
if (minMatch && !iso.includes('M/')) {
const mins = parseInt(minMatch[1], 10);
parts.push(`${mins}m`);
}
return parts.length > 0 ? parts.join(' ') : 'Immediate';
}
}

View File

@@ -0,0 +1,14 @@
/**
* @file index.ts
* @sprint SPRINT_20260107_006_005_FE
* @description OpsMemory feature module exports.
*/
// Models
export * from './models/playbook.models';
// Services
export { PlaybookSuggestionService } from './services/playbook-suggestion.service';
// Components
export { EvidenceCardComponent } from './components/evidence-card/evidence-card.component';

View File

@@ -0,0 +1,130 @@
/**
* @file playbook.models.ts
* @sprint SPRINT_20260107_006_005_FE (OM-FE-001)
* @description TypeScript interfaces matching OpsMemory API responses.
*/
/**
* A single playbook suggestion from OpsMemory.
*/
export interface PlaybookSuggestion {
/** The recommended action to take */
suggestedAction: DecisionAction;
/** Confidence score (0-1) */
confidence: number;
/** Human-readable rationale for the suggestion */
rationale: string;
/** Number of similar past decisions */
evidenceCount: number;
/** Factors that contributed to the match */
matchingFactors: string[];
/** Evidence from past decisions */
evidence: PlaybookEvidence[];
}
/**
* Evidence from a past decision that supports the suggestion.
*/
export interface PlaybookEvidence {
/** OpsMemory record ID */
memoryId: string;
/** CVE ID from the past decision */
cveId: string;
/** Action that was taken */
action: DecisionAction;
/** Outcome of the decision */
outcome: OutcomeStatus;
/** Time to resolution (ISO 8601 duration) */
resolutionTime: string;
/** Similarity score to current situation */
similarity: number;
}
/**
* Response from the suggestions API.
*/
export interface PlaybookSuggestionsResponse {
/** List of suggestions, ordered by confidence */
suggestions: PlaybookSuggestion[];
/** Hash of the situation for caching */
situationHash: string;
}
/**
* Query parameters for the suggestions API.
*/
export interface PlaybookSuggestionsQuery {
/** Tenant ID (required) */
tenantId: string;
/** CVE ID to get suggestions for */
cveId?: string;
/** Severity level */
severity?: 'critical' | 'high' | 'medium' | 'low';
/** Reachability status */
reachability?: 'reachable' | 'unreachable' | 'unknown';
/** Component type (ecosystem) */
componentType?: string;
/** Context tags (comma-separated) */
contextTags?: string;
/** Maximum number of suggestions (default: 3) */
maxResults?: number;
/** Minimum confidence threshold (default: 0.5) */
minConfidence?: number;
}
/**
* Decision action types.
*/
export type DecisionAction =
| 'accept_risk'
| 'target_fix'
| 'quarantine'
| 'patch_now'
| 'defer'
| 'investigate';
/**
* Outcome status types.
*/
export type OutcomeStatus =
| 'success'
| 'partial_success'
| 'failure'
| 'pending'
| 'unknown';
/**
* Maps decision action to display label.
*/
export function getActionLabel(action: DecisionAction): string {
const labels: Record<DecisionAction, string> = {
accept_risk: 'Accept Risk',
target_fix: 'Target Fix',
quarantine: 'Quarantine',
patch_now: 'Patch Now',
defer: 'Defer',
investigate: 'Investigate',
};
return labels[action] ?? action;
}
/**
* Maps outcome status to display label and color.
*/
export function getOutcomeDisplay(outcome: OutcomeStatus): {
label: string;
color: 'success' | 'warning' | 'error' | 'neutral';
} {
switch (outcome) {
case 'success':
return { label: 'Successful', color: 'success' };
case 'partial_success':
return { label: 'Partial Success', color: 'warning' };
case 'failure':
return { label: 'Failed', color: 'error' };
case 'pending':
return { label: 'Pending', color: 'neutral' };
default:
return { label: 'Unknown', color: 'neutral' };
}
}

View File

@@ -0,0 +1,204 @@
/**
* @file playbook-suggestion.service.spec.ts
* @sprint SPRINT_20260107_006_005_FE (OM-FE-005)
* @description Unit tests for PlaybookSuggestionService.
*/
import { TestBed } from '@angular/core/testing';
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import { PlaybookSuggestionService } from './playbook-suggestion.service';
import {
PlaybookSuggestionsResponse,
PlaybookSuggestionsQuery,
} from '../models/playbook.models';
describe('PlaybookSuggestionService', () => {
let service: PlaybookSuggestionService;
let httpMock: HttpTestingController;
const mockResponse: PlaybookSuggestionsResponse = {
suggestions: [
{
suggestedAction: 'accept_risk',
confidence: 0.85,
rationale: 'Similar situations resolved successfully with risk acceptance',
evidenceCount: 5,
matchingFactors: ['severity', 'reachability'],
evidence: [
{
memoryId: 'mem-abc123',
cveId: 'CVE-2023-44487',
action: 'accept_risk',
outcome: 'success',
resolutionTime: 'PT4H',
similarity: 0.92,
},
],
},
],
situationHash: 'hash123',
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [PlaybookSuggestionService],
});
service = TestBed.inject(PlaybookSuggestionService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getSuggestions', () => {
const query: PlaybookSuggestionsQuery = {
tenantId: 'tenant-123',
cveId: 'CVE-2023-44487',
severity: 'high',
reachability: 'reachable',
};
it('should fetch suggestions with query parameters', (done) => {
service.getSuggestions(query).subscribe((suggestions) => {
expect(suggestions).toEqual(mockResponse.suggestions);
done();
});
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/opsmemory/suggestions')
);
expect(req.request.method).toBe('GET');
expect(req.request.params.get('tenantId')).toBe('tenant-123');
expect(req.request.params.get('cveId')).toBe('CVE-2023-44487');
expect(req.request.params.get('severity')).toBe('high');
expect(req.request.params.get('reachability')).toBe('reachable');
req.flush(mockResponse);
});
it('should cache responses', (done) => {
// First call
service.getSuggestions(query).subscribe(() => {
// Second call should use cache
service.getSuggestions(query).subscribe((suggestions) => {
expect(suggestions).toEqual(mockResponse.suggestions);
done();
});
});
// Only one HTTP request should be made
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/opsmemory/suggestions')
);
req.flush(mockResponse);
});
it('should handle errors gracefully', (done) => {
service.getSuggestions(query).subscribe({
error: (error) => {
expect(error.message).toBe('Failed to fetch playbook suggestions');
done();
},
});
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/opsmemory/suggestions')
);
req.flush('Server error', { status: 500, statusText: 'Server Error' });
});
it('should handle 401 unauthorized', (done) => {
service.getSuggestions(query).subscribe({
error: (error) => {
expect(error.message).toBe('Not authorized to access OpsMemory');
done();
},
});
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/opsmemory/suggestions')
);
req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
});
it('should include optional parameters', (done) => {
const fullQuery: PlaybookSuggestionsQuery = {
...query,
componentType: 'npm',
contextTags: 'production,payment',
maxResults: 5,
minConfidence: 0.7,
};
service.getSuggestions(fullQuery).subscribe(() => done());
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/opsmemory/suggestions')
);
expect(req.request.params.get('componentType')).toBe('npm');
expect(req.request.params.get('contextTags')).toBe('production,payment');
expect(req.request.params.get('maxResults')).toBe('5');
expect(req.request.params.get('minConfidence')).toBe('0.7');
req.flush(mockResponse);
});
});
describe('clearCache', () => {
it('should clear all cached entries', (done) => {
const query: PlaybookSuggestionsQuery = { tenantId: 'tenant-123' };
// First call to populate cache
service.getSuggestions(query).subscribe(() => {
service.clearCache();
// After clearing, should make new request
service.getSuggestions(query).subscribe(() => done());
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/opsmemory/suggestions')
);
req.flush(mockResponse);
});
// First request
const req1 = httpMock.expectOne((r) =>
r.url.includes('/api/v1/opsmemory/suggestions')
);
req1.flush(mockResponse);
});
});
describe('invalidate', () => {
it('should invalidate specific cache entry', (done) => {
const query: PlaybookSuggestionsQuery = { tenantId: 'tenant-123' };
// First call to populate cache
service.getSuggestions(query).subscribe(() => {
service.invalidate(query);
// After invalidating, should make new request
service.getSuggestions(query).subscribe(() => done());
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/opsmemory/suggestions')
);
req.flush(mockResponse);
});
// First request
const req1 = httpMock.expectOne((r) =>
r.url.includes('/api/v1/opsmemory/suggestions')
);
req1.flush(mockResponse);
});
});
});

View File

@@ -0,0 +1,185 @@
/**
* @file playbook-suggestion.service.ts
* @sprint SPRINT_20260107_006_005_FE (OM-FE-001)
* @description Angular service to fetch playbook suggestions from OpsMemory API.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
import {
Observable,
catchError,
map,
retry,
shareReplay,
throwError,
of,
timer,
} from 'rxjs';
import {
PlaybookSuggestionsResponse,
PlaybookSuggestionsQuery,
PlaybookSuggestion,
} from '../models/playbook.models';
/**
* Cache entry for suggestions.
*/
interface CacheEntry {
response: PlaybookSuggestionsResponse;
timestamp: number;
}
/**
* Service for fetching playbook suggestions from OpsMemory.
*/
@Injectable({
providedIn: 'root',
})
export class PlaybookSuggestionService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/opsmemory';
private readonly cacheDurationMs = 5 * 60 * 1000; // 5 minutes
private readonly maxRetries = 2;
private readonly retryDelayMs = 1000;
/** Cache keyed by situation hash */
private readonly cache = new Map<string, CacheEntry>();
/**
* Get playbook suggestions for a given situation.
*/
getSuggestions(
query: PlaybookSuggestionsQuery
): Observable<PlaybookSuggestion[]> {
const cacheKey = this.buildCacheKey(query);
const cached = this.cache.get(cacheKey);
// Return cached if valid
if (cached && Date.now() - cached.timestamp < this.cacheDurationMs) {
return of(cached.response.suggestions);
}
const params = this.buildParams(query);
return this.http
.get<PlaybookSuggestionsResponse>(`${this.baseUrl}/suggestions`, {
params,
})
.pipe(
retry({
count: this.maxRetries,
delay: (error, retryCount) => {
// Only retry on transient errors
if (this.isTransientError(error)) {
return timer(this.retryDelayMs * retryCount);
}
return throwError(() => error);
},
}),
map((response) => {
// Cache the response
this.cache.set(cacheKey, {
response,
timestamp: Date.now(),
});
return response.suggestions;
}),
catchError((error) => this.handleError(error)),
shareReplay({ bufferSize: 1, refCount: true })
);
}
/**
* Clear the suggestion cache.
*/
clearCache(): void {
this.cache.clear();
}
/**
* Clear cached entry for a specific query.
*/
invalidate(query: PlaybookSuggestionsQuery): void {
const cacheKey = this.buildCacheKey(query);
this.cache.delete(cacheKey);
}
/**
* Build HTTP params from query object.
*/
private buildParams(query: PlaybookSuggestionsQuery): HttpParams {
let params = new HttpParams().set('tenantId', query.tenantId);
if (query.cveId) {
params = params.set('cveId', query.cveId);
}
if (query.severity) {
params = params.set('severity', query.severity);
}
if (query.reachability) {
params = params.set('reachability', query.reachability);
}
if (query.componentType) {
params = params.set('componentType', query.componentType);
}
if (query.contextTags) {
params = params.set('contextTags', query.contextTags);
}
if (query.maxResults !== undefined) {
params = params.set('maxResults', query.maxResults.toString());
}
if (query.minConfidence !== undefined) {
params = params.set('minConfidence', query.minConfidence.toString());
}
return params;
}
/**
* Build cache key from query parameters.
*/
private buildCacheKey(query: PlaybookSuggestionsQuery): string {
return JSON.stringify({
tenantId: query.tenantId,
cveId: query.cveId,
severity: query.severity,
reachability: query.reachability,
componentType: query.componentType,
contextTags: query.contextTags,
});
}
/**
* Check if error is transient and worth retrying.
*/
private isTransientError(error: HttpErrorResponse): boolean {
// Retry on 5xx server errors or network errors
return (
error.status === 0 || // Network error
error.status === 502 || // Bad Gateway
error.status === 503 || // Service Unavailable
error.status === 504 // Gateway Timeout
);
}
/**
* Handle HTTP errors.
*/
private handleError(error: HttpErrorResponse): Observable<never> {
let message = 'Failed to fetch playbook suggestions';
if (error.status === 0) {
message = 'Unable to connect to OpsMemory service';
} else if (error.status === 401) {
message = 'Not authorized to access OpsMemory';
} else if (error.status === 404) {
message = 'OpsMemory service not found';
} else if (error.error?.message) {
message = error.error.message;
}
console.error('PlaybookSuggestionService error:', message, error);
return throwError(() => new Error(message));
}
}

View File

@@ -0,0 +1,294 @@
/**
* @file cdx-evidence-panel.component.spec.ts
* @sprint SPRINT_20260107_005_004_FE (UI-011)
* @description Unit tests for CdxEvidencePanelComponent.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CdxEvidencePanelComponent } from './cdx-evidence-panel.component';
import { ComponentEvidence, OccurrenceEvidence } from '../../models/cyclonedx-evidence.models';
describe('CdxEvidencePanelComponent', () => {
let component: CdxEvidencePanelComponent;
let fixture: ComponentFixture<CdxEvidencePanelComponent>;
const mockEvidence: ComponentEvidence = {
identity: {
field: 'purl',
confidence: 0.95,
methods: [
{ technique: 'manifest-analysis', confidence: 0.95, value: 'pkg:npm/lodash@4.17.21' },
],
},
occurrences: [
{ location: '/node_modules/lodash/index.js', line: 42 },
{ location: '/node_modules/lodash/lodash.min.js' },
{ location: '/node_modules/lodash/package.json' },
],
licenses: [
{
license: { id: 'MIT', url: 'https://opensource.org/licenses/MIT' },
acknowledgement: 'declared',
},
],
copyright: [{ text: 'Copyright 2024 Lodash Contributors' }],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CdxEvidencePanelComponent],
}).compileComponents();
fixture = TestBed.createComponent(CdxEvidencePanelComponent);
component = fixture.componentInstance;
});
describe('basic rendering', () => {
it('should create', () => {
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should display panel header', () => {
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.evidence-panel__title');
expect(header.textContent).toBe('EVIDENCE');
});
it('should show empty state when no evidence', () => {
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('evidence', undefined);
fixture.detectChanges();
const empty = fixture.nativeElement.querySelector('.evidence-empty');
expect(empty).toBeTruthy();
});
});
describe('identity section', () => {
beforeEach(() => {
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
});
it('should display identity section', () => {
const identitySection = fixture.nativeElement.querySelector('.identity-card');
expect(identitySection).toBeTruthy();
});
it('should display confidence badge', () => {
const badge = fixture.nativeElement.querySelector('app-evidence-confidence-badge');
expect(badge).toBeTruthy();
});
it('should display detection methods', () => {
const methods = fixture.nativeElement.querySelectorAll('.identity-method');
expect(methods.length).toBe(1);
expect(methods[0].textContent).toContain('Manifest Analysis');
});
it('should be expanded by default', () => {
expect(component.isSectionExpanded('identity')).toBe(true);
});
});
describe('occurrences section', () => {
beforeEach(() => {
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
});
it('should display occurrences count in header', () => {
// First expand the section
component.toggleSection('occurrences');
fixture.detectChanges();
const header = fixture.nativeElement.querySelector(
'[aria-controls="occurrences-content"]'
);
expect(header.textContent).toContain('Occurrences (3)');
});
it('should display occurrence items when expanded', () => {
component.toggleSection('occurrences');
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.occurrence-item');
expect(items.length).toBe(3);
});
it('should display line number when available', () => {
component.toggleSection('occurrences');
fixture.detectChanges();
const lineNumbers = fixture.nativeElement.querySelectorAll('.occurrence-item__line');
expect(lineNumbers.length).toBe(1);
expect(lineNumbers[0].textContent).toBe(':42');
});
it('should emit viewOccurrence when View button clicked', () => {
component.toggleSection('occurrences');
fixture.detectChanges();
const emitSpy = jest.spyOn(component.viewOccurrence, 'emit');
const viewBtn = fixture.nativeElement.querySelector('.occurrence-item__view-btn');
viewBtn.click();
expect(emitSpy).toHaveBeenCalledWith(mockEvidence.occurrences![0]);
});
});
describe('licenses section', () => {
beforeEach(() => {
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
});
it('should display license items when expanded', () => {
component.toggleSection('licenses');
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.license-item');
expect(items.length).toBe(1);
});
it('should display license ID', () => {
component.toggleSection('licenses');
fixture.detectChanges();
const licenseId = fixture.nativeElement.querySelector('.license-item__id');
expect(licenseId.textContent).toBe('MIT');
});
it('should display acknowledgement', () => {
component.toggleSection('licenses');
fixture.detectChanges();
const ack = fixture.nativeElement.querySelector('.license-item__ack');
expect(ack.textContent).toContain('declared');
});
it('should display external link when URL available', () => {
component.toggleSection('licenses');
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.license-item__link');
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toBe('https://opensource.org/licenses/MIT');
});
});
describe('copyright section', () => {
beforeEach(() => {
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
});
it('should display copyright items when expanded', () => {
component.toggleSection('copyright');
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.copyright-item');
expect(items.length).toBe(1);
expect(items[0].textContent).toContain('Copyright 2024 Lodash Contributors');
});
});
describe('section toggling', () => {
beforeEach(() => {
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
});
it('should toggle section expansion', () => {
expect(component.isSectionExpanded('occurrences')).toBe(false);
component.toggleSection('occurrences');
expect(component.isSectionExpanded('occurrences')).toBe(true);
component.toggleSection('occurrences');
expect(component.isSectionExpanded('occurrences')).toBe(false);
});
it('should expand all sections with toggleAll', () => {
// Collapse identity first
component.toggleSection('identity');
fixture.detectChanges();
// Toggle all
component.toggleAll();
fixture.detectChanges();
expect(component.isSectionExpanded('identity')).toBe(true);
expect(component.isSectionExpanded('occurrences')).toBe(true);
expect(component.isSectionExpanded('licenses')).toBe(true);
expect(component.isSectionExpanded('copyright')).toBe(true);
});
it('should collapse all sections with toggleAll when all expanded', () => {
// Expand all first
component.toggleSection('occurrences');
component.toggleSection('licenses');
component.toggleSection('copyright');
fixture.detectChanges();
// Toggle all (should collapse)
component.toggleAll();
fixture.detectChanges();
expect(component.isSectionExpanded('identity')).toBe(false);
expect(component.isSectionExpanded('occurrences')).toBe(false);
expect(component.isSectionExpanded('licenses')).toBe(false);
expect(component.isSectionExpanded('copyright')).toBe(false);
});
});
describe('accessibility', () => {
beforeEach(() => {
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
});
it('should have aria-label on panel', () => {
const panel = fixture.nativeElement.querySelector('.evidence-panel');
expect(panel.getAttribute('aria-label')).toContain('Evidence for');
});
it('should have aria-expanded on section headers', () => {
const header = fixture.nativeElement.querySelector('[aria-controls="identity-content"]');
expect(header.getAttribute('aria-expanded')).toBe('true');
});
it('should have aria-label on occurrence view buttons', () => {
component.toggleSection('occurrences');
fixture.detectChanges();
const viewBtn = fixture.nativeElement.querySelector('.occurrence-item__view-btn');
expect(viewBtn.getAttribute('aria-label')).toContain('View');
});
});
describe('technique labels', () => {
it('should return correct label for manifest-analysis', () => {
expect(component.getTechniqueLabel('manifest-analysis')).toBe('Manifest Analysis');
});
it('should return correct label for binary-analysis', () => {
expect(component.getTechniqueLabel('binary-analysis')).toBe('Binary Analysis');
});
it('should return Other for unknown technique', () => {
expect(component.getTechniqueLabel('unknown-technique' as any)).toBe('Other');
});
});
});

View File

@@ -0,0 +1,613 @@
/**
* @file cdx-evidence-panel.component.ts
* @sprint SPRINT_20260107_005_004_FE (UI-001)
* @description Panel component for displaying CycloneDX 1.7 evidence data.
* Shows identity evidence, occurrences, licenses, and copyright information.
*/
import { Component, computed, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ComponentEvidence,
IdentityEvidence,
OccurrenceEvidence,
LicenseEvidence,
CopyrightEvidence,
getIdentityTechniqueLabel,
} from '../../models/cyclonedx-evidence.models';
import { EvidenceConfidenceBadgeComponent } from '../evidence-confidence-badge/evidence-confidence-badge.component';
type SectionId = 'identity' | 'occurrences' | 'licenses' | 'copyright';
/**
* Panel component for displaying CycloneDX 1.7 component evidence.
*
* Features:
* - Identity evidence with confidence badge and detection methods
* - Occurrence list with file paths and line numbers
* - License evidence with acknowledgement status
* - Copyright evidence list
* - Collapsible sections
* - Full accessibility support (ARIA labels, keyboard navigation)
*
* @example
* <app-cdx-evidence-panel
* [purl]="component.purl"
* [evidence]="component.evidence"
* (viewOccurrence)="onViewOccurrence($event)"
* />
*/
@Component({
selector: 'app-cdx-evidence-panel',
standalone: true,
imports: [CommonModule, EvidenceConfidenceBadgeComponent],
template: `
<section class="evidence-panel" [attr.aria-label]="'Evidence for ' + purl()">
<header class="evidence-panel__header">
<h3 class="evidence-panel__title">EVIDENCE</h3>
<button
type="button"
class="evidence-panel__expand-btn"
[attr.aria-expanded]="allExpanded()"
(click)="toggleAll()"
>
{{ allExpanded() ? 'Collapse All' : 'Expand All' }}
</button>
</header>
@if (evidence(); as ev) {
<!-- Identity Section -->
@if (ev.identity) {
<section class="evidence-section" [attr.aria-expanded]="isSectionExpanded('identity')">
<button
type="button"
class="evidence-section__header"
[attr.aria-controls]="'identity-content'"
[attr.aria-expanded]="isSectionExpanded('identity')"
(click)="toggleSection('identity')"
>
<span class="evidence-section__title">Identity</span>
<span class="evidence-section__icon" [class.rotated]="isSectionExpanded('identity')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.5 6L8 9.5L11.5 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>
</span>
</button>
@if (isSectionExpanded('identity')) {
<div id="identity-content" class="evidence-section__content">
<div class="identity-card">
<div class="identity-card__row">
<span class="identity-card__label">{{ ev.identity.field | uppercase }}:</span>
<code class="identity-card__value">{{ identityValue() }}</code>
<app-evidence-confidence-badge
[confidence]="ev.identity.confidence"
[showPercentage]="true"
/>
</div>
@if (ev.identity.methods && ev.identity.methods.length > 0) {
<div class="identity-card__methods">
<span class="identity-card__methods-label">Methods:</span>
@for (method of ev.identity.methods; track method.technique) {
<span class="identity-method">
{{ getTechniqueLabel(method.technique) }}
@if (method.confidence !== undefined) {
<span class="identity-method__conf">
({{ (method.confidence * 100) | number:'1.0-0' }}%)
</span>
}
</span>
}
</div>
}
</div>
</div>
}
</section>
}
<!-- Occurrences Section -->
@if (ev.occurrences && ev.occurrences.length > 0) {
<section class="evidence-section" [attr.aria-expanded]="isSectionExpanded('occurrences')">
<button
type="button"
class="evidence-section__header"
[attr.aria-controls]="'occurrences-content'"
[attr.aria-expanded]="isSectionExpanded('occurrences')"
(click)="toggleSection('occurrences')"
>
<span class="evidence-section__title">
Occurrences ({{ ev.occurrences.length }})
</span>
<span class="evidence-section__icon" [class.rotated]="isSectionExpanded('occurrences')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.5 6L8 9.5L11.5 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>
</span>
</button>
@if (isSectionExpanded('occurrences')) {
<div id="occurrences-content" class="evidence-section__content">
<ul class="occurrence-list" role="list">
@for (occurrence of ev.occurrences; track occurrence.location; let i = $index) {
<li class="occurrence-item">
<code class="occurrence-item__path">{{ occurrence.location }}</code>
@if (occurrence.line) {
<span class="occurrence-item__line">:{{ occurrence.line }}</span>
}
<button
type="button"
class="occurrence-item__view-btn"
(click)="onViewOccurrence(occurrence)"
[attr.aria-label]="'View ' + occurrence.location"
>
View
</button>
</li>
}
</ul>
</div>
}
</section>
}
<!-- Licenses Section -->
@if (ev.licenses && ev.licenses.length > 0) {
<section class="evidence-section" [attr.aria-expanded]="isSectionExpanded('licenses')">
<button
type="button"
class="evidence-section__header"
[attr.aria-controls]="'licenses-content'"
[attr.aria-expanded]="isSectionExpanded('licenses')"
(click)="toggleSection('licenses')"
>
<span class="evidence-section__title">Licenses</span>
<span class="evidence-section__icon" [class.rotated]="isSectionExpanded('licenses')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.5 6L8 9.5L11.5 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>
</span>
</button>
@if (isSectionExpanded('licenses')) {
<div id="licenses-content" class="evidence-section__content">
<ul class="license-list" role="list">
@for (license of ev.licenses; track license.license.id ?? license.license.name) {
<li class="license-item">
<span class="license-item__id">
{{ license.license.id ?? license.license.name }}
</span>
<span class="license-item__ack" [class]="'ack--' + license.acknowledgement">
({{ license.acknowledgement }})
</span>
@if (license.license.url) {
<a
class="license-item__link"
[href]="license.license.url"
target="_blank"
rel="noopener noreferrer"
[attr.aria-label]="'View ' + (license.license.id ?? license.license.name) + ' license'"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M10 2H14V6M14 2L7 9M12 9V14H2V4H7"/>
</svg>
</a>
}
</li>
}
</ul>
</div>
}
</section>
}
<!-- Copyright Section -->
@if (ev.copyright && ev.copyright.length > 0) {
<section class="evidence-section" [attr.aria-expanded]="isSectionExpanded('copyright')">
<button
type="button"
class="evidence-section__header"
[attr.aria-controls]="'copyright-content'"
[attr.aria-expanded]="isSectionExpanded('copyright')"
(click)="toggleSection('copyright')"
>
<span class="evidence-section__title">Copyright</span>
<span class="evidence-section__icon" [class.rotated]="isSectionExpanded('copyright')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.5 6L8 9.5L11.5 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>
</span>
</button>
@if (isSectionExpanded('copyright')) {
<div id="copyright-content" class="evidence-section__content">
<ul class="copyright-list" role="list">
@for (cr of ev.copyright; track cr.text) {
<li class="copyright-item">{{ cr.text }}</li>
}
</ul>
</div>
}
</section>
}
<!-- No evidence message -->
@if (!ev.identity && (!ev.occurrences || ev.occurrences.length === 0) &&
(!ev.licenses || ev.licenses.length === 0) &&
(!ev.copyright || ev.copyright.length === 0)) {
<div class="evidence-empty">
<p>No evidence data available for this component.</p>
</div>
}
} @else {
<div class="evidence-empty">
<p>No evidence data available.</p>
</div>
}
</section>
`,
styles: [`
.evidence-panel {
background: var(--surface-secondary, #f9fafb);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.evidence-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--surface-primary, #ffffff);
border-bottom: 1px solid var(--border-default, #e5e7eb);
}
.evidence-panel__title {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--text-secondary, #6b7280);
}
.evidence-panel__expand-btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: var(--text-link, #2563eb);
background: transparent;
border: none;
cursor: pointer;
transition: color 0.15s;
&:hover {
color: var(--text-link-hover, #1d4ed8);
}
}
.evidence-section {
border-bottom: 1px solid var(--border-light, #f3f4f6);
&:last-child {
border-bottom: none;
}
}
.evidence-section__header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background-color 0.15s;
&:hover {
background: var(--surface-hover, #f3f4f6);
}
&:focus-visible {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: -2px;
}
}
.evidence-section__title {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #111827);
}
.evidence-section__icon {
transition: transform 0.2s;
color: var(--text-muted, #9ca3af);
&.rotated {
transform: rotate(180deg);
}
}
.evidence-section__content {
padding: 0 1rem 1rem;
}
/* Identity Card */
.identity-card {
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
padding: 0.75rem;
}
.identity-card__row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.identity-card__label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
}
.identity-card__value {
font-size: 0.8125rem;
color: var(--text-primary, #111827);
background: var(--code-bg, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 4px;
}
.identity-card__methods {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.identity-card__methods-label {
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
}
.identity-method {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
background: var(--badge-bg, #e5e7eb);
border-radius: 4px;
font-size: 0.6875rem;
}
.identity-method__conf {
color: var(--text-muted, #9ca3af);
}
/* Occurrence List */
.occurrence-list {
list-style: none;
margin: 0;
padding: 0;
}
.occurrence-item {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
.occurrence-item__path {
flex: 1;
font-size: 0.8125rem;
color: var(--text-primary, #111827);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.occurrence-item__line {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
.occurrence-item__view-btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: var(--text-link, #2563eb);
background: transparent;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--surface-hover, #f3f4f6);
border-color: var(--text-link, #2563eb);
}
}
/* License List */
.license-list {
list-style: none;
margin: 0;
padding: 0;
}
.license-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
.license-item__id {
font-weight: 500;
color: var(--text-primary, #111827);
}
.license-item__ack {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
&.ack--declared {
color: var(--semantic-info, #2563eb);
}
&.ack--concluded {
color: var(--semantic-success, #16a34a);
}
}
.license-item__link {
color: var(--text-muted, #9ca3af);
transition: color 0.15s;
&:hover {
color: var(--text-link, #2563eb);
}
}
/* Copyright List */
.copyright-list {
list-style: none;
margin: 0;
padding: 0;
}
.copyright-item {
padding: 0.5rem 0.75rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
font-size: 0.8125rem;
color: var(--text-primary, #111827);
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
/* Empty State */
.evidence-empty {
padding: 2rem 1rem;
text-align: center;
color: var(--text-muted, #9ca3af);
font-size: 0.875rem;
}
`],
})
export class CdxEvidencePanelComponent {
/** Component PURL */
readonly purl = input.required<string>();
/** CycloneDX evidence data */
readonly evidence = input<ComponentEvidence | undefined>(undefined);
/** Emits when user clicks View on an occurrence */
readonly viewOccurrence = output<OccurrenceEvidence>();
/** Emits when user requests to close the panel */
readonly close = output<void>();
/** Expanded sections */
private readonly expandedSections = signal<Set<SectionId>>(new Set(['identity']));
/** Check if section is expanded */
isSectionExpanded(section: SectionId): boolean {
return this.expandedSections().has(section);
}
/** Toggle section expansion */
toggleSection(section: SectionId): void {
this.expandedSections.update((sections) => {
const newSections = new Set(sections);
if (newSections.has(section)) {
newSections.delete(section);
} else {
newSections.add(section);
}
return newSections;
});
}
/** Check if all sections are expanded */
readonly allExpanded = computed(() => {
const expanded = this.expandedSections();
const ev = this.evidence();
if (!ev) return false;
const availableSections: SectionId[] = [];
if (ev.identity) availableSections.push('identity');
if (ev.occurrences?.length) availableSections.push('occurrences');
if (ev.licenses?.length) availableSections.push('licenses');
if (ev.copyright?.length) availableSections.push('copyright');
return availableSections.every((s) => expanded.has(s));
});
/** Toggle all sections */
toggleAll(): void {
const ev = this.evidence();
if (!ev) return;
if (this.allExpanded()) {
this.expandedSections.set(new Set());
} else {
const allSections: SectionId[] = [];
if (ev.identity) allSections.push('identity');
if (ev.occurrences?.length) allSections.push('occurrences');
if (ev.licenses?.length) allSections.push('licenses');
if (ev.copyright?.length) allSections.push('copyright');
this.expandedSections.set(new Set(allSections));
}
}
/** Identity value to display */
readonly identityValue = computed(() => {
const ev = this.evidence();
if (!ev?.identity) return '';
// Try to extract value from methods
const method = ev.identity.methods?.find((m) => m.value);
if (method?.value) return method.value;
// Fallback to PURL
return this.purl();
});
/** Get human-readable technique label */
getTechniqueLabel(technique: string): string {
return getIdentityTechniqueLabel(technique as Parameters<typeof getIdentityTechniqueLabel>[0]);
}
/** Handle view occurrence click */
onViewOccurrence(occurrence: OccurrenceEvidence): void {
this.viewOccurrence.emit(occurrence);
}
}

View File

@@ -0,0 +1,246 @@
/**
* @file commit-info.component.spec.ts
* @sprint SPRINT_20260107_005_004_FE (UI-011)
* @description Unit tests for CommitInfoComponent.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CommitInfoComponent } from './commit-info.component';
import { PedigreeCommit } from '../../models/cyclonedx-evidence.models';
describe('CommitInfoComponent', () => {
let component: CommitInfoComponent;
let fixture: ComponentFixture<CommitInfoComponent>;
const mockCommit: PedigreeCommit = {
uid: 'abc123def456789012345678901234567890abcd',
url: 'https://github.com/example/repo/commit/abc123def456789012345678901234567890abcd',
author: {
name: 'Jane Developer',
email: 'jane@example.com',
timestamp: '2025-12-15T10:30:00Z',
},
committer: {
name: 'Build Bot',
email: 'bot@example.com',
timestamp: '2025-12-15T11:00:00Z',
},
message: `Fix security vulnerability CVE-2025-1234
This patch addresses a critical buffer overflow in the parsing module.
The fix implements proper bounds checking before memory access.
Signed-off-by: Jane Developer <jane@example.com>`,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CommitInfoComponent],
}).compileComponents();
fixture = TestBed.createComponent(CommitInfoComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('commit display', () => {
beforeEach(() => {
fixture.componentRef.setInput('commit', mockCommit);
fixture.detectChanges();
});
it('should display short SHA', () => {
const shaElement = fixture.nativeElement.querySelector('.commit-sha__value');
expect(shaElement.textContent).toBe('abc123d');
});
it('should display author name', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('Jane Developer');
});
it('should display author email', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('jane@example.com');
});
it('should display commit message', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('Fix security vulnerability');
});
it('should display committer when different from author', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('Build Bot');
});
});
describe('short SHA computation', () => {
it('should return first 7 characters', () => {
fixture.componentRef.setInput('commit', mockCommit);
fixture.detectChanges();
expect(component.shortSha()).toBe('abc123d');
});
it('should return empty string for no commit', () => {
fixture.componentRef.setInput('commit', undefined);
fixture.detectChanges();
expect(component.shortSha()).toBe('');
});
});
describe('repository host extraction', () => {
it('should extract github.com from URL', () => {
fixture.componentRef.setInput('commit', mockCommit);
fixture.detectChanges();
expect(component.repoHost()).toBe('github.com');
});
it('should extract gitlab.com from URL', () => {
const gitlabCommit = {
...mockCommit,
url: 'https://gitlab.com/group/project/-/commit/abc123',
};
fixture.componentRef.setInput('commit', gitlabCommit);
fixture.detectChanges();
expect(component.repoHost()).toBe('gitlab.com');
});
it('should return empty string for no URL', () => {
const noUrlCommit = { ...mockCommit, url: undefined };
fixture.componentRef.setInput('commit', noUrlCommit);
fixture.detectChanges();
expect(component.repoHost()).toBe('');
});
});
describe('timestamp formatting', () => {
it('should format ISO timestamp', () => {
const formatted = component.formatTimestamp('2025-12-15T10:30:00Z');
expect(formatted).toContain('2025');
expect(formatted).toContain('Dec');
});
it('should return original for invalid timestamp', () => {
const invalid = 'not-a-date';
// The formatTimestamp catches errors and returns original
expect(component.formatTimestamp(invalid)).toBe(invalid);
});
});
describe('author vs committer', () => {
it('should detect different author and committer', () => {
expect(
component.isDifferentFromAuthor(
mockCommit.author,
mockCommit.committer
)
).toBe(true);
});
it('should not show committer if same as author', () => {
const sameCommit: PedigreeCommit = {
...mockCommit,
committer: mockCommit.author,
};
fixture.componentRef.setInput('commit', sameCommit);
fixture.detectChanges();
const content = fixture.nativeElement.textContent;
expect(content).not.toContain('Committer');
});
});
describe('message truncation', () => {
it('should detect when truncation is needed', () => {
fixture.componentRef.setInput('commit', mockCommit);
fixture.componentRef.setInput('maxLines', 3);
fixture.detectChanges();
expect(component.needsTruncation()).toBe(true);
});
it('should not truncate short messages', () => {
const shortCommit: PedigreeCommit = {
...mockCommit,
message: 'Short message',
};
fixture.componentRef.setInput('commit', shortCommit);
fixture.componentRef.setInput('maxLines', 3);
fixture.detectChanges();
expect(component.needsTruncation()).toBe(false);
});
it('should toggle message expansion', () => {
fixture.componentRef.setInput('commit', mockCommit);
fixture.detectChanges();
expect(component.messageExpanded()).toBe(false);
component.toggleMessage();
expect(component.messageExpanded()).toBe(true);
component.toggleMessage();
expect(component.messageExpanded()).toBe(false);
});
});
describe('copy functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('commit', mockCommit);
fixture.detectChanges();
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
});
it('should copy full SHA to clipboard', async () => {
await component.copySha();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockCommit.uid);
});
it('should set copied state', async () => {
await component.copySha();
expect(component.copied()).toBe(true);
});
});
describe('empty state', () => {
it('should show empty message when no commit', () => {
fixture.componentRef.setInput('commit', undefined);
fixture.detectChanges();
const empty = fixture.nativeElement.querySelector('.commit-empty');
expect(empty).toBeTruthy();
});
});
describe('external link', () => {
beforeEach(() => {
fixture.componentRef.setInput('commit', mockCommit);
fixture.detectChanges();
});
it('should have link to upstream repository', () => {
const link = fixture.nativeElement.querySelector('.commit-sha__link');
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toBe(mockCommit.url);
});
it('should open in new tab', () => {
const link = fixture.nativeElement.querySelector('.commit-sha__link');
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toContain('noopener');
});
});
});

View File

@@ -0,0 +1,383 @@
/**
* @file commit-info.component.ts
* @sprint SPRINT_20260107_005_004_FE (UI-006)
* @description Component for displaying commit information from pedigree.
* Shows commit SHA, author, committer, message, and timestamp.
*/
import { Component, computed, input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PedigreeCommit, CommitIdentity } from '../../models/cyclonedx-evidence.models';
/**
* Commit info component for displaying pedigree commit details.
*
* Features:
* - Display commit SHA with copy button
* - Link to upstream repository
* - Show author and committer
* - Show commit message (truncated with expand)
* - Timestamp display
*
* @example
* <app-commit-info [commit]="pedigree.commits[0]" />
*/
@Component({
selector: 'app-commit-info',
standalone: true,
imports: [CommonModule],
template: `
@if (commit(); as c) {
<article class="commit-info" [attr.aria-label]="'Commit ' + shortSha()">
<!-- Commit SHA -->
<div class="commit-row commit-row--sha">
<span class="commit-label">Commit</span>
<div class="commit-sha">
<code class="commit-sha__value">{{ shortSha() }}</code>
<button
type="button"
class="commit-sha__copy"
(click)="copySha()"
[attr.aria-label]="copied() ? 'Copied!' : 'Copy full SHA'"
>
@if (copied()) {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
} @else {
<svg width="14" height="14" 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 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
}
</button>
@if (c.url) {
<a
class="commit-sha__link"
[href]="c.url"
target="_blank"
rel="noopener noreferrer"
[attr.aria-label]="'View commit on ' + repoHost()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4"/>
<path d="M14 4h6v6M21 3l-9 9"/>
</svg>
{{ repoHost() }}
</a>
}
</div>
</div>
<!-- Author -->
@if (c.author) {
<div class="commit-row">
<span class="commit-label">Author</span>
<div class="commit-identity">
<span class="commit-identity__name">{{ c.author.name ?? 'Unknown' }}</span>
@if (c.author.email) {
<span class="commit-identity__email">&lt;{{ c.author.email }}&gt;</span>
}
@if (c.author.timestamp) {
<time class="commit-identity__time" [attr.datetime]="c.author.timestamp">
{{ formatTimestamp(c.author.timestamp) }}
</time>
}
</div>
</div>
}
<!-- Committer (if different from author) -->
@if (c.committer && isDifferentFromAuthor(c.author, c.committer)) {
<div class="commit-row">
<span class="commit-label">Committer</span>
<div class="commit-identity">
<span class="commit-identity__name">{{ c.committer.name ?? 'Unknown' }}</span>
@if (c.committer.email) {
<span class="commit-identity__email">&lt;{{ c.committer.email }}&gt;</span>
}
@if (c.committer.timestamp) {
<time class="commit-identity__time" [attr.datetime]="c.committer.timestamp">
{{ formatTimestamp(c.committer.timestamp) }}
</time>
}
</div>
</div>
}
<!-- Commit Message -->
@if (c.message) {
<div class="commit-row commit-row--message">
<span class="commit-label">Message</span>
<div class="commit-message" [class.expanded]="messageExpanded()">
<p class="commit-message__text">{{ displayMessage() }}</p>
@if (needsTruncation()) {
<button
type="button"
class="commit-message__toggle"
(click)="toggleMessage()"
>
{{ messageExpanded() ? 'Show less' : 'Show more' }}
</button>
}
</div>
</div>
}
</article>
} @else {
<div class="commit-empty">
<p>No commit information available.</p>
</div>
}
`,
styles: [`
.commit-info {
background: var(--surface-secondary, #f9fafb);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
padding: 1rem;
}
.commit-row {
display: flex;
gap: 0.75rem;
margin-bottom: 0.75rem;
&:last-child {
margin-bottom: 0;
}
&--sha {
align-items: center;
}
&--message {
flex-direction: column;
gap: 0.375rem;
}
}
.commit-label {
min-width: 5rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.025em;
}
/* SHA */
.commit-sha {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.commit-sha__value {
padding: 0.25rem 0.5rem;
background: var(--code-bg, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
color: var(--text-primary, #111827);
}
.commit-sha__copy {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
color: var(--text-muted, #9ca3af);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
&:hover {
color: var(--text-primary, #111827);
background: var(--surface-hover, #e5e7eb);
}
}
.commit-sha__link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: var(--text-link, #2563eb);
text-decoration: none;
background: transparent;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 4px;
transition: all 0.15s;
&:hover {
background: var(--surface-hover, #f3f4f6);
border-color: var(--text-link, #2563eb);
}
}
/* Identity */
.commit-identity {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem;
flex: 1;
}
.commit-identity__name {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #111827);
}
.commit-identity__email {
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
}
.commit-identity__time {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
/* Message */
.commit-message {
flex: 1;
}
.commit-message__text {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: var(--text-primary, #111827);
white-space: pre-wrap;
word-break: break-word;
.commit-message:not(.expanded) & {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.commit-message__toggle {
padding: 0;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-link, #2563eb);
background: transparent;
border: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
/* Empty State */
.commit-empty {
padding: 1.5rem;
text-align: center;
color: var(--text-muted, #9ca3af);
font-size: 0.875rem;
}
`],
})
export class CommitInfoComponent {
/** Commit data to display */
readonly commit = input<PedigreeCommit | undefined>(undefined);
/** Max lines before truncation */
readonly maxLines = input<number>(3);
/** Whether message is expanded */
readonly messageExpanded = signal<boolean>(false);
/** Copied state for SHA */
readonly copied = signal<boolean>(false);
/** Short SHA (first 7 characters) */
readonly shortSha = computed<string>(() => {
const c = this.commit();
return c?.uid?.slice(0, 7) ?? '';
});
/** Repository host from URL */
readonly repoHost = computed<string>(() => {
const url = this.commit()?.url;
if (!url) return '';
try {
const host = new URL(url).hostname;
return host.replace('www.', '');
} catch {
return 'View';
}
});
/** Display message (potentially truncated) */
readonly displayMessage = computed<string>(() => {
return this.commit()?.message ?? '';
});
/** Whether message needs truncation */
readonly needsTruncation = computed<boolean>(() => {
const message = this.commit()?.message;
if (!message) return false;
const lines = message.split('\n').length;
return lines > this.maxLines();
});
/** Toggle message expansion */
toggleMessage(): void {
this.messageExpanded.update((v) => !v);
}
/** Check if committer is different from author */
isDifferentFromAuthor(
author: CommitIdentity | undefined,
committer: CommitIdentity | undefined
): boolean {
if (!author || !committer) return false;
return (
author.name !== committer.name || author.email !== committer.email
);
}
/** Format timestamp for display */
formatTimestamp(timestamp: string): string {
try {
const date = new Date(timestamp);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return timestamp;
}
}
/** Copy full SHA to clipboard */
async copySha(): Promise<void> {
const sha = this.commit()?.uid;
if (!sha) return;
try {
await navigator.clipboard.writeText(sha);
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
} catch {
console.error('Failed to copy SHA');
}
}
}

View File

@@ -0,0 +1,238 @@
/**
* @file diff-viewer.component.spec.ts
* @sprint SPRINT_20260107_005_004_FE (UI-011)
* @description Unit tests for DiffViewerComponent.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DiffViewerComponent } from './diff-viewer.component';
import { PatchDiff } from '../../models/cyclonedx-evidence.models';
describe('DiffViewerComponent', () => {
let component: DiffViewerComponent;
let fixture: ComponentFixture<DiffViewerComponent>;
const mockDiffText = `@@ -1,5 +1,6 @@
context line 1
-deleted line
+added line
context line 2
context line 3
+another added line
context line 4`;
const mockDiff: PatchDiff = {
url: 'https://github.com/example/repo/commit/abc123.diff',
text: {
contentType: 'text/plain',
content: mockDiffText,
},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DiffViewerComponent],
}).compileComponents();
fixture = TestBed.createComponent(DiffViewerComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('diff parsing', () => {
beforeEach(() => {
fixture.componentRef.setInput('diffText', mockDiffText);
fixture.detectChanges();
});
it('should parse diff lines correctly', () => {
const lines = component.parsedLines();
expect(lines.length).toBeGreaterThan(0);
});
it('should identify header lines', () => {
const lines = component.parsedLines();
const headerLine = lines.find((l) => l.type === 'header');
expect(headerLine).toBeTruthy();
expect(headerLine?.content).toContain('@@');
});
it('should identify addition lines', () => {
const lines = component.parsedLines();
const additions = lines.filter((l) => l.type === 'addition');
expect(additions.length).toBe(2);
});
it('should identify deletion lines', () => {
const lines = component.parsedLines();
const deletions = lines.filter((l) => l.type === 'deletion');
expect(deletions.length).toBe(1);
});
it('should identify context lines', () => {
const lines = component.parsedLines();
const context = lines.filter((l) => l.type === 'context');
expect(context.length).toBeGreaterThan(0);
});
});
describe('diff stats', () => {
beforeEach(() => {
fixture.componentRef.setInput('diffText', mockDiffText);
fixture.detectChanges();
});
it('should calculate additions correctly', () => {
const stats = component.diffStats();
expect(stats.additions).toBe(2);
});
it('should calculate deletions correctly', () => {
const stats = component.diffStats();
expect(stats.deletions).toBe(1);
});
});
describe('view mode', () => {
beforeEach(() => {
fixture.componentRef.setInput('diffText', mockDiffText);
fixture.detectChanges();
});
it('should default to unified view', () => {
expect(component.viewMode()).toBe('unified');
});
it('should switch to side-by-side view', () => {
component.setViewMode('side-by-side');
expect(component.viewMode()).toBe('side-by-side');
});
it('should render unified view by default', () => {
const unified = fixture.nativeElement.querySelector('.diff-unified');
expect(unified).toBeTruthy();
});
it('should render side-by-side view when selected', () => {
component.setViewMode('side-by-side');
fixture.detectChanges();
const sideBySide = fixture.nativeElement.querySelector('.diff-side-by-side');
expect(sideBySide).toBeTruthy();
});
});
describe('copy functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('diffText', mockDiffText);
fixture.detectChanges();
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
});
it('should copy diff to clipboard', async () => {
await component.copyDiff();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockDiffText);
});
it('should set copied state', async () => {
await component.copyDiff();
expect(component.copied()).toBe(true);
});
});
describe('close functionality', () => {
it('should emit close event', () => {
const closeSpy = jest.spyOn(component.close, 'emit');
component.onClose();
expect(closeSpy).toHaveBeenCalled();
});
});
describe('base64 decoding', () => {
it('should decode base64 content', () => {
const base64Diff: PatchDiff = {
text: {
content: btoa(mockDiffText),
encoding: 'base64',
},
};
fixture.componentRef.setInput('diff', base64Diff);
fixture.detectChanges();
expect(component.rawDiff()).toBe(mockDiffText);
});
});
describe('empty state', () => {
it('should show empty message when no diff content', () => {
fixture.componentRef.setInput('diffText', '');
fixture.detectChanges();
expect(component.hasContent()).toBe(false);
});
it('should show external link when URL provided', () => {
fixture.componentRef.setInput('diffText', '');
fixture.componentRef.setInput('diffUrl', 'https://example.com/diff');
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.diff-link');
expect(link).toBeTruthy();
});
});
describe('line numbers', () => {
beforeEach(() => {
fixture.componentRef.setInput('diffText', mockDiffText);
fixture.detectChanges();
});
it('should assign line numbers to context lines', () => {
const lines = component.parsedLines();
const contextLines = lines.filter((l) => l.type === 'context');
const withOldLine = contextLines.filter((l) => l.oldLineNumber !== undefined);
expect(withOldLine.length).toBeGreaterThan(0);
});
it('should assign new line numbers to additions', () => {
const lines = component.parsedLines();
const additions = lines.filter((l) => l.type === 'addition');
const withNewLine = additions.filter((l) => l.newLineNumber !== undefined);
expect(withNewLine.length).toBe(additions.length);
});
it('should assign old line numbers to deletions', () => {
const lines = component.parsedLines();
const deletions = lines.filter((l) => l.type === 'deletion');
const withOldLine = deletions.filter((l) => l.oldLineNumber !== undefined);
expect(withOldLine.length).toBe(deletions.length);
});
});
describe('side-by-side lines', () => {
beforeEach(() => {
fixture.componentRef.setInput('diffText', mockDiffText);
fixture.detectChanges();
});
it('should separate old lines correctly', () => {
const oldLines = component.oldLines();
const hasAddition = oldLines.some((l) => l.type === 'addition');
expect(hasAddition).toBe(false);
});
it('should separate new lines correctly', () => {
const newLines = component.newLines();
const hasDeletion = newLines.some((l) => l.type === 'deletion');
expect(hasDeletion).toBe(false);
});
});
});

View File

@@ -0,0 +1,646 @@
/**
* @file diff-viewer.component.ts
* @sprint SPRINT_20260107_005_004_FE (UI-005)
* @description Syntax-highlighted diff display component.
* Supports side-by-side and unified views with collapsible unchanged regions.
*/
import { Component, computed, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PatchDiff } from '../../models/cyclonedx-evidence.models';
/**
* Parsed diff line with metadata.
*/
interface DiffLine {
readonly type: 'addition' | 'deletion' | 'context' | 'header';
readonly content: string;
readonly oldLineNumber?: number;
readonly newLineNumber?: number;
}
/**
* Collapsed region of unchanged lines.
*/
interface CollapsedRegion {
readonly startIndex: number;
readonly endIndex: number;
readonly lineCount: number;
}
/**
* Diff viewer component for displaying patch diffs.
*
* Features:
* - Syntax-highlighted diff display
* - Side-by-side and unified views
* - Line number gutter
* - Copy diff button
* - Collapse unchanged regions
*
* @example
* <app-diff-viewer
* [diff]="patch.diff"
* [viewMode]="'unified'"
* (close)="onCloseDiff()"
* />
*/
@Component({
selector: 'app-diff-viewer',
standalone: true,
imports: [CommonModule],
template: `
<section class="diff-viewer" [attr.aria-label]="'Diff viewer'">
<!-- Header -->
<header class="diff-viewer__header">
<h3 class="diff-viewer__title">Diff</h3>
<div class="diff-viewer__controls">
<!-- View Mode Toggle -->
<div class="view-mode-toggle" role="tablist">
<button
type="button"
class="view-mode-btn"
[class.active]="viewMode() === 'unified'"
(click)="setViewMode('unified')"
role="tab"
[attr.aria-selected]="viewMode() === 'unified'"
>
Unified
</button>
<button
type="button"
class="view-mode-btn"
[class.active]="viewMode() === 'side-by-side'"
(click)="setViewMode('side-by-side')"
role="tab"
[attr.aria-selected]="viewMode() === 'side-by-side'"
>
Side-by-Side
</button>
</div>
<!-- Copy Button -->
<button
type="button"
class="copy-diff-btn"
(click)="copyDiff()"
[attr.aria-label]="copied() ? 'Copied!' : 'Copy diff'"
>
<svg width="16" height="16" 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 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
{{ copied() ? 'Copied!' : 'Copy' }}
</button>
<!-- Close Button -->
<button
type="button"
class="close-btn"
(click)="onClose()"
aria-label="Close diff viewer"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</header>
<!-- Stats -->
@if (diffStats(); as stats) {
<div class="diff-stats">
<span class="diff-stat diff-stat--additions">+{{ stats.additions }}</span>
<span class="diff-stat diff-stat--deletions">-{{ stats.deletions }}</span>
<span class="diff-stat diff-stat--files">{{ stats.files }} file(s)</span>
</div>
}
<!-- Content -->
<div class="diff-viewer__content" [class.side-by-side]="viewMode() === 'side-by-side'">
@if (viewMode() === 'unified') {
<!-- Unified View -->
<div class="diff-unified">
@for (line of visibleLines(); track $index; let i = $index) {
@if (isCollapsedRegion(i)) {
<div class="diff-collapsed" (click)="expandRegion(i)">
<span class="diff-collapsed__icon">+</span>
<span class="diff-collapsed__text">
{{ getCollapsedCount(i) }} unchanged lines (click to expand)
</span>
</div>
} @else {
<div
class="diff-line"
[class.diff-line--addition]="line.type === 'addition'"
[class.diff-line--deletion]="line.type === 'deletion'"
[class.diff-line--context]="line.type === 'context'"
[class.diff-line--header]="line.type === 'header'"
>
<span class="diff-line__gutter diff-line__gutter--old">
{{ line.oldLineNumber ?? '' }}
</span>
<span class="diff-line__gutter diff-line__gutter--new">
{{ line.newLineNumber ?? '' }}
</span>
<span class="diff-line__prefix">
@switch (line.type) {
@case ('addition') { + }
@case ('deletion') { - }
@case ('header') { @@ }
@default { &nbsp; }
}
</span>
<code class="diff-line__content">{{ line.content }}</code>
</div>
}
}
</div>
} @else {
<!-- Side-by-Side View -->
<div class="diff-side-by-side">
<div class="diff-pane diff-pane--old">
<div class="diff-pane__header">Old</div>
@for (line of oldLines(); track $index) {
<div
class="diff-line"
[class.diff-line--deletion]="line.type === 'deletion'"
[class.diff-line--context]="line.type === 'context'"
[class.diff-line--empty]="line.type === 'addition'"
>
<span class="diff-line__gutter">{{ line.oldLineNumber ?? '' }}</span>
<code class="diff-line__content">{{ line.type !== 'addition' ? line.content : '' }}</code>
</div>
}
</div>
<div class="diff-pane diff-pane--new">
<div class="diff-pane__header">New</div>
@for (line of newLines(); track $index) {
<div
class="diff-line"
[class.diff-line--addition]="line.type === 'addition'"
[class.diff-line--context]="line.type === 'context'"
[class.diff-line--empty]="line.type === 'deletion'"
>
<span class="diff-line__gutter">{{ line.newLineNumber ?? '' }}</span>
<code class="diff-line__content">{{ line.type !== 'deletion' ? line.content : '' }}</code>
</div>
}
</div>
</div>
}
@if (!hasContent()) {
<div class="diff-empty">
<p>No diff content available.</p>
@if (diffUrl()) {
<a
class="diff-link"
[href]="diffUrl()"
target="_blank"
rel="noopener noreferrer"
>
View diff externally
</a>
}
</div>
}
</div>
</section>
`,
styles: [`
.diff-viewer {
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.diff-viewer__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--surface-secondary, #f9fafb);
border-bottom: 1px solid var(--border-default, #e5e7eb);
}
.diff-viewer__title {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
.diff-viewer__controls {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* View Mode Toggle */
.view-mode-toggle {
display: flex;
background: var(--surface-tertiary, #e5e7eb);
border-radius: 6px;
padding: 2px;
}
.view-mode-btn {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
&:hover {
color: var(--text-primary, #111827);
}
&.active {
background: var(--surface-primary, #ffffff);
color: var(--text-primary, #111827);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
.copy-diff-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
background: transparent;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--surface-hover, #f3f4f6);
color: var(--text-primary, #111827);
}
}
.close-btn {
padding: 0.375rem;
color: var(--text-muted, #9ca3af);
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--surface-hover, #f3f4f6);
color: var(--text-primary, #111827);
}
}
/* Stats */
.diff-stats {
display: flex;
gap: 1rem;
padding: 0.5rem 1rem;
background: var(--surface-tertiary, #f3f4f6);
font-size: 0.75rem;
}
.diff-stat {
font-weight: 500;
&--additions {
color: var(--color-addition, #16a34a);
}
&--deletions {
color: var(--color-deletion, #dc2626);
}
&--files {
color: var(--text-secondary, #6b7280);
}
}
/* Content */
.diff-viewer__content {
overflow: auto;
max-height: 500px;
}
/* Unified View */
.diff-unified {
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 0.8125rem;
line-height: 1.5;
}
.diff-line {
display: flex;
min-height: 1.5rem;
&--addition {
background: var(--color-addition-bg, #dcfce7);
}
&--deletion {
background: var(--color-deletion-bg, #fee2e2);
}
&--context {
background: transparent;
}
&--header {
background: var(--surface-secondary, #f3f4f6);
color: var(--text-secondary, #6b7280);
font-style: italic;
}
&--empty {
background: var(--surface-tertiary, #f9fafb);
}
}
.diff-line__gutter {
min-width: 3rem;
padding: 0 0.5rem;
text-align: right;
color: var(--text-muted, #9ca3af);
background: var(--surface-secondary, #f9fafb);
border-right: 1px solid var(--border-light, #e5e7eb);
user-select: none;
&--old {
border-right: none;
}
}
.diff-line__prefix {
width: 1.5rem;
padding: 0 0.25rem;
text-align: center;
color: inherit;
user-select: none;
.diff-line--addition & {
color: var(--color-addition, #16a34a);
}
.diff-line--deletion & {
color: var(--color-deletion, #dc2626);
}
}
.diff-line__content {
flex: 1;
padding: 0 0.5rem;
white-space: pre;
overflow-x: auto;
}
/* Collapsed Region */
.diff-collapsed {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--surface-tertiary, #f3f4f6);
border-top: 1px solid var(--border-light, #e5e7eb);
border-bottom: 1px solid var(--border-light, #e5e7eb);
color: var(--text-secondary, #6b7280);
font-size: 0.75rem;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--surface-hover, #e5e7eb);
}
}
.diff-collapsed__icon {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
background: var(--color-primary, #2563eb);
color: white;
font-size: 0.75rem;
font-weight: 600;
border-radius: 2px;
}
/* Side-by-Side View */
.diff-side-by-side {
display: flex;
}
.diff-pane {
flex: 1;
min-width: 0;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 0.8125rem;
line-height: 1.5;
&--old {
border-right: 1px solid var(--border-default, #e5e7eb);
}
}
.diff-pane__header {
padding: 0.5rem 1rem;
background: var(--surface-secondary, #f9fafb);
border-bottom: 1px solid var(--border-light, #e5e7eb);
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
}
/* Empty State */
.diff-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem 1rem;
color: var(--text-muted, #9ca3af);
}
.diff-link {
color: var(--text-link, #2563eb);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
`],
})
export class DiffViewerComponent {
/** Patch diff data */
readonly diff = input<PatchDiff | undefined>(undefined);
/** Raw diff text (alternative to diff object) */
readonly diffText = input<string | undefined>(undefined);
/** URL to external diff */
readonly diffUrl = input<string | undefined>(undefined);
/** Emits when viewer should close */
readonly close = output<void>();
/** Current view mode */
readonly viewMode = signal<'unified' | 'side-by-side'>('unified');
/** Copied state for feedback */
readonly copied = signal<boolean>(false);
/** Expanded collapsed regions */
private readonly expandedRegions = signal<Set<number>>(new Set());
/** Minimum context lines to show around changes */
private readonly contextLines = 3;
/** Raw diff content */
readonly rawDiff = computed<string>(() => {
const diff = this.diff();
if (diff?.text?.content) {
if (diff.text.encoding === 'base64') {
try {
return atob(diff.text.content);
} catch {
return diff.text.content;
}
}
return diff.text.content;
}
return this.diffText() ?? '';
});
/** Whether there is content to display */
readonly hasContent = computed<boolean>(() => {
return this.rawDiff().trim().length > 0;
});
/** Parsed diff lines */
readonly parsedLines = computed<DiffLine[]>(() => {
const raw = this.rawDiff();
if (!raw) return [];
const lines = raw.split('\n');
const result: DiffLine[] = [];
let oldLine = 1;
let newLine = 1;
for (const line of lines) {
if (line.startsWith('@@')) {
// Parse hunk header for line numbers
const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/);
if (match) {
oldLine = parseInt(match[1], 10);
newLine = parseInt(match[2], 10);
}
result.push({ type: 'header', content: line });
} else if (line.startsWith('+') && !line.startsWith('+++')) {
result.push({
type: 'addition',
content: line.slice(1),
newLineNumber: newLine++,
});
} else if (line.startsWith('-') && !line.startsWith('---')) {
result.push({
type: 'deletion',
content: line.slice(1),
oldLineNumber: oldLine++,
});
} else if (line.startsWith(' ') || line === '') {
result.push({
type: 'context',
content: line.slice(1) || '',
oldLineNumber: oldLine++,
newLineNumber: newLine++,
});
}
}
return result;
});
/** Visible lines with collapsed regions */
readonly visibleLines = computed<DiffLine[]>(() => {
// For now, return all lines (collapse logic would be here)
return this.parsedLines();
});
/** Lines for old pane in side-by-side view */
readonly oldLines = computed<DiffLine[]>(() => {
return this.parsedLines().filter(
(l) => l.type !== 'header' && l.type !== 'addition'
);
});
/** Lines for new pane in side-by-side view */
readonly newLines = computed<DiffLine[]>(() => {
return this.parsedLines().filter(
(l) => l.type !== 'header' && l.type !== 'deletion'
);
});
/** Diff statistics */
readonly diffStats = computed(() => {
const lines = this.parsedLines();
const additions = lines.filter((l) => l.type === 'addition').length;
const deletions = lines.filter((l) => l.type === 'deletion').length;
const files = 1; // Would parse from diff headers
return { additions, deletions, files };
});
/** Set view mode */
setViewMode(mode: 'unified' | 'side-by-side'): void {
this.viewMode.set(mode);
}
/** Check if index is a collapsed region */
isCollapsedRegion(index: number): boolean {
// Simplified - would implement actual collapse logic
return false;
}
/** Get count of collapsed lines */
getCollapsedCount(index: number): number {
return 0;
}
/** Expand a collapsed region */
expandRegion(index: number): void {
this.expandedRegions.update((set) => {
const newSet = new Set(set);
newSet.add(index);
return newSet;
});
}
/** Copy diff to clipboard */
async copyDiff(): Promise<void> {
try {
await navigator.clipboard.writeText(this.rawDiff());
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
} catch {
console.error('Failed to copy diff');
}
}
/** Close the viewer */
onClose(): void {
this.close.emit();
}
}

View File

@@ -0,0 +1,201 @@
/**
* @file evidence-confidence-badge.component.spec.ts
* @sprint SPRINT_20260107_005_004_FE (UI-011)
* @description Unit tests for EvidenceConfidenceBadgeComponent.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EvidenceConfidenceBadgeComponent } from './evidence-confidence-badge.component';
import { getConfidenceTier, CONFIDENCE_TIER_INFO } from '../../models/cyclonedx-evidence.models';
describe('EvidenceConfidenceBadgeComponent', () => {
let component: EvidenceConfidenceBadgeComponent;
let fixture: ComponentFixture<EvidenceConfidenceBadgeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EvidenceConfidenceBadgeComponent],
}).compileComponents();
fixture = TestBed.createComponent(EvidenceConfidenceBadgeComponent);
component = fixture.componentInstance;
});
describe('tier calculation', () => {
it('should return tier1 for confidence >= 0.9', () => {
expect(getConfidenceTier(0.9)).toBe('tier1');
expect(getConfidenceTier(0.95)).toBe('tier1');
expect(getConfidenceTier(1.0)).toBe('tier1');
});
it('should return tier2 for confidence >= 0.75 and < 0.9', () => {
expect(getConfidenceTier(0.75)).toBe('tier2');
expect(getConfidenceTier(0.8)).toBe('tier2');
expect(getConfidenceTier(0.89)).toBe('tier2');
});
it('should return tier3 for confidence >= 0.5 and < 0.75', () => {
expect(getConfidenceTier(0.5)).toBe('tier3');
expect(getConfidenceTier(0.6)).toBe('tier3');
expect(getConfidenceTier(0.74)).toBe('tier3');
});
it('should return tier4 for confidence >= 0.25 and < 0.5', () => {
expect(getConfidenceTier(0.25)).toBe('tier4');
expect(getConfidenceTier(0.35)).toBe('tier4');
expect(getConfidenceTier(0.49)).toBe('tier4');
});
it('should return tier5 for confidence < 0.25', () => {
expect(getConfidenceTier(0.0)).toBe('tier5');
expect(getConfidenceTier(0.1)).toBe('tier5');
expect(getConfidenceTier(0.24)).toBe('tier5');
});
it('should return tier5 for undefined confidence', () => {
expect(getConfidenceTier(undefined)).toBe('tier5');
});
});
describe('rendering', () => {
it('should render with default settings', () => {
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
expect(badge).toBeTruthy();
});
it('should apply tier1 class for high confidence', () => {
fixture.componentRef.setInput('confidence', 0.95);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
expect(badge.classList.contains('evidence-confidence-badge--tier1')).toBe(true);
});
it('should apply tier5 class for low confidence', () => {
fixture.componentRef.setInput('confidence', 0.1);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
expect(badge.classList.contains('evidence-confidence-badge--tier5')).toBe(true);
});
it('should show percentage when showPercentage is true', () => {
fixture.componentRef.setInput('confidence', 0.85);
fixture.componentRef.setInput('showPercentage', true);
fixture.detectChanges();
const percentSpan = fixture.nativeElement.querySelector('.badge-percent');
expect(percentSpan).toBeTruthy();
expect(percentSpan.textContent).toBe('85%');
});
it('should show tier label when showTierLabel is true', () => {
fixture.componentRef.setInput('confidence', 0.95);
fixture.componentRef.setInput('showTierLabel', true);
fixture.detectChanges();
const tierSpan = fixture.nativeElement.querySelector('.badge-tier');
expect(tierSpan).toBeTruthy();
expect(tierSpan.textContent).toBe('Very High');
});
it('should show compact dot when neither label nor percentage shown', () => {
fixture.componentRef.setInput('confidence', 0.5);
fixture.componentRef.setInput('showTierLabel', false);
fixture.componentRef.setInput('showPercentage', false);
fixture.detectChanges();
const dot = fixture.nativeElement.querySelector('.badge-dot');
expect(dot).toBeTruthy();
});
});
describe('size variants', () => {
it('should apply sm class for small size', () => {
fixture.componentRef.setInput('confidence', 0.5);
fixture.componentRef.setInput('size', 'sm');
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
expect(badge.classList.contains('evidence-confidence-badge--sm')).toBe(true);
});
it('should apply lg class for large size', () => {
fixture.componentRef.setInput('confidence', 0.5);
fixture.componentRef.setInput('size', 'lg');
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
expect(badge.classList.contains('evidence-confidence-badge--lg')).toBe(true);
});
it('should not apply size class for medium (default)', () => {
fixture.componentRef.setInput('confidence', 0.5);
fixture.componentRef.setInput('size', 'md');
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
expect(badge.classList.contains('evidence-confidence-badge--md')).toBe(false);
});
});
describe('accessibility', () => {
it('should have title attribute with tooltip text', () => {
fixture.componentRef.setInput('confidence', 0.95);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
const title = badge.getAttribute('title');
expect(title).toContain('Very High');
expect(title).toContain('95%');
});
it('should have aria-label', () => {
fixture.componentRef.setInput('confidence', 0.75);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
const ariaLabel = badge.getAttribute('aria-label');
expect(ariaLabel).toContain('Confidence');
expect(ariaLabel).toContain('75 percent');
});
it('should have role="img"', () => {
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
expect(badge.getAttribute('role')).toBe('img');
});
});
describe('custom tier label', () => {
it('should use custom tier label when provided', () => {
fixture.componentRef.setInput('confidence', 0.95);
fixture.componentRef.setInput('tierLabel', 'Custom Label');
fixture.componentRef.setInput('showTierLabel', true);
fixture.detectChanges();
const tierSpan = fixture.nativeElement.querySelector('.badge-tier');
expect(tierSpan.textContent).toBe('Custom Label');
});
});
});
describe('CONFIDENCE_TIER_INFO', () => {
it('should have info for all tiers', () => {
expect(CONFIDENCE_TIER_INFO['tier1']).toBeDefined();
expect(CONFIDENCE_TIER_INFO['tier2']).toBeDefined();
expect(CONFIDENCE_TIER_INFO['tier3']).toBeDefined();
expect(CONFIDENCE_TIER_INFO['tier4']).toBeDefined();
expect(CONFIDENCE_TIER_INFO['tier5']).toBeDefined();
});
it('should have correct colors for each tier', () => {
expect(CONFIDENCE_TIER_INFO['tier1'].color).toBe('green');
expect(CONFIDENCE_TIER_INFO['tier2'].color).toBe('yellow-green');
expect(CONFIDENCE_TIER_INFO['tier3'].color).toBe('yellow');
expect(CONFIDENCE_TIER_INFO['tier4'].color).toBe('orange');
expect(CONFIDENCE_TIER_INFO['tier5'].color).toBe('red');
});
});

View File

@@ -0,0 +1,238 @@
/**
* @file evidence-confidence-badge.component.ts
* @sprint SPRINT_20260107_005_004_FE (UI-007)
* @description Color-coded confidence badge for CycloneDX evidence display.
* Shows confidence percentage with tier-based coloring and accessibility support.
*/
import { Component, computed, input } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ConfidenceTier,
CONFIDENCE_TIER_INFO,
getConfidenceTier,
} from '../../models/cyclonedx-evidence.models';
/**
* Confidence badge component for displaying evidence confidence scores.
*
* Color Scale:
* - Tier 1 (90-100%): Green - Authoritative source
* - Tier 2 (75-89%): Yellow-Green - Strong evidence
* - Tier 3 (50-74%): Yellow - Moderate confidence
* - Tier 4 (25-49%): Orange - Weak evidence
* - Tier 5 (0-24%): Red - Unknown/unverified
*
* @example
* <app-evidence-confidence-badge [confidence]="0.95" />
* <app-evidence-confidence-badge [confidence]="0.75" showPercentage />
* <app-evidence-confidence-badge [confidence]="0.50" [tierLabel]="'Tier 3'" />
*/
@Component({
selector: 'app-evidence-confidence-badge',
standalone: true,
imports: [CommonModule],
template: `
<span
class="evidence-confidence-badge"
[class]="badgeClasses()"
[attr.title]="tooltipText()"
[attr.aria-label]="ariaLabel()"
role="img"
>
@if (showTierLabel()) {
<span class="badge-tier">{{ tierInfo().label }}</span>
}
@if (showPercentage() && confidence() !== undefined) {
<span class="badge-percent">{{ percentageText() }}</span>
}
@if (!showTierLabel() && !showPercentage()) {
<span class="badge-dot" [attr.aria-hidden]="true"></span>
}
</span>
`,
styles: [`
.evidence-confidence-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
cursor: help;
transition: opacity 0.15s, transform 0.15s;
user-select: none;
&:hover {
opacity: 0.9;
transform: scale(1.02);
}
&:focus-visible {
outline: 2px solid var(--color-focus-ring, #3b82f6);
outline-offset: 2px;
}
}
.badge-tier {
text-transform: uppercase;
letter-spacing: 0.025em;
font-size: 0.6875rem;
}
.badge-percent {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.badge-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: currentColor;
}
// Tier 1: Green (90-100%)
.evidence-confidence-badge--tier1 {
background: var(--color-confidence-tier1-bg, #dcfce7);
color: var(--color-confidence-tier1-text, #15803d);
border: 1px solid var(--color-confidence-tier1-border, #86efac);
}
// Tier 2: Yellow-Green (75-89%)
.evidence-confidence-badge--tier2 {
background: var(--color-confidence-tier2-bg, #ecfccb);
color: var(--color-confidence-tier2-text, #4d7c0f);
border: 1px solid var(--color-confidence-tier2-border, #bef264);
}
// Tier 3: Yellow (50-74%)
.evidence-confidence-badge--tier3 {
background: var(--color-confidence-tier3-bg, #fef9c3);
color: var(--color-confidence-tier3-text, #a16207);
border: 1px solid var(--color-confidence-tier3-border, #fde047);
}
// Tier 4: Orange (25-49%)
.evidence-confidence-badge--tier4 {
background: var(--color-confidence-tier4-bg, #ffedd5);
color: var(--color-confidence-tier4-text, #c2410c);
border: 1px solid var(--color-confidence-tier4-border, #fdba74);
}
// Tier 5: Red (0-24%)
.evidence-confidence-badge--tier5 {
background: var(--color-confidence-tier5-bg, #fee2e2);
color: var(--color-confidence-tier5-text, #dc2626);
border: 1px solid var(--color-confidence-tier5-border, #fca5a5);
}
// Size variants
.evidence-confidence-badge--sm {
padding: 0.0625rem 0.375rem;
font-size: 0.625rem;
}
.evidence-confidence-badge--lg {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
}
// Compact (dot only)
.evidence-confidence-badge--compact {
padding: 0.25rem;
min-width: 1rem;
justify-content: center;
}
`],
})
export class EvidenceConfidenceBadgeComponent {
/**
* Confidence score (0-1).
*/
readonly confidence = input<number | undefined>(undefined);
/**
* Show percentage value.
*/
readonly showPercentage = input<boolean>(false);
/**
* Show tier label (e.g., "Very High", "High", etc.).
*/
readonly showTierLabel = input<boolean>(true);
/**
* Size variant.
*/
readonly size = input<'sm' | 'md' | 'lg'>('md');
/**
* Custom tier label override.
*/
readonly tierLabel = input<string | undefined>(undefined);
/**
* Computed confidence tier.
*/
readonly tier = computed<ConfidenceTier>(() => getConfidenceTier(this.confidence()));
/**
* Tier info for display.
*/
readonly tierInfo = computed(() => {
const tier = this.tier();
const info = CONFIDENCE_TIER_INFO[tier];
return {
...info,
label: this.tierLabel() ?? info.label,
};
});
/**
* Badge CSS classes.
*/
readonly badgeClasses = computed(() => {
const tier = this.tier();
const size = this.size();
const isCompact = !this.showTierLabel() && !this.showPercentage();
return [
`evidence-confidence-badge--${tier}`,
size !== 'md' ? `evidence-confidence-badge--${size}` : '',
isCompact ? 'evidence-confidence-badge--compact' : '',
]
.filter(Boolean)
.join(' ');
});
/**
* Percentage text (e.g., "95%").
*/
readonly percentageText = computed(() => {
const conf = this.confidence();
if (conf === undefined) return '';
return `${Math.round(conf * 100)}%`;
});
/**
* Tooltip text with full explanation.
*/
readonly tooltipText = computed(() => {
const info = this.tierInfo();
const conf = this.confidence();
const percent = conf !== undefined ? ` (${Math.round(conf * 100)}%)` : '';
return `${info.label}${percent}: ${info.description}`;
});
/**
* ARIA label for accessibility.
*/
readonly ariaLabel = computed(() => {
const info = this.tierInfo();
const conf = this.confidence();
const percent = conf !== undefined ? `, ${Math.round(conf * 100)} percent` : '';
return `Confidence: ${info.label}${percent}`;
});
}

View File

@@ -0,0 +1,239 @@
/**
* @file evidence-detail-drawer.component.spec.ts
* @sprint SPRINT_20260107_005_004_FE (UI-011)
* @description Unit tests for EvidenceDetailDrawerComponent.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EvidenceDetailDrawerComponent } from './evidence-detail-drawer.component';
import { ComponentEvidence, OccurrenceEvidence } from '../../models/cyclonedx-evidence.models';
describe('EvidenceDetailDrawerComponent', () => {
let component: EvidenceDetailDrawerComponent;
let fixture: ComponentFixture<EvidenceDetailDrawerComponent>;
const mockEvidence: ComponentEvidence = {
identity: {
field: 'purl',
confidence: 0.95,
methods: [
{ technique: 'manifest-analysis', confidence: 0.95, value: 'package.json' },
{ technique: 'hash-comparison', confidence: 0.90 },
],
tools: ['scanner-v1', 'analyzer-v2'],
},
occurrences: [
{ location: '/node_modules/lodash/index.js', line: 1 },
{ location: '/node_modules/lodash/package.json' },
],
licenses: [
{
license: { id: 'MIT', url: 'https://opensource.org/licenses/MIT' },
acknowledgement: 'declared',
},
],
copyright: [
{ text: 'Copyright (c) JS Foundation and other contributors' },
],
};
const mockOccurrence: OccurrenceEvidence = {
location: '/node_modules/lodash/index.js',
line: 42,
symbol: 'debounce',
additionalContext: 'Imported in main module',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EvidenceDetailDrawerComponent],
}).compileComponents();
fixture = TestBed.createComponent(EvidenceDetailDrawerComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('drawer visibility', () => {
it('should not render when open is false', () => {
fixture.componentRef.setInput('open', false);
fixture.detectChanges();
const overlay = fixture.nativeElement.querySelector('.drawer-overlay');
expect(overlay).toBeFalsy();
});
it('should render when open is true', () => {
fixture.componentRef.setInput('open', true);
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
const overlay = fixture.nativeElement.querySelector('.drawer-overlay');
expect(overlay).toBeTruthy();
});
});
describe('evidence display', () => {
beforeEach(() => {
fixture.componentRef.setInput('open', true);
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
});
it('should display identity field', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('PURL');
});
it('should display detection methods', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('Manifest Analysis');
expect(content).toContain('Hash Comparison');
});
it('should display tools used', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('scanner-v1');
expect(content).toContain('analyzer-v2');
});
it('should display license information', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('MIT');
expect(content).toContain('declared');
});
it('should display copyright information', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('JS Foundation');
});
});
describe('occurrence display', () => {
beforeEach(() => {
fixture.componentRef.setInput('open', true);
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.componentRef.setInput('selectedOccurrence', mockOccurrence);
fixture.detectChanges();
});
it('should display occurrence location', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('/node_modules/lodash/index.js');
});
it('should display line number', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('42');
});
it('should display symbol', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('debounce');
});
it('should display additional context', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('Imported in main module');
});
});
describe('close functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('open', true);
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
});
it('should emit closeDrawer when close button clicked', () => {
const closeSpy = jest.spyOn(component.closeDrawer, 'emit');
const closeBtn = fixture.nativeElement.querySelector('.drawer-close-btn');
closeBtn.click();
expect(closeSpy).toHaveBeenCalled();
});
it('should emit closeDrawer when overlay clicked', () => {
const closeSpy = jest.spyOn(component.closeDrawer, 'emit');
const overlay = fixture.nativeElement.querySelector('.drawer-overlay');
overlay.click();
expect(closeSpy).toHaveBeenCalled();
});
it('should emit closeDrawer on escape key', () => {
const closeSpy = jest.spyOn(component.closeDrawer, 'emit');
component.onEscapeKey();
expect(closeSpy).toHaveBeenCalled();
});
});
describe('copy functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('open', true);
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
// Mock clipboard API
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
});
it('should copy value to clipboard', async () => {
await component.copyToClipboard('test-value');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value');
});
it('should set copiedValue after successful copy', async () => {
await component.copyToClipboard('test-value');
expect(component.copiedValue()).toBe('test-value');
});
it('should copy evidence reference', async () => {
await component.copyEvidenceRef();
expect(navigator.clipboard.writeText).toHaveBeenCalled();
expect(component.copiedRef()).toBe(true);
});
});
describe('accessibility', () => {
beforeEach(() => {
fixture.componentRef.setInput('open', true);
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
});
it('should have role="dialog"', () => {
const overlay = fixture.nativeElement.querySelector('.drawer-overlay');
expect(overlay.getAttribute('role')).toBe('dialog');
});
it('should have aria-modal="true"', () => {
const overlay = fixture.nativeElement.querySelector('.drawer-overlay');
expect(overlay.getAttribute('aria-modal')).toBe('true');
});
it('should have accessible close button', () => {
const closeBtn = fixture.nativeElement.querySelector('.drawer-close-btn');
expect(closeBtn.getAttribute('aria-label')).toBe('Close drawer');
});
});
describe('empty state', () => {
it('should show empty message when no evidence', () => {
fixture.componentRef.setInput('open', true);
fixture.componentRef.setInput('evidence', undefined);
fixture.detectChanges();
const content = fixture.nativeElement.textContent;
expect(content).toContain('No evidence data available');
});
});
});

View File

@@ -0,0 +1,864 @@
/**
* @file evidence-detail-drawer.component.ts
* @sprint SPRINT_20260107_005_004_FE (UI-002)
* @description Full-screen drawer for evidence details display.
* Shows detection method chain, source file content, and copy-to-clipboard.
*/
import {
Component,
computed,
input,
output,
signal,
inject,
HostListener,
ElementRef,
OnInit,
OnDestroy,
} from '@angular/core';
import { CommonModule, DOCUMENT } from '@angular/common';
import {
ComponentEvidence,
IdentityEvidence,
OccurrenceEvidence,
getIdentityTechniqueLabel,
} from '../../models/cyclonedx-evidence.models';
import { EvidenceConfidenceBadgeComponent } from '../evidence-confidence-badge/evidence-confidence-badge.component';
/**
* Evidence detail drawer component for full-screen evidence exploration.
*
* Features:
* - Full-screen drawer for evidence details
* - Show detection method chain
* - Show source file content (if available)
* - Copy-to-clipboard for evidence references
* - Close on escape key
*
* @example
* <app-evidence-detail-drawer
* [open]="showDrawer"
* [evidence]="selectedEvidence"
* [occurrence]="selectedOccurrence"
* (closeDrawer)="onCloseDrawer()"
* />
*/
@Component({
selector: 'app-evidence-detail-drawer',
standalone: true,
imports: [CommonModule, EvidenceConfidenceBadgeComponent],
template: `
@if (open()) {
<div
class="drawer-overlay"
(click)="onOverlayClick($event)"
role="dialog"
aria-modal="true"
[attr.aria-label]="'Evidence details for ' + (evidence()?.identity?.field ?? 'component')"
>
<aside
class="drawer-panel"
[class.drawer-panel--open]="open()"
role="document"
#drawerPanel
>
<!-- Header -->
<header class="drawer-header">
<h2 class="drawer-title">Evidence Details</h2>
<button
type="button"
class="drawer-close-btn"
(click)="onClose()"
aria-label="Close drawer"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</header>
<!-- Content -->
<div class="drawer-content">
@if (evidence(); as ev) {
<!-- Identity Section -->
@if (ev.identity) {
<section class="detail-section">
<h3 class="detail-section__title">Identity Evidence</h3>
<div class="identity-detail">
<div class="identity-detail__row">
<span class="identity-detail__label">Field:</span>
<span class="identity-detail__value">{{ ev.identity.field | uppercase }}</span>
</div>
<div class="identity-detail__row">
<span class="identity-detail__label">Confidence:</span>
<app-evidence-confidence-badge
[confidence]="ev.identity.confidence"
[showPercentage]="true"
[showTierLabel]="true"
/>
</div>
</div>
<!-- Detection Method Chain -->
@if (ev.identity.methods && ev.identity.methods.length > 0) {
<div class="method-chain">
<h4 class="method-chain__title">Detection Method Chain</h4>
<ol class="method-chain__list">
@for (method of ev.identity.methods; track method.technique; let i = $index) {
<li class="method-chain__item">
<span class="method-chain__step">{{ i + 1 }}</span>
<div class="method-chain__content">
<span class="method-chain__technique">
{{ getTechniqueLabel(method.technique) }}
</span>
@if (method.value) {
<code class="method-chain__value">{{ method.value }}</code>
<button
type="button"
class="copy-btn"
(click)="copyToClipboard(method.value)"
[attr.aria-label]="'Copy ' + method.value"
>
<svg width="14" height="14" 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 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
{{ copiedValue() === method.value ? 'Copied!' : 'Copy' }}
</button>
}
<div class="method-chain__confidence">
Confidence: {{ (method.confidence * 100) | number:'1.0-0' }}%
</div>
</div>
</li>
}
</ol>
</div>
}
<!-- Tools Used -->
@if (ev.identity.tools && ev.identity.tools.length > 0) {
<div class="tools-section">
<h4 class="tools-section__title">Tools Used</h4>
<div class="tools-list">
@for (tool of ev.identity.tools; track tool) {
<span class="tool-badge">{{ tool }}</span>
}
</div>
</div>
}
</section>
}
<!-- Occurrence Details -->
@if (selectedOccurrence(); as occ) {
<section class="detail-section">
<h3 class="detail-section__title">Occurrence Details</h3>
<div class="occurrence-detail">
<div class="occurrence-detail__row">
<span class="occurrence-detail__label">Location:</span>
<code class="occurrence-detail__value">{{ occ.location }}</code>
<button
type="button"
class="copy-btn"
(click)="copyToClipboard(occ.location)"
[attr.aria-label]="'Copy location'"
>
<svg width="14" height="14" 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 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
{{ copiedValue() === occ.location ? 'Copied!' : 'Copy' }}
</button>
</div>
@if (occ.line) {
<div class="occurrence-detail__row">
<span class="occurrence-detail__label">Line:</span>
<span class="occurrence-detail__value">{{ occ.line }}</span>
</div>
}
@if (occ.offset) {
<div class="occurrence-detail__row">
<span class="occurrence-detail__label">Offset:</span>
<span class="occurrence-detail__value">{{ occ.offset }}</span>
</div>
}
@if (occ.symbol) {
<div class="occurrence-detail__row">
<span class="occurrence-detail__label">Symbol:</span>
<code class="occurrence-detail__value">{{ occ.symbol }}</code>
</div>
}
@if (occ.additionalContext) {
<div class="occurrence-detail__row occurrence-detail__row--full">
<span class="occurrence-detail__label">Context:</span>
<p class="occurrence-detail__context">{{ occ.additionalContext }}</p>
</div>
}
</div>
<!-- Source File Content Preview -->
@if (sourceContent()) {
<div class="source-preview">
<h4 class="source-preview__title">Source Preview</h4>
<pre class="source-preview__code"><code>{{ sourceContent() }}</code></pre>
</div>
}
</section>
}
<!-- All Occurrences Summary -->
@if (ev.occurrences && ev.occurrences.length > 1 && !selectedOccurrence()) {
<section class="detail-section">
<h3 class="detail-section__title">All Occurrences ({{ ev.occurrences.length }})</h3>
<ul class="occurrences-summary">
@for (occ of ev.occurrences; track occ.location) {
<li class="occurrence-summary-item">
<code>{{ occ.location }}</code>
@if (occ.line) {
<span class="occurrence-line">:{{ occ.line }}</span>
}
</li>
}
</ul>
</section>
}
<!-- Licenses Section -->
@if (ev.licenses && ev.licenses.length > 0) {
<section class="detail-section">
<h3 class="detail-section__title">License Evidence</h3>
<ul class="licenses-detail">
@for (lic of ev.licenses; track lic.license.id ?? lic.license.name) {
<li class="license-detail-item">
<span class="license-id">{{ lic.license.id ?? lic.license.name }}</span>
<span class="license-ack" [class]="'ack--' + lic.acknowledgement">
{{ lic.acknowledgement }}
</span>
@if (lic.license.url) {
<a
class="license-link"
[href]="lic.license.url"
target="_blank"
rel="noopener noreferrer"
>
View License
</a>
}
</li>
}
</ul>
</section>
}
<!-- Copyright Section -->
@if (ev.copyright && ev.copyright.length > 0) {
<section class="detail-section">
<h3 class="detail-section__title">Copyright Evidence</h3>
<ul class="copyright-detail">
@for (cr of ev.copyright; track cr.text) {
<li class="copyright-item">{{ cr.text }}</li>
}
</ul>
</section>
}
} @else {
<div class="drawer-empty">
<p>No evidence data available.</p>
</div>
}
</div>
<!-- Footer -->
<footer class="drawer-footer">
<button
type="button"
class="drawer-btn drawer-btn--secondary"
(click)="onClose()"
>
Close
</button>
@if (evidence()?.identity) {
<button
type="button"
class="drawer-btn drawer-btn--primary"
(click)="copyEvidenceRef()"
>
{{ copiedRef() ? 'Copied!' : 'Copy Reference' }}
</button>
}
</footer>
</aside>
</div>
}
`,
styles: [`
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
justify-content: flex-end;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.drawer-panel {
width: 100%;
max-width: 640px;
height: 100%;
background: var(--surface-primary, #ffffff);
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-default, #e5e7eb);
flex-shrink: 0;
}
.drawer-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
.drawer-close-btn {
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted, #9ca3af);
border-radius: 6px;
transition: all 0.15s;
&:hover {
background: var(--surface-hover, #f3f4f6);
color: var(--text-primary, #111827);
}
&:focus-visible {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: 2px;
}
}
.drawer-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-default, #e5e7eb);
flex-shrink: 0;
}
.drawer-btn {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
&:focus-visible {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: 2px;
}
}
.drawer-btn--primary {
background: var(--color-primary, #2563eb);
color: white;
border: none;
&:hover {
background: var(--color-primary-hover, #1d4ed8);
}
}
.drawer-btn--secondary {
background: transparent;
color: var(--text-primary, #111827);
border: 1px solid var(--border-default, #e5e7eb);
&:hover {
background: var(--surface-hover, #f3f4f6);
}
}
/* Detail Sections */
.detail-section {
margin-bottom: 2rem;
&:last-child {
margin-bottom: 0;
}
}
.detail-section__title {
margin: 0 0 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Identity Detail */
.identity-detail {
background: var(--surface-secondary, #f9fafb);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.identity-detail__row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.identity-detail__label {
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
min-width: 80px;
}
.identity-detail__value {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #111827);
}
/* Method Chain */
.method-chain {
background: var(--surface-secondary, #f9fafb);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.method-chain__title {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary, #111827);
}
.method-chain__list {
list-style: none;
margin: 0;
padding: 0;
}
.method-chain__item {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-light, #e5e7eb);
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.method-chain__step {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
background: var(--color-primary, #2563eb);
color: white;
font-size: 0.75rem;
font-weight: 600;
border-radius: 50%;
flex-shrink: 0;
}
.method-chain__content {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.method-chain__technique {
font-weight: 500;
color: var(--text-primary, #111827);
}
.method-chain__value {
background: var(--code-bg, #e5e7eb);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.8125rem;
word-break: break-all;
}
.method-chain__confidence {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
width: 100%;
}
/* Copy Button */
.copy-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: var(--text-link, #2563eb);
background: transparent;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--surface-hover, #f3f4f6);
border-color: var(--text-link, #2563eb);
}
}
/* Tools Section */
.tools-section {
margin-top: 1rem;
}
.tools-section__title {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
}
.tools-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.tool-badge {
padding: 0.25rem 0.5rem;
background: var(--surface-tertiary, #e5e7eb);
border-radius: 4px;
font-size: 0.75rem;
color: var(--text-primary, #111827);
}
/* Occurrence Detail */
.occurrence-detail {
background: var(--surface-secondary, #f9fafb);
padding: 1rem;
border-radius: 8px;
}
.occurrence-detail__row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
&--full {
flex-direction: column;
align-items: flex-start;
}
}
.occurrence-detail__label {
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
min-width: 60px;
}
.occurrence-detail__value {
font-size: 0.875rem;
color: var(--text-primary, #111827);
}
.occurrence-detail__context {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: var(--text-primary, #111827);
line-height: 1.5;
}
/* Source Preview */
.source-preview {
margin-top: 1rem;
}
.source-preview__title {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
}
.source-preview__code {
margin: 0;
padding: 1rem;
background: var(--code-bg, #1f2937);
color: var(--code-text, #e5e7eb);
border-radius: 6px;
overflow-x: auto;
font-size: 0.8125rem;
line-height: 1.5;
}
/* Occurrences Summary */
.occurrences-summary {
list-style: none;
margin: 0;
padding: 0;
}
.occurrence-summary-item {
padding: 0.5rem;
background: var(--surface-secondary, #f9fafb);
border-radius: 4px;
margin-bottom: 0.25rem;
font-size: 0.8125rem;
&:last-child {
margin-bottom: 0;
}
}
.occurrence-line {
color: var(--text-muted, #9ca3af);
}
/* Licenses Detail */
.licenses-detail {
list-style: none;
margin: 0;
padding: 0;
}
.license-detail-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--surface-secondary, #f9fafb);
border-radius: 6px;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.license-id {
font-weight: 500;
color: var(--text-primary, #111827);
}
.license-ack {
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.75rem;
&.ack--declared {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info-text, #1d4ed8);
}
&.ack--concluded {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success-text, #15803d);
}
}
.license-link {
margin-left: auto;
font-size: 0.8125rem;
color: var(--text-link, #2563eb);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
/* Copyright Detail */
.copyright-detail {
list-style: none;
margin: 0;
padding: 0;
}
.copyright-item {
padding: 0.75rem;
background: var(--surface-secondary, #f9fafb);
border-radius: 6px;
margin-bottom: 0.5rem;
font-size: 0.875rem;
&:last-child {
margin-bottom: 0;
}
}
/* Empty State */
.drawer-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-muted, #9ca3af);
}
/* Responsive */
@media (max-width: 640px) {
.drawer-panel {
max-width: 100%;
}
}
`],
})
export class EvidenceDetailDrawerComponent implements OnInit, OnDestroy {
private readonly document = inject(DOCUMENT);
/** Whether the drawer is open */
readonly open = input<boolean>(false);
/** Evidence data to display */
readonly evidence = input<ComponentEvidence | undefined>(undefined);
/** Selected occurrence for detail view */
readonly selectedOccurrence = input<OccurrenceEvidence | undefined>(undefined);
/** Source file content (optional) */
readonly sourceContent = input<string | undefined>(undefined);
/** Emits when drawer should close */
readonly closeDrawer = output<void>();
/** Currently copied value for feedback */
readonly copiedValue = signal<string | null>(null);
/** Whether reference was copied */
readonly copiedRef = signal<boolean>(false);
/** Handle escape key to close drawer */
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.open()) {
this.onClose();
}
}
ngOnInit(): void {
// Lock body scroll when drawer opens
if (this.open()) {
this.lockBodyScroll();
}
}
ngOnDestroy(): void {
this.unlockBodyScroll();
}
/** Lock body scroll */
private lockBodyScroll(): void {
this.document.body.style.overflow = 'hidden';
}
/** Unlock body scroll */
private unlockBodyScroll(): void {
this.document.body.style.overflow = '';
}
/** Handle overlay click */
onOverlayClick(event: MouseEvent): void {
if ((event.target as HTMLElement).classList.contains('drawer-overlay')) {
this.onClose();
}
}
/** Close the drawer */
onClose(): void {
this.unlockBodyScroll();
this.closeDrawer.emit();
}
/** Get technique label */
getTechniqueLabel(technique: string): string {
return getIdentityTechniqueLabel(technique as Parameters<typeof getIdentityTechniqueLabel>[0]);
}
/** Copy value to clipboard */
async copyToClipboard(value: string): Promise<void> {
try {
await navigator.clipboard.writeText(value);
this.copiedValue.set(value);
setTimeout(() => this.copiedValue.set(null), 2000);
} catch {
console.error('Failed to copy to clipboard');
}
}
/** Copy full evidence reference */
async copyEvidenceRef(): Promise<void> {
const ev = this.evidence();
if (!ev?.identity) return;
const ref = JSON.stringify(
{
field: ev.identity.field,
confidence: ev.identity.confidence,
methods: ev.identity.methods?.map((m) => m.technique),
tools: ev.identity.tools,
},
null,
2
);
try {
await navigator.clipboard.writeText(ref);
this.copiedRef.set(true);
setTimeout(() => this.copiedRef.set(false), 2000);
} catch {
console.error('Failed to copy reference');
}
}
}

View File

@@ -0,0 +1,13 @@
/**
* @file index.ts
* @sprint SPRINT_20260107_005_004_FE
* @description Public API for SBOM components.
*/
export { CdxEvidencePanelComponent } from './cdx-evidence-panel/cdx-evidence-panel.component';
export { EvidenceConfidenceBadgeComponent } from './evidence-confidence-badge/evidence-confidence-badge.component';
export { PedigreeTimelineComponent } from './pedigree-timeline/pedigree-timeline.component';
export { PatchListComponent, ViewDiffEvent } from './patch-list/patch-list.component';
export { EvidenceDetailDrawerComponent } from './evidence-detail-drawer/evidence-detail-drawer.component';
export { DiffViewerComponent } from './diff-viewer/diff-viewer.component';
export { CommitInfoComponent } from './commit-info/commit-info.component';

View File

@@ -0,0 +1,323 @@
/**
* @file patch-list.component.spec.ts
* @sprint SPRINT_20260107_005_004_FE (UI-011)
* @description Unit tests for PatchListComponent.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PatchListComponent } from './patch-list.component';
import { ComponentPedigree, getPatchTypeLabel, getPatchBadgeColor } from '../../models/cyclonedx-evidence.models';
describe('PatchListComponent', () => {
let component: PatchListComponent;
let fixture: ComponentFixture<PatchListComponent>;
const mockPedigree: ComponentPedigree = {
patches: [
{
type: 'backport',
diff: { url: 'https://github.com/openssl/openssl/commit/abc123.patch' },
resolves: [
{ id: 'CVE-2024-1234', type: 'security', name: 'Buffer overflow vulnerability' },
{ id: 'CVE-2024-5678', type: 'security' },
],
},
{
type: 'cherry-pick',
resolves: [{ id: 'CVE-2024-9999', type: 'security' }],
},
{
type: 'monkey',
resolves: [],
},
],
commits: [
{
uid: 'abc123def456789',
url: 'https://github.com/openssl/openssl/commit/abc123def456789',
},
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PatchListComponent],
}).compileComponents();
fixture = TestBed.createComponent(PatchListComponent);
component = fixture.componentInstance;
});
describe('basic rendering', () => {
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should display header with patch count', () => {
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.patch-list__title');
expect(header.textContent).toContain('Patches Applied (3)');
});
it('should show empty state when no patches', () => {
fixture.componentRef.setInput('pedigree', { patches: [] });
fixture.detectChanges();
const empty = fixture.nativeElement.querySelector('.patch-list__empty');
expect(empty).toBeTruthy();
});
it('should show empty state when pedigree undefined', () => {
fixture.componentRef.setInput('pedigree', undefined);
fixture.detectChanges();
const empty = fixture.nativeElement.querySelector('.patch-list__empty');
expect(empty).toBeTruthy();
});
});
describe('patch items', () => {
beforeEach(() => {
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
});
it('should render all patches', () => {
const items = fixture.nativeElement.querySelectorAll('.patch-item');
expect(items.length).toBe(3);
});
it('should display type badges', () => {
const badges = fixture.nativeElement.querySelectorAll('.patch-badge');
expect(badges.length).toBe(3);
});
it('should apply correct class for backport badge', () => {
const backportBadge = fixture.nativeElement.querySelector('.patch-badge--backport');
expect(backportBadge).toBeTruthy();
expect(backportBadge.textContent).toBe('Backport');
});
it('should apply correct class for cherry-pick badge', () => {
const cherryPickBadge = fixture.nativeElement.querySelector('.patch-badge--cherry-pick');
expect(cherryPickBadge).toBeTruthy();
expect(cherryPickBadge.textContent).toBe('Cherry-pick');
});
it('should apply correct class for monkey patch badge', () => {
const monkeyBadge = fixture.nativeElement.querySelector('.patch-badge--monkey');
expect(monkeyBadge).toBeTruthy();
expect(monkeyBadge.textContent).toBe('Monkey Patch');
});
});
describe('CVE tags', () => {
beforeEach(() => {
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
});
it('should display CVE tags for resolved issues', () => {
const cveTags = fixture.nativeElement.querySelectorAll('.cve-tag');
expect(cveTags.length).toBeGreaterThan(0);
});
it('should limit displayed CVEs to 3 with "more" indicator', () => {
const pedigreeWithManyCves: ComponentPedigree = {
patches: [
{
type: 'backport',
resolves: [
{ id: 'CVE-2024-0001', type: 'security' },
{ id: 'CVE-2024-0002', type: 'security' },
{ id: 'CVE-2024-0003', type: 'security' },
{ id: 'CVE-2024-0004', type: 'security' },
{ id: 'CVE-2024-0005', type: 'security' },
],
},
],
};
fixture.componentRef.setInput('pedigree', pedigreeWithManyCves);
fixture.detectChanges();
const moreTags = fixture.nativeElement.querySelectorAll('.cve-tag--more');
expect(moreTags.length).toBe(1);
expect(moreTags[0].textContent).toContain('+2 more');
});
});
describe('diff button', () => {
beforeEach(() => {
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
});
it('should show Diff button when diff URL available', () => {
const diffBtns = fixture.nativeElement.querySelectorAll('.patch-action-btn');
expect(diffBtns.length).toBe(1); // Only first patch has diff
});
it('should emit viewDiff when Diff button clicked', () => {
const emitSpy = jest.spyOn(component.viewDiff, 'emit');
const diffBtn = fixture.nativeElement.querySelector('.patch-action-btn');
diffBtn.click();
expect(emitSpy).toHaveBeenCalled();
const emittedEvent = emitSpy.mock.calls[0][0];
expect(emittedEvent.diffUrl).toBe('https://github.com/openssl/openssl/commit/abc123.patch');
});
});
describe('expand/collapse', () => {
beforeEach(() => {
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
});
it('should start collapsed', () => {
expect(component.isExpanded(0)).toBe(false);
expect(component.isExpanded(1)).toBe(false);
expect(component.isExpanded(2)).toBe(false);
});
it('should expand on toggle', () => {
component.toggleExpand(0);
fixture.detectChanges();
expect(component.isExpanded(0)).toBe(true);
});
it('should collapse on second toggle', () => {
component.toggleExpand(0);
component.toggleExpand(0);
fixture.detectChanges();
expect(component.isExpanded(0)).toBe(false);
});
it('should show details when expanded', () => {
component.toggleExpand(0);
fixture.detectChanges();
const details = fixture.nativeElement.querySelector('.patch-item__details');
expect(details).toBeTruthy();
});
it('should show resolved issues list when expanded', () => {
component.toggleExpand(0);
fixture.detectChanges();
const resolvedList = fixture.nativeElement.querySelector('.resolved-list');
expect(resolvedList).toBeTruthy();
const resolvedItems = fixture.nativeElement.querySelectorAll('.resolved-item');
expect(resolvedItems.length).toBe(2);
});
});
describe('confidence badges', () => {
beforeEach(() => {
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
});
it('should show confidence badge with default values', () => {
const badges = fixture.nativeElement.querySelectorAll('app-evidence-confidence-badge');
expect(badges.length).toBe(3);
});
it('should use custom confidence from patchConfidences map', () => {
const customConfidences = new Map<number, number>();
customConfidences.set(0, 0.99);
fixture.componentRef.setInput('patchConfidences', customConfidences);
fixture.detectChanges();
const conf = component.getCommitConfidence(mockPedigree.patches![0]);
expect(conf).toBe(0.99);
});
it('should return default confidence based on patch type', () => {
const backportConf = component.getCommitConfidence(mockPedigree.patches![0]);
const cherryPickConf = component.getCommitConfidence(mockPedigree.patches![1]);
const monkeyConf = component.getCommitConfidence(mockPedigree.patches![2]);
expect(backportConf).toBe(0.95);
expect(cherryPickConf).toBe(0.80);
expect(monkeyConf).toBe(0.50);
});
});
describe('accessibility', () => {
beforeEach(() => {
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
});
it('should have aria-label on patch list section', () => {
const section = fixture.nativeElement.querySelector('.patch-list');
expect(section.getAttribute('aria-label')).toBe('Patches applied');
});
it('should have role="list" on items container', () => {
const list = fixture.nativeElement.querySelector('.patch-list__items');
expect(list.getAttribute('role')).toBe('list');
});
it('should have aria-expanded on expand buttons', () => {
const expandBtn = fixture.nativeElement.querySelector('.patch-expand-btn');
expect(expandBtn.getAttribute('aria-expanded')).toBe('false');
component.toggleExpand(0);
fixture.detectChanges();
expect(expandBtn.getAttribute('aria-expanded')).toBe('true');
});
it('should have aria-label on expand buttons', () => {
const expandBtn = fixture.nativeElement.querySelector('.patch-expand-btn');
expect(expandBtn.getAttribute('aria-label')).toContain('Expand patch details');
});
});
});
describe('getPatchTypeLabel', () => {
it('should return correct label for backport', () => {
expect(getPatchTypeLabel('backport')).toBe('Backport');
});
it('should return correct label for cherry-pick', () => {
expect(getPatchTypeLabel('cherry-pick')).toBe('Cherry-pick');
});
it('should return correct label for monkey', () => {
expect(getPatchTypeLabel('monkey')).toBe('Monkey Patch');
});
it('should return correct label for unofficial', () => {
expect(getPatchTypeLabel('unofficial')).toBe('Unofficial');
});
});
describe('getPatchBadgeColor', () => {
it('should return green for backport', () => {
expect(getPatchBadgeColor('backport')).toBe('green');
});
it('should return blue for cherry-pick', () => {
expect(getPatchBadgeColor('cherry-pick')).toBe('blue');
});
it('should return orange for monkey', () => {
expect(getPatchBadgeColor('monkey')).toBe('orange');
});
it('should return purple for unofficial', () => {
expect(getPatchBadgeColor('unofficial')).toBe('purple');
});
});

View File

@@ -0,0 +1,525 @@
/**
* @file patch-list.component.ts
* @sprint SPRINT_20260107_005_004_FE (UI-004)
* @description List component for displaying CycloneDX 1.7 pedigree patches.
* Shows patch type badges, resolved CVEs, confidence scores, and diff previews.
*/
import { Component, computed, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ComponentPedigree,
PedigreePatch,
PatchType,
ConfidenceTier,
getConfidenceTier,
getPatchBadgeColor,
getPatchTypeLabel,
CONFIDENCE_TIER_INFO,
} from '../../models/cyclonedx-evidence.models';
import { EvidenceConfidenceBadgeComponent } from '../evidence-confidence-badge/evidence-confidence-badge.component';
/**
* Event emitted when user wants to view a patch diff.
*/
export interface ViewDiffEvent {
readonly patch: PedigreePatch;
readonly diffUrl?: string;
}
/**
* Patch list component for displaying pedigree patches.
*
* Features:
* - List patches with type badges (backport, cherry-pick)
* - Show resolved CVEs per patch
* - Show confidence score with tier explanation
* - Expand to show diff preview
* - Link to full diff viewer
*
* @example
* <app-patch-list
* [pedigree]="component.pedigree"
* (viewDiff)="onViewDiff($event)"
* />
*/
@Component({
selector: 'app-patch-list',
standalone: true,
imports: [CommonModule, EvidenceConfidenceBadgeComponent],
template: `
<section class="patch-list" [attr.aria-label]="'Patches applied'">
<header class="patch-list__header">
<h4 class="patch-list__title">Patches Applied ({{ patches().length }})</h4>
</header>
@if (patches().length > 0) {
<ul class="patch-list__items" role="list">
@for (patch of patches(); track $index; let i = $index) {
<li class="patch-item" [class.expanded]="isExpanded(i)">
<div class="patch-item__header">
<!-- Type Badge -->
<span
class="patch-badge"
[class]="'patch-badge--' + patch.type"
>
{{ getTypeLabel(patch.type) }}
</span>
<!-- Resolved CVEs -->
@if (patch.resolves && patch.resolves.length > 0) {
<div class="patch-item__cves">
@for (resolve of patch.resolves.slice(0, 3); track resolve.id) {
<span class="cve-tag">{{ resolve.id }}</span>
}
@if (patch.resolves.length > 3) {
<span class="cve-tag cve-tag--more">
+{{ patch.resolves.length - 3 }} more
</span>
}
</div>
}
<!-- Confidence Badge (if commit has confidence) -->
@if (getCommitConfidence(patch) !== undefined) {
<app-evidence-confidence-badge
[confidence]="getCommitConfidence(patch)"
[showPercentage]="true"
size="sm"
/>
}
<!-- Actions -->
<div class="patch-item__actions">
@if (hasDiff(patch)) {
<button
type="button"
class="patch-action-btn"
(click)="onViewDiff(patch)"
[attr.aria-label]="'View diff for patch ' + (i + 1)"
>
Diff
</button>
}
<button
type="button"
class="patch-expand-btn"
[attr.aria-expanded]="isExpanded(i)"
(click)="toggleExpand(i)"
[attr.aria-label]="(isExpanded(i) ? 'Collapse' : 'Expand') + ' patch details'"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
[class.rotated]="isExpanded(i)"
>
<path d="M4.5 6L8 9.5L11.5 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>
</button>
</div>
</div>
<!-- Expanded Details -->
@if (isExpanded(i)) {
<div class="patch-item__details">
<!-- Commit Info -->
@if (getCommitForPatch(patch); as commit) {
<div class="patch-detail">
<span class="patch-detail__label">Commit:</span>
<code class="patch-detail__value">{{ commit.uid.slice(0, 7) }}</code>
@if (commit.url) {
<a
class="patch-detail__link"
[href]="commit.url"
target="_blank"
rel="noopener noreferrer"
[attr.aria-label]="'View commit ' + commit.uid.slice(0, 7)"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M10 2H14V6M14 2L7 9M12 9V14H2V4H7" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>
</a>
}
</div>
}
<!-- Confidence Tier Explanation -->
@if (getCommitConfidence(patch); as conf) {
<div class="patch-detail">
<span class="patch-detail__label">Confidence:</span>
<span class="patch-detail__value">
{{ getTierDescription(conf) }}
</span>
</div>
}
<!-- All Resolved Issues -->
@if (patch.resolves && patch.resolves.length > 0) {
<div class="patch-detail patch-detail--full">
<span class="patch-detail__label">Resolves:</span>
<ul class="resolved-list">
@for (resolve of patch.resolves; track resolve.id) {
<li class="resolved-item">
<span class="resolved-item__id">{{ resolve.id }}</span>
@if (resolve.name) {
<span class="resolved-item__name">{{ resolve.name }}</span>
}
<span class="resolved-item__type" [class]="'type--' + resolve.type">
{{ resolve.type }}
</span>
</li>
}
</ul>
</div>
}
</div>
}
</li>
}
</ul>
} @else {
<div class="patch-list__empty">
<p>No patches recorded for this component.</p>
</div>
}
</section>
`,
styles: [`
.patch-list {
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.patch-list__header {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-light, #f3f4f6);
}
.patch-list__title {
margin: 0;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary, #111827);
}
.patch-list__items {
list-style: none;
margin: 0;
padding: 0;
}
.patch-item {
border-bottom: 1px solid var(--border-light, #f3f4f6);
&:last-child {
border-bottom: none;
}
&.expanded {
background: var(--surface-secondary, #f9fafb);
}
}
.patch-item__header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
/* Patch Type Badge */
.patch-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.patch-badge--backport {
background: var(--color-backport-bg, #dcfce7);
color: var(--color-backport-text, #15803d);
}
.patch-badge--cherry-pick {
background: var(--color-cherrypick-bg, #dbeafe);
color: var(--color-cherrypick-text, #1d4ed8);
}
.patch-badge--monkey {
background: var(--color-monkey-bg, #ffedd5);
color: var(--color-monkey-text, #c2410c);
}
.patch-badge--unofficial {
background: var(--color-unofficial-bg, #f3e8ff);
color: var(--color-unofficial-text, #7c3aed);
}
/* CVE Tags */
.patch-item__cves {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
flex: 1;
}
.cve-tag {
padding: 0.125rem 0.375rem;
background: var(--surface-tertiary, #f3f4f6);
border-radius: 4px;
font-size: 0.6875rem;
font-family: monospace;
color: var(--text-primary, #111827);
}
.cve-tag--more {
color: var(--text-muted, #9ca3af);
font-family: inherit;
}
/* Actions */
.patch-item__actions {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
}
.patch-action-btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: var(--text-link, #2563eb);
background: transparent;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--surface-hover, #f3f4f6);
border-color: var(--text-link, #2563eb);
}
}
.patch-expand-btn {
padding: 0.25rem;
color: var(--text-muted, #9ca3af);
background: transparent;
border: none;
cursor: pointer;
transition: color 0.15s;
&:hover {
color: var(--text-primary, #111827);
}
svg {
transition: transform 0.2s;
&.rotated {
transform: rotate(180deg);
}
}
}
/* Expanded Details */
.patch-item__details {
padding: 0.75rem 1rem;
padding-top: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.patch-detail {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.patch-detail--full {
flex-direction: column;
align-items: flex-start;
}
.patch-detail__label {
color: var(--text-secondary, #6b7280);
font-size: 0.75rem;
}
.patch-detail__value {
color: var(--text-primary, #111827);
}
.patch-detail__link {
color: var(--text-muted, #9ca3af);
transition: color 0.15s;
&:hover {
color: var(--text-link, #2563eb);
}
}
/* Resolved List */
.resolved-list {
list-style: none;
margin: 0.25rem 0 0;
padding: 0;
width: 100%;
}
.resolved-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
}
.resolved-item__id {
font-family: monospace;
font-size: 0.8125rem;
color: var(--text-primary, #111827);
}
.resolved-item__name {
flex: 1;
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resolved-item__type {
padding: 0.0625rem 0.25rem;
border-radius: 2px;
font-size: 0.625rem;
text-transform: uppercase;
}
.type--security {
background: var(--color-security-bg, #fee2e2);
color: var(--color-security-text, #dc2626);
}
.type--defect {
background: var(--color-defect-bg, #fef3c7);
color: var(--color-defect-text, #d97706);
}
.type--enhancement {
background: var(--color-enhancement-bg, #dbeafe);
color: var(--color-enhancement-text, #2563eb);
}
/* Empty State */
.patch-list__empty {
padding: 1.5rem 1rem;
text-align: center;
color: var(--text-muted, #9ca3af);
font-size: 0.875rem;
}
`],
})
export class PatchListComponent {
/** CycloneDX pedigree data */
readonly pedigree = input<ComponentPedigree | undefined>(undefined);
/** Custom confidence map for patches (keyed by index or patch identifier) */
readonly patchConfidences = input<Map<number, number>>(new Map());
/** Emits when user wants to view a patch diff */
readonly viewDiff = output<ViewDiffEvent>();
/** Expanded patch indices */
private readonly expandedIndices = signal<Set<number>>(new Set());
/** Computed patches list */
readonly patches = computed<PedigreePatch[]>(() => {
return this.pedigree()?.patches ?? [];
});
/** Check if patch at index is expanded */
isExpanded(index: number): boolean {
return this.expandedIndices().has(index);
}
/** Toggle patch expansion */
toggleExpand(index: number): void {
this.expandedIndices.update((set) => {
const newSet = new Set(set);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
}
/** Get patch type label */
getTypeLabel(type: PatchType): string {
return getPatchTypeLabel(type);
}
/** Check if patch has diff available */
hasDiff(patch: PedigreePatch): boolean {
return !!(patch.diff?.url || patch.diff?.text?.content);
}
/** Get commit info for a patch (from pedigree.commits by correlation) */
getCommitForPatch(patch: PedigreePatch): { uid: string; url?: string } | null {
const pedigree = this.pedigree();
if (!pedigree?.commits?.length) return null;
// Try to find related commit - in real implementation this would be correlated
// For now, return first commit if available
const commit = pedigree.commits[0];
return commit ? { uid: commit.uid, url: commit.url } : null;
}
/** Get confidence for a patch */
getCommitConfidence(patch: PedigreePatch): number | undefined {
const pedigree = this.pedigree();
const index = this.patches().indexOf(patch);
// Check custom confidence map first
const customConf = this.patchConfidences().get(index);
if (customConf !== undefined) return customConf;
// Fallback to default based on patch type (heuristic)
switch (patch.type) {
case 'backport':
return 0.95; // Tier 1: Distro advisory
case 'cherry-pick':
return 0.80; // Tier 2: Commit match
case 'monkey':
return 0.50; // Tier 3: Runtime patch
case 'unofficial':
return 0.30; // Tier 4: Community patch
default:
return undefined;
}
}
/** Get tier description for confidence value */
getTierDescription(confidence: number): string {
const tier = getConfidenceTier(confidence);
return CONFIDENCE_TIER_INFO[tier].description;
}
/** Handle view diff click */
onViewDiff(patch: PedigreePatch): void {
this.viewDiff.emit({
patch,
diffUrl: patch.diff?.url,
});
}
}

View File

@@ -0,0 +1,303 @@
/**
* @file pedigree-timeline.component.spec.ts
* @sprint SPRINT_20260107_005_004_FE (UI-011)
* @description Unit tests for PedigreeTimelineComponent.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PedigreeTimelineComponent } from './pedigree-timeline.component';
import { ComponentPedigree } from '../../models/cyclonedx-evidence.models';
describe('PedigreeTimelineComponent', () => {
let component: PedigreeTimelineComponent;
let fixture: ComponentFixture<PedigreeTimelineComponent>;
const mockPedigree: ComponentPedigree = {
ancestors: [
{ type: 'library', name: 'openssl', version: '1.1.1n', purl: 'pkg:generic/openssl@1.1.1n' },
],
variants: [
{
type: 'library',
name: 'openssl',
version: '1.1.1n-0+deb11u5',
purl: 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5',
},
],
commits: [
{
uid: 'abc123def456789',
url: 'https://github.com/openssl/openssl/commit/abc123def456789',
},
],
patches: [
{
type: 'backport',
resolves: [{ id: 'CVE-2024-1234', type: 'security' }],
},
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PedigreeTimelineComponent],
}).compileComponents();
fixture = TestBed.createComponent(PedigreeTimelineComponent);
component = fixture.componentInstance;
});
describe('basic rendering', () => {
it('should create', () => {
fixture.componentRef.setInput('currentPurl', 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should display panel header', () => {
fixture.componentRef.setInput('currentPurl', 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.pedigree-timeline__title');
expect(header.textContent).toBe('PEDIGREE');
});
it('should show empty state when no pedigree', () => {
fixture.componentRef.setInput('currentPurl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('pedigree', undefined);
fixture.detectChanges();
// Should still show current node even without pedigree
const nodes = fixture.nativeElement.querySelectorAll('.timeline-node');
expect(nodes.length).toBe(1); // Just the current node
});
});
describe('node computation', () => {
beforeEach(() => {
fixture.componentRef.setInput('currentPurl', 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
fixture.componentRef.setInput('currentName', 'openssl');
fixture.componentRef.setInput('currentVersion', '1.1.1n-0+deb11u5');
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
});
it('should compute nodes from pedigree', () => {
const nodes = component.nodes();
expect(nodes.length).toBe(3); // 1 ancestor + 1 variant + 1 current
});
it('should include ancestor nodes', () => {
const nodes = component.nodes();
const ancestorNode = nodes.find((n) => n.nodeType === 'ancestor');
expect(ancestorNode).toBeTruthy();
expect(ancestorNode?.label).toBe('openssl');
expect(ancestorNode?.version).toBe('1.1.1n');
});
it('should include variant nodes', () => {
const nodes = component.nodes();
const variantNode = nodes.find((n) => n.nodeType === 'variant');
expect(variantNode).toBeTruthy();
expect(variantNode?.label).toBe('openssl');
expect(variantNode?.version).toBe('1.1.1n-0+deb11u5');
});
it('should include current node', () => {
const nodes = component.nodes();
const currentNode = nodes.find((n) => n.nodeType === 'current');
expect(currentNode).toBeTruthy();
expect(currentNode?.label).toBe('openssl');
});
});
describe('node rendering', () => {
beforeEach(() => {
fixture.componentRef.setInput('currentPurl', 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
fixture.componentRef.setInput('currentName', 'openssl');
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
});
it('should render all nodes', () => {
const nodes = fixture.nativeElement.querySelectorAll('.timeline-node');
expect(nodes.length).toBe(3);
});
it('should apply ancestor class to ancestor nodes', () => {
const ancestorNode = fixture.nativeElement.querySelector('.timeline-node--ancestor');
expect(ancestorNode).toBeTruthy();
});
it('should apply variant class to variant nodes', () => {
const variantNode = fixture.nativeElement.querySelector('.timeline-node--variant');
expect(variantNode).toBeTruthy();
});
it('should apply current class to current node', () => {
const currentNode = fixture.nativeElement.querySelector('.timeline-node--current');
expect(currentNode).toBeTruthy();
});
it('should display node names', () => {
const names = fixture.nativeElement.querySelectorAll('.timeline-node__name');
expect(names.length).toBe(3);
names.forEach((name: HTMLElement) => {
expect(name.textContent).toBe('openssl');
});
});
it('should display version when available', () => {
const versions = fixture.nativeElement.querySelectorAll('.timeline-node__version');
expect(versions.length).toBeGreaterThan(0);
});
it('should render connectors between nodes', () => {
const connectors = fixture.nativeElement.querySelectorAll('.timeline-connector');
expect(connectors.length).toBe(2); // Between 3 nodes
});
});
describe('stage labels', () => {
it('should show Upstream label when ancestors exist', () => {
fixture.componentRef.setInput('currentPurl', 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
const stages = fixture.nativeElement.querySelectorAll('.timeline-stage');
const stageTexts = Array.from(stages).map((s: any) => s.textContent);
expect(stageTexts).toContain('Upstream');
});
it('should show Distro label when variants exist', () => {
fixture.componentRef.setInput('currentPurl', 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
const stages = fixture.nativeElement.querySelectorAll('.timeline-stage');
const stageTexts = Array.from(stages).map((s: any) => s.textContent);
expect(stageTexts).toContain('Distro');
});
it('should always show Local label', () => {
fixture.componentRef.setInput('currentPurl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('pedigree', undefined);
fixture.detectChanges();
const currentStage = fixture.nativeElement.querySelector('.timeline-stage--current');
expect(currentStage.textContent).toBe('Local');
});
});
describe('node click events', () => {
beforeEach(() => {
fixture.componentRef.setInput('currentPurl', 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
});
it('should emit nodeClick when node clicked', () => {
const emitSpy = jest.spyOn(component.nodeClick, 'emit');
const node = fixture.nativeElement.querySelector('.timeline-node');
node.click();
expect(emitSpy).toHaveBeenCalled();
});
it('should pass correct node data on click', () => {
const emitSpy = jest.spyOn(component.nodeClick, 'emit');
const ancestorNode = fixture.nativeElement.querySelector('.timeline-node--ancestor');
ancestorNode.click();
const emittedNode = emitSpy.mock.calls[0][0];
expect(emittedNode.nodeType).toBe('ancestor');
expect(emittedNode.label).toBe('openssl');
});
});
describe('accessibility', () => {
beforeEach(() => {
fixture.componentRef.setInput('currentPurl', 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
});
it('should have aria-label on timeline section', () => {
const section = fixture.nativeElement.querySelector('.pedigree-timeline');
expect(section.getAttribute('aria-label')).toContain('Pedigree for');
});
it('should have aria-label on each node button', () => {
const nodes = fixture.nativeElement.querySelectorAll('.timeline-node');
nodes.forEach((node: HTMLElement) => {
expect(node.getAttribute('aria-label')).toBeTruthy();
});
});
it('should have role="list" on nodes container', () => {
const container = fixture.nativeElement.querySelector('.timeline-nodes');
expect(container.getAttribute('role')).toBe('list');
});
});
describe('hasAncestors and hasVariants computed', () => {
it('should return true for hasAncestors when ancestors exist', () => {
fixture.componentRef.setInput('currentPurl', 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
expect(component.hasAncestors()).toBe(true);
});
it('should return false for hasAncestors when no ancestors', () => {
fixture.componentRef.setInput('currentPurl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('pedigree', { variants: [] });
fixture.detectChanges();
expect(component.hasAncestors()).toBe(false);
});
it('should return true for hasVariants when variants exist', () => {
fixture.componentRef.setInput('currentPurl', 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
fixture.componentRef.setInput('pedigree', mockPedigree);
fixture.detectChanges();
expect(component.hasVariants()).toBe(true);
});
});
describe('PURL name extraction', () => {
it('should extract name from npm PURL', () => {
fixture.componentRef.setInput('currentPurl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('pedigree', undefined);
fixture.detectChanges();
const nodes = component.nodes();
const currentNode = nodes.find((n) => n.nodeType === 'current');
expect(currentNode?.label).toBe('lodash');
});
it('should use PURL as fallback if extraction fails', () => {
fixture.componentRef.setInput('currentPurl', 'invalid-purl');
fixture.componentRef.setInput('pedigree', undefined);
fixture.detectChanges();
const nodes = component.nodes();
const currentNode = nodes.find((n) => n.nodeType === 'current');
expect(currentNode?.label).toBe('invalid-purl');
});
it('should prefer currentName input over PURL extraction', () => {
fixture.componentRef.setInput('currentPurl', 'pkg:npm/lodash@4.17.21');
fixture.componentRef.setInput('currentName', 'Custom Name');
fixture.componentRef.setInput('pedigree', undefined);
fixture.detectChanges();
const nodes = component.nodes();
const currentNode = nodes.find((n) => n.nodeType === 'current');
expect(currentNode?.label).toBe('Custom Name');
});
});
});

View File

@@ -0,0 +1,399 @@
/**
* @file pedigree-timeline.component.ts
* @sprint SPRINT_20260107_005_004_FE (UI-003)
* @description D3.js horizontal timeline visualization for CycloneDX 1.7 pedigree data.
* Shows ancestor -> variant -> current progression with clickable nodes.
*/
import {
Component,
ElementRef,
OnDestroy,
OnInit,
computed,
effect,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ComponentPedigree,
PedigreeComponent,
PedigreeTimelineNode,
} from '../../models/cyclonedx-evidence.models';
/**
* Pedigree timeline component using D3.js for visualization.
*
* Features:
* - Horizontal timeline showing component lineage
* - Ancestor -> Variant -> Current progression
* - Clickable nodes for details
* - Responsive layout
* - Version change highlighting
*
* @example
* <app-pedigree-timeline
* [pedigree]="component.pedigree"
* [currentPurl]="component.purl"
* (nodeClick)="onNodeClick($event)"
* />
*/
@Component({
selector: 'app-pedigree-timeline',
standalone: true,
imports: [CommonModule],
template: `
<section class="pedigree-timeline" [attr.aria-label]="'Pedigree for ' + currentPurl()">
<header class="pedigree-timeline__header">
<h3 class="pedigree-timeline__title">PEDIGREE</h3>
@if (nodes().length > 0) {
<span class="pedigree-timeline__count">
{{ nodes().length }} {{ nodes().length === 1 ? 'node' : 'nodes' }}
</span>
}
</header>
@if (nodes().length > 0) {
<div class="pedigree-timeline__chart" #chartContainer>
<!-- Timeline axis -->
<div class="timeline-axis">
<div class="timeline-axis__line"></div>
<!-- Timeline stages -->
<div class="timeline-stages">
@if (hasAncestors()) {
<span class="timeline-stage">Upstream</span>
}
@if (hasVariants()) {
<span class="timeline-stage">Distro</span>
}
<span class="timeline-stage timeline-stage--current">Local</span>
</div>
</div>
<!-- Nodes -->
<div class="timeline-nodes" role="list">
@for (node of nodes(); track node.id; let first = $first; let last = $last) {
<div class="timeline-node-wrapper">
@if (!first) {
<div class="timeline-connector"></div>
}
<button
type="button"
class="timeline-node"
[class.timeline-node--ancestor]="node.nodeType === 'ancestor'"
[class.timeline-node--variant]="node.nodeType === 'variant'"
[class.timeline-node--current]="node.nodeType === 'current'"
[attr.aria-label]="getNodeAriaLabel(node)"
(click)="onNodeClick(node)"
>
<span class="timeline-node__name">{{ node.label }}</span>
@if (node.version) {
<span class="timeline-node__version">{{ node.version }}</span>
}
</button>
</div>
}
</div>
</div>
} @else {
<div class="pedigree-timeline__empty">
<p>No pedigree data available for this component.</p>
</div>
}
</section>
`,
styles: [`
.pedigree-timeline {
background: var(--surface-secondary, #f9fafb);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.pedigree-timeline__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--surface-primary, #ffffff);
border-bottom: 1px solid var(--border-default, #e5e7eb);
}
.pedigree-timeline__title {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--text-secondary, #6b7280);
}
.pedigree-timeline__count {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
.pedigree-timeline__chart {
padding: 1.5rem 1rem;
}
/* Timeline Axis */
.timeline-axis {
position: relative;
margin-bottom: 1rem;
}
.timeline-axis__line {
height: 2px;
background: linear-gradient(
to right,
var(--color-ancestor, #6366f1),
var(--color-variant, #8b5cf6),
var(--color-current, #10b981)
);
border-radius: 1px;
}
.timeline-stages {
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
}
.timeline-stage {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #9ca3af);
}
.timeline-stage--current {
color: var(--color-current, #10b981);
}
/* Timeline Nodes */
.timeline-nodes {
display: flex;
align-items: center;
gap: 0;
overflow-x: auto;
padding: 0.5rem 0;
}
.timeline-node-wrapper {
display: flex;
align-items: center;
}
.timeline-connector {
width: 2rem;
height: 2px;
background: var(--border-default, #e5e7eb);
flex-shrink: 0;
}
.timeline-node {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 6rem;
padding: 0.75rem 1rem;
background: var(--surface-primary, #ffffff);
border: 2px solid var(--border-default, #e5e7eb);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&:focus-visible {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: 2px;
}
}
.timeline-node--ancestor {
border-color: var(--color-ancestor, #6366f1);
background: var(--color-ancestor-bg, #eef2ff);
.timeline-node__name {
color: var(--color-ancestor, #6366f1);
}
}
.timeline-node--variant {
border-color: var(--color-variant, #8b5cf6);
background: var(--color-variant-bg, #f5f3ff);
.timeline-node__name {
color: var(--color-variant, #8b5cf6);
}
}
.timeline-node--current {
border-color: var(--color-current, #10b981);
background: var(--color-current-bg, #ecfdf5);
border-width: 3px;
.timeline-node__name {
color: var(--color-current, #10b981);
font-weight: 600;
}
}
.timeline-node__name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 8rem;
}
.timeline-node__version {
font-size: 0.6875rem;
color: var(--text-muted, #9ca3af);
margin-top: 0.125rem;
}
/* Empty State */
.pedigree-timeline__empty {
padding: 2rem 1rem;
text-align: center;
color: var(--text-muted, #9ca3af);
font-size: 0.875rem;
}
/* Responsive */
@media (max-width: 640px) {
.timeline-nodes {
flex-direction: column;
align-items: stretch;
}
.timeline-connector {
width: 2px;
height: 1.5rem;
margin: 0 auto;
}
.timeline-node {
width: 100%;
max-width: none;
}
}
`],
})
export class PedigreeTimelineComponent implements OnInit, OnDestroy {
/** CycloneDX pedigree data */
readonly pedigree = input<ComponentPedigree | undefined>(undefined);
/** Current component PURL for highlighting */
readonly currentPurl = input.required<string>();
/** Current component name */
readonly currentName = input<string>('');
/** Current component version */
readonly currentVersion = input<string | undefined>(undefined);
/** Emits when a node is clicked */
readonly nodeClick = output<PedigreeTimelineNode>();
/** Chart container reference */
readonly chartContainer = viewChild<ElementRef<HTMLDivElement>>('chartContainer');
/** Computed timeline nodes */
readonly nodes = computed<PedigreeTimelineNode[]>(() => {
const pedigree = this.pedigree();
const nodes: PedigreeTimelineNode[] = [];
// Add ancestors
if (pedigree?.ancestors) {
for (const ancestor of pedigree.ancestors) {
nodes.push(this.componentToNode(ancestor, 'ancestor'));
}
}
// Add variants (distro packages)
if (pedigree?.variants) {
for (const variant of pedigree.variants) {
nodes.push(this.componentToNode(variant, 'variant'));
}
}
// Add current node
nodes.push({
id: 'current',
label: this.currentName() || this.extractNameFromPurl(this.currentPurl()),
nodeType: 'current',
version: this.currentVersion(),
purl: this.currentPurl(),
});
return nodes;
});
/** Check if there are ancestors */
readonly hasAncestors = computed(() => {
const pedigree = this.pedigree();
return pedigree?.ancestors && pedigree.ancestors.length > 0;
});
/** Check if there are variants */
readonly hasVariants = computed(() => {
const pedigree = this.pedigree();
return pedigree?.variants && pedigree.variants.length > 0;
});
ngOnInit(): void {
// Component initialization
}
ngOnDestroy(): void {
// Cleanup if needed
}
/** Convert pedigree component to timeline node */
private componentToNode(
component: PedigreeComponent,
nodeType: 'ancestor' | 'variant'
): PedigreeTimelineNode {
return {
id: component.bomRef ?? `${nodeType}-${component.name}-${component.version}`,
label: component.name,
nodeType,
version: component.version,
purl: component.purl,
};
}
/** Extract name from PURL */
private extractNameFromPurl(purl: string): string {
const match = purl.match(/pkg:[^/]+\/([^@]+)/);
return match?.[1] ?? purl;
}
/** Get aria label for node */
getNodeAriaLabel(node: PedigreeTimelineNode): string {
const type = node.nodeType === 'current' ? 'Current' :
node.nodeType === 'ancestor' ? 'Upstream' : 'Distro';
const version = node.version ? ` version ${node.version}` : '';
return `${type}: ${node.label}${version}. Click for details.`;
}
/** Handle node click */
onNodeClick(node: PedigreeTimelineNode): void {
this.nodeClick.emit(node);
}
}

View File

@@ -0,0 +1,9 @@
/**
* @file index.ts
* @sprint SPRINT_20260107_005_004_FE
* @description Public API for SBOM feature module.
*/
export * from './components';
export * from './models';
export * from './services';

View File

@@ -0,0 +1,501 @@
/**
* @file cyclonedx-evidence.models.ts
* @sprint SPRINT_20260107_005_004_FE (UI-009)
* @description TypeScript interfaces for CycloneDX 1.7 evidence and pedigree fields.
* @see https://cyclonedx.org/docs/1.7/json/#components_items_evidence
*/
// ============================================================================
// CONFIDENCE & SCORING
// ============================================================================
/**
* Confidence tier mapping to visual indicators.
* Higher tiers indicate more reliable evidence sources.
*/
export type ConfidenceTier =
| 'tier1' // 90-100%: Distro advisory, signed attestation
| 'tier2' // 75-89%: Changelog analysis, commit match
| 'tier3' // 50-74%: File heuristics, pattern matching
| 'tier4' // 25-49%: Version inference, fuzzy matching
| 'tier5'; // 0-24%: Fallback/unknown
/**
* Maps confidence score (0-1) to visual tier and color.
*/
export function getConfidenceTier(confidence: number | undefined): ConfidenceTier {
if (confidence === undefined || confidence === null) return 'tier5';
if (confidence >= 0.9) return 'tier1';
if (confidence >= 0.75) return 'tier2';
if (confidence >= 0.5) return 'tier3';
if (confidence >= 0.25) return 'tier4';
return 'tier5';
}
/**
* Tier display metadata for UI rendering.
*/
export interface ConfidenceTierInfo {
readonly tier: ConfidenceTier;
readonly label: string;
readonly color: 'green' | 'yellow-green' | 'yellow' | 'orange' | 'red';
readonly description: string;
}
export const CONFIDENCE_TIER_INFO: Record<ConfidenceTier, ConfidenceTierInfo> = {
tier1: {
tier: 'tier1',
label: 'Very High',
color: 'green',
description: 'Verified by authoritative source (distro advisory, signed attestation)',
},
tier2: {
tier: 'tier2',
label: 'High',
color: 'yellow-green',
description: 'Strong evidence (changelog analysis, commit match)',
},
tier3: {
tier: 'tier3',
label: 'Medium',
color: 'yellow',
description: 'Moderate confidence (file heuristics, pattern matching)',
},
tier4: {
tier: 'tier4',
label: 'Low',
color: 'orange',
description: 'Weak evidence (version inference, fuzzy matching)',
},
tier5: {
tier: 'tier5',
label: 'Unknown',
color: 'red',
description: 'No confidence data or unverified',
},
};
// ============================================================================
// CYCLONEDX 1.7 EVIDENCE TYPES
// ============================================================================
/**
* CycloneDX 1.7 evidence.identity - How the component was identified.
* @see https://cyclonedx.org/docs/1.7/json/#components_items_evidence_identity
*/
export interface IdentityEvidence {
/** The field that was identified (e.g., 'purl', 'cpe', 'name') */
readonly field: IdentityField;
/** Overall confidence in the identity (0-1) */
readonly confidence?: number;
/** Detection methods used */
readonly methods?: readonly IdentityMethod[];
/** Tools that performed the identification */
readonly tools?: readonly string[];
}
/**
* Fields that can be identified in identity evidence.
*/
export type IdentityField =
| 'group'
| 'name'
| 'version'
| 'purl'
| 'cpe'
| 'omniborId'
| 'swidTagId'
| 'hash';
/**
* Method used to identify a component.
*/
export interface IdentityMethod {
/** Detection technique */
readonly technique: IdentityTechnique;
/** Confidence of this specific method (0-1) */
readonly confidence: number;
/** Value detected by this method */
readonly value?: string;
}
/**
* Techniques for component identification.
*/
export type IdentityTechnique =
| 'source-code-analysis'
| 'binary-analysis'
| 'manifest-analysis'
| 'ast-fingerprint'
| 'hash-comparison'
| 'instrumentation'
| 'filename'
| 'attestation'
| 'other';
/**
* CycloneDX 1.7 evidence.occurrences - Where the component appears.
* @see https://cyclonedx.org/docs/1.7/json/#components_items_evidence_occurrences
*/
export interface OccurrenceEvidence {
/** BOM reference to the component */
readonly bomRef?: string;
/** File path or location */
readonly location: string;
/** Line number (if applicable) */
readonly line?: number;
/** Column offset (if applicable) */
readonly offset?: number;
/** Symbol or identifier at this location */
readonly symbol?: string;
/** Additional context */
readonly additionalContext?: string;
}
/**
* CycloneDX 1.7 evidence.licenses - License evidence with acknowledgement.
* @see https://cyclonedx.org/docs/1.7/json/#components_items_evidence_licenses
*/
export interface LicenseEvidence {
/** SPDX license ID or expression */
readonly license: LicenseInfo;
/** How the license was acknowledged */
readonly acknowledgement: LicenseAcknowledgement;
}
/**
* License information.
*/
export interface LicenseInfo {
/** SPDX license ID (e.g., 'MIT', 'Apache-2.0') */
readonly id?: string;
/** License name if not SPDX */
readonly name?: string;
/** URL to license text */
readonly url?: string;
}
/**
* License acknowledgement status.
*/
export type LicenseAcknowledgement =
| 'declared' // Declared by author in manifest
| 'concluded' // Concluded through analysis
| 'other';
/**
* CycloneDX 1.7 evidence.copyright - Copyright evidence.
* @see https://cyclonedx.org/docs/1.7/json/#components_items_evidence_copyright
*/
export interface CopyrightEvidence {
/** Copyright text */
readonly text: string;
}
/**
* CycloneDX 1.7 evidence.callstack - Call stack evidence (for crypto, etc.).
*/
export interface CallstackEvidence {
/** Frames in the call stack */
readonly frames: readonly CallstackFrame[];
}
/**
* Single frame in a call stack.
*/
export interface CallstackFrame {
/** Package containing this frame */
readonly package?: string;
/** Module/namespace */
readonly module?: string;
/** Function/method name */
readonly function?: string;
/** Full qualified name */
readonly fullFilename?: string;
/** Line number */
readonly line?: number;
/** Column */
readonly column?: number;
/** Parameters */
readonly parameters?: readonly string[];
}
/**
* Complete evidence object for a CycloneDX 1.7 component.
*/
export interface ComponentEvidence {
/** Identity evidence - how the component was identified */
readonly identity?: IdentityEvidence;
/** Occurrence evidence - where the component appears */
readonly occurrences?: readonly OccurrenceEvidence[];
/** License evidence */
readonly licenses?: readonly LicenseEvidence[];
/** Copyright evidence */
readonly copyright?: readonly CopyrightEvidence[];
/** Call stack evidence (for crypto usage, etc.) */
readonly callstack?: CallstackEvidence;
}
// ============================================================================
// CYCLONEDX 1.7 PEDIGREE TYPES
// ============================================================================
/**
* CycloneDX 1.7 pedigree - Component provenance and lineage.
* @see https://cyclonedx.org/docs/1.7/json/#components_items_pedigree
*/
export interface ComponentPedigree {
/** Ancestor components this was derived from */
readonly ancestors?: readonly PedigreeComponent[];
/** Variant components (modified versions) */
readonly variants?: readonly PedigreeComponent[];
/** Descendant components */
readonly descendants?: readonly PedigreeComponent[];
/** Commits associated with this component */
readonly commits?: readonly PedigreeCommit[];
/** Patches applied to this component */
readonly patches?: readonly PedigreePatch[];
/** Notes about the pedigree */
readonly notes?: string;
}
/**
* Component reference within pedigree.
*/
export interface PedigreeComponent {
/** Component type */
readonly type: ComponentType;
/** Component name */
readonly name: string;
/** Component version */
readonly version?: string;
/** Package URL */
readonly purl?: string;
/** Component BOM-ref */
readonly bomRef?: string;
}
/**
* CycloneDX component types.
*/
export type ComponentType =
| 'application'
| 'framework'
| 'library'
| 'container'
| 'platform'
| 'device-driver'
| 'machine-learning-model'
| 'data'
| 'cryptographic-asset'
| 'firmware'
| 'file'
| 'operating-system';
/**
* Commit information in pedigree.
*/
export interface PedigreeCommit {
/** Unique identifier (commit SHA) */
readonly uid: string;
/** URL to commit */
readonly url?: string;
/** Commit author */
readonly author?: CommitIdentity;
/** Commit committer */
readonly committer?: CommitIdentity;
/** Commit message */
readonly message?: string;
}
/**
* Identity for commit author/committer.
*/
export interface CommitIdentity {
/** Timestamp */
readonly timestamp?: string;
/** Name */
readonly name?: string;
/** Email */
readonly email?: string;
}
/**
* Patch information in pedigree.
*/
export interface PedigreePatch {
/** Patch type */
readonly type: PatchType;
/** Diff information */
readonly diff?: PatchDiff;
/** CVEs resolved by this patch */
readonly resolves?: readonly PatchResolves[];
}
/**
* Types of patches.
*/
export type PatchType =
| 'unofficial' // Community patch
| 'monkey' // Runtime patch
| 'backport' // Backported fix
| 'cherry-pick'; // Cherry-picked commit
/**
* Diff information for a patch.
*/
export interface PatchDiff {
/** URL to the diff */
readonly url?: string;
/** Text content of the diff */
readonly text?: {
readonly contentType?: string;
readonly content?: string;
readonly encoding?: 'base64';
};
}
/**
* What a patch resolves.
*/
export interface PatchResolves {
/** Vulnerability or issue ID */
readonly id: string;
/** Type of issue */
readonly type: 'defect' | 'enhancement' | 'security';
/** Name/description */
readonly name?: string;
/** Description */
readonly description?: string;
/** URL for more info */
readonly source?: { readonly url?: string };
}
// ============================================================================
// UI VIEW MODELS
// ============================================================================
/**
* View model for displaying evidence panel data.
*/
export interface EvidencePanelData {
/** Component PURL */
readonly purl: string;
/** Component name */
readonly name: string;
/** Component version */
readonly version?: string;
/** Evidence data */
readonly evidence?: ComponentEvidence;
/** Pedigree data */
readonly pedigree?: ComponentPedigree;
/** Loading state */
readonly isLoading: boolean;
/** Error if any */
readonly error?: string;
}
/**
* View model for pedigree timeline visualization.
*/
export interface PedigreeTimelineNode {
/** Unique ID for the node */
readonly id: string;
/** Node label */
readonly label: string;
/** Node type (ancestor, current, variant) */
readonly nodeType: 'ancestor' | 'current' | 'variant';
/** Version string */
readonly version?: string;
/** PURL if available */
readonly purl?: string;
/** X position for visualization */
x?: number;
/** Y position for visualization */
y?: number;
}
/**
* View model for patch list display.
*/
export interface PatchListItem {
/** Patch type */
readonly type: PatchType;
/** Type badge color */
readonly badgeColor: 'blue' | 'purple' | 'green' | 'orange';
/** CVEs resolved */
readonly resolvedCves: readonly string[];
/** Commit SHA (short) */
readonly commitSha?: string;
/** Commit URL */
readonly commitUrl?: string;
/** Confidence score */
readonly confidence?: number;
/** Confidence tier */
readonly confidenceTier: ConfidenceTier;
/** Has diff available */
readonly hasDiff: boolean;
/** Diff URL if available */
readonly diffUrl?: string;
}
/**
* Maps patch type to badge color.
*/
export function getPatchBadgeColor(type: PatchType): PatchListItem['badgeColor'] {
switch (type) {
case 'backport':
return 'green';
case 'cherry-pick':
return 'blue';
case 'monkey':
return 'orange';
case 'unofficial':
default:
return 'purple';
}
}
/**
* Maps patch type to human-readable label.
*/
export function getPatchTypeLabel(type: PatchType): string {
switch (type) {
case 'backport':
return 'Backport';
case 'cherry-pick':
return 'Cherry-pick';
case 'monkey':
return 'Monkey Patch';
case 'unofficial':
default:
return 'Unofficial';
}
}
/**
* Maps identity technique to human-readable label.
*/
export function getIdentityTechniqueLabel(technique: IdentityTechnique): string {
switch (technique) {
case 'source-code-analysis':
return 'Source Code Analysis';
case 'binary-analysis':
return 'Binary Analysis';
case 'manifest-analysis':
return 'Manifest Analysis';
case 'ast-fingerprint':
return 'AST Fingerprint';
case 'hash-comparison':
return 'Hash Comparison';
case 'instrumentation':
return 'Instrumentation';
case 'filename':
return 'Filename Match';
case 'attestation':
return 'Attestation';
case 'other':
default:
return 'Other';
}
}

View File

@@ -0,0 +1,7 @@
/**
* @file index.ts
* @sprint SPRINT_20260107_005_004_FE
* @description Public API for SBOM models.
*/
export * from './cyclonedx-evidence.models';

View File

@@ -0,0 +1,585 @@
/**
* @file component-detail.page.ts
* @sprint SPRINT_20260107_005_004_FE (UI-010)
* @description Page component for displaying full component details with evidence and pedigree.
* Integrates all SBOM evidence components for a comprehensive component view.
*/
import { Component, computed, inject, input, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { toSignal } from '@angular/core/rxjs-interop';
import { of, catchError, switchMap, startWith, map } from 'rxjs';
import {
ComponentEvidence,
ComponentPedigree,
OccurrenceEvidence,
PedigreeTimelineNode,
PedigreePatch,
} from '../../models/cyclonedx-evidence.models';
import { SbomEvidenceService } from '../../services/sbom-evidence.service';
import { CdxEvidencePanelComponent } from '../../components/cdx-evidence-panel/cdx-evidence-panel.component';
import { PedigreeTimelineComponent } from '../../components/pedigree-timeline/pedigree-timeline.component';
import { PatchListComponent, ViewDiffEvent } from '../../components/patch-list/patch-list.component';
import { EvidenceDetailDrawerComponent } from '../../components/evidence-detail-drawer/evidence-detail-drawer.component';
import { DiffViewerComponent } from '../../components/diff-viewer/diff-viewer.component';
import { CommitInfoComponent } from '../../components/commit-info/commit-info.component';
/**
* Loading state for component data.
*/
interface LoadingState {
readonly isLoading: boolean;
readonly error?: string;
}
/**
* Component detail page integrating all evidence and pedigree views.
*
* Features:
* - Add Evidence panel to component detail page
* - Add Pedigree timeline to component detail page
* - Lazy load evidence data
* - Handle components without evidence/pedigree
*
* @example
* <app-component-detail-page [purl]="'pkg:npm/lodash@4.17.21'" />
*/
@Component({
selector: 'app-component-detail-page',
standalone: true,
imports: [
CommonModule,
CdxEvidencePanelComponent,
PedigreeTimelineComponent,
PatchListComponent,
EvidenceDetailDrawerComponent,
DiffViewerComponent,
CommitInfoComponent,
],
providers: [SbomEvidenceService],
template: `
<main class="component-detail-page">
<!-- Header -->
<header class="page-header">
<div class="page-header__breadcrumb">
<a href="/sbom" class="breadcrumb-link">SBOM</a>
<span class="breadcrumb-separator">/</span>
<a href="/sbom/components" class="breadcrumb-link">Components</a>
<span class="breadcrumb-separator">/</span>
<span class="breadcrumb-current">{{ componentName() }}</span>
</div>
<h1 class="page-title">{{ componentName() }}</h1>
@if (version()) {
<span class="page-version">v{{ version() }}</span>
}
</header>
<!-- Loading State -->
@if (isLoading()) {
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Loading component evidence...</p>
</div>
}
<!-- Error State -->
@if (error(); as err) {
<div class="error-state" role="alert">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4M12 16h.01"/>
</svg>
<p>{{ err }}</p>
<button type="button" class="retry-btn" (click)="retry()">Retry</button>
</div>
}
<!-- Content -->
@if (!isLoading() && !error()) {
<div class="page-content">
<!-- Component Overview -->
<section class="content-section">
<div class="component-overview">
<div class="overview-item">
<span class="overview-label">PURL</span>
<code class="overview-value">{{ purl() }}</code>
</div>
@if (ecosystem()) {
<div class="overview-item">
<span class="overview-label">Ecosystem</span>
<span class="overview-value ecosystem-badge">{{ ecosystem() }}</span>
</div>
}
</div>
</section>
<!-- Evidence Panel -->
@if (hasEvidence()) {
<section class="content-section">
<app-cdx-evidence-panel
[purl]="purl()"
[evidence]="evidence()"
(viewOccurrence)="onViewOccurrence($event)"
/>
</section>
}
<!-- Pedigree Timeline -->
@if (hasPedigree()) {
<section class="content-section">
<app-pedigree-timeline
[pedigree]="pedigree()"
[currentPurl]="purl()"
[currentName]="componentName()"
[currentVersion]="version()"
(nodeClick)="onNodeClick($event)"
/>
</section>
}
<!-- Commits -->
@if (hasCommits()) {
<section class="content-section">
<h2 class="section-title">Commits</h2>
<div class="commits-list">
@for (commit of pedigree()?.commits; track commit.uid) {
<app-commit-info [commit]="commit" />
}
</div>
</section>
}
<!-- Patches -->
@if (hasPatches()) {
<section class="content-section">
<app-patch-list
[pedigree]="pedigree()"
(viewDiff)="onViewDiff($event)"
/>
</section>
}
<!-- No Evidence/Pedigree -->
@if (!hasEvidence() && !hasPedigree()) {
<section class="content-section">
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
<h3>No Evidence Data</h3>
<p>
This component doesn't have evidence or pedigree information yet.
Evidence is collected during SBOM generation when detection methods
capture provenance data.
</p>
</div>
</section>
}
</div>
}
<!-- Evidence Detail Drawer -->
<app-evidence-detail-drawer
[open]="drawerOpen()"
[evidence]="evidence()"
[selectedOccurrence]="selectedOccurrence()"
(closeDrawer)="closeDrawer()"
/>
<!-- Diff Viewer Modal -->
@if (showDiffViewer()) {
<div class="modal-overlay" (click)="closeDiffViewer()">
<div class="modal-content" (click)="$event.stopPropagation()">
<app-diff-viewer
[diff]="selectedPatch()?.diff"
[diffUrl]="selectedPatch()?.diff?.url"
(close)="closeDiffViewer()"
/>
</div>
</div>
}
</main>
`,
styles: [`
.component-detail-page {
min-height: 100vh;
background: var(--surface-tertiary, #f9fafb);
padding: 1.5rem;
}
/* Header */
.page-header {
margin-bottom: 1.5rem;
}
.page-header__breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.breadcrumb-link {
color: var(--text-link, #2563eb);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.breadcrumb-separator {
color: var(--text-muted, #9ca3af);
}
.breadcrumb-current {
color: var(--text-secondary, #6b7280);
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #111827);
display: inline-flex;
align-items: center;
gap: 0.75rem;
}
.page-version {
padding: 0.25rem 0.5rem;
background: var(--surface-secondary, #e5e7eb);
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 4rem 2rem;
color: var(--text-secondary, #6b7280);
}
.loading-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--border-default, #e5e7eb);
border-top-color: var(--color-primary, #2563eb);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error State */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 3rem 2rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--color-error-border, #fca5a5);
border-radius: 8px;
text-align: center;
color: var(--color-error, #dc2626);
}
.retry-btn {
padding: 0.5rem 1rem;
background: var(--color-primary, #2563eb);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--color-primary-hover, #1d4ed8);
}
}
/* Content */
.page-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.content-section {
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.section-title {
margin: 0;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--surface-secondary, #f9fafb);
border-bottom: 1px solid var(--border-default, #e5e7eb);
}
/* Component Overview */
.component-overview {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
padding: 1rem;
}
.overview-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.overview-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.overview-value {
font-size: 0.875rem;
color: var(--text-primary, #111827);
&.ecosystem-badge {
display: inline-flex;
padding: 0.25rem 0.5rem;
background: var(--surface-tertiary, #e5e7eb);
border-radius: 4px;
font-weight: 500;
}
}
/* Commits List */
.commits-list {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 4rem 2rem;
text-align: center;
color: var(--text-muted, #9ca3af);
h3 {
margin: 0;
color: var(--text-secondary, #6b7280);
}
p {
margin: 0;
max-width: 400px;
line-height: 1.5;
}
}
/* Modal Overlay */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2rem;
}
.modal-content {
width: 100%;
max-width: 900px;
max-height: 90vh;
overflow: auto;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
/* Responsive */
@media (max-width: 768px) {
.component-detail-page {
padding: 1rem;
}
.page-title {
font-size: 1.25rem;
}
.component-overview {
flex-direction: column;
}
}
`],
})
export class ComponentDetailPage implements OnInit {
private readonly evidenceService = inject(SbomEvidenceService);
/** Component PURL */
readonly purl = input.required<string>();
/** SBOM digest for context (optional) */
readonly sbomDigest = input<string | undefined>(undefined);
/** Evidence data */
readonly evidence = signal<ComponentEvidence | undefined>(undefined);
/** Pedigree data */
readonly pedigree = signal<ComponentPedigree | undefined>(undefined);
/** Loading state */
readonly isLoading = signal<boolean>(true);
/** Error message */
readonly error = signal<string | undefined>(undefined);
/** Drawer open state */
readonly drawerOpen = signal<boolean>(false);
/** Selected occurrence for drawer */
readonly selectedOccurrence = signal<OccurrenceEvidence | undefined>(undefined);
/** Diff viewer open state */
readonly showDiffViewer = signal<boolean>(false);
/** Selected patch for diff viewer */
readonly selectedPatch = signal<PedigreePatch | undefined>(undefined);
/** Component name extracted from PURL */
readonly componentName = computed<string>(() => {
const purl = this.purl();
const match = purl.match(/pkg:[^/]+\/([^@]+)/);
return match?.[1] ?? purl;
});
/** Component version extracted from PURL */
readonly version = computed<string | undefined>(() => {
const purl = this.purl();
const match = purl.match(/@([^?]+)/);
return match?.[1];
});
/** Ecosystem extracted from PURL */
readonly ecosystem = computed<string | undefined>(() => {
const purl = this.purl();
const match = purl.match(/pkg:([^/]+)\//);
return match?.[1];
});
/** Check if has evidence */
readonly hasEvidence = computed<boolean>(() => {
const ev = this.evidence();
return !!(
ev?.identity ||
(ev?.occurrences && ev.occurrences.length > 0) ||
(ev?.licenses && ev.licenses.length > 0) ||
(ev?.copyright && ev.copyright.length > 0)
);
});
/** Check if has pedigree */
readonly hasPedigree = computed<boolean>(() => {
const ped = this.pedigree();
return !!(
(ped?.ancestors && ped.ancestors.length > 0) ||
(ped?.variants && ped.variants.length > 0) ||
(ped?.commits && ped.commits.length > 0) ||
(ped?.patches && ped.patches.length > 0)
);
});
/** Check if has commits */
readonly hasCommits = computed<boolean>(() => {
const ped = this.pedigree();
return !!(ped?.commits && ped.commits.length > 0);
});
/** Check if has patches */
readonly hasPatches = computed<boolean>(() => {
const ped = this.pedigree();
return !!(ped?.patches && ped.patches.length > 0);
});
ngOnInit(): void {
this.loadEvidence();
}
/** Load evidence data */
loadEvidence(): void {
this.isLoading.set(true);
this.error.set(undefined);
this.evidenceService.getEvidence(this.purl()).subscribe({
next: (data) => {
this.evidence.set(data.evidence);
this.pedigree.set(data.pedigree);
this.isLoading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load evidence');
this.isLoading.set(false);
},
});
}
/** Retry loading */
retry(): void {
this.loadEvidence();
}
/** Handle occurrence view click */
onViewOccurrence(occurrence: OccurrenceEvidence): void {
this.selectedOccurrence.set(occurrence);
this.drawerOpen.set(true);
}
/** Handle pedigree node click */
onNodeClick(node: PedigreeTimelineNode): void {
// Could navigate to the node's component or show details
console.log('Node clicked:', node);
}
/** Handle diff view request */
onViewDiff(event: ViewDiffEvent): void {
this.selectedPatch.set(event.patch);
this.showDiffViewer.set(true);
}
/** Close evidence drawer */
closeDrawer(): void {
this.drawerOpen.set(false);
this.selectedOccurrence.set(undefined);
}
/** Close diff viewer */
closeDiffViewer(): void {
this.showDiffViewer.set(false);
this.selectedPatch.set(undefined);
}
}

View File

@@ -0,0 +1,13 @@
/**
* @file index.ts
* @sprint SPRINT_20260107_005_004_FE
* @description Public API for SBOM services.
*/
export {
SbomEvidenceService,
DefaultSbomEvidenceApiClient,
SBOM_EVIDENCE_API,
SbomEvidenceApi,
ComponentEvidenceResponse,
} from './sbom-evidence.service';

View File

@@ -0,0 +1,224 @@
/**
* @file sbom-evidence.service.ts
* @sprint SPRINT_20260107_005_004_FE (UI-008)
* @description Service for fetching CycloneDX 1.7 evidence and pedigree data.
*/
import { Injectable, InjectionToken, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, catchError, map, of, shareReplay, tap } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
import {
ComponentEvidence,
ComponentPedigree,
EvidencePanelData,
} from '../models/cyclonedx-evidence.models';
/**
* API response for component evidence.
*/
export interface ComponentEvidenceResponse {
readonly purl: string;
readonly name: string;
readonly version?: string;
readonly evidence?: ComponentEvidence;
readonly pedigree?: ComponentPedigree;
}
/**
* API client interface for evidence endpoints.
*/
export interface SbomEvidenceApi {
/**
* Fetch evidence and pedigree for a component by PURL.
*/
getComponentEvidence(purl: string): Observable<ComponentEvidenceResponse>;
/**
* Fetch evidence and pedigree for a component by BOM-ref within an SBOM.
*/
getComponentEvidenceByBomRef(
sbomDigest: string,
bomRef: string
): Observable<ComponentEvidenceResponse>;
}
/**
* Injection token for SBOM Evidence API client.
*/
export const SBOM_EVIDENCE_API = new InjectionToken<SbomEvidenceApi>('SbomEvidenceApi');
/**
* Service for managing CycloneDX 1.7 evidence and pedigree data.
* Provides caching, loading states, and error handling.
*/
@Injectable()
export class SbomEvidenceService {
private readonly http = inject(HttpClient);
// Cache for evidence responses (keyed by PURL)
private readonly cache = new Map<string, Observable<ComponentEvidenceResponse>>();
// Current loading PURL
private readonly _loadingPurl = signal<string | null>(null);
readonly loadingPurl = this._loadingPurl.asReadonly();
// Current error
private readonly _error = signal<string | null>(null);
readonly error = this._error.asReadonly();
// Is loading any evidence
readonly isLoading = computed(() => this._loadingPurl() !== null);
/**
* Base API URL - can be configured via DI.
*/
private readonly baseUrl = '/api/v1/sbom/components';
/**
* Fetch evidence for a component by PURL.
* Uses caching to avoid duplicate requests.
*/
getEvidence(purl: string): Observable<EvidencePanelData> {
// Check cache first
let cached = this.cache.get(purl);
if (!cached) {
this._loadingPurl.set(purl);
this._error.set(null);
cached = this.http
.get<ComponentEvidenceResponse>(`${this.baseUrl}/evidence`, {
params: { purl },
})
.pipe(
tap(() => this._loadingPurl.set(null)),
catchError((err: HttpErrorResponse) => {
this._loadingPurl.set(null);
this._error.set(this.formatError(err));
// Return empty response on error
return of<ComponentEvidenceResponse>({
purl,
name: this.extractNameFromPurl(purl),
});
}),
shareReplay({ bufferSize: 1, refCount: true })
);
this.cache.set(purl, cached);
}
return cached.pipe(
map((response) => this.toViewData(response))
);
}
/**
* Fetch evidence by BOM-ref within a specific SBOM.
*/
getEvidenceByBomRef(sbomDigest: string, bomRef: string): Observable<EvidencePanelData> {
const cacheKey = `${sbomDigest}:${bomRef}`;
let cached = this.cache.get(cacheKey);
if (!cached) {
this._loadingPurl.set(cacheKey);
this._error.set(null);
cached = this.http
.get<ComponentEvidenceResponse>(`${this.baseUrl}/${encodeURIComponent(bomRef)}/evidence`, {
params: { sbom: sbomDigest },
})
.pipe(
tap(() => this._loadingPurl.set(null)),
catchError((err: HttpErrorResponse) => {
this._loadingPurl.set(null);
this._error.set(this.formatError(err));
return of<ComponentEvidenceResponse>({
purl: bomRef,
name: bomRef,
});
}),
shareReplay({ bufferSize: 1, refCount: true })
);
this.cache.set(cacheKey, cached);
}
return cached.pipe(
map((response) => this.toViewData(response))
);
}
/**
* Clear cache for a specific PURL or all cached data.
*/
clearCache(purl?: string): void {
if (purl) {
this.cache.delete(purl);
} else {
this.cache.clear();
}
}
/**
* Convert API response to view model.
*/
private toViewData(response: ComponentEvidenceResponse): EvidencePanelData {
return {
purl: response.purl,
name: response.name,
version: response.version,
evidence: response.evidence,
pedigree: response.pedigree,
isLoading: false,
error: this._error() ?? undefined,
};
}
/**
* Extract component name from PURL.
*/
private extractNameFromPurl(purl: string): string {
// pkg:npm/lodash@4.17.21 -> lodash
const match = purl.match(/pkg:[^/]+\/([^@]+)/);
return match?.[1] ?? purl;
}
/**
* Format HTTP error for display.
*/
private formatError(err: HttpErrorResponse): string {
if (err.status === 404) {
return 'No evidence found for this component';
}
if (err.status === 0) {
return 'Network error - please check your connection';
}
return err.message || 'An error occurred while fetching evidence';
}
}
/**
* Default implementation of SbomEvidenceApi using HttpClient.
*/
@Injectable()
export class DefaultSbomEvidenceApiClient implements SbomEvidenceApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/sbom/components';
getComponentEvidence(purl: string): Observable<ComponentEvidenceResponse> {
return this.http.get<ComponentEvidenceResponse>(`${this.baseUrl}/evidence`, {
params: { purl },
});
}
getComponentEvidenceByBomRef(
sbomDigest: string,
bomRef: string
): Observable<ComponentEvidenceResponse> {
return this.http.get<ComponentEvidenceResponse>(
`${this.baseUrl}/${encodeURIComponent(bomRef)}/evidence`,
{ params: { sbom: sbomDigest } }
);
}
}

View File

@@ -2,8 +2,9 @@
// decision-drawer-enhanced.component.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Tasks: T018, T019, T020, T021
// Sprint: SPRINT_20260107_006_005_FE (OM-FE-003 - Playbook integration)
// Description: Enhanced decision drawer with TTL picker, policy reference,
// sign-and-apply flow, and undo toast
// sign-and-apply flow, undo toast, and OpsMemory playbook suggestions
// -----------------------------------------------------------------------------
import {
@@ -21,6 +22,14 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { Subject, takeUntil } from 'rxjs';
import {
PlaybookSuggestionComponent,
SituationContext,
} from '../playbook-suggestion/playbook-suggestion.component';
import {
PlaybookSuggestion,
DecisionAction,
} from '../../../opsmemory/models/playbook.models';
export type DecisionStatus = 'affected' | 'not_affected' | 'under_investigation';
@@ -60,7 +69,7 @@ export interface ApprovalResponse {
@Component({
selector: 'app-decision-drawer-enhanced',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, PlaybookSuggestionComponent],
template: `
<aside class="decision-drawer" [class.open]="isOpen" role="dialog" aria-labelledby="drawer-title">
<header>
@@ -70,6 +79,13 @@ export interface ApprovalResponse {
</button>
</header>
<!-- OM-FE-003: Playbook Suggestions Section -->
<stellaops-playbook-suggestion
[context]="playbookContext()"
[startCollapsed]="false"
(suggestionSelected)="applyPlaybookSuggestion($event)"
/>
<section class="status-selection">
<h4>VEX Status</h4>
<div class="radio-group" role="radiogroup" aria-label="VEX Status">
@@ -507,6 +523,13 @@ export class DecisionDrawerEnhancedComponent implements OnDestroy {
@Input() isAdmin = false;
@Input() apiBaseUrl = '/api/v1';
// OM-FE-003: Context for playbook suggestions
@Input() tenantId = '';
@Input() componentPurl?: string;
@Input() severity?: 'critical' | 'high' | 'medium' | 'low';
@Input() reachability?: 'reachable' | 'unreachable' | 'unknown';
@Input() contextTags?: string[];
@Output() close = new EventEmitter<void>();
@Output() decisionSubmit = new EventEmitter<DecisionFormData>();
@Output() decisionRevoked = new EventEmitter<string>();
@@ -545,6 +568,19 @@ export class DecisionDrawerEnhancedComponent implements OnDestroy {
readonly showCustomTtl = computed(() => this.formData().exceptionTtlDays === -1);
// OM-FE-003: Build context for playbook suggestions
readonly playbookContext = computed<SituationContext | undefined>(() => {
if (!this.tenantId) return undefined;
return {
tenantId: this.tenantId,
cveId: this.alert?.vulnId,
severity: this.severity,
reachability: this.reachability,
componentPurl: this.componentPurl,
contextTags: this.contextTags,
};
});
readonly computedExpiryDate = computed(() => {
const days = this.formData().exceptionTtlDays;
if (!days || days <= 0) {
@@ -729,4 +765,57 @@ export class DecisionDrawerEnhancedComponent implements OnDestroy {
});
this.customExpiryDate.set('');
}
/**
* OM-FE-003: Apply a playbook suggestion to pre-fill the decision form.
* Maps OpsMemory decision actions to VEX statuses and reason codes.
*/
applyPlaybookSuggestion(suggestion: PlaybookSuggestion): void {
const { status, reasonCode } = this.mapActionToVex(suggestion.suggestedAction);
this.formData.update((f) => ({
...f,
status,
reasonCode,
reasonText: `Based on ${suggestion.evidenceCount} similar past decisions: ${suggestion.rationale}`,
}));
}
/**
* Map OpsMemory action to VEX status and reason code.
*/
private mapActionToVex(action: DecisionAction): {
status: DecisionStatus;
reasonCode: string;
} {
switch (action) {
case 'accept_risk':
return {
status: 'not_affected',
reasonCode: 'inline_mitigations_already_exist',
};
case 'target_fix':
return {
status: 'affected',
reasonCode: 'vulnerable_code_reachable',
};
case 'quarantine':
case 'patch_now':
return {
status: 'affected',
reasonCode: 'exploit_available',
};
case 'defer':
return {
status: 'not_affected',
reasonCode: 'vulnerable_code_not_in_execute_path',
};
case 'investigate':
default:
return {
status: 'under_investigation',
reasonCode: 'requires_further_analysis',
};
}
}
}

View File

@@ -1,8 +1,8 @@
# Evidence Panel Accessibility Audit
> **Sprint:** SPRINT_20260107_006_001_FE
> **Task:** EP-014
> **Audit Date:** 2026-01-09
> **Sprint:** SPRINT_20260107_006_001_FE, SPRINT_20260109_009_006_FE
> **Task:** EP-014, Accessibility Audit
> **Audit Date:** 2026-01-10
> **Auditor:** Automated Implementation Review
## Summary
@@ -101,6 +101,97 @@ All Evidence Panel components have been audited for WCAG 2.1 AA compliance. The
| **Status Badge** | PASS | Text label accompanies color |
| **Confidence** | PASS | Percentage displayed as text |
| **View Graph Button** | PASS | Clear button label |
| **Entry Points** | PASS | Entry tags readable by screen readers |
| **Empty State** | PASS | Descriptive message when no data |
### LatticeStateBadgeComponent
| Criterion | Status | Notes |
|-----------|--------|-------|
| **ARIA Role** | PASS | `role="status"` for live region announcements |
| **ARIA Label** | PASS | Dynamic `aria-label` includes state description |
| **Icon Hiding** | PASS | Icons have `aria-hidden="true"` |
| **Color Independence** | PASS | Text label always present with color |
**Code Evidence:**
```typescript
<span
class="lattice-badge"
[attr.aria-label]="ariaLabel()"
tabindex="0"
role="status"
>
<span class="badge-icon" aria-hidden="true">{{ icon() }}</span>
<span class="badge-label">{{ label() }}</span>
</span>
```
### ConfidenceMeterComponent
| Criterion | Status | Notes |
|-----------|--------|-------|
| **ARIA Role** | PASS | `role="meter"` for semantic meter element |
| **ARIA Values** | PASS | `aria-valuenow`, `aria-valuemin`, `aria-valuemax` properly set |
| **ARIA Label** | PASS | Descriptive label includes percentage and confidence level |
| **Visual Ticks** | PASS | Decorative ticks hidden with `aria-hidden="true"` |
**Code Evidence:**
```typescript
<div
class="confidence-meter"
role="meter"
[attr.aria-valuenow]="percentValue()"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="100"
[attr.aria-label]="ariaLabel()"
>
<div class="meter-ticks" aria-hidden="true">
```
### EvidenceUriLinkComponent
| Criterion | Status | Notes |
|-----------|--------|-------|
| **Link Label** | PASS | Dynamic `aria-label` describes evidence type and action |
| **Icon Hiding** | PASS | Icons have `aria-hidden="true"` |
| **External Link** | PASS | External indicator hidden from screen readers |
| **Focus Indicator** | PASS | Visible focus ring on keyboard navigation |
**Code Evidence:**
```typescript
<a
[href]="resolvedUrl()"
class="evidence-uri-link"
[attr.aria-label]="ariaLabel()"
target="_blank"
rel="noopener noreferrer"
>
<span class="uri-icon" aria-hidden="true">
```
### StaticEvidenceCardComponent
| Criterion | Status | Notes |
|-----------|--------|-------|
| **Expand/Collapse** | PASS | `aria-expanded` state tracked |
| **Button Label** | PASS | Descriptive toggle button text |
| **Section Headings** | PASS | Proper h4 headings for card sections |
### RuntimeEvidenceCardComponent
| Criterion | Status | Notes |
|-----------|--------|-------|
| **Expand/Collapse** | PASS | `aria-expanded` state tracked |
| **Button Label** | PASS | Descriptive toggle button text |
| **Observation Data** | PASS | Time/count data presented as text |
### SymbolPathViewerComponent
| Criterion | Status | Notes |
|-----------|--------|-------|
| **Path Expansion** | PASS | `aria-expanded` on path headers |
| **Node Labels** | PASS | Entry/Vulnerable badges provide context |
| **Keyboard Navigation** | PASS | Enter/Space to expand paths |
## Keyboard Navigation Summary
@@ -125,6 +216,15 @@ All Evidence Panel components have been audited for WCAG 2.1 AA compliance. The
| Tab active | #1e40af | #dbeafe | 4.9:1 | PASS |
| Tab inactive | #6b7280 | #ffffff | 4.6:1 | PASS |
| Error state | #991b1b | #fee2e2 | 4.6:1 | PASS |
| Lattice ConfirmedUnreachable | #166534 | #dcfce7 | 4.7:1 | PASS |
| Lattice ConfirmedReachable | #991b1b | #fee2e2 | 4.6:1 | PASS |
| Lattice Contested | #b45309 | #fef3c7 | 4.5:1 | PASS |
| Lattice Static states | #1e40af | #dbeafe | 4.9:1 | PASS |
| Lattice Unknown | #6b7280 | #f3f4f6 | 4.6:1 | PASS |
| Confidence high | #166534 | #dcfce7 | 4.7:1 | PASS |
| Confidence medium | #92400e | #fef3c7 | 4.8:1 | PASS |
| Confidence low | #991b1b | #fee2e2 | 4.6:1 | PASS |
| Evidence URI link | #3b82f6 | #ffffff | 4.5:1 | PASS |
*All ratios meet WCAG 2.1 AA minimum of 4.5:1 for normal text*

View File

@@ -0,0 +1,123 @@
// -----------------------------------------------------------------------------
// confidence-meter.component.scss
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: SCSS styling extraction
// -----------------------------------------------------------------------------
:host {
display: block;
}
.confidence-meter {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.meter-track {
position: relative;
height: 0.5rem;
background-color: var(--meter-track-bg, #e5e7eb);
border-radius: 9999px;
overflow: hidden;
}
.meter-fill {
height: 100%;
border-radius: 9999px;
transition: width 0.3s ease-out, background-color 0.3s ease;
}
// Confidence level colors
.confidence-meter--high .meter-fill {
background-color: var(--confidence-high, #22c55e);
}
.confidence-meter--medium .meter-fill {
background-color: var(--confidence-medium, #eab308);
}
.confidence-meter--low .meter-fill {
background-color: var(--confidence-low, #f97316);
}
.confidence-meter--very-low .meter-fill {
background-color: var(--confidence-very-low, #ef4444);
}
.meter-label {
display: flex;
align-items: baseline;
gap: 0.25rem;
font-size: 0.75rem;
}
.meter-value {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.confidence-meter--high .meter-value {
color: var(--confidence-high-text, #166534);
}
.confidence-meter--medium .meter-value {
color: var(--confidence-medium-text, #a16207);
}
.confidence-meter--low .meter-value {
color: var(--confidence-low-text, #c2410c);
}
.confidence-meter--very-low .meter-value {
color: var(--confidence-very-low-text, #991b1b);
}
.meter-text {
color: var(--meter-text-color, #6b7280);
}
.meter-ticks {
position: relative;
height: 0.25rem;
}
.tick {
position: absolute;
width: 1px;
height: 100%;
background-color: var(--tick-color, #d1d5db);
&--25 { left: 25%; }
&--50 { left: 50%; }
&--75 { left: 75%; }
}
// Compact variant
:host(.compact) {
.meter-track {
height: 0.375rem;
}
.meter-label {
font-size: 0.6875rem;
}
}
// Inline variant
:host(.inline) {
.confidence-meter {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.meter-track {
flex: 1;
min-width: 3rem;
}
.meter-label {
flex-shrink: 0;
}
}

View File

@@ -0,0 +1,179 @@
// -----------------------------------------------------------------------------
// confidence-meter.component.spec.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Write unit tests
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConfidenceMeterComponent } from './confidence-meter.component';
describe('ConfidenceMeterComponent', () => {
let component: ConfidenceMeterComponent;
let fixture: ComponentFixture<ConfidenceMeterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ConfidenceMeterComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConfidenceMeterComponent);
component = fixture.componentInstance;
});
describe('rendering', () => {
it('should create', () => {
fixture.componentRef.setInput('confidence', 0.5);
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should display confidence as percentage', () => {
fixture.componentRef.setInput('confidence', 0.75);
fixture.detectChanges();
const label = fixture.nativeElement.querySelector('.meter-value');
expect(label.textContent).toContain('75%');
});
it('should set fill width based on confidence', () => {
fixture.componentRef.setInput('confidence', 0.6);
fixture.detectChanges();
const fill = fixture.nativeElement.querySelector('.meter-fill');
expect(fill.style.width).toBe('60%');
});
});
describe('confidence levels', () => {
it('should apply high class for confidence >= 0.8', () => {
fixture.componentRef.setInput('confidence', 0.85);
fixture.detectChanges();
const meter = fixture.nativeElement.querySelector('.confidence-meter');
expect(meter.classList).toContain('confidence-meter--high');
});
it('should apply medium class for confidence 0.5-0.79', () => {
fixture.componentRef.setInput('confidence', 0.65);
fixture.detectChanges();
const meter = fixture.nativeElement.querySelector('.confidence-meter');
expect(meter.classList).toContain('confidence-meter--medium');
});
it('should apply low class for confidence 0.25-0.49', () => {
fixture.componentRef.setInput('confidence', 0.35);
fixture.detectChanges();
const meter = fixture.nativeElement.querySelector('.confidence-meter');
expect(meter.classList).toContain('confidence-meter--low');
});
it('should apply very-low class for confidence < 0.25', () => {
fixture.componentRef.setInput('confidence', 0.15);
fixture.detectChanges();
const meter = fixture.nativeElement.querySelector('.confidence-meter');
expect(meter.classList).toContain('confidence-meter--very-low');
});
});
describe('options', () => {
it('should show label by default', () => {
fixture.componentRef.setInput('confidence', 0.5);
fixture.detectChanges();
const label = fixture.nativeElement.querySelector('.meter-label');
expect(label).toBeTruthy();
});
it('should hide label when showLabel is false', () => {
fixture.componentRef.setInput('confidence', 0.5);
fixture.componentRef.setInput('showLabel', false);
fixture.detectChanges();
const label = fixture.nativeElement.querySelector('.meter-label');
expect(label).toBeFalsy();
});
it('should hide ticks by default', () => {
fixture.componentRef.setInput('confidence', 0.5);
fixture.detectChanges();
const ticks = fixture.nativeElement.querySelector('.meter-ticks');
expect(ticks).toBeFalsy();
});
it('should show ticks when showTicks is true', () => {
fixture.componentRef.setInput('confidence', 0.5);
fixture.componentRef.setInput('showTicks', true);
fixture.detectChanges();
const ticks = fixture.nativeElement.querySelector('.meter-ticks');
expect(ticks).toBeTruthy();
});
});
describe('accessibility', () => {
it('should have role="meter"', () => {
fixture.componentRef.setInput('confidence', 0.5);
fixture.detectChanges();
const meter = fixture.nativeElement.querySelector('.confidence-meter');
expect(meter.getAttribute('role')).toBe('meter');
});
it('should have correct aria-valuenow', () => {
fixture.componentRef.setInput('confidence', 0.75);
fixture.detectChanges();
const meter = fixture.nativeElement.querySelector('.confidence-meter');
expect(meter.getAttribute('aria-valuenow')).toBe('75');
});
it('should have aria-valuemin and aria-valuemax', () => {
fixture.componentRef.setInput('confidence', 0.5);
fixture.detectChanges();
const meter = fixture.nativeElement.querySelector('.confidence-meter');
expect(meter.getAttribute('aria-valuemin')).toBe('0');
expect(meter.getAttribute('aria-valuemax')).toBe('100');
});
it('should have descriptive aria-label', () => {
fixture.componentRef.setInput('confidence', 0.85);
fixture.detectChanges();
const meter = fixture.nativeElement.querySelector('.confidence-meter');
expect(meter.getAttribute('aria-label')).toContain('85%');
});
});
describe('edge cases', () => {
it('should handle 0 confidence', () => {
fixture.componentRef.setInput('confidence', 0);
fixture.detectChanges();
const fill = fixture.nativeElement.querySelector('.meter-fill');
expect(fill.style.width).toBe('0%');
});
it('should handle 1.0 confidence', () => {
fixture.componentRef.setInput('confidence', 1.0);
fixture.detectChanges();
const fill = fixture.nativeElement.querySelector('.meter-fill');
expect(fill.style.width).toBe('100%');
});
it('should round to nearest integer percent', () => {
fixture.componentRef.setInput('confidence', 0.333);
fixture.detectChanges();
const label = fixture.nativeElement.querySelector('.meter-value');
expect(label.textContent).toContain('33%');
});
});
});

View File

@@ -0,0 +1,91 @@
// -----------------------------------------------------------------------------
// confidence-meter.component.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Create confidence-meter.component.ts - Confidence score display
// -----------------------------------------------------------------------------
import { Component, input, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { formatConfidence } from '../../models/reachability.models';
type ConfidenceLevel = 'high' | 'medium' | 'low' | 'very-low';
@Component({
selector: 'app-confidence-meter',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="confidence-meter"
[class]="meterClass()"
role="meter"
[attr.aria-valuenow]="percentValue()"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="100"
[attr.aria-label]="ariaLabel()"
>
<div class="meter-track">
<div
class="meter-fill"
[style.width.%]="percentValue()"
></div>
</div>
@if (showLabel()) {
<div class="meter-label">
<span class="meter-value">{{ formattedValue() }}</span>
<span class="meter-text">confidence</span>
</div>
}
@if (showTicks()) {
<div class="meter-ticks" aria-hidden="true">
<span class="tick tick--25"></span>
<span class="tick tick--50"></span>
<span class="tick tick--75"></span>
</div>
}
</div>
`,
styleUrl: './confidence-meter.component.scss',
})
export class ConfidenceMeterComponent {
/** Confidence value (0-1) */
readonly confidence = input.required<number>();
/** Whether to show the label */
readonly showLabel = input(true);
/** Whether to show tick marks */
readonly showTicks = input(false);
/** Computed percentage value */
readonly percentValue = computed(() => {
return Math.round(this.confidence() * 100);
});
/** Computed formatted value */
readonly formattedValue = computed(() => {
return formatConfidence(this.confidence());
});
/** Computed confidence level */
readonly level = computed<ConfidenceLevel>(() => {
const c = this.confidence();
if (c >= 0.8) return 'high';
if (c >= 0.5) return 'medium';
if (c >= 0.25) return 'low';
return 'very-low';
});
/** Computed CSS class */
readonly meterClass = computed(() => {
return `confidence-meter confidence-meter--${this.level()}`;
});
/** Computed ARIA label */
readonly ariaLabel = computed(() => {
return `Confidence: ${this.formattedValue()}`;
});
}

View File

@@ -0,0 +1,101 @@
// -----------------------------------------------------------------------------
// evidence-uri-link.component.scss
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: SCSS styling extraction
// -----------------------------------------------------------------------------
:host {
display: inline-block;
}
.evidence-uri-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.75rem;
text-decoration: none;
color: var(--link-color, #2563eb);
background-color: var(--link-bg, #eff6ff);
border: 1px solid transparent;
transition: all 0.15s ease;
&:hover {
background-color: var(--link-hover-bg, #dbeafe);
border-color: var(--link-hover-border, #bfdbfe);
}
&:focus-visible {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: 2px;
}
// Evidence type colors
&--static .uri-icon {
background-color: var(--static-icon-bg, #dbeafe);
color: var(--static-icon-color, #1d4ed8);
}
&--runtime .uri-icon {
background-color: var(--runtime-icon-bg, #dcfce7);
color: var(--runtime-icon-color, #16a34a);
}
&--hybrid .uri-icon {
background-color: var(--hybrid-icon-bg, #fef3c7);
color: var(--hybrid-icon-color, #d97706);
}
}
.uri-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.125rem;
height: 1.125rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
}
.uri-text {
max-width: 20ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:host(.full-width) .uri-text {
max-width: none;
}
.uri-external {
display: inline-flex;
opacity: 0.5;
transition: opacity 0.15s ease;
.evidence-uri-link:hover & {
opacity: 1;
}
}
// Compact variant
:host(.compact) {
.evidence-uri-link {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
}
.uri-icon {
width: 0.875rem;
height: 0.875rem;
font-size: 0.5rem;
}
.uri-external svg {
width: 10px;
height: 10px;
}
}

View File

@@ -0,0 +1,154 @@
// -----------------------------------------------------------------------------
// evidence-uri-link.component.spec.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Write unit tests
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EvidenceUriLinkComponent } from './evidence-uri-link.component';
describe('EvidenceUriLinkComponent', () => {
let component: EvidenceUriLinkComponent;
let fixture: ComponentFixture<EvidenceUriLinkComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EvidenceUriLinkComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(EvidenceUriLinkComponent);
component = fixture.componentInstance;
});
describe('rendering', () => {
it('should create', () => {
fixture.componentRef.setInput('uri', 'stella://sha256:abc123/static/path');
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should render static evidence type correctly', () => {
fixture.componentRef.setInput('uri', 'stella://sha256:abc123/static/evidence');
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.evidence-uri-link');
expect(link.classList).toContain('evidence-uri-link--static');
});
it('should render runtime evidence type correctly', () => {
fixture.componentRef.setInput('uri', 'stella://sha256:abc123/runtime/evidence');
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.evidence-uri-link');
expect(link.classList).toContain('evidence-uri-link--runtime');
});
it('should render hybrid evidence type correctly', () => {
fixture.componentRef.setInput('uri', 'stella://sha256:abc123/hybrid/evidence');
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.evidence-uri-link');
expect(link.classList).toContain('evidence-uri-link--hybrid');
});
});
describe('display text', () => {
it('should use path from URI when no custom text', () => {
fixture.componentRef.setInput('uri', 'stella://sha256:abc123/static/my-evidence');
fixture.detectChanges();
const text = fixture.nativeElement.querySelector('.uri-text');
expect(text.textContent).toContain('my-evidence');
});
it('should use custom text when provided', () => {
fixture.componentRef.setInput('uri', 'stella://sha256:abc123/static/path');
fixture.componentRef.setInput('text', 'Custom Label');
fixture.detectChanges();
const text = fixture.nativeElement.querySelector('.uri-text');
expect(text.textContent).toContain('Custom Label');
});
it('should truncate long URIs', () => {
const longUri = 'https://example.com/very/long/path/that/exceeds/limit';
fixture.componentRef.setInput('uri', longUri);
fixture.detectChanges();
const text = fixture.nativeElement.querySelector('.uri-text');
expect(text.textContent.length).toBeLessThan(longUri.length);
expect(text.textContent).toContain('...');
});
});
describe('href generation', () => {
it('should convert stella:// URI to internal route', () => {
fixture.componentRef.setInput('uri', 'stella://sha256:abc123/static/evidence');
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.evidence-uri-link');
expect(link.getAttribute('href')).toBe('/evidence/sha256:abc123/static/evidence');
});
});
describe('click handling', () => {
it('should emit clicked event on click', () => {
fixture.componentRef.setInput('uri', 'stella://sha256:abc123/static/evidence');
fixture.detectChanges();
const clickedSpy = jasmine.createSpy('clicked');
component.clicked.subscribe(clickedSpy);
const link = fixture.nativeElement.querySelector('.evidence-uri-link');
link.click();
expect(clickedSpy).toHaveBeenCalledWith('stella://sha256:abc123/static/evidence');
});
it('should prevent default navigation', () => {
fixture.componentRef.setInput('uri', 'stella://sha256:abc123/static/evidence');
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.evidence-uri-link');
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
spyOn(event, 'preventDefault');
link.dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalled();
});
});
describe('accessibility', () => {
it('should have descriptive aria-label', () => {
fixture.componentRef.setInput('uri', 'stella://sha256:abc123/static/evidence');
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.evidence-uri-link');
const ariaLabel = link.getAttribute('aria-label');
expect(ariaLabel).toContain('static');
expect(ariaLabel).toContain('evidence');
});
});
describe('edge cases', () => {
it('should handle non-stella URIs gracefully', () => {
fixture.componentRef.setInput('uri', 'https://example.com/evidence');
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.evidence-uri-link');
expect(link).toBeTruthy();
expect(link.classList).toContain('evidence-uri-link--hybrid'); // default type
});
it('should handle malformed stella URIs gracefully', () => {
fixture.componentRef.setInput('uri', 'stella://malformed');
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.evidence-uri-link');
expect(link).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,109 @@
// -----------------------------------------------------------------------------
// evidence-uri-link.component.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Create evidence-uri-link.component.ts - Clickable stella:// URI
// -----------------------------------------------------------------------------
import { Component, input, output, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { parseEvidenceUri, ParsedEvidenceUri } from '../../models/reachability.models';
@Component({
selector: 'app-evidence-uri-link',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<a
class="evidence-uri-link"
[class]="linkClass()"
[href]="hrefValue()"
[attr.aria-label]="ariaLabel()"
(click)="handleClick($event)"
>
<span class="uri-icon" aria-hidden="true">
@switch (evidenceType()) {
@case ('static') { S }
@case ('runtime') { R }
@default { E }
}
</span>
<span class="uri-text">{{ displayText() }}</span>
<span class="uri-external" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
<path fill-rule="evenodd" d="M10.604 1h4.146a.25.25 0 01.25.25v4.146a.25.25 0 01-.427.177L13.03 4.03 9.28 7.78a.75.75 0 01-1.06-1.06l3.75-3.75-1.543-1.543A.25.25 0 0110.604 1zM3.75 2A1.75 1.75 0 002 3.75v8.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 12.25v-3.5a.75.75 0 00-1.5 0v3.5a.25.25 0 01-.25.25h-8.5a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25h3.5a.75.75 0 000-1.5h-3.5z"/>
</svg>
</span>
</a>
`,
styleUrl: './evidence-uri-link.component.scss',
})
export class EvidenceUriLinkComponent {
/** The stella:// URI */
readonly uri = input.required<string>();
/** Custom display text (optional, defaults to parsed path) */
readonly text = input<string>();
/** Emits when link is clicked */
readonly clicked = output<string>();
/** Parsed URI */
readonly parsed = computed<ParsedEvidenceUri | null>(() => {
return parseEvidenceUri(this.uri());
});
/** Evidence type from parsed URI */
readonly evidenceType = computed(() => {
return this.parsed()?.evidenceType ?? 'hybrid';
});
/** Display text */
readonly displayText = computed(() => {
const customText = this.text();
if (customText) {
return customText;
}
const parsed = this.parsed();
if (parsed) {
return parsed.path || parsed.evidenceType;
}
// Fallback: show truncated URI
const uri = this.uri();
if (uri.length > 30) {
return uri.substring(0, 27) + '...';
}
return uri;
});
/** CSS class based on evidence type */
readonly linkClass = computed(() => {
return `evidence-uri-link evidence-uri-link--${this.evidenceType()}`;
});
/** Href value */
readonly hrefValue = computed(() => {
// For stella:// URIs, we convert to internal route
const parsed = this.parsed();
if (parsed) {
return `/evidence/${parsed.artifactDigest}/${parsed.evidenceType}/${parsed.path}`;
}
return this.uri();
});
/** ARIA label */
readonly ariaLabel = computed(() => {
const parsed = this.parsed();
if (parsed) {
return `View ${parsed.evidenceType} evidence: ${parsed.path}`;
}
return `View evidence: ${this.uri()}`;
});
handleClick(event: Event): void {
event.preventDefault();
this.clicked.emit(this.uri());
}
}

View File

@@ -2,6 +2,7 @@
// evidence-panel/index.ts
// Sprint: SPRINT_20260107_006_001_FE_tabbed_evidence_panel
// Sprint: SPRINT_20260107_006_002_FE_diff_runtime_tabs
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Description: Barrel export file for evidence panel components
// -----------------------------------------------------------------------------
@@ -23,3 +24,11 @@ export { RuntimeTabComponent } from './runtime-tab.component';
export { LiveIndicatorComponent } from './live-indicator.component';
export { RtsScoreDisplayComponent } from './rts-score-display.component';
export { FunctionTraceComponent } from './function-trace.component';
// Hybrid Reachability Components (Sprint 009_006)
export { LatticeStateBadgeComponent } from './lattice-state-badge.component';
export { ConfidenceMeterComponent } from './confidence-meter.component';
export { EvidenceUriLinkComponent } from './evidence-uri-link.component';
export { StaticEvidenceCardComponent } from './static-evidence-card.component';
export { RuntimeEvidenceCardComponent } from './runtime-evidence-card.component';
export { SymbolPathViewerComponent } from './symbol-path-viewer.component';

View File

@@ -0,0 +1,92 @@
// -----------------------------------------------------------------------------
// lattice-state-badge.component.scss
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: SCSS styling extraction
// -----------------------------------------------------------------------------
:host {
display: inline-block;
}
.lattice-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: default;
transition: transform 0.1s ease;
&:hover {
transform: scale(1.02);
}
}
.badge-icon {
font-size: 0.875rem;
}
// Severity-based colors
.lattice-badge--critical {
background-color: var(--severity-critical-bg, #fef2f2);
color: var(--severity-critical-text, #991b1b);
border: 1px solid var(--severity-critical-border, #fecaca);
}
.lattice-badge--high {
background-color: var(--severity-high-bg, #fff7ed);
color: var(--severity-high-text, #c2410c);
border: 1px solid var(--severity-high-border, #fed7aa);
}
.lattice-badge--medium {
background-color: var(--severity-medium-bg, #fffbeb);
color: var(--severity-medium-text, #b45309);
border: 1px solid var(--severity-medium-border, #fde68a);
}
.lattice-badge--low {
background-color: var(--severity-low-bg, #f0fdf4);
color: var(--severity-low-text, #166534);
border: 1px solid var(--severity-low-border, #bbf7d0);
}
.lattice-badge--info {
background-color: var(--severity-info-bg, #eff6ff);
color: var(--severity-info-text, #1e40af);
border: 1px solid var(--severity-info-border, #bfdbfe);
}
.lattice-badge--unknown {
background-color: var(--severity-unknown-bg, #f9fafb);
color: var(--severity-unknown-text, #4b5563);
border: 1px solid var(--severity-unknown-border, #e5e7eb);
}
// Compact variant
:host(.compact) {
.lattice-badge {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
}
.badge-icon {
font-size: 0.75rem;
}
}
// Large variant
:host(.large) {
.lattice-badge {
padding: 0.375rem 0.875rem;
font-size: 0.875rem;
}
.badge-icon {
font-size: 1rem;
}
}

View File

@@ -0,0 +1,141 @@
// -----------------------------------------------------------------------------
// lattice-state-badge.component.spec.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Write unit tests
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LatticeStateBadgeComponent } from './lattice-state-badge.component';
import { LatticeState } from '../../models/reachability.models';
describe('LatticeStateBadgeComponent', () => {
let component: LatticeStateBadgeComponent;
let fixture: ComponentFixture<LatticeStateBadgeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LatticeStateBadgeComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(LatticeStateBadgeComponent);
component = fixture.componentInstance;
});
describe('rendering', () => {
it('should create', () => {
fixture.componentRef.setInput('state', LatticeState.Unknown);
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should display Unknown state correctly', () => {
fixture.componentRef.setInput('state', LatticeState.Unknown);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.lattice-badge');
expect(badge.textContent).toContain('Unknown');
expect(badge.classList).toContain('lattice-badge--unknown');
});
it('should display Confirmed state with critical severity', () => {
fixture.componentRef.setInput('state', LatticeState.Confirmed);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.lattice-badge');
expect(badge.textContent).toContain('Confirmed Reachable');
expect(badge.classList).toContain('lattice-badge--critical');
});
it('should display RefutedUnreachable state with info severity', () => {
fixture.componentRef.setInput('state', LatticeState.RefutedUnreachable);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.lattice-badge');
expect(badge.textContent).toContain('Confirmed Unreachable');
expect(badge.classList).toContain('lattice-badge--info');
});
it('should display Conflicted state with medium severity', () => {
fixture.componentRef.setInput('state', LatticeState.Conflicted);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.lattice-badge');
expect(badge.textContent).toContain('Evidence Conflict');
expect(badge.classList).toContain('lattice-badge--medium');
});
});
describe('compact mode', () => {
it('should show short label when compact is true', () => {
fixture.componentRef.setInput('state', LatticeState.StaticReachable);
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.lattice-badge');
expect(badge.textContent).toContain('Static+');
});
it('should show full label when compact is false', () => {
fixture.componentRef.setInput('state', LatticeState.StaticReachable);
fixture.componentRef.setInput('compact', false);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.lattice-badge');
expect(badge.textContent).toContain('Static: Reachable');
});
});
describe('accessibility', () => {
it('should have role="status"', () => {
fixture.componentRef.setInput('state', LatticeState.Unknown);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.lattice-badge');
expect(badge.getAttribute('role')).toBe('status');
});
it('should have descriptive aria-label', () => {
fixture.componentRef.setInput('state', LatticeState.Confirmed);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.lattice-badge');
const ariaLabel = badge.getAttribute('aria-label');
expect(ariaLabel).toContain('Reachability:');
expect(ariaLabel).toContain('Confirmed Reachable');
});
it('should have title with description', () => {
fixture.componentRef.setInput('state', LatticeState.Conflicted);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.lattice-badge');
expect(badge.getAttribute('title')).toContain('conflict');
});
});
describe('all states', () => {
const allStates: LatticeState[] = [
LatticeState.Unknown,
LatticeState.StaticReachable,
LatticeState.StaticUnreachable,
LatticeState.RuntimeObserved,
LatticeState.RuntimeNotObserved,
LatticeState.Confirmed,
LatticeState.RefutedUnreachable,
LatticeState.Conflicted,
];
allStates.forEach((state) => {
it(`should render ${state} without errors`, () => {
fixture.componentRef.setInput('state', state);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.lattice-badge');
expect(badge).toBeTruthy();
expect(badge.textContent.trim()).not.toBe('');
});
});
});
});

View File

@@ -0,0 +1,100 @@
// -----------------------------------------------------------------------------
// lattice-state-badge.component.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Create lattice-state-badge.component.ts - 8-state lattice visualization
// -----------------------------------------------------------------------------
import { Component, input, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
LatticeState,
getLatticeStateLabel,
getLatticeStateDescription,
LATTICE_SEVERITY,
LatticeSeverity,
} from '../../models/reachability.models';
@Component({
selector: 'app-lattice-state-badge',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<span
class="lattice-badge"
[class]="badgeClass()"
[attr.aria-label]="ariaLabel()"
[attr.title]="description()"
role="status"
>
<span class="badge-icon" aria-hidden="true">{{ icon() }}</span>
<span class="badge-label">{{ label() }}</span>
</span>
`,
styleUrl: './lattice-state-badge.component.scss',
})
export class LatticeStateBadgeComponent {
/** The lattice state to display */
readonly state = input.required<LatticeState>();
/** Whether to show short label */
readonly compact = input(false);
/** Computed severity level */
readonly severity = computed<LatticeSeverity>(() => {
return LATTICE_SEVERITY[this.state()] ?? 'unknown';
});
/** Computed CSS class */
readonly badgeClass = computed(() => {
return `lattice-badge lattice-badge--${this.severity()}`;
});
/** Computed label text */
readonly label = computed(() => {
if (this.compact()) {
return this.shortLabel();
}
return getLatticeStateLabel(this.state());
});
/** Short label for compact mode */
readonly shortLabel = computed(() => {
const shortLabels: Record<LatticeState, string> = {
[LatticeState.Unknown]: '?',
[LatticeState.StaticReachable]: 'Static+',
[LatticeState.StaticUnreachable]: 'Static-',
[LatticeState.RuntimeObserved]: 'Runtime+',
[LatticeState.RuntimeNotObserved]: 'Runtime-',
[LatticeState.Confirmed]: 'Confirmed',
[LatticeState.RefutedUnreachable]: 'Refuted',
[LatticeState.Conflicted]: 'Conflict',
};
return shortLabels[this.state()] ?? this.state();
});
/** Computed icon */
readonly icon = computed(() => {
const icons: Record<LatticeState, string> = {
[LatticeState.Unknown]: '?',
[LatticeState.StaticReachable]: 'S',
[LatticeState.StaticUnreachable]: 'S',
[LatticeState.RuntimeObserved]: 'R',
[LatticeState.RuntimeNotObserved]: 'R',
[LatticeState.Confirmed]: '!',
[LatticeState.RefutedUnreachable]: '-',
[LatticeState.Conflicted]: '!',
};
return icons[this.state()] ?? '?';
});
/** Computed description for tooltip */
readonly description = computed(() => {
return getLatticeStateDescription(this.state());
});
/** Computed ARIA label */
readonly ariaLabel = computed(() => {
return `Reachability: ${getLatticeStateLabel(this.state())}. ${this.description()}`;
});
}

View File

@@ -0,0 +1,511 @@
// -----------------------------------------------------------------------------
// runtime-evidence-card.component.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Create runtime-evidence-card.component.ts - Runtime observation summary card
// -----------------------------------------------------------------------------
import { Component, input, output, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RuntimeEvidence } from '../../models/reachability.models';
import { EvidenceUriLinkComponent } from './evidence-uri-link.component';
@Component({
selector: 'app-runtime-evidence-card',
standalone: true,
imports: [CommonModule, EvidenceUriLinkComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article
class="evidence-card"
[class.evidence-card--expanded]="expanded()"
[class.evidence-card--observed]="wasObserved()"
[class.evidence-card--not-observed]="!wasObserved()"
>
<header class="card-header" (click)="toggle.emit()">
<div class="header-left">
<span class="card-icon" aria-hidden="true">R</span>
<h4 class="card-title">Runtime Observation</h4>
@if (evidence()) {
<span
class="status-pill"
[class.status-pill--positive]="wasObserved()"
[class.status-pill--negative]="!wasObserved()"
>
{{ wasObserved() ? 'Observed' : 'Not Observed' }}
</span>
}
</div>
<button
class="expand-btn"
[attr.aria-expanded]="expanded()"
[attr.aria-label]="expanded() ? 'Collapse runtime evidence' : 'Expand runtime evidence'"
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="16"
height="16"
[class.rotated]="expanded()"
>
<path fill-rule="evenodd" d="M4.22 6.22a.75.75 0 011.06 0L8 8.94l2.72-2.72a.75.75 0 111.06 1.06l-3.25 3.25a.75.75 0 01-1.06 0L4.22 7.28a.75.75 0 010-1.06z"/>
</svg>
</button>
</header>
@if (!evidence()) {
<div class="empty-state">
<p>No runtime observation data available</p>
</div>
} @else {
<div class="card-summary">
<dl class="summary-stats">
<div class="stat">
<dt>Hit Count</dt>
<dd>{{ formatNumber(evidence()!.hitCount) }}</dd>
</div>
<div class="stat">
<dt>Observation Window</dt>
<dd>{{ formatDuration(evidence()!.observationWindow) }}</dd>
</div>
@if (evidence()!.contexts.length > 0) {
<div class="stat">
<dt>Contexts</dt>
<dd>{{ evidence()!.contexts.length }}</dd>
</div>
}
</dl>
</div>
@if (expanded()) {
<div class="card-details">
<!-- Time Window -->
<section class="detail-section">
<h5>Observation Period</h5>
<div class="time-range">
<div class="time-point">
<span class="time-label">Start</span>
<span class="time-value">{{ formatDate(evidence()!.windowStart) }}</span>
</div>
<div class="time-arrow" aria-hidden="true">-</div>
<div class="time-point">
<span class="time-label">End</span>
<span class="time-value">{{ formatDate(evidence()!.windowEnd) }}</span>
</div>
</div>
@if (wasObserved()) {
<div class="seen-times">
@if (evidence()!.firstSeen) {
<div class="seen-item">
<span class="seen-label">First seen:</span>
<span class="seen-value">{{ formatDate(evidence()!.firstSeen!) }}</span>
</div>
}
@if (evidence()!.lastSeen) {
<div class="seen-item">
<span class="seen-label">Last seen:</span>
<span class="seen-value">{{ formatDate(evidence()!.lastSeen!) }}</span>
</div>
}
</div>
}
</section>
<!-- Traffic Profile -->
@if (evidence()!.traffic) {
<section class="detail-section">
<h5>Traffic Profile</h5>
<dl class="traffic-stats">
<div class="traffic-stat">
<dt>Avg RPS</dt>
<dd>{{ evidence()!.traffic!.requestsPerSecond | number:'1.1-1' }}</dd>
</div>
<div class="traffic-stat">
<dt>Peak RPS</dt>
<dd>{{ evidence()!.traffic!.peakRps | number:'1.1-1' }}</dd>
</div>
<div class="traffic-stat">
<dt>Traffic %</dt>
<dd>{{ evidence()!.traffic!.trafficPercentage | number:'1.0-0' }}%</dd>
</div>
</dl>
</section>
}
<!-- Execution Contexts -->
@if (evidence()!.contexts.length > 0) {
<section class="detail-section">
<h5>Execution Contexts</h5>
<ul class="context-list">
@for (ctx of evidence()!.contexts.slice(0, 5); track ctx.traceId ?? $index) {
<li class="context-item">
@if (ctx.route) {
<span class="ctx-route">{{ ctx.route }}</span>
}
@if (ctx.containerId) {
<code class="ctx-container">{{ truncateId(ctx.containerId) }}</code>
}
@if (ctx.traceId) {
<code class="ctx-trace">{{ truncateId(ctx.traceId) }}</code>
}
</li>
}
@if (evidence()!.contexts.length > 5) {
<li class="context-more">
+{{ evidence()!.contexts.length - 5 }} more contexts
</li>
}
</ul>
</section>
}
<!-- Evidence URIs -->
@if (evidence()!.evidenceUris.length > 0) {
<section class="detail-section">
<h5>Evidence Sources</h5>
<div class="uri-list">
@for (uri of evidence()!.evidenceUris; track uri) {
<app-evidence-uri-link [uri]="uri" class="compact" />
}
</div>
</section>
}
<!-- Metadata -->
<footer class="card-meta">
@if (evidence()!.agentVersion) {
<span>Agent: {{ evidence()!.agentVersion }}</span>
}
</footer>
</div>
}
}
</article>
`,
styles: [`
:host {
display: block;
}
.evidence-card {
border: 1px solid var(--card-border, #e5e7eb);
border-radius: 0.5rem;
background-color: var(--card-bg, #ffffff);
overflow: hidden;
transition: box-shadow 0.15s ease;
}
.evidence-card:hover {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.evidence-card--observed {
border-left: 3px solid var(--observed-color, #ef4444);
}
.evidence-card--not-observed {
border-left: 3px solid var(--not-observed-color, #22c55e);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
cursor: pointer;
user-select: none;
background-color: var(--header-bg, #f9fafb);
}
.card-header:hover {
background-color: var(--header-hover-bg, #f3f4f6);
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
background-color: var(--runtime-icon-bg, #dcfce7);
color: var(--runtime-icon-color, #16a34a);
font-size: 0.75rem;
font-weight: 700;
}
.card-title {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
}
.status-pill {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
}
.status-pill--positive {
background-color: var(--status-observed-bg, #fef2f2);
color: var(--status-observed-text, #991b1b);
}
.status-pill--negative {
background-color: var(--status-not-observed-bg, #dcfce7);
color: var(--status-not-observed-text, #166534);
}
.expand-btn {
display: inline-flex;
padding: 0.25rem;
border: none;
background: transparent;
cursor: pointer;
color: var(--expand-btn-color, #6b7280);
transition: transform 0.2s ease;
}
.expand-btn svg.rotated {
transform: rotate(180deg);
}
.empty-state {
padding: 1.5rem;
text-align: center;
color: var(--empty-text, #9ca3af);
font-size: 0.875rem;
}
.card-summary {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--divider, #e5e7eb);
}
.summary-stats {
display: flex;
gap: 1.5rem;
margin: 0;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.stat dt {
font-size: 0.6875rem;
color: var(--stat-label, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat dd {
margin: 0;
font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.card-details {
padding: 1rem;
}
.detail-section {
margin-bottom: 1rem;
}
.detail-section:last-of-type {
margin-bottom: 0;
}
.detail-section h5 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--section-title, #374151);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.time-range {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.time-point {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.time-label {
font-size: 0.6875rem;
color: var(--time-label, #9ca3af);
}
.time-value {
font-variant-numeric: tabular-nums;
}
.time-arrow {
color: var(--arrow-color, #d1d5db);
padding: 0 0.25rem;
}
.seen-times {
display: flex;
gap: 1.5rem;
margin-top: 0.75rem;
font-size: 0.8125rem;
}
.seen-item {
display: flex;
gap: 0.25rem;
}
.seen-label {
color: var(--seen-label, #6b7280);
}
.traffic-stats {
display: flex;
gap: 1.5rem;
margin: 0;
}
.traffic-stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.traffic-stat dt {
font-size: 0.6875rem;
color: var(--stat-label, #6b7280);
}
.traffic-stat dd {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.context-list {
margin: 0;
padding: 0;
list-style: none;
}
.context-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.ctx-route {
font-weight: 500;
}
.ctx-container,
.ctx-trace {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.6875rem;
background-color: var(--code-bg, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
.context-more {
padding: 0.25rem 0;
color: var(--more-color, #6b7280);
font-size: 0.75rem;
}
.uri-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.card-meta {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--divider, #e5e7eb);
font-size: 0.75rem;
color: var(--meta-color, #9ca3af);
}
`],
})
export class RuntimeEvidenceCardComponent {
/** Runtime evidence data */
readonly evidence = input<RuntimeEvidence | undefined>();
/** Whether card is expanded */
readonly expanded = input(true);
/** Toggle expansion */
readonly toggle = output<void>();
/** Whether symbol was observed */
readonly wasObserved = computed(() => {
return this.evidence()?.wasObserved ?? false;
});
formatNumber(num: number): string {
if (num >= 1_000_000) {
return (num / 1_000_000).toFixed(1) + 'M';
}
if (num >= 1_000) {
return (num / 1_000).toFixed(1) + 'K';
}
return num.toLocaleString();
}
formatDuration(iso: string): string {
// Parse ISO 8601 duration (P7D, PT1H, etc.)
const match = iso.match(/P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/);
if (!match) return iso;
const [, days, hours, minutes, seconds] = match;
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
if (minutes) parts.push(`${minutes}m`);
if (seconds) parts.push(`${seconds}s`);
return parts.join(' ') || iso;
}
formatDate(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleString();
} catch {
return isoString;
}
}
truncateId(id: string): string {
if (id.length > 12) {
return id.substring(0, 12) + '...';
}
return id;
}
}

View File

@@ -0,0 +1,373 @@
// -----------------------------------------------------------------------------
// static-evidence-card.component.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Create static-evidence-card.component.ts - Static analysis summary card
// -----------------------------------------------------------------------------
import { Component, input, output, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StaticEvidence } from '../../models/reachability.models';
import { ConfidenceMeterComponent } from './confidence-meter.component';
import { EvidenceUriLinkComponent } from './evidence-uri-link.component';
@Component({
selector: 'app-static-evidence-card',
standalone: true,
imports: [CommonModule, ConfidenceMeterComponent, EvidenceUriLinkComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article
class="evidence-card"
[class.evidence-card--expanded]="expanded()"
[class.evidence-card--reachable]="isReachable()"
[class.evidence-card--unreachable]="!isReachable()"
>
<header class="card-header" (click)="toggle.emit()">
<div class="header-left">
<span class="card-icon" aria-hidden="true">S</span>
<h4 class="card-title">Static Analysis</h4>
@if (evidence()) {
<span
class="status-pill"
[class.status-pill--positive]="isReachable()"
[class.status-pill--negative]="!isReachable()"
>
{{ isReachable() ? 'Reachable' : 'Not Reachable' }}
</span>
}
</div>
<button
class="expand-btn"
[attr.aria-expanded]="expanded()"
[attr.aria-label]="expanded() ? 'Collapse static evidence' : 'Expand static evidence'"
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
width="16"
height="16"
[class.rotated]="expanded()"
>
<path fill-rule="evenodd" d="M4.22 6.22a.75.75 0 011.06 0L8 8.94l2.72-2.72a.75.75 0 111.06 1.06l-3.25 3.25a.75.75 0 01-1.06 0L4.22 7.28a.75.75 0 010-1.06z"/>
</svg>
</button>
</header>
@if (!evidence()) {
<div class="empty-state">
<p>No static analysis data available</p>
</div>
} @else {
<div class="card-summary">
<dl class="summary-stats">
<div class="stat">
<dt>Paths Found</dt>
<dd>{{ evidence()!.pathCount }}</dd>
</div>
@if (evidence()!.shortestPathLength !== undefined) {
<div class="stat">
<dt>Shortest Path</dt>
<dd>{{ evidence()!.shortestPathLength }} calls</dd>
</div>
}
<div class="stat">
<dt>Entry Points</dt>
<dd>{{ evidence()!.entrypoints.length }}</dd>
</div>
</dl>
</div>
@if (expanded()) {
<div class="card-details">
<!-- Entry Points -->
@if (evidence()!.entrypoints.length > 0) {
<section class="detail-section">
<h5>Entry Points</h5>
<ul class="entry-list">
@for (entry of evidence()!.entrypoints; track entry) {
<li class="entry-item">
<code>{{ entry }}</code>
</li>
}
</ul>
</section>
}
<!-- Guards / Conditions -->
@if (evidence()!.guards.length > 0) {
<section class="detail-section">
<h5>Guard Conditions</h5>
<ul class="guard-list">
@for (guard of evidence()!.guards; track guard.key) {
<li class="guard-item">
<span class="guard-type">{{ guard.type }}</span>
<code class="guard-key">{{ guard.key }}</code>
@if (guard.value) {
<span class="guard-value">= {{ guard.value }}</span>
}
@if (guard.expression) {
<code class="guard-expr">{{ guard.expression }}</code>
}
</li>
}
</ul>
</section>
}
<!-- Evidence URIs -->
@if (evidence()!.evidenceUris.length > 0) {
<section class="detail-section">
<h5>Evidence Sources</h5>
<div class="uri-list">
@for (uri of evidence()!.evidenceUris; track uri) {
<app-evidence-uri-link [uri]="uri" class="compact" />
}
</div>
</section>
}
<!-- Metadata -->
<footer class="card-meta">
<span>Analyzed {{ formatDate(evidence()!.analyzedAt) }}</span>
@if (evidence()!.analyzerVersion) {
<span>by {{ evidence()!.analyzerVersion }}</span>
}
</footer>
</div>
}
}
</article>
`,
styles: [`
:host {
display: block;
}
.evidence-card {
border: 1px solid var(--card-border, #e5e7eb);
border-radius: 0.5rem;
background-color: var(--card-bg, #ffffff);
overflow: hidden;
transition: box-shadow 0.15s ease;
}
.evidence-card:hover {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.evidence-card--reachable {
border-left: 3px solid var(--reachable-color, #f97316);
}
.evidence-card--unreachable {
border-left: 3px solid var(--unreachable-color, #22c55e);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
cursor: pointer;
user-select: none;
background-color: var(--header-bg, #f9fafb);
}
.card-header:hover {
background-color: var(--header-hover-bg, #f3f4f6);
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
background-color: var(--static-icon-bg, #dbeafe);
color: var(--static-icon-color, #1d4ed8);
font-size: 0.75rem;
font-weight: 700;
}
.card-title {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
}
.status-pill {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
}
.status-pill--positive {
background-color: var(--status-positive-bg, #fef3c7);
color: var(--status-positive-text, #b45309);
}
.status-pill--negative {
background-color: var(--status-negative-bg, #dcfce7);
color: var(--status-negative-text, #166534);
}
.expand-btn {
display: inline-flex;
padding: 0.25rem;
border: none;
background: transparent;
cursor: pointer;
color: var(--expand-btn-color, #6b7280);
transition: transform 0.2s ease;
}
.expand-btn svg.rotated {
transform: rotate(180deg);
}
.empty-state {
padding: 1.5rem;
text-align: center;
color: var(--empty-text, #9ca3af);
font-size: 0.875rem;
}
.card-summary {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--divider, #e5e7eb);
}
.summary-stats {
display: flex;
gap: 1.5rem;
margin: 0;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.stat dt {
font-size: 0.6875rem;
color: var(--stat-label, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat dd {
margin: 0;
font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.card-details {
padding: 1rem;
}
.detail-section {
margin-bottom: 1rem;
}
.detail-section:last-of-type {
margin-bottom: 0;
}
.detail-section h5 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--section-title, #374151);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.entry-list,
.guard-list {
margin: 0;
padding: 0;
list-style: none;
}
.entry-item,
.guard-item {
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.entry-item code,
.guard-key,
.guard-expr {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.75rem;
background-color: var(--code-bg, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
.guard-type {
display: inline-block;
padding: 0.125rem 0.375rem;
margin-right: 0.375rem;
border-radius: 0.25rem;
background-color: var(--guard-type-bg, #e5e7eb);
font-size: 0.6875rem;
font-weight: 500;
}
.guard-value {
color: var(--guard-value-color, #6b7280);
margin-left: 0.25rem;
}
.uri-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.card-meta {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--divider, #e5e7eb);
font-size: 0.75rem;
color: var(--meta-color, #9ca3af);
}
`],
})
export class StaticEvidenceCardComponent {
/** Static evidence data */
readonly evidence = input<StaticEvidence | undefined>();
/** Whether card is expanded */
readonly expanded = input(true);
/** Toggle expansion */
readonly toggle = output<void>();
/** Whether symbol is reachable */
readonly isReachable = computed(() => {
return this.evidence()?.isReachable ?? false;
});
formatDate(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleString();
} catch {
return isoString;
}
}
}

View File

@@ -0,0 +1,400 @@
// -----------------------------------------------------------------------------
// symbol-path-viewer.component.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Create symbol-path-viewer.component.ts - Call path visualization
// -----------------------------------------------------------------------------
import { Component, input, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CallPath, CallPathNode } from '../../models/reachability.models';
@Component({
selector: 'app-symbol-path-viewer',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="symbol-path-viewer" role="tree" aria-label="Call paths to vulnerable code">
@if (paths().length === 0) {
<div class="empty-state">
<p>No call paths available</p>
</div>
} @else {
<div class="path-controls">
<span class="path-count">{{ paths().length }} path(s) found</span>
@if (paths().length > 1) {
<div class="path-nav">
<button
class="nav-btn"
[disabled]="selectedPathIndex() === 0"
(click)="previousPath()"
aria-label="Previous path"
>
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
<path fill-rule="evenodd" d="M11.78 9.78a.75.75 0 01-1.06 0L8 7.06 5.28 9.78a.75.75 0 01-1.06-1.06l3.25-3.25a.75.75 0 011.06 0l3.25 3.25a.75.75 0 010 1.06z"/>
</svg>
</button>
<span class="path-index">{{ selectedPathIndex() + 1 }} / {{ paths().length }}</span>
<button
class="nav-btn"
[disabled]="selectedPathIndex() === paths().length - 1"
(click)="nextPath()"
aria-label="Next path"
>
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
<path fill-rule="evenodd" d="M4.22 6.22a.75.75 0 011.06 0L8 8.94l2.72-2.72a.75.75 0 111.06 1.06l-3.25 3.25a.75.75 0 01-1.06 0L4.22 7.28a.75.75 0 010-1.06z"/>
</svg>
</button>
</div>
}
</div>
@if (selectedPath(); as path) {
<div class="path-confidence">
<span class="conf-label">Path confidence:</span>
<span class="conf-value">{{ (path.confidence * 100) | number:'1.0-0' }}%</span>
</div>
<ol class="path-nodes" role="treeitem">
@for (node of path.nodes; track node.symbol; let i = $index; let last = $last) {
<li
class="path-node"
[class.path-node--entry]="node.type === 'entry'"
[class.path-node--vulnerable]="node.type === 'vulnerable'"
[class.path-node--intermediate]="node.type === 'intermediate'"
>
<div class="node-connector" aria-hidden="true">
<span class="connector-line"></span>
<span class="connector-dot"></span>
</div>
<div class="node-content">
<div class="node-header">
<span class="node-badge">{{ getNodeBadge(node.type) }}</span>
<code class="node-symbol">{{ node.symbol }}</code>
</div>
@if (node.file) {
<div class="node-location">
<span class="location-file">{{ node.file }}</span>
@if (node.line !== undefined) {
<span class="location-line">:{{ node.line }}</span>
}
</div>
}
</div>
@if (!last) {
<div class="call-arrow" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
<path fill-rule="evenodd" d="M8.22 2.97a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06l2.97-2.97H3.75a.75.75 0 010-1.5h7.44L8.22 4.03a.75.75 0 010-1.06z"/>
</svg>
<span class="call-type">{{ getCallType(i) }}</span>
</div>
}
</li>
}
</ol>
<!-- Vulnerable Symbol Highlight -->
@if (vulnerableSymbol()) {
<div class="vulnerable-highlight">
<span class="vuln-icon" aria-hidden="true">!</span>
<span class="vuln-text">
Vulnerable symbol: <code>{{ vulnerableSymbol() }}</code>
</span>
</div>
}
}
}
</div>
`,
styles: [`
:host {
display: block;
}
.symbol-path-viewer {
padding: 1rem;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.5rem;
background-color: var(--bg-color, #f9fafb);
}
.empty-state {
text-align: center;
color: var(--empty-text, #9ca3af);
font-size: 0.875rem;
padding: 1rem;
}
.path-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.path-count {
font-size: 0.8125rem;
color: var(--count-color, #6b7280);
}
.path-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: 1px solid var(--btn-border, #d1d5db);
border-radius: 0.25rem;
background-color: var(--btn-bg, #ffffff);
color: var(--btn-color, #374151);
cursor: pointer;
transition: all 0.15s ease;
}
.nav-btn:hover:not(:disabled) {
background-color: var(--btn-hover-bg, #f3f4f6);
border-color: var(--btn-hover-border, #9ca3af);
}
.nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.path-index {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--index-color, #6b7280);
min-width: 3rem;
text-align: center;
}
.path-confidence {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 1rem;
font-size: 0.8125rem;
}
.conf-label {
color: var(--conf-label, #6b7280);
}
.conf-value {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.path-nodes {
margin: 0;
padding: 0;
list-style: none;
}
.path-node {
position: relative;
display: flex;
padding: 0.5rem 0;
}
.node-connector {
display: flex;
flex-direction: column;
align-items: center;
width: 1.5rem;
margin-right: 0.75rem;
}
.connector-line {
flex: 1;
width: 2px;
background-color: var(--connector-color, #d1d5db);
}
.path-node:first-child .connector-line {
visibility: hidden;
}
.connector-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
background-color: var(--dot-color, #9ca3af);
border: 2px solid var(--bg-color, #f9fafb);
}
.path-node--entry .connector-dot {
background-color: var(--entry-color, #3b82f6);
}
.path-node--vulnerable .connector-dot {
background-color: var(--vulnerable-color, #ef4444);
}
.node-content {
flex: 1;
}
.node-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.node-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
}
.path-node--entry .node-badge {
background-color: var(--entry-badge-bg, #dbeafe);
color: var(--entry-badge-color, #1d4ed8);
}
.path-node--intermediate .node-badge {
background-color: var(--intermediate-badge-bg, #e5e7eb);
color: var(--intermediate-badge-color, #4b5563);
}
.path-node--vulnerable .node-badge {
background-color: var(--vulnerable-badge-bg, #fef2f2);
color: var(--vulnerable-badge-color, #991b1b);
}
.node-symbol {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8125rem;
color: var(--symbol-color, #111827);
}
.path-node--vulnerable .node-symbol {
color: var(--vulnerable-symbol, #991b1b);
font-weight: 600;
}
.node-location {
margin-top: 0.125rem;
font-size: 0.75rem;
color: var(--location-color, #6b7280);
}
.location-file {
font-family: var(--font-mono, ui-monospace, monospace);
}
.location-line {
color: var(--line-color, #9ca3af);
}
.call-arrow {
display: flex;
align-items: center;
gap: 0.25rem;
margin: 0.25rem 0 0 2.25rem;
color: var(--arrow-color, #9ca3af);
}
.call-type {
font-size: 0.6875rem;
text-transform: lowercase;
}
.vulnerable-highlight {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.75rem;
border-radius: 0.375rem;
background-color: var(--vuln-bg, #fef2f2);
border: 1px solid var(--vuln-border, #fecaca);
}
.vuln-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background-color: var(--vuln-icon-bg, #ef4444);
color: var(--vuln-icon-color, #ffffff);
font-size: 0.75rem;
font-weight: 700;
}
.vuln-text {
font-size: 0.8125rem;
color: var(--vuln-text, #991b1b);
}
.vuln-text code {
font-family: var(--font-mono, ui-monospace, monospace);
font-weight: 600;
}
`],
})
export class SymbolPathViewerComponent {
/** Call paths to display */
readonly paths = input.required<CallPath[]>();
/** Vulnerable symbol name */
readonly vulnerableSymbol = input<string>();
/** Currently selected path index */
readonly selectedPathIndex = signal(0);
/** Get selected path */
readonly selectedPath = () => {
const paths = this.paths();
const index = this.selectedPathIndex();
return paths[index] ?? null;
};
previousPath(): void {
const current = this.selectedPathIndex();
if (current > 0) {
this.selectedPathIndex.set(current - 1);
}
}
nextPath(): void {
const current = this.selectedPathIndex();
const max = this.paths().length - 1;
if (current < max) {
this.selectedPathIndex.set(current + 1);
}
}
getNodeBadge(type: CallPathNode['type']): string {
switch (type) {
case 'entry': return 'Entry';
case 'vulnerable': return 'Vuln';
default: return '';
}
}
getCallType(nodeIndex: number): string {
const path = this.selectedPath();
if (!path) return 'call';
// This would ideally come from edge data
// For now, just return generic "calls"
return 'calls';
}
}

View File

@@ -24,7 +24,7 @@ import { AttestationChainComponent } from './attestation-chain.component';
import { ProvenanceTabComponent } from './provenance-tab.component';
import { DiffTabComponent } from './diff-tab.component';
import { RuntimeTabComponent } from './runtime-tab.component';
import { ReachabilityContextComponent } from '../reachability-context/reachability-context.component';
import { ReachabilityTabComponent } from './reachability-tab.component';
import {
EvidenceTabType,
EvidenceTab,
@@ -49,7 +49,7 @@ import { TabUrlPersistenceService } from '../../services/tab-url-persistence.ser
ProvenanceTabComponent,
DiffTabComponent,
RuntimeTabComponent,
ReachabilityContextComponent,
ReachabilityTabComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@@ -162,9 +162,10 @@ import { TabUrlPersistenceService } from '../../services/tab-url-persistence.ser
<button class="retry-btn" (click)="retryLoad('reachability')">Retry</button>
</div>
} @else if (reachabilityState()?.data) {
<div class="reachability-wrapper">
<app-reachability-context [data]="reachabilityState()!.data" />
</div>
<app-reachability-tab
[data]="reachabilityState()!.data"
(viewFullGraph)="onViewFullReachabilityGraph()"
/>
} @else {
<div class="empty-state">
<span>No reachability evidence available</span>
@@ -537,6 +538,9 @@ export class TabbedEvidencePanelComponent implements OnInit, OnDestroy {
/** Emitted when tab changes */
readonly tabChange = output<EvidenceTabType>();
/** Emitted when user wants to view full reachability graph */
readonly viewFullGraphClick = output<void>();
/** Tab configuration */
readonly tabs = DEFAULT_EVIDENCE_TABS;
@@ -659,6 +663,13 @@ export class TabbedEvidencePanelComponent implements OnInit, OnDestroy {
// Could show a toast notification
}
/** Handle view full reachability graph request */
onViewFullReachabilityGraph(): void {
// Navigate to full reachability graph view or open in modal
// This could emit an event or trigger navigation
this.viewFullGraphClick.emit();
}
// === Private Methods ===
private loadTabEvidence(tab: EvidenceTabType, findingId: string, forceRefresh = false): void {

View File

@@ -0,0 +1,261 @@
/**
* @file playbook-suggestion.component.spec.ts
* @sprint SPRINT_20260107_006_005_FE (OM-FE-005)
* @description Unit tests for PlaybookSuggestionComponent.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import {
PlaybookSuggestionComponent,
SituationContext,
} from './playbook-suggestion.component';
import { PlaybookSuggestionService } from '../../../opsmemory/services/playbook-suggestion.service';
import { PlaybookSuggestion } from '../../../opsmemory/models/playbook.models';
describe('PlaybookSuggestionComponent', () => {
let component: PlaybookSuggestionComponent;
let fixture: ComponentFixture<PlaybookSuggestionComponent>;
let mockService: jest.Mocked<PlaybookSuggestionService>;
const mockContext: SituationContext = {
tenantId: 'tenant-123',
cveId: 'CVE-2023-44487',
severity: 'high',
reachability: 'reachable',
};
const mockSuggestions: PlaybookSuggestion[] = [
{
suggestedAction: 'accept_risk',
confidence: 0.85,
rationale: 'Similar situations resolved successfully',
evidenceCount: 5,
matchingFactors: ['severity', 'reachability'],
evidence: [
{
memoryId: 'mem-abc123',
cveId: 'CVE-2023-44487',
action: 'accept_risk',
outcome: 'success',
resolutionTime: 'PT4H',
similarity: 0.92,
},
],
},
];
beforeEach(async () => {
mockService = {
getSuggestions: jest.fn().mockReturnValue(of(mockSuggestions)),
clearCache: jest.fn(),
invalidate: jest.fn(),
} as unknown as jest.Mocked<PlaybookSuggestionService>;
await TestBed.configureTestingModule({
imports: [PlaybookSuggestionComponent],
providers: [
{ provide: PlaybookSuggestionService, useValue: mockService },
],
}).compileComponents();
fixture = TestBed.createComponent(PlaybookSuggestionComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('initial state', () => {
it('should start collapsed by default', () => {
fixture.detectChanges();
expect(component.collapsed()).toBe(true);
});
it('should start expanded if startCollapsed is false', () => {
fixture.componentRef.setInput('startCollapsed', false);
fixture.componentRef.setInput('context', mockContext);
fixture.detectChanges();
expect(component.collapsed()).toBe(false);
});
});
describe('toggle', () => {
it('should toggle collapsed state', () => {
fixture.detectChanges();
expect(component.collapsed()).toBe(true);
component.toggleCollapsed();
expect(component.collapsed()).toBe(false);
component.toggleCollapsed();
expect(component.collapsed()).toBe(true);
});
it('should fetch suggestions when expanding', () => {
fixture.componentRef.setInput('context', mockContext);
fixture.detectChanges();
component.toggleCollapsed();
expect(mockService.getSuggestions).toHaveBeenCalledWith(
expect.objectContaining({
tenantId: 'tenant-123',
cveId: 'CVE-2023-44487',
})
);
});
});
describe('fetching suggestions', () => {
it('should show loading state', () => {
fixture.componentRef.setInput('context', mockContext);
fixture.componentRef.setInput('startCollapsed', false);
fixture.detectChanges();
// During loading
expect(component.loading()).toBe(true);
});
it('should display suggestions after loading', (done) => {
fixture.componentRef.setInput('context', mockContext);
fixture.componentRef.setInput('startCollapsed', false);
fixture.detectChanges();
setTimeout(() => {
expect(component.suggestions()).toEqual(mockSuggestions);
expect(component.loading()).toBe(false);
done();
}, 0);
});
it('should show error state on failure', (done) => {
mockService.getSuggestions.mockReturnValue(
throwError(() => new Error('Network error'))
);
fixture.componentRef.setInput('context', mockContext);
fixture.componentRef.setInput('startCollapsed', false);
fixture.detectChanges();
setTimeout(() => {
expect(component.error()).toBe('Network error');
expect(component.loading()).toBe(false);
done();
}, 0);
});
});
describe('empty state', () => {
it('should show empty message when no suggestions', (done) => {
mockService.getSuggestions.mockReturnValue(of([]));
fixture.componentRef.setInput('context', mockContext);
fixture.componentRef.setInput('startCollapsed', false);
fixture.detectChanges();
setTimeout(() => {
expect(component.suggestions().length).toBe(0);
const emptyEl = fixture.nativeElement.querySelector('.playbook-empty');
expect(emptyEl).toBeTruthy();
done();
}, 0);
});
});
describe('suggestion selection', () => {
it('should emit suggestionSelected when "Use This Approach" clicked', (done) => {
fixture.componentRef.setInput('context', mockContext);
fixture.componentRef.setInput('startCollapsed', false);
fixture.detectChanges();
setTimeout(() => {
fixture.detectChanges();
const selectSpy = jest.spyOn(component.suggestionSelected, 'emit');
component.useSuggestion(mockSuggestions[0]);
expect(selectSpy).toHaveBeenCalledWith(mockSuggestions[0]);
done();
}, 0);
});
});
describe('evidence expansion', () => {
it('should toggle evidence visibility', () => {
fixture.componentRef.setInput('context', mockContext);
fixture.detectChanges();
expect(component.expandedSuggestion()).toBe(null);
component.toggleEvidence('accept_risk');
expect(component.expandedSuggestion()).toBe('accept_risk');
component.toggleEvidence('accept_risk');
expect(component.expandedSuggestion()).toBe(null);
});
});
describe('retry', () => {
it('should refetch on retry', () => {
mockService.getSuggestions.mockReturnValue(
throwError(() => new Error('Network error'))
);
fixture.componentRef.setInput('context', mockContext);
fixture.componentRef.setInput('startCollapsed', false);
fixture.detectChanges();
// Reset mock to return success
mockService.getSuggestions.mockReturnValue(of(mockSuggestions));
component.retry();
expect(mockService.getSuggestions).toHaveBeenCalledTimes(2);
});
});
describe('formatting', () => {
it('should format confidence as percentage', () => {
expect(component.formatConfidence(0.85)).toBe('85% match');
expect(component.formatConfidence(0.92)).toBe('92% match');
expect(component.formatConfidence(1)).toBe('100% match');
});
it('should get action labels', () => {
expect(component.getActionLabel('accept_risk')).toBe('Accept Risk');
expect(component.getActionLabel('target_fix')).toBe('Target Fix');
expect(component.getActionLabel('quarantine')).toBe('Quarantine');
});
});
describe('accessibility', () => {
beforeEach(() => {
fixture.componentRef.setInput('context', mockContext);
fixture.detectChanges();
});
it('should have aria-expanded on header', () => {
const header = fixture.nativeElement.querySelector(
'.playbook-panel__header'
);
expect(header.getAttribute('aria-expanded')).toBe('false');
component.toggleCollapsed();
fixture.detectChanges();
expect(header.getAttribute('aria-expanded')).toBe('true');
});
it('should have role list for suggestions', (done) => {
component.toggleCollapsed();
setTimeout(() => {
fixture.detectChanges();
const list = fixture.nativeElement.querySelector('.playbook-suggestions');
expect(list?.getAttribute('role')).toBe('list');
done();
}, 0);
});
});
});

View File

@@ -0,0 +1,567 @@
/**
* @file playbook-suggestion.component.ts
* @sprint SPRINT_20260107_006_005_FE (OM-FE-002)
* @description Component for displaying OpsMemory playbook suggestions in the decision drawer.
*/
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
inject,
OnInit,
OnDestroy,
effect,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subject, takeUntil } from 'rxjs';
import { PlaybookSuggestionService } from '../../../opsmemory/services/playbook-suggestion.service';
import {
PlaybookSuggestion,
PlaybookEvidence,
DecisionAction,
getActionLabel,
getOutcomeDisplay,
} from '../../../opsmemory/models/playbook.models';
import { EvidenceCardComponent } from '../../../opsmemory/components/evidence-card/evidence-card.component';
/**
* Context for the current finding situation.
*/
export interface SituationContext {
tenantId: string;
cveId?: string;
severity?: 'critical' | 'high' | 'medium' | 'low';
reachability?: 'reachable' | 'unreachable' | 'unknown';
componentPurl?: string;
contextTags?: string[];
}
/**
* Component for displaying playbook suggestions from OpsMemory.
* Shows similar past decisions and their outcomes to guide current triage.
*/
@Component({
selector: 'stellaops-playbook-suggestion',
standalone: true,
imports: [CommonModule, EvidenceCardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="playbook-panel" [class.playbook-panel--collapsed]="collapsed()">
<button
class="playbook-panel__header"
(click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()"
aria-controls="playbook-content"
>
<span class="playbook-panel__icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 3a1 1 0 011-1h10a1 1 0 011 1v10a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm2 2v2h8V5H4zm0 4v2h8V9H4z"/>
</svg>
</span>
<span class="playbook-panel__title">Past Decisions</span>
@if (suggestions().length > 0) {
<span class="playbook-panel__badge">{{ suggestions().length }}</span>
}
<span class="playbook-panel__toggle">
{{ collapsed() ? '+' : '-' }}
</span>
</button>
@if (!collapsed()) {
<div id="playbook-content" class="playbook-panel__content">
@if (loading()) {
<div class="playbook-loading" role="status" aria-label="Loading suggestions">
<div class="playbook-loading__spinner"></div>
<span>Finding similar decisions...</span>
</div>
} @else if (error()) {
<div class="playbook-error" role="alert">
<span class="playbook-error__icon">!</span>
<span>{{ error() }}</span>
<button class="playbook-error__retry" (click)="retry()">Retry</button>
</div>
} @else if (suggestions().length === 0) {
<div class="playbook-empty">
<span>No similar past decisions found.</span>
</div>
} @else {
<div class="playbook-suggestions" role="list">
@for (suggestion of suggestions(); track suggestion.suggestedAction) {
<div class="playbook-suggestion" role="listitem">
<div class="playbook-suggestion__header">
<span
class="playbook-suggestion__action"
[class]="'playbook-suggestion__action--' + suggestion.suggestedAction"
>
{{ getActionLabel(suggestion.suggestedAction) }}
</span>
<span class="playbook-suggestion__confidence">
{{ formatConfidence(suggestion.confidence) }}
</span>
</div>
<p class="playbook-suggestion__rationale">
{{ suggestion.rationale }}
</p>
<div class="playbook-suggestion__factors">
@for (factor of suggestion.matchingFactors; track factor) {
<span class="playbook-suggestion__factor">{{ factor }}</span>
}
</div>
@if (expandedSuggestion() === suggestion.suggestedAction) {
<div class="playbook-suggestion__evidence">
<h4>Similar Decisions ({{ suggestion.evidenceCount }})</h4>
@for (evidence of suggestion.evidence; track evidence.memoryId) {
<stellaops-evidence-card
[evidence]="evidence"
(viewDetails)="onViewEvidence(evidence)"
/>
}
</div>
}
<div class="playbook-suggestion__actions">
<button
class="playbook-btn playbook-btn--expand"
(click)="toggleEvidence(suggestion.suggestedAction)"
>
{{
expandedSuggestion() === suggestion.suggestedAction
? 'Hide Details'
: 'Show Details'
}}
</button>
<button
class="playbook-btn playbook-btn--use"
(click)="useSuggestion(suggestion)"
>
Use This Approach
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
`,
styles: [
`
.playbook-panel {
background: var(--surface-info, #e3f2fd);
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
}
.playbook-panel__header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 12px 16px;
background: none;
border: none;
cursor: pointer;
text-align: left;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1976d2);
}
.playbook-panel__header:hover {
background: rgba(25, 118, 210, 0.04);
}
.playbook-panel__icon {
display: flex;
align-items: center;
justify-content: center;
}
.playbook-panel__title {
flex: 1;
}
.playbook-panel__badge {
background: var(--accent-primary, #1976d2);
color: white;
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
}
.playbook-panel__toggle {
font-size: 18px;
font-weight: 300;
}
.playbook-panel__content {
padding: 0 16px 16px;
}
.playbook-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
color: var(--text-secondary, #666);
}
.playbook-loading__spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-default, #ddd);
border-top-color: var(--accent-primary, #1976d2);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.playbook-error {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--surface-error, #ffebee);
border-radius: 4px;
color: var(--semantic-error, #c62828);
}
.playbook-error__icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: var(--semantic-error, #c62828);
color: white;
border-radius: 50%;
font-size: 12px;
font-weight: bold;
}
.playbook-error__retry {
margin-left: auto;
padding: 4px 12px;
background: none;
border: 1px solid currentColor;
border-radius: 4px;
cursor: pointer;
color: inherit;
}
.playbook-empty {
padding: 16px;
text-align: center;
color: var(--text-secondary, #666);
}
.playbook-suggestions {
display: flex;
flex-direction: column;
gap: 12px;
}
.playbook-suggestion {
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.playbook-suggestion__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.playbook-suggestion__action {
font-weight: 600;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
text-transform: uppercase;
}
.playbook-suggestion__action--accept_risk {
background: var(--semantic-success-light, #e8f5e9);
color: var(--semantic-success, #2e7d32);
}
.playbook-suggestion__action--target_fix {
background: var(--semantic-warning-light, #fff3e0);
color: var(--semantic-warning, #f57c00);
}
.playbook-suggestion__action--quarantine {
background: var(--semantic-error-light, #ffebee);
color: var(--semantic-error, #c62828);
}
.playbook-suggestion__action--patch_now {
background: var(--semantic-error-light, #ffebee);
color: var(--semantic-error, #c62828);
}
.playbook-suggestion__action--defer {
background: var(--surface-secondary, #f5f5f5);
color: var(--text-secondary, #666);
}
.playbook-suggestion__action--investigate {
background: var(--surface-info, #e3f2fd);
color: var(--text-info, #1565c0);
}
.playbook-suggestion__confidence {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #333);
}
.playbook-suggestion__rationale {
margin: 8px 0;
font-size: 14px;
color: var(--text-secondary, #666);
line-height: 1.5;
}
.playbook-suggestion__factors {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 12px;
}
.playbook-suggestion__factor {
font-size: 11px;
padding: 2px 8px;
background: var(--surface-secondary, #f5f5f5);
border-radius: 4px;
color: var(--text-secondary, #666);
}
.playbook-suggestion__evidence {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-default, #eee);
}
.playbook-suggestion__evidence h4 {
margin: 0 0 8px;
font-size: 12px;
color: var(--text-secondary, #666);
font-weight: 600;
}
.playbook-suggestion__actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.playbook-btn {
padding: 8px 16px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.playbook-btn--expand {
background: none;
border: 1px solid var(--border-default, #ddd);
color: var(--text-secondary, #666);
}
.playbook-btn--expand:hover {
background: var(--surface-secondary, #f5f5f5);
}
.playbook-btn--use {
background: var(--accent-primary, #1976d2);
border: none;
color: white;
font-weight: 600;
}
.playbook-btn--use:hover {
background: var(--accent-primary-hover, #1565c0);
}
`,
],
})
export class PlaybookSuggestionComponent implements OnInit, OnDestroy {
private readonly playbookService = inject(PlaybookSuggestionService);
private readonly destroy$ = new Subject<void>();
/** Situation context from the parent decision drawer */
readonly context = input<SituationContext | undefined>(undefined);
/** Whether the panel should start collapsed */
readonly startCollapsed = input<boolean>(false);
/** Emits when user selects a suggestion to use */
readonly suggestionSelected = output<PlaybookSuggestion>();
/** Emits when user wants to view evidence details */
readonly viewEvidence = output<PlaybookEvidence>();
/** Loading state */
readonly loading = signal(false);
/** Error message if fetch failed */
readonly error = signal<string | null>(null);
/** Fetched suggestions */
readonly suggestions = signal<PlaybookSuggestion[]>([]);
/** Whether panel is collapsed */
readonly collapsed = signal(true);
/** Currently expanded suggestion (by action) */
readonly expandedSuggestion = signal<DecisionAction | null>(null);
/** Whether suggestions are available */
readonly hasSuggestions = computed(() => this.suggestions().length > 0);
constructor() {
// Auto-fetch when context changes
effect(() => {
const ctx = this.context();
if (ctx && !this.collapsed()) {
this.fetchSuggestions(ctx);
}
});
}
ngOnInit(): void {
this.collapsed.set(this.startCollapsed());
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Toggle panel collapsed state.
*/
toggleCollapsed(): void {
const newCollapsed = !this.collapsed();
this.collapsed.set(newCollapsed);
// Fetch suggestions when expanding
if (!newCollapsed) {
const ctx = this.context();
if (ctx && this.suggestions().length === 0) {
this.fetchSuggestions(ctx);
}
}
}
/**
* Toggle evidence expansion for a suggestion.
*/
toggleEvidence(action: DecisionAction): void {
if (this.expandedSuggestion() === action) {
this.expandedSuggestion.set(null);
} else {
this.expandedSuggestion.set(action);
}
}
/**
* Use the selected suggestion to pre-fill the decision form.
*/
useSuggestion(suggestion: PlaybookSuggestion): void {
this.suggestionSelected.emit(suggestion);
}
/**
* View details of an evidence item.
*/
onViewEvidence(evidence: PlaybookEvidence): void {
this.viewEvidence.emit(evidence);
}
/**
* Retry fetching suggestions after error.
*/
retry(): void {
const ctx = this.context();
if (ctx) {
this.fetchSuggestions(ctx);
}
}
/**
* Format confidence as percentage.
*/
formatConfidence(confidence: number): string {
return `${Math.round(confidence * 100)}% match`;
}
/**
* Get display label for action.
*/
getActionLabel(action: DecisionAction): string {
return getActionLabel(action);
}
/**
* Fetch suggestions from OpsMemory.
*/
private fetchSuggestions(context: SituationContext): void {
this.loading.set(true);
this.error.set(null);
const componentType = context.componentPurl
? this.extractComponentType(context.componentPurl)
: undefined;
this.playbookService
.getSuggestions({
tenantId: context.tenantId,
cveId: context.cveId,
severity: context.severity,
reachability: context.reachability,
componentType,
contextTags: context.contextTags?.join(','),
})
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (suggestions) => {
this.suggestions.set(suggestions);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load suggestions');
this.loading.set(false);
},
});
}
/**
* Extract component type from PURL.
*/
private extractComponentType(purl: string): string | undefined {
const match = purl.match(/^pkg:([^/]+)\//);
return match?.[1];
}
}

View File

@@ -2,6 +2,7 @@
// models/index.ts
// Sprint: SPRINT_20260107_006_001_FE_tabbed_evidence_panel
// Sprint: SPRINT_20260107_006_002_FE_diff_runtime_tabs
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Description: Barrel export file for triage feature models
// -----------------------------------------------------------------------------
@@ -13,3 +14,6 @@ export * from './diff-evidence.models';
// Runtime Evidence Models (Sprint 006_002)
export * from './runtime-evidence.models';
// Hybrid Reachability Models (Sprint 009_006)
export * from './reachability.models';

View File

@@ -0,0 +1,365 @@
// -----------------------------------------------------------------------------
// reachability.models.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Create reachability.models.ts - TypeScript interfaces for hybrid reachability
// -----------------------------------------------------------------------------
/**
* 8-state lattice model for hybrid reachability.
* States progress from Unknown through static/runtime evidence to final verdicts.
*/
export enum LatticeState {
/** No evidence collected yet */
Unknown = 'Unknown',
/** Static analysis shows path from entry to symbol */
StaticReachable = 'StaticReachable',
/** Static analysis shows no path from any entry */
StaticUnreachable = 'StaticUnreachable',
/** Runtime observation confirms execution */
RuntimeObserved = 'RuntimeObserved',
/** Runtime monitoring found no execution in window */
RuntimeNotObserved = 'RuntimeNotObserved',
/** Static + Runtime both confirm reachability */
Confirmed = 'Confirmed',
/** Static unreachable + Runtime not observed */
RefutedUnreachable = 'RefutedUnreachable',
/** Evidence conflict requiring manual review */
Conflicted = 'Conflicted',
}
/**
* Maps lattice states to VEX status recommendations.
*/
export const LATTICE_TO_VEX_STATUS: Record<LatticeState, string> = {
[LatticeState.Unknown]: 'under_investigation',
[LatticeState.StaticReachable]: 'affected',
[LatticeState.StaticUnreachable]: 'not_affected',
[LatticeState.RuntimeObserved]: 'affected',
[LatticeState.RuntimeNotObserved]: 'not_affected',
[LatticeState.Confirmed]: 'affected',
[LatticeState.RefutedUnreachable]: 'not_affected',
[LatticeState.Conflicted]: 'under_investigation',
};
/**
* Severity level for lattice states (for UI coloring).
*/
export type LatticeSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
/**
* Maps lattice states to severity levels.
*/
export const LATTICE_SEVERITY: Record<LatticeState, LatticeSeverity> = {
[LatticeState.Unknown]: 'unknown',
[LatticeState.StaticReachable]: 'high',
[LatticeState.StaticUnreachable]: 'low',
[LatticeState.RuntimeObserved]: 'critical',
[LatticeState.RuntimeNotObserved]: 'low',
[LatticeState.Confirmed]: 'critical',
[LatticeState.RefutedUnreachable]: 'info',
[LatticeState.Conflicted]: 'medium',
};
/**
* Symbol reference with canonical ID.
*/
export interface SymbolRef {
/** Package URL */
purl: string;
/** Symbol/function name */
symbol: string;
/** Programming language */
language: string;
/** Display-friendly name */
displayName: string;
/** Canonical identifier for deduplication */
canonicalId: string;
}
/**
* Guard condition that affects reachability.
*/
export interface GuardCondition {
/** Guard type (FeatureFlag, Config, Argument, etc.) */
type: string;
/** Guard key/name */
key: string;
/** Value required for reachability */
value?: string;
/** Complex expression if not simple key=value */
expression?: string;
}
/**
* Static call graph analysis evidence.
*/
export interface StaticEvidence {
/** Whether a path exists from entry to symbol */
isReachable: boolean;
/** Number of distinct paths found */
pathCount: number;
/** Shortest call depth from entry */
shortestPathLength?: number;
/** Entry points that can reach the symbol */
entrypoints: string[];
/** Guard conditions affecting reachability */
guards: GuardCondition[];
/** Evidence URIs (stella:// scheme) */
evidenceUris: string[];
/** When analysis was performed */
analyzedAt: string;
/** Analyzer version */
analyzerVersion?: string;
/** Call paths from entry to symbol */
callPaths: CallPath[];
}
/**
* A single call path from entry to vulnerable symbol.
*/
export interface CallPath {
/** Ordered list of function calls */
nodes: CallPathNode[];
/** Confidence in this path */
confidence: number;
}
/**
* A node in a call path.
*/
export interface CallPathNode {
/** Function/method name */
symbol: string;
/** File path */
file?: string;
/** Line number */
line?: number;
/** Node type */
type: 'entry' | 'intermediate' | 'vulnerable';
}
/**
* Execution context where symbol was observed.
*/
export interface ExecutionContext {
/** Container ID */
containerId?: string;
/** Process ID */
processId?: number;
/** HTTP route if applicable */
route?: string;
/** Trace ID for correlation */
traceId?: string;
}
/**
* Traffic profile during observation.
*/
export interface TrafficProfile {
/** Requests per second during window */
requestsPerSecond: number;
/** Peak RPS observed */
peakRps: number;
/** Percentage of typical traffic */
trafficPercentage: number;
}
/**
* Runtime observation evidence.
*/
export interface RuntimeEvidence {
/** Whether symbol was observed executing */
wasObserved: boolean;
/** Observation window duration */
observationWindow: string; // ISO 8601 duration
/** Window start time */
windowStart: string;
/** Window end time */
windowEnd: string;
/** Execution count */
hitCount: number;
/** First observation time */
firstSeen?: string;
/** Last observation time */
lastSeen?: string;
/** Execution contexts */
contexts: ExecutionContext[];
/** Evidence URIs */
evidenceUris: string[];
/** Traffic profile */
traffic?: TrafficProfile;
/** Agent version */
agentVersion?: string;
}
/**
* VEX verdict recommendation.
*/
export interface VerdictRecommendation {
/** Recommended VEX status */
status: 'affected' | 'not_affected' | 'under_investigation' | 'fixed';
/** VEX justification code */
justification?: string;
/** Human-readable explanation */
explanation?: string;
/** Additional status notes */
statusNotes?: string;
/** Whether manual review is recommended */
requiresManualReview: boolean;
}
/**
* Evidence bundle with URIs.
*/
export interface EvidenceBundle {
/** All evidence URIs */
uris: string[];
/** Content digest of evidence */
contentDigest?: string;
}
/**
* Hybrid reachability result combining static and runtime evidence.
*/
export interface HybridReachabilityResult {
/** Queried symbol */
symbol: SymbolRef;
/** Artifact digest analyzed */
artifactDigest: string;
/** Computed lattice state */
latticeState: LatticeState;
/** Overall confidence (0-1) */
confidence: number;
/** Static analysis result (if performed) */
staticEvidence?: StaticEvidence;
/** Runtime analysis result (if performed) */
runtimeEvidence?: RuntimeEvidence;
/** VEX verdict recommendation */
verdict: VerdictRecommendation;
/** Evidence bundle */
evidence: EvidenceBundle;
/** All evidence URIs (flattened) */
evidenceUris: string[];
/** When result was computed */
computedAt: string;
/** Who/what computed the result */
computedBy: string;
/** Content digest for verification */
contentDigest: string;
}
/**
* Query options for hybrid reachability.
*/
export interface HybridQueryOptions {
/** Include static analysis */
includeStatic: boolean;
/** Include runtime analysis */
includeRuntime: boolean;
/** Runtime observation window (ISO 8601 duration) */
observationWindow: string;
/** Minimum confidence threshold */
minConfidenceThreshold: number;
/** Include full evidence bundles */
includeEvidence: boolean;
/** Tenant ID for multi-tenant */
tenantId?: string;
}
/**
* Default query options.
*/
export const DEFAULT_QUERY_OPTIONS: HybridQueryOptions = {
includeStatic: true,
includeRuntime: true,
observationWindow: 'P7D', // 7 days
minConfidenceThreshold: 0.5,
includeEvidence: true,
};
/**
* Helper to get human-readable lattice state label.
*/
export function getLatticeStateLabel(state: LatticeState): string {
const labels: Record<LatticeState, string> = {
[LatticeState.Unknown]: 'Unknown',
[LatticeState.StaticReachable]: 'Static: Reachable',
[LatticeState.StaticUnreachable]: 'Static: Unreachable',
[LatticeState.RuntimeObserved]: 'Runtime: Observed',
[LatticeState.RuntimeNotObserved]: 'Runtime: Not Observed',
[LatticeState.Confirmed]: 'Confirmed Reachable',
[LatticeState.RefutedUnreachable]: 'Confirmed Unreachable',
[LatticeState.Conflicted]: 'Evidence Conflict',
};
return labels[state] ?? state;
}
/**
* Helper to get lattice state description.
*/
export function getLatticeStateDescription(state: LatticeState): string {
const descriptions: Record<LatticeState, string> = {
[LatticeState.Unknown]: 'No reachability evidence has been collected yet.',
[LatticeState.StaticReachable]: 'Static analysis found a path from an entry point to this symbol.',
[LatticeState.StaticUnreachable]: 'Static analysis found no path from any entry point.',
[LatticeState.RuntimeObserved]: 'Runtime monitoring observed this symbol being executed.',
[LatticeState.RuntimeNotObserved]: 'Runtime monitoring did not observe execution within the window.',
[LatticeState.Confirmed]: 'Both static analysis and runtime observation confirm reachability.',
[LatticeState.RefutedUnreachable]: 'Both static and runtime evidence confirm the code is not reached.',
[LatticeState.Conflicted]: 'Static and runtime evidence conflict; manual review recommended.',
};
return descriptions[state] ?? '';
}
/**
* Helper to format confidence as percentage string.
*/
export function formatConfidence(confidence: number): string {
return `${Math.round(confidence * 100)}%`;
}
/**
* Helper to parse stella:// evidence URI.
*/
export interface ParsedEvidenceUri {
scheme: 'stella';
artifactDigest: string;
evidenceType: 'static' | 'runtime' | 'hybrid';
path: string;
query: Record<string, string>;
}
export function parseEvidenceUri(uri: string): ParsedEvidenceUri | null {
if (!uri.startsWith('stella://')) {
return null;
}
try {
const url = new URL(uri);
const artifactDigest = url.hostname;
const pathParts = url.pathname.split('/').filter(Boolean);
const evidenceType = (pathParts[0] as 'static' | 'runtime' | 'hybrid') ?? 'hybrid';
const path = pathParts.slice(1).join('/');
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
return {
scheme: 'stella',
artifactDigest,
evidenceType,
path,
query,
};
} catch {
return null;
}
}

View File

@@ -2,6 +2,7 @@
// services/index.ts
// Sprint: SPRINT_20260107_006_001_FE_tabbed_evidence_panel
// Sprint: SPRINT_20260107_006_002_FE_diff_runtime_tabs
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Description: Barrel export file for triage feature services
// -----------------------------------------------------------------------------
@@ -14,3 +15,6 @@ export { DiffEvidenceService } from './diff-evidence.service';
// Runtime Evidence Services (Sprint 006_002)
export { RuntimeEvidenceService } from './runtime-evidence.service';
// Hybrid Reachability Services (Sprint 009_006)
export { ReachabilityService } from './reachability.service';

View File

@@ -0,0 +1,281 @@
// -----------------------------------------------------------------------------
// reachability.service.spec.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Write unit tests
// -----------------------------------------------------------------------------
import { TestBed } from '@angular/core/testing';
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import { ReachabilityService } from './reachability.service';
import {
HybridReachabilityResult,
LatticeState,
} from '../models/reachability.models';
describe('ReachabilityService', () => {
let service: ReachabilityService;
let httpMock: HttpTestingController;
const mockResult: HybridReachabilityResult = {
symbol: {
purl: 'pkg:npm/lodash@4.17.21',
symbol: 'get',
language: 'javascript',
displayName: 'lodash.get',
canonicalId: 'npm:lodash:get',
},
artifactDigest: 'sha256:abc123',
latticeState: LatticeState.Confirmed,
confidence: 0.95,
staticEvidence: {
isReachable: true,
pathCount: 3,
shortestPathLength: 2,
entrypoints: ['main.js:init'],
guards: [],
evidenceUris: ['stella://sha256:abc123/static/reach'],
analyzedAt: '2026-01-10T10:00:00Z',
callPaths: [],
},
runtimeEvidence: {
wasObserved: true,
observationWindow: 'P7D',
windowStart: '2026-01-03T00:00:00Z',
windowEnd: '2026-01-10T00:00:00Z',
hitCount: 1500,
firstSeen: '2026-01-03T10:30:00Z',
lastSeen: '2026-01-10T09:45:00Z',
contexts: [],
evidenceUris: ['stella://sha256:abc123/runtime/signals'],
},
verdict: {
status: 'affected',
justification: 'code_execution_observed',
explanation: 'Symbol was executed in production',
requiresManualReview: false,
},
evidence: {
uris: [
'stella://sha256:abc123/static/reach',
'stella://sha256:abc123/runtime/signals',
],
},
evidenceUris: [
'stella://sha256:abc123/static/reach',
'stella://sha256:abc123/runtime/signals',
],
computedAt: '2026-01-10T10:05:00Z',
computedBy: 'hybrid-reachability-index',
contentDigest: 'sha256:def456',
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [ReachabilityService],
});
service = TestBed.inject(ReachabilityService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
service.clearCache();
});
describe('getReachability', () => {
it('should fetch reachability for a finding', async () => {
const promise = service.getReachability('finding-123', 'sha256:abc123');
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/reachability/findings/finding-123/hybrid')
);
expect(req.request.method).toBe('GET');
expect(req.request.params.get('artifactDigest')).toBe('sha256:abc123');
expect(req.request.params.get('includeStatic')).toBe('true');
expect(req.request.params.get('includeRuntime')).toBe('true');
req.flush({ data: mockResult, meta: { queryTime: 50, cached: false } });
const result = await promise;
expect(result.latticeState).toBe(LatticeState.Confirmed);
expect(result.confidence).toBe(0.95);
});
it('should cache results', async () => {
// First call
const promise1 = service.getReachability('finding-123', 'sha256:abc123');
httpMock
.expectOne((r) => r.url.includes('/api/v1/reachability/findings/finding-123/hybrid'))
.flush({ data: mockResult, meta: { queryTime: 50, cached: false } });
await promise1;
// Second call should use cache (no HTTP request)
const result2 = await service.getReachability('finding-123', 'sha256:abc123');
httpMock.expectNone((r) => r.url.includes('/api/v1/reachability'));
expect(result2.latticeState).toBe(LatticeState.Confirmed);
});
it('should respect custom options', async () => {
const promise = service.getReachability('finding-123', 'sha256:abc123', {
includeStatic: true,
includeRuntime: false,
observationWindow: 'P30D',
});
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/reachability/findings/finding-123/hybrid')
);
expect(req.request.params.get('includeRuntime')).toBe('false');
expect(req.request.params.get('observationWindow')).toBe('P30D');
req.flush({ data: mockResult, meta: { queryTime: 50, cached: false } });
await promise;
});
});
describe('getStaticReachability', () => {
it('should query with static-only options', async () => {
const promise = service.getStaticReachability('finding-123', 'sha256:abc123');
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/reachability/findings/finding-123/hybrid')
);
expect(req.request.params.get('includeStatic')).toBe('true');
expect(req.request.params.get('includeRuntime')).toBe('false');
req.flush({ data: mockResult, meta: { queryTime: 30, cached: false } });
await promise;
});
});
describe('getRuntimeReachability', () => {
it('should query with runtime-only options', async () => {
const promise = service.getRuntimeReachability('finding-123', 'sha256:abc123', 'P14D');
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/reachability/findings/finding-123/hybrid')
);
expect(req.request.params.get('includeStatic')).toBe('false');
expect(req.request.params.get('includeRuntime')).toBe('true');
expect(req.request.params.get('observationWindow')).toBe('P14D');
req.flush({ data: mockResult, meta: { queryTime: 40, cached: false } });
await promise;
});
});
describe('refreshReachability', () => {
it('should bypass cache and fetch fresh data', async () => {
// First call
const promise1 = service.getReachability('finding-123', 'sha256:abc123');
httpMock
.expectOne((r) => r.url.includes('/api/v1/reachability/findings/finding-123/hybrid'))
.flush({ data: mockResult, meta: { queryTime: 50, cached: false } });
await promise1;
// Refresh should make new request
const promise2 = service.refreshReachability('finding-123', 'sha256:abc123');
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/reachability/findings/finding-123/hybrid')
);
req.flush({ data: { ...mockResult, confidence: 0.98 }, meta: { queryTime: 45, cached: false } });
const result = await promise2;
expect(result.confidence).toBe(0.98);
});
});
describe('getSymbolReachability', () => {
it('should query by PURL and symbol', async () => {
const promise = service.getSymbolReachability(
'pkg:npm/lodash@4.17.21',
'get',
'sha256:abc123'
);
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/reachability/symbols/query')
);
expect(req.request.params.get('purl')).toBe('pkg:npm/lodash@4.17.21');
expect(req.request.params.get('symbol')).toBe('get');
expect(req.request.params.get('artifactDigest')).toBe('sha256:abc123');
req.flush({ data: mockResult, meta: { queryTime: 60, cached: false } });
await promise;
});
});
describe('helper methods', () => {
describe('isAffected', () => {
it('should return true for reachable states', () => {
expect(service.isAffected(LatticeState.StaticReachable)).toBeTrue();
expect(service.isAffected(LatticeState.RuntimeObserved)).toBeTrue();
expect(service.isAffected(LatticeState.Confirmed)).toBeTrue();
});
it('should return false for non-reachable states', () => {
expect(service.isAffected(LatticeState.StaticUnreachable)).toBeFalse();
expect(service.isAffected(LatticeState.RuntimeNotObserved)).toBeFalse();
expect(service.isAffected(LatticeState.RefutedUnreachable)).toBeFalse();
expect(service.isAffected(LatticeState.Unknown)).toBeFalse();
expect(service.isAffected(LatticeState.Conflicted)).toBeFalse();
});
});
describe('isNotAffected', () => {
it('should return true for unreachable states', () => {
expect(service.isNotAffected(LatticeState.StaticUnreachable)).toBeTrue();
expect(service.isNotAffected(LatticeState.RuntimeNotObserved)).toBeTrue();
expect(service.isNotAffected(LatticeState.RefutedUnreachable)).toBeTrue();
});
it('should return false for reachable or unknown states', () => {
expect(service.isNotAffected(LatticeState.StaticReachable)).toBeFalse();
expect(service.isNotAffected(LatticeState.RuntimeObserved)).toBeFalse();
expect(service.isNotAffected(LatticeState.Confirmed)).toBeFalse();
expect(service.isNotAffected(LatticeState.Unknown)).toBeFalse();
});
});
describe('requiresInvestigation', () => {
it('should return true for Unknown and Conflicted', () => {
expect(service.requiresInvestigation(LatticeState.Unknown)).toBeTrue();
expect(service.requiresInvestigation(LatticeState.Conflicted)).toBeTrue();
});
it('should return false for definitive states', () => {
expect(service.requiresInvestigation(LatticeState.Confirmed)).toBeFalse();
expect(service.requiresInvestigation(LatticeState.RefutedUnreachable)).toBeFalse();
expect(service.requiresInvestigation(LatticeState.StaticReachable)).toBeFalse();
});
});
});
describe('clearCache', () => {
it('should clear all cached data', async () => {
// Populate cache
const promise1 = service.getReachability('finding-123', 'sha256:abc123');
httpMock
.expectOne((r) => r.url.includes('/api/v1/reachability/findings/finding-123/hybrid'))
.flush({ data: mockResult, meta: { queryTime: 50, cached: false } });
await promise1;
// Clear cache
service.clearCache();
// Next call should make HTTP request
const promise2 = service.getReachability('finding-123', 'sha256:abc123');
const req = httpMock.expectOne((r) =>
r.url.includes('/api/v1/reachability/findings/finding-123/hybrid')
);
req.flush({ data: mockResult, meta: { queryTime: 50, cached: false } });
await promise2;
});
});
});

View File

@@ -0,0 +1,260 @@
// -----------------------------------------------------------------------------
// reachability.service.ts
// Sprint: SPRINT_20260109_009_006_FE_evidence_panel_ui
// Task: Create reachability.service.ts - API integration for hybrid reachability
// -----------------------------------------------------------------------------
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
HybridReachabilityResult,
HybridQueryOptions,
DEFAULT_QUERY_OPTIONS,
LatticeState,
} from '../models/reachability.models';
/**
* API response wrapper for reachability queries.
*/
interface ReachabilityApiResponse {
data: HybridReachabilityResult;
meta: {
queryTime: number;
cached: boolean;
};
}
/**
* Service for querying hybrid reachability data from the backend.
*/
@Injectable({
providedIn: 'root',
})
export class ReachabilityService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/reachability';
/**
* Cache for recent queries to avoid redundant API calls.
*/
private readonly cache = new Map<string, {
result: HybridReachabilityResult;
timestamp: number;
}>();
private readonly CACHE_TTL_MS = 60_000; // 1 minute
/**
* Query hybrid reachability for a finding.
*
* @param findingId - The finding/vulnerability ID
* @param artifactDigest - The artifact digest (sha256:...)
* @param options - Query options
* @returns Hybrid reachability result
*/
async getReachability(
findingId: string,
artifactDigest: string,
options: Partial<HybridQueryOptions> = {}
): Promise<HybridReachabilityResult> {
const cacheKey = this.makeCacheKey(findingId, artifactDigest, options);
const cached = this.getFromCache(cacheKey);
if (cached) {
return cached;
}
const mergedOptions = { ...DEFAULT_QUERY_OPTIONS, ...options };
const params = this.buildParams(mergedOptions);
const response = await firstValueFrom(
this.http.get<ReachabilityApiResponse>(
`${this.baseUrl}/findings/${encodeURIComponent(findingId)}/hybrid`,
{
params: params.set('artifactDigest', artifactDigest),
}
)
);
this.setCache(cacheKey, response.data);
return response.data;
}
/**
* Query reachability for a specific symbol.
*
* @param purl - Package URL
* @param symbol - Symbol/function name
* @param artifactDigest - Artifact digest
* @param options - Query options
* @returns Hybrid reachability result
*/
async getSymbolReachability(
purl: string,
symbol: string,
artifactDigest: string,
options: Partial<HybridQueryOptions> = {}
): Promise<HybridReachabilityResult> {
const cacheKey = `symbol:${purl}:${symbol}:${artifactDigest}`;
const cached = this.getFromCache(cacheKey);
if (cached) {
return cached;
}
const mergedOptions = { ...DEFAULT_QUERY_OPTIONS, ...options };
const params = this.buildParams(mergedOptions)
.set('purl', purl)
.set('symbol', symbol)
.set('artifactDigest', artifactDigest);
const response = await firstValueFrom(
this.http.get<ReachabilityApiResponse>(`${this.baseUrl}/symbols/query`, { params })
);
this.setCache(cacheKey, response.data);
return response.data;
}
/**
* Get static-only reachability (faster, no runtime data).
*
* @param findingId - Finding ID
* @param artifactDigest - Artifact digest
* @returns Hybrid result with only static evidence
*/
async getStaticReachability(
findingId: string,
artifactDigest: string
): Promise<HybridReachabilityResult> {
return this.getReachability(findingId, artifactDigest, {
includeStatic: true,
includeRuntime: false,
});
}
/**
* Get runtime-only reachability.
*
* @param findingId - Finding ID
* @param artifactDigest - Artifact digest
* @param window - Observation window (ISO 8601 duration)
* @returns Hybrid result with only runtime evidence
*/
async getRuntimeReachability(
findingId: string,
artifactDigest: string,
window: string = 'P7D'
): Promise<HybridReachabilityResult> {
return this.getReachability(findingId, artifactDigest, {
includeStatic: false,
includeRuntime: true,
observationWindow: window,
});
}
/**
* Refresh reachability data (bypasses cache).
*
* @param findingId - Finding ID
* @param artifactDigest - Artifact digest
* @param options - Query options
* @returns Fresh hybrid reachability result
*/
async refreshReachability(
findingId: string,
artifactDigest: string,
options: Partial<HybridQueryOptions> = {}
): Promise<HybridReachabilityResult> {
const cacheKey = this.makeCacheKey(findingId, artifactDigest, options);
this.cache.delete(cacheKey);
return this.getReachability(findingId, artifactDigest, options);
}
/**
* Clear all cached reachability data.
*/
clearCache(): void {
this.cache.clear();
}
/**
* Check if a lattice state indicates affected status.
*/
isAffected(state: LatticeState): boolean {
return [
LatticeState.StaticReachable,
LatticeState.RuntimeObserved,
LatticeState.Confirmed,
].includes(state);
}
/**
* Check if a lattice state indicates not affected status.
*/
isNotAffected(state: LatticeState): boolean {
return [
LatticeState.StaticUnreachable,
LatticeState.RuntimeNotObserved,
LatticeState.RefutedUnreachable,
].includes(state);
}
/**
* Check if a lattice state requires investigation.
*/
requiresInvestigation(state: LatticeState): boolean {
return [LatticeState.Unknown, LatticeState.Conflicted].includes(state);
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private makeCacheKey(
findingId: string,
artifactDigest: string,
options: Partial<HybridQueryOptions>
): string {
const optStr = JSON.stringify(options);
return `finding:${findingId}:${artifactDigest}:${optStr}`;
}
private getFromCache(key: string): HybridReachabilityResult | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
const age = Date.now() - entry.timestamp;
if (age > this.CACHE_TTL_MS) {
this.cache.delete(key);
return null;
}
return entry.result;
}
private setCache(key: string, result: HybridReachabilityResult): void {
// Evict old entries if cache is too large
if (this.cache.size > 100) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, {
result,
timestamp: Date.now(),
});
}
private buildParams(options: HybridQueryOptions): HttpParams {
return new HttpParams()
.set('includeStatic', options.includeStatic.toString())
.set('includeRuntime', options.includeRuntime.toString())
.set('observationWindow', options.observationWindow)
.set('minConfidence', options.minConfidenceThreshold.toString())
.set('includeEvidence', options.includeEvidence.toString());
}
}