audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories

This commit is contained in:
master
2026-01-07 18:49:59 +02:00
parent 04ec098046
commit 608a7f85c0
866 changed files with 56323 additions and 6231 deletions

View File

@@ -0,0 +1,360 @@
// -----------------------------------------------------------------------------
// quiet-triage-workflow.e2e.spec.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Task: T030 - E2E tests for complete workflow
// Description: End-to-end tests for the quiet-by-default triage workflow
// -----------------------------------------------------------------------------
import { test, expect, Page } from '@playwright/test';
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:4200';
test.describe('Quiet Triage Workflow', () => {
let page: Page;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
// Navigate to findings page
await page.goto(`${BASE_URL}/triage/findings`);
await page.waitForLoadState('networkidle');
});
test.afterEach(async () => {
await page.close();
});
test.describe('Lane Toggle', () => {
test('should default to Quiet lane showing only actionable findings', async () => {
// Verify Quiet lane is active by default
const quietButton = page.locator('app-triage-lane-toggle button:has-text("Actionable")');
await expect(quietButton).toHaveClass(/lane-toggle__btn--active/);
// Verify no gated findings are visible
const gatedBadges = page.locator('.gated-badge');
await expect(gatedBadges).toHaveCount(0);
});
test('should toggle to Review lane with single click', async () => {
// Click Review button
const reviewButton = page.locator('app-triage-lane-toggle button:has-text("Review")');
await reviewButton.click();
// Verify Review lane is now active
await expect(reviewButton).toHaveClass(/lane-toggle__btn--active/);
// Verify gated findings are now visible (if any exist)
const findingCards = page.locator('.finding-card');
const count = await findingCards.count();
if (count > 0) {
// All visible findings should be gated
const gatedCards = page.locator('.finding-card--gated');
await expect(gatedCards).toHaveCount(count);
}
});
test('should support Q keyboard shortcut for Quiet lane', async () => {
// First switch to Review
await page.locator('app-triage-lane-toggle button:has-text("Review")').click();
// Press Q key
await page.keyboard.press('q');
// Verify Quiet lane is active
const quietButton = page.locator('app-triage-lane-toggle button:has-text("Actionable")');
await expect(quietButton).toHaveClass(/lane-toggle__btn--active/);
});
test('should support R keyboard shortcut for Review lane', async () => {
// Press R key
await page.keyboard.press('r');
// Verify Review lane is active
const reviewButton = page.locator('app-triage-lane-toggle button:has-text("Review")');
await expect(reviewButton).toHaveClass(/lane-toggle__btn--active/);
});
});
test.describe('Gated Bucket Chips', () => {
test('should display bucket counts when on Review lane', async () => {
// Switch to Review lane
await page.locator('app-triage-lane-toggle button:has-text("Review")').click();
// Verify bucket chips are visible
const bucketChips = page.locator('app-gated-bucket-chips');
await expect(bucketChips).toBeVisible();
});
test('should filter by gating reason when chip is clicked', async () => {
// Switch to Review lane
await page.locator('app-triage-lane-toggle button:has-text("Review")').click();
// Click on a bucket chip (if available)
const unreachableChip = page.locator('.chip:has-text("Not Reachable")');
if (await unreachableChip.isVisible()) {
await unreachableChip.click();
// Verify filter is applied
const reasonFilter = page.locator('app-gating-reason-filter select');
await expect(reasonFilter).toHaveValue('Unreachable');
}
});
});
test.describe('Finding Selection and Breadcrumb', () => {
test('should display detail panel when finding is selected', async () => {
// Click on first finding
const firstFinding = page.locator('.finding-card').first();
await firstFinding.click();
// Verify detail panel is visible
const detailPanel = page.locator('.detail-panel');
await expect(detailPanel).toBeVisible();
});
test('should display provenance breadcrumb in detail panel', async () => {
// Select a finding
await page.locator('.finding-card').first().click();
// Verify breadcrumb is visible
const breadcrumb = page.locator('app-provenance-breadcrumb');
await expect(breadcrumb).toBeVisible();
});
test('should navigate breadcrumb levels on click', async () => {
// Select a finding
await page.locator('.finding-card').first().click();
// Click on layer level in breadcrumb
const layerLink = page.locator('.breadcrumb-item:has-text("layer")');
if (await layerLink.isVisible()) {
await layerLink.click();
// Verify navigation event (could trigger a modal or navigation)
// This depends on implementation
}
});
});
test.describe('Decision Drawer', () => {
test('should open decision drawer when Record Decision is clicked', async () => {
// Select a finding
await page.locator('.finding-card').first().click();
// Click Record Decision button
const recordButton = page.locator('button:has-text("Record Decision")');
await recordButton.click();
// Verify drawer is open
const drawer = page.locator('app-decision-drawer-enhanced.open, .decision-drawer.open');
await expect(drawer).toBeVisible();
});
test('should support A/N/U keyboard shortcuts in drawer', async () => {
// Select a finding and open drawer
await page.locator('.finding-card').first().click();
await page.locator('button:has-text("Record Decision")').click();
// Wait for drawer to be visible
await page.waitForSelector('.decision-drawer.open');
// Press 'N' for Not Affected
await page.keyboard.press('n');
// Verify Not Affected is selected
const notAffectedOption = page.locator('.radio-option:has-text("Not Affected")');
await expect(notAffectedOption).toHaveClass(/selected/);
});
test('should close drawer on Escape key', async () => {
// Open drawer
await page.locator('.finding-card').first().click();
await page.locator('button:has-text("Record Decision")').click();
await page.waitForSelector('.decision-drawer.open');
// Press Escape
await page.keyboard.press('Escape');
// Verify drawer is closed
const drawer = page.locator('.decision-drawer.open');
await expect(drawer).not.toBeVisible();
});
test('should show undo toast after submitting decision', async () => {
// This test requires mocking the API
// Skipping for now as it needs backend integration
test.skip();
});
});
test.describe('Evidence Export', () => {
test('should display export button in detail panel', async () => {
// Select a finding
await page.locator('.finding-card').first().click();
// Verify export button is visible
const exportButton = page.locator('app-export-evidence-button');
await expect(exportButton).toBeVisible();
});
test('should show progress indicator when export is triggered', async () => {
// Select a finding
await page.locator('.finding-card').first().click();
// Click export button
const exportButton = page.locator('app-export-evidence-button button');
if (await exportButton.isEnabled()) {
await exportButton.click();
// Verify progress indicator appears (may need API mock)
const progress = page.locator('.export-progress');
// Progress might appear briefly before completion
}
});
});
test.describe('Accessibility', () => {
test('should have proper ARIA labels on lane toggle', async () => {
const laneToggle = page.locator('app-triage-lane-toggle [role="tablist"]');
await expect(laneToggle).toHaveAttribute('aria-label', 'Triage lane selection');
const tabs = page.locator('app-triage-lane-toggle [role="tab"]');
const count = await tabs.count();
expect(count).toBe(2);
});
test('should support keyboard navigation in findings list', async () => {
// Focus on first finding
const firstFinding = page.locator('.finding-card').first();
await firstFinding.focus();
// Press Enter to select
await page.keyboard.press('Enter');
// Verify detail panel opens
const detailPanel = page.locator('.detail-panel');
await expect(detailPanel).toBeVisible();
});
test('should work in high contrast mode', async () => {
// Emulate high contrast mode
await page.emulateMedia({ colorScheme: 'dark' });
// Verify page still renders correctly
const pageContent = page.locator('.findings-page');
await expect(pageContent).toBeVisible();
// Verify critical elements have sufficient contrast
const severityBadge = page.locator('.severity-badge').first();
if (await severityBadge.isVisible()) {
// Badge should have border in high contrast mode
const styles = await severityBadge.evaluate(el =>
window.getComputedStyle(el).borderWidth
);
// This assertion depends on CSS implementation
}
});
});
test.describe('Performance', () => {
test('should render findings list within 2 seconds', async () => {
const startTime = Date.now();
await page.goto(`${BASE_URL}/triage/findings`);
await page.waitForSelector('.finding-card', { timeout: 2000 });
const renderTime = Date.now() - startTime;
expect(renderTime).toBeLessThan(2000);
});
test('should display skeleton UI while loading', async () => {
// This test requires slow network simulation
await page.route('**/api/v1/triage/**', async route => {
await new Promise(resolve => setTimeout(resolve, 500));
await route.continue();
});
await page.goto(`${BASE_URL}/triage/findings`);
// Skeleton should be visible during loading
const skeleton = page.locator('.skeleton, [class*="loading"]');
// Assertion depends on skeleton implementation
});
});
});
test.describe('Complete Triage Workflow', () => {
test('full workflow: view -> toggle -> select -> breadcrumb -> export', async ({ page }) => {
// 1. Navigate to findings
await page.goto(`${BASE_URL}/triage/findings`);
await page.waitForLoadState('networkidle');
// 2. Verify default is Quiet lane
const quietButton = page.locator('app-triage-lane-toggle button:has-text("Actionable")');
await expect(quietButton).toHaveClass(/lane-toggle__btn--active/);
// 3. Toggle to Review lane
const reviewButton = page.locator('app-triage-lane-toggle button:has-text("Review")');
await reviewButton.click();
await expect(reviewButton).toHaveClass(/lane-toggle__btn--active/);
// 4. Toggle back to Quiet lane
await quietButton.click();
await expect(quietButton).toHaveClass(/lane-toggle__btn--active/);
// 5. Select a finding
const firstFinding = page.locator('.finding-card').first();
if (await firstFinding.isVisible()) {
await firstFinding.click();
// 6. Verify breadcrumb is shown
const breadcrumb = page.locator('app-provenance-breadcrumb');
await expect(breadcrumb).toBeVisible();
// 7. Verify export button is available
const exportButton = page.locator('app-export-evidence-button');
await expect(exportButton).toBeVisible();
}
});
test('approval workflow: select -> open drawer -> submit -> verify toast', async ({ page }) => {
// Mock the approval API
await page.route('**/api/v1/scans/*/approvals', route => {
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'approval-123',
createdAt: new Date().toISOString(),
}),
});
});
await page.goto(`${BASE_URL}/triage/findings`);
await page.waitForLoadState('networkidle');
// Select a finding
const firstFinding = page.locator('.finding-card').first();
if (await firstFinding.isVisible()) {
await firstFinding.click();
// Open decision drawer
await page.locator('button:has-text("Record Decision")').click();
await page.waitForSelector('.decision-drawer.open');
// Select status
await page.keyboard.press('n'); // Not Affected
// Select reason
const reasonSelect = page.locator('.reason-select');
await reasonSelect.selectOption('vulnerable_code_not_present');
// Submit decision
const submitButton = page.locator('button:has-text("Sign & Apply")');
await submitButton.click();
// Verify undo toast appears
const undoToast = page.locator('.undo-toast');
await expect(undoToast).toBeVisible({ timeout: 5000 });
}
});
});

View File

@@ -0,0 +1,249 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { test, expect } from '@playwright/test';
test.describe('Timeline Feature', () => {
const mockTimelineResponse = {
correlationId: 'test-corr-001',
events: [
{
eventId: 'evt-001',
tHlc: '1704067200000:0:node1',
tsWall: '2024-01-01T12:00:00Z',
correlationId: 'test-corr-001',
service: 'Scheduler',
kind: 'EXECUTE',
payload: '{"jobId": "job-001"}',
payloadDigest: 'abc123',
engineVersion: { name: 'Test', version: '1.0.0', digest: 'def456' },
schemaVersion: 1,
},
{
eventId: 'evt-002',
tHlc: '1704067201000:0:node1',
tsWall: '2024-01-01T12:00:01Z',
correlationId: 'test-corr-001',
service: 'AirGap',
kind: 'IMPORT',
payload: '{}',
payloadDigest: 'ghi789',
engineVersion: { name: 'Test', version: '1.0.0', digest: 'jkl012' },
schemaVersion: 1,
},
],
totalCount: 2,
hasMore: false,
};
const mockCriticalPathResponse = {
correlationId: 'test-corr-001',
totalDurationMs: 5000,
stages: [
{
stage: 'ENQUEUE->EXECUTE',
service: 'Scheduler',
durationMs: 1000,
percentage: 20,
fromHlc: '1704067200000:0:node1',
toHlc: '1704067201000:0:node1',
},
],
};
test.beforeEach(async ({ page }) => {
// Mock API responses
await page.route('**/api/v1/timeline/test-corr-001', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTimelineResponse),
});
});
await page.route('**/api/v1/timeline/test-corr-001/critical-path', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockCriticalPathResponse),
});
});
});
test('should display timeline page', async ({ page }) => {
await page.goto('/timeline/test-corr-001');
// Check page title
await expect(page.locator('h1')).toContainText('Timeline');
// Check correlation ID is displayed
await expect(page.locator('.correlation-id')).toContainText('test-corr-001');
});
test('should display causal lanes with events', async ({ page }) => {
await page.goto('/timeline/test-corr-001');
// Wait for events to load
await expect(page.locator('.lane')).toHaveCount(2);
// Check lane names
await expect(page.locator('.lane-name').first()).toBeVisible();
});
test('should display critical path', async ({ page }) => {
await page.goto('/timeline/test-corr-001');
// Check critical path is rendered
await expect(page.locator('.critical-path-container')).toBeVisible();
await expect(page.locator('.stage-bar')).toHaveCount(1);
});
test('should select event and show details', async ({ page }) => {
await page.goto('/timeline/test-corr-001');
// Wait for events
await expect(page.locator('.event-marker').first()).toBeVisible();
// Click on an event
await page.locator('.event-marker').first().click();
// Check detail panel shows event info
await expect(page.locator('.event-detail-panel')).toBeVisible();
await expect(page.locator('.event-id code')).toContainText('evt-001');
});
test('should filter by service', async ({ page }) => {
await page.goto('/timeline/test-corr-001');
// Open service filter
await page.locator('mat-select').first().click();
// Select Scheduler
await page.locator('mat-option').filter({ hasText: 'Scheduler' }).click();
// Press escape to close
await page.keyboard.press('Escape');
// Check URL params updated
await expect(page).toHaveURL(/services=Scheduler/);
});
test('should support keyboard navigation', async ({ page }) => {
await page.goto('/timeline/test-corr-001');
// Wait for events
await expect(page.locator('.event-marker').first()).toBeVisible();
// Tab to first event
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Focus should be on event marker
const focused = page.locator('.event-marker:focus');
await expect(focused).toBeVisible();
// Press enter to select
await page.keyboard.press('Enter');
// Detail panel should show
await expect(page.locator('.event-detail-panel .event-id')).toBeVisible();
});
test('should export timeline', async ({ page }) => {
// Mock export endpoints
await page.route('**/api/v1/timeline/test-corr-001/export', async (route) => {
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify({
exportId: 'exp-001',
correlationId: 'test-corr-001',
format: 'ndjson',
signBundle: false,
status: 'INITIATED',
estimatedEventCount: 2,
}),
});
});
await page.route('**/api/v1/timeline/export/exp-001', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
exportId: 'exp-001',
status: 'COMPLETED',
format: 'ndjson',
eventCount: 2,
fileSizeBytes: 1024,
}),
});
});
await page.route('**/api/v1/timeline/export/exp-001/download', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/x-ndjson',
body: '{"event": "test"}\n',
});
});
await page.goto('/timeline/test-corr-001');
// Click export button
await page.locator('button:has-text("Export")').click();
// Select NDJSON option
await page.locator('button:has-text("NDJSON")').first().click();
// Wait for download (or snackbar)
await expect(page.locator('text=Export downloaded successfully')).toBeVisible({
timeout: 10000,
});
});
test('should handle empty state', async ({ page }) => {
await page.goto('/timeline');
// Should show empty state
await expect(page.locator('.empty-state')).toBeVisible();
await expect(page.locator('.empty-state p')).toContainText(
'Enter a correlation ID'
);
});
test('should handle error state', async ({ page }) => {
// Mock error response
await page.route('**/api/v1/timeline/error-test', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
});
});
await page.goto('/timeline/error-test');
// Should show error state
await expect(page.locator('.error-state')).toBeVisible();
});
test('should be accessible', async ({ page }) => {
await page.goto('/timeline/test-corr-001');
// Check ARIA labels
await expect(page.locator('[role="main"]')).toHaveAttribute(
'aria-label',
'Event Timeline'
);
await expect(page.locator('[role="region"]').first()).toBeVisible();
// Check focus indicators
await page.keyboard.press('Tab');
const focusedElement = page.locator(':focus');
await expect(focusedElement).toBeVisible();
});
});

View File

@@ -0,0 +1,307 @@
// -----------------------------------------------------------------------------
// determinization.models.ts
// Sprint: SPRINT_20260106_001_005_FE_determinization_ui
// Task: DFE-001 - Create TypeScript interfaces for determinization
// Description: TypeScript models for determinization UI
// -----------------------------------------------------------------------------
/**
* Observation state for a CVE finding.
*/
export enum ObservationState {
PendingDeterminization = 'PendingDeterminization',
Determined = 'Determined',
Disputed = 'Disputed',
StaleRequiresRefresh = 'StaleRequiresRefresh',
ManualReviewRequired = 'ManualReviewRequired',
Suppressed = 'Suppressed'
}
/**
* Uncertainty tier based on signal completeness.
*/
export enum UncertaintyTier {
VeryLow = 'VeryLow',
Low = 'Low',
Medium = 'Medium',
High = 'High',
VeryHigh = 'VeryHigh'
}
/**
* Policy verdict status for guardrails.
*/
export enum PolicyVerdictStatus {
Pass = 'Pass',
GuardedPass = 'GuardedPass',
Blocked = 'Blocked',
Ignored = 'Ignored',
Warned = 'Warned',
Deferred = 'Deferred',
Escalated = 'Escalated',
RequiresVex = 'RequiresVex'
}
/**
* CVE observation from the API.
*/
export interface CveObservation {
readonly nodeId: string;
readonly cveId: string;
readonly product: string;
readonly tenantId: string;
readonly state: ObservationState;
readonly uncertaintyScore: number;
readonly uncertaintyTier: UncertaintyTier;
readonly signals: SignalsSummary;
readonly decay: DecayInfo;
readonly guardrails?: GuardrailsInfo;
readonly createdAt: string;
readonly updatedAt: string;
readonly nextReviewAt?: string;
}
/**
* Summary of signals for an observation.
*/
export interface SignalsSummary {
readonly epss?: EpssSignal;
readonly kev?: KevSignal;
readonly vex?: VexSignal;
readonly reachability?: ReachabilitySignal;
readonly missingSignals: string[];
readonly completeness: number; // 0-100
}
/**
* EPSS signal data.
*/
export interface EpssSignal {
readonly score: number;
readonly percentile: number;
readonly capturedAt: string;
}
/**
* KEV signal data.
*/
export interface KevSignal {
readonly isInKev: boolean;
readonly capturedAt: string;
}
/**
* VEX signal data.
*/
export interface VexSignal {
readonly status: string;
readonly justification?: string;
readonly source: string;
readonly capturedAt: string;
}
/**
* Reachability signal data.
*/
export interface ReachabilitySignal {
readonly status: string;
readonly isReachable: boolean;
readonly capturedAt: string;
}
/**
* Decay/freshness information.
*/
export interface DecayInfo {
readonly ageHours: number;
readonly freshnessPercent: number;
readonly isStale: boolean;
readonly staleSince?: string;
readonly expiresAt?: string;
}
/**
* Guardrails information.
*/
export interface GuardrailsInfo {
readonly status: PolicyVerdictStatus;
readonly activeGuardrails: GuardrailDetail[];
readonly blockedBy?: string;
readonly expiresAt?: string;
}
/**
* Individual guardrail detail.
*/
export interface GuardrailDetail {
readonly guardrailId: string;
readonly name: string;
readonly type: string;
readonly condition: string;
readonly isActive: boolean;
}
/**
* State transition history entry.
*/
export interface StateTransition {
readonly transitionId: string;
readonly fromState: ObservationState | null;
readonly toState: ObservationState;
readonly reason: string;
readonly triggeredBy: string;
readonly transitionedAt: string;
readonly signalSnapshot?: SignalsSummary;
}
/**
* Review queue item.
*/
export interface ReviewQueueItem {
readonly observation: CveObservation;
readonly queuedAt: string;
readonly priority: number;
readonly reason: string;
readonly assignedTo?: string;
}
/**
* Page of review queue items.
*/
export interface ReviewQueuePage {
readonly items: ReviewQueueItem[];
readonly totalCount: number;
readonly pageIndex: number;
readonly pageSize: number;
}
/**
* Observation state display info.
*/
export interface ObservationStateDisplay {
readonly label: string;
readonly icon: string;
readonly color: string;
readonly description: string;
}
/**
* Mapping of observation states to display info.
*/
export const OBSERVATION_STATE_DISPLAY: Record<ObservationState, ObservationStateDisplay> = {
[ObservationState.PendingDeterminization]: {
label: 'Unknown (auto-tracking)',
icon: 'schedule',
color: 'warning',
description: 'Awaiting signal collection and analysis'
},
[ObservationState.Determined]: {
label: 'Determined',
icon: 'check_circle',
color: 'success',
description: 'All signals collected and analyzed'
},
[ObservationState.Disputed]: {
label: 'Disputed',
icon: 'warning',
color: 'error',
description: 'Conflicting signals require manual review'
},
[ObservationState.StaleRequiresRefresh]: {
label: 'Stale',
icon: 'update',
color: 'warning',
description: 'Signals are outdated and need refresh'
},
[ObservationState.ManualReviewRequired]: {
label: 'Needs Review',
icon: 'rate_review',
color: 'error',
description: 'Manual review required before decision'
},
[ObservationState.Suppressed]: {
label: 'Suppressed',
icon: 'visibility_off',
color: 'muted',
description: 'Observation is suppressed from tracking'
}
};
/**
* Uncertainty tier display info.
*/
export interface UncertaintyTierDisplay {
readonly label: string;
readonly color: string;
readonly minScore: number;
readonly maxScore: number;
}
/**
* Mapping of uncertainty tiers to display info.
*/
export const UNCERTAINTY_TIER_DISPLAY: Record<UncertaintyTier, UncertaintyTierDisplay> = {
[UncertaintyTier.VeryLow]: {
label: 'Very Low',
color: 'success',
minScore: 0,
maxScore: 0.2
},
[UncertaintyTier.Low]: {
label: 'Low',
color: 'success-light',
minScore: 0.2,
maxScore: 0.4
},
[UncertaintyTier.Medium]: {
label: 'Medium',
color: 'warning',
minScore: 0.4,
maxScore: 0.6
},
[UncertaintyTier.High]: {
label: 'High',
color: 'warning-dark',
minScore: 0.6,
maxScore: 0.8
},
[UncertaintyTier.VeryHigh]: {
label: 'Very High',
color: 'error',
minScore: 0.8,
maxScore: 1.0
}
};
/**
* Helper to get uncertainty tier from score.
*/
export function getUncertaintyTier(score: number): UncertaintyTier {
if (score < 0.2) return UncertaintyTier.VeryLow;
if (score < 0.4) return UncertaintyTier.Low;
if (score < 0.6) return UncertaintyTier.Medium;
if (score < 0.8) return UncertaintyTier.High;
return UncertaintyTier.VeryHigh;
}
/**
* Helper to format review ETA.
*/
export function formatReviewEta(nextReviewAt: string | undefined): string {
if (!nextReviewAt) return 'No scheduled review';
const reviewDate = new Date(nextReviewAt);
const now = new Date();
const diffMs = reviewDate.getTime() - now.getTime();
if (diffMs < 0) return 'Review overdue';
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `in ${diffDays}d`;
if (diffHours > 0) return `in ${diffHours}h`;
const diffMinutes = Math.floor(diffMs / (1000 * 60));
return `in ${diffMinutes}m`;
}

View File

@@ -0,0 +1,246 @@
// -----------------------------------------------------------------------------
// determinization.service.spec.ts
// Sprint: SPRINT_20260106_001_005_FE_determinization_ui
// Task: DFE-017 - Unit tests for DeterminizationService
// Description: Angular tests for determinization service
// -----------------------------------------------------------------------------
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { DeterminizationService } from './determinization.service';
import { ObservationState, CveObservation } from '../../models/determinization.models';
describe('DeterminizationService', () => {
let service: DeterminizationService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [DeterminizationService]
});
service = TestBed.inject(DeterminizationService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
describe('getObservation', () => {
it('should fetch observation by ID', () => {
const mockObservation = createMockObservation('node-1');
service.getObservation('node-1').subscribe(obs => {
expect(obs.nodeId).toBe('node-1');
});
const req = httpMock.expectOne('/api/v1/determinization/observations/node-1');
expect(req.request.method).toBe('GET');
req.flush(mockObservation);
});
});
describe('getObservationsByCve', () => {
it('should fetch observations for a CVE', () => {
const mockObservations = [createMockObservation('node-1')];
service.getObservationsByCve('CVE-2024-1234').subscribe(obs => {
expect(obs.length).toBe(1);
});
const req = httpMock.expectOne('/api/v1/determinization/observations?cveId=CVE-2024-1234');
expect(req.request.method).toBe('GET');
req.flush(mockObservations);
});
});
describe('getObservationsByState', () => {
it('should fetch observations by state with pagination', () => {
const mockObservations = [createMockObservation('node-1')];
service.getObservationsByState(ObservationState.PendingDeterminization, 50, 10).subscribe(obs => {
expect(obs.length).toBe(1);
});
const req = httpMock.expectOne(
'/api/v1/determinization/observations?state=PendingDeterminization&limit=50&offset=10'
);
expect(req.request.method).toBe('GET');
req.flush(mockObservations);
});
});
describe('getStateHistory', () => {
it('should fetch state transition history', () => {
const mockHistory = [
{
transitionId: 'trans-1',
fromState: null,
toState: ObservationState.PendingDeterminization,
reason: 'Initial observation',
triggeredBy: 'system',
transitionedAt: new Date().toISOString()
}
];
service.getStateHistory('node-1').subscribe(history => {
expect(history.length).toBe(1);
expect(history[0].toState).toBe(ObservationState.PendingDeterminization);
});
const req = httpMock.expectOne('/api/v1/determinization/observations/node-1/history');
expect(req.request.method).toBe('GET');
req.flush(mockHistory);
});
});
describe('refreshSignals', () => {
it('should send refresh request', () => {
const mockResponse = {
success: true,
observation: createMockObservation('node-1')
};
service.refreshSignals({ observationId: 'node-1', forceRefresh: true }).subscribe(res => {
expect(res.success).toBe(true);
});
const req = httpMock.expectOne('/api/v1/determinization/observations/node-1/refresh');
expect(req.request.method).toBe('POST');
expect(req.request.body.forceRefresh).toBe(true);
req.flush(mockResponse);
});
});
describe('updateState', () => {
it('should send state update request', () => {
const mockResponse = {
success: true,
observation: createMockObservation('node-1')
};
service.updateState({
observationId: 'node-1',
newState: ObservationState.ManualReviewRequired,
reason: 'Test reason'
}).subscribe(res => {
expect(res.success).toBe(true);
});
const req = httpMock.expectOne('/api/v1/determinization/observations/node-1/state');
expect(req.request.method).toBe('PATCH');
expect(req.request.body.newState).toBe(ObservationState.ManualReviewRequired);
expect(req.request.body.reason).toBe('Test reason');
req.flush(mockResponse);
});
});
describe('suppressObservation', () => {
it('should suppress observation', () => {
const mockResponse = {
success: true,
observation: {
...createMockObservation('node-1'),
state: ObservationState.Suppressed
}
};
service.suppressObservation('node-1', 'False positive').subscribe(res => {
expect(res.observation.state).toBe(ObservationState.Suppressed);
});
const req = httpMock.expectOne('/api/v1/determinization/observations/node-1/state');
expect(req.request.method).toBe('PATCH');
expect(req.request.body.newState).toBe(ObservationState.Suppressed);
req.flush(mockResponse);
});
});
describe('getReviewQueue', () => {
it('should fetch review queue with pagination', () => {
const mockPage = {
items: [],
totalCount: 0,
pageIndex: 0,
pageSize: 25
};
service.getReviewQueue(0, 25, 'priority', 'desc').subscribe(page => {
expect(page.pageSize).toBe(25);
});
const req = httpMock.expectOne(
'/api/v1/determinization/review-queue?pageIndex=0&pageSize=25&sortBy=priority&sortDirection=desc'
);
expect(req.request.method).toBe('GET');
req.flush(mockPage);
});
});
describe('getStateCounts', () => {
it('should fetch state counts', () => {
const mockCounts = {
[ObservationState.PendingDeterminization]: 10,
[ObservationState.Determined]: 50,
[ObservationState.ManualReviewRequired]: 5
};
service.getStateCounts().subscribe(counts => {
expect(counts[ObservationState.PendingDeterminization]).toBe(10);
});
const req = httpMock.expectOne('/api/v1/determinization/stats/by-state');
expect(req.request.method).toBe('GET');
req.flush(mockCounts);
});
});
describe('getPendingCount', () => {
it('should return pending count', () => {
const mockCounts = {
[ObservationState.PendingDeterminization]: 15
};
service.getPendingCount().subscribe(count => {
expect(count).toBe(15);
});
const req = httpMock.expectOne('/api/v1/determinization/stats/by-state');
req.flush(mockCounts);
});
it('should return 0 when no pending', () => {
service.getPendingCount().subscribe(count => {
expect(count).toBe(0);
});
const req = httpMock.expectOne('/api/v1/determinization/stats/by-state');
req.flush({});
});
});
function createMockObservation(nodeId: string): CveObservation {
return {
nodeId,
cveId: 'CVE-2024-1234',
product: 'test-product',
tenantId: 'tenant-1',
state: ObservationState.PendingDeterminization,
uncertaintyScore: 0.5,
uncertaintyTier: 'Medium' as any,
signals: {
completeness: 50,
missingSignals: ['vex', 'reachability']
},
decay: {
ageHours: 2,
freshnessPercent: 90,
isStale: false
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
}
});

View File

@@ -0,0 +1,202 @@
// -----------------------------------------------------------------------------
// determinization.service.ts
// Sprint: SPRINT_20260106_001_005_FE_determinization_ui
// Task: DFE-002 - Create DeterminizationService with API methods
// Description: Angular service for determinization API
// -----------------------------------------------------------------------------
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import {
CveObservation,
ReviewQueuePage,
StateTransition,
ObservationState
} from '../../models/determinization.models';
/**
* Request to refresh observation signals.
*/
export interface RefreshSignalsRequest {
readonly observationId: string;
readonly forceRefresh?: boolean;
}
/**
* Request to update observation state.
*/
export interface UpdateStateRequest {
readonly observationId: string;
readonly newState: ObservationState;
readonly reason: string;
}
/**
* Response for observation updates.
*/
export interface ObservationUpdateResponse {
readonly success: boolean;
readonly observation: CveObservation;
readonly transition?: StateTransition;
}
/**
* Service for determinization API operations.
*/
@Injectable({
providedIn: 'root'
})
export class DeterminizationService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/determinization';
/**
* Get observation by ID.
*/
getObservation(nodeId: string): Observable<CveObservation> {
return this.http.get<CveObservation>(`${this.baseUrl}/observations/${nodeId}`);
}
/**
* Get observations for a CVE.
*/
getObservationsByCve(cveId: string): Observable<CveObservation[]> {
return this.http.get<CveObservation[]>(`${this.baseUrl}/observations`, {
params: new HttpParams().set('cveId', cveId)
});
}
/**
* Get observations for a product.
*/
getObservationsByProduct(product: string): Observable<CveObservation[]> {
return this.http.get<CveObservation[]>(`${this.baseUrl}/observations`, {
params: new HttpParams().set('product', product)
});
}
/**
* Get observations by state.
*/
getObservationsByState(
state: ObservationState,
limit = 100,
offset = 0
): Observable<CveObservation[]> {
return this.http.get<CveObservation[]>(`${this.baseUrl}/observations`, {
params: new HttpParams()
.set('state', state)
.set('limit', limit.toString())
.set('offset', offset.toString())
});
}
/**
* Get state transition history for an observation.
*/
getStateHistory(nodeId: string): Observable<StateTransition[]> {
return this.http.get<StateTransition[]>(
`${this.baseUrl}/observations/${nodeId}/history`
);
}
/**
* Refresh signals for an observation.
*/
refreshSignals(request: RefreshSignalsRequest): Observable<ObservationUpdateResponse> {
return this.http.post<ObservationUpdateResponse>(
`${this.baseUrl}/observations/${request.observationId}/refresh`,
{ forceRefresh: request.forceRefresh ?? false }
);
}
/**
* Update observation state.
*/
updateState(request: UpdateStateRequest): Observable<ObservationUpdateResponse> {
return this.http.patch<ObservationUpdateResponse>(
`${this.baseUrl}/observations/${request.observationId}/state`,
{
newState: request.newState,
reason: request.reason
}
);
}
/**
* Suppress an observation.
*/
suppressObservation(nodeId: string, reason: string): Observable<ObservationUpdateResponse> {
return this.updateState({
observationId: nodeId,
newState: ObservationState.Suppressed,
reason
});
}
/**
* Request manual review for an observation.
*/
requestReview(nodeId: string, reason: string): Observable<ObservationUpdateResponse> {
return this.updateState({
observationId: nodeId,
newState: ObservationState.ManualReviewRequired,
reason
});
}
/**
* Get review queue.
*/
getReviewQueue(
pageIndex = 0,
pageSize = 25,
sortBy = 'priority',
sortDirection: 'asc' | 'desc' = 'desc'
): Observable<ReviewQueuePage> {
return this.http.get<ReviewQueuePage>(`${this.baseUrl}/review-queue`, {
params: new HttpParams()
.set('pageIndex', pageIndex.toString())
.set('pageSize', pageSize.toString())
.set('sortBy', sortBy)
.set('sortDirection', sortDirection)
});
}
/**
* Get counts by state for dashboard.
*/
getStateCounts(): Observable<Record<ObservationState, number>> {
return this.http.get<Record<ObservationState, number>>(
`${this.baseUrl}/stats/by-state`
);
}
/**
* Get count of pending observations.
*/
getPendingCount(): Observable<number> {
return this.getStateCounts().pipe(
map(counts => counts[ObservationState.PendingDeterminization] ?? 0)
);
}
/**
* Get count of observations needing review.
*/
getReviewRequiredCount(): Observable<number> {
return this.getStateCounts().pipe(
map(counts => counts[ObservationState.ManualReviewRequired] ?? 0)
);
}
/**
* Get stale observations count.
*/
getStaleCount(): Observable<number> {
return this.getStateCounts().pipe(
map(counts => counts[ObservationState.StaleRequiresRefresh] ?? 0)
);
}
}

View File

@@ -0,0 +1,45 @@
<div class="causal-lanes-container" #container role="region" aria-label="Timeline causal lanes">
<!-- Time axis -->
<div class="time-axis" role="presentation">
<span class="axis-label">HLC Timeline</span>
<div class="axis-line"></div>
</div>
<!-- Swimlanes -->
<div class="lanes">
@for (lane of lanes; track lane.service) {
<div class="lane" [style.borderLeftColor]="lane.config.color">
<div class="lane-header">
<mat-icon [style.color]="lane.config.color">{{ lane.config.icon }}</mat-icon>
<span class="lane-name">{{ lane.service }}</span>
</div>
<div class="lane-events">
@for (event of lane.events; track event.eventId) {
<button
class="event-marker"
[class.selected]="isSelected(event)"
[style.left.px]="getEventPosition(event)"
[style.backgroundColor]="getEventKindConfig(event.kind).color"
[matTooltip]="formatTooltip(event)"
(click)="onEventClick(event)"
(keydown)="onKeyDown($event, event)"
[attr.aria-label]="formatTooltip(event)"
[attr.aria-selected]="isSelected(event)"
role="option"
>
<mat-icon>{{ getEventKindConfig(event.kind).icon }}</mat-icon>
</button>
}
</div>
</div>
}
</div>
<!-- Empty state -->
@if (lanes.length === 0) {
<div class="empty-state">
<mat-icon>timeline</mat-icon>
<p>No events to display</p>
</div>
}
</div>

View File

@@ -0,0 +1,167 @@
.causal-lanes-container {
display: flex;
flex-direction: column;
width: 100%;
min-height: 200px;
background: var(--surface-container, #f5f5f5);
border-radius: 8px;
overflow: hidden;
}
.time-axis {
display: flex;
align-items: center;
padding: 8px 16px;
background: var(--surface-variant, #e0e0e0);
border-bottom: 1px solid var(--outline-variant, #ccc);
.axis-label {
font-size: 12px;
font-weight: 500;
color: var(--on-surface-variant, #666);
margin-right: 16px;
min-width: 100px;
}
.axis-line {
flex: 1;
height: 2px;
background: linear-gradient(
to right,
var(--primary, #4285f4) 0%,
var(--primary, #4285f4) 100%
);
position: relative;
&::before,
&::after {
content: '';
position: absolute;
top: -4px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--primary, #4285f4);
}
&::before {
left: 0;
}
&::after {
right: 0;
}
}
}
.lanes {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.lane {
display: flex;
align-items: stretch;
min-height: 60px;
border-left: 4px solid transparent;
border-bottom: 1px solid var(--outline-variant, #e0e0e0);
&:last-child {
border-bottom: none;
}
}
.lane-header {
display: flex;
align-items: center;
gap: 8px;
width: 140px;
min-width: 140px;
padding: 8px 12px;
background: var(--surface, #fff);
border-right: 1px solid var(--outline-variant, #e0e0e0);
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
.lane-name {
font-size: 13px;
font-weight: 500;
color: var(--on-surface, #333);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.lane-events {
position: relative;
flex: 1;
background: var(--surface, #fff);
padding: 8px 16px;
}
.event-marker {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: white;
}
&:hover {
transform: translate(-50%, -50%) scale(1.2);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
&:focus {
outline: 2px solid var(--primary, #4285f4);
outline-offset: 2px;
}
&.selected {
border-color: var(--primary, #4285f4);
transform: translate(-50%, -50%) scale(1.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--on-surface-variant, #666);
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
p {
font-size: 14px;
margin: 0;
}
}

View File

@@ -0,0 +1,105 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CausalLanesComponent } from './causal-lanes.component';
import { TimelineEvent } from '../../models/timeline.models';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('CausalLanesComponent', () => {
let component: CausalLanesComponent;
let fixture: ComponentFixture<CausalLanesComponent>;
const mockEvents: TimelineEvent[] = [
{
eventId: 'evt-001',
tHlc: '1704067200000:0:node1',
tsWall: '2024-01-01T12:00:00Z',
correlationId: 'test-corr',
service: 'Scheduler',
kind: 'EXECUTE',
payload: '{}',
payloadDigest: 'abc',
engineVersion: { name: 'Test', version: '1.0', digest: 'def' },
schemaVersion: 1,
},
{
eventId: 'evt-002',
tHlc: '1704067201000:0:node1',
tsWall: '2024-01-01T12:00:01Z',
correlationId: 'test-corr',
service: 'AirGap',
kind: 'IMPORT',
payload: '{}',
payloadDigest: 'ghi',
engineVersion: { name: 'Test', version: '1.0', digest: 'jkl' },
schemaVersion: 1,
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CausalLanesComponent, NoopAnimationsModule],
}).compileComponents();
fixture = TestBed.createComponent(CausalLanesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should build lanes from events', () => {
component.events = mockEvents;
component.ngOnChanges({
events: {
currentValue: mockEvents,
previousValue: [],
firstChange: true,
isFirstChange: () => true,
},
});
expect(component.lanes.length).toBe(2);
expect(component.lanes.map((l) => l.service).sort()).toEqual([
'AirGap',
'Scheduler',
]);
});
it('should emit event on selection', () => {
const spy = jest.spyOn(component.eventSelected, 'emit');
component.onEventClick(mockEvents[0]);
expect(spy).toHaveBeenCalledWith(mockEvents[0]);
});
it('should identify selected event', () => {
component.selectedEventId = 'evt-001';
expect(component.isSelected(mockEvents[0])).toBe(true);
expect(component.isSelected(mockEvents[1])).toBe(false);
});
it('should get correct lane config for known service', () => {
const config = component.getLaneConfig('Scheduler');
expect(config.name).toBe('Scheduler');
expect(config.color).toBeDefined();
});
it('should get fallback config for unknown service', () => {
const config = component.getLaneConfig('UnknownService');
expect(config.name).toBe('UnknownService');
expect(config.color).toBe('#757575');
});
it('should handle keyboard navigation', () => {
const spy = jest.spyOn(component.eventSelected, 'emit');
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
component.onKeyDown(enterEvent, mockEvents[0]);
expect(spy).toHaveBeenCalledWith(mockEvents[0]);
});
});

View File

@@ -0,0 +1,175 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import {
Component,
Input,
Output,
EventEmitter,
OnChanges,
SimpleChanges,
ElementRef,
ViewChild,
AfterViewInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ScrollingModule } from '@angular/cdk/scrolling';
import {
TimelineEvent,
SERVICE_CONFIGS,
EVENT_KIND_CONFIGS,
ServiceConfig,
EventKindConfig,
} from '../../models/timeline.models';
/**
* Causal lanes visualization component.
* Displays events organized by service in swimlanes.
*/
@Component({
selector: 'app-causal-lanes',
standalone: true,
imports: [CommonModule, MatIconModule, MatTooltipModule, ScrollingModule],
templateUrl: './causal-lanes.component.html',
styleUrls: ['./causal-lanes.component.scss'],
})
export class CausalLanesComponent implements OnChanges, AfterViewInit {
@Input() events: TimelineEvent[] = [];
@Input() selectedEventId: string | null = null;
@Output() eventSelected = new EventEmitter<TimelineEvent>();
@ViewChild('container') containerRef!: ElementRef<HTMLDivElement>;
lanes: Lane[] = [];
timeRange: TimeRange = { start: 0, end: 0 };
pixelsPerMs = 0.1;
private readonly laneHeight = 60;
private readonly eventWidth = 32;
private readonly minTimeWidth = 1000;
ngOnChanges(changes: SimpleChanges): void {
if (changes['events']) {
this.buildLanes();
}
}
ngAfterViewInit(): void {
this.updatePixelScale();
}
onEventClick(event: TimelineEvent): void {
this.eventSelected.emit(event);
}
onKeyDown(event: KeyboardEvent, timelineEvent: TimelineEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.eventSelected.emit(timelineEvent);
}
}
getEventPosition(event: TimelineEvent): number {
const eventTime = this.parseHlcWall(event.tsWall);
return (eventTime - this.timeRange.start) * this.pixelsPerMs;
}
getLaneConfig(serviceName: string): ServiceConfig {
return (
SERVICE_CONFIGS.find((c) => c.name === serviceName) ?? {
name: serviceName,
color: '#757575',
icon: 'circle',
}
);
}
getEventKindConfig(kind: string): EventKindConfig {
return (
EVENT_KIND_CONFIGS.find((c) => c.kind === kind) ?? {
kind,
label: kind,
icon: 'circle',
color: '#757575',
}
);
}
isSelected(event: TimelineEvent): boolean {
return this.selectedEventId === event.eventId;
}
formatTooltip(event: TimelineEvent): string {
const time = new Date(event.tsWall).toLocaleTimeString();
return `${event.kind} at ${time}`;
}
private buildLanes(): void {
if (!this.events.length) {
this.lanes = [];
return;
}
// Group events by service
const serviceMap = new Map<string, TimelineEvent[]>();
for (const event of this.events) {
const existing = serviceMap.get(event.service) ?? [];
existing.push(event);
serviceMap.set(event.service, existing);
}
// Build lanes
this.lanes = Array.from(serviceMap.entries())
.map(([service, events]) => ({
service,
events: events.sort((a, b) => a.tHlc.localeCompare(b.tHlc)),
config: this.getLaneConfig(service),
}))
.sort((a, b) => a.service.localeCompare(b.service));
// Calculate time range
const times = this.events.map((e) => this.parseHlcWall(e.tsWall));
this.timeRange = {
start: Math.min(...times),
end: Math.max(...times),
};
this.updatePixelScale();
}
private parseHlcWall(tsWall: string): number {
return new Date(tsWall).getTime();
}
private updatePixelScale(): void {
if (!this.containerRef) {
return;
}
const containerWidth = this.containerRef.nativeElement.clientWidth || 800;
const timeSpan = Math.max(
this.timeRange.end - this.timeRange.start,
this.minTimeWidth
);
// Leave room for margins
const availableWidth = containerWidth - 200;
this.pixelsPerMs = availableWidth / timeSpan;
}
}
interface Lane {
service: string;
events: TimelineEvent[];
config: ServiceConfig;
}
interface TimeRange {
start: number;
end: number;
}

View File

@@ -0,0 +1,47 @@
<div class="critical-path-container" role="img" aria-label="Critical path analysis">
@if (criticalPath) {
<div class="header">
<h4>Critical Path</h4>
<span class="total-duration">
Total: {{ formatDuration(criticalPath.totalDurationMs) }}
</span>
</div>
<div class="bar-chart">
@for (stage of criticalPath.stages; track stage.stage) {
<div
class="stage-bar"
[class.bottleneck]="isBottleneck(stage)"
[style.width.%]="getStageWidth(stage)"
[style.backgroundColor]="getStageColor(stage)"
[matTooltip]="formatTooltip(stage)"
matTooltipPosition="above"
role="presentation"
>
@if (stage.percentage >= 15) {
<span class="stage-label">{{ stage.stage }}</span>
<span class="stage-duration">{{ formatDuration(stage.durationMs) }}</span>
}
</div>
}
</div>
<div class="legend">
@for (stage of criticalPath.stages; track stage.stage) {
<div class="legend-item" [class.bottleneck]="isBottleneck(stage)">
<span
class="legend-color"
[style.backgroundColor]="getStageColor(stage)"
></span>
<span class="legend-label">{{ stage.stage }}</span>
<span class="legend-value">{{ stage.percentage.toFixed(1) }}%</span>
</div>
}
</div>
} @else {
<div class="empty-state">
<mat-icon>hourglass_empty</mat-icon>
<p>No critical path data</p>
</div>
}
</div>

View File

@@ -0,0 +1,129 @@
.critical-path-container {
padding: 16px;
background: var(--surface, #fff);
border-radius: 8px;
border: 1px solid var(--outline-variant, #e0e0e0);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--on-surface, #333);
}
.total-duration {
font-size: 13px;
color: var(--on-surface-variant, #666);
font-weight: 500;
}
}
.bar-chart {
display: flex;
height: 40px;
border-radius: 4px;
overflow: hidden;
background: var(--surface-container, #f5f5f5);
}
.stage-bar {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 4px;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
&:hover {
filter: brightness(1.1);
}
&.bottleneck {
box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.2);
}
.stage-label {
font-size: 10px;
font-weight: 600;
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 4px;
}
.stage-duration {
font-size: 9px;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
background: var(--surface-container, #f5f5f5);
&.bottleneck {
background: #FEE2E2;
font-weight: 600;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
.legend-label {
color: var(--on-surface, #333);
}
.legend-value {
color: var(--on-surface-variant, #666);
font-weight: 500;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
color: var(--on-surface-variant, #666);
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
margin-bottom: 8px;
opacity: 0.5;
}
p {
font-size: 13px;
margin: 0;
}
}

View File

@@ -0,0 +1,99 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CriticalPathComponent } from './critical-path.component';
import { CriticalPathResponse } from '../../models/timeline.models';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('CriticalPathComponent', () => {
let component: CriticalPathComponent;
let fixture: ComponentFixture<CriticalPathComponent>;
const mockCriticalPath: CriticalPathResponse = {
correlationId: 'test-corr',
totalDurationMs: 5000,
stages: [
{
stage: 'ENQUEUE->EXECUTE',
service: 'Scheduler',
durationMs: 1000,
percentage: 20,
fromHlc: '1704067200000:0:node1',
toHlc: '1704067201000:0:node1',
},
{
stage: 'EXECUTE->ATTEST',
service: 'Attestor',
durationMs: 3500,
percentage: 70,
fromHlc: '1704067201000:0:node1',
toHlc: '1704067204500:0:node1',
},
{
stage: 'ATTEST->COMPLETE',
service: 'Policy',
durationMs: 500,
percentage: 10,
fromHlc: '1704067204500:0:node1',
toHlc: '1704067205000:0:node1',
},
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CriticalPathComponent, NoopAnimationsModule],
}).compileComponents();
fixture = TestBed.createComponent(CriticalPathComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should return correct stage width', () => {
component.criticalPath = mockCriticalPath;
expect(component.getStageWidth(mockCriticalPath.stages[0])).toBe(20);
expect(component.getStageWidth(mockCriticalPath.stages[1])).toBe(70);
});
it('should identify bottleneck stage', () => {
component.criticalPath = mockCriticalPath;
expect(component.isBottleneck(mockCriticalPath.stages[0])).toBe(false);
expect(component.isBottleneck(mockCriticalPath.stages[1])).toBe(true);
expect(component.isBottleneck(mockCriticalPath.stages[2])).toBe(false);
});
it('should color stages by severity', () => {
component.criticalPath = mockCriticalPath;
// 70% - red (bottleneck)
expect(component.getStageColor(mockCriticalPath.stages[1])).toBe('#EA4335');
// 20% - green (normal)
expect(component.getStageColor(mockCriticalPath.stages[0])).toBe('#34A853');
// 10% - green (normal)
expect(component.getStageColor(mockCriticalPath.stages[2])).toBe('#34A853');
});
it('should format durations correctly', () => {
expect(component.formatDuration(500)).toBe('500ms');
expect(component.formatDuration(1500)).toBe('1.5s');
expect(component.formatDuration(90000)).toBe('1.5m');
});
it('should format tooltip with all details', () => {
const tooltip = component.formatTooltip(mockCriticalPath.stages[0]);
expect(tooltip).toContain('ENQUEUE->EXECUTE');
expect(tooltip).toContain('Scheduler');
expect(tooltip).toContain('1.0s');
expect(tooltip).toContain('20.0%');
});
});

View File

@@ -0,0 +1,64 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CriticalPathResponse, CriticalPathStage } from '../../models/timeline.models';
/**
* Critical path visualization component.
* Displays bottleneck stages as a horizontal bar chart.
*/
@Component({
selector: 'app-critical-path',
standalone: true,
imports: [CommonModule, MatIconModule, MatTooltipModule],
templateUrl: './critical-path.component.html',
styleUrls: ['./critical-path.component.scss'],
})
export class CriticalPathComponent {
@Input() criticalPath: CriticalPathResponse | null = null;
getStageWidth(stage: CriticalPathStage): number {
return stage.percentage || 0;
}
getStageColor(stage: CriticalPathStage): string {
const percentage = stage.percentage || 0;
if (percentage > 50) {
return '#EA4335'; // Red - bottleneck
} else if (percentage > 25) {
return '#FBBC04'; // Yellow - warning
} else {
return '#34A853'; // Green - normal
}
}
isBottleneck(stage: CriticalPathStage): boolean {
if (!this.criticalPath?.stages.length) {
return false;
}
const maxPercentage = Math.max(...this.criticalPath.stages.map((s) => s.percentage));
return stage.percentage === maxPercentage;
}
formatDuration(durationMs: number): string {
if (durationMs < 1000) {
return `${Math.round(durationMs)}ms`;
} else if (durationMs < 60000) {
return `${(durationMs / 1000).toFixed(1)}s`;
} else {
return `${(durationMs / 60000).toFixed(1)}m`;
}
}
formatTooltip(stage: CriticalPathStage): string {
return `${stage.stage}\nService: ${stage.service}\nDuration: ${this.formatDuration(stage.durationMs)}\n${stage.percentage.toFixed(1)}% of total`;
}
}

View File

@@ -0,0 +1,73 @@
<div class="event-detail-panel" role="region" aria-label="Event details">
@if (event) {
<div class="panel-header">
<div class="event-kind" [style.backgroundColor]="kindConfig?.color">
<mat-icon>{{ kindConfig?.icon }}</mat-icon>
<span>{{ kindConfig?.label }}</span>
</div>
<button mat-icon-button (click)="copyToClipboard(event.eventId)" aria-label="Copy event ID">
<mat-icon>content_copy</mat-icon>
</button>
</div>
<div class="event-id">
<span class="label">Event ID</span>
<code>{{ event.eventId }}</code>
</div>
<div class="info-grid">
<div class="info-item">
<span class="label">Service</span>
<span class="value">{{ event.service }}</span>
</div>
<div class="info-item">
<span class="label">Kind</span>
<span class="value">{{ event.kind }}</span>
</div>
<div class="info-item">
<span class="label">HLC</span>
<code class="value mono">{{ formatHlc(event.tHlc) }}</code>
</div>
<div class="info-item">
<span class="label">Wall Clock</span>
<span class="value">{{ formatTimestamp(event.tsWall) }}</span>
</div>
</div>
<mat-tab-group>
<mat-tab label="Payload">
@if (parsedPayload) {
<pre class="payload-json">{{ formatJson(parsedPayload) }}</pre>
} @else {
<p class="no-payload">No payload data</p>
}
</mat-tab>
<mat-tab label="Engine">
<div class="engine-info">
<div class="info-item">
<span class="label">Engine Name</span>
<span class="value">{{ event.engineVersion.name }}</span>
</div>
<div class="info-item">
<span class="label">Version</span>
<span class="value">{{ event.engineVersion.version }}</span>
</div>
<div class="info-item">
<span class="label">Digest</span>
<code class="value mono">{{ event.engineVersion.digest }}</code>
</div>
</div>
</mat-tab>
<mat-tab label="Evidence">
<app-evidence-links [payload]="event.payload"></app-evidence-links>
</mat-tab>
</mat-tab-group>
} @else {
<div class="empty-state">
<mat-icon>touch_app</mat-icon>
<p>Select an event to view details</p>
</div>
}
</div>

View File

@@ -0,0 +1,149 @@
.event-detail-panel {
height: 100%;
display: flex;
flex-direction: column;
background: var(--surface, #fff);
border-radius: 8px;
border: 1px solid var(--outline-variant, #e0e0e0);
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--outline-variant, #e0e0e0);
background: var(--surface-container, #f5f5f5);
}
.event-kind {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 16px;
color: white;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
span {
font-size: 13px;
font-weight: 600;
}
}
.event-id {
padding: 12px 16px;
border-bottom: 1px solid var(--outline-variant, #e0e0e0);
.label {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--on-surface-variant, #666);
text-transform: uppercase;
margin-bottom: 4px;
}
code {
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: var(--on-surface, #333);
word-break: break-all;
}
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--outline-variant, #e0e0e0);
}
.info-item {
.label {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--on-surface-variant, #666);
text-transform: uppercase;
margin-bottom: 4px;
}
.value {
font-size: 13px;
color: var(--on-surface, #333);
&.mono {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
word-break: break-all;
}
}
}
mat-tab-group {
flex: 1;
overflow: hidden;
::ng-deep .mat-mdc-tab-body-wrapper {
flex: 1;
overflow: auto;
}
}
.payload-json {
margin: 0;
padding: 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.5;
background: var(--surface-container, #f5f5f5);
color: var(--on-surface, #333);
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.no-payload {
padding: 24px;
text-align: center;
color: var(--on-surface-variant, #666);
font-size: 13px;
}
.engine-info {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--on-surface-variant, #666);
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
p {
font-size: 14px;
margin: 0;
}
}

View File

@@ -0,0 +1,69 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { TimelineEvent, EVENT_KIND_CONFIGS } from '../../models/timeline.models';
/**
* Event detail panel component.
* Displays detailed information about a selected event.
*/
@Component({
selector: 'app-event-detail-panel',
standalone: true,
imports: [CommonModule, MatIconModule, MatButtonModule, MatTabsModule],
templateUrl: './event-detail-panel.component.html',
styleUrls: ['./event-detail-panel.component.scss'],
})
export class EventDetailPanelComponent {
@Input() event: TimelineEvent | null = null;
get parsedPayload(): object | null {
if (!this.event?.payload) {
return null;
}
try {
return JSON.parse(this.event.payload);
} catch {
return null;
}
}
get kindConfig() {
if (!this.event) {
return null;
}
return (
EVENT_KIND_CONFIGS.find((c) => c.kind === this.event!.kind) ?? {
kind: this.event.kind,
label: this.event.kind,
icon: 'circle',
color: '#757575',
}
);
}
formatTimestamp(tsWall: string): string {
return new Date(tsWall).toLocaleString();
}
formatHlc(hlc: string): string {
return hlc;
}
formatJson(obj: object): string {
return JSON.stringify(obj, null, 2);
}
copyToClipboard(text: string): void {
navigator.clipboard.writeText(text);
}
}

View File

@@ -0,0 +1,25 @@
<div class="evidence-links">
@if (evidenceRefs.length > 0) {
<div class="links-list">
@for (ref of evidenceRefs; track ref.id) {
<a
class="evidence-link"
[routerLink]="getRouterLink(ref)"
[style.borderLeftColor]="ref.color"
>
<mat-icon [style.color]="ref.color">{{ ref.icon }}</mat-icon>
<div class="link-content">
<span class="link-type">{{ ref.type }}</span>
<code class="link-id">{{ ref.id }}</code>
</div>
<mat-icon class="arrow">arrow_forward</mat-icon>
</a>
}
</div>
} @else {
<div class="empty-state">
<mat-icon>link_off</mat-icon>
<p>No evidence references found</p>
</div>
}
</div>

View File

@@ -0,0 +1,88 @@
.evidence-links {
padding: 16px;
}
.links-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.evidence-link {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--surface-container, #f5f5f5);
border-radius: 8px;
border-left: 4px solid;
text-decoration: none;
transition: background-color 0.2s ease;
&:hover {
background: var(--surface-container-high, #e8e8e8);
}
&:focus {
outline: 2px solid var(--primary, #4285f4);
outline-offset: 2px;
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
.link-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.link-type {
font-size: 12px;
font-weight: 600;
color: var(--on-surface, #333);
}
.link-id {
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
color: var(--on-surface-variant, #666);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.arrow {
color: var(--on-surface-variant, #666);
font-size: 18px;
width: 18px;
height: 18px;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
color: var(--on-surface-variant, #666);
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
margin-bottom: 8px;
opacity: 0.5;
}
p {
font-size: 13px;
margin: 0;
}
}

View File

@@ -0,0 +1,133 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
/**
* Evidence links component.
* Parses event payload and displays links to related evidence (SBOM, VEX, Policy, etc.).
*/
@Component({
selector: 'app-evidence-links',
standalone: true,
imports: [CommonModule, MatIconModule, MatButtonModule, RouterModule],
templateUrl: './evidence-links.component.html',
styleUrls: ['./evidence-links.component.scss'],
})
export class EvidenceLinksComponent {
@Input() payload: string = '';
get evidenceRefs(): EvidenceRef[] {
if (!this.payload) {
return [];
}
try {
const parsed = JSON.parse(this.payload);
return this.extractEvidenceRefs(parsed);
} catch {
return [];
}
}
private extractEvidenceRefs(obj: unknown, refs: EvidenceRef[] = []): EvidenceRef[] {
if (!obj || typeof obj !== 'object') {
return refs;
}
const record = obj as Record<string, unknown>;
// Check for SBOM references
if (record['sbomId'] || record['sbom_id'] || record['sbomDigest']) {
refs.push({
type: 'SBOM',
id: String(record['sbomId'] ?? record['sbom_id'] ?? record['sbomDigest']),
icon: 'description',
route: '/sbom',
color: '#4285F4',
});
}
// Check for VEX references
if (record['vexId'] || record['vex_id'] || record['vexDigest']) {
refs.push({
type: 'VEX',
id: String(record['vexId'] ?? record['vex_id'] ?? record['vexDigest']),
icon: 'verified_user',
route: '/vex-hub',
color: '#34A853',
});
}
// Check for Policy references
if (record['policyId'] || record['policy_id'] || record['policyDigest']) {
refs.push({
type: 'Policy',
id: String(record['policyId'] ?? record['policy_id'] ?? record['policyDigest']),
icon: 'policy',
route: '/policy',
color: '#FBBC04',
});
}
// Check for Attestation references
if (record['attestationId'] || record['attestation_id'] || record['attestationDigest']) {
refs.push({
type: 'Attestation',
id: String(record['attestationId'] ?? record['attestation_id'] ?? record['attestationDigest']),
icon: 'verified',
route: '/proof',
color: '#EA4335',
});
}
// Check for Scan references
if (record['scanId'] || record['scan_id']) {
refs.push({
type: 'Scan',
id: String(record['scanId'] ?? record['scan_id']),
icon: 'security',
route: '/scans',
color: '#9C27B0',
});
}
// Check for Job references
if (record['jobId'] || record['job_id']) {
refs.push({
type: 'Job',
id: String(record['jobId'] ?? record['job_id']),
icon: 'work',
route: '/runs',
color: '#607D8B',
});
}
// Recursively check nested objects
for (const value of Object.values(record)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
this.extractEvidenceRefs(value, refs);
}
}
return refs;
}
getRouterLink(ref: EvidenceRef): string[] {
return [ref.route, ref.id];
}
}
interface EvidenceRef {
type: string;
id: string;
icon: string;
route: string;
color: string;
}

View File

@@ -0,0 +1,37 @@
<div class="export-button-container">
@if (isExporting) {
<div class="export-progress">
<mat-spinner diameter="24"></mat-spinner>
<span>Exporting... {{ exportProgress }}%</span>
</div>
} @else {
<button
mat-stroked-button
[matMenuTriggerFor]="exportMenu"
[disabled]="!correlationId"
aria-label="Export timeline"
>
<mat-icon>download</mat-icon>
Export
</button>
<mat-menu #exportMenu="matMenu">
<button mat-menu-item (click)="exportNdjson(false)">
<mat-icon>description</mat-icon>
<span>NDJSON</span>
</button>
<button mat-menu-item (click)="exportNdjson(true)">
<mat-icon>verified</mat-icon>
<span>NDJSON (DSSE-signed)</span>
</button>
<button mat-menu-item (click)="exportJson(false)">
<mat-icon>code</mat-icon>
<span>JSON</span>
</button>
<button mat-menu-item (click)="exportJson(true)">
<mat-icon>verified</mat-icon>
<span>JSON (DSSE-signed)</span>
</button>
</mat-menu>
}
</div>

View File

@@ -0,0 +1,27 @@
.export-button-container {
display: inline-flex;
align-items: center;
}
.export-progress {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--surface-container, #f5f5f5);
border-radius: 4px;
span {
font-size: 13px;
color: var(--on-surface-variant, #666);
}
}
button {
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
margin-right: 4px;
}
}

View File

@@ -0,0 +1,141 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { Component, Input, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { TimelineService } from '../../services/timeline.service';
import { ExportRequest } from '../../models/timeline.models';
/**
* Export button component.
* Triggers export of timeline data as DSSE-signed bundle.
*/
@Component({
selector: 'app-export-button',
standalone: true,
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './export-button.component.html',
styleUrls: ['./export-button.component.scss'],
})
export class ExportButtonComponent {
@Input() correlationId: string = '';
@Input() fromHlc?: string;
@Input() toHlc?: string;
private readonly timelineService = inject(TimelineService);
private readonly snackBar = inject(MatSnackBar);
isExporting = false;
exportProgress = 0;
async exportNdjson(signed: boolean = false): Promise<void> {
await this.doExport('ndjson', signed);
}
async exportJson(signed: boolean = false): Promise<void> {
await this.doExport('json', signed);
}
private async doExport(
format: 'ndjson' | 'json',
signBundle: boolean
): Promise<void> {
if (!this.correlationId || this.isExporting) {
return;
}
this.isExporting = true;
this.exportProgress = 0;
const request: ExportRequest = {
correlationId: this.correlationId,
format,
signBundle,
fromHlc: this.fromHlc,
toHlc: this.toHlc,
};
try {
// Initiate export
const initiated = await this.timelineService
.initiateExport(request)
.toPromise();
if (!initiated) {
throw new Error('Failed to initiate export');
}
// Poll for completion
let completed = false;
while (!completed) {
await this.delay(1000);
const status = await this.timelineService
.getExportStatus(initiated.exportId)
.toPromise();
if (!status) {
throw new Error('Failed to get export status');
}
if (status.status === 'COMPLETED') {
completed = true;
this.exportProgress = 100;
// Download the bundle
const blob = await this.timelineService
.downloadExport(initiated.exportId)
.toPromise();
if (blob) {
this.downloadBlob(blob, `timeline-${this.correlationId}.${format}`);
this.snackBar.open('Export downloaded successfully', 'Close', {
duration: 3000,
});
}
} else if (status.status === 'FAILED') {
throw new Error(status.error ?? 'Export failed');
} else {
this.exportProgress = Math.min(90, this.exportProgress + 10);
}
}
} catch (error) {
console.error('Export failed:', error);
this.snackBar.open('Export failed. Please try again.', 'Close', {
duration: 5000,
});
} finally {
this.isExporting = false;
this.exportProgress = 0;
}
}
private downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,44 @@
<div class="timeline-filter" role="search" aria-label="Timeline filters">
<form [formGroup]="filterForm" class="filter-form">
<mat-form-field appearance="outline" class="filter-field">
<mat-label>Services</mat-label>
<mat-select formControlName="services" multiple>
@for (service of services; track service) {
<mat-option [value]="service">{{ service }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="filter-field">
<mat-label>Event Kinds</mat-label>
<mat-select formControlName="kinds" multiple>
@for (kind of kinds; track kind) {
<mat-option [value]="kind">{{ kind }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="filter-field hlc-field">
<mat-label>From HLC</mat-label>
<input matInput formControlName="fromHlc" placeholder="e.g., 1704067200000:0:node">
</mat-form-field>
<mat-form-field appearance="outline" class="filter-field hlc-field">
<mat-label>To HLC</mat-label>
<input matInput formControlName="toHlc" placeholder="e.g., 1704153600000:0:node">
</mat-form-field>
@if (hasActiveFilters) {
<button
mat-stroked-button
type="button"
(click)="clearFilters()"
class="clear-button"
aria-label="Clear all filters"
>
<mat-icon>clear</mat-icon>
Clear
</button>
}
</form>
</div>

View File

@@ -0,0 +1,55 @@
.timeline-filter {
padding: 16px;
background: var(--surface, #fff);
border-radius: 8px;
border: 1px solid var(--outline-variant, #e0e0e0);
}
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
}
.filter-field {
min-width: 160px;
&.hlc-field {
min-width: 200px;
}
::ng-deep {
.mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
}
.clear-button {
height: 40px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
margin-right: 4px;
}
}
// Responsive
@media (max-width: 768px) {
.filter-form {
flex-direction: column;
align-items: stretch;
}
.filter-field {
width: 100%;
min-width: unset;
}
.clear-button {
width: 100%;
}
}

View File

@@ -0,0 +1,149 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { Component, Output, EventEmitter, inject, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, FormGroup } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { ActivatedRoute, Router } from '@angular/router';
import { Subject, takeUntil, debounceTime } from 'rxjs';
import {
TimelineQueryOptions,
SERVICE_CONFIGS,
EVENT_KIND_CONFIGS,
} from '../../models/timeline.models';
/**
* Timeline filter component.
* Provides filtering by service, kind, and HLC range.
*/
@Component({
selector: 'app-timeline-filter',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatFormFieldModule,
MatSelectModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
],
templateUrl: './timeline-filter.component.html',
styleUrls: ['./timeline-filter.component.scss'],
})
export class TimelineFilterComponent implements OnInit, OnDestroy {
@Output() filterChange = new EventEmitter<TimelineQueryOptions>();
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly destroy$ = new Subject<void>();
readonly services = SERVICE_CONFIGS.map((c) => c.name);
readonly kinds = EVENT_KIND_CONFIGS.map((c) => c.kind);
filterForm: FormGroup = this.fb.group({
services: [[] as string[]],
kinds: [[] as string[]],
fromHlc: [''],
toHlc: [''],
});
ngOnInit(): void {
// Initialize from URL params
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
if (params['services']) {
this.filterForm.patchValue({ services: params['services'].split(',') });
}
if (params['kinds']) {
this.filterForm.patchValue({ kinds: params['kinds'].split(',') });
}
if (params['fromHlc']) {
this.filterForm.patchValue({ fromHlc: params['fromHlc'] });
}
if (params['toHlc']) {
this.filterForm.patchValue({ toHlc: params['toHlc'] });
}
});
// Emit on changes with debounce
this.filterForm.valueChanges
.pipe(takeUntil(this.destroy$), debounceTime(300))
.subscribe((value) => {
this.emitFilter(value);
this.updateUrl(value);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
clearFilters(): void {
this.filterForm.reset({
services: [],
kinds: [],
fromHlc: '',
toHlc: '',
});
}
get hasActiveFilters(): boolean {
const value = this.filterForm.value;
return (
value.services?.length > 0 ||
value.kinds?.length > 0 ||
!!value.fromHlc ||
!!value.toHlc
);
}
private emitFilter(value: FilterFormValue): void {
const options: TimelineQueryOptions = {};
if (value.services?.length) {
options.services = value.services;
}
if (value.kinds?.length) {
options.kinds = value.kinds;
}
if (value.fromHlc) {
options.fromHlc = value.fromHlc;
}
if (value.toHlc) {
options.toHlc = value.toHlc;
}
this.filterChange.emit(options);
}
private updateUrl(value: FilterFormValue): void {
const queryParams: Record<string, string | null> = {
services: value.services?.length ? value.services.join(',') : null,
kinds: value.kinds?.length ? value.kinds.join(',') : null,
fromHlc: value.fromHlc || null,
toHlc: value.toHlc || null,
};
this.router.navigate([], {
relativeTo: this.route,
queryParams,
queryParamsHandling: 'merge',
});
}
}
interface FilterFormValue {
services: string[];
kinds: string[];
fromHlc: string;
toHlc: string;
}

View File

@@ -0,0 +1,26 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
// Timeline Feature Module - Barrel Exports
// Models
export * from './models/timeline.models';
// Services
export * from './services/timeline.service';
// Components
export * from './components/causal-lanes/causal-lanes.component';
export * from './components/critical-path/critical-path.component';
export * from './components/event-detail-panel/event-detail-panel.component';
export * from './components/timeline-filter/timeline-filter.component';
export * from './components/export-button/export-button.component';
export * from './components/evidence-links/evidence-links.component';
// Pages
export * from './pages/timeline-page/timeline-page.component';
// Routes
export { TIMELINE_ROUTES } from './timeline.routes';

View File

@@ -0,0 +1,197 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
/**
* HLC timestamp from the Timeline API.
*/
export interface HlcTimestamp {
/** Wall-clock component in milliseconds since epoch. */
wall: number;
/** Logical counter for same wall-clock values. */
logical: number;
/** Node ID that generated the timestamp. */
nodeId: string;
/** Sortable string representation. */
sortable: string;
}
/**
* Engine version information.
*/
export interface EngineVersion {
name: string;
version: string;
digest: string;
}
/**
* Timeline event from the API.
*/
export interface TimelineEvent {
eventId: string;
tHlc: string;
tsWall: string;
correlationId: string;
service: string;
kind: string;
payload: string;
payloadDigest: string;
engineVersion: EngineVersion;
schemaVersion: number;
}
/**
* Timeline API response.
*/
export interface TimelineResponse {
correlationId: string;
events: TimelineEvent[];
totalCount: number;
hasMore: boolean;
nextCursor?: string;
}
/**
* Critical path stage.
*/
export interface CriticalPathStage {
stage: string;
service: string;
durationMs: number;
percentage: number;
fromHlc: string;
toHlc: string;
}
/**
* Critical path API response.
*/
export interface CriticalPathResponse {
correlationId: string;
totalDurationMs: number;
stages: CriticalPathStage[];
}
/**
* Query options for timeline API.
*/
export interface TimelineQueryOptions {
limit?: number;
offset?: number;
services?: string[];
kinds?: string[];
fromHlc?: string;
toHlc?: string;
cursor?: string;
}
/**
* Export request.
*/
export interface ExportRequest {
correlationId: string;
format: 'ndjson' | 'json';
signBundle: boolean;
fromHlc?: string;
toHlc?: string;
}
/**
* Export initiated response.
*/
export interface ExportInitiatedResponse {
exportId: string;
correlationId: string;
format: string;
signBundle: boolean;
status: string;
estimatedEventCount: number;
}
/**
* Export status response.
*/
export interface ExportStatusResponse {
exportId: string;
status: 'INITIATED' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED';
format: string;
eventCount: number;
fileSizeBytes: number;
createdAt: string;
completedAt?: string;
error?: string;
}
/**
* Replay request.
*/
export interface ReplayRequest {
correlationId: string;
mode: 'dry-run' | 'verify';
fromHlc?: string;
toHlc?: string;
}
/**
* Replay status response.
*/
export interface ReplayStatusResponse {
replayId: string;
status: 'INITIATED' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED';
progress: number;
eventsProcessed: number;
totalEvents: number;
startedAt: string;
completedAt?: string;
error?: string;
deterministicMatch?: boolean;
}
/**
* Service icon and color mapping.
*/
export interface ServiceConfig {
name: string;
color: string;
icon: string;
}
/**
* Event kind icon and color mapping.
*/
export interface EventKindConfig {
kind: string;
label: string;
icon: string;
color: string;
}
/**
* Default service configurations.
*/
export const SERVICE_CONFIGS: ServiceConfig[] = [
{ name: 'Scheduler', color: '#4285F4', icon: 'schedule' },
{ name: 'AirGap', color: '#34A853', icon: 'cloud_off' },
{ name: 'Attestor', color: '#FBBC04', icon: 'verified' },
{ name: 'Policy', color: '#EA4335', icon: 'policy' },
{ name: 'Scanner', color: '#9C27B0', icon: 'security' },
{ name: 'Concelier', color: '#00BCD4', icon: 'merge_type' },
{ name: 'Authority', color: '#FF5722', icon: 'admin_panel_settings' },
];
/**
* Default event kind configurations.
*/
export const EVENT_KIND_CONFIGS: EventKindConfig[] = [
{ kind: 'ENQUEUE', label: 'Enqueue', icon: 'add_circle', color: '#2196F3' },
{ kind: 'EXECUTE', label: 'Execute', icon: 'play_circle', color: '#4CAF50' },
{ kind: 'COMPLETE', label: 'Complete', icon: 'check_circle', color: '#8BC34A' },
{ kind: 'FAIL', label: 'Fail', icon: 'error', color: '#F44336' },
{ kind: 'IMPORT', label: 'Import', icon: 'cloud_download', color: '#00BCD4' },
{ kind: 'MERGE', label: 'Merge', icon: 'merge_type', color: '#9C27B0' },
{ kind: 'ATTEST', label: 'Attest', icon: 'verified', color: '#FFEB3B' },
{ kind: 'VERIFY', label: 'Verify', icon: 'fact_check', color: '#FF9800' },
{ kind: 'GATE', label: 'Gate', icon: 'security', color: '#607D8B' },
];

View File

@@ -0,0 +1,102 @@
<div class="timeline-page" role="main" aria-label="Event Timeline">
<!-- Header -->
<header class="page-header">
<div class="header-content">
<h1>
<mat-icon>timeline</mat-icon>
Timeline
</h1>
@if (correlationId()) {
<span class="correlation-id">{{ correlationId() }}</span>
}
</div>
<div class="header-actions">
<app-export-button
[correlationId]="correlationId()"
[fromHlc]="currentFilters().fromHlc"
[toHlc]="currentFilters().toHlc"
></app-export-button>
<button mat-icon-button (click)="refreshTimeline()" aria-label="Refresh timeline">
<mat-icon>refresh</mat-icon>
</button>
</div>
</header>
<!-- Filters -->
<app-timeline-filter (filterChange)="onFilterChange($event)"></app-timeline-filter>
<!-- Loading state -->
@if (loading()) {
<div class="loading-state">
<mat-spinner diameter="48"></mat-spinner>
<p>Loading timeline...</p>
</div>
}
<!-- Error state -->
@if (error()) {
<div class="error-state" role="alert">
<mat-icon>error_outline</mat-icon>
<p>{{ error() }}</p>
<button mat-stroked-button (click)="refreshTimeline()">
<mat-icon>refresh</mat-icon>
Retry
</button>
</div>
}
<!-- Empty state -->
@if (!loading() && !error() && !correlationId()) {
<div class="empty-state">
<mat-icon>search</mat-icon>
<p>Enter a correlation ID to view the event timeline</p>
</div>
}
<!-- Content -->
@if (!loading() && !error() && timeline()) {
<div class="timeline-content">
<!-- Critical path -->
<section class="critical-path-section" aria-label="Critical path analysis">
<app-critical-path [criticalPath]="criticalPath()"></app-critical-path>
</section>
<!-- Main content grid -->
<div class="main-grid">
<!-- Causal lanes -->
<section class="lanes-section" aria-label="Event causal lanes">
<app-causal-lanes
[events]="timeline()?.events ?? []"
[selectedEventId]="selectedEvent()?.eventId ?? null"
(eventSelected)="onEventSelected($event)"
></app-causal-lanes>
<!-- Load more -->
@if (timeline()?.hasMore) {
<div class="load-more">
<button mat-stroked-button (click)="loadMore()">
<mat-icon>expand_more</mat-icon>
Load more events
</button>
</div>
}
</section>
<!-- Event detail panel -->
<aside class="detail-section" aria-label="Event details">
<app-event-detail-panel
[event]="selectedEvent()"
></app-event-detail-panel>
</aside>
</div>
<!-- Stats footer -->
<footer class="stats-footer">
<span>{{ timeline()?.events?.length ?? 0 }} events</span>
@if (timeline()?.hasMore) {
<span>of {{ timeline()?.totalCount ?? 0 }} total</span>
}
</footer>
</div>
}
</div>

View File

@@ -0,0 +1,170 @@
.timeline-page {
display: flex;
flex-direction: column;
height: 100%;
padding: 24px;
gap: 16px;
background: var(--background, #fafafa);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid var(--outline-variant, #e0e0e0);
}
.header-content {
display: flex;
align-items: center;
gap: 16px;
h1 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--on-surface, #333);
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: var(--primary, #4285f4);
}
}
.correlation-id {
font-size: 14px;
font-family: 'JetBrains Mono', monospace;
color: var(--on-surface-variant, #666);
padding: 4px 12px;
background: var(--surface-container, #f5f5f5);
border-radius: 4px;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.loading-state,
.error-state,
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 48px;
color: var(--on-surface-variant, #666);
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
opacity: 0.5;
}
p {
font-size: 16px;
margin: 0;
}
}
.error-state {
mat-icon {
color: var(--error, #ea4335);
opacity: 1;
}
}
.timeline-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.critical-path-section {
flex-shrink: 0;
}
.main-grid {
flex: 1;
display: grid;
grid-template-columns: 1fr 360px;
gap: 16px;
overflow: hidden;
}
.lanes-section {
display: flex;
flex-direction: column;
overflow: hidden;
app-causal-lanes {
flex: 1;
overflow: auto;
}
}
.load-more {
display: flex;
justify-content: center;
padding: 16px;
}
.detail-section {
overflow: hidden;
app-event-detail-panel {
height: 100%;
}
}
.stats-footer {
display: flex;
gap: 8px;
font-size: 12px;
color: var(--on-surface-variant, #666);
padding: 8px 0;
border-top: 1px solid var(--outline-variant, #e0e0e0);
}
// Responsive
@media (max-width: 1024px) {
.main-grid {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
}
.detail-section {
max-height: 300px;
}
}
@media (max-width: 768px) {
.timeline-page {
padding: 16px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.header-content {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}

View File

@@ -0,0 +1,171 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { Subject, takeUntil, switchMap, catchError, of, combineLatest } from 'rxjs';
import { TimelineService } from '../../services/timeline.service';
import {
TimelineEvent,
TimelineResponse,
CriticalPathResponse,
TimelineQueryOptions,
} from '../../models/timeline.models';
import { CausalLanesComponent } from '../../components/causal-lanes/causal-lanes.component';
import { CriticalPathComponent } from '../../components/critical-path/critical-path.component';
import { EventDetailPanelComponent } from '../../components/event-detail-panel/event-detail-panel.component';
import { TimelineFilterComponent } from '../../components/timeline-filter/timeline-filter.component';
import { ExportButtonComponent } from '../../components/export-button/export-button.component';
/**
* Timeline page component.
* Main page for viewing and analyzing event timelines.
*/
@Component({
selector: 'app-timeline-page',
standalone: true,
imports: [
CommonModule,
MatProgressSpinnerModule,
MatButtonModule,
MatIconModule,
CausalLanesComponent,
CriticalPathComponent,
EventDetailPanelComponent,
TimelineFilterComponent,
ExportButtonComponent,
],
templateUrl: './timeline-page.component.html',
styleUrls: ['./timeline-page.component.scss'],
})
export class TimelinePageComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly timelineService = inject(TimelineService);
private readonly destroy$ = new Subject<void>();
// State signals
correlationId = signal<string>('');
timeline = signal<TimelineResponse | null>(null);
criticalPath = signal<CriticalPathResponse | null>(null);
selectedEvent = signal<TimelineEvent | null>(null);
loading = signal<boolean>(false);
error = signal<string | null>(null);
currentFilters = signal<TimelineQueryOptions>({});
ngOnInit(): void {
// Subscribe to route params
this.route.paramMap
.pipe(
takeUntil(this.destroy$),
switchMap((params) => {
const correlationId = params.get('correlationId') ?? '';
this.correlationId.set(correlationId);
if (!correlationId) {
return of({ timeline: null, criticalPath: null });
}
this.loading.set(true);
this.error.set(null);
return combineLatest({
timeline: this.timelineService.getTimeline(correlationId, this.currentFilters()).pipe(
catchError((err) => {
console.error('Failed to load timeline:', err);
return of(null);
})
),
criticalPath: this.timelineService.getCriticalPath(correlationId).pipe(
catchError((err) => {
console.error('Failed to load critical path:', err);
return of(null);
})
),
});
})
)
.subscribe(({ timeline, criticalPath }) => {
this.timeline.set(timeline);
this.criticalPath.set(criticalPath);
this.loading.set(false);
if (!timeline && this.correlationId()) {
this.error.set('Failed to load timeline data');
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
onEventSelected(event: TimelineEvent): void {
this.selectedEvent.set(event);
}
onFilterChange(filters: TimelineQueryOptions): void {
this.currentFilters.set(filters);
this.refreshTimeline();
}
refreshTimeline(): void {
const correlationId = this.correlationId();
if (!correlationId) {
return;
}
this.loading.set(true);
this.timelineService
.getTimeline(correlationId, this.currentFilters())
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (timeline) => {
this.timeline.set(timeline);
this.loading.set(false);
},
error: (err) => {
console.error('Failed to refresh timeline:', err);
this.loading.set(false);
},
});
}
loadMore(): void {
const current = this.timeline();
if (!current?.hasMore || !current.nextCursor) {
return;
}
const filters: TimelineQueryOptions = {
...this.currentFilters(),
cursor: current.nextCursor,
};
this.loading.set(true);
this.timelineService
.getTimeline(this.correlationId(), filters)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response) => {
// Merge events
const merged: TimelineResponse = {
...response,
events: [...current.events, ...response.events],
};
this.timeline.set(merged);
this.loading.set(false);
},
error: (err) => {
console.error('Failed to load more events:', err);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,201 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { TestBed } from '@angular/core/testing';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { TimelineService } from './timeline.service';
import {
TimelineResponse,
CriticalPathResponse,
ExportInitiatedResponse,
} from '../models/timeline.models';
describe('TimelineService', () => {
let service: TimelineService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
TimelineService,
provideHttpClient(),
provideHttpClientTesting(),
],
});
service = TestBed.inject(TimelineService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
service.clearCache();
});
describe('getTimeline', () => {
it('should fetch timeline for correlation ID', () => {
const mockResponse: TimelineResponse = {
correlationId: 'test-corr-001',
events: [
{
eventId: 'evt-001',
tHlc: '1704067200000:0:node1',
tsWall: '2024-01-01T12:00:00Z',
correlationId: 'test-corr-001',
service: 'Scheduler',
kind: 'EXECUTE',
payload: '{}',
payloadDigest: 'abc123',
engineVersion: { name: 'Test', version: '1.0.0', digest: 'def456' },
schemaVersion: 1,
},
],
totalCount: 1,
hasMore: false,
};
service.getTimeline('test-corr-001').subscribe((result) => {
expect(result).toEqual(mockResponse);
expect(result.events.length).toBe(1);
});
const req = httpMock.expectOne('/api/v1/timeline/test-corr-001');
expect(req.request.method).toBe('GET');
req.flush(mockResponse);
});
it('should include query parameters', () => {
const mockResponse: TimelineResponse = {
correlationId: 'test-corr-001',
events: [],
totalCount: 0,
hasMore: false,
};
service
.getTimeline('test-corr-001', {
limit: 50,
services: ['Scheduler', 'AirGap'],
kinds: ['EXECUTE'],
})
.subscribe();
const req = httpMock.expectOne((request) => {
return (
request.url === '/api/v1/timeline/test-corr-001' &&
request.params.get('limit') === '50' &&
request.params.get('services') === 'Scheduler,AirGap' &&
request.params.get('kinds') === 'EXECUTE'
);
});
expect(req.request.method).toBe('GET');
req.flush(mockResponse);
});
it('should cache recent queries', () => {
const mockResponse: TimelineResponse = {
correlationId: 'test-corr-002',
events: [],
totalCount: 0,
hasMore: false,
};
// First call - hits API
service.getTimeline('test-corr-002').subscribe();
httpMock.expectOne('/api/v1/timeline/test-corr-002').flush(mockResponse);
// Second call - hits cache
service.getTimeline('test-corr-002').subscribe((result) => {
expect(result).toEqual(mockResponse);
});
httpMock.expectNone('/api/v1/timeline/test-corr-002');
});
});
describe('getCriticalPath', () => {
it('should fetch critical path', () => {
const mockResponse: CriticalPathResponse = {
correlationId: 'test-corr-001',
totalDurationMs: 5000,
stages: [
{
stage: 'ENQUEUE->EXECUTE',
service: 'Scheduler',
durationMs: 1000,
percentage: 20,
fromHlc: '1704067200000:0:node1',
toHlc: '1704067201000:0:node1',
},
],
};
service.getCriticalPath('test-corr-001').subscribe((result) => {
expect(result).toEqual(mockResponse);
expect(result.stages.length).toBe(1);
});
const req = httpMock.expectOne(
'/api/v1/timeline/test-corr-001/critical-path'
);
expect(req.request.method).toBe('GET');
req.flush(mockResponse);
});
});
describe('initiateExport', () => {
it('should initiate export', () => {
const mockResponse: ExportInitiatedResponse = {
exportId: 'exp-001',
correlationId: 'test-corr-001',
format: 'ndjson',
signBundle: false,
status: 'INITIATED',
estimatedEventCount: 100,
};
service
.initiateExport({
correlationId: 'test-corr-001',
format: 'ndjson',
signBundle: false,
})
.subscribe((result) => {
expect(result.exportId).toBe('exp-001');
});
const req = httpMock.expectOne('/api/v1/timeline/test-corr-001/export');
expect(req.request.method).toBe('POST');
expect(req.request.body.format).toBe('ndjson');
req.flush(mockResponse);
});
});
describe('clearCache', () => {
it('should clear the cache', () => {
const mockResponse: TimelineResponse = {
correlationId: 'test-corr-003',
events: [],
totalCount: 0,
hasMore: false,
};
// First call
service.getTimeline('test-corr-003').subscribe();
httpMock.expectOne('/api/v1/timeline/test-corr-003').flush(mockResponse);
// Clear cache
service.clearCache();
// Should hit API again
service.getTimeline('test-corr-003').subscribe();
httpMock.expectOne('/api/v1/timeline/test-corr-003').flush(mockResponse);
});
});
});

View File

@@ -0,0 +1,201 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, BehaviorSubject, catchError, retry, tap } from 'rxjs';
import {
TimelineResponse,
TimelineQueryOptions,
CriticalPathResponse,
ExportRequest,
ExportInitiatedResponse,
ExportStatusResponse,
ReplayRequest,
ReplayStatusResponse,
} from '../models/timeline.models';
/**
* Service for interacting with the Timeline API.
*/
@Injectable({ providedIn: 'root' })
export class TimelineService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/timeline';
// Cache for recent queries
private readonly cache = new Map<string, TimelineResponse>();
private readonly cacheMaxSize = 50;
// Loading state
private readonly loadingSubject = new BehaviorSubject<boolean>(false);
readonly loading$ = this.loadingSubject.asObservable();
/**
* Get timeline events for a correlation ID.
*/
getTimeline(
correlationId: string,
options?: TimelineQueryOptions
): Observable<TimelineResponse> {
const cacheKey = this.buildCacheKey(correlationId, options);
// Check cache first
if (this.cache.has(cacheKey)) {
return new Observable((observer) => {
observer.next(this.cache.get(cacheKey)!);
observer.complete();
});
}
this.loadingSubject.next(true);
const params = this.buildParams(options);
return this.http
.get<TimelineResponse>(`${this.baseUrl}/${correlationId}`, { params })
.pipe(
retry({ count: 2, delay: 1000 }),
tap((response) => {
this.addToCache(cacheKey, response);
this.loadingSubject.next(false);
}),
catchError((error) => {
this.loadingSubject.next(false);
throw error;
})
);
}
/**
* Get critical path analysis for a correlation ID.
*/
getCriticalPath(correlationId: string): Observable<CriticalPathResponse> {
return this.http
.get<CriticalPathResponse>(`${this.baseUrl}/${correlationId}/critical-path`)
.pipe(retry({ count: 2, delay: 1000 }));
}
/**
* Initiate an export operation.
*/
initiateExport(request: ExportRequest): Observable<ExportInitiatedResponse> {
return this.http.post<ExportInitiatedResponse>(
`${this.baseUrl}/${request.correlationId}/export`,
request
);
}
/**
* Get export status.
*/
getExportStatus(exportId: string): Observable<ExportStatusResponse> {
return this.http.get<ExportStatusResponse>(
`${this.baseUrl}/export/${exportId}`
);
}
/**
* Download export bundle.
*/
downloadExport(exportId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/export/${exportId}/download`, {
responseType: 'blob',
});
}
/**
* Initiate a replay operation.
*/
initiateReplay(request: ReplayRequest): Observable<ReplayStatusResponse> {
return this.http.post<ReplayStatusResponse>(
`${this.baseUrl}/${request.correlationId}/replay`,
request
);
}
/**
* Get replay status.
*/
getReplayStatus(replayId: string): Observable<ReplayStatusResponse> {
return this.http.get<ReplayStatusResponse>(
`${this.baseUrl}/replay/${replayId}`
);
}
/**
* Clear the cache.
*/
clearCache(): void {
this.cache.clear();
}
private buildParams(options?: TimelineQueryOptions): HttpParams {
let params = new HttpParams();
if (!options) {
return params;
}
if (options.limit !== undefined) {
params = params.set('limit', options.limit.toString());
}
if (options.offset !== undefined) {
params = params.set('offset', options.offset.toString());
}
if (options.services?.length) {
params = params.set('services', options.services.join(','));
}
if (options.kinds?.length) {
params = params.set('kinds', options.kinds.join(','));
}
if (options.fromHlc) {
params = params.set('fromHlc', options.fromHlc);
}
if (options.toHlc) {
params = params.set('toHlc', options.toHlc);
}
if (options.cursor) {
params = params.set('cursor', options.cursor);
}
return params;
}
private buildCacheKey(
correlationId: string,
options?: TimelineQueryOptions
): string {
const parts = [correlationId];
if (options) {
if (options.limit) parts.push(`l:${options.limit}`);
if (options.offset) parts.push(`o:${options.offset}`);
if (options.services?.length) parts.push(`s:${options.services.join(',')}`);
if (options.kinds?.length) parts.push(`k:${options.kinds.join(',')}`);
if (options.fromHlc) parts.push(`f:${options.fromHlc}`);
if (options.toHlc) parts.push(`t:${options.toHlc}`);
}
return parts.join('|');
}
private addToCache(key: string, value: TimelineResponse): void {
// Evict oldest entries if cache is full
if (this.cache.size >= this.cacheMaxSize) {
const firstKey = this.cache.keys().next().value;
if (firstKey) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, value);
}
}

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
*/
import { Routes } from '@angular/router';
export const TIMELINE_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/timeline-page/timeline-page.component').then(
(m) => m.TimelinePageComponent
),
},
{
path: ':correlationId',
loadComponent: () =>
import('./pages/timeline-page/timeline-page.component').then(
(m) => m.TimelinePageComponent
),
},
];
export default TIMELINE_ROUTES;

View File

@@ -0,0 +1,732 @@
// -----------------------------------------------------------------------------
// decision-drawer-enhanced.component.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Tasks: T018, T019, T020, T021
// Description: Enhanced decision drawer with TTL picker, policy reference,
// sign-and-apply flow, and undo toast
// -----------------------------------------------------------------------------
import {
Component,
Input,
Output,
EventEmitter,
HostListener,
signal,
computed,
inject,
OnDestroy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { Subject, takeUntil } from 'rxjs';
export type DecisionStatus = 'affected' | 'not_affected' | 'under_investigation';
export interface DecisionFormData {
status: DecisionStatus;
reasonCode: string;
reasonText?: string;
exceptionTtlDays?: number;
policyReference?: string;
signedByUserId?: string;
}
export interface AlertSummary {
id: string;
artifactId: string;
vulnId: string;
severity: string;
scanId?: string;
}
export interface ApprovalRequest {
findingId: string;
scanId: string;
status: DecisionStatus;
reasonCode: string;
reasonText?: string;
expiresAt?: string;
policyReference?: string;
}
export interface ApprovalResponse {
id: string;
createdAt: string;
expiresAt?: string;
}
@Component({
selector: 'app-decision-drawer-enhanced',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<aside class="decision-drawer" [class.open]="isOpen" role="dialog" aria-labelledby="drawer-title">
<header>
<h3 id="drawer-title">Record Decision</h3>
<button class="close-btn" (click)="close.emit()" aria-label="Close drawer">
&times;
</button>
</header>
<section class="status-selection">
<h4>VEX Status</h4>
<div class="radio-group" role="radiogroup" aria-label="VEX Status">
<label class="radio-option" [class.selected]="formData().status === 'affected'">
<input type="radio" name="status" value="affected"
[checked]="formData().status === 'affected'"
(change)="setStatus('affected')">
<span class="key-hint" aria-hidden="true">A</span>
<span>Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'not_affected'">
<input type="radio" name="status" value="not_affected"
[checked]="formData().status === 'not_affected'"
(change)="setStatus('not_affected')">
<span class="key-hint" aria-hidden="true">N</span>
<span>Not Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'under_investigation'">
<input type="radio" name="status" value="under_investigation"
[checked]="formData().status === 'under_investigation'"
(change)="setStatus('under_investigation')">
<span class="key-hint" aria-hidden="true">U</span>
<span>Under Investigation</span>
</label>
</div>
</section>
<section class="reason-selection">
<h4>Reason</h4>
<select [ngModel]="formData().reasonCode"
(ngModelChange)="setReasonCode($event)"
class="reason-select"
aria-label="Select reason">
<option value="">Select reason...</option>
<optgroup label="Not Affected Reasons">
<option value="component_not_present">Component not present</option>
<option value="vulnerable_code_not_present">Vulnerable code not present</option>
<option value="vulnerable_code_not_in_execute_path">Vulnerable code not in execute path</option>
<option value="vulnerable_code_cannot_be_controlled_by_adversary">Vulnerable code cannot be controlled</option>
<option value="inline_mitigations_already_exist">Inline mitigations exist</option>
</optgroup>
<optgroup label="Affected Reasons">
<option value="vulnerable_code_reachable">Vulnerable code is reachable</option>
<option value="exploit_available">Exploit available</option>
</optgroup>
<optgroup label="Investigation">
<option value="requires_further_analysis">Requires further analysis</option>
<option value="waiting_for_vendor">Waiting for vendor response</option>
</optgroup>
</select>
<textarea
[ngModel]="formData().reasonText"
(ngModelChange)="setReasonText($event)"
placeholder="Additional notes (optional)"
rows="3"
class="reason-text"
aria-label="Additional notes">
</textarea>
</section>
<!-- T018: TTL Picker for Exceptions -->
<section class="ttl-section" *ngIf="showTtlPicker()">
<h4>Exception Time-to-Live</h4>
<div class="ttl-picker">
<label class="ttl-option" *ngFor="let opt of ttlOptions">
<input type="radio" name="ttl"
[value]="opt.days"
[checked]="formData().exceptionTtlDays === opt.days"
(change)="setTtlDays(opt.days)">
<span>{{ opt.label }}</span>
</label>
<div class="custom-ttl" *ngIf="showCustomTtl()">
<input type="date"
[min]="minExpiryDate"
[max]="maxExpiryDate"
[ngModel]="customExpiryDate()"
(ngModelChange)="setCustomExpiry($event)"
aria-label="Custom expiry date">
</div>
</div>
<p class="ttl-note" *ngIf="formData().exceptionTtlDays">
Expires: {{ computedExpiryDate() | date:'mediumDate' }}
</p>
</section>
<!-- T019: Policy Reference Display -->
<section class="policy-section">
<h4>Policy Reference</h4>
<div class="policy-display">
<input type="text"
[ngModel]="formData().policyReference"
(ngModelChange)="setPolicyReference($event)"
[readonly]="!isAdmin"
[placeholder]="defaultPolicyRef"
class="policy-input"
aria-label="Policy reference">
<button *ngIf="isAdmin" class="btn-icon" (click)="resetPolicyRef()" title="Reset to default">
&#x21ba;
</button>
</div>
<p class="policy-note">
<a [href]="policyDocUrl" target="_blank" rel="noopener">View policy documentation</a>
</p>
</section>
<section class="audit-summary">
<h4>Audit Summary</h4>
<dl class="summary-list">
<dt>Alert ID</dt>
<dd>{{ alert?.id ?? '-' }}</dd>
<dt>Artifact</dt>
<dd class="truncate" [title]="alert?.artifactId">{{ alert?.artifactId ?? '-' }}</dd>
<dt>Vulnerability</dt>
<dd>{{ alert?.vulnId ?? '-' }}</dd>
<dt>Evidence Hash</dt>
<dd class="hash">{{ evidenceHash || '-' }}</dd>
<dt>Policy Version</dt>
<dd>{{ policyVersion || '-' }}</dd>
</dl>
</section>
<footer>
<button class="btn btn-secondary" (click)="close.emit()" [disabled]="isSubmitting()">
Cancel
</button>
<!-- T020: Sign-and-Apply Flow -->
<button class="btn btn-primary"
[disabled]="!isValid() || isSubmitting()"
(click)="signAndApply()">
<span *ngIf="isSubmitting()" class="spinner"></span>
{{ isSubmitting() ? 'Signing...' : 'Sign & Apply' }}
</button>
</footer>
</aside>
<!-- Backdrop -->
<div class="backdrop" *ngIf="isOpen" (click)="close.emit()" aria-hidden="true"></div>
<!-- T021: Undo Toast -->
<div class="undo-toast" *ngIf="showUndoToast()" role="alert" aria-live="polite">
<span class="undo-message">Decision recorded for {{ lastApproval()?.findingId }}</span>
<button class="undo-btn" (click)="undoLastDecision()">Undo ({{ undoCountdown() }}s)</button>
<button class="dismiss-btn" (click)="dismissUndo()" aria-label="Dismiss">&times;</button>
</div>
`,
styles: [`
.decision-drawer {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 380px;
background: var(--surface-color, #fff);
border-left: 1px solid var(--border-color, #e0e0e0);
box-shadow: -4px 0 16px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 101;
overflow-y: auto;
}
.decision-drawer.open { transform: translateX(0); }
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 100;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
position: sticky;
top: 0;
background: var(--surface-color, #fff);
}
h3 { margin: 0; font-size: 18px; }
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
line-height: 1;
color: var(--text-secondary, #666);
}
section {
padding: 16px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: var(--text-secondary, #666);
font-weight: 600;
}
.radio-group { display: flex; flex-direction: column; gap: 8px; }
.radio-option {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.radio-option:hover { background: var(--surface-variant, #f5f5f5); }
.radio-option.selected {
border-color: var(--primary-color, #1976d2);
background: var(--primary-bg, #e3f2fd);
}
.radio-option input { position: absolute; opacity: 0; width: 0; height: 0; }
.key-hint {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--surface-variant, #f5f5f5);
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #666);
}
.radio-option.selected .key-hint {
background: var(--primary-color, #1976d2);
color: white;
}
.reason-select, .reason-text, .policy-input {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
font-size: 14px;
font-family: inherit;
box-sizing: border-box;
}
.reason-select { margin-bottom: 8px; background: var(--surface-color, #fff); }
.reason-text { resize: vertical; }
/* TTL Picker */
.ttl-picker { display: flex; flex-wrap: wrap; gap: 8px; }
.ttl-option {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.ttl-option:has(input:checked) {
border-color: var(--primary-color, #1976d2);
background: var(--primary-bg, #e3f2fd);
}
.ttl-option input { margin: 0; }
.custom-ttl { flex-basis: 100%; margin-top: 8px; }
.custom-ttl input { padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; }
.ttl-note, .policy-note {
margin: 8px 0 0;
font-size: 12px;
color: var(--text-secondary, #666);
}
.policy-note a { color: var(--primary-color, #1976d2); }
/* Policy Display */
.policy-display { display: flex; gap: 8px; align-items: center; }
.policy-input { flex: 1; }
.policy-input[readonly] { background: var(--surface-variant, #f5f5f5); }
.btn-icon {
background: none;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px 10px;
cursor: pointer;
font-size: 16px;
}
/* Summary */
.summary-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
font-size: 13px;
margin: 0;
}
.summary-list dt { color: var(--text-secondary, #666); }
.summary-list dd { margin: 0; color: var(--text-primary, #333); }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.hash { font-family: ui-monospace, monospace; font-size: 11px; word-break: break-all; }
footer {
margin-top: auto;
padding: 16px;
display: flex;
gap: 8px;
justify-content: flex-end;
border-top: 1px solid var(--border-color, #e0e0e0);
position: sticky;
bottom: 0;
background: var(--surface-color, #fff);
}
.btn {
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: var(--primary-color, #1976d2);
color: white;
border: none;
}
.btn-primary:hover:not(:disabled) { background: var(--primary-dark, #1565c0); }
.btn-secondary {
background: transparent;
border: 1px solid var(--border-color, #e0e0e0);
color: var(--text-primary, #333);
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Undo Toast */
.undo-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--surface-inverse, #333);
color: white;
padding: 12px 16px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
z-index: 200;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translate(-50%, 100%); opacity: 0; }
to { transform: translate(-50%, 0); opacity: 1; }
}
.undo-message { font-size: 14px; }
.undo-btn {
background: var(--primary-color, #1976d2);
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.undo-btn:hover { background: var(--primary-dark, #1565c0); }
.dismiss-btn {
background: none;
border: none;
color: rgba(255,255,255,0.7);
font-size: 18px;
cursor: pointer;
padding: 4px;
}
.dismiss-btn:hover { color: white; }
`]
})
export class DecisionDrawerEnhancedComponent implements OnDestroy {
private readonly http = inject(HttpClient);
private readonly destroy$ = new Subject<void>();
@Input() alert?: AlertSummary;
@Input() isOpen = false;
@Input() evidenceHash = '';
@Input() policyVersion = '';
@Input() isAdmin = false;
@Input() apiBaseUrl = '/api/v1';
@Output() close = new EventEmitter<void>();
@Output() decisionSubmit = new EventEmitter<DecisionFormData>();
@Output() decisionRevoked = new EventEmitter<string>();
readonly defaultPolicyRef = 'POL-VEX-001';
readonly policyDocUrl = '/docs/policies/vex-decision-policy';
readonly ttlOptions = [
{ days: 30, label: '30 days' },
{ days: 90, label: '90 days' },
{ days: 180, label: '6 months' },
{ days: 365, label: '1 year' },
{ days: -1, label: 'Custom' },
];
formData = signal<DecisionFormData>({
status: 'under_investigation',
reasonCode: '',
reasonText: '',
exceptionTtlDays: 90,
policyReference: this.defaultPolicyRef,
});
isSubmitting = signal(false);
showUndoToast = signal(false);
undoCountdown = signal(10);
lastApproval = signal<{ id: string; findingId: string } | null>(null);
customExpiryDate = signal<string>('');
private undoTimer: ReturnType<typeof setInterval> | null = null;
readonly showTtlPicker = computed(() =>
this.formData().status === 'not_affected' &&
['inline_mitigations_already_exist', 'requires_further_analysis'].includes(this.formData().reasonCode)
);
readonly showCustomTtl = computed(() => this.formData().exceptionTtlDays === -1);
readonly computedExpiryDate = computed(() => {
const days = this.formData().exceptionTtlDays;
if (!days || days <= 0) {
const custom = this.customExpiryDate();
return custom ? new Date(custom) : null;
}
const date = new Date();
date.setDate(date.getDate() + days);
return date;
});
get minExpiryDate(): string {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.toISOString().split('T')[0];
}
get maxExpiryDate(): string {
const maxDate = new Date();
maxDate.setFullYear(maxDate.getFullYear() + 2);
return maxDate.toISOString().split('T')[0];
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
if (this.undoTimer) clearInterval(this.undoTimer);
}
@HostListener('document:keydown', ['$event'])
handleKeydown(event: KeyboardEvent): void {
if (!this.isOpen) return;
if (event.key === 'Escape') {
event.preventDefault();
this.close.emit();
return;
}
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
return;
}
switch (event.key.toLowerCase()) {
case 'a':
event.preventDefault();
this.setStatus('affected');
break;
case 'n':
event.preventDefault();
this.setStatus('not_affected');
break;
case 'u':
event.preventDefault();
this.setStatus('under_investigation');
break;
}
}
setStatus(status: DecisionStatus): void {
this.formData.update((f) => ({ ...f, status }));
}
setReasonCode(reasonCode: string): void {
this.formData.update((f) => ({ ...f, reasonCode }));
}
setReasonText(reasonText: string): void {
this.formData.update((f) => ({ ...f, reasonText }));
}
setTtlDays(days: number): void {
this.formData.update((f) => ({ ...f, exceptionTtlDays: days }));
}
setCustomExpiry(date: string): void {
this.customExpiryDate.set(date);
}
setPolicyReference(ref: string): void {
this.formData.update((f) => ({ ...f, policyReference: ref }));
}
resetPolicyRef(): void {
this.formData.update((f) => ({ ...f, policyReference: this.defaultPolicyRef }));
}
isValid(): boolean {
const data = this.formData();
return !!data.status && !!data.reasonCode;
}
async signAndApply(): Promise<void> {
if (!this.isValid() || !this.alert?.scanId) return;
this.isSubmitting.set(true);
const data = this.formData();
const expiryDate = this.computedExpiryDate();
const request: ApprovalRequest = {
findingId: this.alert.id,
scanId: this.alert.scanId,
status: data.status,
reasonCode: data.reasonCode,
reasonText: data.reasonText,
expiresAt: expiryDate?.toISOString(),
policyReference: data.policyReference,
};
try {
const response = await this.http
.post<ApprovalResponse>(`${this.apiBaseUrl}/scans/${this.alert.scanId}/approvals`, request)
.pipe(takeUntil(this.destroy$))
.toPromise();
if (response) {
this.lastApproval.set({ id: response.id, findingId: this.alert.id });
this.decisionSubmit.emit(data);
this.startUndoTimer();
this.close.emit();
this.resetForm();
}
} catch (error) {
console.error('Failed to submit decision:', error);
} finally {
this.isSubmitting.set(false);
}
}
private startUndoTimer(): void {
this.showUndoToast.set(true);
this.undoCountdown.set(10);
if (this.undoTimer) clearInterval(this.undoTimer);
this.undoTimer = setInterval(() => {
const count = this.undoCountdown();
if (count <= 1) {
this.dismissUndo();
} else {
this.undoCountdown.set(count - 1);
}
}, 1000);
}
async undoLastDecision(): Promise<void> {
const approval = this.lastApproval();
if (!approval || !this.alert?.scanId) return;
try {
await this.http
.delete(`${this.apiBaseUrl}/scans/${this.alert.scanId}/approvals/${approval.findingId}`)
.pipe(takeUntil(this.destroy$))
.toPromise();
this.decisionRevoked.emit(approval.findingId);
} catch (error) {
console.error('Failed to revoke decision:', error);
} finally {
this.dismissUndo();
}
}
dismissUndo(): void {
this.showUndoToast.set(false);
this.lastApproval.set(null);
if (this.undoTimer) {
clearInterval(this.undoTimer);
this.undoTimer = null;
}
}
resetForm(): void {
this.formData.set({
status: 'under_investigation',
reasonCode: '',
reasonText: '',
exceptionTtlDays: 90,
policyReference: this.defaultPolicyRef,
});
this.customExpiryDate.set('');
}
}

View File

@@ -0,0 +1,173 @@
// -----------------------------------------------------------------------------
// export-evidence-button.component.spec.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Task: T025 - Tests for export evidence button
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ExportEvidenceButtonComponent, ExportStatus } from './export-evidence-button.component';
describe('ExportEvidenceButtonComponent', () => {
let component: ExportEvidenceButtonComponent;
let fixture: ComponentFixture<ExportEvidenceButtonComponent>;
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ExportEvidenceButtonComponent, HttpClientTestingModule]
}).compileComponents();
fixture = TestBed.createComponent(ExportEvidenceButtonComponent);
component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController);
component.bundleId = 'bundle-123';
fixture.detectChanges();
});
afterEach(() => {
httpMock.verify();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should start in idle state', () => {
expect(component.status()).toBe('idle');
});
it('should display Export Bundle button when idle', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Export Bundle');
});
it('should be disabled when bundleId is null', () => {
component.bundleId = null;
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.disabled).toBe(true);
});
it('should trigger export on click', () => {
const startedSpy = jest.spyOn(component.exportStarted, 'emit');
component.startExport();
const req = httpMock.expectOne('/api/v1/bundles/bundle-123/export');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
includeLayerSboms: true,
includeRekorProofs: true
});
req.flush({
exportId: 'exp-456',
status: 'pending',
statusUrl: '/api/v1/bundles/bundle-123/export/exp-456'
});
expect(startedSpy).toHaveBeenCalledWith('exp-456');
expect(component.status()).toBe('processing');
});
it('should update progress during processing', fakeAsync(() => {
component.startExport();
const triggerReq = httpMock.expectOne('/api/v1/bundles/bundle-123/export');
triggerReq.flush({
exportId: 'exp-456',
status: 'pending',
statusUrl: '/api/v1/bundles/bundle-123/export/exp-456'
});
tick(1000);
const statusReq = httpMock.expectOne('/api/v1/bundles/bundle-123/export/exp-456');
statusReq.flush({
exportId: 'exp-456',
status: 'processing',
progress: 50
});
expect(component.progress()).toBe(50);
tick(1000);
const statusReq2 = httpMock.expectOne('/api/v1/bundles/bundle-123/export/exp-456');
statusReq2.flush({
exportId: 'exp-456',
status: 'ready',
progress: 100,
downloadUrl: '/download/exp-456',
fileSize: 1024000
});
expect(component.status()).toBe('ready');
expect(component.downloadUrl()).toBe('/download/exp-456');
expect(component.fileSize()).toBe(1024000);
}));
it('should handle export failure', () => {
const failedSpy = jest.spyOn(component.exportFailed, 'emit');
component.startExport();
const req = httpMock.expectOne('/api/v1/bundles/bundle-123/export');
req.error(new ProgressEvent('error'), { status: 500 });
expect(component.status()).toBe('failed');
expect(failedSpy).toHaveBeenCalled();
});
it('should format file size correctly', () => {
component.fileSize.set(512);
expect(component.fileSizeFormatted()).toBe('512 B');
component.fileSize.set(2048);
expect(component.fileSizeFormatted()).toBe('2.0 KB');
component.fileSize.set(5 * 1024 * 1024);
expect(component.fileSizeFormatted()).toBe('5.0 MB');
});
it('should toggle layer sboms option', () => {
expect(component.includeLayerSboms()).toBe(true);
component.toggleLayerSboms();
expect(component.includeLayerSboms()).toBe(false);
});
it('should toggle rekor proofs option', () => {
expect(component.includeRekorProofs()).toBe(true);
component.toggleRekorProofs();
expect(component.includeRekorProofs()).toBe(false);
});
it('should compute progress dasharray', () => {
component.progress.set(75);
expect(component.progressDasharray()).toBe('75, 100');
});
it('should emit completed event on download', () => {
const completedSpy = jest.spyOn(component.exportCompleted, 'emit');
(component as any).exportId = 'exp-456';
component.onDownload();
expect(completedSpy).toHaveBeenCalledWith('exp-456');
});
it('should show options when showOptions is true', () => {
component.showOptions = true;
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Include layer SBOMs');
expect(compiled.textContent).toContain('Include Rekor proofs');
});
});

View File

@@ -0,0 +1,411 @@
// -----------------------------------------------------------------------------
// export-evidence-button.component.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Tasks: T022-T025 - Evidence export button with async job tracking
// Description: One-click evidence bundle export with progress indicator
// -----------------------------------------------------------------------------
import {
Component,
Input,
Output,
EventEmitter,
signal,
computed,
inject,
OnDestroy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Subject, takeUntil, interval, switchMap, filter, take } from 'rxjs';
export type ExportStatus = 'idle' | 'pending' | 'processing' | 'ready' | 'failed';
export interface ExportProgress {
status: ExportStatus;
progress?: number;
downloadUrl?: string;
fileSize?: number;
error?: string;
}
interface ExportTriggerResponse {
exportId: string;
status: string;
estimatedSize?: number;
statusUrl: string;
}
interface ExportStatusResponse {
exportId: string;
status: string;
progress?: number;
downloadUrl?: string;
fileSize?: number;
error?: string;
}
@Component({
selector: 'app-export-evidence-button',
standalone: true,
imports: [CommonModule],
template: `
<div class="export-container">
@switch (status()) {
@case ('idle') {
<button
class="export-btn"
(click)="startExport()"
[disabled]="!bundleId"
title="Export evidence bundle"
aria-label="Export evidence bundle"
>
<span class="export-icon" aria-hidden="true">📥</span>
<span class="export-label">Export Bundle</span>
</button>
}
@case ('pending') {
<button class="export-btn export-btn--pending" disabled>
<span class="spinner" aria-hidden="true"></span>
<span class="export-label">Preparing...</span>
</button>
}
@case ('processing') {
<button class="export-btn export-btn--processing" disabled>
<span class="progress-ring" aria-hidden="true">
<svg viewBox="0 0 36 36" class="progress-svg">
<path
class="progress-bg"
d="M18 2.0845
a 15.9155 15.9155 0 0 1 0 31.831
a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path
class="progress-bar"
[attr.stroke-dasharray]="progressDasharray()"
d="M18 2.0845
a 15.9155 15.9155 0 0 1 0 31.831
a 15.9155 15.9155 0 0 1 0 -31.831"
/>
</svg>
</span>
<span class="export-label">{{ progress() }}%</span>
</button>
}
@case ('ready') {
<a
class="export-btn export-btn--ready"
[href]="downloadUrl()"
download
(click)="onDownload()"
>
<span class="export-icon" aria-hidden="true">✅</span>
<span class="export-label">Download</span>
@if (fileSizeFormatted()) {
<span class="file-size">({{ fileSizeFormatted() }})</span>
}
</a>
}
@case ('failed') {
<div class="export-error">
<button class="export-btn export-btn--failed" (click)="startExport()">
<span class="export-icon" aria-hidden="true">⚠️</span>
<span class="export-label">Retry</span>
</button>
<span class="error-text" role="alert">{{ errorMessage() }}</span>
</div>
}
}
<!-- Options dropdown -->
@if (showOptions && status() === 'idle') {
<div class="export-options">
<label class="option-checkbox">
<input
type="checkbox"
[checked]="includeLayerSboms()"
(change)="toggleLayerSboms()"
/>
Include layer SBOMs
</label>
<label class="option-checkbox">
<input
type="checkbox"
[checked]="includeRekorProofs()"
(change)="toggleRekorProofs()"
/>
Include Rekor proofs
</label>
</div>
}
</div>
`,
styles: [`
:host {
display: inline-block;
}
.export-container {
display: inline-flex;
flex-direction: column;
gap: 0.5rem;
}
.export-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--accent-primary, #0066cc);
background: var(--accent-primary, #0066cc);
color: white;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.15s ease;
}
.export-btn:hover:not(:disabled) {
background: var(--accent-primary-hover, #0052a3);
}
.export-btn:focus-visible {
outline: 2px solid var(--focus-ring, #0066cc);
outline-offset: 2px;
}
.export-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.export-btn--pending,
.export-btn--processing {
background: var(--surface-secondary, #f5f5f5);
border-color: var(--border-subtle, #ddd);
color: var(--text-secondary, #666);
}
.export-btn--ready {
background: var(--status-success, #28a745);
border-color: var(--status-success, #28a745);
}
.export-btn--ready:hover {
background: var(--status-success-hover, #218838);
}
.export-btn--failed {
background: transparent;
border-color: var(--status-error, #dc3545);
color: var(--status-error, #dc3545);
}
.export-icon {
font-size: 1rem;
}
.file-size {
font-size: 0.75rem;
opacity: 0.8;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-subtle, #ddd);
border-top-color: var(--accent-primary, #0066cc);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.progress-ring {
width: 24px;
height: 24px;
}
.progress-svg {
width: 100%;
height: 100%;
}
.progress-bg {
fill: none;
stroke: var(--border-subtle, #ddd);
stroke-width: 3;
}
.progress-bar {
fill: none;
stroke: var(--accent-primary, #0066cc);
stroke-width: 3;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: 50% 50%;
transition: stroke-dasharray 0.3s ease;
}
.export-error {
display: flex;
align-items: center;
gap: 0.5rem;
}
.error-text {
font-size: 0.75rem;
color: var(--status-error, #dc3545);
}
.export-options {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem;
background: var(--surface-secondary, #f5f5f5);
border-radius: 4px;
font-size: 0.75rem;
}
.option-checkbox {
display: flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
}
.option-checkbox input {
margin: 0;
}
`]
})
export class ExportEvidenceButtonComponent implements OnDestroy {
private readonly http = inject(HttpClient);
private readonly destroy$ = new Subject<void>();
@Input() bundleId: string | null = null;
@Input() showOptions = false;
@Output() exportStarted = new EventEmitter<string>();
@Output() exportCompleted = new EventEmitter<string>();
@Output() exportFailed = new EventEmitter<string>();
readonly status = signal<ExportStatus>('idle');
readonly progress = signal(0);
readonly downloadUrl = signal<string | null>(null);
readonly fileSize = signal<number | null>(null);
readonly errorMessage = signal<string | null>(null);
readonly includeLayerSboms = signal(true);
readonly includeRekorProofs = signal(true);
private exportId: string | null = null;
readonly progressDasharray = computed(() => {
const p = this.progress();
return `${p}, 100`;
});
readonly fileSizeFormatted = computed(() => {
const size = this.fileSize();
if (!size) return null;
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
});
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
toggleLayerSboms(): void {
this.includeLayerSboms.update(v => !v);
}
toggleRekorProofs(): void {
this.includeRekorProofs.update(v => !v);
}
startExport(): void {
if (!this.bundleId) return;
this.status.set('pending');
this.progress.set(0);
this.errorMessage.set(null);
this.http.post<ExportTriggerResponse>(
`/api/v1/bundles/${this.bundleId}/export`,
{
includeLayerSboms: this.includeLayerSboms(),
includeRekorProofs: this.includeRekorProofs(),
}
).subscribe({
next: (response) => {
this.exportId = response.exportId;
this.exportStarted.emit(response.exportId);
this.pollStatus(response.statusUrl);
},
error: (err) => {
this.status.set('failed');
this.errorMessage.set(err.message ?? 'Failed to start export');
this.exportFailed.emit(err.message);
}
});
}
onDownload(): void {
if (this.exportId) {
this.exportCompleted.emit(this.exportId);
}
// Reset after a short delay
setTimeout(() => {
this.status.set('idle');
this.downloadUrl.set(null);
}, 3000);
}
private pollStatus(statusUrl: string): void {
this.status.set('processing');
interval(1000).pipe(
takeUntil(this.destroy$),
switchMap(() => this.http.get<ExportStatusResponse>(statusUrl)),
filter(response => {
// Update progress
if (response.progress !== undefined) {
this.progress.set(response.progress);
}
// Continue polling until ready or failed
return response.status === 'ready' || response.status === 'failed';
}),
take(1)
).subscribe({
next: (response) => {
if (response.status === 'ready') {
this.status.set('ready');
this.downloadUrl.set(response.downloadUrl ?? null);
this.fileSize.set(response.fileSize ?? null);
} else {
this.status.set('failed');
this.errorMessage.set(response.error ?? 'Export failed');
this.exportFailed.emit(response.error ?? 'Export failed');
}
},
error: (err) => {
this.status.set('failed');
this.errorMessage.set('Failed to check export status');
this.exportFailed.emit(err.message);
}
});
}
}

View File

@@ -0,0 +1,643 @@
// -----------------------------------------------------------------------------
// findings-detail-page.component.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Task: T026 - Wire components into findings detail page
// Description: Container component that integrates all triage UX components
// -----------------------------------------------------------------------------
import {
Component,
Input,
Output,
EventEmitter,
signal,
computed,
inject,
OnInit,
OnDestroy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subject, takeUntil, forkJoin } from 'rxjs';
// Components
import { TriageLaneToggleComponent, TriageLane } from '../triage-lane-toggle/triage-lane-toggle.component';
import { GatedBucketsComponent } from '../gated-buckets/gated-buckets.component';
import { GatingReasonFilterComponent, GatingReason } from '../gating-reason-filter/gating-reason-filter.component';
import { ProvenanceBreadcrumbComponent, BreadcrumbNavigation, FindingProvenance } from '../provenance-breadcrumb/provenance-breadcrumb.component';
import { DecisionDrawerEnhancedComponent, DecisionFormData, AlertSummary } from '../decision-drawer/decision-drawer-enhanced.component';
import { ExportEvidenceButtonComponent } from '../export-evidence-button/export-evidence-button.component';
// Services
import { GatingService } from '../../services/gating.service';
import { ReachGraphSliceService, CallPath } from '../../services/reach-graph-slice.service';
import { TtfsTelemetryService } from '../../services/ttfs-telemetry.service';
// Models
import { FindingGatingStatus, GatedBucketsSummary } from '../../models/gating.model';
export interface FindingDetail {
id: string;
advisoryId: string;
packageName: string;
packageVersion: string;
packagePurl: string;
severity: 'critical' | 'high' | 'medium' | 'low' | 'unknown';
status: 'open' | 'in_progress' | 'fixed' | 'excepted';
gatingStatus?: FindingGatingStatus;
isGated: boolean;
scanId: string;
imageRef?: string;
imageDigest?: string;
layerDigest?: string;
layerIndex?: number;
symbolName?: string;
}
@Component({
selector: 'app-findings-detail-page',
standalone: true,
imports: [
CommonModule,
TriageLaneToggleComponent,
GatedBucketsComponent,
GatingReasonFilterComponent,
ProvenanceBreadcrumbComponent,
DecisionDrawerEnhancedComponent,
ExportEvidenceButtonComponent,
],
template: `
<div class="findings-page">
<!-- Header with lane toggle and bucket chips -->
<header class="page-header">
<div class="header-left">
<h1>Findings</h1>
<app-triage-lane-toggle
[visibleCount]="actionableCount()"
[hiddenCount]="gatedCount()"
(laneChange)="onLaneChange($event)"
/>
</div>
<div class="header-right">
<app-gated-bucket-chips
*ngIf="bucketsSummary()"
[buckets]="bucketsSummary()!"
(filterChange)="onBucketFilter($event)"
/>
</div>
</header>
<!-- Filters bar -->
<div class="filters-bar" *ngIf="currentLane() === 'review'">
<app-gating-reason-filter
(reasonChange)="onGatingReasonFilter($event)"
/>
</div>
<!-- Findings list -->
<div class="findings-list" role="list">
<article
*ngFor="let finding of displayedFindings(); trackBy: trackFinding"
class="finding-card"
[class.finding-card--gated]="finding.isGated"
[class.finding-card--selected]="selectedFinding()?.id === finding.id"
role="listitem"
tabindex="0"
(click)="selectFinding(finding)"
(keydown.enter)="selectFinding(finding)"
>
<div class="finding-header">
<span class="finding-id">{{ finding.advisoryId }}</span>
<span class="severity-badge" [class]="'severity-' + finding.severity">
{{ finding.severity | uppercase }}
</span>
<!-- T007: Gated badge indicator -->
<span class="gated-badge" *ngIf="finding.isGated" aria-label="Gated finding">
<span class="gated-icon" aria-hidden="true">&#x1F512;</span>
{{ finding.gatingStatus?.reason ?? 'Gated' }}
</span>
</div>
<div class="finding-body">
<span class="package-name">{{ finding.packageName }}</span>
<span class="package-version">{{ finding.packageVersion }}</span>
</div>
</article>
</div>
<!-- Finding detail panel -->
<aside class="detail-panel" *ngIf="selectedFinding()" role="complementary">
<header class="detail-header">
<h2>{{ selectedFinding()!.advisoryId }}</h2>
<button class="close-btn" (click)="clearSelection()" aria-label="Close detail panel">
&times;
</button>
</header>
<!-- Provenance breadcrumb -->
<app-provenance-breadcrumb
*ngIf="selectedProvenance()"
[provenance]="selectedProvenance()!"
[callPath]="selectedCallPath()"
(navigation)="onBreadcrumbNavigation($event)"
/>
<!-- Status summary -->
<div class="status-summary">
<div class="status-item">
<span class="status-label">Status</span>
<span class="status-value">{{ selectedFinding()!.status | titlecase }}</span>
</div>
<div class="status-item" *ngIf="selectedFinding()!.gatingStatus">
<span class="status-label">Gating</span>
<span class="status-value">{{ selectedFinding()!.gatingStatus!.reason }}</span>
</div>
</div>
<!-- Call path visualization -->
<div class="call-path-section" *ngIf="selectedCallPath()">
<h3>Call Path</h3>
<div class="call-path-viz">
<div *ngFor="let node of selectedCallPath()!.nodes; let i = index" class="path-node">
<span class="node-name">{{ node.name }}</span>
<span class="node-location" *ngIf="node.file">{{ node.file }}:{{ node.line }}</span>
<span class="path-arrow" *ngIf="i < selectedCallPath()!.nodes.length - 1">&#x2193;</span>
</div>
</div>
</div>
<!-- Actions footer -->
<footer class="detail-footer">
<app-export-evidence-button
[bundleId]="selectedFinding()!.scanId"
[includeLayerSboms]="true"
/>
<button class="btn btn-primary" (click)="openDecisionDrawer()">
Record Decision
</button>
</footer>
</aside>
<!-- Decision drawer -->
<app-decision-drawer-enhanced
[alert]="drawerAlert()"
[isOpen]="isDrawerOpen()"
[evidenceHash]="evidenceHash()"
[policyVersion]="policyVersion()"
[isAdmin]="isAdmin"
(close)="closeDecisionDrawer()"
(decisionSubmit)="onDecisionSubmit($event)"
(decisionRevoked)="onDecisionRevoked($event)"
/>
</div>
`,
styles: [`
.findings-page {
display: grid;
grid-template-columns: 1fr 400px;
grid-template-rows: auto auto 1fr;
gap: 16px;
height: 100%;
padding: 16px;
}
.page-header {
grid-column: 1 / -1;
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.header-left {
display: flex;
align-items: center;
gap: 24px;
}
.header-left h1 {
margin: 0;
font-size: 24px;
}
.filters-bar {
grid-column: 1;
display: flex;
gap: 16px;
align-items: center;
}
.findings-list {
grid-column: 1;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
}
.finding-card {
padding: 16px;
background: var(--surface-color, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.finding-card:hover {
border-color: var(--primary-color, #1976d2);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.finding-card:focus {
outline: 2px solid var(--primary-color, #1976d2);
outline-offset: 2px;
}
.finding-card--selected {
border-color: var(--primary-color, #1976d2);
background: var(--primary-bg, #e3f2fd);
}
/* T007: Gated finding styling */
.finding-card--gated {
opacity: 0.7;
background: var(--surface-variant, #f5f5f5);
}
.finding-card--gated .finding-id {
color: var(--text-secondary, #666);
}
.finding-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.finding-id {
font-weight: 600;
font-size: 14px;
}
.severity-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
}
.severity-critical { background: #ffebee; color: #c62828; }
.severity-high { background: #fff3e0; color: #e65100; }
.severity-medium { background: #fffde7; color: #f9a825; }
.severity-low { background: #e8f5e9; color: #2e7d32; }
.severity-unknown { background: #f5f5f5; color: #616161; }
/* T007: Gated badge */
.gated-badge {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 2px 8px;
background: var(--surface-variant, #e0e0e0);
border-radius: 4px;
color: var(--text-secondary, #666);
}
.gated-icon {
font-size: 10px;
}
.finding-body {
display: flex;
gap: 8px;
font-size: 13px;
color: var(--text-secondary, #666);
}
.detail-panel {
grid-column: 2;
grid-row: 2 / -1;
background: var(--surface-color, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.detail-header h2 {
margin: 0;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-secondary, #666);
}
.status-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.status-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.status-label {
font-size: 12px;
color: var(--text-secondary, #666);
}
.status-value {
font-size: 14px;
font-weight: 500;
}
.call-path-section {
padding: 16px;
flex: 1;
overflow-y: auto;
}
.call-path-section h3 {
margin: 0 0 12px;
font-size: 14px;
color: var(--text-secondary, #666);
}
.call-path-viz {
display: flex;
flex-direction: column;
gap: 8px;
}
.path-node {
display: flex;
flex-direction: column;
padding: 8px;
background: var(--surface-variant, #f5f5f5);
border-radius: 4px;
font-size: 13px;
}
.node-name {
font-weight: 500;
font-family: ui-monospace, monospace;
}
.node-location {
font-size: 11px;
color: var(--text-tertiary, #888);
}
.path-arrow {
text-align: center;
color: var(--text-secondary, #666);
}
.detail-footer {
padding: 16px;
border-top: 1px solid var(--border-color, #e0e0e0);
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn {
padding: 10px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-primary {
background: var(--primary-color, #1976d2);
color: white;
border: none;
}
.btn-primary:hover {
background: var(--primary-dark, #1565c0);
}
/* High contrast mode */
@media (prefers-contrast: high) {
.finding-card {
border-width: 2px;
}
.gated-badge {
border: 1px solid currentColor;
}
.severity-badge {
border: 1px solid currentColor;
}
}
`]
})
export class FindingsDetailPageComponent implements OnInit, OnDestroy {
private readonly gatingService = inject(GatingService);
private readonly reachGraphService = inject(ReachGraphSliceService);
private readonly ttfsService = inject(TtfsTelemetryService);
private readonly destroy$ = new Subject<void>();
@Input() findings: FindingDetail[] = [];
@Input() scanId = '';
@Input() isAdmin = false;
@Input() policyVersion = 'v1.0.0';
@Output() findingSelected = new EventEmitter<FindingDetail>();
@Output() decisionRecorded = new EventEmitter<{ findingId: string; decision: DecisionFormData }>();
readonly currentLane = signal<TriageLane>('quiet');
readonly gatingReasonFilter = signal<GatingReason>('All');
readonly bucketsSummary = signal<GatedBucketsSummary | null>(null);
readonly selectedFinding = signal<FindingDetail | null>(null);
readonly selectedCallPath = signal<CallPath | null>(null);
readonly isDrawerOpen = signal(false);
readonly evidenceHash = signal('');
// T004: Filter by lane
readonly displayedFindings = computed(() => {
let results = this.findings;
const lane = this.currentLane();
// T004: Filter by lane (quiet = actionable, review = gated)
if (lane === 'quiet') {
results = results.filter(f => !f.isGated);
} else {
results = results.filter(f => f.isGated);
// T006: Further filter by gating reason
const reason = this.gatingReasonFilter();
if (reason !== 'All') {
results = results.filter(f =>
f.gatingStatus?.reason?.toLowerCase().includes(reason.toLowerCase())
);
}
}
return results;
});
readonly actionableCount = computed(() =>
this.findings.filter(f => !f.isGated).length
);
readonly gatedCount = computed(() =>
this.findings.filter(f => f.isGated).length
);
readonly selectedProvenance = computed<FindingProvenance | null>(() => {
const finding = this.selectedFinding();
if (!finding) return null;
return {
imageRef: finding.imageRef ?? '',
imageDigest: finding.imageDigest ?? '',
layerDigest: finding.layerDigest ?? '',
layerIndex: finding.layerIndex ?? 0,
packagePurl: finding.packagePurl,
symbolName: finding.symbolName,
callPath: this.selectedCallPath()
? this.reachGraphService.formatCallPathForBreadcrumb(this.selectedCallPath()!)
: undefined,
attestations: {
image: true,
layer: true,
package: false,
symbol: false,
},
};
});
readonly drawerAlert = computed<AlertSummary | undefined>(() => {
const finding = this.selectedFinding();
if (!finding) return undefined;
return {
id: finding.id,
artifactId: finding.packagePurl,
vulnId: finding.advisoryId,
severity: finding.severity,
scanId: finding.scanId,
};
});
ngOnInit(): void {
if (this.scanId) {
this.loadBucketsSummary();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadBucketsSummary(): void {
this.gatingService.getGatedBucketsSummary(this.scanId)
.pipe(takeUntil(this.destroy$))
.subscribe(summary => {
this.bucketsSummary.set(summary);
});
}
onLaneChange(lane: TriageLane): void {
this.currentLane.set(lane);
}
onBucketFilter(reason: string): void {
this.currentLane.set('review');
this.gatingReasonFilter.set(reason as GatingReason);
}
onGatingReasonFilter(reason: GatingReason): void {
this.gatingReasonFilter.set(reason);
}
selectFinding(finding: FindingDetail): void {
this.selectedFinding.set(finding);
this.findingSelected.emit(finding);
// T029: Start TTFS tracking
this.ttfsService.startTracking(finding.id, new Date());
this.ttfsService.recordSkeletonRender(finding.id);
// T011: Load call path data
this.loadCallPath(finding);
}
private loadCallPath(finding: FindingDetail): void {
this.reachGraphService.getPrimaryCallPath(finding.scanId, finding.id)
.pipe(takeUntil(this.destroy$))
.subscribe(path => {
this.selectedCallPath.set(path);
// T029: Record first evidence
if (path) {
this.ttfsService.recordFirstEvidence(finding.id, 'callPath');
}
});
}
clearSelection(): void {
this.selectedFinding.set(null);
this.selectedCallPath.set(null);
}
onBreadcrumbNavigation(nav: BreadcrumbNavigation): void {
console.log('Breadcrumb navigation:', nav);
// Handle navigation to different levels
}
openDecisionDrawer(): void {
this.isDrawerOpen.set(true);
}
closeDecisionDrawer(): void {
this.isDrawerOpen.set(false);
}
onDecisionSubmit(decision: DecisionFormData): void {
const finding = this.selectedFinding();
if (finding) {
this.decisionRecorded.emit({ findingId: finding.id, decision });
// T029: Record decision
this.ttfsService.recordDecision(finding.id, decision.status);
}
}
onDecisionRevoked(findingId: string): void {
console.log('Decision revoked for:', findingId);
// Refresh finding data
}
trackFinding(_index: number, finding: FindingDetail): string {
return finding.id;
}
}

View File

@@ -0,0 +1,169 @@
// -----------------------------------------------------------------------------
// gating-reason-filter.component.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Task: T006 - Add GatingReasonFilter dropdown
// Description: Dropdown filter to select specific gating reasons
// -----------------------------------------------------------------------------
import { Component, Output, EventEmitter, signal, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
export type GatingReason =
| 'Unreachable'
| 'VexNotAffected'
| 'Backported'
| 'KnownFalsePositive'
| 'BelowThreshold'
| 'NoExploit'
| 'ControlsPresent'
| 'All';
export interface GatingReasonOption {
value: GatingReason;
label: string;
description: string;
count?: number;
}
@Component({
selector: 'app-gating-reason-filter',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="gating-filter">
<label class="gating-filter__label" for="gating-reason-select">
Filter by reason
</label>
<select
id="gating-reason-select"
class="gating-filter__select"
[ngModel]="selectedReason()"
(ngModelChange)="onReasonChange($event)"
aria-label="Filter by gating reason"
>
<option value="All">All gated findings</option>
<option *ngFor="let opt of options" [value]="opt.value" [disabled]="opt.count === 0">
{{ opt.label }}{{ opt.count !== undefined ? ' (' + opt.count + ')' : '' }}
</option>
</select>
<!-- Inline description -->
<p class="gating-filter__description" *ngIf="selectedDescription()">
{{ selectedDescription() }}
</p>
</div>
`,
styles: [`
:host {
display: block;
}
.gating-filter {
display: flex;
flex-direction: column;
gap: 4px;
}
.gating-filter__label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #666);
}
.gating-filter__select {
padding: 8px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
background: var(--surface-color, #fff);
font-size: 14px;
cursor: pointer;
min-width: 200px;
}
.gating-filter__select:focus {
outline: 2px solid var(--primary-color, #1976d2);
outline-offset: 2px;
}
.gating-filter__select:hover {
border-color: var(--border-hover, #999);
}
.gating-filter__description {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-tertiary, #888);
font-style: italic;
}
/* High contrast mode */
@media (prefers-contrast: high) {
.gating-filter__select {
border-width: 2px;
}
.gating-filter__select:focus {
outline-width: 3px;
}
}
`]
})
export class GatingReasonFilterComponent {
@Input() set reasons(value: GatingReasonOption[]) {
this.options = value;
}
@Output() reasonChange = new EventEmitter<GatingReason>();
readonly selectedReason = signal<GatingReason>('All');
options: GatingReasonOption[] = [
{
value: 'Unreachable',
label: 'Not Reachable',
description: 'Vulnerable code is not in any execution path',
},
{
value: 'VexNotAffected',
label: 'VEX Not Affected',
description: 'Vendor VEX statement declares not affected',
},
{
value: 'Backported',
label: 'Backported',
description: 'Fix has been backported to this version',
},
{
value: 'KnownFalsePositive',
label: 'Known False Positive',
description: 'Previously triaged as false positive',
},
{
value: 'BelowThreshold',
label: 'Below Threshold',
description: 'Score below configured threshold',
},
{
value: 'NoExploit',
label: 'No Known Exploit',
description: 'No known exploit or proof-of-concept',
},
{
value: 'ControlsPresent',
label: 'Mitigating Controls',
description: 'Compensating controls are in place',
},
];
readonly selectedDescription = () => {
const reason = this.selectedReason();
if (reason === 'All') return '';
return this.options.find(o => o.value === reason)?.description ?? '';
};
onReasonChange(reason: GatingReason): void {
this.selectedReason.set(reason);
this.reasonChange.emit(reason);
}
}

View File

@@ -1,6 +1,6 @@
// -----------------------------------------------------------------------------
// index.ts
// Sprint: SPRINT_20251226_013_FE_triage_canvas
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Export all triage canvas components
// -----------------------------------------------------------------------------
@@ -14,6 +14,28 @@ export {
type FilterChange,
} from './triage-list/triage-list.component';
// Lane Toggle (Sprint: SPRINT_20260106_004_001)
export {
TriageLaneToggleComponent,
type TriageLane,
} from './triage-lane-toggle/triage-lane-toggle.component';
// Provenance Breadcrumb (Sprint: SPRINT_20260106_004_001)
export {
ProvenanceBreadcrumbComponent,
type BreadcrumbLevel,
type BreadcrumbNode,
type BreadcrumbNavigation,
type FindingProvenance,
} from './provenance-breadcrumb/provenance-breadcrumb.component';
// Export Evidence Button (Sprint: SPRINT_20260106_004_001)
export {
ExportEvidenceButtonComponent,
type ExportStatus,
type ExportProgress,
} from './export-evidence-button/export-evidence-button.component';
// AI Integration
export {
AiRecommendationPanelComponent,
@@ -51,7 +73,14 @@ export {
// Re-export existing components
export { KeyboardHelpComponent } from './keyboard-help/keyboard-help.component';
export { DecisionDrawerComponent } from './decision-drawer/decision-drawer.component';
export { DecisionDrawerEnhancedComponent, type DecisionFormData, type AlertSummary, type ApprovalRequest } from './decision-drawer/decision-drawer-enhanced.component';
export { EvidencePillsComponent } from './evidence-pills/evidence-pills.component';
// Gating Reason Filter (Sprint: SPRINT_20260106_004_001)
export { GatingReasonFilterComponent, type GatingReason, type GatingReasonOption } from './gating-reason-filter/gating-reason-filter.component';
// Findings Detail Page (Sprint: SPRINT_20260106_004_001)
export { FindingsDetailPageComponent, type FindingDetail } from './findings-detail-page/findings-detail-page.component';
export { GatedBucketsComponent } from './gated-buckets/gated-buckets.component';
export { GatingExplainerComponent } from './gating-explainer/gating-explainer.component';
export { VexTrustDisplayComponent } from './vex-trust-display/vex-trust-display.component';

View File

@@ -0,0 +1,147 @@
// -----------------------------------------------------------------------------
// provenance-breadcrumb.component.spec.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Task: T014 - Unit tests for breadcrumb navigation
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
ProvenanceBreadcrumbComponent,
FindingProvenance,
BreadcrumbNavigation
} from './provenance-breadcrumb.component';
describe('ProvenanceBreadcrumbComponent', () => {
let component: ProvenanceBreadcrumbComponent;
let fixture: ComponentFixture<ProvenanceBreadcrumbComponent>;
const mockProvenance: FindingProvenance = {
imageRef: 'registry.example.com/myapp:v1.2.3',
imageDigest: 'sha256:abc123',
layerDigest: 'sha256:layer456',
layerIndex: 3,
packagePurl: 'pkg:npm/lodash@4.17.21',
symbolName: 'merge',
callPath: 'main() -> processData() -> merge()',
attestations: {
image: true,
layer: true,
package: false,
symbol: false
}
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProvenanceBreadcrumbComponent]
}).compileComponents();
fixture = TestBed.createComponent(ProvenanceBreadcrumbComponent);
component = fixture.componentInstance;
component.provenance = mockProvenance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display image reference', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('myapp:v1.2.3');
});
it('should display layer index', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Layer 3');
});
it('should display package purl', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('lodash');
});
it('should display symbol name', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('merge');
});
it('should display call path', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('main()');
});
it('should show attestation badge for image', () => {
const compiled = fixture.nativeElement as HTMLElement;
const badges = compiled.querySelectorAll('.attestation-badge');
expect(badges.length).toBeGreaterThan(0);
});
it('should emit navigation event on navigate', () => {
let emitted: BreadcrumbNavigation | undefined;
component.navigation.subscribe(nav => emitted = nav);
component.onNavigate('layer');
expect(emitted).toBeDefined();
expect(emitted?.level).toBe('layer');
expect(emitted?.action).toBe('navigate');
expect(emitted?.digest).toBe('sha256:layer456');
});
it('should emit view-attestation action', () => {
let emitted: BreadcrumbNavigation | undefined;
component.navigation.subscribe(nav => emitted = nav);
component.onViewAttestation('image');
expect(emitted?.action).toBe('view-attestation');
expect(emitted?.digest).toBe('sha256:abc123');
});
it('should emit view-sbom action', () => {
let emitted: BreadcrumbNavigation | undefined;
component.navigation.subscribe(nav => emitted = nav);
component.onViewSbom('layer');
expect(emitted?.action).toBe('view-sbom');
});
it('should truncate long image refs', () => {
component.provenance = {
...mockProvenance,
imageRef: 'registry.example.com/very/long/path/to/myapp:v1.2.3@sha256:abcdef'
};
fixture.detectChanges();
expect(component.truncatedImageRef().length).toBeLessThanOrEqual(40);
});
it('should truncate long purls', () => {
component.provenance = {
...mockProvenance,
packagePurl: 'pkg:npm/@very-long-scope/very-long-package-name@1.2.3-beta.1+build.123'
};
fixture.detectChanges();
expect(component.truncatedPurl().length).toBeLessThanOrEqual(50);
});
it('should handle provenance without symbol', () => {
component.provenance = {
...mockProvenance,
symbolName: undefined,
callPath: undefined
};
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).not.toContain('merge');
});
it('should update current level', () => {
component.level = 'package';
expect(component.currentLevel()).toBe('package');
});
});

View File

@@ -0,0 +1,447 @@
// -----------------------------------------------------------------------------
// provenance-breadcrumb.component.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Tasks: T009-T013 - Breadcrumb navigation from Image to Call-Path
// Description: Navigation breadcrumb showing artifact provenance path with
// attestation indicators at each hop.
// -----------------------------------------------------------------------------
import {
Component,
Input,
Output,
EventEmitter,
signal,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
export type BreadcrumbLevel = 'image' | 'layer' | 'package' | 'symbol' | 'call-path';
export interface BreadcrumbNode {
level: BreadcrumbLevel;
label: string;
digest?: string;
hasAttestation: boolean;
attestationType?: 'sbom' | 'vex' | 'policy' | 'chain';
isCurrent?: boolean;
}
export interface BreadcrumbNavigation {
level: BreadcrumbLevel;
digest?: string;
action: 'navigate' | 'view-attestation' | 'view-sbom';
}
export interface FindingProvenance {
imageRef: string;
imageDigest: string;
layerDigest: string;
layerIndex: number;
packagePurl: string;
symbolName?: string;
callPath?: string;
attestations: {
image?: boolean;
layer?: boolean;
package?: boolean;
symbol?: boolean;
};
}
@Component({
selector: 'app-provenance-breadcrumb',
standalone: true,
imports: [CommonModule],
template: `
<nav class="breadcrumb-bar" aria-label="Provenance path">
<ol class="breadcrumb-list">
<!-- Image -->
<li class="breadcrumb-item">
<button
class="breadcrumb-link"
[class.breadcrumb-link--current]="currentLevel() === 'image'"
(click)="onNavigate('image')"
[attr.aria-current]="currentLevel() === 'image' ? 'page' : null"
>
<span class="breadcrumb-icon" aria-hidden="true">📦</span>
<span class="breadcrumb-label" [title]="provenance?.imageRef">
{{ truncatedImageRef() }}
</span>
@if (provenance?.attestations?.image) {
<span class="attestation-badge" title="SBOM attestation present">
</span>
}
</button>
<button
class="breadcrumb-action"
(click)="onViewAttestation('image')"
title="View image attestation"
aria-label="View image attestation"
>
🔗
</button>
</li>
<li class="breadcrumb-separator" aria-hidden="true"></li>
<!-- Layer -->
<li class="breadcrumb-item">
<button
class="breadcrumb-link"
[class.breadcrumb-link--current]="currentLevel() === 'layer'"
(click)="onNavigate('layer')"
[attr.aria-current]="currentLevel() === 'layer' ? 'page' : null"
>
<span class="breadcrumb-icon" aria-hidden="true">📄</span>
<span class="breadcrumb-label" [title]="provenance?.layerDigest">
Layer {{ provenance?.layerIndex ?? 0 }}
</span>
@if (provenance?.attestations?.layer) {
<span class="attestation-badge" title="Layer SBOM attestation">
</span>
}
</button>
<button
class="breadcrumb-action"
(click)="onViewSbom('layer')"
title="View layer SBOM"
aria-label="View layer SBOM"
>
📋
</button>
</li>
<li class="breadcrumb-separator" aria-hidden="true"></li>
<!-- Package -->
<li class="breadcrumb-item">
<button
class="breadcrumb-link"
[class.breadcrumb-link--current]="currentLevel() === 'package'"
(click)="onNavigate('package')"
[attr.aria-current]="currentLevel() === 'package' ? 'page' : null"
>
<span class="breadcrumb-icon" aria-hidden="true">📚</span>
<span class="breadcrumb-label" [title]="provenance?.packagePurl">
{{ truncatedPurl() }}
</span>
@if (provenance?.attestations?.package) {
<span class="attestation-badge" title="Package attestation">
</span>
}
</button>
</li>
@if (provenance?.symbolName) {
<li class="breadcrumb-separator" aria-hidden="true"></li>
<!-- Symbol -->
<li class="breadcrumb-item">
<button
class="breadcrumb-link"
[class.breadcrumb-link--current]="currentLevel() === 'symbol'"
(click)="onNavigate('symbol')"
[attr.aria-current]="currentLevel() === 'symbol' ? 'page' : null"
>
<span class="breadcrumb-icon" aria-hidden="true">⚙️</span>
<span class="breadcrumb-label" [title]="provenance?.symbolName">
{{ truncatedSymbol() }}
</span>
</button>
<button
class="breadcrumb-action"
(click)="onViewReachGraph()"
title="View in ReachGraph"
aria-label="View function in ReachGraph"
>
🔍
</button>
</li>
}
@if (provenance?.callPath) {
<li class="breadcrumb-separator" aria-hidden="true"></li>
<!-- Call Path (current) -->
<li class="breadcrumb-item breadcrumb-item--current">
<span class="breadcrumb-current" aria-current="page">
<span class="breadcrumb-icon" aria-hidden="true">📍</span>
<span class="breadcrumb-label" [title]="provenance?.callPath">
{{ truncatedCallPath() }}
</span>
</span>
</li>
}
</ol>
<!-- Copy full path button -->
<button
class="copy-path-btn"
(click)="copyFullPath()"
[class.copy-path-btn--copied]="copied()"
title="Copy full provenance path"
aria-label="Copy full provenance path to clipboard"
>
{{ copied() ? '✓ Copied' : '📋 Copy' }}
</button>
</nav>
`,
styles: [`
:host {
display: block;
}
.breadcrumb-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: var(--surface-secondary, #f8f9fa);
border-radius: 8px;
border: 1px solid var(--border-subtle, #e0e0e0);
}
.breadcrumb-list {
display: flex;
align-items: center;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
overflow-x: auto;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.breadcrumb-separator {
color: var(--text-tertiary, #999);
font-size: 1rem;
padding: 0 0.25rem;
}
.breadcrumb-link {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
font-size: 0.8125rem;
color: var(--text-secondary, #555);
transition: all 0.15s ease;
}
.breadcrumb-link:hover {
background: var(--surface-hover, #e9ecef);
color: var(--text-primary, #1a1a1a);
}
.breadcrumb-link:focus-visible {
outline: 2px solid var(--focus-ring, #0066cc);
outline-offset: 2px;
}
.breadcrumb-link--current {
background: var(--surface-primary, #fff);
color: var(--text-primary, #1a1a1a);
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.breadcrumb-current {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent-primary, #0066cc);
}
.breadcrumb-icon {
font-size: 0.875rem;
}
.breadcrumb-label {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attestation-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--status-success-bg, #d4edda);
color: var(--status-success, #28a745);
font-size: 0.625rem;
}
.breadcrumb-action {
padding: 0.25rem;
border: none;
background: transparent;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
}
.breadcrumb-action:hover {
opacity: 1;
}
.breadcrumb-action:focus-visible {
outline: 2px solid var(--focus-ring, #0066cc);
outline-offset: 2px;
}
.copy-path-btn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-subtle, #e0e0e0);
background: var(--surface-primary, #fff);
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.copy-path-btn:hover {
border-color: var(--accent-primary, #0066cc);
}
.copy-path-btn--copied {
background: var(--status-success-bg, #d4edda);
border-color: var(--status-success, #28a745);
color: var(--status-success, #28a745);
}
/* High contrast mode */
@media (prefers-contrast: high) {
.breadcrumb-link--current,
.breadcrumb-current {
border: 2px solid currentColor;
}
}
`]
})
export class ProvenanceBreadcrumbComponent {
@Input() provenance: FindingProvenance | null = null;
@Input() set level(value: BreadcrumbLevel) {
this.currentLevel.set(value);
}
@Output() navigation = new EventEmitter<BreadcrumbNavigation>();
readonly currentLevel = signal<BreadcrumbLevel>('call-path');
readonly copied = signal(false);
readonly truncatedImageRef = computed(() => {
const ref = this.provenance?.imageRef ?? '';
if (ref.length <= 30) return ref;
// Show last part after /
const parts = ref.split('/');
const last = parts[parts.length - 1];
return last.length > 30 ? `...${last.slice(-27)}` : last;
});
readonly truncatedPurl = computed(() => {
const purl = this.provenance?.packagePurl ?? '';
if (purl.length <= 40) return purl;
// Show type and name
const match = purl.match(/pkg:(\w+)\/([^@]+)/);
if (match) {
return `pkg:${match[1]}/${match[2].slice(0, 20)}...`;
}
return `${purl.slice(0, 37)}...`;
});
readonly truncatedSymbol = computed(() => {
const symbol = this.provenance?.symbolName ?? '';
if (symbol.length <= 30) return symbol;
return `${symbol.slice(0, 27)}...`;
});
readonly truncatedCallPath = computed(() => {
const path = this.provenance?.callPath ?? '';
if (path.length <= 40) return path;
return `${path.slice(0, 37)}...`;
});
onNavigate(level: BreadcrumbLevel): void {
this.currentLevel.set(level);
this.navigation.emit({
level,
digest: this.getDigestForLevel(level),
action: 'navigate'
});
}
onViewAttestation(level: BreadcrumbLevel): void {
this.navigation.emit({
level,
digest: this.getDigestForLevel(level),
action: 'view-attestation'
});
}
onViewSbom(level: BreadcrumbLevel): void {
this.navigation.emit({
level,
digest: this.getDigestForLevel(level),
action: 'view-sbom'
});
}
onViewReachGraph(): void {
this.navigation.emit({
level: 'symbol',
action: 'navigate'
});
}
async copyFullPath(): Promise<void> {
if (!this.provenance) return;
const path = [
this.provenance.imageRef,
`layer:${this.provenance.layerIndex}`,
this.provenance.packagePurl,
this.provenance.symbolName,
this.provenance.callPath
].filter(Boolean).join(' → ');
try {
await navigator.clipboard.writeText(path);
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
} catch {
console.error('Failed to copy to clipboard');
}
}
private getDigestForLevel(level: BreadcrumbLevel): string | undefined {
switch (level) {
case 'image':
return this.provenance?.imageDigest;
case 'layer':
return this.provenance?.layerDigest;
default:
return undefined;
}
}
}

View File

@@ -0,0 +1,115 @@
// -----------------------------------------------------------------------------
// triage-lane-toggle.component.spec.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Task: T008 - Unit tests for lane toggle
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TriageLaneToggleComponent, TriageLane } from './triage-lane-toggle.component';
describe('TriageLaneToggleComponent', () => {
let component: TriageLaneToggleComponent;
let fixture: ComponentFixture<TriageLaneToggleComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TriageLaneToggleComponent]
}).compileComponents();
fixture = TestBed.createComponent(TriageLaneToggleComponent);
component = fixture.componentInstance;
component.visibleCount = 25;
component.hiddenCount = 100;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should default to quiet lane', () => {
expect(component.currentLane()).toBe('quiet');
});
it('should display visible count for actionable lane', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('(25)');
});
it('should display hidden count for review lane', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('(100)');
});
it('should emit laneChange when switching to review', () => {
const spy = jest.spyOn(component.laneChange, 'emit');
component.selectLane('review');
expect(spy).toHaveBeenCalledWith('review');
expect(component.currentLane()).toBe('review');
});
it('should emit laneChange when switching to quiet', () => {
component.selectLane('review'); // First switch to review
const spy = jest.spyOn(component.laneChange, 'emit');
spy.calls?.reset?.();
component.selectLane('quiet');
expect(spy).toHaveBeenCalledWith('quiet');
expect(component.currentLane()).toBe('quiet');
});
it('should not emit when selecting current lane', () => {
const spy = jest.spyOn(component.laneChange, 'emit');
component.selectLane('quiet'); // Already on quiet
expect(spy).not.toHaveBeenCalled();
});
it('should accept lane input', () => {
component.lane = 'review';
fixture.detectChanges();
expect(component.currentLane()).toBe('review');
});
it('should have correct aria-selected attributes', () => {
const compiled = fixture.nativeElement as HTMLElement;
const buttons = compiled.querySelectorAll('button[role="tab"]');
expect(buttons[0].getAttribute('aria-selected')).toBe('true');
expect(buttons[1].getAttribute('aria-selected')).toBe('false');
component.selectLane('review');
fixture.detectChanges();
expect(buttons[0].getAttribute('aria-selected')).toBe('false');
expect(buttons[1].getAttribute('aria-selected')).toBe('true');
});
it('should compute total count', () => {
expect(component.totalCount()).toBe(125);
});
describe('keyboard shortcuts', () => {
it('should switch to quiet on Q key', () => {
component.selectLane('review');
const event = new KeyboardEvent('keydown', { key: 'q' });
component.onQuietShortcut(event);
expect(component.currentLane()).toBe('quiet');
});
it('should switch to review on R key', () => {
const event = new KeyboardEvent('keydown', { key: 'r' });
component.onReviewShortcut(event);
expect(component.currentLane()).toBe('review');
});
});
});

View File

@@ -0,0 +1,195 @@
// -----------------------------------------------------------------------------
// triage-lane-toggle.component.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Task: T002 - Create TriageLaneToggle component
// Description: Toggle between Quiet (actionable) and Review (hidden/gated) lanes
// -----------------------------------------------------------------------------
import {
Component,
Input,
Output,
EventEmitter,
signal,
computed,
HostListener,
} from '@angular/core';
import { CommonModule } from '@angular/common';
export type TriageLane = 'quiet' | 'review';
@Component({
selector: 'app-triage-lane-toggle',
standalone: true,
imports: [CommonModule],
template: `
<div class="lane-toggle" role="tablist" aria-label="Triage lane selection">
<button
class="lane-toggle__btn"
[class.lane-toggle__btn--active]="currentLane() === 'quiet'"
role="tab"
[attr.aria-selected]="currentLane() === 'quiet'"
[attr.aria-controls]="'findings-panel'"
tabindex="0"
(click)="selectLane('quiet')"
(keydown.ArrowRight)="selectLane('review')"
>
<span class="lane-toggle__icon" aria-hidden="true">✓</span>
<span class="lane-toggle__label">Actionable</span>
<span class="lane-toggle__count" [attr.aria-label]="visibleCount + ' actionable findings'">
({{ visibleCount }})
</span>
</button>
<button
class="lane-toggle__btn"
[class.lane-toggle__btn--active]="currentLane() === 'review'"
role="tab"
[attr.aria-selected]="currentLane() === 'review'"
[attr.aria-controls]="'findings-panel'"
tabindex="0"
(click)="selectLane('review')"
(keydown.ArrowLeft)="selectLane('quiet')"
>
<span class="lane-toggle__icon" aria-hidden="true">👁</span>
<span class="lane-toggle__label">Review</span>
<span class="lane-toggle__count" [attr.aria-label]="hiddenCount + ' hidden findings'">
({{ hiddenCount }})
</span>
</button>
<!-- Keyboard hint -->
<span class="lane-toggle__hint" aria-hidden="true">
Press <kbd>Q</kbd> for Quiet, <kbd>R</kbd> for Review
</span>
</div>
`,
styles: [`
:host {
display: inline-block;
}
.lane-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
background: var(--surface-secondary, #f5f5f5);
border-radius: 8px;
padding: 4px;
}
.lane-toggle__btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #666);
transition: all 0.15s ease;
}
.lane-toggle__btn:hover {
background: var(--surface-hover, #e0e0e0);
}
.lane-toggle__btn:focus-visible {
outline: 2px solid var(--focus-ring, #0066cc);
outline-offset: 2px;
}
.lane-toggle__btn--active {
background: var(--surface-primary, #fff);
color: var(--text-primary, #1a1a1a);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.lane-toggle__icon {
font-size: 1rem;
}
.lane-toggle__count {
color: var(--text-tertiary, #999);
font-weight: 400;
}
.lane-toggle__btn--active .lane-toggle__count {
color: var(--accent-primary, #0066cc);
font-weight: 600;
}
.lane-toggle__hint {
margin-left: 1rem;
font-size: 0.75rem;
color: var(--text-tertiary, #999);
}
.lane-toggle__hint kbd {
display: inline-block;
padding: 0.125rem 0.375rem;
background: var(--surface-tertiary, #e0e0e0);
border-radius: 3px;
font-family: monospace;
font-size: 0.75rem;
}
/* High contrast mode */
@media (prefers-contrast: high) {
.lane-toggle__btn--active {
border: 2px solid currentColor;
}
}
`]
})
export class TriageLaneToggleComponent {
@Input() visibleCount = 0;
@Input() hiddenCount = 0;
@Input() set lane(value: TriageLane) {
this.currentLane.set(value);
}
@Output() laneChange = new EventEmitter<TriageLane>();
readonly currentLane = signal<TriageLane>('quiet');
readonly totalCount = computed(() => this.visibleCount + this.hiddenCount);
selectLane(lane: TriageLane): void {
if (this.currentLane() !== lane) {
this.currentLane.set(lane);
this.laneChange.emit(lane);
}
}
@HostListener('document:keydown.q', ['$event'])
onQuietShortcut(event: KeyboardEvent): void {
// Only if not in an input field
if (!this.isInputFocused()) {
event.preventDefault();
this.selectLane('quiet');
}
}
@HostListener('document:keydown.r', ['$event'])
onReviewShortcut(event: KeyboardEvent): void {
// Only if not in an input field
if (!this.isInputFocused()) {
event.preventDefault();
this.selectLane('review');
}
}
private isInputFocused(): boolean {
const active = document.activeElement;
return (
active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement ||
active instanceof HTMLSelectElement ||
active?.getAttribute('contenteditable') === 'true'
);
}
}

View File

@@ -0,0 +1,214 @@
// -----------------------------------------------------------------------------
// reach-graph-slice.service.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Task: T011 - Integrate with ReachGraphSliceService API
// Description: Service for fetching call-path data from the reachability graph
// -----------------------------------------------------------------------------
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, catchError, of, map } from 'rxjs';
/**
* A node in the reachability graph representing a code location.
*/
export interface ReachGraphNode {
id: string;
type: 'entrypoint' | 'method' | 'function' | 'vulnerability';
name: string;
signature?: string;
file?: string;
line?: number;
packagePurl?: string;
confidence: number;
}
/**
* An edge connecting two nodes in the reachability graph.
*/
export interface ReachGraphEdge {
from: string;
to: string;
type: 'call' | 'import' | 'inheritance' | 'interface';
confidence: number;
attestedAt?: string;
}
/**
* A complete call path from entrypoint to vulnerable code.
*/
export interface CallPath {
id: string;
entrypoint: ReachGraphNode;
vulnerability: ReachGraphNode;
nodes: ReachGraphNode[];
edges: ReachGraphEdge[];
depth: number;
minConfidence: number;
maxConfidence: number;
hasAttestation: boolean;
attestationDigest?: string;
}
/**
* Slice of the reachability graph for a specific vulnerability.
*/
export interface ReachGraphSlice {
vulnerabilityId: string;
packagePurl: string;
paths: CallPath[];
totalPaths: number;
truncated: boolean;
computedAt: string;
graphDigest: string;
}
/**
* Options for fetching graph slices.
*/
export interface ReachGraphSliceOptions {
maxPaths?: number;
maxDepth?: number;
minConfidence?: number;
includeAttestations?: boolean;
}
/**
* Service for interacting with the ReachGraph Slice API.
* Provides call-path data for breadcrumb navigation and reachability visualization.
*/
@Injectable({
providedIn: 'root'
})
export class ReachGraphSliceService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/reachability';
/**
* Get the graph slice for a specific vulnerability in a scan.
*/
getSlice(
scanId: string,
vulnerabilityId: string,
packagePurl: string,
options?: ReachGraphSliceOptions
): Observable<ReachGraphSlice | null> {
let params = new HttpParams()
.set('vulnerabilityId', vulnerabilityId)
.set('packagePurl', packagePurl);
if (options?.maxPaths) {
params = params.set('maxPaths', options.maxPaths.toString());
}
if (options?.maxDepth) {
params = params.set('maxDepth', options.maxDepth.toString());
}
if (options?.minConfidence !== undefined) {
params = params.set('minConfidence', options.minConfidence.toString());
}
if (options?.includeAttestations !== undefined) {
params = params.set('includeAttestations', options.includeAttestations.toString());
}
return this.http.get<ReachGraphSlice>(`${this.baseUrl}/scans/${scanId}/slice`, { params })
.pipe(
catchError(err => {
console.error(`Failed to get reach graph slice for ${vulnerabilityId}:`, err);
return of(null);
})
);
}
/**
* Get all call paths for a finding.
*/
getCallPaths(
scanId: string,
findingId: string,
options?: ReachGraphSliceOptions
): Observable<CallPath[]> {
let params = new HttpParams();
if (options?.maxPaths) {
params = params.set('maxPaths', options.maxPaths.toString());
}
if (options?.minConfidence !== undefined) {
params = params.set('minConfidence', options.minConfidence.toString());
}
return this.http.get<{ paths: CallPath[] }>(
`${this.baseUrl}/scans/${scanId}/findings/${findingId}/paths`,
{ params }
).pipe(
map(response => response.paths ?? []),
catchError(err => {
console.error(`Failed to get call paths for ${findingId}:`, err);
return of([]);
})
);
}
/**
* Get a specific call path by ID.
*/
getCallPath(scanId: string, pathId: string): Observable<CallPath | null> {
return this.http.get<CallPath>(`${this.baseUrl}/scans/${scanId}/paths/${pathId}`)
.pipe(
catchError(err => {
console.error(`Failed to get call path ${pathId}:`, err);
return of(null);
})
);
}
/**
* Get the primary (shortest/highest confidence) call path for a finding.
*/
getPrimaryCallPath(scanId: string, findingId: string): Observable<CallPath | null> {
return this.getCallPaths(scanId, findingId, { maxPaths: 1 }).pipe(
map(paths => paths.length > 0 ? paths[0] : null)
);
}
/**
* Format a call path for display in breadcrumb.
*/
formatCallPathForBreadcrumb(path: CallPath): string {
if (!path.nodes || path.nodes.length === 0) {
return path.entrypoint.name + ' -> ... -> ' + path.vulnerability.name;
}
const parts: string[] = [path.entrypoint.name];
// Add intermediate nodes (up to 3)
const intermediates = path.nodes.slice(0, 3);
for (const node of intermediates) {
parts.push(node.name);
}
if (path.nodes.length > 3) {
parts.push(`... (${path.nodes.length - 3} more)`);
}
parts.push(path.vulnerability.name);
return parts.join(' -> ');
}
/**
* Get attestation status for a call path.
*/
getPathAttestationStatus(path: CallPath): 'attested' | 'partial' | 'none' {
if (path.hasAttestation && path.attestationDigest) {
return 'attested';
}
// Check if any edges are attested
const attestedEdges = path.edges.filter(e => e.attestedAt);
if (attestedEdges.length > 0) {
return attestedEdges.length === path.edges.length ? 'attested' : 'partial';
}
return 'none';
}
}

View File

@@ -0,0 +1,229 @@
// -----------------------------------------------------------------------------
// decay-progress.component.ts
// Sprint: SPRINT_20260106_001_005_FE_determinization_ui
// Task: DFE-006 - Create DecayProgressComponent
// Description: Progress indicator for signal freshness/decay
// -----------------------------------------------------------------------------
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { DecayInfo } from '../../../../core/models/determinization.models';
/**
* Displays signal freshness/decay as a progress indicator.
*
* Shows a progress bar that decreases as signals age,
* with warnings when data becomes stale.
*/
@Component({
selector: 'stellaops-decay-progress',
standalone: true,
imports: [CommonModule, MatProgressBarModule, MatIconModule, MatTooltipModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (decay) {
<div
class="decay-progress"
[class.decay-progress--stale]="decay.isStale"
[matTooltip]="tooltip"
[attr.aria-label]="ariaLabel"
role="meter"
[attr.aria-valuenow]="decay.freshnessPercent"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="100">
<div class="decay-progress__header">
<mat-icon class="decay-progress__icon" [class.decay-progress__icon--warning]="decay.isStale">
{{ decay.isStale ? 'schedule' : 'check_circle' }}
</mat-icon>
<span class="decay-progress__label">{{ freshnessLabel }}</span>
</div>
<mat-progress-bar
class="decay-progress__bar"
mode="determinate"
[value]="decay.freshnessPercent"
[color]="progressColor">
</mat-progress-bar>
@if (showAge) {
<div class="decay-progress__age">
{{ ageLabel }}
</div>
}
</div>
}
`,
styles: [`
:host {
display: block;
}
.decay-progress {
padding: 6px;
border-radius: 4px;
background-color: var(--color-surface, #fafafa);
&__header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
&__icon {
font-size: 14px;
width: 14px;
height: 14px;
color: var(--color-success, #2e7d32);
&--warning {
color: var(--color-warning, #f57c00);
}
}
&__label {
font-size: 11px;
font-weight: 500;
flex: 1;
}
&__bar {
height: 3px;
border-radius: 2px;
}
&__age {
margin-top: 4px;
font-size: 10px;
color: var(--color-text-secondary, #666);
text-align: right;
}
&--stale {
background-color: var(--color-warning-light, #fff3e0);
border-left: 2px solid var(--color-warning, #f57c00);
}
}
:host-context(.dark-theme) {
.decay-progress {
background-color: var(--color-surface-dark, #2d2d2d);
&--stale {
background-color: rgba(245, 124, 0, 0.1);
}
}
}
`]
})
export class DecayProgressComponent {
/**
* Decay information to display.
*/
@Input() decay?: DecayInfo;
/**
* Whether to show the age label.
*/
@Input() showAge = true;
/**
* Get the freshness label.
*/
get freshnessLabel(): string {
if (!this.decay) return '';
if (this.decay.isStale) {
return 'Stale';
}
if (this.decay.freshnessPercent >= 75) {
return 'Fresh';
}
if (this.decay.freshnessPercent >= 50) {
return 'Aging';
}
return 'Old';
}
/**
* Get the age label.
*/
get ageLabel(): string {
if (!this.decay) return '';
const hours = this.decay.ageHours;
if (hours < 1) {
return 'Just now';
}
if (hours < 24) {
return `${Math.floor(hours)}h ago`;
}
const days = Math.floor(hours / 24);
if (days === 1) {
return 'Yesterday';
}
if (days < 7) {
return `${days}d ago`;
}
const weeks = Math.floor(days / 7);
if (weeks === 1) {
return '1 week ago';
}
return `${weeks} weeks ago`;
}
/**
* Get the progress bar color.
*/
get progressColor(): 'primary' | 'accent' | 'warn' {
if (!this.decay) return 'primary';
if (this.decay.isStale) return 'warn';
if (this.decay.freshnessPercent >= 75) return 'primary';
if (this.decay.freshnessPercent >= 50) return 'accent';
return 'warn';
}
/**
* Get tooltip text.
*/
get tooltip(): string {
if (!this.decay) return '';
const lines = [
`Freshness: ${this.decay.freshnessPercent}%`,
`Age: ${this.ageLabel}`
];
if (this.decay.isStale && this.decay.staleSince) {
lines.push(`Stale since: ${new Date(this.decay.staleSince).toLocaleString()}`);
}
if (this.decay.expiresAt) {
lines.push(`Expires: ${new Date(this.decay.expiresAt).toLocaleString()}`);
}
return lines.join('\n');
}
/**
* Get ARIA label.
*/
get ariaLabel(): string {
if (!this.decay) return '';
return `Signal freshness: ${this.decay.freshnessPercent}%, ${this.freshnessLabel}`;
}
}

View File

@@ -0,0 +1,268 @@
// -----------------------------------------------------------------------------
// guardrails-badge.component.ts
// Sprint: SPRINT_20260106_001_005_FE_determinization_ui
// Task: DFE-005 - Create GuardrailsBadgeComponent
// Description: Badge showing active guardrails status
// -----------------------------------------------------------------------------
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatBadgeModule } from '@angular/material/badge';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
GuardrailsInfo,
PolicyVerdictStatus
} from '../../../../core/models/determinization.models';
/**
* Displays guardrails status as a badge.
*
* Shows the number of active guardrails with a color indicating
* the overall policy verdict status.
*/
@Component({
selector: 'stellaops-guardrails-badge',
standalone: true,
imports: [CommonModule, MatBadgeModule, MatIconModule, MatTooltipModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (guardrails) {
<div
class="guardrails-badge"
[class]="'guardrails-badge--' + statusColor"
[matTooltip]="tooltip"
[attr.aria-label]="ariaLabel">
<mat-icon
[matBadge]="activeCount"
[matBadgeColor]="badgeColor"
matBadgeSize="small"
[matBadgeHidden]="activeCount === 0">
{{ statusIcon }}
</mat-icon>
@if (showLabel) {
<span class="guardrails-badge__label">{{ statusLabel }}</span>
}
</div>
}
`,
styles: [`
:host {
display: inline-flex;
align-items: center;
}
.guardrails-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
cursor: default;
&__label {
font-size: 12px;
font-weight: 500;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&--pass {
background-color: var(--color-success-light, #e8f5e9);
color: var(--color-success, #2e7d32);
}
&--guarded {
background-color: var(--color-info-light, #e3f2fd);
color: var(--color-info, #1976d2);
}
&--blocked {
background-color: var(--color-error-light, #ffebee);
color: var(--color-error, #d32f2f);
}
&--warning {
background-color: var(--color-warning-light, #fff3e0);
color: var(--color-warning, #f57c00);
}
&--muted {
background-color: var(--color-muted-light, #f5f5f5);
color: var(--color-muted, #757575);
}
}
:host-context(.dark-theme) {
.guardrails-badge {
&--pass {
background-color: rgba(46, 125, 50, 0.2);
}
&--guarded {
background-color: rgba(25, 118, 210, 0.2);
}
&--blocked {
background-color: rgba(211, 47, 47, 0.2);
}
&--warning {
background-color: rgba(245, 124, 0, 0.2);
}
&--muted {
background-color: rgba(117, 117, 117, 0.2);
}
}
}
`]
})
export class GuardrailsBadgeComponent {
/**
* Guardrails information to display.
*/
@Input() guardrails?: GuardrailsInfo;
/**
* Whether to show the status label.
*/
@Input() showLabel = true;
/**
* Get the number of active guardrails.
*/
get activeCount(): number {
return this.guardrails?.activeGuardrails?.filter(g => g.isActive).length ?? 0;
}
/**
* Get the status color class.
*/
get statusColor(): string {
if (!this.guardrails) return 'muted';
switch (this.guardrails.status) {
case PolicyVerdictStatus.Pass:
return 'pass';
case PolicyVerdictStatus.GuardedPass:
return 'guarded';
case PolicyVerdictStatus.Blocked:
return 'blocked';
case PolicyVerdictStatus.Warned:
case PolicyVerdictStatus.Deferred:
case PolicyVerdictStatus.Escalated:
return 'warning';
default:
return 'muted';
}
}
/**
* Get the badge color.
*/
get badgeColor(): 'primary' | 'accent' | 'warn' {
if (!this.guardrails) return 'primary';
switch (this.guardrails.status) {
case PolicyVerdictStatus.Blocked:
return 'warn';
case PolicyVerdictStatus.GuardedPass:
return 'accent';
default:
return 'primary';
}
}
/**
* Get the status icon.
*/
get statusIcon(): string {
if (!this.guardrails) return 'security';
switch (this.guardrails.status) {
case PolicyVerdictStatus.Pass:
return 'check_circle';
case PolicyVerdictStatus.GuardedPass:
return 'gpp_good';
case PolicyVerdictStatus.Blocked:
return 'gpp_bad';
case PolicyVerdictStatus.Warned:
return 'warning';
case PolicyVerdictStatus.Escalated:
return 'escalator_warning';
case PolicyVerdictStatus.RequiresVex:
return 'policy';
default:
return 'security';
}
}
/**
* Get the status label.
*/
get statusLabel(): string {
if (!this.guardrails) return '';
switch (this.guardrails.status) {
case PolicyVerdictStatus.Pass:
return 'Passed';
case PolicyVerdictStatus.GuardedPass:
return 'Guarded';
case PolicyVerdictStatus.Blocked:
return 'Blocked';
case PolicyVerdictStatus.Warned:
return 'Warning';
case PolicyVerdictStatus.Deferred:
return 'Deferred';
case PolicyVerdictStatus.Escalated:
return 'Escalated';
case PolicyVerdictStatus.RequiresVex:
return 'VEX Required';
default:
return this.guardrails.status;
}
}
/**
* Get tooltip text.
*/
get tooltip(): string {
if (!this.guardrails) return '';
const lines = [`Status: ${this.statusLabel}`];
if (this.activeCount > 0) {
lines.push(`Active guardrails: ${this.activeCount}`);
for (const guardrail of this.guardrails.activeGuardrails) {
if (guardrail.isActive) {
lines.push(` - ${guardrail.name}`);
}
}
}
if (this.guardrails.blockedBy) {
lines.push(`Blocked by: ${this.guardrails.blockedBy}`);
}
if (this.guardrails.expiresAt) {
lines.push(`Expires: ${new Date(this.guardrails.expiresAt).toLocaleDateString()}`);
}
return lines.join('\n');
}
/**
* Get ARIA label.
*/
get ariaLabel(): string {
return `Guardrails status: ${this.statusLabel}, ${this.activeCount} active`;
}
}

View File

@@ -0,0 +1,20 @@
// -----------------------------------------------------------------------------
// index.ts
// Sprint: SPRINT_20260106_001_005_FE_determinization_ui
// Task: DFE-007 - Create barrel export for determinization components
// Description: Public API for determinization UI components
// -----------------------------------------------------------------------------
// Components
export { ObservationStateChipComponent } from './observation-state-chip/observation-state-chip.component';
export { UncertaintyIndicatorComponent } from './uncertainty-indicator/uncertainty-indicator.component';
export { GuardrailsBadgeComponent } from './guardrails-badge/guardrails-badge.component';
export { DecayProgressComponent } from './decay-progress/decay-progress.component';
// All determinization components for convenient importing
export const DETERMINIZATION_COMPONENTS = [
ObservationStateChipComponent,
UncertaintyIndicatorComponent,
GuardrailsBadgeComponent,
DecayProgressComponent
] as const;

View File

@@ -0,0 +1,172 @@
// -----------------------------------------------------------------------------
// observation-state-chip.component.spec.ts
// Sprint: SPRINT_20260106_001_005_FE_determinization_ui
// Task: DFE-015 - Unit tests for ObservationStateChipComponent
// Description: Angular tests for observation state chip component
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ObservationStateChipComponent } from './observation-state-chip.component';
import { ObservationState } from '../../../../core/models/determinization.models';
describe('ObservationStateChipComponent', () => {
let component: ObservationStateChipComponent;
let fixture: ComponentFixture<ObservationStateChipComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ObservationStateChipComponent, NoopAnimationsModule]
}).compileComponents();
fixture = TestBed.createComponent(ObservationStateChipComponent);
component = fixture.componentInstance;
});
describe('PendingDeterminization state', () => {
beforeEach(() => {
component.state = ObservationState.PendingDeterminization;
fixture.detectChanges();
});
it('should display "Unknown (auto-tracking)" label', () => {
expect(component.label).toBe('Unknown (auto-tracking)');
});
it('should use schedule icon', () => {
expect(component.icon).toBe('schedule');
});
it('should use warning color', () => {
expect(component.colorClass).toBe('warning');
});
it('should show ETA when nextReviewAt is provided', () => {
const futureDate = new Date();
futureDate.setHours(futureDate.getHours() + 2);
component.nextReviewAt = futureDate.toISOString();
fixture.detectChanges();
expect(component.etaText).toContain('in');
});
});
describe('Determined state', () => {
beforeEach(() => {
component.state = ObservationState.Determined;
fixture.detectChanges();
});
it('should display "Determined" label', () => {
expect(component.label).toBe('Determined');
});
it('should use check_circle icon', () => {
expect(component.icon).toBe('check_circle');
});
it('should use success color', () => {
expect(component.colorClass).toBe('success');
});
it('should not show ETA', () => {
expect(component.etaText).toBe('');
});
});
describe('ManualReviewRequired state', () => {
beforeEach(() => {
component.state = ObservationState.ManualReviewRequired;
fixture.detectChanges();
});
it('should display "Needs Review" label', () => {
expect(component.label).toBe('Needs Review');
});
it('should use rate_review icon', () => {
expect(component.icon).toBe('rate_review');
});
it('should use error color', () => {
expect(component.colorClass).toBe('error');
});
});
describe('Suppressed state', () => {
beforeEach(() => {
component.state = ObservationState.Suppressed;
fixture.detectChanges();
});
it('should display "Suppressed" label', () => {
expect(component.label).toBe('Suppressed');
});
it('should use visibility_off icon', () => {
expect(component.icon).toBe('visibility_off');
});
it('should use muted color', () => {
expect(component.colorClass).toBe('muted');
});
});
describe('StaleRequiresRefresh state', () => {
beforeEach(() => {
component.state = ObservationState.StaleRequiresRefresh;
fixture.detectChanges();
});
it('should display "Stale" label', () => {
expect(component.label).toBe('Stale');
});
it('should use update icon', () => {
expect(component.icon).toBe('update');
});
it('should show ETA when provided', () => {
const futureDate = new Date();
futureDate.setMinutes(futureDate.getMinutes() + 30);
component.nextReviewAt = futureDate.toISOString();
fixture.detectChanges();
expect(component.etaText).toContain('in');
});
});
describe('Accessibility', () => {
it('should have appropriate aria-label', () => {
component.state = ObservationState.PendingDeterminization;
fixture.detectChanges();
expect(component.ariaLabel).toContain('Observation state');
expect(component.ariaLabel).toContain('Unknown (auto-tracking)');
});
it('should include ETA in aria-label when available', () => {
component.state = ObservationState.PendingDeterminization;
const futureDate = new Date();
futureDate.setHours(futureDate.getHours() + 1);
component.nextReviewAt = futureDate.toISOString();
fixture.detectChanges();
expect(component.ariaLabel).toContain('next review');
});
});
describe('ETA visibility', () => {
it('should respect showEta input', () => {
component.state = ObservationState.PendingDeterminization;
component.showEta = false;
const futureDate = new Date();
futureDate.setHours(futureDate.getHours() + 1);
component.nextReviewAt = futureDate.toISOString();
fixture.detectChanges();
// ETA text is computed but showEta controls display in template
expect(component.showEta).toBe(false);
});
});
});

View File

@@ -0,0 +1,189 @@
// -----------------------------------------------------------------------------
// observation-state-chip.component.ts
// Sprint: SPRINT_20260106_001_005_FE_determinization_ui
// Task: DFE-003 - Create ObservationStateChipComponent
// Description: Chip component displaying observation state with review ETA
// -----------------------------------------------------------------------------
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
ObservationState,
OBSERVATION_STATE_DISPLAY,
formatReviewEta
} from '../../../../core/models/determinization.models';
/**
* Displays observation state as a Material chip.
*
* Shows the state label, icon, and next review ETA for pending states.
* Example: "Unknown (auto-tracking) - in 2h"
*/
@Component({
selector: 'stellaops-observation-state-chip',
standalone: true,
imports: [CommonModule, MatChipsModule, MatIconModule, MatTooltipModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<mat-chip
[class]="'observation-state-chip observation-state-chip--' + colorClass"
[matTooltip]="tooltip"
[attr.aria-label]="ariaLabel">
<mat-icon matChipAvatar>{{ icon }}</mat-icon>
<span class="observation-state-chip__label">{{ label }}</span>
@if (showEta && etaText) {
<span class="observation-state-chip__eta">{{ etaText }}</span>
}
</mat-chip>
`,
styles: [`
:host {
display: inline-block;
}
.observation-state-chip {
font-size: 12px;
height: 28px;
&__label {
font-weight: 500;
}
&__eta {
margin-left: 4px;
font-size: 11px;
opacity: 0.8;
}
&--success {
background-color: var(--color-success-light, #e8f5e9);
color: var(--color-success, #2e7d32);
mat-icon {
color: var(--color-success, #2e7d32);
}
}
&--warning {
background-color: var(--color-warning-light, #fff3e0);
color: var(--color-warning, #f57c00);
mat-icon {
color: var(--color-warning, #f57c00);
}
}
&--error {
background-color: var(--color-error-light, #ffebee);
color: var(--color-error, #d32f2f);
mat-icon {
color: var(--color-error, #d32f2f);
}
}
&--muted {
background-color: var(--color-muted-light, #f5f5f5);
color: var(--color-muted, #757575);
mat-icon {
color: var(--color-muted, #757575);
}
}
}
:host-context(.dark-theme) {
.observation-state-chip {
&--success {
background-color: rgba(46, 125, 50, 0.2);
}
&--warning {
background-color: rgba(245, 124, 0, 0.2);
}
&--error {
background-color: rgba(211, 47, 47, 0.2);
}
&--muted {
background-color: rgba(117, 117, 117, 0.2);
}
}
}
`]
})
export class ObservationStateChipComponent {
/**
* The observation state to display.
*/
@Input({ required: true }) state!: ObservationState;
/**
* Next review timestamp for ETA display.
*/
@Input() nextReviewAt?: string;
/**
* Whether to show the review ETA.
*/
@Input() showEta = true;
/**
* Get display info for the current state.
*/
get displayInfo() {
return OBSERVATION_STATE_DISPLAY[this.state];
}
/**
* Get the label text.
*/
get label(): string {
return this.displayInfo?.label ?? this.state;
}
/**
* Get the icon name.
*/
get icon(): string {
return this.displayInfo?.icon ?? 'help_outline';
}
/**
* Get the color class.
*/
get colorClass(): string {
return this.displayInfo?.color ?? 'muted';
}
/**
* Get the tooltip text.
*/
get tooltip(): string {
return this.displayInfo?.description ?? '';
}
/**
* Get the ETA text.
*/
get etaText(): string {
if (this.state === ObservationState.PendingDeterminization ||
this.state === ObservationState.StaleRequiresRefresh) {
return formatReviewEta(this.nextReviewAt);
}
return '';
}
/**
* Get ARIA label for accessibility.
*/
get ariaLabel(): string {
const base = `Observation state: ${this.label}`;
const eta = this.etaText ? `, next review ${this.etaText}` : '';
return base + eta;
}
}

View File

@@ -0,0 +1,236 @@
// -----------------------------------------------------------------------------
// uncertainty-indicator.component.ts
// Sprint: SPRINT_20260106_001_005_FE_determinization_ui
// Task: DFE-004 - Create UncertaintyIndicatorComponent
// Description: Visual indicator for uncertainty tier and completeness
// -----------------------------------------------------------------------------
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
UncertaintyTier,
UNCERTAINTY_TIER_DISPLAY,
getUncertaintyTier
} from '../../../../core/models/determinization.models';
/**
* Displays uncertainty level with tier and completeness progress.
*
* Shows a color-coded progress bar and tier label indicating
* the confidence level of the current observation.
*/
@Component({
selector: 'stellaops-uncertainty-indicator',
standalone: true,
imports: [CommonModule, MatProgressBarModule, MatIconModule, MatTooltipModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="uncertainty-indicator"
[class]="'uncertainty-indicator--' + tierColor"
[matTooltip]="tooltip"
[attr.aria-label]="ariaLabel"
role="meter"
[attr.aria-valuenow]="completeness"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="100">
<div class="uncertainty-indicator__header">
<mat-icon class="uncertainty-indicator__icon">{{ tierIcon }}</mat-icon>
<span class="uncertainty-indicator__tier">{{ tierLabel }}</span>
<span class="uncertainty-indicator__percent">{{ completeness }}%</span>
</div>
<mat-progress-bar
class="uncertainty-indicator__bar"
mode="determinate"
[value]="completeness"
[color]="progressColor">
</mat-progress-bar>
@if (showMissingSignals && missingSignals.length > 0) {
<div class="uncertainty-indicator__missing">
<span class="uncertainty-indicator__missing-label">Missing:</span>
@for (signal of missingSignals; track signal) {
<span class="uncertainty-indicator__missing-item">{{ signal }}</span>
}
</div>
}
</div>
`,
styles: [`
:host {
display: block;
}
.uncertainty-indicator {
padding: 8px;
border-radius: 4px;
background-color: var(--color-surface, #fafafa);
&__header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
&__icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&__tier {
font-weight: 500;
font-size: 12px;
flex: 1;
}
&__percent {
font-size: 12px;
font-weight: 600;
}
&__bar {
height: 4px;
border-radius: 2px;
}
&__missing {
margin-top: 6px;
font-size: 11px;
color: var(--color-text-secondary, #666);
}
&__missing-label {
margin-right: 4px;
}
&__missing-item {
display: inline-block;
padding: 1px 4px;
margin-right: 4px;
background-color: var(--color-muted-light, #eee);
border-radius: 2px;
font-family: monospace;
}
&--success {
border-left: 3px solid var(--color-success, #2e7d32);
}
&--success-light {
border-left: 3px solid var(--color-success-light, #81c784);
}
&--warning {
border-left: 3px solid var(--color-warning, #f57c00);
}
&--warning-dark {
border-left: 3px solid var(--color-warning-dark, #e65100);
}
&--error {
border-left: 3px solid var(--color-error, #d32f2f);
}
}
:host-context(.dark-theme) {
.uncertainty-indicator {
background-color: var(--color-surface-dark, #2d2d2d);
&__missing-item {
background-color: var(--color-muted-dark, #444);
}
}
}
`]
})
export class UncertaintyIndicatorComponent {
/**
* Uncertainty score (0.0 to 1.0).
*/
@Input({ required: true }) score!: number;
/**
* Signal completeness percentage (0 to 100).
*/
@Input({ required: true }) completeness!: number;
/**
* Missing signals list.
*/
@Input() missingSignals: string[] = [];
/**
* Whether to show missing signals.
*/
@Input() showMissingSignals = true;
/**
* Get the uncertainty tier.
*/
get tier(): UncertaintyTier {
return getUncertaintyTier(this.score);
}
/**
* Get display info for the tier.
*/
get tierDisplay() {
return UNCERTAINTY_TIER_DISPLAY[this.tier];
}
/**
* Get tier label.
*/
get tierLabel(): string {
return this.tierDisplay?.label ?? 'Unknown';
}
/**
* Get tier color class.
*/
get tierColor(): string {
return this.tierDisplay?.color ?? 'muted';
}
/**
* Get tier icon.
*/
get tierIcon(): string {
if (this.score < 0.4) return 'verified';
if (this.score < 0.7) return 'help_outline';
return 'error_outline';
}
/**
* Get progress bar color.
*/
get progressColor(): 'primary' | 'accent' | 'warn' {
if (this.completeness >= 75) return 'primary';
if (this.completeness >= 50) return 'accent';
return 'warn';
}
/**
* Get tooltip text.
*/
get tooltip(): string {
return `Uncertainty: ${this.tierLabel} (${(this.score * 100).toFixed(0)}%), ` +
`Completeness: ${this.completeness}%`;
}
/**
* Get ARIA label.
*/
get ariaLabel(): string {
return `Uncertainty indicator: ${this.tierLabel} tier, ` +
`${this.completeness}% complete`;
}
}