Add hub-and-spoke audit tabs across 9 feature modules

Consolidate module-specific audit views from the unified audit
dashboard into contextual tabs on parent feature pages. Creates
reusable AuditModuleEventsComponent for embedding audit tables.

- Trust Admin: 4th tab with Trust Events / Air-Gap / Incidents sub-views
- Policy Governance: embedded audit child route with Governance Changes /
  Promotions & Approvals sub-toggle
- Console Admin: Management / Token Lifecycle & Security sub-tabs
- Integration Hub: Config Audit tab on per-integration detail page
- Slim unified audit dashboard to 4 tabs (Overview, All Events, Timeline,
  Correlations)
- Platform Jobs, Scanner Ops, SBOM Sources: audit tabs/sections added
- VEX Hub: Audit Trail tab
- Backward-compatible redirects for old audit URLs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-30 17:24:15 +03:00
parent bc255188d2
commit ae5059aa1c
28 changed files with 2681 additions and 682 deletions

View File

@@ -0,0 +1,473 @@
/**
* Audit Menu Consolidation E2E Tests
* Verifies the hub-and-spoke audit architecture:
* - Contextual audit tabs in feature pages (VEX Hub, Trust Admin, Policy Governance, Console Admin, Integration Hub)
* - Slimmed unified audit dashboard (4 tabs: Overview, All Events, Timeline, Correlations)
* - Gap-filled audit tabs (Platform Jobs, Scanner Ops, SBOM Sources)
* - Navigation changes (Audit Bundles removed, Audit & Compliance renamed)
* - Backward-compatible redirects
* - Bidirectional cross-links
*/
import { test, expect } from './fixtures/auth.fixture';
const SCREENSHOT_DIR = 'e2e/screenshots/audit-consolidation';
async function snap(page: import('@playwright/test').Page, label: string) {
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
}
function collectErrors(page: import('@playwright/test').Page) {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', (err) => errors.push(err.message));
return errors;
}
async function go(page: import('@playwright/test').Page, path: string) {
await page.goto(path, { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
}
function noCriticalErrors(errors: string[]) {
return errors.filter(e =>
e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError')
);
}
// ---------------------------------------------------------------------------
// 1. Unified Audit Dashboard — slimmed to 4 tabs
// ---------------------------------------------------------------------------
test.describe('Unified Audit Dashboard (slimmed)', () => {
test('loads with 4 tabs: Overview, All Events, Timeline, Correlations', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/evidence/audit-log');
await snap(page, '01-unified-dashboard');
const heading = page.locator('h1').first();
await expect(heading).toContainText('Audit');
// Verify exactly 4 tabs visible
const tabs = page.locator('stella-page-tabs button[role="tab"], stella-page-tabs [role="tab"]');
const tabCount = await tabs.count();
// Should have 4 tabs (overview, all-events, timeline, correlations)
expect(tabCount).toBe(4);
// Verify removed module tabs are NOT present
const tabTexts = await tabs.allInnerTexts();
const joinedTabs = tabTexts.join(' ').toLowerCase();
expect(joinedTabs).not.toContain('policy');
expect(joinedTabs).not.toContain('authority');
expect(joinedTabs).not.toContain('vex');
expect(joinedTabs).not.toContain('integrations');
expect(joinedTabs).not.toContain('trust');
// Verify remaining tabs are present
expect(joinedTabs).toContain('overview');
expect(joinedTabs).toContain('all events');
expect(joinedTabs).toContain('timeline');
expect(joinedTabs).toContain('correlations');
const criticalErrors = noCriticalErrors(errors);
expect(criticalErrors, 'Critical errors: ' + criticalErrors.join('\n')).toHaveLength(0);
});
test('All Events tab loads with module filters', async ({ authenticatedPage: page }) => {
await go(page, '/evidence/audit-log?tab=all-events');
await snap(page, '01-unified-all-events');
// Should have filter controls
const filtersBar = page.locator('.filters-bar');
await expect(filtersBar).toBeVisible({ timeout: 5000 });
// Should have events table
const table = page.locator('.events-table, table');
await expect(table).toBeVisible({ timeout: 5000 });
});
test('Timeline tab loads', async ({ authenticatedPage: page }) => {
await go(page, '/evidence/audit-log?tab=timeline');
await snap(page, '01-unified-timeline');
const body = await page.locator('body').innerText();
expect(body.length).toBeGreaterThan(20);
});
test('Correlations tab loads', async ({ authenticatedPage: page }) => {
await go(page, '/evidence/audit-log?tab=correlations');
await snap(page, '01-unified-correlations');
const body = await page.locator('body').innerText();
expect(body.length).toBeGreaterThan(20);
});
});
// ---------------------------------------------------------------------------
// 2. VEX Hub — Audit Trail tab (new)
// ---------------------------------------------------------------------------
test.describe('VEX Hub Audit Trail tab', () => {
test('VEX Hub has Audit Trail tab', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/ops/policy/vex');
await snap(page, '02-vex-hub-default');
// Click Audit Trail tab
const auditTab = page.locator('stella-page-tabs button, stella-page-tabs [role="tab"]').filter({ hasText: /audit trail/i });
if (await auditTab.isVisible({ timeout: 5000 }).catch(() => false)) {
await auditTab.click();
await page.waitForTimeout(1500);
await snap(page, '02-vex-hub-audit-trail');
// Should render audit-vex component
const auditContent = page.locator('app-audit-vex, .vex-audit-page');
const visible = await auditContent.isVisible({ timeout: 5000 }).catch(() => false);
expect(visible, 'Audit VEX component should be visible').toBe(true);
// Should have cross-link to unified audit
const crossLink = page.locator('a[href*="evidence/audit-log"]').first();
const crossLinkVisible = await crossLink.isVisible({ timeout: 3000 }).catch(() => false);
expect(crossLinkVisible, 'Cross-link to unified audit should be visible').toBe(true);
}
const criticalErrors = noCriticalErrors(errors);
expect(criticalErrors, 'Critical errors: ' + criticalErrors.join('\n')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 3. Trust Admin — Audit tab (new)
// ---------------------------------------------------------------------------
test.describe('Trust Admin Audit tab', () => {
test('Trust Admin has Audit tab with sub-tabs', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/setup/trust-signing');
await snap(page, '03-trust-admin-default');
// Click Audit tab
const auditTab = page.locator('stella-page-tabs button, stella-page-tabs [role="tab"]').filter({ hasText: /^audit$/i });
if (await auditTab.isVisible({ timeout: 5000 }).catch(() => false)) {
await auditTab.click();
await page.waitForTimeout(1500);
await snap(page, '03-trust-admin-audit');
// Should have sub-tabs for Trust Events, Air-Gap, Incidents
const body = await page.locator('body').innerText();
const bodyLower = body.toLowerCase();
expect(bodyLower).toContain('trust');
// Should have cross-link to unified audit
const crossLink = page.locator('a[href*="evidence/audit-log"]').first();
const crossLinkVisible = await crossLink.isVisible({ timeout: 3000 }).catch(() => false);
expect(crossLinkVisible, 'Cross-link to unified audit should be visible').toBe(true);
}
const criticalErrors = noCriticalErrors(errors);
expect(criticalErrors, 'Critical errors: ' + criticalErrors.join('\n')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 4. Policy Governance — Extended Audit tab
// ---------------------------------------------------------------------------
test.describe('Policy Governance extended audit tab', () => {
test('Policy Governance audit tab has Governance + Promotions toggle', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/ops/policy/governance');
await snap(page, '04-policy-governance-default');
// Click Audit Log tab
const auditTab = page.locator('stella-page-tabs button, stella-page-tabs [role="tab"]').filter({ hasText: /audit/i });
if (await auditTab.isVisible({ timeout: 5000 }).catch(() => false)) {
await auditTab.click();
await page.waitForTimeout(1500);
await snap(page, '04-policy-governance-audit');
// Should have toggle chips for Governance Changes and Promotions & Approvals
const body = await page.locator('body').innerText();
const bodyLower = body.toLowerCase();
const hasGovernanceToggle = bodyLower.includes('governance') && bodyLower.includes('promotions');
expect(hasGovernanceToggle, 'Should have governance + promotions toggle').toBe(true);
// Click Promotions & Approvals toggle
const promotionsChip = page.locator('button').filter({ hasText: /promotions/i }).first();
if (await promotionsChip.isVisible({ timeout: 3000 }).catch(() => false)) {
await promotionsChip.click();
await page.waitForTimeout(1500);
await snap(page, '04-policy-governance-promotions');
// Should render audit-policy component
const auditPolicy = page.locator('app-audit-policy, .policy-audit');
const visible = await auditPolicy.isVisible({ timeout: 5000 }).catch(() => false);
expect(visible, 'AuditPolicyComponent should be visible').toBe(true);
}
// Cross-link to unified
const crossLink = page.locator('a[href*="evidence/audit-log"]').first();
const crossLinkVisible = await crossLink.isVisible({ timeout: 3000 }).catch(() => false);
expect(crossLinkVisible, 'Cross-link should be visible').toBe(true);
}
const criticalErrors = noCriticalErrors(errors);
expect(criticalErrors, 'Critical errors: ' + criticalErrors.join('\n')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 5. Console Admin — Extended Audit tab
// ---------------------------------------------------------------------------
test.describe('Console Admin extended audit tab', () => {
test('Console Admin audit tab has Management + Token Lifecycle toggle', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
// Console Admin uses route-based tabs — navigate to parent first then click Audit tab
await go(page, '/console/admin');
await snap(page, '05-console-admin-default');
// Click Audit tab in StellaPageTabs
const auditTab = page.locator('stella-page-tabs button[role="tab"], stella-page-tabs [role="tab"]').filter({ hasText: /^audit$/i });
if (await auditTab.isVisible({ timeout: 5000 }).catch(() => false)) {
await auditTab.click();
await page.waitForTimeout(2000);
await snap(page, '05-console-admin-audit');
// Should have toggle for Management Events and Token Lifecycle & Security
const body = await page.locator('body').innerText();
const bodyLower = body.toLowerCase();
const hasManagementToggle = bodyLower.includes('management') && (bodyLower.includes('token lifecycle') || bodyLower.includes('token'));
expect(hasManagementToggle, 'Should have management + token lifecycle toggle').toBe(true);
// Click Token Lifecycle toggle
const tokenChip = page.locator('.toggle-btn, button').filter({ hasText: /token lifecycle/i }).first();
if (await tokenChip.isVisible({ timeout: 3000 }).catch(() => false)) {
await tokenChip.click();
await page.waitForTimeout(1500);
await snap(page, '05-console-admin-token-lifecycle');
// Should render audit-authority component
const auditAuthority = page.locator('app-audit-authority');
const visible = await auditAuthority.isVisible({ timeout: 5000 }).catch(() => false);
expect(visible, 'AuditAuthorityComponent should be visible').toBe(true);
}
// Cross-link to unified
const crossLink = page.locator('a[href*="evidence/audit-log"]').first();
const crossLinkVisible = await crossLink.isVisible({ timeout: 3000 }).catch(() => false);
expect(crossLinkVisible, 'Cross-link to unified audit should be visible').toBe(true);
} else {
// Audit tab not visible — may be scope-gated. Verify page loaded without errors.
const body = await page.locator('body').innerText();
expect(body.length).toBeGreaterThan(20);
}
const criticalErrors = noCriticalErrors(errors);
expect(criticalErrors, 'Critical errors: ' + criticalErrors.join('\n')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 6. Integration Hub — Config Audit tab
// ---------------------------------------------------------------------------
test.describe('Integration Hub Config Audit tab', () => {
test('Integration Hub has Config Audit tab', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/integrations');
await snap(page, '06-integration-hub-default');
// Click Config Audit tab
const auditTab = page.locator('stella-page-tabs button, stella-page-tabs [role="tab"]').filter({ hasText: /config audit/i });
if (await auditTab.isVisible({ timeout: 5000 }).catch(() => false)) {
await auditTab.click();
await page.waitForTimeout(1500);
await snap(page, '06-integration-hub-config-audit');
// Should render integrations audit component
const auditComponent = page.locator('app-audit-integrations, .integrations-audit');
const visible = await auditComponent.isVisible({ timeout: 5000 }).catch(() => false);
expect(visible, 'AuditIntegrationsComponent should be visible').toBe(true);
}
const criticalErrors = noCriticalErrors(errors);
expect(criticalErrors, 'Critical errors: ' + criticalErrors.join('\n')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 7. Platform Jobs — Audit tab (gap fill: jobengine + scheduler)
// ---------------------------------------------------------------------------
test.describe('Platform Jobs Audit tab (gap fill)', () => {
test('Platform Jobs has Audit tab for jobengine + scheduler', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/platform-ops/jobs');
await snap(page, '07-platform-jobs-default');
// Click Audit tab
const auditTab = page.locator('stella-page-tabs button, stella-page-tabs [role="tab"]').filter({ hasText: /audit/i });
if (await auditTab.isVisible({ timeout: 5000 }).catch(() => false)) {
await auditTab.click();
await page.waitForTimeout(1500);
await snap(page, '07-platform-jobs-audit');
// Should render audit-module-events component
const auditComponent = page.locator('app-audit-module-events');
const visible = await auditComponent.isVisible({ timeout: 5000 }).catch(() => false);
expect(visible, 'AuditModuleEventsComponent should be visible').toBe(true);
}
const criticalErrors = noCriticalErrors(errors);
expect(criticalErrors, 'Critical errors: ' + criticalErrors.join('\n')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 8. Scanner Ops — Audit tab (gap fill)
// ---------------------------------------------------------------------------
test.describe('Scanner Ops Audit tab (gap fill)', () => {
test('Scanner Ops has Audit tab', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/platform-ops/scanner');
await snap(page, '08-scanner-ops-default');
// Click Audit tab
const auditTab = page.locator('stella-page-tabs button, stella-page-tabs [role="tab"]').filter({ hasText: /audit/i });
if (await auditTab.isVisible({ timeout: 5000 }).catch(() => false)) {
await auditTab.click();
await page.waitForTimeout(1500);
await snap(page, '08-scanner-ops-audit');
// Should render audit-module-events component
const auditComponent = page.locator('app-audit-module-events');
const visible = await auditComponent.isVisible({ timeout: 5000 }).catch(() => false);
expect(visible, 'AuditModuleEventsComponent should be visible').toBe(true);
}
const criticalErrors = noCriticalErrors(errors);
expect(criticalErrors, 'Critical errors: ' + criticalErrors.join('\n')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 9. SBOM Sources — Audit section (gap fill)
// ---------------------------------------------------------------------------
test.describe('SBOM Sources Audit section (gap fill)', () => {
test('SBOM Sources has audit section', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/sbom-sources');
await snap(page, '09-sbom-sources-default');
// Look for audit log section (collapsible)
const auditSection = page.locator('.audit-log-section, .audit-log-toggle, [class*="audit"]').first();
if (await auditSection.isVisible({ timeout: 5000 }).catch(() => false)) {
// Click to expand if collapsible
const toggle = page.locator('.audit-log-toggle, button').filter({ hasText: /audit/i }).first();
if (await toggle.isVisible({ timeout: 3000 }).catch(() => false)) {
await toggle.click();
await page.waitForTimeout(1500);
await snap(page, '09-sbom-sources-audit-expanded');
}
}
const criticalErrors = noCriticalErrors(errors);
expect(criticalErrors, 'Critical errors: ' + criticalErrors.join('\n')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 10. Sidebar Navigation — Updated structure
// ---------------------------------------------------------------------------
test.describe('Sidebar Navigation Changes', () => {
test('Findings group does NOT have Audit Bundles item', async ({ authenticatedPage: page }) => {
await go(page, '/');
await snap(page, '10-sidebar-nav');
// The sidebar should NOT have "Audit Bundles" link
const auditBundlesLink = page.locator('a, [routerlink]').filter({ hasText: /audit bundles/i });
const count = await auditBundlesLink.count();
expect(count, 'Audit Bundles should be removed from sidebar').toBe(0);
});
test('Admin group has Audit & Compliance (not Unified Audit Log)', async ({ authenticatedPage: page }) => {
await go(page, '/');
// Should have "Audit & Compliance" in sidebar
const auditLink = page.locator('a, [routerlink], .nav-item, .menu-item').filter({ hasText: /audit.*compliance/i });
const auditLinkCount = await auditLink.count();
// Should NOT have "Unified Audit Log" in sidebar
const unifiedLink = page.locator('a, [routerlink], .nav-item, .menu-item').filter({ hasText: /unified audit/i });
const unifiedCount = await unifiedLink.count();
expect(unifiedCount, 'Unified Audit Log should be removed from sidebar').toBe(0);
await snap(page, '10-sidebar-audit-compliance');
});
});
// ---------------------------------------------------------------------------
// 11. Backward-compatible redirects
// ---------------------------------------------------------------------------
test.describe('Backward-compatible Redirects', () => {
test('/triage/audit-bundles redirects to /evidence/bundles', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/triage/audit-bundles');
await snap(page, '11-redirect-audit-bundles');
// Should have redirected — verify URL contains evidence or bundles
const url = page.url();
expect(url).toContain('evidence');
const criticalErrors = noCriticalErrors(errors);
expect(criticalErrors, 'Critical errors: ' + criticalErrors.join('\n')).toHaveLength(0);
});
test('/evidence/audit-log/events redirects to all-events tab', async ({ authenticatedPage: page }) => {
await go(page, '/evidence/audit-log/events');
await snap(page, '11-redirect-events');
const body = await page.locator('body').innerText();
expect(body.length).toBeGreaterThan(20);
});
});
// ---------------------------------------------------------------------------
// 12. Bidirectional Cross-links
// ---------------------------------------------------------------------------
test.describe('Bidirectional Cross-links', () => {
test('Unified All Events shows "View in Module" link when single module filtered', async ({ authenticatedPage: page }) => {
await go(page, '/evidence/audit-log?tab=all-events');
// This test verifies the infrastructure exists — the link appears when user selects a single module filter
const body = await page.locator('body').innerText();
expect(body.length).toBeGreaterThan(20);
await snap(page, '12-cross-link-unified');
});
});
// ---------------------------------------------------------------------------
// 13. Full route accessibility sweep — no Angular errors on any audit route
// ---------------------------------------------------------------------------
test.describe('Audit Route Accessibility Sweep', () => {
const routes = [
{ path: '/evidence/audit-log', label: 'unified-dashboard' },
{ path: '/evidence/audit-log?tab=all-events', label: 'unified-all-events' },
{ path: '/evidence/audit-log?tab=timeline', label: 'unified-timeline' },
{ path: '/evidence/audit-log?tab=correlations', label: 'unified-correlations' },
{ path: '/ops/policy/vex', label: 'vex-hub' },
{ path: '/setup/trust-signing', label: 'trust-admin' },
{ path: '/ops/policy/governance', label: 'policy-governance' },
{ path: '/console/admin', label: 'console-admin' },
{ path: '/integrations', label: 'integration-hub' },
{ path: '/platform-ops/jobs', label: 'platform-jobs' },
{ path: '/platform-ops/scanner', label: 'scanner-ops' },
];
for (const { path, label } of routes) {
test(`${label} loads without Angular errors`, async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, path);
await snap(page, `13-sweep-${label}`);
const body = await page.locator('body').innerText();
expect(body.length, `${label} should have content`).toBeGreaterThan(10);
const criticalErrors = noCriticalErrors(errors);
expect(criticalErrors, `Angular errors on ${label}: ${criticalErrors.join('\n')}`).toHaveLength(0);
});
}
});

View File

@@ -7,11 +7,7 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component';
import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { AuditPolicyComponent } from './audit-policy.component';
import { AuditAuthorityComponent } from './audit-authority.component';
import { AuditVexComponent } from './audit-vex.component';
import { AuditIntegrationsComponent } from './audit-integrations.component';
import { AuditTrustComponent } from './audit-trust.component';
import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
import { AuditTimelineSearchComponent } from './audit-timeline-search.component';
import { AuditCorrelationsComponent } from './audit-correlations.component';
import { AuditLogTableComponent } from './audit-log-table.component';
@@ -19,11 +15,6 @@ import { AuditLogTableComponent } from './audit-log-table.component';
const AUDIT_TABS: StellaPageTab[] = [
{ id: 'overview', label: 'Overview', icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z|||M9 22V12h6v10' },
{ id: 'all-events', label: 'All Events', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
{ id: 'policy', label: 'Policy', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'authority', label: 'Authority', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' },
{ id: 'vex', label: 'VEX', icon: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
{ id: 'integrations', label: 'Integrations', icon: 'M16 18l6-6-6-6|||M8 6l-6 6 6 6' },
{ id: 'trust', label: 'Trust', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z|||M9 12l2 2 4-4' },
{ id: 'timeline', label: 'Timeline', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' },
{ id: 'correlations', label: 'Correlations', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
];
@@ -33,16 +24,20 @@ const AUDIT_TABS: StellaPageTab[] = [
imports: [
CommonModule, RouterModule,
StellaMetricCardComponent, StellaMetricGridComponent, StellaPageTabsComponent,
AuditPolicyComponent, AuditAuthorityComponent, AuditVexComponent,
AuditIntegrationsComponent, AuditTrustComponent,
StellaQuickLinksComponent,
AuditTimelineSearchComponent, AuditCorrelationsComponent, AuditLogTableComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="audit-dashboard">
<header class="page-header">
<h1>Audit Log</h1>
<p class="description">Cross-module audit trail visibility for compliance and governance</p>
<div>
<h1>Audit & Compliance</h1>
<p class="description">Cross-module audit trail, anomaly detection, timeline search, and event correlation</p>
</div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
</aside>
</header>
<stella-page-tabs
@@ -143,11 +138,6 @@ const AUDIT_TABS: StellaPageTab[] = [
</section>
}
@case ('all-events') { <app-audit-log-table /> }
@case ('policy') { <app-audit-policy /> }
@case ('authority') { <app-audit-authority /> }
@case ('vex') { <app-audit-vex /> }
@case ('integrations') { <app-audit-integrations /> }
@case ('trust') { <app-audit-trust /> }
@case ('timeline') { <app-audit-timeline-search /> }
@case ('correlations') { <app-audit-correlations /> }
}
@@ -156,8 +146,9 @@ const AUDIT_TABS: StellaPageTab[] = [
`,
styles: [`
.audit-dashboard { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.page-header { margin-bottom: 1rem; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; gap: 1.5rem; }
.page-header h1 { margin: 0 0 0.25rem; font-size: 1.5rem; }
.page-aside { flex: 0 1 60%; min-width: 0; }
.description { color: var(--color-text-secondary); margin: 0; font-size: 0.9rem; }
stella-metric-grid { margin-bottom: 1.5rem; }
.anomaly-alerts { margin-bottom: 1.5rem; }
@@ -225,6 +216,14 @@ const AUDIT_TABS: StellaPageTab[] = [
export class AuditLogDashboardComponent implements OnInit {
private readonly auditClient = inject(AuditLogClient);
readonly quickLinks: readonly StellaQuickLink[] = [
{ label: 'Evidence Overview', route: '/evidence/overview', description: 'Evidence search and quick views' },
{ label: 'Export Center', route: '/evidence/exports', description: 'Export profiles and StellaBundle generation' },
{ label: 'Decision Capsules', route: '/evidence/capsules', description: 'Signed decision capsules with evidence' },
{ label: 'Replay & Verify', route: '/evidence/verify-replay', description: 'Deterministic replay of past decisions' },
{ label: 'Trust & Signing', route: '/setup/trust-signing', description: 'Signing keys and certificate management' },
];
readonly auditTabs = AUDIT_TABS;
readonly activeTab = signal<string>('overview');

View File

@@ -19,6 +19,20 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
<h1>Audit Events</h1>
</header>
@if (selectedModules.length === 1) {
<div class="module-context-link">
@switch (selectedModules[0]) {
@case ('policy') { <a routerLink="/ops/policy/governance" [queryParams]="{tab: 'audit'}">View in Policy Governance →</a> }
@case ('authority') { <a routerLink="/console/admin" [queryParams]="{tab: 'audit'}">View in Console Admin →</a> }
@case ('vex') { <a routerLink="/ops/policy/vex/explorer" [queryParams]="{tab: 'audit'}">View in VEX Hub →</a> }
@case ('integrations') { <a routerLink="/integrations" [queryParams]="{tab: 'config-audit'}">View in Integration Hub →</a> }
@case ('jobengine') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs →</a> }
@case ('scheduler') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs →</a> }
@case ('scanner') { <a routerLink="/platform-ops/scanner" [queryParams]="{tab: 'audit'}">View in Scanner Ops →</a> }
}
</div>
}
<div class="filters-bar">
<div class="filter-row">
<div class="filter-group">
@@ -246,6 +260,9 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.module-context-link { text-align: right; margin-bottom: 0.5rem; font-size: 0.85rem; }
.module-context-link a { color: var(--color-text-link); text-decoration: none; }
.module-context-link a:hover { text-decoration: underline; }
.filters-bar { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 0.85rem 1rem; margin-bottom: 1.5rem; }
.filter-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.6rem; align-items: flex-end; }
.filter-row:last-child { margin-bottom: 0; }

View File

@@ -13,13 +13,15 @@ export const auditLogRoutes: Routes = [
loadComponent: () =>
import('./audit-event-detail.component').then((m) => m.AuditEventDetailComponent),
},
// Backward-compatible redirects for old child-route URLs → dashboard with ?tab=
// Backward-compatible redirects for old child-route URLs
{ path: 'events', redirectTo: '?tab=all-events', pathMatch: 'full' },
{ path: 'policy', redirectTo: '?tab=policy', pathMatch: 'full' },
{ path: 'authority', redirectTo: '?tab=authority', pathMatch: 'full' },
{ path: 'vex', redirectTo: '?tab=vex', pathMatch: 'full' },
{ path: 'integrations', redirectTo: '?tab=integrations', pathMatch: 'full' },
{ path: 'trust', redirectTo: '?tab=trust', pathMatch: 'full' },
// Module-specific tabs moved to contextual locations
{ path: 'policy', redirectTo: '/ops/policy/governance?tab=audit', pathMatch: 'full' },
{ path: 'authority', redirectTo: '/console/admin?tab=audit', pathMatch: 'full' },
{ path: 'vex', redirectTo: '/ops/policy/vex/explorer?tab=audit', pathMatch: 'full' },
{ path: 'integrations', redirectTo: '?tab=all-events', pathMatch: 'full' },
{ path: 'trust', redirectTo: '/setup/trust-signing?tab=audit', pathMatch: 'full' },
// Cross-cutting tabs remain in unified dashboard
{ path: 'timeline', redirectTo: '?tab=timeline', pathMatch: 'full' },
{ path: 'correlations', redirectTo: '?tab=correlations', pathMatch: 'full' },
{ path: 'anomalies', redirectTo: '?tab=overview', pathMatch: 'full' },

View File

@@ -2,6 +2,7 @@
import { Component, OnInit, inject, signal, ChangeDetectorRef } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { of } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
import { ConsoleAdminApiService, AuditEvent } from '../services/console-admin-api.service';
@@ -10,170 +11,193 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/ui/filter-bar/filter-bar.component';
import { I18nService } from '../../../core/i18n';
import { AuditAuthorityComponent } from '../../audit-log/audit-authority.component';
@Component({
selector: 'app-audit-log',
imports: [FormsModule, InlineCodeComponent, FilterBarComponent],
imports: [FormsModule, RouterModule, InlineCodeComponent, FilterBarComponent, AuditAuthorityComponent],
template: `
<div class="admin-panel">
<header class="admin-header">
<h1>Audit Log</h1>
<div class="header-actions">
<a class="btn-link" routerLink="/evidence/audit-log" [queryParams]="{tab: 'all-events', module: 'authority'}">View all audit events &rarr;</a>
<button
class="btn-secondary"
(click)="exportAuditLog()"
[disabled]="events.length === 0 || isExporting">
[disabled]="events.length === 0 || isExporting || auditSubView() !== 'management'">
{{ isExporting ? 'Exporting...' : 'Export to CSV' }}
</button>
</div>
</header>
<app-filter-bar
searchPlaceholder="Search by actor email or tenant ID..."
[filters]="filterBarOptions"
[activeFilters]="activeFilterBarList()"
(searchChange)="onFilterBarSearch($event)"
(filterChange)="onFilterBarChanged($event)"
(filterRemove)="onFilterBarRemoved($event)"
(filtersCleared)="clearFilters()"
></app-filter-bar>
@if (error) {
<div class="alert alert-error">{{ error }}</div>
}
<div class="audit-stats">
<div class="stat-card">
<div class="stat-label">Total Events</div>
<div class="stat-value">{{ filteredEvents.length }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Shown</div>
<div class="stat-value">{{ paginatedEvents.length }}</div>
</div>
<div class="view-toggle">
<button
class="toggle-btn"
[class.active]="auditSubView() === 'management'"
(click)="auditSubView.set('management')">
Management Events
</button>
<button
class="toggle-btn"
[class.active]="auditSubView() === 'token-lifecycle'"
(click)="auditSubView.set('token-lifecycle')">
Token Lifecycle &amp; Security
</button>
</div>
@if (isLoading) {
<div class="skeleton-list">
@for (i of [1,2,3,4,5,6]; track i) {
<div class="skeleton-row">
<div class="skeleton-cell" style="flex:1.2"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1.5"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:0.5"></div>
@if (auditSubView() === 'management') {
<app-filter-bar
searchPlaceholder="Search by actor email or tenant ID..."
[filters]="filterBarOptions"
[activeFilters]="activeFilterBarList()"
(searchChange)="onFilterBarSearch($event)"
(filterChange)="onFilterBarChanged($event)"
(filterRemove)="onFilterBarRemoved($event)"
(filtersCleared)="clearFilters()"
></app-filter-bar>
@if (error) {
<div class="alert alert-error">{{ error }}</div>
}
<div class="audit-stats">
<div class="stat-card">
<div class="stat-label">Total Events</div>
<div class="stat-value">{{ filteredEvents.length }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Shown</div>
<div class="stat-value">{{ paginatedEvents.length }}</div>
</div>
</div>
@if (isLoading) {
<div class="skeleton-list">
@for (i of [1,2,3,4,5,6]; track i) {
<div class="skeleton-row">
<div class="skeleton-cell" style="flex:1.2"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1.5"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:1"></div>
<div class="skeleton-cell" style="flex:0.5"></div>
</div>
}
</div>
} @else if (filteredEvents.length === 0) {
<div class="empty-state">No audit events found</div>
} @else {
<table class="admin-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Event Type</th>
<th>Actor</th>
<th>Tenant ID</th>
<th>Resource Type</th>
<th>Resource ID</th>
<th>Details</th>
</tr>
</thead>
<tbody>
@for (event of paginatedEvents; track event.id ?? event.eventType + event.occurredAt) {
<tr>
<td class="timestamp">{{ formatTimestamp(event.timestamp ?? event.occurredAt) }}</td>
<td>
<span class="event-badge" [class]="getEventClass(event.eventType)">
{{ event.eventType }}
</span>
</td>
<td>{{ event.actor }}</td>
<td><app-inline-code [code]="event.tenantId"></app-inline-code></td>
<td>{{ event.resourceType }}</td>
<td><app-inline-code [code]="event.resourceId"></app-inline-code></td>
<td>
<button
class="btn-sm"
(click)="viewDetails(event)"
title="View full details">
Details
</button>
</td>
</tr>
}
</tbody>
</table>
@if (filteredEvents.length > pageSize) {
<div class="pagination">
<button
class="btn-secondary"
(click)="previousPage()"
[disabled]="currentPage === 0">
Previous
</button>
<span class="page-info">
Page {{ currentPage + 1 }} of {{ totalPages }}
</span>
<button
class="btn-secondary"
(click)="nextPage()"
[disabled]="currentPage >= totalPages - 1">
Next
</button>
</div>
}
</div>
} @else if (filteredEvents.length === 0) {
<div class="empty-state">No audit events found</div>
} @else {
<table class="admin-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Event Type</th>
<th>Actor</th>
<th>Tenant ID</th>
<th>Resource Type</th>
<th>Resource ID</th>
<th>Details</th>
</tr>
</thead>
<tbody>
@for (event of paginatedEvents; track event.id ?? event.eventType + event.occurredAt) {
<tr>
<td class="timestamp">{{ formatTimestamp(event.timestamp ?? event.occurredAt) }}</td>
<td>
<span class="event-badge" [class]="getEventClass(event.eventType)">
{{ event.eventType }}
</span>
</td>
<td>{{ event.actor }}</td>
<td><app-inline-code [code]="event.tenantId"></app-inline-code></td>
<td>{{ event.resourceType }}</td>
<td><app-inline-code [code]="event.resourceId"></app-inline-code></td>
<td>
<button
class="btn-sm"
(click)="viewDetails(event)"
title="View full details">
Details
</button>
</td>
</tr>
}
</tbody>
</table>
}
@if (filteredEvents.length > pageSize) {
<div class="pagination">
<button
class="btn-secondary"
(click)="previousPage()"
[disabled]="currentPage === 0">
Previous
</button>
<span class="page-info">
Page {{ currentPage + 1 }} of {{ totalPages }}
</span>
<button
class="btn-secondary"
(click)="nextPage()"
[disabled]="currentPage >= totalPages - 1">
Next
</button>
@if (selectedEvent) {
<div class="modal-overlay" (click)="closeDetails()">
<div class="modal-content" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Audit Event Details</h2>
<button class="btn-close" (click)="closeDetails()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="modal-body">
<div class="detail-row">
<span class="detail-label">Event ID:</span>
<app-inline-code [code]="selectedEvent.id"></app-inline-code>
</div>
<div class="detail-row">
<span class="detail-label">Timestamp:</span>
<span>{{ formatTimestamp(selectedEvent.timestamp ?? selectedEvent.occurredAt) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Event Type:</span>
<span class="event-badge" [class]="getEventClass(selectedEvent.eventType)">
{{ selectedEvent.eventType }}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Actor:</span>
<span>{{ selectedEvent.actor }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Tenant ID:</span>
<app-inline-code [code]="selectedEvent.tenantId"></app-inline-code>
</div>
<div class="detail-row">
<span class="detail-label">Resource Type:</span>
<span>{{ selectedEvent.resourceType }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Resource ID:</span>
<app-inline-code [code]="selectedEvent.resourceId"></app-inline-code>
</div>
<div class="detail-row">
<span class="detail-label">Metadata:</span>
<pre class="metadata-json">{{ formatMetadata(selectedEvent.metadata ?? {}) }}</pre>
</div>
</div>
</div>
</div>
}
}
@if (selectedEvent) {
<div class="modal-overlay" (click)="closeDetails()">
<div class="modal-content" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Audit Event Details</h2>
<button class="btn-close" (click)="closeDetails()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="modal-body">
<div class="detail-row">
<span class="detail-label">Event ID:</span>
<app-inline-code [code]="selectedEvent.id"></app-inline-code>
</div>
<div class="detail-row">
<span class="detail-label">Timestamp:</span>
<span>{{ formatTimestamp(selectedEvent.timestamp ?? selectedEvent.occurredAt) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Event Type:</span>
<span class="event-badge" [class]="getEventClass(selectedEvent.eventType)">
{{ selectedEvent.eventType }}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Actor:</span>
<span>{{ selectedEvent.actor }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Tenant ID:</span>
<app-inline-code [code]="selectedEvent.tenantId"></app-inline-code>
</div>
<div class="detail-row">
<span class="detail-label">Resource Type:</span>
<span>{{ selectedEvent.resourceType }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Resource ID:</span>
<app-inline-code [code]="selectedEvent.resourceId"></app-inline-code>
</div>
<div class="detail-row">
<span class="detail-label">Metadata:</span>
<pre class="metadata-json">{{ formatMetadata(selectedEvent.metadata ?? {}) }}</pre>
</div>
</div>
</div>
</div>
@if (auditSubView() === 'token-lifecycle') {
<app-audit-authority />
}
</div>
`,
@@ -197,11 +221,56 @@ import { I18nService } from '../../../core/i18n';
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* Filter bar styles handled by shared FilterBarComponent */
.view-toggle {
display: flex;
border: 1px solid var(--theme-border-primary);
border-radius: var(--radius-md);
overflow: hidden;
margin-bottom: 16px;
width: fit-content;
}
.toggle-btn {
padding: 0.45rem 1rem;
background: var(--theme-bg-secondary);
border: none;
font-size: 0.84rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
color: var(--theme-text-secondary);
transition: background 150ms ease, color 150ms ease;
}
.toggle-btn:hover {
background: var(--theme-bg-tertiary);
}
.toggle-btn.active {
background: var(--color-brand-primary, var(--theme-brand-primary));
color: white;
}
.toggle-btn:not(:last-child) {
border-right: 1px solid var(--theme-border-primary);
}
.btn-link {
font-size: 0.84rem;
color: var(--color-text-link, var(--theme-brand-primary));
text-decoration: none;
font-weight: var(--font-weight-medium);
}
.btn-link:hover {
text-decoration: underline;
}
.audit-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
@@ -501,6 +570,8 @@ export class AuditLogComponent implements OnInit {
private readonly cdr = inject(ChangeDetectorRef);
private readonly i18n = inject(I18nService);
readonly auditSubView = signal<'management' | 'token-lifecycle'>('management');
events: AuditEvent[] = [];
filteredEvents: AuditEvent[] = [];
paginatedEvents: AuditEvent[] = [];

View File

@@ -5,6 +5,7 @@ import { filter } from 'rxjs';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
type TabType = 'tenants' | 'users' | 'roles' | 'clients' | 'tokens' | 'audit' | 'branding';
@@ -27,18 +28,36 @@ const PAGE_TABS: readonly StellaPageTab[] = [
@Component({
selector: 'app-console-admin-layout',
standalone: true,
imports: [RouterOutlet, StellaPageTabsComponent, PageActionOutletComponent],
imports: [RouterOutlet, StellaPageTabsComponent, PageActionOutletComponent, StellaQuickLinksComponent],
template: `
<stella-page-tabs
[tabs]="pageTabs"
[activeTab]="activeTab()"
ariaLabel="Console admin tabs"
(tabChange)="onTabChange($event)"
>
<app-page-action-outlet tabBarAction />
<router-outlet />
</stella-page-tabs>
<div class="console-admin">
<header class="console-admin__header">
<div>
<h1 class="console-admin__title">Console Administration</h1>
<p class="console-admin__subtitle">Manage tenants, users, roles, and platform access.</p>
</div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
</aside>
</header>
<stella-page-tabs
[tabs]="pageTabs"
[activeTab]="activeTab()"
ariaLabel="Console admin tabs"
(tabChange)="onTabChange($event)"
>
<app-page-action-outlet tabBarAction />
<router-outlet />
</stella-page-tabs>
</div>
`,
styles: [`
.console-admin { max-width: 1400px; margin: 0 auto; padding: 1.5rem; }
.console-admin__header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; gap: 1.5rem; }
.console-admin__title { font-size: 1.5rem; font-weight: var(--font-weight-bold, 700); margin: 0 0 0.25rem; }
.console-admin__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; }
.page-aside { flex: 0 1 60%; min-width: 0; }
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConsoleAdminLayoutComponent implements OnInit {
@@ -48,6 +67,12 @@ export class ConsoleAdminLayoutComponent implements OnInit {
readonly pageTabs = PAGE_TABS;
readonly activeTab = signal<string>('tenants');
readonly quickLinks: readonly StellaQuickLink[] = [
{ label: 'Identity & Access', route: '/setup/identity-access', description: 'User and group access management' },
{ label: 'Certificates & Trust', route: '/setup/trust-signing', description: 'Signing keys and certificate management' },
{ label: 'Theme & Branding', route: '/setup/tenant-branding', description: 'Customize tenant appearance' },
{ label: 'Audit Events', route: '/evidence/audit-log', description: 'Cross-module audit trail' },
];
ngOnInit(): void {
this.setActiveTabFromUrl(this.router.url);

View File

@@ -62,6 +62,12 @@ export const consoleAdminRoutes: Routes = [
path: 'branding',
loadComponent: () => import('./branding/branding-editor.component').then(m => m.BrandingEditorComponent),
data: { requiredScopes: [StellaOpsScopes.AUTHORITY_BRANDING_READ] }
},
{
path: 'assistant',
title: 'Stella Assistant Editor',
loadComponent: () => import('./assistant-admin/assistant-admin.component').then(m => m.AssistantAdminComponent),
data: { requiredScopes: [StellaOpsScopes.UI_ADMIN] }
}
]
}

View File

@@ -19,8 +19,9 @@ import {
getProviderLabel,
} from './integration.models';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { AuditModuleEventsComponent } from '../../shared/components/audit-module-events/audit-module-events.component';
type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'events' | 'health';
type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'events' | 'health' | 'audit';
const HUB_DETAIL_TABS: StellaPageTab[] = [
{ id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' },
@@ -28,6 +29,7 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
{ id: 'scopes-rules', label: 'Scopes & Rules', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'events', label: 'Events', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
{ id: 'health', label: 'Health', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' },
{ id: 'audit', label: 'Config Audit', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
];
/**
@@ -36,7 +38,7 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
*/
@Component({
selector: 'app-integration-detail',
imports: [CommonModule, RouterModule, StellaPageTabsComponent],
imports: [CommonModule, RouterModule, StellaPageTabsComponent, AuditModuleEventsComponent],
template: `
@if (loading) {
<div class="loading">Loading integration details...</div>
@@ -212,6 +214,15 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
}
</div>
}
@case ('audit') {
<div class="tab-panel">
<h2>Config Audit</h2>
<app-audit-module-events module="integrations" />
<div class="audit-cross-link">
<a routerLink="/evidence/audit-log" [queryParams]="{tab: 'all-events'}">View all audit events &rarr;</a>
</div>
</div>
}
}
</section>
</div>
@@ -232,9 +243,9 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
}
.back-link {
color: var(--color-text-secondary);
color: var(--color-text-link, var(--color-brand-primary));
text-decoration: none;
font-size: 0.875rem;
font-size: 0.8125rem;
}
.detail-header {
@@ -242,14 +253,16 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
}
.detail-header h1 {
margin: 0.5rem 0;
font-size: 1.35rem;
font-weight: var(--font-weight-semibold, 600);
margin: 0;
display: inline;
margin-right: 1rem;
}
.detail-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1.5rem;
background: var(--color-surface-primary);
@@ -257,6 +270,11 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
margin-bottom: 2rem;
}
.summary-item {
min-width: 120px;
flex: 1;
}
.summary-item label {
display: block;
font-size: 0.75rem;
@@ -273,12 +291,22 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
.config-list {
display: grid;
grid-template-columns: 150px 1fr;
grid-template-columns: minmax(100px, auto) 1fr;
gap: 0.5rem 1rem;
}
.config-list dt {
font-weight: var(--font-weight-medium);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.config-list dd {
font-size: 0.8125rem;
color: var(--color-text-primary);
margin: 0;
}
.tags {
@@ -292,6 +320,11 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
background: var(--color-surface-primary);
border-radius: var(--radius-sm);
font-size: 0.875rem;
transition: background 150ms ease;
}
.tag:hover {
background: var(--color-surface-secondary, rgba(0, 0, 0, 0.06));
}
.health-actions {
@@ -301,9 +334,10 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
}
.result-card {
padding: 1rem;
padding: 1rem 1.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
margin-bottom: 1rem;
}
@@ -344,41 +378,42 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
.event-table td {
border-bottom: 1px solid var(--color-border-primary);
text-align: left;
padding: 0.6rem 0.4rem;
font-size: 0.82rem;
padding: 0.625rem 0.875rem;
font-size: 0.8125rem;
white-space: nowrap;
}
.event-table th {
font-size: 0.7rem;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.03em;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.btn-primary, .btn-secondary, .btn-danger {
padding: 0.75rem 1.5rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-weight: var(--font-weight-medium);
font-size: 0.8125rem;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
transition: all 150ms ease;
}
.btn-primary {
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
border: none;
background: var(--color-brand-primary);
color: white;
}
.btn-secondary {
background: transparent;
color: var(--color-text-link);
border: 1px solid var(--color-brand-primary);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
border-color: var(--color-border-primary);
}
.btn-danger {
background: var(--color-status-error);
color: var(--color-text-heading);
border: none;
background: var(--color-status-error, #B53525);
color: white;
}
.btn-primary:disabled, .btn-secondary:disabled {
@@ -391,6 +426,7 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
border-radius: var(--radius-sm);
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
}
.status-active, .health-healthy { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
@@ -418,6 +454,23 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [
}
.loading { text-align: center; padding: 3rem; color: var(--color-text-secondary); }
.audit-cross-link {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border-primary);
}
.audit-cross-link a {
color: var(--color-text-link, var(--color-brand-primary));
text-decoration: none;
font-size: 0.8125rem;
font-weight: 600;
}
.audit-cross-link a:hover {
text-decoration: underline;
}
.workflow-cta {
display: inline-block;
margin-top: 0.75rem;

View File

@@ -30,13 +30,8 @@ export const integrationHubRoutes: Routes = [
loadComponent: () =>
import('./integration-shell.component').then((m) => m.IntegrationShellComponent),
children: [
{
path: '',
title: 'Integrations',
data: { breadcrumb: 'Integrations' },
loadComponent: () =>
import('./integration-hub.component').then((m) => m.IntegrationHubComponent),
},
// Empty path: shell handles landing inline (onboarding panel or redirect to first tab)
// No component loaded — the shell template's @if(showOnboarding) / @else(router-outlet) handles this
{
path: 'onboarding',

View File

@@ -1,10 +1,10 @@
import { ChangeDetectorRef, Component, inject, NgZone, OnInit, signal } from '@angular/core';
import { ChangeDetectorRef, Component, computed, inject, NgZone, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { timeout } from 'rxjs';
import { IntegrationService } from './integration.service';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { DoctorStore } from '../doctor/services/doctor.store';
import { integrationWorkspaceCommands } from './integration-route-context';
import {
HealthStatus,
@@ -24,33 +24,75 @@ import {
*/
@Component({
selector: 'app-integration-list',
imports: [CommonModule, RouterModule, FormsModule, DoctorChecksInlineComponent],
imports: [CommonModule, RouterModule, FormsModule],
template: `
<div class="integration-list">
<header class="list-header">
<h1>{{ typeLabel }} Integrations</h1>
<button class="btn-primary" (click)="addIntegration()">{{ addActionLabel() }}</button>
<div class="list-header__left">
<h1>{{ typeLabel }} Integrations</h1>
</div>
<div class="list-header__right">
<a class="doctor-icon-btn"
[class.doctor-icon-btn--running]="doctorStore.isRunning()"
[class.doctor-icon-btn--warn]="doctorSummary()?.warn"
[class.doctor-icon-btn--fail]="doctorSummary()?.fail"
routerLink="/ops/operations/doctor"
[queryParams]="{ category: 'integration' }"
[title]="doctorTooltip()">
<svg class="doctor-icon-btn__icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
@if (doctorSummary(); as s) {
@if (s.total > 0) {
<span class="doctor-icon-btn__badge">{{ s.total }}</span>
}
}
</a>
<button class="btn-primary" (click)="addIntegration()">{{ addActionLabel() }}</button>
</div>
</header>
<section class="filters">
<select [(ngModel)]="filterStatus" (change)="loadIntegrations()" class="filter-select">
<option [ngValue]="undefined">All Statuses</option>
<option [ngValue]="IntegrationStatus.Pending">Pending</option>
<option [ngValue]="IntegrationStatus.Active">Active</option>
<option [ngValue]="IntegrationStatus.Failed">Failed</option>
<option [ngValue]="IntegrationStatus.Disabled">Disabled</option>
<option [ngValue]="IntegrationStatus.Archived">Archived</option>
</select>
<!-- Status toggle bar -->
<nav class="status-bar" role="group" aria-label="Filter by status">
@for (opt of statusOptions; track opt.value) {
<button type="button"
class="status-bar__item"
[class.status-bar__item--active]="filterStatus === opt.value"
(click)="setStatusFilter(opt.value)">
{{ opt.label }}
@if (opt.value !== undefined && statusCounts()[opt.value] !== undefined) {
<span class="status-bar__count">{{ statusCounts()[opt.value] }}</span>
}
</button>
}
</nav>
<!-- Full-width search -->
<div class="search-row">
<svg class="search-row__icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
</svg>
<input
type="text"
[(ngModel)]="searchQuery"
(input)="loadIntegrations()"
placeholder="Search integrations..."
class="search-input"
(input)="onSearchInput()"
placeholder="Search by name, provider, or tag..."
class="search-row__input"
aria-label="Search integrations"
/>
</section>
<st-doctor-checks-inline category="integration" heading="Integration Health Checks" />
@if (searchQuery) {
<button type="button" class="search-row__clear" (click)="searchQuery = ''; onSearchInput()" aria-label="Clear search">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
</button>
}
</div>
@if (actionFeedback()) {
<div class="action-feedback" [class.action-feedback--error]="actionFeedbackTone() === 'error'" role="status">
@@ -75,14 +117,23 @@ import {
<button class="btn-primary" (click)="addIntegration()">{{ emptyStateActionLabel() }}</button>
</div>
} @else {
<table class="integration-table">
<table class="stella-table stella-table--hoverable">
<thead>
<tr>
<th>Name</th>
<th class="sortable" (click)="toggleSort('name')" [class.sorted]="sortBy === 'name'">
Name
<span class="sort-arrow">{{ sortBy === 'name' ? (sortDesc ? '\u25BC' : '\u25B2') : '' }}</span>
</th>
<th>Provider</th>
<th>Status</th>
<th class="sortable" (click)="toggleSort('status')" [class.sorted]="sortBy === 'status'">
Status
<span class="sort-arrow">{{ sortBy === 'status' ? (sortDesc ? '\u25BC' : '\u25B2') : '' }}</span>
</th>
<th>Health</th>
<th>Last Checked</th>
<th class="sortable" (click)="toggleSort('lastHealthCheckAt')" [class.sorted]="sortBy === 'lastHealthCheckAt'">
Last Checked
<span class="sort-arrow">{{ sortBy === 'lastHealthCheckAt' ? (sortDesc ? '\u25BC' : '\u25B2') : '' }}</span>
</th>
<th>Actions</th>
</tr>
</thead>
@@ -114,20 +165,28 @@ import {
}
</tbody>
</table>
}
@if (totalCount > pageSize) {
<nav class="pagination">
<button [disabled]="page === 1" (click)="page = page - 1; loadIntegrations()">Previous</button>
<span>Page {{ page }} of {{ totalPages }}</span>
<button [disabled]="page >= totalPages" (click)="page = page + 1; loadIntegrations()">Next</button>
</nav>
<!-- Pagination -->
@if (totalPages > 1) {
<nav class="pager" aria-label="Pagination">
<span class="pager__info">{{ totalCount }} total &middot; page {{ page }} of {{ totalPages }}</span>
<div class="pager__controls">
<button class="pager__btn" [disabled]="page === 1" (click)="goPage(1)" title="First page">&laquo;</button>
<button class="pager__btn" [disabled]="page === 1" (click)="goPage(page - 1)" title="Previous page">&lsaquo;</button>
@for (p of visiblePages(); track p) {
<button class="pager__btn" [class.pager__btn--active]="p === page" (click)="goPage(p)">{{ p }}</button>
}
<button class="pager__btn" [disabled]="page >= totalPages" (click)="goPage(page + 1)" title="Next page">&rsaquo;</button>
<button class="pager__btn" [disabled]="page >= totalPages" (click)="goPage(totalPages)" title="Last page">&raquo;</button>
</div>
</nav>
}
}
</div>
`,
styles: [`
.integration-list {
padding: 2rem;
padding: 1.5rem 0 2rem;
max-width: 1200px;
margin: 0 auto;
}
@@ -136,192 +195,238 @@ import {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
margin-bottom: 1rem;
}
.list-header__left { display: flex; align-items: center; gap: 0.75rem; }
.list-header__right { display: flex; align-items: center; gap: 0.75rem; }
.list-header h1 { margin: 0; font-size: 1.25rem; }
.list-header h1 {
margin: 0;
font-size: 1.5rem;
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.filter-select, .search-input {
padding: 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
}
.search-input {
flex: 1;
max-width: 300px;
}
.integration-table {
width: 100%;
border-collapse: collapse;
}
.integration-table th,
.integration-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
}
.integration-table th {
background: var(--color-surface-secondary);
font-weight: var(--font-weight-semibold);
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-secondary);
letter-spacing: 0.03em;
}
.status-badge, .health-badge {
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
text-transform: uppercase;
}
.status-active, .health-healthy {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
}
.status-pending, .health-unknown {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.status-failed, .health-unhealthy {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.status-disabled, .status-archived, .health-degraded {
background: var(--color-border-primary);
color: var(--color-text-primary);
}
.actions {
display: flex;
gap: 0.5rem;
}
.actions button, .actions a {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
/* ── Doctor icon ── */
.doctor-icon-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
}
.actions button:hover, .actions a:hover {
color: var(--color-text-link);
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
}
.pagination button {
padding: 0.5rem 1rem;
width: 34px; height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 150ms ease;
text-decoration: none;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
.doctor-icon-btn:hover { background: var(--color-surface-secondary); color: var(--color-text-primary); border-color: var(--color-brand-primary); }
.doctor-icon-btn--running .doctor-icon-btn__icon { animation: doctor-spin 1.2s linear infinite; }
.doctor-icon-btn--warn { border-color: var(--color-status-warning); color: var(--color-status-warning); }
.doctor-icon-btn--fail { border-color: var(--color-status-error); color: var(--color-status-error); }
.doctor-icon-btn__icon { width: 18px; height: 18px; }
.doctor-icon-btn__badge {
position: absolute; top: -5px; right: -5px;
min-width: 16px; height: 16px;
border-radius: var(--radius-full, 50%);
background: var(--color-brand-primary); color: white;
font-size: 0.625rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
padding: 0 3px; line-height: 1;
}
.doctor-icon-btn--fail .doctor-icon-btn__badge { background: var(--color-status-error); }
.doctor-icon-btn--warn .doctor-icon-btn__badge { background: var(--color-status-warning); }
@keyframes doctor-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.loading, .empty-state {
text-align: center;
padding: 3rem;
/* ── Status toggle bar ── */
.status-bar {
display: flex;
gap: 0;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
overflow: hidden;
margin-bottom: 0.75rem;
}
.status-bar__item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.45rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
border: none;
border-right: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 120ms ease;
white-space: nowrap;
}
.status-bar__item:last-child { border-right: none; }
.status-bar__item:hover { background: var(--color-surface-secondary); color: var(--color-text-primary); }
.status-bar__item--active {
background: var(--color-brand-primary);
color: white;
}
.status-bar__count {
font-size: 0.625rem;
font-weight: 700;
background: rgba(255,255,255,0.2);
border-radius: var(--radius-full, 50%);
min-width: 16px; height: 16px;
display: inline-flex; align-items: center; justify-content: center;
padding: 0 4px; line-height: 1;
}
.status-bar__item--active .status-bar__count {
background: rgba(255,255,255,0.3);
}
.status-bar__item:not(.status-bar__item--active) .status-bar__count {
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
}
.action-feedback {
/* ── Search ── */
.search-row {
position: relative;
margin-bottom: 1rem;
}
.search-row__icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted, var(--color-text-secondary));
pointer-events: none;
}
.search-row__input {
width: 100%;
padding: 0.55rem 2.25rem 0.55rem 2.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.8125rem;
background: var(--color-surface-primary);
color: var(--color-text-primary);
transition: border-color 150ms ease;
box-sizing: border-box;
}
.search-row__input:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-brand-primary) 20%, transparent);
}
.search-row__input::placeholder { color: var(--color-text-muted, var(--color-text-secondary)); }
.search-row__clear {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: var(--color-text-secondary);
padding: 0.25rem;
display: flex;
border-radius: var(--radius-sm);
}
.search-row__clear:hover { color: var(--color-text-primary); background: var(--color-surface-secondary); }
/* ── Table ── */
:host ::ng-deep .stella-table th {
font-size: 0.6875rem;
letter-spacing: 0.05em;
background: color-mix(in srgb, var(--color-surface-tertiary) 60%, var(--color-border-primary) 15%);
padding: 0.625rem 0.875rem;
}
.sortable { cursor: pointer; user-select: none; }
.sortable:hover { color: var(--color-text-primary); }
.sorted { color: var(--color-brand-primary) !important; }
.sort-arrow { font-size: 0.6rem; margin-left: 0.2rem; }
.status-badge, .health-badge {
padding: 0.2rem 0.45rem;
border-radius: var(--radius-sm);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.status-active, .health-healthy { background: color-mix(in srgb, var(--color-status-success) 12%, transparent); color: var(--color-status-success); }
.status-pending, .health-unknown { background: color-mix(in srgb, var(--color-status-warning) 12%, transparent); color: var(--color-status-warning); }
.status-failed, .health-unhealthy { background: color-mix(in srgb, var(--color-status-error) 12%, transparent); color: var(--color-status-error); }
.status-disabled, .status-archived, .health-degraded { background: var(--color-surface-secondary); color: var(--color-text-secondary); }
.actions { display: flex; gap: 0.35rem; }
.actions button, .actions a {
background: none; border: none; cursor: pointer;
padding: 0.25rem; display: inline-flex;
align-items: center; justify-content: center;
color: var(--color-text-secondary);
opacity: 0.6;
transition: all 150ms ease;
border-radius: var(--radius-sm);
}
.actions button:hover, .actions a:hover { color: var(--color-brand-primary); opacity: 1; background: var(--color-surface-secondary); }
/* ── Pagination ── */
.pager {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-status-success-border);
border-radius: var(--radius-md);
background: rgba(74, 222, 128, 0.1);
color: var(--color-status-success-text);
margin-bottom: 1rem;
margin-top: 1rem;
padding: 0.5rem 0;
}
.action-feedback--error {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
color: var(--color-status-error);
}
.action-feedback__close {
border: none;
background: transparent;
cursor: pointer;
color: inherit;
text-decoration: underline;
font-size: 0.82rem;
}
.error-state {
display: grid;
gap: 0.75rem;
justify-items: center;
text-align: center;
padding: 3rem;
.pager__info {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.error-state p {
margin: 0;
max-width: 40rem;
}
.error-actions {
.pager__controls {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
gap: 2px;
}
.pager__btn {
min-width: 30px; height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.35rem;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
border-radius: var(--radius-sm);
font-size: 0.75rem;
cursor: pointer;
transition: all 120ms ease;
color: var(--color-text-primary);
}
.pager__btn:hover:not(:disabled) { background: var(--color-surface-secondary); border-color: var(--color-brand-primary); }
.pager__btn--active { background: var(--color-brand-primary); color: white; border-color: var(--color-brand-primary); }
.pager__btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Feedback + states ── */
.loading, .empty-state { text-align: center; padding: 3rem; color: var(--color-text-secondary); }
.action-feedback {
display: flex; justify-content: space-between; align-items: center; gap: 1rem;
padding: 0.6rem 1rem;
border: 1px solid color-mix(in srgb, var(--color-status-success) 35%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-status-success) 10%, transparent);
color: var(--color-status-success); margin-bottom: 0.75rem; font-size: 0.8125rem;
}
.action-feedback--error {
border-color: color-mix(in srgb, var(--color-status-error) 35%, transparent);
background: color-mix(in srgb, var(--color-status-error) 8%, transparent);
color: var(--color-status-error);
}
.action-feedback__close { border: none; background: transparent; cursor: pointer; color: inherit; text-decoration: underline; font-size: 0.78rem; }
.error-state { display: grid; gap: 0.75rem; justify-items: center; text-align: center; padding: 3rem; color: var(--color-text-secondary); }
.error-state p { margin: 0; max-width: 40rem; }
.error-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; justify-content: center; }
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
border: none;
border-radius: var(--radius-md);
font-weight: var(--font-weight-medium);
cursor: pointer;
padding: 0.5rem 1rem; background: var(--color-brand-primary); color: var(--color-btn-primary-text);
border: none; border-radius: var(--radius-md); font-weight: 600; cursor: pointer; font-size: 0.8125rem;
}
.btn-secondary {
padding: 0.75rem 1.5rem;
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-link);
font-weight: var(--font-weight-medium);
cursor: pointer;
padding: 0.5rem 1rem; border: 1px solid var(--color-brand-primary); border-radius: var(--radius-md);
background: var(--color-surface-secondary); color: var(--color-text-link); font-weight: 600; cursor: pointer; font-size: 0.8125rem;
}
`]
})
@@ -331,9 +436,20 @@ export class IntegrationListComponent implements OnInit {
private readonly integrationService = inject(IntegrationService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly zone = inject(NgZone);
readonly doctorStore = inject(DoctorStore);
readonly doctorSummary = computed(() => this.doctorStore.summaryByCategory('integration'));
protected readonly IntegrationStatus = IntegrationStatus;
readonly statusOptions: { value: IntegrationStatus | undefined; label: string }[] = [
{ value: undefined, label: 'All' },
{ value: IntegrationStatus.Active, label: 'Active' },
{ value: IntegrationStatus.Pending, label: 'Pending' },
{ value: IntegrationStatus.Failed, label: 'Failed' },
{ value: IntegrationStatus.Disabled, label: 'Disabled' },
{ value: IntegrationStatus.Archived, label: 'Archived' },
];
/** Maps raw route data type strings to human-readable display names. */
private static readonly TYPE_DISPLAY_NAMES: Record<string, string> = {
Registry: 'Registry',
@@ -359,11 +475,15 @@ export class IntegrationListComponent implements OnInit {
pageSize = 20;
totalCount = 0;
totalPages = 1;
sortBy = 'name';
sortDesc = false;
loadErrorMessage: string | null = null;
readonly actionFeedback = signal<string | null>(null);
readonly actionFeedbackTone = signal<'success' | 'error'>('success');
readonly statusCounts = signal<Record<number, number>>({});
private integrationType?: IntegrationType;
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
ngOnInit(): void {
const typeFromRoute = this.route.snapshot.data['type'];
@@ -373,6 +493,7 @@ export class IntegrationListComponent implements OnInit {
IntegrationListComponent.TYPE_DISPLAY_NAMES[typeFromRoute] ?? typeFromRoute;
}
this.loadIntegrations();
this.loadStatusCounts();
}
loadIntegrations(): void {
@@ -384,6 +505,8 @@ export class IntegrationListComponent implements OnInit {
search: this.searchQuery || undefined,
page: this.page,
pageSize: this.pageSize,
sortBy: this.sortBy,
sortDescending: this.sortDesc,
}).pipe(
timeout({ first: 12_000 }),
).subscribe({
@@ -411,6 +534,45 @@ export class IntegrationListComponent implements OnInit {
});
}
setStatusFilter(status: IntegrationStatus | undefined): void {
this.filterStatus = status;
this.page = 1;
this.loadIntegrations();
}
onSearchInput(): void {
if (this.searchDebounce) clearTimeout(this.searchDebounce);
this.searchDebounce = setTimeout(() => {
this.page = 1;
this.loadIntegrations();
}, 300);
}
toggleSort(column: string): void {
if (this.sortBy === column) {
this.sortDesc = !this.sortDesc;
} else {
this.sortBy = column;
this.sortDesc = false;
}
this.page = 1;
this.loadIntegrations();
}
goPage(p: number): void {
if (p < 1 || p > this.totalPages || p === this.page) return;
this.page = p;
this.loadIntegrations();
}
visiblePages(): number[] {
const pages: number[] = [];
const start = Math.max(1, this.page - 2);
const end = Math.min(this.totalPages, this.page + 2);
for (let i = start; i <= end; i++) pages.push(i);
return pages;
}
testConnection(integration: Integration): void {
this.integrationService.testConnection(integration.id).subscribe({
next: (result) => {
@@ -422,6 +584,7 @@ export class IntegrationListComponent implements OnInit {
this.actionFeedback.set(`Connection failed: ${result.message || 'Unknown error'}`);
}
this.loadIntegrations();
this.loadStatusCounts();
},
error: (err) => {
this.actionFeedbackTone.set('error');
@@ -445,26 +608,11 @@ export class IntegrationListComponent implements OnInit {
});
}
// Helper methods for displaying enums
getStatusLabel(status: IntegrationStatus): string {
return getIntegrationStatusLabel(status);
}
getStatusColor(status: IntegrationStatus): string {
return getIntegrationStatusColor(status);
}
getHealthLabel(status: HealthStatus): string {
return getHealthStatusLabel(status);
}
getHealthColor(status: HealthStatus): string {
return getHealthStatusColor(status);
}
getProviderName(provider: number): string {
return getProviderLabel(provider);
}
getStatusLabel(status: IntegrationStatus): string { return getIntegrationStatusLabel(status); }
getStatusColor(status: IntegrationStatus): string { return getIntegrationStatusColor(status); }
getHealthLabel(status: HealthStatus): string { return getHealthStatusLabel(status); }
getHealthColor(status: HealthStatus): string { return getHealthStatusColor(status); }
getProviderName(provider: number): string { return getProviderLabel(provider); }
integrationDetailRoute(integrationId: string): string[] {
return this.integrationCommands(integrationId);
@@ -487,14 +635,57 @@ export class IntegrationListComponent implements OnInit {
});
}
doctorTooltip(): string {
const s = this.doctorSummary();
if (!s || s.total === 0) return 'Open integration diagnostics';
if (s.fail > 0) return `Diagnostics: ${s.fail} failed, ${s.warn} warnings, ${s.pass} passed`;
if (s.warn > 0) return `Diagnostics: ${s.warn} warnings, ${s.pass} passed`;
return `Diagnostics: ${s.total} checks passed`;
}
addIntegration(): void {
const commands = this.supportsTypedOnboarding()
? this.integrationCommands('onboarding', this.getOnboardingTypeSegment(this.integrationType))
: this.integrationCommands('onboarding');
void this.router.navigate(commands, {
queryParamsHandling: 'merge',
});
void this.router.navigate(commands, { queryParamsHandling: 'merge' });
}
private loadStatusCounts(): void {
// Load counts per status for the toggle bar badges
const statuses = [
IntegrationStatus.Active,
IntegrationStatus.Pending,
IntegrationStatus.Failed,
IntegrationStatus.Disabled,
IntegrationStatus.Archived,
];
const counts: Record<number, number> = {};
let completed = 0;
for (const status of statuses) {
this.integrationService.list({
type: this.integrationType,
status,
page: 1,
pageSize: 1,
}).subscribe({
next: (r) => {
counts[status] = r.totalCount;
completed++;
if (completed === statuses.length) {
this.statusCounts.set({ ...counts });
}
},
error: () => {
counts[status] = 0;
completed++;
if (completed === statuses.length) {
this.statusCounts.set({ ...counts });
}
},
});
}
}
private parseType(typeStr: string): IntegrationType | undefined {
@@ -513,19 +704,13 @@ export class IntegrationListComponent implements OnInit {
private getOnboardingTypeSegment(type?: IntegrationType): string {
switch (type) {
case IntegrationType.Scm:
return 'scm';
case IntegrationType.CiCd:
return 'ci';
case IntegrationType.RuntimeHost:
return 'host';
case IntegrationType.FeedMirror:
return 'feed';
case IntegrationType.RepoSource:
return 'secrets';
case IntegrationType.Scm: return 'scm';
case IntegrationType.CiCd: return 'ci';
case IntegrationType.RuntimeHost: return 'host';
case IntegrationType.FeedMirror: return 'feed';
case IntegrationType.RepoSource: return 'secrets';
case IntegrationType.Registry:
default:
return 'registry';
default: return 'registry';
}
}

View File

@@ -1,47 +1,109 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { filter } from 'rxjs';
import { ActivatedRoute, NavigationEnd, Router, RouterModule, RouterOutlet } from '@angular/router';
import { filter, take, forkJoin } from 'rxjs';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
import { IntegrationService } from './integration.service';
type TabType = 'hub' | 'registries' | 'scm' | 'ci' | 'runtime-hosts' | 'advisory-vex-sources' | 'secrets' | 'activity';
type TabType = 'registries' | 'scm' | 'ci' | 'runtime-hosts' | 'advisory-vex-sources' | 'secrets';
const KNOWN_TAB_IDS: readonly string[] = [
'hub', 'registries', 'scm', 'ci', 'runtime-hosts', 'advisory-vex-sources', 'secrets', 'activity',
'registries', 'scm', 'ci', 'runtime-hosts', 'advisory-vex-sources', 'secrets',
];
const PAGE_TABS: readonly StellaPageTab[] = [
{ id: 'hub', label: 'Hub', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M2 12h20|||M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' },
{ id: 'registries', label: 'Registries', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' },
{ id: 'scm', label: 'SCM', icon: 'M6 3v12|||M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 9a9 9 0 0 1-9 9' },
{ id: 'ci', label: 'CI/CD', icon: 'M5 3l14 9-14 9V3z' },
{ id: 'runtime-hosts', label: 'Runtimes / Hosts', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' },
{ id: 'advisory-vex-sources', label: 'Advisory & VEX', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'secrets', label: 'Secrets', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' },
{ id: 'activity', label: 'Activity', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
];
/** Priority order for auto-selecting the first populated tab */
const TAB_PRIORITY: readonly TabType[] = ['registries', 'scm', 'ci', 'advisory-vex-sources', 'runtime-hosts', 'secrets'];
@Component({
selector: 'app-integration-shell',
standalone: true,
imports: [RouterOutlet, StellaPageTabsComponent],
imports: [RouterOutlet, RouterModule, StellaPageTabsComponent, StellaQuickLinksComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="integration-shell">
<header class="integration-shell__header">
<h1>Integrations</h1>
<p>External system connectors for release, security, and evidence flows.</p>
<div>
<h1>Integrations</h1>
<p>External system connectors for release, security, and evidence flows.</p>
</div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
</aside>
</header>
<stella-page-tabs
[tabs]="pageTabs"
[activeTab]="activeTab()"
urlParam="tab"
ariaLabel="Integration tabs"
(tabChange)="onTabChange($event)"
>
<router-outlet />
@if (showOnboarding()) {
<section class="onboarding-panel">
<div class="onboarding-panel__header">
<h2>Get Started</h2>
<p>Connect StellaOps to the providers installed in this environment.</p>
</div>
<div class="onboarding-panel__categories">
<div class="category-card">
<div class="category-card__header">
<div>
<h3>Container Registries</h3>
<p>Connect container registries for image discovery, probing, and policy handoff.</p>
</div>
<button class="btn btn-primary" type="button" (click)="navigateOnboarding('registry')">+ Add Registry</button>
</div>
</div>
<div class="category-card">
<div class="category-card__header">
<div>
<h3>Source Control</h3>
<p>Connect repository hosts for commit metadata, drift context, and release evidence.</p>
</div>
<button class="btn btn-primary" type="button" (click)="navigateOnboarding('scm')">+ Add SCM</button>
</div>
</div>
<div class="category-card">
<div class="category-card__header">
<div>
<h3>CI/CD Pipelines</h3>
<p>Connect CI/CD systems for deployment gate signals and pipeline health monitoring.</p>
</div>
<button class="btn btn-primary" type="button" (click)="navigateOnboarding('ci')">+ Add CI/CD</button>
</div>
</div>
<div class="category-card">
<div class="category-card__header">
<div>
<h3>Advisory & VEX Sources</h3>
<p>Browse, enable, and health-check upstream advisory and VEX data sources.</p>
</div>
<button class="btn btn-primary" type="button" (click)="onTabChange('advisory-vex-sources')">Configure Sources</button>
</div>
</div>
</div>
<div class="onboarding-panel__hint">
<strong>Suggested order:</strong> Registries first (unblock releases), then SCM (wire metadata), CI/CD (capture pipelines), Advisory (security posture), Secrets (vaults).
</div>
</section>
} @else {
<router-outlet />
}
</stella-page-tabs>
</section>
`,
@@ -52,9 +114,15 @@ const PAGE_TABS: readonly StellaPageTab[] = [
}
.integration-shell__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
padding: 0 0 0.5rem;
}
.page-aside { flex: 0 1 60%; min-width: 0; }
.integration-shell__header h1 {
margin: 0;
font-size: 1.35rem;
@@ -65,15 +133,79 @@ const PAGE_TABS: readonly StellaPageTab[] = [
color: var(--color-text-secondary);
font-size: 0.8rem;
}
/* ── Onboarding panel ── */
.onboarding-panel {
padding: 1.5rem 0;
display: grid;
gap: 1.5rem;
}
.onboarding-panel__header h2 {
margin: 0;
font-size: 1.2rem;
}
.onboarding-panel__header p {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.onboarding-panel__categories {
display: grid;
gap: 1rem;
}
.category-card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1rem 1.2rem;
background: var(--color-surface-primary);
}
.category-card__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.category-card h3 {
margin: 0;
font-size: 1rem;
}
.category-card p {
margin: 0.2rem 0 0;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.onboarding-panel__hint {
font-size: 0.8rem;
color: var(--color-text-secondary);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
padding: 0.6rem 1rem;
}
`],
})
export class IntegrationShellComponent implements OnInit {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly integrationService = inject(IntegrationService);
readonly pageTabs = PAGE_TABS;
readonly activeTab = signal<string>('hub');
readonly activeTab = signal<string>('registries');
readonly showOnboarding = signal(false);
readonly quickLinks: readonly StellaQuickLink[] = [
{ label: 'Scanner Ops', route: '/ops/scanner-ops', description: 'Offline kits and scan baselines' },
{ label: 'Advisory Sources', route: '/security/advisory-sources', description: 'NVD, OSV, and GHSA feeds' },
{ label: 'SBOM Sources', route: '/security/supply-chain-data', description: 'Supply-chain data and SBOM health' },
{ label: 'Audit Events', route: '/evidence/audit-log', description: 'Cross-module audit trail' },
];
ngOnInit(): void {
this.setActiveTabFromUrl(this.router.url);
@@ -81,13 +213,19 @@ export class IntegrationShellComponent implements OnInit {
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
).subscribe(e => this.setActiveTabFromUrl(e.urlAfterRedirects));
// Check if any integrations exist to decide landing behavior
this.loadCounts();
}
onTabChange(tabId: string): void {
this.activeTab.set(tabId as TabType);
// 'hub' is the root/default tab — navigate to shell root
const route = tabId === 'hub' ? './' : tabId;
this.router.navigate([route], { relativeTo: this.route, queryParamsHandling: 'merge' });
this.showOnboarding.set(false); // Hide onboarding when navigating to a tab
this.router.navigate([tabId], { relativeTo: this.route, queryParamsHandling: 'merge' });
}
navigateOnboarding(type: string): void {
this.router.navigate(['onboarding', type], { relativeTo: this.route });
}
private setActiveTabFromUrl(url: string): void {
@@ -95,9 +233,41 @@ export class IntegrationShellComponent implements OnInit {
const lastSegment = segments.at(-1) ?? '';
if (KNOWN_TAB_IDS.includes(lastSegment as TabType)) {
this.activeTab.set(lastSegment as TabType);
} else {
// Default to 'hub' when at the integration root
this.activeTab.set('hub');
this.showOnboarding.set(false);
}
// If at integrations root and onboarding is showing, keep it
// Otherwise the loadCounts logic will handle redirect
}
private loadCounts(): void {
// Quick count check using pageSize=1 for each type
forkJoin({
reg: this.integrationService.list({ type: 1, page: 1, pageSize: 1 }),
scm: this.integrationService.list({ type: 2, page: 1, pageSize: 1 }),
ci: this.integrationService.list({ type: 3, page: 1, pageSize: 1 }),
}).pipe(take(1)).subscribe({
next: (counts) => {
const total = counts.reg.totalCount + counts.scm.totalCount + counts.ci.totalCount;
if (total === 0) {
// No integrations — check if we're at the root path
const url = this.router.url.split('?')[0];
if (url.endsWith('/integrations') || url.endsWith('/integrations/')) {
this.showOnboarding.set(true);
}
} else {
// Has integrations — if at root, redirect to first populated tab
const url = this.router.url.split('?')[0];
if (url.endsWith('/integrations') || url.endsWith('/integrations/')) {
const firstTab = counts.reg.totalCount > 0 ? 'registries'
: counts.scm.totalCount > 0 ? 'scm'
: counts.ci.totalCount > 0 ? 'ci'
: 'registries';
this.activeTab.set(firstTab);
this.router.navigate([firstTab], { relativeTo: this.route, replaceUrl: true, queryParamsHandling: 'merge' });
}
}
},
});
}
}

View File

@@ -4,8 +4,9 @@ import { RouterLink } from '@angular/router';
import { OPERATIONS_PATHS, dataIntegrityPath, deadLetterQueuePath } from './operations-paths';
import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { AuditModuleEventsComponent } from '../../../shared/components/audit-module-events/audit-module-events.component';
type JobsQueuesTab = 'jobs' | 'runs' | 'schedules' | 'dead-letters' | 'workers';
type JobsQueuesTab = 'jobs' | 'runs' | 'schedules' | 'dead-letters' | 'workers' | 'audit';
const JOBS_QUEUES_TABS: StellaPageTab[] = [
{ id: 'jobs', label: 'Jobs', icon: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|||M8 2h8v4H8z' },
@@ -13,6 +14,7 @@ const JOBS_QUEUES_TABS: StellaPageTab[] = [
{ id: 'schedules', label: 'Schedules', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' },
{ id: 'dead-letters', label: 'Dead Letters', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' },
{ id: 'workers', label: 'Workers', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' },
{ id: 'audit', label: 'Audit', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M16 13H8 M16 17H8 M10 9H8' },
];
type JobImpact = 'BLOCKING' | 'DEGRADED' | 'INFO';
type Cadence = 'Hourly' | 'Daily';
@@ -71,7 +73,7 @@ interface WorkerRow {
@Component({
selector: 'app-platform-jobs-queues-page',
standalone: true,
imports: [FormsModule, RouterLink, StellaPageTabsComponent],
imports: [FormsModule, RouterLink, StellaPageTabsComponent, AuditModuleEventsComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="jobs-queues">
@@ -336,6 +338,15 @@ interface WorkerRow {
</section>
}
@if (tab() === 'audit') {
<section class="audit-section">
<div class="audit-cross-link">
<a routerLink="/evidence/audit-log" [queryParams]="{tab: 'all-events'}">View all audit events →</a>
</div>
<app-audit-module-events [modules]="['jobengine', 'scheduler']" />
</section>
}
<section class="drawer">
<h2>Context</h2>
@if (tab() === 'jobs') {
@@ -609,6 +620,10 @@ interface WorkerRow {
text-decoration: none;
font-size: 0.73rem;
}
.audit-cross-link { text-align: right; margin-bottom: 0.5rem; font-size: 0.875rem; }
.audit-cross-link a { color: var(--color-text-link); text-decoration: none; }
.audit-cross-link a:hover { text-decoration: underline; }
`],
})
export class PlatformJobsQueuesPageComponent {
@@ -721,6 +736,8 @@ export class PlatformJobsQueuesPageComponent {
return 'Job, error, or correlation id';
case 'workers':
return 'Worker, queue, or capacity';
case 'audit':
return 'Search audit events';
}
});
@@ -736,6 +753,8 @@ export class PlatformJobsQueuesPageComponent {
return 'Retryable';
case 'workers':
return 'State';
case 'audit':
return 'Severity';
}
});
@@ -751,6 +770,8 @@ export class PlatformJobsQueuesPageComponent {
return 'Impact';
case 'workers':
return 'Queue';
case 'audit':
return 'Module';
}
});
@@ -766,6 +787,8 @@ export class PlatformJobsQueuesPageComponent {
return ['YES', 'NO'];
case 'workers':
return ['HEALTHY', 'DEGRADED'];
case 'audit':
return [];
}
});
@@ -781,6 +804,8 @@ export class PlatformJobsQueuesPageComponent {
return ['BLOCKING', 'DEGRADED'];
case 'workers':
return ['security', 'feeds', 'supply'];
case 'audit':
return [];
}
});

View File

@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { finalize } from 'rxjs/operators';
import {
@@ -13,6 +14,7 @@ import {
AuditEventType,
GovernanceAuditDiff,
} from '../../core/api/policy-governance.models';
import { AuditPolicyComponent } from '../../features/audit-log/audit-policy.component';
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
import { StellaFilterChipComponent } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
@@ -25,10 +27,27 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
*/
@Component({
selector: 'app-governance-audit',
imports: [CommonModule, FormsModule, LoadingStateComponent, StellaFilterChipComponent],
imports: [CommonModule, FormsModule, RouterModule, LoadingStateComponent, StellaFilterChipComponent, AuditPolicyComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="audit" [attr.aria-busy]="loading()">
<!-- Sub-view toggle -->
<div class="audit-view-toggle">
<button
class="audit-view-chip"
[class.audit-view-chip--active]="auditView() === 'governance'"
(click)="auditView.set('governance')"
>Governance Changes</button>
<button
class="audit-view-chip"
[class.audit-view-chip--active]="auditView() === 'promotions'"
(click)="auditView.set('promotions')"
>Promotions &amp; Approvals</button>
<a class="audit-cross-link" routerLink="/evidence/audit-log" [queryParams]="{tab: 'all-events', module: 'policy'}">View all audit events &rarr;</a>
</div>
@if (auditView() === 'governance') {
<!-- Filters -->
<div class="filters">
<stella-filter-chip
@@ -220,11 +239,63 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope';
<p>No audit events found matching your filters.</p>
</div>
}
}
@if (auditView() === 'promotions') {
<app-audit-policy />
}
</div>
`,
styles: [`
:host { display: block; }
/* Sub-view toggle chips */
.audit-view-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.audit-view-chip {
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-muted);
transition: all 0.15s ease;
}
.audit-view-chip:hover {
background: var(--color-surface-tertiary);
color: var(--color-text-primary);
}
.audit-view-chip--active {
background: var(--color-brand-primary);
color: #fff;
border-color: var(--color-brand-primary);
}
.audit-view-chip--active:hover {
background: var(--color-brand-primary);
color: #fff;
}
.audit-cross-link {
margin-left: auto;
font-size: 0.85rem;
color: var(--color-text-link);
text-decoration: none;
}
.audit-cross-link:hover {
text-decoration: underline;
}
.audit {
padding: 1.5rem;
}
@@ -550,6 +621,7 @@ export class GovernanceAuditComponent implements OnInit {
private readonly api = inject(POLICY_GOVERNANCE_API);
private readonly governanceScope = injectPolicyGovernanceScopeResolver();
readonly auditView = signal<'governance' | 'promotions'>('governance');
protected readonly loading = signal(false);
protected readonly events = signal<GovernanceAuditEvent[]>([]);
protected readonly response = signal<AuditResponse | null>(null);

View File

@@ -0,0 +1,105 @@
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { TrustWeightingComponent } from './trust-weighting.component';
import { StalenessConfigComponent } from './staleness-config.component';
import { SealedModeControlComponent } from './sealed-mode-control.component';
type ConfigSection = 'trust-weights' | 'staleness' | 'sealed-mode';
/**
* Governance Configuration panel.
* Merges Trust Weights, Staleness, and Sealed Mode into one tabbed view.
*/
@Component({
selector: 'app-governance-config-panel',
standalone: true,
imports: [TrustWeightingComponent, StalenessConfigComponent, SealedModeControlComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="config-panel">
<nav class="config-panel__toggles" role="tablist" aria-label="Configuration sections">
@for (section of sections; track section.id) {
<button
class="config-panel__toggle"
[class.config-panel__toggle--active]="activeSection() === section.id"
role="tab"
[attr.aria-selected]="activeSection() === section.id"
[attr.aria-controls]="'config-section-' + section.id"
(click)="activeSection.set(section.id)"
>
<svg class="config-panel__toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@for (p of section.icon.split('|||'); track $index) {
<path [attr.d]="p" />
}
</svg>
{{ section.label }}
</button>
}
</nav>
<div class="config-panel__content" role="tabpanel" [attr.id]="'config-section-' + activeSection()">
@switch (activeSection()) {
@case ('trust-weights') { <app-trust-weighting /> }
@case ('staleness') { <app-staleness-config /> }
@case ('sealed-mode') { <app-sealed-mode-control /> }
}
</div>
</div>
`,
styles: [`
:host { display: block; }
.config-panel__toggles {
display: flex;
gap: 0.5rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border-subtle, rgba(255,255,255,0.08));
padding-bottom: 0.75rem;
}
.config-panel__toggle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: 1px solid var(--color-border-subtle, rgba(255,255,255,0.12));
border-radius: 6px;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.config-panel__toggle:hover {
background: var(--color-surface-hover, rgba(255,255,255,0.04));
color: var(--color-text-primary);
}
.config-panel__toggle--active {
background: var(--color-accent-subtle, rgba(99,102,241,0.12));
border-color: var(--color-accent, #6366f1);
color: var(--color-accent, #6366f1);
}
.config-panel__toggle-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.config-panel__content {
min-height: 200px;
}
`]
})
export class GovernanceConfigPanelComponent {
protected readonly activeSection = signal<ConfigSection>('trust-weights');
protected readonly sections: { id: ConfigSection; label: string; icon: string }[] = [
{ id: 'trust-weights', label: 'Trust Weights', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'staleness', label: 'Staleness', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' },
{ id: 'sealed-mode', label: 'Sealed Mode', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
];
}

View File

@@ -0,0 +1,105 @@
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { PolicyValidatorComponent } from './policy-validator.component';
import { SchemaPlaygroundComponent } from './schema-playground.component';
import { SchemaDocsComponent } from './schema-docs.component';
type ToolSection = 'validator' | 'schema-playground' | 'schema-docs';
/**
* Governance Developer Tools panel.
* Merges Validator, Playground, and Docs into one tabbed view.
*/
@Component({
selector: 'app-governance-tools-panel',
standalone: true,
imports: [PolicyValidatorComponent, SchemaPlaygroundComponent, SchemaDocsComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tools-panel">
<nav class="tools-panel__toggles" role="tablist" aria-label="Developer tool sections">
@for (section of sections; track section.id) {
<button
class="tools-panel__toggle"
[class.tools-panel__toggle--active]="activeSection() === section.id"
role="tab"
[attr.aria-selected]="activeSection() === section.id"
[attr.aria-controls]="'tools-section-' + section.id"
(click)="activeSection.set(section.id)"
>
<svg class="tools-panel__toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@for (p of section.icon.split('|||'); track $index) {
<path [attr.d]="p" />
}
</svg>
{{ section.label }}
</button>
}
</nav>
<div class="tools-panel__content" role="tabpanel" [attr.id]="'tools-section-' + activeSection()">
@switch (activeSection()) {
@case ('validator') { <app-policy-validator /> }
@case ('schema-playground') { <app-schema-playground /> }
@case ('schema-docs') { <app-schema-docs /> }
}
</div>
</div>
`,
styles: [`
:host { display: block; }
.tools-panel__toggles {
display: flex;
gap: 0.5rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border-subtle, rgba(255,255,255,0.08));
padding-bottom: 0.75rem;
}
.tools-panel__toggle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: 1px solid var(--color-border-subtle, rgba(255,255,255,0.12));
border-radius: 6px;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.tools-panel__toggle:hover {
background: var(--color-surface-hover, rgba(255,255,255,0.04));
color: var(--color-text-primary);
}
.tools-panel__toggle--active {
background: var(--color-accent-subtle, rgba(99,102,241,0.12));
border-color: var(--color-accent, #6366f1);
color: var(--color-accent, #6366f1);
}
.tools-panel__toggle-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.tools-panel__content {
min-height: 200px;
}
`]
})
export class GovernanceToolsPanelComponent {
protected readonly activeSection = signal<ToolSection>('validator');
protected readonly sections: { id: ToolSection; label: string; icon: string }[] = [
{ id: 'validator', label: 'Validator', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
{ id: 'schema-playground', label: 'Playground', icon: 'M16 18l6-6-6-6|||M8 6l-6 6 6 6' },
{ id: 'schema-docs', label: 'Docs', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
];
}

View File

@@ -537,8 +537,8 @@ export class ImpactPreviewComponent implements OnInit {
// In real implementation, apply the trust weight changes
setTimeout(() => {
this.applying.set(false);
// Navigate back to trust weights
window.location.href = '/ops/policy/governance/trust-weights';
// Navigate back to configuration (trust weights section)
window.location.href = '/ops/policy/governance/config';
}, 1500);
}
}

View File

@@ -27,15 +27,15 @@ describe('PolicyGovernanceComponent', () => {
expect(compiled.querySelector('.governance__title')?.textContent).toContain('Policy Governance');
});
it('should render eyebrow text', () => {
it('should render 6 rationalized tabs', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.governance__eyebrow')?.textContent).toContain('Admin / Policy');
});
it('should render all navigation tabs', () => {
const compiled = fixture.nativeElement as HTMLElement;
const tabs = compiled.querySelectorAll('.governance__tab');
expect(tabs.length).toBe(10);
const tabLabels = Array.from(compiled.querySelectorAll('[role="tab"]')).map(
(el) => el.textContent?.trim()
);
expect(tabLabels).toEqual(
jasmine.arrayContaining(['Risk Budget', 'Profiles', 'Configuration', 'Conflicts', 'Developer Tools', 'Audit'])
);
expect(tabLabels.length).toBe(6);
});
it('should include Risk Budget tab', () => {
@@ -43,50 +43,32 @@ describe('PolicyGovernanceComponent', () => {
expect(compiled.textContent).toContain('Risk Budget');
});
it('should include Trust Weights tab', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Trust Weights');
});
it('should include Staleness tab', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Staleness');
});
it('should include Sealed Mode tab', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Sealed Mode');
});
it('should include Profiles tab', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Profiles');
});
it('should include Validator tab', () => {
it('should include Configuration tab (merged from Trust Weights, Staleness, Sealed Mode)', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Validator');
expect(compiled.textContent).toContain('Configuration');
});
it('should include Audit Log tab', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Audit Log');
});
it('should include Conflicts tab with badge', () => {
it('should include Conflicts tab', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Conflicts');
expect(compiled.querySelector('.governance__tab-badge')).toBeTruthy();
});
it('should include Playground tab', () => {
it('should include Developer Tools tab (merged from Validator, Playground, Docs)', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Playground');
expect(compiled.textContent).toContain('Developer Tools');
});
it('should include Docs tab', () => {
it('should include Audit tab as embedded child', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Docs');
const tabLabels = Array.from(compiled.querySelectorAll('[role="tab"]')).map(
(el) => el.textContent?.trim()
);
expect(tabLabels).toContain('Audit');
});
it('should have router outlet for child routes', () => {

View File

@@ -4,36 +4,38 @@ import { Router, RouterOutlet, NavigationEnd, ActivatedRoute } from '@angular/ro
import { filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
/**
* Policy Governance main component with tabbed navigation.
* Provides access to Risk Budget, Trust Weights, Staleness, Sealed Mode, and Profiles.
* Rationalized from 10 tabs to 6: Risk Budget, Profiles, Configuration, Conflicts, Developer Tools, Audit.
*
* @sprint SPRINT_20251229_021a_FE
*/
const GOVERNANCE_TABS: readonly StellaPageTab[] = [
{ id: 'budget', label: 'Risk Budget', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' },
{ id: 'trust', label: 'Trust Weights', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'staleness', label: 'Staleness', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' },
{ id: 'sealed', label: 'Sealed Mode', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'profiles', label: 'Profiles', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' },
{ id: 'validator', label: 'Validator', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
{ id: 'audit', label: 'Audit Log', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
{ id: 'config', label: 'Configuration', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' },
{ id: 'conflicts', label: 'Conflicts', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01', badge: 2, status: 'warn', statusHint: '2 conflicts detected' },
{ id: 'schema-playground', label: 'Playground', icon: 'M16 18l6-6-6-6|||M8 6l-6 6 6 6' },
{ id: 'schema-docs', label: 'Docs', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
{ id: 'tools', label: 'Developer Tools', icon: 'M16 18l6-6-6-6|||M8 6l-6 6 6 6' },
{ id: 'audit', label: 'Audit', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
];
@Component({
selector: 'app-policy-governance',
standalone: true,
imports: [RouterOutlet, StellaPageTabsComponent],
imports: [RouterOutlet, StellaPageTabsComponent, StellaQuickLinksComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="governance">
<div class="governance__header">
<h1 class="governance__title">Policy Governance</h1>
<p class="governance__subtitle">{{ activeSubtitle() }}</p>
<div>
<h1 class="governance__title">Policy Governance</h1>
<p class="governance__subtitle">{{ activeSubtitle() }}</p>
</div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
</aside>
</div>
<stella-page-tabs
@@ -80,20 +82,21 @@ const GOVERNANCE_TABS: readonly StellaPageTab[] = [
margin: 0;
}
.page-aside {
flex: 0 1 60%;
min-width: 0;
}
`]
})
export class PolicyGovernanceComponent implements OnInit {
private static readonly TAB_ROUTES: Record<string, string> = {
budget: '/ops/policy/governance',
trust: '/ops/policy/governance/trust-weights',
staleness: '/ops/policy/governance/staleness',
sealed: '/ops/policy/governance/sealed-mode',
profiles: '/ops/policy/governance/profiles',
validator: '/ops/policy/governance/validator',
audit: '/ops/policy/governance/audit',
config: '/ops/policy/governance/config',
conflicts: '/ops/policy/governance/conflicts',
'schema-playground': '/ops/policy/governance/schema-playground',
'schema-docs': '/ops/policy/governance/schema-docs',
tools: '/ops/policy/governance/tools',
audit: '/ops/policy/governance/audit',
};
private static readonly ROUTE_TO_TAB: Record<string, string> = {
@@ -101,15 +104,17 @@ export class PolicyGovernanceComponent implements OnInit {
'overview': 'budget',
'risk-budget': 'budget',
'budget': 'budget',
'trust-weights': 'trust',
'staleness': 'staleness',
'sealed-mode': 'sealed',
'profiles': 'profiles',
'validator': 'validator',
'audit': 'audit',
'config': 'config',
'trust-weights': 'config',
'staleness': 'config',
'sealed-mode': 'config',
'conflicts': 'conflicts',
'schema-playground': 'schema-playground',
'schema-docs': 'schema-docs',
'tools': 'tools',
'validator': 'tools',
'schema-playground': 'tools',
'schema-docs': 'tools',
'audit': 'audit',
};
private readonly router = inject(Router);
@@ -119,19 +124,23 @@ export class PolicyGovernanceComponent implements OnInit {
protected readonly activeTab = signal<string>('budget');
protected readonly GOVERNANCE_TABS = GOVERNANCE_TABS;
readonly quickLinks: readonly StellaQuickLink[] = [
{ label: 'Policy Packs', route: '/ops/policy/packs', description: 'Author and manage policy pack rules' },
{ label: 'Simulation', route: '/ops/policy/simulation', description: 'Shadow mode and what-if analysis' },
{ label: 'VEX & Exceptions', route: '/ops/policy/vex', description: 'Vulnerability exceptions and waivers' },
{ label: 'Impact Preview', route: '/ops/policy/impact-preview', description: 'Preview policy change effects' },
{ label: 'Audit Events', route: '/evidence/audit-log', description: 'Cross-module audit trail' },
];
protected readonly activeSubtitle = computed(() => {
switch (this.activeTab()) {
case 'budget': return 'Monitor budget consumption and manage risk thresholds.';
case 'trust': return 'Configure trust weights for vulnerability sources and issuers.';
case 'staleness': return 'Configure data freshness thresholds and enforcement rules.';
case 'sealed': return 'Manage air-gapped operation mode and trusted source overrides.';
case 'profiles': return 'Manage risk evaluation profiles and signal weights.';
case 'validator': return 'Validate policy documents against the schema.';
case 'audit': return 'Track all governance configuration changes.';
case 'conflicts': return 'Identify and resolve rule overlaps and precedence issues.';
case 'schema-playground': return 'Test and validate risk profile schemas interactively.';
case 'schema-docs': return 'Reference documentation for risk profile configuration schemas.';
default: return 'Monitor budget consumption and manage risk thresholds.';
case 'budget': return 'Monitor budget consumption and manage risk thresholds.';
case 'profiles': return 'Manage risk evaluation profiles and signal weights.';
case 'config': return 'Configure trust weights, staleness thresholds, and sealed mode.';
case 'conflicts': return 'Identify and resolve rule overlaps and precedence issues.';
case 'tools': return 'Validate policies, test schemas, and browse reference docs.';
case 'audit': return 'Governance change history and promotion approvals.';
default: return 'Monitor budget consumption and manage risk thresholds.';
}
});

View File

@@ -2,7 +2,8 @@ import { Routes } from '@angular/router';
/**
* Policy Governance feature routes.
* Provides tabbed navigation for governance controls.
* Rationalized from 10 tabs to 6: Risk Budget, Profiles, Configuration, Conflicts, Developer Tools, Audit.
* Legacy routes (trust-weights, staleness, sealed-mode, validator, schema-*) redirect to merged panels.
*
* @sprint SPRINT_20251229_021a_FE
*/
@@ -12,6 +13,7 @@ export const policyGovernanceRoutes: Routes = [
loadComponent: () =>
import('./policy-governance.component').then((m) => m.PolicyGovernanceComponent),
children: [
// ── Risk Budget (default) ──
{
path: '',
loadComponent: () =>
@@ -42,26 +44,8 @@ export const policyGovernanceRoutes: Routes = [
loadComponent: () =>
import('./risk-budget-config.component').then((m) => m.RiskBudgetConfigComponent),
},
{
path: 'trust-weights',
loadComponent: () =>
import('./trust-weighting.component').then((m) => m.TrustWeightingComponent),
},
{
path: 'staleness',
loadComponent: () =>
import('./staleness-config.component').then((m) => m.StalenessConfigComponent),
},
{
path: 'sealed-mode',
loadComponent: () =>
import('./sealed-mode-control.component').then((m) => m.SealedModeControlComponent),
},
{
path: 'sealed-mode/overrides',
loadComponent: () =>
import('./sealed-mode-overrides.component').then((m) => m.SealedModeOverridesComponent),
},
// ── Profiles ──
{
path: 'profiles',
loadComponent: () =>
@@ -77,16 +61,24 @@ export const policyGovernanceRoutes: Routes = [
loadComponent: () =>
import('./risk-profile-editor.component').then((m) => m.RiskProfileEditorComponent),
},
// ── Configuration (merged: Trust Weights + Staleness + Sealed Mode) ──
{
path: 'validator',
path: 'config',
loadComponent: () =>
import('./policy-validator.component').then((m) => m.PolicyValidatorComponent),
import('./governance-config-panel.component').then((m) => m.GovernanceConfigPanelComponent),
},
// Legacy routes redirect to merged config panel
{ path: 'trust-weights', redirectTo: 'config', pathMatch: 'full' },
{ path: 'staleness', redirectTo: 'config', pathMatch: 'full' },
{ path: 'sealed-mode', redirectTo: 'config', pathMatch: 'full' },
{
path: 'audit',
path: 'sealed-mode/overrides',
loadComponent: () =>
import('./governance-audit.component').then((m) => m.GovernanceAuditComponent),
import('./sealed-mode-overrides.component').then((m) => m.SealedModeOverridesComponent),
},
// ── Conflicts ──
{
path: 'conflicts',
loadComponent: () =>
@@ -97,21 +89,31 @@ export const policyGovernanceRoutes: Routes = [
loadComponent: () =>
import('./conflict-resolution-wizard.component').then((m) => m.ConflictResolutionWizardComponent),
},
// ── Developer Tools (merged: Validator + Playground + Docs) ──
{
path: 'tools',
loadComponent: () =>
import('./governance-tools-panel.component').then((m) => m.GovernanceToolsPanelComponent),
},
// Legacy routes redirect to merged tools panel
{ path: 'validator', redirectTo: 'tools', pathMatch: 'full' },
{ path: 'schema-playground', redirectTo: 'tools', pathMatch: 'full' },
{ path: 'schema-docs', redirectTo: 'tools', pathMatch: 'full' },
// ── Audit (embedded child, not a redirect) ──
{
path: 'audit',
loadComponent: () =>
import('./governance-audit.component').then((m) => m.GovernanceAuditComponent),
},
// ── Impact preview (ancillary, no tab) ──
{
path: 'impact-preview',
loadComponent: () =>
import('./impact-preview.component').then((m) => m.ImpactPreviewComponent),
},
{
path: 'schema-playground',
loadComponent: () =>
import('./schema-playground.component').then((m) => m.SchemaPlaygroundComponent),
},
{
path: 'schema-docs',
loadComponent: () =>
import('./schema-docs.component').then((m) => m.SchemaDocsComponent),
},
],
},
];

View File

@@ -263,6 +263,22 @@
}
}
<!-- Audit Log section -->
<section class="audit-log-section">
<button class="audit-log-toggle" (click)="auditExpanded.set(!auditExpanded())" type="button">
<span class="audit-log-toggle__icon" [class.audit-log-toggle__icon--expanded]="auditExpanded()">&#9654;</span>
Audit Log
</button>
@if (auditExpanded()) {
<div class="audit-log-content">
<div class="audit-cross-link">
<a routerLink="/evidence/audit-log" [queryParams]="{tab: 'all-events'}">View all audit events →</a>
</div>
<app-audit-module-events module="sbom" />
</div>
}
</section>
<!-- Delete confirmation dialog -->
@if (showDeleteDialog()) {
<div class="modal-overlay" (click)="cancelDelete()">

View File

@@ -416,6 +416,62 @@
border: 1px solid var(--color-status-info);
}
/* Audit Log collapsible section */
.audit-log-section {
margin-top: var(--space-8);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
}
.audit-log-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-4) var(--space-6);
background: none;
border: none;
cursor: pointer;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
text-align: left;
}
.audit-log-toggle:hover {
background: var(--color-surface-secondary);
}
.audit-log-toggle__icon {
display: inline-block;
font-size: var(--font-size-sm);
transition: transform var(--motion-duration-fast) var(--motion-ease-default);
}
.audit-log-toggle__icon--expanded {
transform: rotate(90deg);
}
.audit-log-content {
padding: 0 var(--space-6) var(--space-6);
}
.audit-cross-link {
text-align: right;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.audit-cross-link a {
color: var(--color-text-link);
text-decoration: none;
}
.audit-cross-link a:hover {
text-decoration: underline;
}
/* Responsive */
@include screen-below-md {
.sources-list-container {

View File

@@ -7,7 +7,7 @@
import { Component, OnInit, signal, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { SbomSourcesService } from '../../services/sbom-sources.service';
import {
SbomSource,
@@ -16,10 +16,11 @@ import {
ListSourcesParams,
} from '../../models/sbom-source.models';
import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component';
import { AuditModuleEventsComponent } from '../../../../shared/components/audit-module-events/audit-module-events.component';
@Component({
selector: 'app-sources-list',
imports: [CommonModule, FormsModule, LoadingStateComponent],
imports: [CommonModule, FormsModule, RouterLink, LoadingStateComponent, AuditModuleEventsComponent],
templateUrl: './sources-list.component.html',
styleUrl: './sources-list.component.scss'
})
@@ -49,6 +50,7 @@ export class SourcesListComponent implements OnInit {
// UI state
readonly selectedSource = signal<SbomSource | null>(null);
readonly showDeleteDialog = signal(false);
readonly auditExpanded = signal(false);
// Computed
readonly hasFilters = computed(() =>

View File

@@ -7,10 +7,12 @@ import { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/ro
import { filter } from 'rxjs';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { AuditModuleEventsComponent } from '../../shared/components/audit-module-events/audit-module-events.component';
import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
type TabType = 'offline-kits' | 'baselines' | 'settings' | 'analyzers' | 'performance';
type TabType = 'offline-kits' | 'baselines' | 'settings' | 'analyzers' | 'performance' | 'audit';
const KNOWN_TAB_IDS: readonly string[] = ['offline-kits', 'baselines', 'settings', 'analyzers', 'performance'];
const KNOWN_TAB_IDS: readonly string[] = ['offline-kits', 'baselines', 'settings', 'analyzers', 'performance', 'audit'];
const PAGE_TABS: readonly StellaPageTab[] = [
{ id: 'offline-kits', label: 'Offline Kits', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' },
@@ -18,11 +20,12 @@ const PAGE_TABS: readonly StellaPageTab[] = [
{ id: 'settings', label: 'Settings', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' },
{ id: 'analyzers', label: 'Analyzers', icon: 'M18 12h2|||M4 12h2|||M12 4v2|||M12 18v2|||M9 9h6v6H9z' },
{ id: 'performance', label: 'Performance', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
{ id: 'audit', label: 'Audit', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M16 13H8 M16 17H8 M10 9H8' },
];
@Component({
selector: 'app-scanner-ops',
imports: [RouterOutlet, StellaPageTabsComponent],
imports: [RouterOutlet, StellaPageTabsComponent, AuditModuleEventsComponent, StellaQuickLinksComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="scanner-ops">
@@ -34,20 +37,9 @@ const PAGE_TABS: readonly StellaPageTab[] = [
Offline kits, baselines, and determinism settings
</p>
</div>
<div class="scanner-ops__stats">
<div class="stat-card">
<span class="stat-value">{{ offlineKitCount() }}</span>
<span class="stat-label">Offline Kits</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ baselineCount() }}</span>
<span class="stat-label">Baselines</span>
</div>
<div class="stat-card" [class.stat-card--healthy]="analyzerHealth() === 'healthy'">
<span class="stat-value">{{ analyzerCount() }}</span>
<span class="stat-label">Analyzers</span>
</div>
</div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
</aside>
</div>
</header>
@@ -57,7 +49,13 @@ const PAGE_TABS: readonly StellaPageTab[] = [
ariaLabel="Scanner operations tabs"
(tabChange)="onTabChange($event)"
>
<router-outlet></router-outlet>
@if (activeTab() === 'audit') {
<section class="audit-section">
<app-audit-module-events module="scanner" />
</section>
} @else {
<router-outlet></router-outlet>
}
</stella-page-tabs>
</div>
`,
@@ -99,42 +97,9 @@ const PAGE_TABS: readonly StellaPageTab[] = [
margin: 0;
}
.scanner-ops__stats {
display: flex;
gap: 1rem;
}
.page-aside { flex: 0 1 60%; min-width: 0; }
.stat-card {
background: rgba(30, 41, 59, 0.6);
border: 1px solid var(--color-text-primary);
border-radius: var(--radius-lg);
padding: 0.75rem 1.25rem;
text-align: center;
min-width: 90px;
}
.stat-card--healthy {
border-color: var(--color-status-success-border);
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
color: var(--color-status-info);
}
.stat-card--healthy .stat-value {
color: var(--color-status-success-border);
}
.stat-label {
display: block;
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.audit-section { padding: 1rem 0; }
`]
})
export class ScannerOpsComponent implements OnInit {
@@ -144,6 +109,13 @@ export class ScannerOpsComponent implements OnInit {
readonly pageTabs = PAGE_TABS;
readonly activeTab = signal<TabType>('offline-kits');
readonly quickLinks: readonly StellaQuickLink[] = [
{ label: 'Security Posture', route: '/security', description: 'Release-blocking posture and advisory freshness' },
{ label: 'Findings Explorer', route: '/security/findings', description: 'Vulnerability findings across artifacts' },
{ label: 'Scan Image', route: '/security/scan', description: 'Trigger on-demand security scans' },
{ label: 'SBOM Data', route: '/security/supply-chain-data', description: 'Supply-chain components and SBOM health' },
{ label: 'Audit Events', route: '/evidence/audit-log', description: 'Cross-module audit trail' },
];
readonly offlineKitCount = signal(2);
readonly baselineCount = signal(3);
readonly analyzerCount = signal(9);

View File

@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import { TrustAuditEvent, ListAuditEventsParams, TrustAuditFilter } from '../../core/api/trust.models';
import { AuditLogClient } from '../../core/api/audit-log.client';
export interface AirgapEvent {
readonly eventId: string;
@@ -46,6 +47,11 @@ export type AirgapEventType =
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="airgap-audit">
@if (!apiConnected()) {
<div class="degraded-banner">
Air-gap audit is showing sample data. Live events will appear when the Trust API air-gap endpoint is available.
</div>
}
<!-- Status Banner -->
<div class="status-banner" [class]="'status-banner--' + currentAirgapMode()">
<div class="status-icon">
@@ -267,6 +273,11 @@ export type AirgapEventType =
.airgap-audit {
padding: 1.5rem;
}
.degraded-banner {
background: var(--color-status-warning-bg); color: var(--color-status-warning-text);
padding: 0.75rem 1rem; border-radius: var(--radius-sm); margin-bottom: 1rem;
font-size: 0.85rem; border-left: 3px solid var(--color-status-warning);
}
.status-banner {
display: flex;
@@ -675,8 +686,10 @@ export type AirgapEventType =
})
export class AirgapAuditComponent implements OnInit {
private readonly trustApi = inject(TRUST_API);
private readonly auditClient = inject(AuditLogClient);
// State
readonly apiConnected = signal(true);
readonly events = signal<AirgapEvent[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
@@ -716,71 +729,65 @@ export class AirgapAuditComponent implements OnInit {
this.loading.set(true);
this.error.set(null);
// Mock data for air-gap events
const mockEvents: AirgapEvent[] = [
// Try to load from real API first, fall back to mock data if unavailable
this.auditClient.getAirgapAudit(undefined, undefined, 50).subscribe({
next: (res) => {
const mapped = res.items.map((e) => this.mapAuditEventToAirgap(e));
this.applyFiltersAndPaginate(mapped);
this.apiConnected.set(true);
},
error: () => {
// Fallback to mock data when API is not available
this.apiConnected.set(false);
this.applyFiltersAndPaginate(this.getMockEvents());
},
});
}
private mapAuditEventToAirgap(e: any): AirgapEvent {
return {
eventId: e.id,
tenantId: e.resource?.id || 'unknown',
eventType: (e.details?.airgapEventType || e.action || 'airgap_enabled') as AirgapEventType,
severity: e.severity || 'info',
timestamp: e.timestamp,
actorName: e.actor?.name,
description: e.description || '',
airgapMode: e.details?.airgapMode || 'none',
syncStatus: e.details?.syncStatus || 'synced',
offlineKeyUsed: e.details?.offlineKeyUsed,
signatureCount: e.details?.signatureCount,
details: e.details,
};
}
private getMockEvents(): AirgapEvent[] {
return [
{
eventId: 'ag-001',
tenantId: 'tenant-1',
eventType: 'offline_signing',
severity: 'info',
eventId: 'ag-001', tenantId: 'tenant-1', eventType: 'offline_signing', severity: 'info',
timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
actorName: 'scanner@stellaops.local',
description: 'Offline signing operation completed for 15 attestations',
airgapMode: 'full',
syncStatus: 'pending',
offlineKeyUsed: 'key-001',
signatureCount: 15,
airgapMode: 'full', syncStatus: 'pending', offlineKeyUsed: 'key-001', signatureCount: 15,
},
{
eventId: 'ag-002',
tenantId: 'tenant-1',
eventType: 'airgap_enabled',
severity: 'warning',
eventId: 'ag-002', tenantId: 'tenant-1', eventType: 'airgap_enabled', severity: 'warning',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
actorName: 'admin@stellaops.local',
description: 'Air-gap mode enabled for disconnected operation',
airgapMode: 'full',
syncStatus: 'skipped',
details: { reason: 'Network maintenance', duration: '4 hours' },
airgapMode: 'full', syncStatus: 'skipped', details: { reason: 'Network maintenance', duration: '4 hours' },
},
{
eventId: 'ag-003',
tenantId: 'tenant-1',
eventType: 'sync_failed',
severity: 'error',
eventId: 'ag-003', tenantId: 'tenant-1', eventType: 'sync_failed', severity: 'error',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 4).toISOString(),
description: 'Synchronization failed: connection timeout',
airgapMode: 'partial',
syncStatus: 'failed',
details: { errorCode: 'CONN_TIMEOUT', retryCount: 3 },
},
{
eventId: 'ag-004',
tenantId: 'tenant-1',
eventType: 'cache_refreshed',
severity: 'info',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 6).toISOString(),
actorName: 'system',
description: 'Vulnerability cache refreshed with latest advisories',
airgapMode: 'none',
syncStatus: 'synced',
},
{
eventId: 'ag-005',
tenantId: 'tenant-1',
eventType: 'key_exported',
severity: 'info',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 8).toISOString(),
actorName: 'admin@stellaops.local',
description: 'Offline signing key exported for air-gapped environment',
airgapMode: 'none',
syncStatus: 'synced',
offlineKeyUsed: 'key-002',
airgapMode: 'partial', syncStatus: 'failed', details: { errorCode: 'CONN_TIMEOUT', retryCount: 3 },
},
];
}
// Filter events
let filtered = [...mockEvents];
private applyFiltersAndPaginate(allEvents: AirgapEvent[]): void {
let filtered = [...allEvents];
if (this.searchQuery()) {
const search = this.searchQuery().toLowerCase();
@@ -802,11 +809,9 @@ export class AirgapAuditComponent implements OnInit {
const start = (this.pageNumber() - 1) * this.pageSize();
const items = filtered.slice(start, start + this.pageSize());
setTimeout(() => {
this.events.set(items);
this.totalCount.set(filtered.length);
this.loading.set(false);
}, 200);
this.events.set(items);
this.totalCount.set(filtered.length);
this.loading.set(false);
}
private loadStatus(): void {

View File

@@ -61,6 +61,11 @@ export type IncidentType =
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="incident-audit">
@if (!apiConnected()) {
<div class="degraded-banner">
Incident audit is showing sample data. Live incidents will appear when the Trust API incident endpoint is available.
</div>
}
<!-- Summary Cards -->
<div class="summary-row">
<div class="summary-card summary-card--open" (click)="filterByStatus('open')">
@@ -335,6 +340,11 @@ export type IncidentType =
.incident-audit {
padding: 1.5rem;
}
.degraded-banner {
background: var(--color-status-warning-bg); color: var(--color-status-warning-text);
padding: 0.75rem 1rem; border-radius: var(--radius-sm); margin-bottom: 1rem;
font-size: 0.85rem; border-left: 3px solid var(--color-status-warning);
}
.summary-row {
display: grid;
@@ -838,6 +848,7 @@ export class IncidentAuditComponent implements OnInit {
private readonly trustApi = inject(TRUST_API);
// State
readonly apiConnected = signal(false);
readonly incidents = signal<IncidentEvent[]>([]);
readonly allIncidents = signal<IncidentEvent[]>([]);
readonly loading = signal(false);

View File

@@ -6,7 +6,7 @@
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { ActivatedRoute, Router, RouterLink, RouterOutlet, NavigationEnd } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
@@ -15,12 +15,16 @@ import { PageActionService } from '../../core/services/page-action.service';
import { TrustAdministrationOverview } from '../../core/api/trust.models';
import { GlossaryTooltipDirective } from '../../shared/directives/glossary-tooltip.directive';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component';
import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component';
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
import { AuditModuleEventsComponent } from '../../shared/components/audit-module-events/audit-module-events.component';
import { AirgapAuditComponent } from './airgap-audit.component';
import { IncidentAuditComponent } from './incident-audit.component';
export type TrustAdminTab = 'keys' | 'issuers' | 'certificates';
const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = ['keys', 'issuers', 'certificates'];
export type TrustAdminTab = 'keys' | 'issuers' | 'certificates' | 'audit';
const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = ['keys', 'issuers', 'certificates', 'audit'];
const TRUST_PAGE_TABS: readonly StellaPageTab[] = [
{
@@ -38,11 +42,16 @@ const TRUST_PAGE_TABS: readonly StellaPageTab[] = [
label: 'Certificates',
icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
},
{
id: 'audit',
label: 'Audit',
icon: 'M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2|||M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v0a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2z|||M9 12l2 2 4-4',
},
];
@Component({
selector: 'app-trust-admin',
imports: [CommonModule, RouterOutlet, GlossaryTooltipDirective, StellaPageTabsComponent, PageActionOutletComponent],
imports: [CommonModule, RouterLink, RouterOutlet, GlossaryTooltipDirective, StellaPageTabsComponent, StellaQuickLinksComponent, PageActionOutletComponent, AuditModuleEventsComponent, AirgapAuditComponent, IncidentAuditComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="trust-admin">
@@ -54,6 +63,9 @@ const TRUST_PAGE_TABS: readonly StellaPageTab[] = [
Manage signing keys, trusted issuers, and mTLS certificates.
</p>
</div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
</aside>
</div>
@if (error()) {
@@ -68,7 +80,27 @@ const TRUST_PAGE_TABS: readonly StellaPageTab[] = [
(tabChange)="onTabChange($event)"
>
<app-page-action-outlet tabBarAction />
<router-outlet></router-outlet>
@if (activeTab() === 'audit') {
<div class="audit-panel">
<div class="audit-sub-tabs">
<button class="audit-sub-tab" [class.audit-sub-tab--active]="auditSubTab() === 'events'" (click)="auditSubTab.set('events')">Trust Events</button>
<button class="audit-sub-tab" [class.audit-sub-tab--active]="auditSubTab() === 'airgap'" (click)="auditSubTab.set('airgap')">Air-Gap Audit</button>
<button class="audit-sub-tab" [class.audit-sub-tab--active]="auditSubTab() === 'incidents'" (click)="auditSubTab.set('incidents')">Incidents</button>
</div>
<div class="audit-sub-content">
@if (auditSubTab() === 'events') {
<app-audit-module-events module="trust-admin" />
} @else if (auditSubTab() === 'airgap') {
<app-airgap-audit />
} @else if (auditSubTab() === 'incidents') {
<app-incident-audit />
}
</div>
<a routerLink="/evidence/audit-log" [queryParams]="{tab: 'all-events'}" class="audit-cross-link">View all audit events &rarr;</a>
</div>
} @else {
<router-outlet></router-outlet>
}
</stella-page-tabs>
</div>
`,
@@ -98,6 +130,8 @@ const TRUST_PAGE_TABS: readonly StellaPageTab[] = [
margin-bottom: 1.5rem;
}
.page-aside { flex: 0 1 60%; min-width: 0; }
.trust-admin__eyebrow {
margin: 0;
color: var(--color-status-info);
@@ -157,6 +191,54 @@ const TRUST_PAGE_TABS: readonly StellaPageTab[] = [
margin-top: 0.5rem;
}
.audit-panel {
padding: 0;
}
.audit-sub-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border-primary);
margin-bottom: 0;
}
.audit-sub-tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-muted);
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
}
.audit-sub-tab:hover {
color: var(--color-text-primary);
}
.audit-sub-tab--active {
color: var(--color-status-info);
border-bottom-color: var(--color-status-info);
}
.audit-sub-content {
min-height: 200px;
}
.audit-cross-link {
display: inline-block;
margin: 1rem 1.25rem;
color: var(--color-status-info);
font-size: 0.875rem;
text-decoration: none;
}
.audit-cross-link:hover {
text-decoration: underline;
}
@keyframes shimmer {
0% { opacity: 0.4; }
50% { opacity: 0.6; }
@@ -177,12 +259,20 @@ export class TrustAdminComponent implements OnInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
private readonly pageAction = inject(PageActionService);
readonly quickLinks: readonly StellaQuickLink[] = [
{ label: 'Evidence Overview', route: '/evidence/overview', description: 'Evidence search and decision verification' },
{ label: 'Offline Kit', route: '/ops/operations/offline-kit', description: 'JWKS management and offline bundles' },
{ label: 'Trust Analytics', route: '/ops/operations/trust-analytics', description: 'Trust metrics and signing trends' },
{ label: 'Audit Events', route: '/evidence/audit-log', description: 'Cross-module audit trail' },
];
// State
readonly loading = signal(true);
readonly refreshing = signal(false);
readonly error = signal<string | null>(null);
readonly overview = signal<TrustAdministrationOverview | null>(null);
readonly activeTab = signal<TrustAdminTab>('keys');
readonly auditSubTab = signal<'events' | 'airgap' | 'incidents'>('events');
readonly workspaceLabel = signal<'Setup' | 'Administration'>('Setup');
// Computed

View File

@@ -30,17 +30,24 @@ import {
StellaPageTabsComponent,
StellaPageTab,
} from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
import { AuditVexComponent } from '../audit-log/audit-vex.component';
type VexHubTab = 'search' | 'stats' | 'consensus';
type VexHubTab = 'search' | 'stats' | 'consensus' | 'audit';
@Component({
selector: 'app-vex-hub',
imports: [CommonModule, RouterModule, StellaPageTabsComponent],
imports: [CommonModule, RouterModule, StellaPageTabsComponent, StellaQuickLinksComponent, AuditVexComponent],
template: `
<div class="vex-hub-container">
<header class="vex-hub-header">
<h1>VEX Hub Explorer</h1>
<p class="subtitle">Explore VEX statements, view consensus, and manage vulnerability status</p>
<div>
<h1>VEX Hub Explorer</h1>
<p class="subtitle">Explore VEX statements, view consensus, and manage vulnerability status</p>
</div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
</aside>
</header>
<!-- AI Consent Banner -->
@@ -225,6 +232,13 @@ type VexHubTab = 'search' | 'stats' | 'consensus';
</div>
}
<!-- Audit Trail Tab -->
@if (activeTab() === 'audit') {
<div class="audit-section">
<app-audit-vex />
</div>
}
<!-- Statement Detail Panel -->
@if (selectedStatement()) {
<div class="detail-overlay" (click)="selectedStatement.set(null)">
@@ -338,9 +352,10 @@ type VexHubTab = 'search' | 'stats' | 'consensus';
`,
styles: [`
.vex-hub-container { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.vex-hub-header { margin-bottom: 1.5rem; }
.vex-hub-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1.5rem; margin-bottom: 1.5rem; }
.vex-hub-header h1 { margin: 0; font-size: 1.75rem; }
.subtitle { color: var(--color-text-secondary); margin-top: 0.25rem; }
.page-aside { flex: 0 1 60%; min-width: 0; }
.ai-consent-banner {
display: flex; justify-content: space-between; align-items: center;
@@ -455,6 +470,8 @@ type VexHubTab = 'search' | 'stats' | 'consensus';
.btn-cancel { background: none; border: 1px solid var(--color-border-primary); padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; }
.btn-enable { background: var(--color-btn-primary-bg); color: var(--color-text-heading); border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; font-weight: var(--font-weight-medium); }
.btn-enable:disabled { opacity: 0.5; cursor: not-allowed; }
.audit-section { margin-top: 0.5rem; }
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
@@ -462,9 +479,18 @@ export class VexHubComponent implements OnInit {
private readonly vexHubApi = inject<VexHubApi>(VEX_HUB_API);
private readonly advisoryAiApi = inject<AdvisoryAiApi>(ADVISORY_AI_API);
readonly quickLinks: readonly StellaQuickLink[] = [
{ label: 'Findings Explorer', route: '/security/findings', description: 'Vulnerability findings linked to VEX' },
{ label: 'Disposition Center', route: '/security/disposition', description: 'Advisory sources and VEX configuration' },
{ label: 'Policy Governance', route: '/ops/policy/governance', description: 'Risk budgets and policy profiles' },
{ label: 'Exceptions', route: '/ops/policy/vex/exceptions', description: 'Active vulnerability exceptions' },
{ label: 'Audit Events', route: '/evidence/audit-log', description: 'Cross-module audit trail' },
];
readonly pageTabs: readonly StellaPageTab[] = [
{ id: 'search', label: 'Search Statements', icon: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3' },
{ id: 'consensus', label: 'Consensus View', icon: 'M20 6L9 17l-5-5' },
{ id: 'audit', label: 'Audit Trail', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M16 13H8 M16 17H8 M10 9H8' },
];
readonly activeTab = signal<VexHubTab>('search');
readonly loading = signal(false);

View File

@@ -0,0 +1,525 @@
/**
* Reusable single-module audit events table.
* Wraps AuditLogClient.getModuleEvents() with table, filters, pagination, and event detail panel.
* Use this to embed audit event views in feature pages that lack dedicated audit components.
*/
import {
Component,
Input,
OnInit,
OnChanges,
SimpleChanges,
inject,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuditLogClient } from '../../../core/api/audit-log.client';
import {
AuditEvent,
AuditLogFilters,
AuditModule,
AuditAction,
AuditSeverity,
} from '../../../core/api/audit-log.models';
@Component({
selector: 'app-audit-module-events',
imports: [CommonModule, RouterModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="module-audit-events">
<!-- Module toggle (multi-module mode) -->
@if (modules && modules.length > 1) {
<div class="module-toggle">
<button
class="toggle-chip"
[class.active]="activeModuleFilter() === 'all'"
(click)="setModuleFilter('all')"
>All</button>
@for (m of modules; track m) {
<button
class="toggle-chip"
[class.active]="activeModuleFilter() === m"
(click)="setModuleFilter(m)"
>{{ formatModule(m) }}</button>
}
</div>
}
<!-- Filters -->
<div class="filters-bar">
<div class="filter-group">
<label>Actions</label>
<select [(ngModel)]="selectedAction" (change)="applyFilters()">
<option value="">All Actions</option>
@for (a of allActions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</div>
<div class="filter-group">
<label>Severity</label>
<select [(ngModel)]="selectedSeverity" (change)="applyFilters()">
<option value="">All Severities</option>
@for (s of allSeverities; track s) {
<option [value]="s">{{ s }}</option>
}
</select>
</div>
<div class="filter-group">
<label>Date Range</label>
<select [(ngModel)]="dateRange" (change)="applyFilters()">
<option value="7d">Last 7 days</option>
<option value="24h">Last 24 hours</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
</div>
<div class="filter-group search-group">
<input type="text" [(ngModel)]="searchQuery" placeholder="Search events..." (keyup.enter)="applyFilters()" />
<button class="btn-sm" (click)="applyFilters()">Search</button>
</div>
<button class="btn-clear" (click)="clearFilters()">Clear</button>
</div>
@if (loading()) {
<div class="loading">Loading audit events...</div>
}
<!-- Events table -->
<table class="events-table">
<thead>
<tr>
<th>Timestamp (UTC)</th>
@if (modules && modules.length > 1) {
<th>Module</th>
}
<th>Action</th>
<th>Severity</th>
<th>Actor</th>
<th>Resource</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody>
@for (event of events(); track event.id) {
<tr [class]="event.severity" (click)="toggleDetail(event)" [class.selected]="selectedEvent()?.id === event.id">
<td class="mono">{{ formatTimestamp(event.timestamp) }}</td>
@if (modules && modules.length > 1) {
<td><span class="badge module" [class]="event.module">{{ formatModule(event.module) }}</span></td>
}
<td><span class="badge action" [class]="event.action">{{ event.action }}</span></td>
<td><span class="badge severity" [class]="event.severity">{{ event.severity }}</span></td>
<td>
<span class="actor" [title]="event.actor.email || ''">
{{ event.actor.name }}
@if (event.actor.type === 'system') { <span class="actor-type">(system)</span> }
</span>
</td>
<td class="resource">{{ event.resource.type }}: {{ event.resource.name || event.resource.id }}</td>
<td class="description">{{ event.description }}</td>
<td>
@if (event.diff) {
<button class="btn-xs" (click)="openDiff(event); $event.stopPropagation()">Diff</button>
}
</td>
</tr>
} @empty {
<tr>
<td [attr.colspan]="modules && modules.length > 1 ? 8 : 7" class="empty-cell">
No audit events match the current filters.
</td>
</tr>
}
</tbody>
</table>
<div class="pagination">
<button [disabled]="!hasPrev()" (click)="prevPage()">Previous</button>
<span>{{ events().length }} events loaded</span>
<button [disabled]="!hasMore()" (click)="nextPage()">Next</button>
</div>
<!-- Event detail panel -->
@if (selectedEvent()) {
<div class="detail-panel">
<header class="panel-header">
<h3>Event Details</h3>
<button class="close-btn" (click)="selectedEvent.set(null)">&times;</button>
</header>
<div class="panel-content">
<div class="detail-row"><span class="label">Event ID:</span><span class="value mono">{{ selectedEvent()?.id }}</span></div>
<div class="detail-row"><span class="label">Timestamp:</span><span class="value mono">{{ selectedEvent()?.timestamp }}</span></div>
<div class="detail-row"><span class="label">Module:</span><span class="value">{{ formatModule(selectedEvent()?.module!) }}</span></div>
<div class="detail-row"><span class="label">Action:</span><span class="value">{{ selectedEvent()?.action }}</span></div>
<div class="detail-row"><span class="label">Severity:</span><span class="value badge severity" [class]="selectedEvent()?.severity">{{ selectedEvent()?.severity }}</span></div>
<div class="detail-row"><span class="label">Actor:</span><span class="value">{{ selectedEvent()?.actor?.name }} ({{ selectedEvent()?.actor?.type }})</span></div>
@if (selectedEvent()?.actor?.email) {
<div class="detail-row"><span class="label">Email:</span><span class="value">{{ selectedEvent()?.actor?.email }}</span></div>
}
<div class="detail-row"><span class="label">Resource:</span><span class="value">{{ selectedEvent()?.resource?.type }}: {{ selectedEvent()?.resource?.id }}</span></div>
<div class="detail-row"><span class="label">Description:</span><span class="value">{{ selectedEvent()?.description }}</span></div>
@if (selectedEvent()?.correlationId) {
<div class="detail-row">
<span class="label">Correlation:</span>
<a class="value mono link" [routerLink]="['/evidence/audit-log']" [queryParams]="{tab: 'correlations', id: selectedEvent()?.correlationId}">{{ selectedEvent()?.correlationId }}</a>
</div>
}
@if (selectedEvent()?.tags?.length) {
<div class="detail-row">
<span class="label">Tags:</span>
<span class="value">
@for (tag of selectedEvent()?.tags; track tag) { <span class="tag">{{ tag }}</span> }
</span>
</div>
}
<div class="detail-section">
<h4>Details</h4>
<pre class="json-block">{{ (selectedEvent()?.details ?? {}) | json }}</pre>
</div>
@if (selectedEvent()?.diff) {
<button class="btn-primary" (click)="openDiff(selectedEvent()!)">View Diff</button>
}
</div>
</div>
}
<!-- Diff modal -->
@if (diffEvent()) {
<div class="diff-backdrop" (click)="closeDiff()">
<div class="diff-modal" (click)="$event.stopPropagation()">
<header class="modal-header">
<h3>Configuration Diff</h3>
<button class="close-btn" (click)="closeDiff()">&times;</button>
</header>
<div class="modal-content">
<div class="diff-meta">
<span>{{ diffEvent()?.resource?.type }}: {{ diffEvent()?.resource?.name || diffEvent()?.resource?.id }}</span>
<span>Changed by {{ diffEvent()?.actor?.name }} at {{ formatTimestamp(diffEvent()?.timestamp!) }}</span>
</div>
<div class="diff-container">
<div class="diff-pane before">
<h4>Before</h4>
<pre>{{ (diffEvent()?.diff?.before ?? {}) | json }}</pre>
</div>
<div class="diff-pane after">
<h4>After</h4>
<pre>{{ (diffEvent()?.diff?.after ?? {}) | json }}</pre>
</div>
</div>
@if (diffEvent()?.diff?.fields?.length) {
<div class="changed-fields">
<strong>Changed fields:</strong>
@for (field of diffEvent()?.diff?.fields; track field) { <span class="field-badge">{{ field }}</span> }
</div>
}
</div>
</div>
</div>
}
</div>
`,
styles: [`
.module-audit-events { max-width: 1400px; }
.module-toggle { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.toggle-chip {
padding: 0.35rem 0.85rem; border-radius: 9999px; cursor: pointer;
background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary);
font-size: 0.8rem; font-weight: var(--font-weight-medium); transition: all 150ms ease;
}
.toggle-chip:hover { border-color: var(--color-brand-primary); }
.toggle-chip.active {
background: var(--color-brand-primary); color: var(--color-text-heading);
border-color: var(--color-brand-primary);
}
.filters-bar {
display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: flex-end;
background: var(--color-surface-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); padding: 0.75rem 1rem; margin-bottom: 1rem;
}
.filter-group { display: flex; flex-direction: column; gap: 0.2rem; }
.filter-group label { font-size: 0.72rem; font-weight: var(--font-weight-medium); color: var(--color-text-secondary); text-transform: uppercase; }
.filter-group select, .filter-group input { padding: 0.4rem 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: 0.84rem; }
.search-group { flex: 1; min-width: 180px; flex-direction: row; align-items: flex-end; }
.search-group input { flex: 1; }
.btn-sm {
padding: 0.4rem 0.75rem; cursor: pointer;
background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text);
border: none; border-radius: var(--radius-sm); font-weight: var(--font-weight-medium); font-size: 0.84rem;
}
.btn-clear {
padding: 0.4rem 0.75rem; cursor: pointer; align-self: flex-end;
background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm); font-size: 0.84rem;
}
.loading { text-align: center; padding: 2rem; color: var(--color-text-secondary); }
.events-table { width: 100%; border-collapse: collapse; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.events-table th, .events-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); font-size: 0.84rem; }
.events-table th {
background: var(--color-surface-elevated); font-weight: var(--font-weight-semibold);
position: sticky; top: 0; z-index: 1;
font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.03em;
}
.events-table tbody tr:nth-child(even) { background: var(--color-surface-elevated); }
.events-table tr { cursor: pointer; transition: background 150ms ease; }
.events-table tr:hover { background: rgba(59, 130, 246, 0.06); }
.events-table tr.selected { background: var(--color-status-info-bg); }
.events-table tr.error, .events-table tr.critical { background: var(--color-status-error-bg); }
.events-table tr.warning { background: var(--color-status-warning-bg); }
.empty-cell { text-align: center; padding: 2rem !important; color: var(--color-text-muted); }
.mono { font-family: monospace; font-size: 0.78rem; }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 9999px; font-size: 0.68rem; font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; }
.badge.module { background: var(--color-surface-elevated); }
.badge.module.policy { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.badge.module.authority { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.module.vex { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.module.integrations { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.module.jobengine, .badge.module.scheduler { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.module.scanner { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.badge.module.attestor { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.module.sbom { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.action { background: var(--color-surface-elevated); }
.badge.action.create, .badge.action.issue { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.action.update, .badge.action.refresh { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.badge.action.delete, .badge.action.revoke { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.badge.action.promote, .badge.action.approve { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.action.fail, .badge.action.reject { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.badge.severity.info { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.badge.severity.warning { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.severity.error { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.badge.severity.critical { background: var(--color-status-error-text); color: white; }
.actor-type { font-size: 0.7rem; color: var(--color-text-muted); }
.resource, .description { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { color: var(--color-text-link); text-decoration: none; font-size: 0.8rem; }
.link:hover { text-decoration: underline; }
.btn-xs {
padding: 0.15rem 0.4rem; font-size: 0.7rem; cursor: pointer; margin-left: 0.5rem;
background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
}
.pagination {
display: flex; justify-content: center; gap: 1rem; align-items: center;
margin-top: 1rem; padding: 0.75rem 1rem; border-top: 1px solid var(--color-border-primary);
}
.pagination button {
padding: 0.45rem 1rem; cursor: pointer; font-size: 0.84rem; font-weight: var(--font-weight-medium);
border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-surface-primary);
}
.pagination button:hover:not(:disabled) { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-btn-primary-bg); }
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.pagination span { font-size: 0.84rem; color: var(--color-text-secondary); }
.detail-panel { position: fixed; top: 0; right: 0; width: 400px; height: 100vh; background: var(--color-surface-primary); border-left: 1px solid var(--color-border-primary); box-shadow: -4px 0 16px rgba(0,0,0,0.1); overflow-y: auto; z-index: 100; }
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid var(--color-border-primary); background: var(--color-surface-elevated); }
.panel-header h3 { margin: 0; }
.close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--color-text-secondary); }
.panel-content { padding: 1rem; }
.detail-row { display: flex; margin-bottom: 0.75rem; }
.detail-row .label { width: 120px; font-weight: var(--font-weight-semibold); font-size: 0.85rem; color: var(--color-text-secondary); }
.detail-row .value { flex: 1; font-size: 0.85rem; word-break: break-all; }
.tag { display: inline-block; background: var(--color-surface-elevated); padding: 0.15rem 0.5rem; border-radius: 9999px; font-size: 0.72rem; margin-right: 0.25rem; }
.detail-section { margin-top: 1rem; }
.detail-section h4 { margin: 0 0 0.5rem; font-size: 0.9rem; }
.json-block { background: var(--color-surface-elevated); padding: 0.75rem; border-radius: var(--radius-sm); font-size: 0.75rem; overflow-x: auto; max-height: 200px; }
.btn-primary {
background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text);
border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm);
cursor: pointer; margin-top: 1rem; font-weight: var(--font-weight-medium);
}
.diff-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 200; }
.diff-modal { background: var(--color-surface-primary); border-radius: var(--radius-lg); width: 90%; max-width: 1000px; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid var(--color-border-primary); }
.modal-header h3 { margin: 0; }
.modal-content { padding: 1rem; overflow-y: auto; flex: 1; }
.diff-meta { display: flex; justify-content: space-between; margin-bottom: 1rem; font-size: 0.85rem; color: var(--color-text-secondary); }
.diff-container { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.diff-pane { background: var(--color-surface-elevated); border-radius: var(--radius-sm); overflow: hidden; }
.diff-pane h4 { margin: 0; padding: 0.5rem 0.75rem; background: var(--color-surface-primary); border-bottom: 1px solid var(--color-border-primary); font-size: 0.85rem; }
.diff-pane.before h4 { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.diff-pane.after h4 { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.diff-pane pre { margin: 0; padding: 0.75rem; font-size: 0.75rem; max-height: 400px; overflow: auto; }
.changed-fields { margin-top: 1rem; font-size: 0.85rem; }
.field-badge { display: inline-block; background: var(--color-status-warning-bg); color: var(--color-status-warning-text); padding: 0.15rem 0.5rem; border-radius: 9999px; margin-left: 0.5rem; font-size: 0.72rem; }
`]
})
export class AuditModuleEventsComponent implements OnInit, OnChanges {
/** Single module mode */
@Input() module?: AuditModule;
/** Multi-module mode (e.g., jobengine + scheduler) */
@Input() modules?: AuditModule[];
private readonly auditClient = inject(AuditLogClient);
readonly events = signal<AuditEvent[]>([]);
readonly loading = signal(false);
readonly selectedEvent = signal<AuditEvent | null>(null);
readonly diffEvent = signal<AuditEvent | null>(null);
readonly hasMore = signal(false);
readonly hasPrev = signal(false);
readonly activeModuleFilter = signal<AuditModule | 'all'>('all');
private cursor: string | null = null;
private cursorStack: string[] = [];
// Filter state
selectedAction = '';
selectedSeverity = '';
dateRange = '7d';
searchQuery = '';
readonly allActions: AuditAction[] = [
'create', 'update', 'delete', 'promote', 'demote', 'revoke', 'issue',
'refresh', 'test', 'fail', 'complete', 'start', 'submit', 'approve',
'reject', 'sign', 'verify', 'rotate', 'enable', 'disable', 'deadletter', 'replay',
];
readonly allSeverities: AuditSeverity[] = ['info', 'warning', 'error', 'critical'];
ngOnInit(): void {
this.loadEvents();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['module'] || changes['modules']) {
this.cursor = null;
this.cursorStack = [];
this.loadEvents();
}
}
private getEffectiveModules(): AuditModule[] {
if (this.activeModuleFilter() !== 'all') {
return [this.activeModuleFilter() as AuditModule];
}
if (this.modules?.length) return this.modules;
if (this.module) return [this.module];
return [];
}
setModuleFilter(filter: AuditModule | 'all'): void {
this.activeModuleFilter.set(filter);
this.cursor = null;
this.cursorStack = [];
this.loadEvents();
}
loadEvents(): void {
const effectiveModules = this.getEffectiveModules();
if (!effectiveModules.length) return;
this.loading.set(true);
const filters = this.buildFilters();
// For single module, use getModuleEvents directly
if (effectiveModules.length === 1) {
this.auditClient.getModuleEvents(effectiveModules[0], filters, this.cursor || undefined, 50).subscribe({
next: (res) => {
this.events.set(res.items);
this.hasMore.set(res.hasMore);
this.cursor = res.cursor;
this.loading.set(false);
},
error: () => this.loading.set(false),
});
} else {
// Multi-module: use getEvents with module filter
filters.modules = effectiveModules;
this.auditClient.getEvents(filters, this.cursor || undefined, 50).subscribe({
next: (res) => {
this.events.set(res.items);
this.hasMore.set(res.hasMore);
this.cursor = res.cursor;
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
}
private buildFilters(): AuditLogFilters {
const filters: AuditLogFilters = {};
if (this.selectedAction) filters.actions = [this.selectedAction as AuditAction];
if (this.selectedSeverity) filters.severities = [this.selectedSeverity as AuditSeverity];
if (this.searchQuery) filters.search = this.searchQuery;
const now = new Date();
if (this.dateRange === '24h') {
filters.startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
} else if (this.dateRange === '7d') {
filters.startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
} else if (this.dateRange === '30d') {
filters.startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
} else if (this.dateRange === '90d') {
filters.startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString();
}
return filters;
}
applyFilters(): void {
this.cursor = null;
this.cursorStack = [];
this.loadEvents();
}
clearFilters(): void {
this.selectedAction = '';
this.selectedSeverity = '';
this.dateRange = '7d';
this.searchQuery = '';
this.applyFilters();
}
nextPage(): void {
if (this.cursor) {
this.cursorStack.push(this.cursor);
this.loadEvents();
}
}
prevPage(): void {
this.cursorStack.pop();
this.cursor = this.cursorStack.length ? this.cursorStack[this.cursorStack.length - 1] : null;
this.hasPrev.set(this.cursorStack.length > 0);
this.loadEvents();
}
toggleDetail(event: AuditEvent): void {
this.selectedEvent.set(this.selectedEvent()?.id === event.id ? null : event);
}
openDiff(event: AuditEvent): void {
this.diffEvent.set(event);
}
closeDiff(): void {
this.diffEvent.set(null);
}
formatTimestamp(ts: string): string {
if (!ts) return '';
const d = new Date(ts);
return d.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
}
formatModule(m: AuditModule): string {
const labels: Record<string, string> = {
authority: 'Authority',
policy: 'Policy',
jobengine: 'JobEngine',
integrations: 'Integrations',
vex: 'VEX',
scanner: 'Scanner',
attestor: 'Attestor',
sbom: 'SBOM',
scheduler: 'Scheduler',
};
return labels[m] || m;
}
}