From ae5059aa1cb921eca10d4289952f7ef14e4d944e Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 30 Mar 2026 17:24:15 +0300 Subject: [PATCH] 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) --- .../e2e/audit-consolidation.e2e.spec.ts | 473 +++++++++++++ .../audit-log-dashboard.component.ts | 39 +- .../audit-log/audit-log-table.component.ts | 17 + .../features/audit-log/audit-log.routes.ts | 14 +- .../audit/audit-log.component.ts | 349 ++++++---- .../console-admin-layout.component.ts | 45 +- .../console-admin/console-admin.routes.ts | 6 + .../integration-detail.component.ts | 103 ++- .../integration-hub/integration-hub.routes.ts | 9 +- .../integration-list.component.ts | 637 +++++++++++------- .../integration-shell.component.ts | 206 +++++- .../platform-jobs-queues-page.component.ts | 29 +- .../governance-audit.component.ts | 74 +- .../governance-config-panel.component.ts | 105 +++ .../governance-tools-panel.component.ts | 105 +++ .../impact-preview.component.ts | 4 +- .../policy-governance.component.spec.ts | 54 +- .../policy-governance.component.ts | 81 ++- .../policy-governance.routes.ts | 72 +- .../sources-list/sources-list.component.html | 16 + .../sources-list/sources-list.component.scss | 56 ++ .../sources-list/sources-list.component.ts | 6 +- .../scanner-ops/scanner-ops.component.ts | 78 +-- .../trust-admin/airgap-audit.component.ts | 113 ++-- .../trust-admin/incident-audit.component.ts | 11 + .../trust-admin/trust-admin.component.ts | 100 ++- .../app/features/vex-hub/vex-hub.component.ts | 36 +- .../audit-module-events.component.ts | 525 +++++++++++++++ 28 files changed, 2681 insertions(+), 682 deletions(-) create mode 100644 src/Web/StellaOps.Web/e2e/audit-consolidation.e2e.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/policy-governance/governance-config-panel.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/policy-governance/governance-tools-panel.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/components/audit-module-events/audit-module-events.component.ts diff --git a/src/Web/StellaOps.Web/e2e/audit-consolidation.e2e.spec.ts b/src/Web/StellaOps.Web/e2e/audit-consolidation.e2e.spec.ts new file mode 100644 index 000000000..32021fd47 --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/audit-consolidation.e2e.spec.ts @@ -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); + }); + } +}); diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts index c5205f524..6e2ad2887 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts @@ -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: `
} @case ('all-events') { } - @case ('policy') { } - @case ('authority') { } - @case ('vex') { } - @case ('integrations') { } - @case ('trust') { } @case ('timeline') { } @case ('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('overview'); diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts index b4c1b2f6f..a6bf9a511 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts @@ -19,6 +19,20 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }

Audit Events

+ @if (selectedModules.length === 1) { + + } +
@@ -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; } diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log.routes.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log.routes.ts index ef0198808..1fd412ead 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log.routes.ts @@ -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' }, diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts index 4cae44500..7aa37d293 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts @@ -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: `

Audit Log

+ View all audit events →
- - - @if (error) { -
{{ error }}
- } - -
-
-
Total Events
-
{{ filteredEvents.length }}
-
-
-
Shown
-
{{ paginatedEvents.length }}
-
+
+ +
- @if (isLoading) { -
- @for (i of [1,2,3,4,5,6]; track i) { -
-
-
-
-
-
-
-
+ @if (auditSubView() === 'management') { + + + @if (error) { +
{{ error }}
+ } + +
+
+
Total Events
+
{{ filteredEvents.length }}
+
+
+
Shown
+
{{ paginatedEvents.length }}
+
+
+ + @if (isLoading) { +
+ @for (i of [1,2,3,4,5,6]; track i) { +
+
+
+
+
+
+
+
+
+ } +
+ } @else if (filteredEvents.length === 0) { +
No audit events found
+ } @else { + + + + + + + + + + + + + + @for (event of paginatedEvents; track event.id ?? event.eventType + event.occurredAt) { + + + + + + + + + + } + +
TimestampEvent TypeActorTenant IDResource TypeResource IDDetails
{{ formatTimestamp(event.timestamp ?? event.occurredAt) }} + + {{ event.eventType }} + + {{ event.actor }}{{ event.resourceType }} + +
+ + @if (filteredEvents.length > pageSize) { + } -
- } @else if (filteredEvents.length === 0) { -
No audit events found
- } @else { - - - - - - - - - - - - - - @for (event of paginatedEvents; track event.id ?? event.eventType + event.occurredAt) { - - - - - - - - - - } - -
TimestampEvent TypeActorTenant IDResource TypeResource IDDetails
{{ formatTimestamp(event.timestamp ?? event.occurredAt) }} - - {{ event.eventType }} - - {{ event.actor }}{{ event.resourceType }} - -
+ } - @if (filteredEvents.length > pageSize) { - `, @@ -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[] = []; diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin-layout.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin-layout.component.ts index 2bd9e6085..c7d7acfac 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin-layout.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin-layout.component.ts @@ -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: ` - - - - +
+
+
+

Console Administration

+

Manage tenants, users, roles, and platform access.

+
+ +
+ + + + +
`, + 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('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); diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin.routes.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin.routes.ts index 57a6c4745..87bc75e43 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin.routes.ts @@ -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] } } ] } diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts index 824015877..aa39d693f 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts @@ -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) {
Loading integration details...
@@ -212,6 +214,15 @@ const HUB_DETAIL_TABS: StellaPageTab[] = [ }
} + @case ('audit') { +
+

Config Audit

+ + +
+ } }
@@ -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; diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts index e3750aa23..27e57d62b 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts @@ -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', diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts index 12a7523d6..83705d531 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts @@ -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: `
-

{{ typeLabel }} Integrations

- +
+

{{ typeLabel }} Integrations

+
+
-
- + + + + +
+ -
- - + @if (searchQuery) { + + } +
@if (actionFeedback()) {
@@ -75,14 +117,23 @@ import {
} @else { - +
- + - + - + @@ -114,20 +165,28 @@ import { }
Name + Name + {{ sortBy === 'name' ? (sortDesc ? '\u25BC' : '\u25B2') : '' }} + ProviderStatus + Status + {{ sortBy === 'status' ? (sortDesc ? '\u25BC' : '\u25B2') : '' }} + HealthLast Checked + Last Checked + {{ sortBy === 'lastHealthCheckAt' ? (sortDesc ? '\u25BC' : '\u25B2') : '' }} + Actions
- } - @if (totalCount > pageSize) { - + + @if (totalPages > 1) { + + } }
`, 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 = { 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(null); readonly actionFeedbackTone = signal<'success' | 'error'>('success'); + readonly statusCounts = signal>({}); private integrationType?: IntegrationType; + private searchDebounce: ReturnType | 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 = {}; + + 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'; } } diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts index f7f198c05..d3d2e0670 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts @@ -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: `
-

Integrations

-

External system connectors for release, security, and evidence flows.

+
+

Integrations

+

External system connectors for release, security, and evidence flows.

+
+
- + @if (showOnboarding()) { +
+
+

Get Started

+

Connect StellaOps to the providers installed in this environment.

+
+ +
+
+
+
+

Container Registries

+

Connect container registries for image discovery, probing, and policy handoff.

+
+ +
+
+ +
+
+
+

Source Control

+

Connect repository hosts for commit metadata, drift context, and release evidence.

+
+ +
+
+ +
+
+
+

CI/CD Pipelines

+

Connect CI/CD systems for deployment gate signals and pipeline health monitoring.

+
+ +
+
+ +
+
+
+

Advisory & VEX Sources

+

Browse, enable, and health-check upstream advisory and VEX data sources.

+
+ +
+
+
+ +
+ Suggested order: Registries first (unblock releases), then SCM (wire metadata), CI/CD (capture pipelines), Advisory (security posture), Secrets (vaults). +
+
+ } @else { + + }
`, @@ -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('hub'); + readonly activeTab = signal('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' }); + } + } + }, + }); } } diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts index c53e6b423..543bc611a 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts @@ -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: `
@@ -336,6 +338,15 @@ interface WorkerRow {
} + @if (tab() === 'audit') { +
+ + +
+ } +

Context

@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 []; } }); diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.ts index 67b98b22d..c5f6c5dda 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.ts @@ -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: `
+ +
+ + + + View all audit events → +
+ + @if (auditView() === 'governance') {
No audit events found matching your filters.

} + } + + @if (auditView() === 'promotions') { + + }
`, 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([]); protected readonly response = signal(null); diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-config-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-config-panel.component.ts new file mode 100644 index 000000000..cbbeb481f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-config-panel.component.ts @@ -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: ` +
+ + +
+ @switch (activeSection()) { + @case ('trust-weights') { } + @case ('staleness') { } + @case ('sealed-mode') { } + } +
+
+ `, + 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('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' }, + ]; +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-tools-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-tools-panel.component.ts new file mode 100644 index 000000000..431c8d275 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-tools-panel.component.ts @@ -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: ` +
+ + +
+ @switch (activeSection()) { + @case ('validator') { } + @case ('schema-playground') { } + @case ('schema-docs') { } + } +
+
+ `, + 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('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' }, + ]; +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts index 42f989d03..6d2402c82 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts @@ -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); } } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.spec.ts index f038a3fb2..5ef8d2b75 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.spec.ts @@ -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', () => { diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts index de2e2e781..c476cdc79 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts @@ -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: `
-

Policy Governance

-

{{ activeSubtitle() }}

+
+

Policy Governance

+

{{ activeSubtitle() }}

+
+
= { 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 = { @@ -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('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.'; } }); diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.routes.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.routes.ts index 8d3907f8c..ee164d3ee 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.routes.ts @@ -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), - }, ], }, ]; diff --git a/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.html b/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.html index 3dd4d9975..9e7ce3eab 100644 --- a/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.html +++ b/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.html @@ -263,6 +263,22 @@ } } + +
+ + @if (auditExpanded()) { + + } +
+ @if (showDeleteDialog()) { @@ -57,7 +49,13 @@ const PAGE_TABS: readonly StellaPageTab[] = [ ariaLabel="Scanner operations tabs" (tabChange)="onTabChange($event)" > - + @if (activeTab() === 'audit') { +
+ +
+ } @else { + + }
`, @@ -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('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); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/airgap-audit.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/airgap-audit.component.ts index 286289f76..2db01c6b8 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/airgap-audit.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/airgap-audit.component.ts @@ -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: `
+ @if (!apiConnected()) { +
+ Air-gap audit is showing sample data. Live events will appear when the Trust API air-gap endpoint is available. +
+ }
@@ -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([]); readonly loading = signal(false); readonly error = signal(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 { diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/incident-audit.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/incident-audit.component.ts index 5eb0e368d..4dd2b4de2 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/incident-audit.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/incident-audit.component.ts @@ -61,6 +61,11 @@ export type IncidentType = changeDetection: ChangeDetectionStrategy.OnPush, template: `
+ @if (!apiConnected()) { +
+ Incident audit is showing sample data. Live incidents will appear when the Trust API incident endpoint is available. +
+ }
@@ -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([]); readonly allIncidents = signal([]); readonly loading = signal(false); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts index c2c9b5302..e40a4a652 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts @@ -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: `
@@ -54,6 +63,9 @@ const TRUST_PAGE_TABS: readonly StellaPageTab[] = [ Manage signing keys, trusted issuers, and mTLS certificates.

+
@if (error()) { @@ -68,7 +80,27 @@ const TRUST_PAGE_TABS: readonly StellaPageTab[] = [ (tabChange)="onTabChange($event)" > - + @if (activeTab() === 'audit') { +
+
+ + + +
+
+ @if (auditSubTab() === 'events') { + + } @else if (auditSubTab() === 'airgap') { + + } @else if (auditSubTab() === 'incidents') { + + } +
+ View all audit events → +
+ } @else { + + }
`, @@ -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(null); readonly overview = signal(null); readonly activeTab = signal('keys'); + readonly auditSubTab = signal<'events' | 'airgap' | 'incidents'>('events'); readonly workspaceLabel = signal<'Setup' | 'Administration'>('Setup'); // Computed diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub.component.ts index aea9b34ea..e51d216a5 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub.component.ts @@ -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: `
-

VEX Hub Explorer

-

Explore VEX statements, view consensus, and manage vulnerability status

+
+

VEX Hub Explorer

+

Explore VEX statements, view consensus, and manage vulnerability status

+
+
@@ -225,6 +232,13 @@ type VexHubTab = 'search' | 'stats' | 'consensus';
} + + @if (activeTab() === 'audit') { +
+ +
+ } + @if (selectedStatement()) {
@@ -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(VEX_HUB_API); private readonly advisoryAiApi = inject(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('search'); readonly loading = signal(false); diff --git a/src/Web/StellaOps.Web/src/app/shared/components/audit-module-events/audit-module-events.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/audit-module-events/audit-module-events.component.ts new file mode 100644 index 000000000..71f44ef02 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/audit-module-events/audit-module-events.component.ts @@ -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: ` +
+ + @if (modules && modules.length > 1) { +
+ + @for (m of modules; track m) { + + } +
+ } + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + @if (loading()) { +
Loading audit events...
+ } + + + + + + + @if (modules && modules.length > 1) { + + } + + + + + + + + + + @for (event of events(); track event.id) { + + + @if (modules && modules.length > 1) { + + } + + + + + + + + } @empty { + + + + } + +
Timestamp (UTC)ModuleActionSeverityActorResourceDescription
{{ formatTimestamp(event.timestamp) }}{{ formatModule(event.module) }}{{ event.action }}{{ event.severity }} + + {{ event.actor.name }} + @if (event.actor.type === 'system') { (system) } + + {{ event.resource.type }}: {{ event.resource.name || event.resource.id }}{{ event.description }} + @if (event.diff) { + + } +
+ No audit events match the current filters. +
+ + + + + @if (selectedEvent()) { +
+
+

Event Details

+ +
+
+
Event ID:{{ selectedEvent()?.id }}
+
Timestamp:{{ selectedEvent()?.timestamp }}
+
Module:{{ formatModule(selectedEvent()?.module!) }}
+
Action:{{ selectedEvent()?.action }}
+
Severity:{{ selectedEvent()?.severity }}
+
Actor:{{ selectedEvent()?.actor?.name }} ({{ selectedEvent()?.actor?.type }})
+ @if (selectedEvent()?.actor?.email) { +
Email:{{ selectedEvent()?.actor?.email }}
+ } +
Resource:{{ selectedEvent()?.resource?.type }}: {{ selectedEvent()?.resource?.id }}
+
Description:{{ selectedEvent()?.description }}
+ @if (selectedEvent()?.correlationId) { + + } + @if (selectedEvent()?.tags?.length) { +
+ Tags: + + @for (tag of selectedEvent()?.tags; track tag) { {{ tag }} } + +
+ } +
+

Details

+
{{ (selectedEvent()?.details ?? {}) | json }}
+
+ @if (selectedEvent()?.diff) { + + } +
+
+ } + + + @if (diffEvent()) { +
+
+ + +
+
+ } +
+ `, + 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([]); + readonly loading = signal(false); + readonly selectedEvent = signal(null); + readonly diffEvent = signal(null); + readonly hasMore = signal(false); + readonly hasPrev = signal(false); + readonly activeModuleFilter = signal('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 = { + authority: 'Authority', + policy: 'Policy', + jobengine: 'JobEngine', + integrations: 'Integrations', + vex: 'VEX', + scanner: 'Scanner', + attestor: 'Attestor', + sbom: 'SBOM', + scheduler: 'Scheduler', + }; + return labels[m] || m; + } +}