Merge remote changes (theirs)
This commit is contained in:
360
src/Web/StellaOps.Web/e2e/quiet-triage-workflow.e2e.spec.ts
Normal file
360
src/Web/StellaOps.Web/e2e/quiet-triage-workflow.e2e.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
518
src/Web/StellaOps.Web/e2e/secret-detection.e2e.spec.ts
Normal file
518
src/Web/StellaOps.Web/e2e/secret-detection.e2e.spec.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Secret Detection UI E2E Tests.
|
||||
* Sprint: SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* Task: SDU-012 - E2E tests
|
||||
*
|
||||
* Tests the complete secret detection configuration and viewing flow:
|
||||
* 1. Settings page navigation and toggles
|
||||
* 2. Revelation policy selection
|
||||
* 3. Rule category configuration
|
||||
* 4. Exception management
|
||||
* 5. Alert destination configuration
|
||||
* 6. Findings list and masked value display
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
test.describe('Secret Detection UI', () => {
|
||||
const settingsUrl = '/tenants/test-tenant/secrets/settings';
|
||||
const findingsUrl = '/tenants/test-tenant/secrets/findings';
|
||||
|
||||
test.describe('Settings Page', () => {
|
||||
test('displays settings page with header and tabs', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Verify header is visible
|
||||
const header = page.locator('.settings-header');
|
||||
await expect(header).toBeVisible();
|
||||
await expect(header).toContainText('Secret Detection');
|
||||
|
||||
// Verify enable/disable toggle
|
||||
const toggle = page.locator('mat-slide-toggle');
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
// Verify tabs are present
|
||||
const tabs = page.locator('mat-tab-group');
|
||||
await expect(tabs).toBeVisible();
|
||||
await expect(page.locator('role=tab')).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('toggles secret detection on and off', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
const toggle = page.locator('mat-slide-toggle');
|
||||
const initialState = await toggle.getAttribute('class');
|
||||
|
||||
// Toggle the switch
|
||||
await toggle.click();
|
||||
|
||||
// Wait for API call and state change
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/secrets/config/settings') && resp.status() === 200
|
||||
);
|
||||
|
||||
// Verify snackbar confirmation
|
||||
const snackbar = page.locator('mat-snack-bar-container');
|
||||
await expect(snackbar).toBeVisible();
|
||||
});
|
||||
|
||||
test('navigates between tabs', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Click Exceptions tab
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
await expect(page.locator('app-exception-manager')).toBeVisible();
|
||||
|
||||
// Click Alerts tab
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
await expect(page.locator('app-alert-destination-config')).toBeVisible();
|
||||
|
||||
// Back to General tab
|
||||
await page.locator('role=tab', { hasText: 'General' }).click();
|
||||
await expect(page.locator('app-revelation-policy-selector')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Revelation Policy Selector', () => {
|
||||
test('displays all policy options', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Wait for component to load
|
||||
const selector = page.locator('app-revelation-policy-selector');
|
||||
await expect(selector).toBeVisible();
|
||||
|
||||
// Verify all four options are visible
|
||||
await expect(page.locator('.policy-option')).toHaveCount(4);
|
||||
await expect(page.getByText('Fully Masked')).toBeVisible();
|
||||
await expect(page.getByText('Partially Revealed')).toBeVisible();
|
||||
await expect(page.getByText('Full Revelation')).toBeVisible();
|
||||
await expect(page.getByText('Redacted')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows preview for selected policy', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Select partial reveal
|
||||
const partialOption = page.locator('.policy-option', { hasText: 'Partially Revealed' });
|
||||
await partialOption.click();
|
||||
|
||||
// Verify preview is shown
|
||||
const preview = partialOption.locator('.preview code');
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview).toContainText('****');
|
||||
});
|
||||
|
||||
test('shows additional controls when masked policy selected', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Select masked policy
|
||||
const maskedOption = page.locator('.policy-option', { hasText: 'Fully Masked' });
|
||||
await maskedOption.click();
|
||||
|
||||
// Verify additional controls appear
|
||||
await expect(maskedOption.locator('.option-controls')).toBeVisible();
|
||||
await expect(maskedOption.locator('mat-form-field', { hasText: 'Mask Character' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('updates preview when mask length changes', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Select masked policy
|
||||
const maskedOption = page.locator('.policy-option', { hasText: 'Fully Masked' });
|
||||
await maskedOption.click();
|
||||
|
||||
// Change mask length
|
||||
const lengthSelect = maskedOption.locator('mat-select', { hasText: /characters/ });
|
||||
await lengthSelect.click();
|
||||
await page.locator('mat-option', { hasText: '16 characters' }).click();
|
||||
|
||||
// Verify preview updated
|
||||
const preview = maskedOption.locator('.preview code');
|
||||
const text = await preview.textContent();
|
||||
expect(text?.length).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Rule Category Toggles', () => {
|
||||
test('displays rule categories grouped by type', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
const toggles = page.locator('app-rule-category-toggles');
|
||||
await expect(toggles).toBeVisible();
|
||||
|
||||
// Verify accordion panels exist
|
||||
const panels = page.locator('mat-expansion-panel');
|
||||
await expect(panels.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows selection count in toolbar', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
const selectionInfo = page.locator('.selection-info');
|
||||
await expect(selectionInfo).toBeVisible();
|
||||
await expect(selectionInfo).toContainText(/\d+ of \d+ categories selected/);
|
||||
});
|
||||
|
||||
test('toggles entire group when group checkbox clicked', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Expand first group
|
||||
const panel = page.locator('mat-expansion-panel').first();
|
||||
await panel.click();
|
||||
|
||||
// Get the group checkbox
|
||||
const groupCheckbox = panel.locator('mat-expansion-panel-header mat-checkbox');
|
||||
|
||||
// Toggle group
|
||||
await groupCheckbox.click();
|
||||
|
||||
// Wait for API call
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/secrets/config/settings') && resp.status() === 200
|
||||
);
|
||||
});
|
||||
|
||||
test('select all and clear all buttons work', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Click select all
|
||||
await page.locator('button', { hasText: 'Select All' }).click();
|
||||
|
||||
// Wait for API response
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/secrets/config/settings') && resp.status() === 200
|
||||
);
|
||||
|
||||
// Click clear all
|
||||
await page.locator('button', { hasText: 'Clear All' }).click();
|
||||
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/secrets/config/settings') && resp.status() === 200
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Exception Manager', () => {
|
||||
test('displays exception list on Exceptions tab', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Navigate to exceptions tab
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
|
||||
const manager = page.locator('app-exception-manager');
|
||||
await expect(manager).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens create dialog when Add Exception clicked', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
|
||||
// Click add button
|
||||
await page.locator('button', { hasText: 'Add Exception' }).click();
|
||||
|
||||
// Verify dialog opens
|
||||
const dialog = page.locator('.dialog-overlay');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page.locator('.dialog-card')).toContainText('Create Exception');
|
||||
});
|
||||
|
||||
test('validates required fields in exception form', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
await page.locator('button', { hasText: 'Add Exception' }).click();
|
||||
|
||||
// Try to submit without required fields
|
||||
const submitButton = page.locator('button[type="submit"]');
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill required fields
|
||||
await page.locator('input[formcontrolname="name"]').fill('Test Exception');
|
||||
await page.locator('input[formcontrolname="pattern"]').fill('test-pattern');
|
||||
|
||||
// Button should now be enabled
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('closes dialog when Cancel clicked', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
await page.locator('button', { hasText: 'Add Exception' }).click();
|
||||
|
||||
// Click cancel
|
||||
await page.locator('button', { hasText: 'Cancel' }).click();
|
||||
|
||||
// Dialog should close
|
||||
const dialog = page.locator('.dialog-overlay');
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Alert Destination Configuration', () => {
|
||||
test('displays alert configuration on Alerts tab', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Navigate to alerts tab
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
|
||||
const config = page.locator('app-alert-destination-config');
|
||||
await expect(config).toBeVisible();
|
||||
await expect(page.getByText('Alert Destinations')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows global settings when alerts enabled', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
|
||||
// Enable alerts if not already
|
||||
const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle');
|
||||
const isChecked = await enableToggle.getAttribute('class');
|
||||
if (!isChecked?.includes('checked')) {
|
||||
await enableToggle.click();
|
||||
}
|
||||
|
||||
// Verify global settings visible
|
||||
await expect(page.locator('.global-settings')).toBeVisible();
|
||||
await expect(page.getByText('Minimum Severity')).toBeVisible();
|
||||
await expect(page.getByText('Rate Limit')).toBeVisible();
|
||||
});
|
||||
|
||||
test('adds new destination when Add Destination clicked', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
|
||||
// Enable alerts
|
||||
const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle');
|
||||
const isChecked = await enableToggle.getAttribute('class');
|
||||
if (!isChecked?.includes('checked')) {
|
||||
await enableToggle.click();
|
||||
}
|
||||
|
||||
// Count initial destinations
|
||||
const initialCount = await page.locator('mat-expansion-panel').count();
|
||||
|
||||
// Add destination
|
||||
await page.locator('button', { hasText: 'Add Destination' }).click();
|
||||
|
||||
// Verify new panel added
|
||||
const newCount = await page.locator('mat-expansion-panel').count();
|
||||
expect(newCount).toBe(initialCount + 1);
|
||||
});
|
||||
|
||||
test('shows different config fields based on destination type', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
|
||||
// Enable alerts and add destination
|
||||
const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle');
|
||||
const isChecked = await enableToggle.getAttribute('class');
|
||||
if (!isChecked?.includes('checked')) {
|
||||
await enableToggle.click();
|
||||
}
|
||||
await page.locator('button', { hasText: 'Add Destination' }).click();
|
||||
|
||||
// Expand the new destination
|
||||
const panel = page.locator('mat-expansion-panel').last();
|
||||
await panel.click();
|
||||
|
||||
// Select Slack type
|
||||
await panel.locator('mat-select', { hasText: 'Type' }).click();
|
||||
await page.locator('mat-option', { hasText: 'Slack' }).click();
|
||||
|
||||
// Verify Slack-specific fields
|
||||
await expect(panel.locator('mat-form-field', { hasText: 'Webhook URL' })).toBeVisible();
|
||||
await expect(panel.locator('mat-form-field', { hasText: 'Channel' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test button sends test alert', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
|
||||
// Enable alerts
|
||||
const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle');
|
||||
const isChecked = await enableToggle.getAttribute('class');
|
||||
if (!isChecked?.includes('checked')) {
|
||||
await enableToggle.click();
|
||||
}
|
||||
|
||||
// Expand first destination (if exists)
|
||||
const panel = page.locator('mat-expansion-panel').first();
|
||||
if (await panel.isVisible()) {
|
||||
await panel.click();
|
||||
|
||||
// Click test button
|
||||
const testButton = panel.locator('button', { hasText: 'Test' });
|
||||
await testButton.click();
|
||||
|
||||
// Wait for API response
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/alerts/test') && resp.status() === 200
|
||||
);
|
||||
|
||||
// Verify snackbar shows result
|
||||
const snackbar = page.locator('mat-snack-bar-container');
|
||||
await expect(snackbar).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Findings List', () => {
|
||||
test('displays findings list with filters', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
// Verify header
|
||||
await expect(page.getByText('Secret Findings')).toBeVisible();
|
||||
|
||||
// Verify filters
|
||||
await expect(page.locator('.filters')).toBeVisible();
|
||||
await expect(page.locator('mat-form-field', { hasText: 'Severity' })).toBeVisible();
|
||||
await expect(page.locator('mat-form-field', { hasText: 'Status' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays table with correct columns', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
const table = page.locator('table[mat-table]');
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
// Verify column headers
|
||||
await expect(page.locator('th', { hasText: 'Severity' })).toBeVisible();
|
||||
await expect(page.locator('th', { hasText: 'Type' })).toBeVisible();
|
||||
await expect(page.locator('th', { hasText: 'Location' })).toBeVisible();
|
||||
await expect(page.locator('th', { hasText: 'Value' })).toBeVisible();
|
||||
await expect(page.locator('th', { hasText: 'Status' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('filters by severity', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
// Open severity filter
|
||||
await page.locator('mat-form-field', { hasText: 'Severity' }).click();
|
||||
|
||||
// Select Critical
|
||||
await page.locator('mat-option', { hasText: 'Critical' }).click();
|
||||
|
||||
// Close dropdown
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Wait for filtered results
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/findings') && resp.url().includes('severity')
|
||||
);
|
||||
});
|
||||
|
||||
test('pagination works correctly', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
const paginator = page.locator('mat-paginator');
|
||||
await expect(paginator).toBeVisible();
|
||||
|
||||
// Click next page if available
|
||||
const nextButton = paginator.locator('button[aria-label="Next page"]');
|
||||
if (await nextButton.isEnabled()) {
|
||||
await nextButton.click();
|
||||
|
||||
// Verify page changed
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/findings') && resp.url().includes('page=2')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking row navigates to details', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
// Click first row
|
||||
const firstRow = page.locator('tr[mat-row]').first();
|
||||
if (await firstRow.isVisible()) {
|
||||
await firstRow.click();
|
||||
|
||||
// Verify navigation
|
||||
await expect(page).toHaveURL(/\/findings\/.+/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Masked Value Display', () => {
|
||||
test('displays masked value by default', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
const maskedDisplay = page.locator('app-masked-value-display').first();
|
||||
if (await maskedDisplay.isVisible()) {
|
||||
const code = maskedDisplay.locator('.value-text');
|
||||
await expect(code).toBeVisible();
|
||||
await expect(code).toContainText(/\*+/);
|
||||
}
|
||||
});
|
||||
|
||||
test('reveal button shows for authorized users', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
const maskedDisplay = page.locator('app-masked-value-display').first();
|
||||
if (await maskedDisplay.isVisible()) {
|
||||
const revealButton = maskedDisplay.locator('button[mattooltip="Reveal secret value"]');
|
||||
// Button visibility depends on user permissions
|
||||
// Just verify the component structure
|
||||
await expect(maskedDisplay.locator('.value-actions')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('copy button is disabled when value is masked', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
const maskedDisplay = page.locator('app-masked-value-display').first();
|
||||
if (await maskedDisplay.isVisible()) {
|
||||
const copyButton = maskedDisplay.locator('button[mattooltip*="Copy"]');
|
||||
if (await copyButton.isVisible()) {
|
||||
// Copy should be disabled when not revealed
|
||||
await expect(copyButton).toBeDisabled();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('settings page is keyboard navigable', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Tab through elements
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Verify focus is visible
|
||||
const focusedElement = await page.locator(':focus');
|
||||
await expect(focusedElement).toBeVisible();
|
||||
});
|
||||
|
||||
test('tabs can be selected with keyboard', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Focus tab list
|
||||
const tabList = page.locator('mat-tab-group');
|
||||
await tabList.focus();
|
||||
|
||||
// Navigate tabs with arrow keys
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
||||
// Verify tab changed
|
||||
const activeTab = page.locator('[role="tab"][aria-selected="true"]');
|
||||
await expect(activeTab).toContainText('Exceptions');
|
||||
});
|
||||
|
||||
test('dialogs trap focus', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
await page.locator('button', { hasText: 'Add Exception' }).click();
|
||||
|
||||
// Tab through dialog elements
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Focus should stay within dialog
|
||||
const focusedElement = await page.locator(':focus');
|
||||
const dialogCard = page.locator('.dialog-card');
|
||||
await expect(dialogCard.locator(':focus')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
249
src/Web/StellaOps.Web/e2e/timeline.e2e.spec.ts
Normal file
249
src/Web/StellaOps.Web/e2e/timeline.e2e.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Alert Destination Config Component.
|
||||
*
|
||||
* Configures alert destinations for secret detection findings.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-008 - Implement alert destination configuration
|
||||
* @task SDU-011 - Add channel test functionality
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { inject } from '@angular/core';
|
||||
|
||||
import { SecretAlertSettings, SecretAlertDestination, SecretSeverity } from '../../models';
|
||||
import { SecretDetectionSettingsService } from '../../services';
|
||||
|
||||
@Component({
|
||||
selector: 'app-alert-destination-config',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatSlideToggleModule,
|
||||
MatExpansionModule,
|
||||
MatChipsModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
],
|
||||
template: `
|
||||
<div class="alert-config">
|
||||
<header class="config-header">
|
||||
<div class="header-content">
|
||||
<h2>Alert Destinations</h2>
|
||||
<p class="description">
|
||||
Configure where and how secret detection alerts are sent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<mat-slide-toggle
|
||||
[checked]="alertsEnabled()"
|
||||
(change)="onAlertsToggle($event.checked)"
|
||||
color="primary">
|
||||
{{ alertsEnabled() ? 'Alerts Enabled' : 'Alerts Disabled' }}
|
||||
</mat-slide-toggle>
|
||||
</header>
|
||||
|
||||
@if (alertsEnabled()) {
|
||||
<section class="global-settings">
|
||||
<h3>Global Settings</h3>
|
||||
|
||||
<div class="settings-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Minimum Severity</mat-label>
|
||||
<mat-select
|
||||
[value]="settings.minimumSeverity ?? 'medium'"
|
||||
(selectionChange)="onMinSeverityChange($event.value)">
|
||||
<mat-option value="low">Low</mat-option>
|
||||
<mat-option value="medium">Medium</mat-option>
|
||||
<mat-option value="high">High</mat-option>
|
||||
<mat-option value="critical">Critical</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Only alert for findings at or above this severity</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Rate Limit (per hour)</mat-label>
|
||||
<input matInput type="number"
|
||||
[value]="settings.rateLimitPerHour ?? 100"
|
||||
(input)="onRateLimitChange($event)"
|
||||
min="1"
|
||||
max="1000">
|
||||
<mat-hint>Maximum alerts per hour per destination</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Deduplication Window (minutes)</mat-label>
|
||||
<input matInput type="number"
|
||||
[value]="settings.deduplicationWindowMinutes ?? 60"
|
||||
(input)="onDedupeWindowChange($event)"
|
||||
min="1"
|
||||
max="1440">
|
||||
<mat-hint>Suppress duplicate alerts within this window</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="destinations-section">
|
||||
<div class="destinations-header">
|
||||
<h3>Destinations</h3>
|
||||
<button mat-stroked-button (click)="addDestination()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Destination
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-accordion multi>
|
||||
@for (dest of destinations(); track dest.id; let i = $index) {
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon [style.color]="getDestinationColor(dest.type)">
|
||||
{{ getDestinationIcon(dest.type) }}
|
||||
</mat-icon>
|
||||
<span>{{ dest.name || getDestinationTypeName(dest.type) }}</span>
|
||||
</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
<mat-chip [class]="dest.enabled ? 'status-enabled' : 'status-disabled'">
|
||||
{{ dest.enabled ? 'Active' : 'Inactive' }}
|
||||
</mat-chip>
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<div class="destination-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.name"
|
||||
(input)="onDestNameChange(i, $event)"
|
||||
placeholder="Friendly name for this destination">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Type</mat-label>
|
||||
<mat-select
|
||||
[value]="dest.type"
|
||||
(selectionChange)="onDestTypeChange(i, $event.value)">
|
||||
<mat-option value="slack">Slack</mat-option>
|
||||
<mat-option value="teams">Microsoft Teams</mat-option>
|
||||
<mat-option value="email">Email</mat-option>
|
||||
<mat-option value="webhook">Webhook</mat-option>
|
||||
<mat-option value="pagerduty">PagerDuty</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@switch (dest.type) {
|
||||
@case ('slack') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Webhook URL</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.webhookUrl ?? ''"
|
||||
(input)="onDestConfigChange(i, 'webhookUrl', $event)"
|
||||
placeholder="https://hooks.slack.com/services/...">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Channel</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.channel ?? ''"
|
||||
(input)="onDestConfigChange(i, 'channel', $event)"
|
||||
placeholder="#security-alerts">
|
||||
</mat-form-field>
|
||||
}
|
||||
@case ('teams') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Webhook URL</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.webhookUrl ?? ''"
|
||||
(input)="onDestConfigChange(i, 'webhookUrl', $event)"
|
||||
placeholder="https://outlook.office.com/webhook/...">
|
||||
</mat-form-field>
|
||||
}
|
||||
@case ('email') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Recipients (comma-separated)</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.recipients ?? ''"
|
||||
(input)="onDestConfigChange(i, 'recipients', $event)"
|
||||
placeholder="security@example.com, devops@example.com">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Subject Prefix</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.subjectPrefix ?? ''"
|
||||
(input)="onDestConfigChange(i, 'subjectPrefix', $event)"
|
||||
placeholder="[SECRET ALERT]">
|
||||
</mat-form-field>
|
||||
}
|
||||
@case ('webhook') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>URL</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.url ?? ''"
|
||||
(input)="onDestConfigChange(i, 'url', $event)"
|
||||
placeholder="https://api.example.com/alerts">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Authentication Header</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.authHeader ?? ''"
|
||||
(input)="onDestConfigChange(i, 'authHeader', $event)"
|
||||
placeholder="Authorization: Bearer ...">
|
||||
</mat-form-field>
|
||||
}
|
||||
@case ('pagerduty') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Integration Key</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.integrationKey ?? ''"
|
||||
(input)="onDestConfigChange(i, 'integrationKey', $event)"
|
||||
placeholder="Events API v2 integration key">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Service Name</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.serviceName ?? ''"
|
||||
(input)="onDestConfigChange(i, 'serviceName', $event)"
|
||||
placeholder="Secret Detection">
|
||||
</mat-form-field>
|
||||
}
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Severity Filter</mat-label>
|
||||
<mat-select
|
||||
[value]="dest.severityFilter ?? []"
|
||||
(selectionChange)="onDestSeverityChange(i, $event.value)"
|
||||
multiple>
|
||||
<mat-option value="critical">Critical</mat-option>
|
||||
<mat-option value="high">High</mat-option>
|
||||
<mat-option value="medium">Medium</mat-option>
|
||||
<mat-option value="low">Low</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Leave empty for all severities</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="destination-footer">
|
||||
<mat-slide-toggle
|
||||
[checked]="dest.enabled"
|
||||
(change)="onDestEnabledChange(i, $event.checked)">
|
||||
Enabled
|
||||
</mat-slide-toggle>
|
||||
|
||||
<div class="footer-actions">
|
||||
<button mat-stroked-button
|
||||
(click)="testDestination(dest)"
|
||||
[disabled]="testingDestination() === dest.id"
|
||||
matTooltip="Send a test alert to verify configuration">
|
||||
@if (testingDestination() === dest.id) {
|
||||
<mat-spinner diameter="18"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>send</mat-icon>
|
||||
}
|
||||
Test
|
||||
</button>
|
||||
|
||||
<button mat-button color="warn" (click)="removeDestination(i)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
|
||||
@if (destinations().length === 0) {
|
||||
<mat-card class="empty-state">
|
||||
<mat-card-content>
|
||||
<mat-icon>notifications_off</mat-icon>
|
||||
<h3>No Destinations Configured</h3>
|
||||
<p>Add a destination to start receiving alerts.</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.alert-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-content h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-content .description {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.global-settings h3,
|
||||
.destinations-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.destinations-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.destinations-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
mat-expansion-panel-header mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.destination-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.destination-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--mat-foreground-divider);
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footer-actions mat-spinner {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.status-enabled {
|
||||
background-color: #28a745 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
background-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--mat-foreground-hint-text);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin: 0;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class AlertDestinationConfigComponent {
|
||||
private readonly settingsService = inject(SecretDetectionSettingsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
|
||||
@Input() settings: SecretAlertSettings = {
|
||||
enabled: false,
|
||||
destinations: [],
|
||||
minimumAlertSeverity: 'High',
|
||||
maxAlertsPerScan: 100,
|
||||
deduplicationWindowHours: 24,
|
||||
includeFilePath: true,
|
||||
includeMaskedValue: false,
|
||||
includeImageRef: true,
|
||||
};
|
||||
@Input() tenantId = '';
|
||||
@Output() settingsChange = new EventEmitter<SecretAlertSettings>();
|
||||
|
||||
readonly alertsEnabled = signal(false);
|
||||
readonly destinations = signal<SecretAlertDestination[]>([]);
|
||||
readonly testingDestination = signal<string | null>(null);
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.alertsEnabled.set(this.settings.enabled ?? false);
|
||||
this.destinations.set([...(this.settings.destinations ?? [])]);
|
||||
}
|
||||
|
||||
onAlertsToggle(enabled: boolean): void {
|
||||
this.alertsEnabled.set(enabled);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
onMinSeverityChange(severity: SecretSeverity): void {
|
||||
this.settings = { ...this.settings, minimumSeverity: severity };
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
onRateLimitChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = parseInt(input.value, 10);
|
||||
if (!isNaN(value) && value > 0) {
|
||||
this.settings = { ...this.settings, rateLimitPerHour: value };
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
onDedupeWindowChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = parseInt(input.value, 10);
|
||||
if (!isNaN(value) && value > 0) {
|
||||
this.settings = { ...this.settings, deduplicationWindowMinutes: value };
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
addDestination(): void {
|
||||
const newDest: SecretAlertDestination = {
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
channelType: 'Webhook',
|
||||
channelId: '',
|
||||
type: 'webhook',
|
||||
enabled: true,
|
||||
config: {},
|
||||
};
|
||||
this.destinations.update(dests => [...dests, newDest]);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
removeDestination(index: number): void {
|
||||
this.destinations.update(dests => dests.filter((_, i) => i !== index));
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
onDestNameChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.updateDestination(index, { name: input.value });
|
||||
}
|
||||
|
||||
onDestTypeChange(index: number, type: 'webhook' | 'slack' | 'email' | 'teams' | 'pagerduty'): void {
|
||||
this.updateDestination(index, { type, config: {} });
|
||||
}
|
||||
|
||||
onDestConfigChange(index: number, key: string, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const current = this.destinations()[index];
|
||||
this.updateDestination(index, {
|
||||
config: { ...(current.config ?? {}), [key]: input.value },
|
||||
});
|
||||
}
|
||||
|
||||
onDestSeverityChange(index: number, severities: SecretSeverity[]): void {
|
||||
this.updateDestination(index, { severityFilter: severities });
|
||||
}
|
||||
|
||||
onDestEnabledChange(index: number, enabled: boolean): void {
|
||||
this.updateDestination(index, { enabled });
|
||||
}
|
||||
|
||||
private updateDestination(index: number, updates: Partial<SecretAlertDestination>): void {
|
||||
this.destinations.update(dests =>
|
||||
dests.map((d, i) => i === index ? { ...d, ...updates } : d)
|
||||
);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
private emitChange(): void {
|
||||
this.settingsChange.emit({
|
||||
...this.settings,
|
||||
enabled: this.alertsEnabled(),
|
||||
destinations: this.destinations(),
|
||||
});
|
||||
}
|
||||
|
||||
getDestinationIcon(type: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
slack: 'chat',
|
||||
teams: 'groups',
|
||||
email: 'email',
|
||||
webhook: 'webhook',
|
||||
pagerduty: 'warning',
|
||||
};
|
||||
return iconMap[type] ?? 'notifications';
|
||||
}
|
||||
|
||||
getDestinationColor(type: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
slack: '#4A154B',
|
||||
teams: '#6264A7',
|
||||
email: '#1976d2',
|
||||
webhook: '#757575',
|
||||
pagerduty: '#06AC38',
|
||||
};
|
||||
return colorMap[type] ?? '#757575';
|
||||
}
|
||||
|
||||
getDestinationTypeName(type: string): string {
|
||||
const nameMap: Record<string, string> = {
|
||||
slack: 'Slack',
|
||||
teams: 'Microsoft Teams',
|
||||
email: 'Email',
|
||||
webhook: 'Webhook',
|
||||
pagerduty: 'PagerDuty',
|
||||
};
|
||||
return nameMap[type] ?? type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests an alert destination by sending a test notification.
|
||||
* @sprint SDU-011 - Add channel test functionality
|
||||
*/
|
||||
testDestination(destination: SecretAlertDestination): void {
|
||||
if (!this.tenantId || !destination.id) {
|
||||
this.snackBar.open('Unable to test: missing tenant or destination ID', 'Close', {
|
||||
duration: 3000,
|
||||
panelClass: 'snackbar-error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.testingDestination.set(destination.id);
|
||||
|
||||
this.settingsService.testAlertDestination(this.tenantId, destination.id).subscribe({
|
||||
next: (result) => {
|
||||
this.testingDestination.set(null);
|
||||
if (result.success) {
|
||||
this.snackBar.open('Test alert sent successfully!', 'Close', {
|
||||
duration: 3000,
|
||||
panelClass: 'snackbar-success',
|
||||
});
|
||||
} else {
|
||||
this.snackBar.open(`Test failed: ${result.message}`, 'Close', {
|
||||
duration: 5000,
|
||||
panelClass: 'snackbar-error',
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.testingDestination.set(null);
|
||||
this.snackBar.open('Failed to send test alert', 'Close', {
|
||||
duration: 5000,
|
||||
panelClass: 'snackbar-error',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Barrel export for alert components.
|
||||
*/
|
||||
export * from './alert-destination-config.component';
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* Exception Manager Component.
|
||||
*
|
||||
* Manages secret detection exception patterns (allowlist entries).
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-007 - Implement exception manager
|
||||
*/
|
||||
|
||||
import { Component, Input, inject, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
|
||||
import { SecretExceptionService } from '../../services';
|
||||
import { SecretExceptionPattern, CreateExceptionRequest } from '../../models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-manager',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatChipsModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatCardModule,
|
||||
MatSnackBarModule,
|
||||
],
|
||||
template: `
|
||||
<div class="exception-manager">
|
||||
<header class="manager-header">
|
||||
<h2>Exception Patterns</h2>
|
||||
<p class="description">
|
||||
Define patterns to exclude known false positives or intentional test secrets.
|
||||
</p>
|
||||
<button mat-raised-button color="primary" (click)="openCreateDialog()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Exception
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">
|
||||
<mat-spinner diameter="32"></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (exceptions().length > 0) {
|
||||
<table mat-table [dataSource]="exceptions()">
|
||||
<!-- Name Column -->
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
<div class="exception-name">
|
||||
<span>{{ exception.name }}</span>
|
||||
@if (exception.description) {
|
||||
<span class="description">{{ exception.description }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Type Column -->
|
||||
<ng-container matColumnDef="matchType">
|
||||
<th mat-header-cell *matHeaderCellDef>Match Type</th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
<mat-chip>{{ exception.matchType | titlecase }}</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Pattern Column -->
|
||||
<ng-container matColumnDef="pattern">
|
||||
<th mat-header-cell *matHeaderCellDef>Pattern</th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
<code class="pattern-value">{{ exception.pattern }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Scope Column -->
|
||||
<ng-container matColumnDef="scope">
|
||||
<th mat-header-cell *matHeaderCellDef>Scope</th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
@if (exception.ruleIds?.length) {
|
||||
<mat-chip-set>
|
||||
@for (ruleId of exception.ruleIds.slice(0, 2); track ruleId) {
|
||||
<mat-chip>{{ ruleId }}</mat-chip>
|
||||
}
|
||||
@if (exception.ruleIds.length > 2) {
|
||||
<mat-chip>+{{ exception.ruleIds.length - 2 }} more</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
} @else {
|
||||
<span class="scope-all">All rules</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="enabled">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
<mat-chip [class]="exception.enabled ? 'status-enabled' : 'status-disabled'">
|
||||
{{ exception.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
<button mat-icon-button
|
||||
(click)="toggleEnabled(exception)"
|
||||
[matTooltip]="exception.enabled ? 'Disable' : 'Enable'">
|
||||
<mat-icon>{{ exception.enabled ? 'toggle_on' : 'toggle_off' }}</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button
|
||||
(click)="openEditDialog(exception)"
|
||||
matTooltip="Edit">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button
|
||||
(click)="deleteException(exception)"
|
||||
matTooltip="Delete"
|
||||
color="warn">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
} @else if (!loading()) {
|
||||
<mat-card class="empty-state">
|
||||
<mat-card-content>
|
||||
<mat-icon>block</mat-icon>
|
||||
<h3>No Exceptions Defined</h3>
|
||||
<p>Create exception patterns to suppress known false positives.</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
@if (showDialog()) {
|
||||
<div class="dialog-overlay" (click)="closeDialog()">
|
||||
<mat-card class="dialog-card" (click)="$event.stopPropagation()">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ editingException() ? 'Edit' : 'Create' }} Exception</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<form [formGroup]="exceptionForm" (ngSubmit)="saveException()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="e.g., Test API Keys">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea matInput formControlName="description" rows="2"
|
||||
placeholder="Why this exception exists..."></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Match Type</mat-label>
|
||||
<mat-select formControlName="matchType">
|
||||
<mat-option value="literal">Literal String</mat-option>
|
||||
<mat-option value="regex">Regular Expression</mat-option>
|
||||
<mat-option value="glob">Glob Pattern</mat-option>
|
||||
<mat-option value="prefix">Prefix Match</mat-option>
|
||||
<mat-option value="suffix">Suffix Match</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Pattern</mat-label>
|
||||
<input matInput formControlName="pattern"
|
||||
placeholder="Pattern to match against secret values or paths">
|
||||
<mat-hint>
|
||||
@switch (exceptionForm.get('matchType')?.value) {
|
||||
@case ('regex') { Use a valid regular expression }
|
||||
@case ('glob') { Use * and ? wildcards }
|
||||
@default { Enter the exact text to match }
|
||||
}
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Target</mat-label>
|
||||
<mat-select formControlName="target">
|
||||
<mat-option value="value">Secret Value</mat-option>
|
||||
<mat-option value="path">File Path</mat-option>
|
||||
<mat-option value="both">Both Value and Path</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox formControlName="enabled">Enabled</mat-checkbox>
|
||||
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" (click)="closeDialog()">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="submit"
|
||||
[disabled]="exceptionForm.invalid || saving()">
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
} @else {
|
||||
{{ editingException() ? 'Update' : 'Create' }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.exception-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.manager-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.manager-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.manager-header .description {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.manager-header button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.exception-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.exception-name .description {
|
||||
font-size: 12px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.pattern-value {
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
background: var(--mat-background-card);
|
||||
border: 1px solid var(--mat-foreground-divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scope-all {
|
||||
font-style: italic;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.status-enabled {
|
||||
background-color: #28a745 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
background-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--mat-foreground-hint-text);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dialog-card form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dialog-card mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ExceptionManagerComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly exceptionService = inject(SecretExceptionService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
|
||||
@Input() exceptions: SecretExceptionPattern[] = [];
|
||||
@Input() tenantId = '';
|
||||
|
||||
readonly loading = this.exceptionService.loading;
|
||||
readonly showDialog = signal(false);
|
||||
readonly editingException = signal<SecretExceptionPattern | null>(null);
|
||||
readonly saving = signal(false);
|
||||
|
||||
readonly displayedColumns = ['name', 'matchType', 'pattern', 'scope', 'enabled', 'actions'];
|
||||
|
||||
exceptionForm: FormGroup = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(100)]],
|
||||
description: ['', Validators.maxLength(500)],
|
||||
matchType: ['literal', Validators.required],
|
||||
pattern: ['', Validators.required],
|
||||
target: ['value', Validators.required],
|
||||
enabled: [true],
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.tenantId) {
|
||||
this.loadExceptions();
|
||||
}
|
||||
}
|
||||
|
||||
loadExceptions(): void {
|
||||
this.exceptionService.listExceptions(this.tenantId).subscribe();
|
||||
}
|
||||
|
||||
openCreateDialog(): void {
|
||||
this.editingException.set(null);
|
||||
this.exceptionForm.reset({
|
||||
matchType: 'literal',
|
||||
target: 'value',
|
||||
enabled: true,
|
||||
});
|
||||
this.showDialog.set(true);
|
||||
}
|
||||
|
||||
openEditDialog(exception: SecretExceptionPattern): void {
|
||||
this.editingException.set(exception);
|
||||
this.exceptionForm.patchValue({
|
||||
name: exception.name,
|
||||
description: exception.description ?? '',
|
||||
matchType: exception.matchType,
|
||||
pattern: exception.pattern,
|
||||
target: exception.target ?? 'value',
|
||||
enabled: exception.enabled,
|
||||
});
|
||||
this.showDialog.set(true);
|
||||
}
|
||||
|
||||
closeDialog(): void {
|
||||
this.showDialog.set(false);
|
||||
this.editingException.set(null);
|
||||
}
|
||||
|
||||
saveException(): void {
|
||||
if (this.exceptionForm.invalid) return;
|
||||
|
||||
this.saving.set(true);
|
||||
const formValue = this.exceptionForm.value;
|
||||
const existing = this.editingException();
|
||||
|
||||
const payload: CreateExceptionRequest = {
|
||||
name: formValue.name ?? '',
|
||||
pattern: formValue.pattern ?? '',
|
||||
reason: formValue.description ?? 'Exception created via UI',
|
||||
matchType: formValue.matchType ?? 'literal',
|
||||
target: formValue.target ?? 'value',
|
||||
enabled: formValue.enabled ?? true,
|
||||
description: formValue.description || undefined,
|
||||
};
|
||||
|
||||
const operation = existing
|
||||
? this.exceptionService.updateException(this.tenantId, existing.id, payload)
|
||||
: this.exceptionService.createException(this.tenantId, payload);
|
||||
|
||||
operation.subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.closeDialog();
|
||||
this.showSuccess(existing ? 'Exception updated' : 'Exception created');
|
||||
this.loadExceptions();
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.showError('Failed to save exception');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleEnabled(exception: SecretExceptionPattern): void {
|
||||
this.exceptionService.updateException(this.tenantId, exception.id, {
|
||||
enabled: !exception.enabled,
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.loadExceptions();
|
||||
},
|
||||
error: () => this.showError('Failed to update exception'),
|
||||
});
|
||||
}
|
||||
|
||||
deleteException(exception: SecretExceptionPattern): void {
|
||||
if (!confirm(`Delete exception "${exception.name}"?`)) return;
|
||||
|
||||
this.exceptionService.deleteException(this.tenantId, exception.id).subscribe({
|
||||
next: () => {
|
||||
this.showSuccess('Exception deleted');
|
||||
this.loadExceptions();
|
||||
},
|
||||
error: () => this.showError('Failed to delete exception'),
|
||||
});
|
||||
}
|
||||
|
||||
private showSuccess(message: string): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 3000,
|
||||
panelClass: 'snackbar-success',
|
||||
});
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 5000,
|
||||
panelClass: 'snackbar-error',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Barrel export for exception components.
|
||||
*/
|
||||
export * from './exception-manager.component';
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Barrel export for findings components.
|
||||
*/
|
||||
export * from './secret-findings-list.component';
|
||||
export * from './masked-value-display.component';
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Masked Value Display Component.
|
||||
*
|
||||
* Displays secret values with masking and optional reveal functionality.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-006 - Implement masked value display
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
|
||||
import { SecretFindingsService } from '../../services';
|
||||
import { inject } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-masked-value-display',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
template: `
|
||||
<div class="value-display">
|
||||
<code class="value-text" [class.revealed]="isRevealed()">
|
||||
{{ displayValue() }}
|
||||
</code>
|
||||
|
||||
<div class="value-actions">
|
||||
@if (canReveal && !isRevealed()) {
|
||||
@if (revealing()) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
} @else {
|
||||
<button mat-icon-button
|
||||
(click)="reveal()"
|
||||
matTooltip="Reveal secret value"
|
||||
[disabled]="revealing()">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@if (isRevealed()) {
|
||||
<button mat-icon-button
|
||||
(click)="hide()"
|
||||
matTooltip="Hide secret value">
|
||||
<mat-icon>visibility_off</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button mat-icon-button
|
||||
(click)="copyToClipboard()"
|
||||
[matTooltip]="copied() ? 'Copied!' : 'Copy to clipboard'"
|
||||
[disabled]="!isRevealed()">
|
||||
<mat-icon>{{ copied() ? 'check' : 'content_copy' }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.value-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.value-text {
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
background: var(--mat-background-card);
|
||||
border: 1px solid var(--mat-foreground-divider);
|
||||
border-radius: 4px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.value-text.revealed {
|
||||
color: var(--mat-foreground-text);
|
||||
background-color: var(--mat-warn-50);
|
||||
border-color: var(--mat-warn-200);
|
||||
}
|
||||
|
||||
.value-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.value-actions button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.value-actions mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
margin: 5px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MaskedValueDisplayComponent {
|
||||
private readonly findingsService = inject(SecretFindingsService);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
|
||||
@Input() value = '';
|
||||
@Input() findingId = '';
|
||||
@Input() tenantId = '';
|
||||
@Input() canReveal = false;
|
||||
|
||||
@Output() revealed = new EventEmitter<string>();
|
||||
|
||||
readonly isRevealed = signal(false);
|
||||
readonly revealedValue = signal<string | null>(null);
|
||||
readonly revealing = signal(false);
|
||||
readonly copied = signal(false);
|
||||
|
||||
readonly displayValue = () => {
|
||||
if (this.isRevealed() && this.revealedValue()) {
|
||||
return this.revealedValue()!;
|
||||
}
|
||||
return this.value || '********';
|
||||
};
|
||||
|
||||
reveal(): void {
|
||||
if (!this.canReveal || !this.findingId || !this.tenantId) return;
|
||||
|
||||
this.revealing.set(true);
|
||||
|
||||
this.findingsService.revealValue(this.tenantId, this.findingId).subscribe({
|
||||
next: (value: string) => {
|
||||
this.revealedValue.set(value);
|
||||
this.isRevealed.set(true);
|
||||
this.revealing.set(false);
|
||||
this.revealed.emit(value);
|
||||
|
||||
// Auto-hide after 30 seconds for security
|
||||
setTimeout(() => this.hide(), 30000);
|
||||
},
|
||||
error: () => {
|
||||
this.revealing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.isRevealed.set(false);
|
||||
this.revealedValue.set(null);
|
||||
}
|
||||
|
||||
copyToClipboard(): void {
|
||||
const valueToCopy = this.revealedValue() ?? this.value;
|
||||
if (valueToCopy) {
|
||||
this.clipboard.copy(valueToCopy);
|
||||
this.copied.set(true);
|
||||
|
||||
setTimeout(() => this.copied.set(false), 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Secret Findings List Component.
|
||||
*
|
||||
* Displays paginated list of detected secrets with filtering and sorting.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-005 - Implement findings list with pagination
|
||||
*/
|
||||
|
||||
import { Component, inject, OnInit, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule, ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatSortModule, Sort } from '@angular/material/sort';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
|
||||
import { SecretFindingsService } from '../../services';
|
||||
import { SecretFinding, SecretSeverity, SecretFindingStatus } from '../../models';
|
||||
import { MaskedValueDisplayComponent } from './';
|
||||
|
||||
@Component({
|
||||
selector: 'app-secret-findings-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatTableModule,
|
||||
MatSortModule,
|
||||
MatPaginatorModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatCardModule,
|
||||
MatTooltipModule,
|
||||
MatMenuModule,
|
||||
MaskedValueDisplayComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="findings-container">
|
||||
<header class="findings-header">
|
||||
<h1>Secret Findings</h1>
|
||||
<div class="header-stats">
|
||||
@if (pagination()) {
|
||||
<span>{{ pagination()!.totalItems }} findings total</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<mat-card class="filters-card">
|
||||
<mat-card-content>
|
||||
<div class="filters">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Search</mat-label>
|
||||
<input matInput
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchChange($event)"
|
||||
placeholder="Search by file, rule, or secret type...">
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Severity</mat-label>
|
||||
<mat-select
|
||||
[value]="severityFilter()"
|
||||
(selectionChange)="onSeverityChange($event.value)"
|
||||
multiple>
|
||||
<mat-option value="critical">Critical</mat-option>
|
||||
<mat-option value="high">High</mat-option>
|
||||
<mat-option value="medium">Medium</mat-option>
|
||||
<mat-option value="low">Low</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select
|
||||
[value]="statusFilter()"
|
||||
(selectionChange)="onStatusChange($event.value)">
|
||||
<mat-option value="">All</mat-option>
|
||||
<mat-option value="active">Active</mat-option>
|
||||
<mat-option value="resolved">Resolved</mat-option>
|
||||
<mat-option value="excepted">Excepted</mat-option>
|
||||
<mat-option value="suppressed">Suppressed</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-stroked-button (click)="resetFilters()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-overlay">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<mat-card class="error-card">
|
||||
<mat-card-content>
|
||||
<mat-icon color="warn">error</mat-icon>
|
||||
<span>{{ error() }}</span>
|
||||
<button mat-button color="primary" (click)="reload()">Retry</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@if (findings().length > 0) {
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="findings()" matSort (matSortChange)="onSortChange($event)">
|
||||
<!-- Severity Column -->
|
||||
<ng-container matColumnDef="severity">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Severity</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<mat-chip [class]="'severity-' + finding.severity">
|
||||
{{ finding.severity | titlecase }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Type Column -->
|
||||
<ng-container matColumnDef="ruleId">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Type</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<div class="rule-info">
|
||||
<span class="rule-name">{{ finding.ruleName ?? finding.ruleId }}</span>
|
||||
@if (finding.category) {
|
||||
<span class="rule-category">{{ finding.category }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- File Column -->
|
||||
<ng-container matColumnDef="filePath">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Location</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<div class="location-info">
|
||||
<code class="file-path" [matTooltip]="finding.filePath">
|
||||
{{ truncatePath(finding.filePath) }}
|
||||
</code>
|
||||
@if (finding.lineNumber) {
|
||||
<span class="line-number">Line {{ finding.lineNumber }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Value Column -->
|
||||
<ng-container matColumnDef="maskedValue">
|
||||
<th mat-header-cell *matHeaderCellDef>Value</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<app-masked-value-display
|
||||
[value]="finding.maskedValue"
|
||||
[findingId]="finding.id"
|
||||
[canReveal]="finding.canReveal">
|
||||
</app-masked-value-display>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Status</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<mat-chip [class]="'status-' + finding.status">
|
||||
{{ finding.status | titlecase }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Detected At Column -->
|
||||
<ng-container matColumnDef="detectedAt">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Detected</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
{{ finding.detectedAt | date:'short' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<button mat-icon-button [matMenuTriggerFor]="actionsMenu" (click)="$event.stopPropagation()">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #actionsMenu="matMenu">
|
||||
<button mat-menu-item (click)="viewDetails(finding)">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
<span>View Details</span>
|
||||
</button>
|
||||
@if (finding.status === 'active') {
|
||||
<button mat-menu-item (click)="createException(finding)">
|
||||
<mat-icon>block</mat-icon>
|
||||
<span>Create Exception</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="markResolved(finding)">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<span>Mark Resolved</span>
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"
|
||||
(click)="viewDetails(row)"
|
||||
class="clickable-row">
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="pagination()?.totalItems ?? 0"
|
||||
[pageSize]="pagination()?.pageSize ?? 25"
|
||||
[pageIndex]="(pagination()?.page ?? 1) - 1"
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons>
|
||||
</mat-paginator>
|
||||
} @else if (!loading()) {
|
||||
<mat-card class="empty-state">
|
||||
<mat-card-content>
|
||||
<mat-icon>verified_user</mat-icon>
|
||||
<h2>No Secrets Found</h2>
|
||||
<p>No secret findings match your current filters.</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.findings-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.findings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.findings-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.filters-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.filters mat-form-field {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable-row:hover {
|
||||
background-color: var(--mat-background-hover);
|
||||
}
|
||||
|
||||
.rule-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rule-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rule-category {
|
||||
font-size: 12px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.location-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-size: 13px;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
font-size: 12px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.severity-critical {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.severity-high {
|
||||
background-color: #fd7e14 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.severity-medium {
|
||||
background-color: #ffc107 !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.severity-low {
|
||||
background-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-resolved {
|
||||
background-color: #28a745 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-excepted {
|
||||
background-color: #17a2b8 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-suppressed {
|
||||
background-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background-color: var(--mat-warn-50);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-card mat-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.empty-state mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--mat-foreground-hint-text);
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SecretFindingsListComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly findingsService = inject(SecretFindingsService);
|
||||
|
||||
readonly tenantId = signal('');
|
||||
|
||||
// Expose service signals
|
||||
readonly findings = this.findingsService.findings;
|
||||
readonly page = this.findingsService.page;
|
||||
readonly pageSize = this.findingsService.pageSize;
|
||||
readonly total = this.findingsService.total;
|
||||
readonly loading = this.findingsService.loading;
|
||||
readonly error = this.findingsService.error;
|
||||
|
||||
// Local filter state
|
||||
readonly searchQuery = signal('');
|
||||
readonly severityFilter = signal<string[]>([]);
|
||||
readonly statusFilter = signal('');
|
||||
readonly sortField = signal('detectedAt');
|
||||
readonly sortDirection = signal<'asc' | 'desc'>('desc');
|
||||
|
||||
readonly displayedColumns = [
|
||||
'severity',
|
||||
'ruleId',
|
||||
'filePath',
|
||||
'maskedValue',
|
||||
'status',
|
||||
'detectedAt',
|
||||
'actions',
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
const tenantId = this.route.snapshot.paramMap.get('tenantId') ?? '';
|
||||
this.tenantId.set(tenantId);
|
||||
|
||||
if (tenantId) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.findingsService.listFindings(tid, {
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
severity: this.severityFilter() as SecretSeverity[],
|
||||
status: this.statusFilter() ? [this.statusFilter() as SecretFindingStatus] : undefined,
|
||||
}).subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
onSearchChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchQuery.set(input.value);
|
||||
this.reloadWithDebounce();
|
||||
}
|
||||
|
||||
onSeverityChange(values: string[]): void {
|
||||
this.severityFilter.set(values);
|
||||
this.reload();
|
||||
}
|
||||
|
||||
onStatusChange(value: string): void {
|
||||
this.statusFilter.set(value);
|
||||
this.reload();
|
||||
}
|
||||
|
||||
onSortChange(sort: Sort): void {
|
||||
this.sortField.set(sort.active);
|
||||
this.sortDirection.set(sort.direction as 'asc' | 'desc' || 'desc');
|
||||
this.reload();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.findingsService.listFindings(tid, {
|
||||
page: event.pageIndex + 1,
|
||||
pageSize: event.pageSize,
|
||||
severity: this.severityFilter() as SecretSeverity[],
|
||||
status: this.statusFilter() ? [this.statusFilter() as SecretFindingStatus] : undefined,
|
||||
}).subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.searchQuery.set('');
|
||||
this.severityFilter.set([]);
|
||||
this.statusFilter.set('');
|
||||
this.reload();
|
||||
}
|
||||
|
||||
viewDetails(finding: SecretFinding): void {
|
||||
this.router.navigate(['findings', finding.id], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
createException(finding: SecretFinding): void {
|
||||
this.router.navigate(['exceptions', 'new'], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { findingId: finding.id },
|
||||
});
|
||||
}
|
||||
|
||||
markResolved(finding: SecretFinding): void {
|
||||
const tid = this.tenantId();
|
||||
this.findingsService.updateStatus(tid, finding.id, 'Resolved').subscribe({
|
||||
next: () => this.reload(),
|
||||
});
|
||||
}
|
||||
|
||||
truncatePath(path: string): string {
|
||||
const maxLen = 40;
|
||||
if (path.length <= maxLen) return path;
|
||||
|
||||
const parts = path.split('/');
|
||||
if (parts.length <= 2) return path;
|
||||
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
}
|
||||
|
||||
private debounceTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private reloadWithDebounce(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
this.debounceTimer = setTimeout(() => this.reload(), 300);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Barrel export for all components in the secret detection feature.
|
||||
*/
|
||||
|
||||
// Settings components
|
||||
export * from './settings';
|
||||
|
||||
// Findings components
|
||||
export * from './findings';
|
||||
|
||||
// Exceptions components
|
||||
export * from './exceptions';
|
||||
|
||||
// Alert components
|
||||
export * from './alerts';
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Barrel export for settings components.
|
||||
*/
|
||||
export * from './secret-detection-settings.component';
|
||||
export * from './revelation-policy-selector.component';
|
||||
export * from './rule-category-toggles.component';
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Revelation Policy Selector Component.
|
||||
*
|
||||
* Configures how detected secrets are revealed/masked in different contexts.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-003 - Implement revelation policy selector
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSliderModule } from '@angular/material/slider';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
import { RevelationPolicyConfig } from '../../models';
|
||||
|
||||
type RevelationMode = 'masked' | 'partial' | 'full' | 'redacted';
|
||||
|
||||
@Component({
|
||||
selector: 'app-revelation-policy-selector',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatRadioModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatSliderModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
],
|
||||
template: `
|
||||
<div class="policy-selector">
|
||||
<mat-card class="policy-option" [class.selected]="config.mode === 'masked'">
|
||||
<mat-card-header>
|
||||
<mat-radio-button
|
||||
[checked]="config.mode === 'masked'"
|
||||
(change)="setMode('masked')"
|
||||
name="policy">
|
||||
</mat-radio-button>
|
||||
<mat-card-title>Fully Masked</mat-card-title>
|
||||
<mat-icon matTooltip="Secret values are completely hidden">info</mat-icon>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p>All secret values are replaced with asterisks or a placeholder.</p>
|
||||
<div class="preview">
|
||||
<code>{{ getMaskedPreview() }}</code>
|
||||
</div>
|
||||
|
||||
@if (config.mode === 'masked') {
|
||||
<div class="option-controls">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Mask Character</mat-label>
|
||||
<input matInput
|
||||
[value]="config.maskChar ?? '*'"
|
||||
(input)="onMaskCharChange($event)"
|
||||
maxlength="1">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Mask Length</mat-label>
|
||||
<mat-select
|
||||
[value]="config.maskLength ?? 8"
|
||||
(selectionChange)="onMaskLengthChange($event.value)">
|
||||
<mat-option [value]="4">4 characters</mat-option>
|
||||
<mat-option [value]="8">8 characters</mat-option>
|
||||
<mat-option [value]="16">16 characters</mat-option>
|
||||
<mat-option [value]="0">Match original length</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="policy-option" [class.selected]="config.mode === 'partial'">
|
||||
<mat-card-header>
|
||||
<mat-radio-button
|
||||
[checked]="config.mode === 'partial'"
|
||||
(change)="setMode('partial')"
|
||||
name="policy">
|
||||
</mat-radio-button>
|
||||
<mat-card-title>Partially Revealed</mat-card-title>
|
||||
<mat-icon matTooltip="Show first/last characters, mask the rest">info</mat-icon>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p>Show a portion of the secret to aid identification while hiding the full value.</p>
|
||||
<div class="preview">
|
||||
<code>{{ getPartialPreview() }}</code>
|
||||
</div>
|
||||
|
||||
@if (config.mode === 'partial') {
|
||||
<div class="option-controls">
|
||||
<div class="slider-control">
|
||||
<label>Reveal first characters: {{ config.revealFirst ?? 4 }}</label>
|
||||
<mat-slider min="0" max="8" step="1" discrete>
|
||||
<input matSliderThumb
|
||||
[value]="config.revealFirst ?? 4"
|
||||
(valueChange)="onRevealFirstChange($event)">
|
||||
</mat-slider>
|
||||
</div>
|
||||
|
||||
<div class="slider-control">
|
||||
<label>Reveal last characters: {{ config.revealLast ?? 0 }}</label>
|
||||
<mat-slider min="0" max="8" step="1" discrete>
|
||||
<input matSliderThumb
|
||||
[value]="config.revealLast ?? 0"
|
||||
(valueChange)="onRevealLastChange($event)">
|
||||
</mat-slider>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="policy-option" [class.selected]="config.mode === 'full'">
|
||||
<mat-card-header>
|
||||
<mat-radio-button
|
||||
[checked]="config.mode === 'full'"
|
||||
(change)="setMode('full')"
|
||||
name="policy">
|
||||
</mat-radio-button>
|
||||
<mat-card-title>Full Revelation</mat-card-title>
|
||||
<mat-icon matTooltip="Secret values are fully visible - use with caution" color="warn">warning</mat-icon>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p class="warning-text">
|
||||
<strong>Warning:</strong> Secrets are shown in full. Use only in secure,
|
||||
controlled environments. Requires elevated permissions.
|
||||
</p>
|
||||
<div class="preview">
|
||||
<code>{{ getFullPreview() }}</code>
|
||||
</div>
|
||||
|
||||
@if (config.mode === 'full') {
|
||||
<div class="option-controls">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Require Permission</mat-label>
|
||||
<mat-select
|
||||
[value]="config.requiredPermission ?? 'admin'"
|
||||
(selectionChange)="onPermissionChange($event.value)">
|
||||
<mat-option value="admin">Administrator</mat-option>
|
||||
<mat-option value="security-lead">Security Lead</mat-option>
|
||||
<mat-option value="auditor">Auditor</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="policy-option" [class.selected]="config.mode === 'redacted'">
|
||||
<mat-card-header>
|
||||
<mat-radio-button
|
||||
[checked]="config.mode === 'redacted'"
|
||||
(change)="setMode('redacted')"
|
||||
name="policy">
|
||||
</mat-radio-button>
|
||||
<mat-card-title>Redacted</mat-card-title>
|
||||
<mat-icon matTooltip="Value is completely removed from output">info</mat-icon>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p>Secret values are not stored or displayed. Only the location and type are recorded.</p>
|
||||
<div class="preview">
|
||||
<code>{{ getRedactedPreview() }}</code>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.policy-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.policy-option {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.policy-option:hover {
|
||||
border-color: var(--mat-primary-100);
|
||||
}
|
||||
|
||||
.policy-option.selected {
|
||||
border-color: var(--mat-primary-500);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.policy-option mat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.policy-option mat-card-title {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.policy-option p {
|
||||
font-size: 14px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.preview {
|
||||
background: var(--mat-background-card);
|
||||
border: 1px solid var(--mat-foreground-divider);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview code {
|
||||
color: var(--mat-foreground-text);
|
||||
}
|
||||
|
||||
.option-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--mat-foreground-divider);
|
||||
}
|
||||
|
||||
.slider-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.slider-control label {
|
||||
font-size: 13px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--mat-warn-500) !important;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class RevelationPolicySelectorComponent {
|
||||
@Input() config: RevelationPolicyConfig = {
|
||||
defaultPolicy: 'FullMask',
|
||||
exportPolicy: 'FullMask',
|
||||
logPolicy: 'FullMask',
|
||||
fullRevealRoles: [],
|
||||
partialRevealChars: 4,
|
||||
mode: 'masked',
|
||||
};
|
||||
@Output() configChange = new EventEmitter<RevelationPolicyConfig>();
|
||||
|
||||
private readonly sampleSecret = 'ghp_abc123XYZ789secret';
|
||||
|
||||
setMode(mode: RevelationMode): void {
|
||||
this.emitChange({ ...this.config, mode });
|
||||
}
|
||||
|
||||
onMaskCharChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const maskChar = input.value || '*';
|
||||
this.emitChange({ ...this.config, maskChar });
|
||||
}
|
||||
|
||||
onMaskLengthChange(length: number): void {
|
||||
this.emitChange({ ...this.config, maskLength: length });
|
||||
}
|
||||
|
||||
onRevealFirstChange(value: number): void {
|
||||
this.emitChange({ ...this.config, revealFirst: value });
|
||||
}
|
||||
|
||||
onRevealLastChange(value: number): void {
|
||||
this.emitChange({ ...this.config, revealLast: value });
|
||||
}
|
||||
|
||||
onPermissionChange(permission: string): void {
|
||||
this.emitChange({ ...this.config, requiredPermission: permission });
|
||||
}
|
||||
|
||||
private emitChange(newConfig: RevelationPolicyConfig): void {
|
||||
this.config = newConfig;
|
||||
this.configChange.emit(newConfig);
|
||||
}
|
||||
|
||||
getMaskedPreview(): string {
|
||||
const char = this.config.maskChar ?? '*';
|
||||
const len = this.config.maskLength ?? 8;
|
||||
const actualLen = len === 0 ? this.sampleSecret.length : len;
|
||||
return char.repeat(actualLen);
|
||||
}
|
||||
|
||||
getPartialPreview(): string {
|
||||
const first = this.config.revealFirst ?? 4;
|
||||
const last = this.config.revealLast ?? 0;
|
||||
const masked = '*'.repeat(8);
|
||||
|
||||
const prefix = this.sampleSecret.substring(0, first);
|
||||
const suffix = last > 0 ? this.sampleSecret.substring(this.sampleSecret.length - last) : '';
|
||||
|
||||
return `${prefix}${masked}${suffix}`;
|
||||
}
|
||||
|
||||
getFullPreview(): string {
|
||||
return this.sampleSecret;
|
||||
}
|
||||
|
||||
getRedactedPreview(): string {
|
||||
return '[REDACTED]';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Rule Category Toggles Component.
|
||||
*
|
||||
* Displays available secret detection rule categories with toggle controls.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-004 - Implement rule category toggles
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
import { SecretRuleCategory } from '../../models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rule-category-toggles',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatCheckboxModule,
|
||||
MatExpansionModule,
|
||||
MatChipsModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatBadgeModule,
|
||||
MatTooltipModule,
|
||||
],
|
||||
template: `
|
||||
<div class="category-toggles">
|
||||
<div class="toolbar">
|
||||
<div class="selection-info">
|
||||
<span>{{ selectedCount() }} of {{ categories.length }} categories selected</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button mat-button (click)="selectAll()">Select All</button>
|
||||
<button mat-button (click)="clearAll()">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-accordion multi>
|
||||
@for (group of groupedCategories(); track group.groupName) {
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-checkbox
|
||||
[checked]="isGroupSelected(group.groupName)"
|
||||
[indeterminate]="isGroupPartial(group.groupName)"
|
||||
(change)="toggleGroup(group.groupName, $event.checked)"
|
||||
(click)="$event.stopPropagation()">
|
||||
</mat-checkbox>
|
||||
<mat-icon [style.color]="getGroupColor(group.groupName)">
|
||||
{{ getGroupIcon(group.groupName) }}
|
||||
</mat-icon>
|
||||
<span>{{ group.groupName }}</span>
|
||||
</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
{{ getGroupSelectedCount(group.groupName) }} / {{ group.categories.length }} enabled
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<div class="category-list">
|
||||
@for (category of group.categories; track category.id) {
|
||||
<div class="category-item">
|
||||
<mat-checkbox
|
||||
[checked]="isSelected(category.id)"
|
||||
(change)="toggleCategory(category.id, $event.checked)">
|
||||
<div class="category-info">
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
<span class="category-description">{{ category.description }}</span>
|
||||
</div>
|
||||
</mat-checkbox>
|
||||
|
||||
<div class="category-meta">
|
||||
<mat-chip-set>
|
||||
<mat-chip [matTooltip]="'Rules in this category'">
|
||||
{{ category.ruleCount }} rules
|
||||
</mat-chip>
|
||||
@if (category.severity) {
|
||||
<mat-chip
|
||||
[class]="'severity-' + category.severity"
|
||||
[matTooltip]="'Default severity'">
|
||||
{{ category.severity }}
|
||||
</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
|
||||
@if (categories.length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon>category</mat-icon>
|
||||
<p>No rule categories available.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.category-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
font-size: 14px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
mat-expansion-panel-header mat-checkbox {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
mat-expansion-panel-header mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--mat-background-card);
|
||||
border: 1px solid var(--mat-foreground-divider);
|
||||
}
|
||||
|
||||
.category-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.category-description {
|
||||
font-size: 13px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.severity-critical {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.severity-high {
|
||||
background-color: #fd7e14 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.severity-medium {
|
||||
background-color: #ffc107 !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.severity-low {
|
||||
background-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--mat-foreground-hint-text);
|
||||
}
|
||||
|
||||
.empty-state mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class RuleCategoryTogglesComponent {
|
||||
@Input() categories: SecretRuleCategory[] = [];
|
||||
@Input() selected: string[] = [];
|
||||
@Output() selectionChange = new EventEmitter<string[]>();
|
||||
|
||||
private readonly selectedSet = signal(new Set<string>());
|
||||
|
||||
readonly selectedCount = computed(() => this.selectedSet().size);
|
||||
|
||||
readonly groupedCategories = computed(() => {
|
||||
const groups = new Map<string, SecretRuleCategory[]>();
|
||||
|
||||
for (const cat of this.categories) {
|
||||
const groupName = cat.group ?? 'Other';
|
||||
const existing = groups.get(groupName) ?? [];
|
||||
existing.push(cat);
|
||||
groups.set(groupName, existing);
|
||||
}
|
||||
|
||||
return Array.from(groups.entries())
|
||||
.map(([groupName, categories]) => ({ groupName, categories }))
|
||||
.sort((a, b) => a.groupName.localeCompare(b.groupName));
|
||||
});
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.selectedSet.set(new Set(this.selected));
|
||||
}
|
||||
|
||||
isSelected(categoryId: string): boolean {
|
||||
return this.selectedSet().has(categoryId);
|
||||
}
|
||||
|
||||
isGroupSelected(groupName: string): boolean {
|
||||
const group = this.groupedCategories().find(g => g.groupName === groupName);
|
||||
if (!group) return false;
|
||||
return group.categories.every(c => this.isSelected(c.id));
|
||||
}
|
||||
|
||||
isGroupPartial(groupName: string): boolean {
|
||||
const group = this.groupedCategories().find(g => g.groupName === groupName);
|
||||
if (!group) return false;
|
||||
|
||||
const selectedCount = group.categories.filter(c => this.isSelected(c.id)).length;
|
||||
return selectedCount > 0 && selectedCount < group.categories.length;
|
||||
}
|
||||
|
||||
getGroupSelectedCount(groupName: string): number {
|
||||
const group = this.groupedCategories().find(g => g.groupName === groupName);
|
||||
if (!group) return 0;
|
||||
return group.categories.filter(c => this.isSelected(c.id)).length;
|
||||
}
|
||||
|
||||
toggleCategory(categoryId: string, checked: boolean): void {
|
||||
const current = new Set(this.selectedSet());
|
||||
if (checked) {
|
||||
current.add(categoryId);
|
||||
} else {
|
||||
current.delete(categoryId);
|
||||
}
|
||||
this.selectedSet.set(current);
|
||||
this.emitSelection();
|
||||
}
|
||||
|
||||
toggleGroup(groupName: string, checked: boolean): void {
|
||||
const group = this.groupedCategories().find(g => g.groupName === groupName);
|
||||
if (!group) return;
|
||||
|
||||
const current = new Set(this.selectedSet());
|
||||
for (const cat of group.categories) {
|
||||
if (checked) {
|
||||
current.add(cat.id);
|
||||
} else {
|
||||
current.delete(cat.id);
|
||||
}
|
||||
}
|
||||
this.selectedSet.set(current);
|
||||
this.emitSelection();
|
||||
}
|
||||
|
||||
selectAll(): void {
|
||||
const all = new Set(this.categories.map(c => c.id));
|
||||
this.selectedSet.set(all);
|
||||
this.emitSelection();
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.selectedSet.set(new Set());
|
||||
this.emitSelection();
|
||||
}
|
||||
|
||||
private emitSelection(): void {
|
||||
this.selectionChange.emit(Array.from(this.selectedSet()));
|
||||
}
|
||||
|
||||
getGroupIcon(groupName: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
'API Keys': 'vpn_key',
|
||||
'Tokens': 'token',
|
||||
'Certificates': 'verified_user',
|
||||
'Passwords': 'password',
|
||||
'Private Keys': 'key',
|
||||
'Credentials': 'lock',
|
||||
'Cloud': 'cloud',
|
||||
'Database': 'storage',
|
||||
'Other': 'category',
|
||||
};
|
||||
return iconMap[groupName] ?? 'category';
|
||||
}
|
||||
|
||||
getGroupColor(groupName: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
'API Keys': '#1976d2',
|
||||
'Tokens': '#7b1fa2',
|
||||
'Certificates': '#388e3c',
|
||||
'Passwords': '#d32f2f',
|
||||
'Private Keys': '#f57c00',
|
||||
'Credentials': '#455a64',
|
||||
'Cloud': '#0288d1',
|
||||
'Database': '#5d4037',
|
||||
'Other': '#757575',
|
||||
};
|
||||
return colorMap[groupName] ?? '#757575';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Secret Detection Settings Page Component.
|
||||
*
|
||||
* Main settings page for configuring secret detection per tenant.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-002 - Build settings page component
|
||||
*/
|
||||
|
||||
import { Component, inject, OnInit, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { SecretDetectionSettingsService } from '../../services';
|
||||
import { RevelationPolicySelectorComponent } from './revelation-policy-selector.component';
|
||||
import { RuleCategoryTogglesComponent } from './rule-category-toggles.component';
|
||||
import { ExceptionManagerComponent } from '../exceptions';
|
||||
import { AlertDestinationConfigComponent } from '../alerts';
|
||||
import { RevelationPolicyConfig, SecretAlertSettings } from '../../models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-secret-detection-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTabsModule,
|
||||
MatSlideToggleModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule,
|
||||
RevelationPolicySelectorComponent,
|
||||
RuleCategoryTogglesComponent,
|
||||
ExceptionManagerComponent,
|
||||
AlertDestinationConfigComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="settings-container">
|
||||
<header class="settings-header">
|
||||
<div class="title-section">
|
||||
<mat-icon>security</mat-icon>
|
||||
<h1>Secret Detection</h1>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
@if (loading()) {
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
}
|
||||
|
||||
<mat-slide-toggle
|
||||
[checked]="isEnabled()"
|
||||
(change)="onEnabledToggle($event.checked)"
|
||||
color="primary"
|
||||
[disabled]="loading()">
|
||||
{{ isEnabled() ? 'Enabled' : 'Disabled' }}
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<mat-card class="error-card">
|
||||
<mat-card-content>
|
||||
<mat-icon color="warn">error</mat-icon>
|
||||
<span>{{ error() }}</span>
|
||||
<button mat-button color="primary" (click)="reload()">
|
||||
Retry
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@if (settings(); as s) {
|
||||
<mat-tab-group animationDuration="0ms">
|
||||
<mat-tab label="General">
|
||||
<div class="tab-content">
|
||||
<section class="section">
|
||||
<h2>Revelation Policy</h2>
|
||||
<p class="section-description">
|
||||
Control how detected secrets are displayed in different contexts.
|
||||
</p>
|
||||
<app-revelation-policy-selector
|
||||
[config]="s.revelationPolicy"
|
||||
(configChange)="onPolicyChange($event)">
|
||||
</app-revelation-policy-selector>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Rule Categories</h2>
|
||||
<p class="section-description">
|
||||
Select which types of secrets to detect.
|
||||
</p>
|
||||
<app-rule-category-toggles
|
||||
[categories]="categories()"
|
||||
[selected]="s.enabledRuleCategories"
|
||||
(selectionChange)="onCategoriesChange($event)">
|
||||
</app-rule-category-toggles>
|
||||
</section>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Exceptions">
|
||||
<div class="tab-content">
|
||||
<app-exception-manager
|
||||
[exceptions]="s.exceptions"
|
||||
[tenantId]="tenantId()">
|
||||
</app-exception-manager>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Alerts">
|
||||
<div class="tab-content">
|
||||
<app-alert-destination-config
|
||||
[settings]="s.alertSettings"
|
||||
[tenantId]="tenantId()"
|
||||
(settingsChange)="onAlertSettingsChange($event)">
|
||||
</app-alert-destination-config>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
} @else if (!loading()) {
|
||||
<mat-card class="empty-state">
|
||||
<mat-card-content>
|
||||
<mat-icon>settings</mat-icon>
|
||||
<h2>No Settings Found</h2>
|
||||
<p>Secret detection has not been configured for this tenant.</p>
|
||||
<button mat-raised-button color="primary" (click)="initializeSettings()">
|
||||
Initialize Settings
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.settings-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-section mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--mat-primary-500);
|
||||
}
|
||||
|
||||
.title-section h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background-color: var(--mat-warn-50);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-card mat-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.empty-state mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--mat-foreground-hint-text);
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SecretDetectionSettingsComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly settingsService = inject(SecretDetectionSettingsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
|
||||
// Get tenant ID from route
|
||||
readonly tenantId = signal('');
|
||||
|
||||
// Expose service signals
|
||||
readonly settings = this.settingsService.settings;
|
||||
readonly categories = this.settingsService.categories;
|
||||
readonly loading = this.settingsService.loading;
|
||||
readonly error = this.settingsService.error;
|
||||
readonly isEnabled = this.settingsService.isEnabled;
|
||||
|
||||
ngOnInit(): void {
|
||||
const tenantId = this.route.snapshot.paramMap.get('tenantId') ?? '';
|
||||
this.tenantId.set(tenantId);
|
||||
|
||||
if (tenantId) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.loadSettings(tid).subscribe();
|
||||
this.settingsService.loadCategories().subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
initializeSettings(): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.createSettings(tid).subscribe({
|
||||
next: () => this.showSuccess('Settings initialized'),
|
||||
error: () => this.showError('Failed to initialize settings'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onEnabledToggle(enabled: boolean): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.toggleEnabled(tid, enabled).subscribe({
|
||||
next: () => this.showSuccess(enabled ? 'Secret detection enabled' : 'Secret detection disabled'),
|
||||
error: () => this.showError('Failed to update setting'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onPolicyChange(policy: RevelationPolicyConfig): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.updateSettings(tid, { revelationPolicy: policy }).subscribe({
|
||||
next: () => this.showSuccess('Revelation policy updated'),
|
||||
error: () => this.showError('Failed to update policy'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onCategoriesChange(categoryIds: string[]): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.updateCategories(tid, categoryIds).subscribe({
|
||||
next: () => this.showSuccess('Rule categories updated'),
|
||||
error: () => this.showError('Failed to update categories'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onAlertSettingsChange(alertSettings: SecretAlertSettings): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.updateSettings(tid, { alertSettings }).subscribe({
|
||||
next: () => this.showSuccess('Alert settings updated'),
|
||||
error: () => this.showError('Failed to update alert settings'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private showSuccess(message: string): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 3000,
|
||||
panelClass: 'snackbar-success',
|
||||
});
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 5000,
|
||||
panelClass: 'snackbar-error',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,20 @@
|
||||
/**
|
||||
* Secret Detection Feature Module.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
*
|
||||
* Frontend components for configuring and viewing secret detection findings.
|
||||
* Provides tenant administrators with tools to manage detection settings,
|
||||
* view findings, and configure alerts.
|
||||
* Main barrel export for the secret detection feature.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-001 - Feature module structure
|
||||
*/
|
||||
|
||||
// Models
|
||||
export * from './models/secret-detection.models';
|
||||
export * from './models/secret-finding.models';
|
||||
export * from './models/revelation-policy.models';
|
||||
export * from './models/alert-destination.models';
|
||||
export * from './models';
|
||||
|
||||
// Services
|
||||
export * from './services/secret-detection-settings.service';
|
||||
export * from './services/secret-findings.service';
|
||||
export * from './services';
|
||||
|
||||
// Components
|
||||
export * from './secret-detection-settings.component';
|
||||
export * from './revelation-policy-config.component';
|
||||
export * from './rule-category-selector.component';
|
||||
export * from './secret-findings-list.component';
|
||||
export * from './masked-value-display.component';
|
||||
export * from './finding-detail-drawer.component';
|
||||
export * from './exception-manager.component';
|
||||
export * from './exception-form.component';
|
||||
export * from './alert-destination-config.component';
|
||||
export * from './channel-test.component';
|
||||
export * from './components';
|
||||
|
||||
// Routes
|
||||
export * from './secret-detection.routes';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Models barrel export.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE
|
||||
* @task SDU-001
|
||||
*/
|
||||
export * from './secret-detection.models';
|
||||
@@ -1,136 +1,195 @@
|
||||
/**
|
||||
* Secret Detection Settings Models.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
* Secret Detection domain models.
|
||||
*
|
||||
* Core models for secret detection configuration.
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-001 - Create secret-detection feature module
|
||||
*/
|
||||
|
||||
import { RevelationPolicy } from './revelation-policy.models';
|
||||
import { AlertDestinationSettings } from './alert-destination.models';
|
||||
|
||||
/**
|
||||
* Secret detection rule category.
|
||||
*/
|
||||
export type SecretRuleCategory =
|
||||
| 'aws'
|
||||
| 'azure'
|
||||
| 'gcp'
|
||||
| 'github'
|
||||
| 'gitlab'
|
||||
| 'generic-api-keys'
|
||||
| 'private-keys'
|
||||
| 'passwords'
|
||||
| 'tokens'
|
||||
| 'database-credentials'
|
||||
| 'custom';
|
||||
|
||||
/**
|
||||
* Rule category display info.
|
||||
*/
|
||||
export interface RuleCategoryInfo {
|
||||
/** Category identifier */
|
||||
category: SecretRuleCategory;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Number of rules in this category */
|
||||
ruleCount: number;
|
||||
/** Icon identifier */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception entry for allowlisting patterns.
|
||||
*/
|
||||
export interface SecretException {
|
||||
/** Unique exception ID */
|
||||
id: string;
|
||||
/** Pattern type */
|
||||
type: 'literal' | 'regex' | 'path';
|
||||
/** Pattern value */
|
||||
pattern: string;
|
||||
/** Rule category this exception applies to (null = all) */
|
||||
category: SecretRuleCategory | null;
|
||||
/** Reason for exception */
|
||||
reason: string;
|
||||
/** Who created the exception */
|
||||
createdBy: string;
|
||||
/** When the exception was created */
|
||||
createdAt: string;
|
||||
/** Expiration date (null = never) */
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret detection settings for a tenant.
|
||||
* Per-tenant secret detection configuration.
|
||||
*/
|
||||
export interface SecretDetectionSettings {
|
||||
/** Whether secret detection is enabled */
|
||||
tenantId: string;
|
||||
enabled: boolean;
|
||||
/** Revelation policy for displaying secrets */
|
||||
revelationPolicy: RevelationPolicy;
|
||||
/** Enabled rule categories */
|
||||
enabledRuleCategories: SecretRuleCategory[];
|
||||
/** Exception patterns */
|
||||
exceptions: SecretException[];
|
||||
/** Alert settings */
|
||||
alertSettings: AlertDestinationSettings;
|
||||
/** Last modified timestamp */
|
||||
modifiedAt: string;
|
||||
/** Last modified by */
|
||||
modifiedBy: string;
|
||||
revelationPolicy: RevelationPolicyConfig;
|
||||
enabledRuleCategories: string[];
|
||||
exceptions: SecretExceptionPattern[];
|
||||
alertSettings: SecretAlertSettings;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default settings.
|
||||
* Revelation policy for how secrets are displayed.
|
||||
*/
|
||||
export const DEFAULT_SECRET_DETECTION_SETTINGS: SecretDetectionSettings = {
|
||||
enabled: false,
|
||||
revelationPolicy: {
|
||||
defaultPolicy: 'FullMask',
|
||||
exportPolicy: 'FullMask',
|
||||
logPolicy: 'FullMask',
|
||||
allowFullReveal: false
|
||||
},
|
||||
enabledRuleCategories: [
|
||||
'aws',
|
||||
'azure',
|
||||
'gcp',
|
||||
'github',
|
||||
'gitlab',
|
||||
'generic-api-keys',
|
||||
'private-keys',
|
||||
'passwords',
|
||||
'tokens',
|
||||
'database-credentials'
|
||||
],
|
||||
exceptions: [],
|
||||
alertSettings: {
|
||||
enabled: false,
|
||||
destinations: [],
|
||||
minimumSeverity: 'high',
|
||||
rateLimitPerHour: 100,
|
||||
deduplicationWindowMinutes: 60
|
||||
},
|
||||
modifiedAt: new Date().toISOString(),
|
||||
modifiedBy: 'system'
|
||||
};
|
||||
export type SecretRevelationPolicy = 'FullMask' | 'PartialReveal' | 'FullReveal';
|
||||
|
||||
/**
|
||||
* All available rule categories with display info.
|
||||
* Revelation mode for display settings.
|
||||
*/
|
||||
export const RULE_CATEGORIES: RuleCategoryInfo[] = [
|
||||
{ category: 'aws', label: 'AWS', description: 'AWS access keys, secret keys, and session tokens', ruleCount: 12, icon: 'cloud' },
|
||||
{ category: 'azure', label: 'Azure', description: 'Azure subscription keys and connection strings', ruleCount: 8, icon: 'cloud' },
|
||||
{ category: 'gcp', label: 'GCP', description: 'Google Cloud API keys and service account credentials', ruleCount: 6, icon: 'cloud' },
|
||||
{ category: 'github', label: 'GitHub', description: 'GitHub personal access tokens and app keys', ruleCount: 5, icon: 'code' },
|
||||
{ category: 'gitlab', label: 'GitLab', description: 'GitLab personal and project access tokens', ruleCount: 4, icon: 'code' },
|
||||
{ category: 'generic-api-keys', label: 'Generic API Keys', description: 'Common API key patterns', ruleCount: 10, icon: 'key' },
|
||||
{ category: 'private-keys', label: 'Private Keys', description: 'RSA, ECDSA, and other private key formats', ruleCount: 8, icon: 'lock' },
|
||||
{ category: 'passwords', label: 'Passwords', description: 'Password patterns in configuration files', ruleCount: 6, icon: 'password' },
|
||||
{ category: 'tokens', label: 'Tokens', description: 'JWT, OAuth, and bearer tokens', ruleCount: 7, icon: 'token' },
|
||||
{ category: 'database-credentials', label: 'Database', description: 'Database connection strings and credentials', ruleCount: 9, icon: 'database' },
|
||||
{ category: 'custom', label: 'Custom', description: 'Custom rules defined by your organization', ruleCount: 0, icon: 'settings' }
|
||||
];
|
||||
export type RevelationMode = 'masked' | 'partial' | 'full' | 'redacted';
|
||||
|
||||
/**
|
||||
* Configuration for revelation policies by context.
|
||||
*/
|
||||
export interface RevelationPolicyConfig {
|
||||
defaultPolicy: SecretRevelationPolicy;
|
||||
exportPolicy: SecretRevelationPolicy;
|
||||
logPolicy: SecretRevelationPolicy;
|
||||
fullRevealRoles: string[];
|
||||
partialRevealChars: number;
|
||||
/** Display mode for UI presentation */
|
||||
mode?: RevelationMode;
|
||||
/** Character used for masking */
|
||||
maskChar?: string;
|
||||
/** Length of the mask */
|
||||
maskLength?: number;
|
||||
/** Number of characters to reveal at start */
|
||||
revealFirst?: number;
|
||||
/** Number of characters to reveal at end */
|
||||
revealLast?: number;
|
||||
/** Required permission for full reveal */
|
||||
requiredPermission?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception pattern for allowlisting false positives.
|
||||
*/
|
||||
export interface SecretExceptionPattern {
|
||||
id: string;
|
||||
pattern: string;
|
||||
reason: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
ruleIds?: string[];
|
||||
pathFilter?: string;
|
||||
/** Display name for the exception */
|
||||
name?: string;
|
||||
/** Description of why exception exists */
|
||||
description?: string;
|
||||
/** How the pattern is matched */
|
||||
matchType?: 'literal' | 'regex' | 'glob' | 'prefix' | 'suffix';
|
||||
/** What the pattern applies to */
|
||||
target?: 'value' | 'path' | 'filename';
|
||||
/** Whether the exception is active */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert configuration for secret findings.
|
||||
*/
|
||||
export interface SecretAlertSettings {
|
||||
enabled: boolean;
|
||||
minimumAlertSeverity: SecretSeverity;
|
||||
destinations: SecretAlertDestination[];
|
||||
maxAlertsPerScan: number;
|
||||
deduplicationWindowHours: number;
|
||||
includeFilePath: boolean;
|
||||
includeMaskedValue: boolean;
|
||||
includeImageRef: boolean;
|
||||
alertMessagePrefix?: string;
|
||||
/** Minimum severity to trigger alert (UI variant) */
|
||||
minimumSeverity?: SecretSeverity;
|
||||
/** Rate limit per hour */
|
||||
rateLimitPerHour?: number;
|
||||
/** Deduplication window in minutes (UI variant) */
|
||||
deduplicationWindowMinutes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert destination configuration.
|
||||
*/
|
||||
export interface SecretAlertDestination {
|
||||
id: string;
|
||||
name: string;
|
||||
channelType: AlertChannelType;
|
||||
channelId: string;
|
||||
severityFilter?: SecretSeverity[];
|
||||
ruleCategoryFilter?: string[];
|
||||
/** Destination type (UI variant) */
|
||||
type?: 'webhook' | 'slack' | 'email' | 'teams' | 'pagerduty';
|
||||
/** Configuration options */
|
||||
config?: Record<string, unknown>;
|
||||
/** Whether destination is enabled */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert channel types.
|
||||
*/
|
||||
export type AlertChannelType = 'Slack' | 'Teams' | 'Email' | 'Webhook' | 'PagerDuty';
|
||||
|
||||
/**
|
||||
* Severity levels for secret findings.
|
||||
*/
|
||||
export type SecretSeverity = 'Low' | 'Medium' | 'High' | 'Critical';
|
||||
|
||||
/**
|
||||
* Secret finding from a scan.
|
||||
*/
|
||||
export interface SecretFinding {
|
||||
id: string;
|
||||
scanId: string;
|
||||
imageRef: string;
|
||||
severity: SecretSeverity;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
ruleCategory: string;
|
||||
filePath: string;
|
||||
lineNumber: number;
|
||||
maskedValue: string;
|
||||
detectedAt: string;
|
||||
status: SecretFindingStatus;
|
||||
excepted: boolean;
|
||||
exceptionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a secret finding.
|
||||
*/
|
||||
export type SecretFindingStatus = 'New' | 'Acknowledged' | 'Resolved' | 'FalsePositive';
|
||||
|
||||
/**
|
||||
* Available rule categories for secret detection.
|
||||
*/
|
||||
export interface SecretRuleCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ruleCount: number;
|
||||
enabled: boolean;
|
||||
/** Group for categorization */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for creating an exception pattern.
|
||||
*/
|
||||
export interface CreateExceptionRequest {
|
||||
pattern: string;
|
||||
reason: string;
|
||||
expiresAt?: string;
|
||||
ruleIds?: string[];
|
||||
pathFilter?: string;
|
||||
/** Display name for the exception */
|
||||
name?: string;
|
||||
/** Description of why exception exists */
|
||||
description?: string;
|
||||
/** How the pattern is matched */
|
||||
matchType?: 'literal' | 'regex' | 'glob' | 'prefix' | 'suffix';
|
||||
/** What the pattern applies to */
|
||||
target?: 'value' | 'path' | 'filename';
|
||||
/** Whether the exception is active */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating settings.
|
||||
*/
|
||||
export interface UpdateSettingsRequest {
|
||||
enabled?: boolean;
|
||||
revelationPolicy?: Partial<RevelationPolicyConfig>;
|
||||
enabledRuleCategories?: string[];
|
||||
alertSettings?: Partial<SecretAlertSettings>;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,58 @@
|
||||
/**
|
||||
* Secret Detection Routes.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
* Secret Detection Feature Routes.
|
||||
*
|
||||
* Route configuration for secret detection feature.
|
||||
* Defines routing configuration for the secret detection feature module.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-001 - Feature module structure
|
||||
*/
|
||||
|
||||
import { Routes } from '@angular/router';
|
||||
import { SecretDetectionSettingsComponent } from './secret-detection-settings.component';
|
||||
import { SecretFindingsListComponent } from './secret-findings-list.component';
|
||||
|
||||
export const SECRET_DETECTION_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'settings',
|
||||
pathMatch: 'full'
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'settings',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () =>
|
||||
import('./components/settings/secret-detection-settings.component')
|
||||
.then(m => m.SecretDetectionSettingsComponent),
|
||||
title: 'Secret Detection Settings',
|
||||
},
|
||||
{
|
||||
path: 'findings',
|
||||
loadComponent: () =>
|
||||
import('./components/findings/secret-findings-list.component')
|
||||
.then(m => m.SecretFindingsListComponent),
|
||||
title: 'Secret Findings',
|
||||
},
|
||||
{
|
||||
path: 'findings/:findingId',
|
||||
loadComponent: () =>
|
||||
import('./components/findings/secret-findings-list.component')
|
||||
.then(m => m.SecretFindingsListComponent),
|
||||
title: 'Finding Details',
|
||||
},
|
||||
{
|
||||
path: 'exceptions',
|
||||
loadComponent: () =>
|
||||
import('./components/exceptions/exception-manager.component')
|
||||
.then(m => m.ExceptionManagerComponent),
|
||||
title: 'Secret Exceptions',
|
||||
},
|
||||
{
|
||||
path: 'exceptions/new',
|
||||
loadComponent: () =>
|
||||
import('./components/exceptions/exception-manager.component')
|
||||
.then(m => m.ExceptionManagerComponent),
|
||||
title: 'New Exception',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
component: SecretDetectionSettingsComponent,
|
||||
title: 'Secret Detection Settings'
|
||||
},
|
||||
{
|
||||
path: 'findings',
|
||||
component: SecretFindingsListComponent,
|
||||
title: 'Secret Findings'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Services barrel export.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE
|
||||
* @task SDU-001
|
||||
*/
|
||||
export * from './secret-detection-settings.service';
|
||||
export * from './secret-exception.service';
|
||||
export * from './secret-findings.service';
|
||||
@@ -1,365 +1,177 @@
|
||||
/**
|
||||
* Secret Detection Settings Service.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
* Secret Detection Settings API Service.
|
||||
*
|
||||
* Service for managing secret detection configuration.
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-001 - Create secret-detection feature module
|
||||
*/
|
||||
|
||||
import { Injectable, InjectionToken, inject, signal, computed } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Observable, of, catchError, map, tap, delay, BehaviorSubject } from 'rxjs';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { catchError, map, tap } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import {
|
||||
SecretDetectionSettings,
|
||||
SecretExceptionPattern,
|
||||
SecretRuleCategory,
|
||||
SecretException,
|
||||
RULE_CATEGORIES,
|
||||
RuleCategoryInfo,
|
||||
DEFAULT_SECRET_DETECTION_SETTINGS
|
||||
} from '../models/secret-detection.models';
|
||||
import { AlertDestinationSettings, AlertDestination, AlertTestResult } from '../models/alert-destination.models';
|
||||
CreateExceptionRequest,
|
||||
UpdateSettingsRequest,
|
||||
SecretFinding,
|
||||
} from '../models';
|
||||
|
||||
/**
|
||||
* Injection token for Settings API client.
|
||||
*/
|
||||
export const SECRET_DETECTION_SETTINGS_API = new InjectionToken<SecretDetectionSettingsApi>('SECRET_DETECTION_SETTINGS_API');
|
||||
|
||||
/**
|
||||
* Settings API interface.
|
||||
*/
|
||||
export interface SecretDetectionSettingsApi {
|
||||
/**
|
||||
* Get current settings for the tenant.
|
||||
*/
|
||||
getSettings(): Observable<SecretDetectionSettings>;
|
||||
|
||||
/**
|
||||
* Update settings.
|
||||
*/
|
||||
updateSettings(settings: Partial<SecretDetectionSettings>): Observable<SecretDetectionSettings>;
|
||||
|
||||
/**
|
||||
* Enable/disable secret detection.
|
||||
*/
|
||||
setEnabled(enabled: boolean): Observable<SecretDetectionSettings>;
|
||||
|
||||
/**
|
||||
* Update enabled rule categories.
|
||||
*/
|
||||
updateRuleCategories(categories: SecretRuleCategory[]): Observable<SecretDetectionSettings>;
|
||||
|
||||
/**
|
||||
* Add an exception.
|
||||
*/
|
||||
addException(exception: Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>): Observable<SecretException>;
|
||||
|
||||
/**
|
||||
* Remove an exception.
|
||||
*/
|
||||
removeException(exceptionId: string): Observable<void>;
|
||||
|
||||
/**
|
||||
* Update alert settings.
|
||||
*/
|
||||
updateAlertSettings(settings: AlertDestinationSettings): Observable<AlertDestinationSettings>;
|
||||
|
||||
/**
|
||||
* Test an alert destination.
|
||||
*/
|
||||
testAlertDestination(destinationId: string): Observable<AlertTestResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-based Settings API client.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpSecretDetectionSettingsApi implements SecretDetectionSettingsApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/config/secret-detection';
|
||||
|
||||
getSettings(): Observable<SecretDetectionSettings> {
|
||||
return this.http.get<SecretDetectionSettings>(this.baseUrl);
|
||||
}
|
||||
|
||||
updateSettings(settings: Partial<SecretDetectionSettings>): Observable<SecretDetectionSettings> {
|
||||
return this.http.patch<SecretDetectionSettings>(this.baseUrl, settings);
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): Observable<SecretDetectionSettings> {
|
||||
return this.http.patch<SecretDetectionSettings>(this.baseUrl, { enabled });
|
||||
}
|
||||
|
||||
updateRuleCategories(categories: SecretRuleCategory[]): Observable<SecretDetectionSettings> {
|
||||
return this.http.patch<SecretDetectionSettings>(this.baseUrl, { enabledRuleCategories: categories });
|
||||
}
|
||||
|
||||
addException(exception: Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>): Observable<SecretException> {
|
||||
return this.http.post<SecretException>(`${this.baseUrl}/exceptions`, exception);
|
||||
}
|
||||
|
||||
removeException(exceptionId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/exceptions/${encodeURIComponent(exceptionId)}`);
|
||||
}
|
||||
|
||||
updateAlertSettings(settings: AlertDestinationSettings): Observable<AlertDestinationSettings> {
|
||||
return this.http.put<AlertDestinationSettings>(`${this.baseUrl}/alerts`, settings);
|
||||
}
|
||||
|
||||
testAlertDestination(destinationId: string): Observable<AlertTestResult> {
|
||||
return this.http.post<AlertTestResult>(`${this.baseUrl}/alerts/destinations/${encodeURIComponent(destinationId)}/test`, {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Settings API for development/testing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MockSecretDetectionSettingsApi implements SecretDetectionSettingsApi {
|
||||
private settings: SecretDetectionSettings = { ...DEFAULT_SECRET_DETECTION_SETTINGS };
|
||||
|
||||
getSettings(): Observable<SecretDetectionSettings> {
|
||||
return of({ ...this.settings }).pipe(delay(200));
|
||||
}
|
||||
|
||||
updateSettings(updates: Partial<SecretDetectionSettings>): Observable<SecretDetectionSettings> {
|
||||
this.settings = { ...this.settings, ...updates, modifiedAt: new Date().toISOString() };
|
||||
return of({ ...this.settings }).pipe(delay(200));
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): Observable<SecretDetectionSettings> {
|
||||
return this.updateSettings({ enabled });
|
||||
}
|
||||
|
||||
updateRuleCategories(categories: SecretRuleCategory[]): Observable<SecretDetectionSettings> {
|
||||
return this.updateSettings({ enabledRuleCategories: categories });
|
||||
}
|
||||
|
||||
addException(exception: Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>): Observable<SecretException> {
|
||||
const newException: SecretException = {
|
||||
...exception,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'current-user'
|
||||
};
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
exceptions: [...this.settings.exceptions, newException],
|
||||
modifiedAt: new Date().toISOString()
|
||||
};
|
||||
return of(newException).pipe(delay(200));
|
||||
}
|
||||
|
||||
removeException(exceptionId: string): Observable<void> {
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
exceptions: this.settings.exceptions.filter(e => e.id !== exceptionId),
|
||||
modifiedAt: new Date().toISOString()
|
||||
};
|
||||
return of(void 0).pipe(delay(200));
|
||||
}
|
||||
|
||||
updateAlertSettings(settings: AlertDestinationSettings): Observable<AlertDestinationSettings> {
|
||||
this.settings = { ...this.settings, alertSettings: settings, modifiedAt: new Date().toISOString() };
|
||||
return of(settings).pipe(delay(200));
|
||||
}
|
||||
|
||||
testAlertDestination(destinationId: string): Observable<AlertTestResult> {
|
||||
// Simulate random success/failure for testing
|
||||
const success = Math.random() > 0.2;
|
||||
return of({
|
||||
success,
|
||||
error: success ? undefined : 'Connection timed out',
|
||||
testedAt: new Date().toISOString(),
|
||||
responseTimeMs: Math.floor(Math.random() * 500) + 100
|
||||
}).pipe(delay(1000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret Detection Settings Service.
|
||||
* Manages state and operations for secret detection configuration.
|
||||
* API service for secret detection configuration.
|
||||
* Communicates with Scanner WebService endpoints.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SecretDetectionSettingsService {
|
||||
private readonly api = inject(SECRET_DETECTION_SETTINGS_API);
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/secrets/config';
|
||||
|
||||
// State
|
||||
// State signals
|
||||
private readonly _settings = signal<SecretDetectionSettings | null>(null);
|
||||
private readonly _categories = signal<SecretRuleCategory[]>([]);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
private readonly _saving = signal(false);
|
||||
|
||||
// Public signals
|
||||
// Public readonly signals
|
||||
readonly settings = this._settings.asReadonly();
|
||||
readonly categories = this._categories.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
readonly saving = this._saving.asReadonly();
|
||||
|
||||
// Computed
|
||||
// Computed values
|
||||
readonly isEnabled = computed(() => this._settings()?.enabled ?? false);
|
||||
readonly enabledCategories = computed(() => this._settings()?.enabledRuleCategories ?? []);
|
||||
readonly exceptions = computed(() => this._settings()?.exceptions ?? []);
|
||||
readonly alertSettings = computed(() => this._settings()?.alertSettings ?? null);
|
||||
readonly availableCategories = computed(() => RULE_CATEGORIES);
|
||||
readonly enabledCategories = computed(() =>
|
||||
this._settings()?.enabledRuleCategories ?? []
|
||||
);
|
||||
readonly exceptions = computed(() =>
|
||||
this._settings()?.exceptions ?? []
|
||||
);
|
||||
readonly alertSettings = computed(() =>
|
||||
this._settings()?.alertSettings ?? null
|
||||
);
|
||||
|
||||
/**
|
||||
* Load settings from the API.
|
||||
* Loads settings for a tenant.
|
||||
*/
|
||||
loadSettings(): void {
|
||||
loadSettings(tenantId: string): Observable<SecretDetectionSettings> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.getSettings().pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
return this.http.get<SecretDetectionSettings>(
|
||||
`${this.baseUrl}/settings/${tenantId}`
|
||||
).pipe(
|
||||
tap(settings => {
|
||||
this._settings.set(settings);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to load settings');
|
||||
return of(null);
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
).subscribe(settings => {
|
||||
if (settings) {
|
||||
this._settings.set(settings);
|
||||
}
|
||||
this._loading.set(false);
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle secret detection enabled state.
|
||||
* Creates default settings for a new tenant.
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
this._saving.set(true);
|
||||
createSettings(tenantId: string): Observable<SecretDetectionSettings> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.setEnabled(enabled).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
return this.http.post<SecretDetectionSettings>(
|
||||
`${this.baseUrl}/settings/${tenantId}`,
|
||||
{}
|
||||
).pipe(
|
||||
tap(settings => {
|
||||
this._settings.set(settings);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to create settings');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings for a tenant.
|
||||
*/
|
||||
updateSettings(
|
||||
tenantId: string,
|
||||
request: UpdateSettingsRequest
|
||||
): Observable<SecretDetectionSettings> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.put<SecretDetectionSettings>(
|
||||
`${this.baseUrl}/settings/${tenantId}`,
|
||||
request
|
||||
).pipe(
|
||||
tap(settings => {
|
||||
this._settings.set(settings);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to update settings');
|
||||
return of(null);
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
).subscribe(settings => {
|
||||
if (settings) {
|
||||
this._settings.set(settings);
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update enabled rule categories.
|
||||
* Toggles secret detection on/off.
|
||||
*/
|
||||
updateCategories(categories: SecretRuleCategory[]): void {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.updateRuleCategories(categories).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to update categories');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(settings => {
|
||||
if (settings) {
|
||||
this._settings.set(settings);
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
toggleEnabled(tenantId: string, enabled: boolean): Observable<SecretDetectionSettings> {
|
||||
return this.updateSettings(tenantId, { enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update revelation policy.
|
||||
* Loads available rule categories.
|
||||
*/
|
||||
updateRevelationPolicy(policy: SecretDetectionSettings['revelationPolicy']): void {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.updateSettings({ revelationPolicy: policy }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to update revelation policy');
|
||||
return of(null);
|
||||
loadCategories(): Observable<SecretRuleCategory[]> {
|
||||
return this.http.get<SecretRuleCategory[]>(
|
||||
`${this.baseUrl}/rules/categories`
|
||||
).pipe(
|
||||
tap(categories => this._categories.set(categories)),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to load categories');
|
||||
return of([]);
|
||||
})
|
||||
).subscribe(settings => {
|
||||
if (settings) {
|
||||
this._settings.set(settings);
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an exception.
|
||||
* Updates enabled rule categories.
|
||||
*/
|
||||
addException(exception: Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>): void {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.addException(exception).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to add exception');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(newException => {
|
||||
if (newException) {
|
||||
const current = this._settings();
|
||||
if (current) {
|
||||
this._settings.set({
|
||||
...current,
|
||||
exceptions: [...current.exceptions, newException]
|
||||
});
|
||||
}
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
updateCategories(
|
||||
tenantId: string,
|
||||
categoryIds: string[]
|
||||
): Observable<SecretDetectionSettings> {
|
||||
return this.updateSettings(tenantId, { enabledRuleCategories: categoryIds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an exception.
|
||||
* Sends a test alert to verify destination configuration.
|
||||
* @sprint SDU-011 - Add channel test functionality
|
||||
*/
|
||||
removeException(exceptionId: string): void {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.removeException(exceptionId).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to remove exception');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(() => {
|
||||
const current = this._settings();
|
||||
if (current) {
|
||||
this._settings.set({
|
||||
...current,
|
||||
exceptions: current.exceptions.filter(e => e.id !== exceptionId)
|
||||
testAlertDestination(
|
||||
tenantId: string,
|
||||
destinationId: string
|
||||
): Observable<{ success: boolean; message: string }> {
|
||||
return this.http.post<{ success: boolean; message: string }>(
|
||||
`${this.baseUrl}/settings/${tenantId}/alerts/test`,
|
||||
{ destinationId }
|
||||
).pipe(
|
||||
catchError(err => {
|
||||
return of({
|
||||
success: false,
|
||||
message: err.error?.message || err.message || 'Test failed',
|
||||
});
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update alert settings.
|
||||
*/
|
||||
updateAlertSettings(settings: AlertDestinationSettings): void {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.updateAlertSettings(settings).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to update alert settings');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(alertSettings => {
|
||||
if (alertSettings) {
|
||||
const current = this._settings();
|
||||
if (current) {
|
||||
this._settings.set({ ...current, alertSettings });
|
||||
}
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test an alert destination.
|
||||
*/
|
||||
testDestination(destinationId: string): Observable<AlertTestResult> {
|
||||
return this.api.testAlertDestination(destinationId);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Secret Exception API Service.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-008, SDU-009 - Exception manager
|
||||
*/
|
||||
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { catchError, tap, map } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import {
|
||||
SecretExceptionPattern,
|
||||
CreateExceptionRequest,
|
||||
} from '../models';
|
||||
|
||||
/**
|
||||
* API service for secret exception patterns.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SecretExceptionService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/secrets/config/exceptions';
|
||||
|
||||
private readonly _exceptions = signal<SecretExceptionPattern[]>([]);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
|
||||
readonly exceptions = this._exceptions.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
|
||||
/**
|
||||
* Lists all exception patterns for a tenant.
|
||||
*/
|
||||
listExceptions(tenantId: string): Observable<SecretExceptionPattern[]> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.get<{ items: SecretExceptionPattern[] }>(
|
||||
`${this.baseUrl}/${tenantId}`
|
||||
).pipe(
|
||||
tap(response => {
|
||||
this._exceptions.set(response.items);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to load exceptions');
|
||||
this._loading.set(false);
|
||||
return of({ items: [] as SecretExceptionPattern[] });
|
||||
}),
|
||||
// Map to array
|
||||
map(response => response.items)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new exception pattern.
|
||||
*/
|
||||
createException(
|
||||
tenantId: string,
|
||||
request: CreateExceptionRequest
|
||||
): Observable<SecretExceptionPattern> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.post<SecretExceptionPattern>(
|
||||
`${this.baseUrl}/${tenantId}`,
|
||||
request
|
||||
).pipe(
|
||||
tap(exception => {
|
||||
// Add to local state
|
||||
this._exceptions.update(current => [...current, exception]);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to create exception');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing exception pattern.
|
||||
*/
|
||||
updateException(
|
||||
tenantId: string,
|
||||
exceptionId: string,
|
||||
request: Partial<CreateExceptionRequest>
|
||||
): Observable<SecretExceptionPattern> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.put<SecretExceptionPattern>(
|
||||
`${this.baseUrl}/${tenantId}/${exceptionId}`,
|
||||
request
|
||||
).pipe(
|
||||
tap(updated => {
|
||||
// Update in local state
|
||||
this._exceptions.update(current =>
|
||||
current.map(e => e.id === exceptionId ? updated : e)
|
||||
);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to update exception');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an exception pattern.
|
||||
*/
|
||||
deleteException(tenantId: string, exceptionId: string): Observable<void> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.delete<void>(
|
||||
`${this.baseUrl}/${tenantId}/${exceptionId}`
|
||||
).pipe(
|
||||
tap(() => {
|
||||
// Remove from local state
|
||||
this._exceptions.update(current =>
|
||||
current.filter(e => e.id !== exceptionId)
|
||||
);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to delete exception');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an exception pattern without saving.
|
||||
*/
|
||||
validatePattern(pattern: string): Observable<{ valid: boolean; error?: string }> {
|
||||
return this.http.post<{ valid: boolean; error?: string }>(
|
||||
`${this.baseUrl}/validate`,
|
||||
{ pattern }
|
||||
).pipe(
|
||||
catchError(() => of({ valid: false, error: 'Validation service unavailable' }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,519 +1,188 @@
|
||||
/**
|
||||
* Secret Findings Service.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
* Secret Findings API Service.
|
||||
*
|
||||
* Service for querying and managing secret findings.
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-005 - Create findings list component
|
||||
*/
|
||||
|
||||
import { Injectable, InjectionToken, inject, signal, computed } from '@angular/core';
|
||||
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Observable, of, catchError, delay } from 'rxjs';
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { catchError, tap, map } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import {
|
||||
SecretFinding,
|
||||
SecretFindingsFilter,
|
||||
SecretFindingsPage,
|
||||
SecretFindingsSortField,
|
||||
SecretFindingsSortDirection,
|
||||
SecretFindingStatus
|
||||
} from '../models/secret-finding.models';
|
||||
SecretSeverity,
|
||||
SecretFindingStatus,
|
||||
} from '../models';
|
||||
|
||||
/**
|
||||
* Injection token for Findings API client.
|
||||
* Query parameters for listing findings.
|
||||
*/
|
||||
export const SECRET_FINDINGS_API = new InjectionToken<SecretFindingsApi>('SECRET_FINDINGS_API');
|
||||
|
||||
/**
|
||||
* Resolution request for a finding.
|
||||
*/
|
||||
export interface ResolveFindingRequest {
|
||||
/** New status */
|
||||
status: SecretFindingStatus;
|
||||
/** Reason for resolution */
|
||||
reason: string;
|
||||
export interface FindingsQuery {
|
||||
scanId?: string;
|
||||
imageRef?: string;
|
||||
severity?: SecretSeverity[];
|
||||
status?: SecretFindingStatus[];
|
||||
ruleCategory?: string[];
|
||||
excepted?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings API interface.
|
||||
* Paginated response for findings.
|
||||
*/
|
||||
export interface SecretFindingsApi {
|
||||
/**
|
||||
* Get paginated findings.
|
||||
*/
|
||||
getFindings(
|
||||
filter: SecretFindingsFilter,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
sortField: SecretFindingsSortField,
|
||||
sortDirection: SecretFindingsSortDirection
|
||||
): Observable<SecretFindingsPage>;
|
||||
|
||||
/**
|
||||
* Get a single finding by ID.
|
||||
*/
|
||||
getFinding(findingId: string): Observable<SecretFinding>;
|
||||
|
||||
/**
|
||||
* Resolve a finding.
|
||||
*/
|
||||
resolveFinding(findingId: string, request: ResolveFindingRequest): Observable<SecretFinding>;
|
||||
|
||||
/**
|
||||
* Bulk resolve findings.
|
||||
*/
|
||||
bulkResolveFindingst(findingIds: string[], request: ResolveFindingRequest): Observable<SecretFinding[]>;
|
||||
|
||||
/**
|
||||
* Get finding counts by status.
|
||||
*/
|
||||
getFindingCounts(): Observable<Record<SecretFindingStatus, number>>;
|
||||
export interface FindingsResponse {
|
||||
items: SecretFinding[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-based Findings API client.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpSecretFindingsApi implements SecretFindingsApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/secrets/findings';
|
||||
|
||||
getFindings(
|
||||
filter: SecretFindingsFilter,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
sortField: SecretFindingsSortField,
|
||||
sortDirection: SecretFindingsSortDirection
|
||||
): Observable<SecretFindingsPage> {
|
||||
let params = new HttpParams()
|
||||
.set('page', page.toString())
|
||||
.set('pageSize', pageSize.toString())
|
||||
.set('sortField', sortField)
|
||||
.set('sortDirection', sortDirection);
|
||||
|
||||
if (filter.severity?.length) {
|
||||
params = params.set('severity', filter.severity.join(','));
|
||||
}
|
||||
if (filter.status?.length) {
|
||||
params = params.set('status', filter.status.join(','));
|
||||
}
|
||||
if (filter.category?.length) {
|
||||
params = params.set('category', filter.category.join(','));
|
||||
}
|
||||
if (filter.artifactRef) {
|
||||
params = params.set('artifactRef', filter.artifactRef);
|
||||
}
|
||||
if (filter.search) {
|
||||
params = params.set('search', filter.search);
|
||||
}
|
||||
if (filter.detectedAfter) {
|
||||
params = params.set('detectedAfter', filter.detectedAfter);
|
||||
}
|
||||
if (filter.detectedBefore) {
|
||||
params = params.set('detectedBefore', filter.detectedBefore);
|
||||
}
|
||||
|
||||
return this.http.get<SecretFindingsPage>(this.baseUrl, { params });
|
||||
}
|
||||
|
||||
getFinding(findingId: string): Observable<SecretFinding> {
|
||||
return this.http.get<SecretFinding>(`${this.baseUrl}/${encodeURIComponent(findingId)}`);
|
||||
}
|
||||
|
||||
resolveFinding(findingId: string, request: ResolveFindingRequest): Observable<SecretFinding> {
|
||||
return this.http.post<SecretFinding>(`${this.baseUrl}/${encodeURIComponent(findingId)}/resolve`, request);
|
||||
}
|
||||
|
||||
bulkResolveFindingst(findingIds: string[], request: ResolveFindingRequest): Observable<SecretFinding[]> {
|
||||
return this.http.post<SecretFinding[]>(`${this.baseUrl}/bulk-resolve`, {
|
||||
findingIds,
|
||||
...request
|
||||
});
|
||||
}
|
||||
|
||||
getFindingCounts(): Observable<Record<SecretFindingStatus, number>> {
|
||||
return this.http.get<Record<SecretFindingStatus, number>>(`${this.baseUrl}/counts`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Findings API for development/testing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MockSecretFindingsApi implements SecretFindingsApi {
|
||||
private findings: SecretFinding[] = [
|
||||
{
|
||||
id: 'finding-001',
|
||||
scanDigest: 'sha256:abc123',
|
||||
artifactDigest: 'sha256:def456',
|
||||
artifactRef: 'myregistry.io/myapp:v1.0.0',
|
||||
severity: 'critical',
|
||||
status: 'open',
|
||||
rule: {
|
||||
ruleId: 'aws-access-key-id',
|
||||
ruleName: 'AWS Access Key ID',
|
||||
category: 'aws',
|
||||
description: 'Detects AWS Access Key IDs'
|
||||
},
|
||||
location: {
|
||||
filePath: 'config/settings.yaml',
|
||||
lineNumber: 42,
|
||||
columnNumber: 15,
|
||||
context: 'aws_access_key: AKIA****WXYZ'
|
||||
},
|
||||
maskedValue: 'AKIA****WXYZ',
|
||||
secretType: 'AWS Access Key ID',
|
||||
detectedAt: '2026-01-04T10:30:00Z',
|
||||
lastSeenAt: '2026-01-04T10:30:00Z',
|
||||
occurrenceCount: 1,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resolutionReason: null
|
||||
},
|
||||
{
|
||||
id: 'finding-002',
|
||||
scanDigest: 'sha256:abc123',
|
||||
artifactDigest: 'sha256:def456',
|
||||
artifactRef: 'myregistry.io/myapp:v1.0.0',
|
||||
severity: 'high',
|
||||
status: 'open',
|
||||
rule: {
|
||||
ruleId: 'github-pat',
|
||||
ruleName: 'GitHub Personal Access Token',
|
||||
category: 'github',
|
||||
description: 'Detects GitHub Personal Access Tokens'
|
||||
},
|
||||
location: {
|
||||
filePath: '.github/workflows/deploy.yml',
|
||||
lineNumber: 28,
|
||||
columnNumber: 10,
|
||||
context: 'token: ghp_****abcd'
|
||||
},
|
||||
maskedValue: 'ghp_****abcd',
|
||||
secretType: 'GitHub PAT',
|
||||
detectedAt: '2026-01-04T10:32:00Z',
|
||||
lastSeenAt: '2026-01-04T10:32:00Z',
|
||||
occurrenceCount: 2,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resolutionReason: null
|
||||
},
|
||||
{
|
||||
id: 'finding-003',
|
||||
scanDigest: 'sha256:abc123',
|
||||
artifactDigest: 'sha256:ghi789',
|
||||
artifactRef: 'myregistry.io/api-service:v2.1.0',
|
||||
severity: 'medium',
|
||||
status: 'excepted',
|
||||
rule: {
|
||||
ruleId: 'private-key-rsa',
|
||||
ruleName: 'RSA Private Key',
|
||||
category: 'private-keys',
|
||||
description: 'Detects RSA private keys'
|
||||
},
|
||||
location: {
|
||||
filePath: 'test/fixtures/test-key.pem',
|
||||
lineNumber: 1,
|
||||
columnNumber: 1,
|
||||
context: '-----BEGIN RSA PRIVATE KEY-----'
|
||||
},
|
||||
maskedValue: '[REDACTED]',
|
||||
secretType: 'RSA Private Key',
|
||||
detectedAt: '2026-01-03T15:00:00Z',
|
||||
lastSeenAt: '2026-01-04T10:30:00Z',
|
||||
occurrenceCount: 5,
|
||||
resolvedBy: 'admin@example.com',
|
||||
resolvedAt: '2026-01-03T16:00:00Z',
|
||||
resolutionReason: 'Test fixture - not a real key'
|
||||
}
|
||||
];
|
||||
|
||||
getFindings(
|
||||
filter: SecretFindingsFilter,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
sortField: SecretFindingsSortField,
|
||||
sortDirection: SecretFindingsSortDirection
|
||||
): Observable<SecretFindingsPage> {
|
||||
let filtered = [...this.findings];
|
||||
|
||||
// Apply filters
|
||||
if (filter.severity?.length) {
|
||||
filtered = filtered.filter(f => filter.severity!.includes(f.severity));
|
||||
}
|
||||
if (filter.status?.length) {
|
||||
filtered = filtered.filter(f => filter.status!.includes(f.status));
|
||||
}
|
||||
if (filter.category?.length) {
|
||||
filtered = filtered.filter(f => filter.category!.includes(f.rule.category));
|
||||
}
|
||||
if (filter.artifactRef) {
|
||||
filtered = filtered.filter(f => f.artifactRef.includes(filter.artifactRef!));
|
||||
}
|
||||
if (filter.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filtered = filtered.filter(f =>
|
||||
f.location.filePath.toLowerCase().includes(search) ||
|
||||
f.rule.ruleName.toLowerCase().includes(search) ||
|
||||
f.secretType.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortField) {
|
||||
case 'severity':
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
break;
|
||||
case 'detectedAt':
|
||||
comparison = new Date(a.detectedAt).getTime() - new Date(b.detectedAt).getTime();
|
||||
break;
|
||||
case 'artifactRef':
|
||||
comparison = a.artifactRef.localeCompare(b.artifactRef);
|
||||
break;
|
||||
case 'category':
|
||||
comparison = a.rule.category.localeCompare(b.rule.category);
|
||||
break;
|
||||
case 'occurrenceCount':
|
||||
comparison = a.occurrenceCount - b.occurrenceCount;
|
||||
break;
|
||||
}
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
// Paginate
|
||||
const start = page * pageSize;
|
||||
const items = filtered.slice(start, start + pageSize);
|
||||
|
||||
return of({
|
||||
items,
|
||||
totalCount: filtered.length,
|
||||
page,
|
||||
pageSize,
|
||||
hasMore: start + pageSize < filtered.length
|
||||
}).pipe(delay(200));
|
||||
}
|
||||
|
||||
getFinding(findingId: string): Observable<SecretFinding> {
|
||||
const finding = this.findings.find(f => f.id === findingId);
|
||||
if (!finding) {
|
||||
throw new Error(`Finding not found: ${findingId}`);
|
||||
}
|
||||
return of(finding).pipe(delay(100));
|
||||
}
|
||||
|
||||
resolveFinding(findingId: string, request: ResolveFindingRequest): Observable<SecretFinding> {
|
||||
const index = this.findings.findIndex(f => f.id === findingId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Finding not found: ${findingId}`);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
this.findings[index] = {
|
||||
...this.findings[index],
|
||||
status: request.status,
|
||||
resolvedBy: 'current-user@example.com',
|
||||
resolvedAt: now,
|
||||
resolutionReason: request.reason
|
||||
};
|
||||
return of(this.findings[index]).pipe(delay(200));
|
||||
}
|
||||
|
||||
bulkResolveFindingst(findingIds: string[], request: ResolveFindingRequest): Observable<SecretFinding[]> {
|
||||
const now = new Date().toISOString();
|
||||
const resolved: SecretFinding[] = [];
|
||||
for (const id of findingIds) {
|
||||
const index = this.findings.findIndex(f => f.id === id);
|
||||
if (index !== -1) {
|
||||
this.findings[index] = {
|
||||
...this.findings[index],
|
||||
status: request.status,
|
||||
resolvedBy: 'current-user@example.com',
|
||||
resolvedAt: now,
|
||||
resolutionReason: request.reason
|
||||
};
|
||||
resolved.push(this.findings[index]);
|
||||
}
|
||||
}
|
||||
return of(resolved).pipe(delay(300));
|
||||
}
|
||||
|
||||
getFindingCounts(): Observable<Record<SecretFindingStatus, number>> {
|
||||
const counts: Record<SecretFindingStatus, number> = {
|
||||
open: 0,
|
||||
resolved: 0,
|
||||
excepted: 0,
|
||||
'false-positive': 0
|
||||
};
|
||||
for (const finding of this.findings) {
|
||||
counts[finding.status]++;
|
||||
}
|
||||
return of(counts).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret Findings Service.
|
||||
* Manages state and operations for secret findings.
|
||||
* API service for secret findings.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SecretFindingsService {
|
||||
private readonly api = inject(SECRET_FINDINGS_API);
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/secrets/findings';
|
||||
|
||||
// State
|
||||
private readonly _findings = signal<SecretFinding[]>([]);
|
||||
private readonly _totalCount = signal(0);
|
||||
private readonly _selectedFinding = signal<SecretFinding | null>(null);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
private readonly _currentPage = signal(0);
|
||||
private readonly _pageSize = signal(20);
|
||||
private readonly _filter = signal<SecretFindingsFilter>({});
|
||||
private readonly _sortField = signal<SecretFindingsSortField>('severity');
|
||||
private readonly _sortDirection = signal<SecretFindingsSortDirection>('asc');
|
||||
private readonly _selectedFinding = signal<SecretFinding | null>(null);
|
||||
private readonly _counts = signal<Record<SecretFindingStatus, number>>({
|
||||
open: 0,
|
||||
resolved: 0,
|
||||
excepted: 0,
|
||||
'false-positive': 0
|
||||
});
|
||||
private readonly _total = signal(0);
|
||||
private readonly _page = signal(1);
|
||||
private readonly _pageSize = signal(25);
|
||||
|
||||
// Public signals
|
||||
readonly findings = this._findings.asReadonly();
|
||||
readonly totalCount = this._totalCount.asReadonly();
|
||||
readonly selectedFinding = this._selectedFinding.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
readonly currentPage = this._currentPage.asReadonly();
|
||||
readonly total = this._total.asReadonly();
|
||||
readonly page = this._page.asReadonly();
|
||||
readonly pageSize = this._pageSize.asReadonly();
|
||||
readonly filter = this._filter.asReadonly();
|
||||
readonly sortField = this._sortField.asReadonly();
|
||||
readonly sortDirection = this._sortDirection.asReadonly();
|
||||
readonly selectedFinding = this._selectedFinding.asReadonly();
|
||||
readonly counts = this._counts.asReadonly();
|
||||
|
||||
// Computed
|
||||
readonly hasMore = computed(() => {
|
||||
const start = this._currentPage() * this._pageSize();
|
||||
return start + this._pageSize() < this._totalCount();
|
||||
// Computed statistics
|
||||
readonly stats = computed(() => {
|
||||
const findings = this._findings();
|
||||
return {
|
||||
total: findings.length,
|
||||
critical: findings.filter(f => f.severity === 'Critical').length,
|
||||
high: findings.filter(f => f.severity === 'High').length,
|
||||
medium: findings.filter(f => f.severity === 'Medium').length,
|
||||
low: findings.filter(f => f.severity === 'Low').length,
|
||||
excepted: findings.filter(f => f.excepted).length,
|
||||
};
|
||||
});
|
||||
readonly openCount = computed(() => this._counts().open);
|
||||
readonly totalPages = computed(() => Math.ceil(this._totalCount() / this._pageSize()));
|
||||
|
||||
/**
|
||||
* Load findings with current filter and pagination.
|
||||
* Lists findings with optional filters.
|
||||
*/
|
||||
loadFindings(): void {
|
||||
listFindings(tenantId: string, query?: FindingsQuery): Observable<FindingsResponse> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.getFindings(
|
||||
this._filter(),
|
||||
this._currentPage(),
|
||||
this._pageSize(),
|
||||
this._sortField(),
|
||||
this._sortDirection()
|
||||
let params = new HttpParams();
|
||||
if (query?.scanId) params = params.set('scanId', query.scanId);
|
||||
if (query?.imageRef) params = params.set('imageRef', query.imageRef);
|
||||
if (query?.severity?.length) params = params.set('severity', query.severity.join(','));
|
||||
if (query?.status?.length) params = params.set('status', query.status.join(','));
|
||||
if (query?.ruleCategory?.length) params = params.set('ruleCategory', query.ruleCategory.join(','));
|
||||
if (query?.excepted !== undefined) params = params.set('excepted', String(query.excepted));
|
||||
if (query?.page) params = params.set('page', String(query.page));
|
||||
if (query?.pageSize) params = params.set('pageSize', String(query.pageSize));
|
||||
|
||||
return this.http.get<FindingsResponse>(
|
||||
`${this.baseUrl}/${tenantId}`,
|
||||
{ params }
|
||||
).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
tap(response => {
|
||||
this._findings.set(response.items);
|
||||
this._total.set(response.total);
|
||||
this._page.set(response.page);
|
||||
this._pageSize.set(response.pageSize);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to load findings');
|
||||
return of(null);
|
||||
this._loading.set(false);
|
||||
return of({ items: [], total: 0, page: 1, pageSize: 25 });
|
||||
})
|
||||
).subscribe(page => {
|
||||
if (page) {
|
||||
this._findings.set(page.items);
|
||||
this._totalCount.set(page.totalCount);
|
||||
}
|
||||
this._loading.set(false);
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load finding counts.
|
||||
* Gets a single finding by ID.
|
||||
*/
|
||||
loadCounts(): void {
|
||||
this.api.getFindingCounts().pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe(counts => {
|
||||
if (counts) {
|
||||
this._counts.set(counts);
|
||||
}
|
||||
});
|
||||
getFinding(tenantId: string, findingId: string): Observable<SecretFinding> {
|
||||
this._loading.set(true);
|
||||
|
||||
return this.http.get<SecretFinding>(
|
||||
`${this.baseUrl}/${tenantId}/${findingId}`
|
||||
).pipe(
|
||||
tap(finding => {
|
||||
this._selectedFinding.set(finding);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to load finding');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update filter and reload.
|
||||
* Updates finding status.
|
||||
*/
|
||||
setFilter(filter: SecretFindingsFilter): void {
|
||||
this._filter.set(filter);
|
||||
this._currentPage.set(0);
|
||||
this.loadFindings();
|
||||
updateStatus(
|
||||
tenantId: string,
|
||||
findingId: string,
|
||||
status: SecretFindingStatus
|
||||
): Observable<SecretFinding> {
|
||||
return this.http.patch<SecretFinding>(
|
||||
`${this.baseUrl}/${tenantId}/${findingId}/status`,
|
||||
{ status }
|
||||
).pipe(
|
||||
tap(updated => {
|
||||
// Update in local state
|
||||
this._findings.update(current =>
|
||||
current.map(f => f.id === findingId ? updated : f)
|
||||
);
|
||||
if (this._selectedFinding()?.id === findingId) {
|
||||
this._selectedFinding.set(updated);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sort and reload.
|
||||
* Reveals the secret value for a finding (requires appropriate permissions).
|
||||
*/
|
||||
setSort(field: SecretFindingsSortField, direction: SecretFindingsSortDirection): void {
|
||||
this._sortField.set(field);
|
||||
this._sortDirection.set(direction);
|
||||
this.loadFindings();
|
||||
revealValue(tenantId: string, findingId: string): Observable<string> {
|
||||
return this.http.get<{ value: string }>(
|
||||
`${this.baseUrl}/${tenantId}/${findingId}/reveal`
|
||||
).pipe(
|
||||
map(response => response.value),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to reveal secret value');
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to page.
|
||||
*/
|
||||
setPage(page: number): void {
|
||||
this._currentPage.set(page);
|
||||
this.loadFindings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a finding for detail view.
|
||||
* Selects a finding for detail view.
|
||||
*/
|
||||
selectFinding(finding: SecretFinding | null): void {
|
||||
this._selectedFinding.set(finding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific finding by ID.
|
||||
* Clears current selection.
|
||||
*/
|
||||
loadFinding(findingId: string): void {
|
||||
this._loading.set(true);
|
||||
this.api.getFinding(findingId).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to load finding');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(finding => {
|
||||
if (finding) {
|
||||
this._selectedFinding.set(finding);
|
||||
}
|
||||
this._loading.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a finding.
|
||||
*/
|
||||
resolveFinding(findingId: string, status: SecretFindingStatus, reason: string): void {
|
||||
this._loading.set(true);
|
||||
this.api.resolveFinding(findingId, { status, reason }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to resolve finding');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(updated => {
|
||||
if (updated) {
|
||||
// Update in list
|
||||
this._findings.update(findings =>
|
||||
findings.map(f => f.id === findingId ? updated : f)
|
||||
);
|
||||
// Update selected if same
|
||||
if (this._selectedFinding()?.id === findingId) {
|
||||
this._selectedFinding.set(updated);
|
||||
}
|
||||
// Reload counts
|
||||
this.loadCounts();
|
||||
}
|
||||
this._loading.set(false);
|
||||
});
|
||||
clearSelection(): void {
|
||||
this._selectedFinding.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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%');
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
26
src/Web/StellaOps.Web/src/app/features/timeline/index.ts
Normal file
26
src/Web/StellaOps.Web/src/app/features/timeline/index.ts
Normal 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';
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
×
|
||||
</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">
|
||||
↺
|
||||
</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">×</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('');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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">🔒</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">
|
||||
×
|
||||
</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">↓</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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user