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) {
+
+ @switch (selectedModules[0]) {
+ @case ('policy') {
View in Policy Governance → }
+ @case ('authority') {
View in Console Admin → }
+ @case ('vex') {
View in VEX Hub → }
+ @case ('integrations') {
View in Integration Hub → }
+ @case ('jobengine') {
View in Platform Jobs → }
+ @case ('scheduler') {
View in Platform Jobs → }
+ @case ('scanner') {
View in Scanner Ops → }
+ }
+
+ }
+
@@ -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: `
-
-
- @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 {
+
+
+
+ | Timestamp |
+ Event Type |
+ Actor |
+ Tenant ID |
+ Resource Type |
+ Resource ID |
+ Details |
+
+
+
+ @for (event of paginatedEvents; track event.id ?? event.eventType + event.occurredAt) {
+
+ | {{ 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 {
-
-
-
- | Timestamp |
- Event Type |
- Actor |
- Tenant ID |
- Resource Type |
- Resource ID |
- Details |
-
-
-
- @for (event of paginatedEvents; track event.id ?? event.eventType + event.occurredAt) {
-
- | {{ 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: `
-
-
-
-
+
`,
+ 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') {
+
+ }
}
@@ -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: `
-
-
+
+
+
+
+
+
-
-
-
+ @if (searchQuery) {
+
+ }
+
@if (actionFeedback()) {
@@ -75,14 +117,23 @@ import {
} @else {
-
+
- | Name |
+
+ Name
+ {{ sortBy === 'name' ? (sortDesc ? '\u25BC' : '\u25B2') : '' }}
+ |
Provider |
- Status |
+
+ Status
+ {{ sortBy === 'status' ? (sortDesc ? '\u25BC' : '\u25B2') : '' }}
+ |
Health |
- Last Checked |
+
+ Last Checked
+ {{ sortBy === 'lastHealthCheckAt' ? (sortDesc ? '\u25BC' : '\u25B2') : '' }}
+ |
Actions |
@@ -114,20 +165,28 @@ import {
}
- }
- @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: `
-
+ @if (showOnboarding()) {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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: `
+
+
+
+ @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: `
+
+ `,
+ 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: `
= {
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()) {
diff --git a/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.scss b/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.scss
index 5bf7c119d..5245d172e 100644
--- a/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.scss
+++ b/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.scss
@@ -416,6 +416,62 @@
border: 1px solid var(--color-status-info);
}
+/* Audit Log collapsible section */
+.audit-log-section {
+ margin-top: var(--space-8);
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-primary);
+}
+
+.audit-log-toggle {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ padding: var(--space-4) var(--space-6);
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: var(--font-size-lg);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ text-align: left;
+}
+
+.audit-log-toggle:hover {
+ background: var(--color-surface-secondary);
+}
+
+.audit-log-toggle__icon {
+ display: inline-block;
+ font-size: var(--font-size-sm);
+ transition: transform var(--motion-duration-fast) var(--motion-ease-default);
+}
+
+.audit-log-toggle__icon--expanded {
+ transform: rotate(90deg);
+}
+
+.audit-log-content {
+ padding: 0 var(--space-6) var(--space-6);
+}
+
+.audit-cross-link {
+ text-align: right;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+}
+
+.audit-cross-link a {
+ color: var(--color-text-link);
+ text-decoration: none;
+}
+
+.audit-cross-link a:hover {
+ text-decoration: underline;
+}
+
/* Responsive */
@include screen-below-md {
.sources-list-container {
diff --git a/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.ts b/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.ts
index 8517245b4..593b95cfb 100644
--- a/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/sbom-sources/components/sources-list/sources-list.component.ts
@@ -7,7 +7,7 @@
import { Component, OnInit, signal, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
-import { Router } from '@angular/router';
+import { Router, RouterLink } from '@angular/router';
import { SbomSourcesService } from '../../services/sbom-sources.service';
import {
SbomSource,
@@ -16,10 +16,11 @@ import {
ListSourcesParams,
} from '../../models/sbom-source.models';
import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component';
+import { AuditModuleEventsComponent } from '../../../../shared/components/audit-module-events/audit-module-events.component';
@Component({
selector: 'app-sources-list',
- imports: [CommonModule, FormsModule, LoadingStateComponent],
+ imports: [CommonModule, FormsModule, RouterLink, LoadingStateComponent, AuditModuleEventsComponent],
templateUrl: './sources-list.component.html',
styleUrl: './sources-list.component.scss'
})
@@ -49,6 +50,7 @@ export class SourcesListComponent implements OnInit {
// UI state
readonly selectedSource = signal
(null);
readonly showDeleteDialog = signal(false);
+ readonly auditExpanded = signal(false);
// Computed
readonly hasFilters = computed(() =>
diff --git a/src/Web/StellaOps.Web/src/app/features/scanner-ops/scanner-ops.component.ts b/src/Web/StellaOps.Web/src/app/features/scanner-ops/scanner-ops.component.ts
index 26e025360..85ff8fb71 100644
--- a/src/Web/StellaOps.Web/src/app/features/scanner-ops/scanner-ops.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/scanner-ops/scanner-ops.component.ts
@@ -7,10 +7,12 @@ import { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/ro
import { filter } from 'rxjs';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
+import { AuditModuleEventsComponent } from '../../shared/components/audit-module-events/audit-module-events.component';
+import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
-type TabType = 'offline-kits' | 'baselines' | 'settings' | 'analyzers' | 'performance';
+type TabType = 'offline-kits' | 'baselines' | 'settings' | 'analyzers' | 'performance' | 'audit';
-const KNOWN_TAB_IDS: readonly string[] = ['offline-kits', 'baselines', 'settings', 'analyzers', 'performance'];
+const KNOWN_TAB_IDS: readonly string[] = ['offline-kits', 'baselines', 'settings', 'analyzers', 'performance', 'audit'];
const PAGE_TABS: readonly StellaPageTab[] = [
{ id: 'offline-kits', label: 'Offline Kits', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' },
@@ -18,11 +20,12 @@ const PAGE_TABS: readonly StellaPageTab[] = [
{ id: 'settings', label: 'Settings', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' },
{ id: 'analyzers', label: 'Analyzers', icon: 'M18 12h2|||M4 12h2|||M12 4v2|||M12 18v2|||M9 9h6v6H9z' },
{ id: 'performance', label: 'Performance', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
+ { id: 'audit', label: 'Audit', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M16 13H8 M16 17H8 M10 9H8' },
];
@Component({
selector: 'app-scanner-ops',
- imports: [RouterOutlet, StellaPageTabsComponent],
+ imports: [RouterOutlet, StellaPageTabsComponent, AuditModuleEventsComponent, StellaQuickLinksComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@@ -34,20 +37,9 @@ const PAGE_TABS: readonly StellaPageTab[] = [
Offline kits, baselines, and determinism settings
-
-
- {{ offlineKitCount() }}
- Offline Kits
-
-
- {{ baselineCount() }}
- Baselines
-
-
- {{ analyzerCount() }}
- Analyzers
-
-
+
@@ -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: `
@@ -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...
+ }
+
+
+
+
+
+ | Timestamp (UTC) |
+ @if (modules && modules.length > 1) {
+ Module |
+ }
+ Action |
+ Severity |
+ Actor |
+ Resource |
+ Description |
+ |
+
+
+
+ @for (event of events(); track event.id) {
+
+ | {{ formatTimestamp(event.timestamp) }} |
+ @if (modules && modules.length > 1) {
+ {{ 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) {
+
+ }
+ |
+
+ } @empty {
+
+ | 1 ? 8 : 7" class="empty-cell">
+ No audit events match the current filters.
+ |
+
+ }
+
+
+
+
+
+
+ @if (selectedEvent()) {
+
+
+
+
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()) {
+
+
+
+
+
+ {{ diffEvent()?.resource?.type }}: {{ diffEvent()?.resource?.name || diffEvent()?.resource?.id }}
+ Changed by {{ diffEvent()?.actor?.name }} at {{ formatTimestamp(diffEvent()?.timestamp!) }}
+
+
+
+
Before
+
{{ (diffEvent()?.diff?.before ?? {}) | json }}
+
+
+
After
+
{{ (diffEvent()?.diff?.after ?? {}) | json }}
+
+
+ @if (diffEvent()?.diff?.fields?.length) {
+
+ Changed fields:
+ @for (field of diffEvent()?.diff?.fields; track field) { {{ field }} }
+
+ }
+
+
+
+ }
+
+ `,
+ 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;
+ }
+}