sprints work
This commit is contained in:
@@ -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');
|
||||
|
||||
276
src/Web/StellaOps.Web/e2e/playbook-suggestions.e2e.spec.ts
Normal file
276
src/Web/StellaOps.Web/e2e/playbook-suggestions.e2e.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
610
src/Web/StellaOps.Web/e2e/sbom-evidence.e2e.spec.ts
Normal file
610
src/Web/StellaOps.Web/e2e/sbom-evidence.e2e.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -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: '**',
|
||||
|
||||
524
src/Web/StellaOps.Web/src/app/core/api/ai-runs.client.ts
Normal file
524
src/Web/StellaOps.Web/src/app/core/api/ai-runs.client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
229
src/Web/StellaOps.Web/src/app/core/api/ai-runs.models.ts
Normal file
229
src/Web/StellaOps.Web/src/app/core/api/ai-runs.models.ts
Normal 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;
|
||||
}
|
||||
401
src/Web/StellaOps.Web/src/app/core/api/evidence-pack.client.ts
Normal file
401
src/Web/StellaOps.Web/src/app/core/api/evidence-pack.client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
178
src/Web/StellaOps.Web/src/app/core/api/evidence-pack.models.ts
Normal file
178
src/Web/StellaOps.Web/src/app/core/api/evidence-pack.models.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
7
src/Web/StellaOps.Web/src/app/features/ai-runs/index.ts
Normal file
7
src/Web/StellaOps.Web/src/app/features/ai-runs/index.ts
Normal 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';
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
14
src/Web/StellaOps.Web/src/app/features/opsmemory/index.ts
Normal file
14
src/Web/StellaOps.Web/src/app/features/opsmemory/index.ts
Normal 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';
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"><{{ c.author.email }}></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"><{{ c.committer.email }}></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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { }
|
||||
}
|
||||
</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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
9
src/Web/StellaOps.Web/src/app/features/sbom/index.ts
Normal file
9
src/Web/StellaOps.Web/src/app/features/sbom/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE
|
||||
* @description Public API for SBOM models.
|
||||
*/
|
||||
|
||||
export * from './cyclonedx-evidence.models';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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*
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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%');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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()}`;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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()}`;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user