From 8beed2afb483125fe53a91ad72bfe07ed3694139 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 7 Apr 2026 15:33:32 +0300 Subject: [PATCH] feat(audit): consolidate audit views, merge governance audit into unified log Remove standalone GovernanceAuditComponent and AuditPolicyComponent in favor of the unified audit log with policy-specific category chips, structured governance diffs, and per-event policy detail fields. Evidence and policy-decisioning routes now redirect to the consolidated audit page under Operations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/app/core/api/audit-log.models.ts | 10 +- .../audit-log/audit-event-detail.component.ts | 145 ++- .../audit-log-dashboard.component.ts | 16 +- .../audit-log/audit-log-table.component.ts | 280 +++++- .../features/audit-log/audit-log.routes.ts | 4 +- .../audit-log/audit-policy.component.ts | 154 --- .../policy-decisioning.routes.ts | 8 +- .../governance-audit.component.spec.ts | 55 -- .../governance-audit.component.ts | 899 ------------------ .../policy-governance.component.ts | 9 +- .../policy-governance.routes.ts | 11 +- .../src/app/features/policy/policy.routes.ts | 6 +- .../src/app/routes/evidence.routes.ts | 32 +- .../src/app/routes/operations.routes.ts | 25 + 14 files changed, 441 insertions(+), 1213 deletions(-) delete mode 100644 src/Web/StellaOps.Web/src/app/features/audit-log/audit-policy.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.ts diff --git a/src/Web/StellaOps.Web/src/app/core/api/audit-log.models.ts b/src/Web/StellaOps.Web/src/app/core/api/audit-log.models.ts index 0c2f4738b..f1ba39657 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/audit-log.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/audit-log.models.ts @@ -62,9 +62,13 @@ export interface AuditResource { /** Before/after state for diff-enabled events */ export interface AuditDiff { - before: unknown; - after: unknown; - fields: string[]; + before?: unknown; + after?: unknown; + fields?: string[]; + /** Governance-style structured diff sections */ + added?: Record; + removed?: Record; + modified?: Record; } /** Full audit event record */ diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts index ea799870f..25bf4dd6a 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts @@ -102,26 +102,100 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
{{ (event()?.details ?? {}) | json }}
+ @if (event()?.module === 'policy') { +
+

Policy Details

+
+ @if (event()?.details?.['packName'] || event()?.details?.['packId']) { +
+ Pack + {{ event()?.details?.['packName'] || event()?.details?.['packId'] }} +
+ } + @if (event()?.details?.['policyHash']) { +
+ Policy Hash + {{ event()?.details?.['policyHash'] }} +
+ } + @if (event()?.details?.['shadowModeStatus']) { +
+ Shadow Mode + {{ event()?.details?.['shadowModeStatus'] }} + @if (event()?.details?.['shadowModeDays']) { ({{ event()?.details?.['shadowModeDays'] }}d) } + +
+ } + @if (event()?.details?.['coverage'] !== undefined && event()?.details?.['coverage'] !== null) { +
+ Coverage + {{ event()?.details?.['coverage'] }}% +
+ } +
+
+ } + @if (event()?.diff) {

Configuration Diff

-
-
-

Before

-
{{ (event()?.diff?.before ?? {}) | json }}
-
-
-

After

-
{{ (event()?.diff?.after ?? {}) | json }}
-
-
- @if (event()?.diff?.fields?.length) { -
- Changed fields: - @for (field of event()?.diff?.fields; track field) { - {{ field }} + @if (hasGovernanceDiff()) { +
+ @if (govDiffEntries(event()?.diff?.added).length > 0) { +
+
Added
+ @for (entry of govDiffEntries(event()?.diff?.added); track entry[0]) { +
+ {{ entry[0] }}: + {{ fmtDiffVal(entry[1]) }} +
+ } +
+ } + @if (govDiffEntries(event()?.diff?.removed).length > 0) { +
+
Removed
+ @for (entry of govDiffEntries(event()?.diff?.removed); track entry[0]) { +
+ {{ entry[0] }}: + {{ fmtDiffVal(entry[1]) }} +
+ } +
+ } + @if (govDiffEntries(event()?.diff?.modified).length > 0) { +
+
Modified
+ @for (entry of govDiffEntries(event()?.diff?.modified); track entry[0]) { +
+ {{ entry[0] }}: + {{ fmtDiffVal(entry[1]?.before) }} + + {{ fmtDiffVal(entry[1]?.after) }} +
+ } +
}
+ } @else { +
+
+

Before

+
{{ (event()?.diff?.before ?? {}) | json }}
+
+
+

After

+
{{ (event()?.diff?.after ?? {}) | json }}
+
+
+ @if (event()?.diff?.fields?.length) { +
+ Changed fields: + @for (field of event()?.diff?.fields; track field) { + {{ field }} + } +
+ } }
} @@ -218,6 +292,28 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo .related-events-table tr:hover { background: var(--color-surface-elevated); } .related-events-table tr.current { background: var(--color-status-info-bg); } .loading { text-align: center; padding: 3rem; color: var(--color-text-secondary); } + .policy-details-section { margin-bottom: 1.5rem; } + .policy-details-section h3 { margin: 0 0 0.75rem; font-size: 1rem; } + /* Governance-style structured diff */ + .governance-diff { display: flex; flex-direction: column; gap: 1rem; } + .diff-group { border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--color-border-primary); } + .diff-group__title { + padding: 0.4rem 0.75rem; font-size: 0.78rem; font-weight: var(--font-weight-semibold); + text-transform: uppercase; letter-spacing: 0.03em; + } + .diff-group--added .diff-group__title { background: var(--color-status-success-bg); color: var(--color-status-success-text); } + .diff-group--removed .diff-group__title { background: var(--color-status-error-bg); color: var(--color-status-error-text); } + .diff-group--modified .diff-group__title { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); } + .diff-line { + display: flex; gap: 0.5rem; padding: 0.35rem 0.75rem; font-size: 0.8rem; + border-bottom: 1px solid var(--color-border-primary); + } + .diff-line:last-child { border-bottom: none; } + .diff-key { font-weight: var(--font-weight-semibold); font-family: monospace; font-size: 0.78rem; min-width: 140px; } + .diff-val { font-family: monospace; font-size: 0.78rem; } + .diff-before { font-family: monospace; font-size: 0.78rem; color: var(--color-status-error-text); text-decoration: line-through; } + .diff-arrow { color: var(--color-text-muted); font-size: 0.78rem; } + .diff-after { font-family: monospace; font-size: 0.78rem; color: var(--color-status-success-text); } `] }) export class AuditEventDetailComponent implements OnInit { @@ -247,6 +343,25 @@ export class AuditEventDetailComponent implements OnInit { }); } + hasGovernanceDiff(): boolean { + const diff = this.event()?.diff; + if (!diff) return false; + return !!(diff.added && Object.keys(diff.added).length) || + !!(diff.removed && Object.keys(diff.removed).length) || + !!(diff.modified && Object.keys(diff.modified).length); + } + + govDiffEntries(obj: Record | undefined | null): [string, any][] { + if (!obj) return []; + return Object.entries(obj); + } + + fmtDiffVal(value: unknown): string { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + } + formatTimestamp(ts: string): string { return new Date(ts).toISOString().replace('T', ' ').slice(0, 19); } 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 f87d7684e..e742eba94 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 @@ -13,12 +13,16 @@ import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/co import { AuditCorrelationsComponent } from './audit-correlations.component'; import { AuditLogTableComponent } from './audit-log-table.component'; import { AuditTimelineSearchComponent } from './audit-timeline-search.component'; +import { ExportCenterComponent } from '../evidence-export/export-center.component'; +import { TriageAuditBundlesComponent } from '../triage/triage-audit-bundles.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: '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' }, + { id: 'exports', label: 'Exports', icon: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4|||M17 8l-5-5-5 5|||M12 3v12' }, + { id: 'bundles', label: 'Bundles', 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' }, ]; @Component({ @@ -34,14 +38,16 @@ const AUDIT_TABS: StellaPageTab[] = [ AuditTimelineSearchComponent, AuditCorrelationsComponent, AuditLogTableComponent, + ExportCenterComponent, + TriageAuditBundlesComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -355,10 +363,8 @@ export class AuditLogDashboardComponent implements OnInit { private readonly helperCtx = inject(StellaHelperContextService); 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: 'Proof Chains', route: '/evidence/proofs', description: 'Evidence chain visualization' }, { label: 'Trust & Signing', route: '/setup/trust-signing', description: 'Signing keys and certificate management' }, ]; 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 a6bf9a511..119c4f439 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 @@ -1,11 +1,22 @@ // Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { RouterModule } from '@angular/router'; +import { RouterModule, ActivatedRoute } 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'; +type PolicyCategory = 'all' | 'governance' | 'promotions' | 'approvals' | 'rejections' | 'simulations'; + +const POLICY_CATEGORY_ACTIONS: Record = { + all: null, + governance: ['create', 'update', 'delete', 'enable', 'disable'], + promotions: ['promote'], + approvals: ['approve'], + rejections: ['reject'], + simulations: ['test'], +}; + @Component({ selector: 'app-audit-log-table', imports: [CommonModule, RouterModule, FormsModule], @@ -22,13 +33,12 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } @if (selectedModules.length === 1) { } @@ -37,7 +47,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
- @for (m of allModules; track m) { } @@ -45,7 +55,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
- @for (a of allActions; track a) { } @@ -94,6 +104,18 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
+ @if (isPolicyOnly) { +
+ @for (cat of policyCategoryList; track cat.id) { + + } +
+ } + @if (loading()) {
Loading events...
} @@ -102,12 +124,20 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } Timestamp (UTC) - Module + @if (!isPolicyOnly) { Module } Action Severity + @if (isPolicyOnly) { + Pack + Policy Hash + } Actor Resource - Description + @if (isPolicyOnly) { + Shadow Mode + Coverage + } + @if (!isPolicyOnly) { Description } @@ -115,9 +145,15 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } @for (event of events(); track event.id) { {{ formatTimestamp(event.timestamp) }} - {{ formatModule(event.module) }} + @if (!isPolicyOnly) { + {{ formatModule(event.module) }} + } {{ event.action }} {{ event.severity }} + @if (isPolicyOnly) { + {{ getDetail(event, 'packName') || getDetail(event, 'packId') || '-' }} + {{ truncateHash(getDetail(event, 'policyHash')) }} + } {{ event.actor.name }} @@ -125,7 +161,26 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } {{ event.resource.type }}: {{ event.resource.name || event.resource.id }} - {{ event.description }} + @if (isPolicyOnly) { + + @if (getDetail(event, 'shadowModeStatus')) { + + {{ getDetail(event, 'shadowModeStatus') }} + @if (getDetail(event, 'shadowModeDays')) { + ({{ getDetail(event, 'shadowModeDays') }}d) + } + + } @else { - } + + + @if (getDetail(event, 'coverage') !== undefined && getDetail(event, 'coverage') !== null) { + {{ getDetail(event, 'coverage') }}% + } @else { - } + + } + @if (!isPolicyOnly) { + {{ event.description }} + } View @if (event.diff) { @@ -134,7 +189,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } } @empty { - No events match the current filters. + No events match the current filters. } @@ -229,23 +284,66 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } {{ 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 }} + + @if (hasGovernanceDiff(diffEvent()!)) { + +
+ @if (diffEntries(diffEvent()?.diff?.added).length > 0) { +
+
Added
+ @for (entry of diffEntries(diffEvent()?.diff?.added); track entry[0]) { +
+ {{ entry[0] }}: + {{ formatDiffValue(entry[1]) }} +
+ } +
+ } + @if (diffEntries(diffEvent()?.diff?.removed).length > 0) { +
+
Removed
+ @for (entry of diffEntries(diffEvent()?.diff?.removed); track entry[0]) { +
+ {{ entry[0] }}: + {{ formatDiffValue(entry[1]) }} +
+ } +
+ } + @if (diffEntries(diffEvent()?.diff?.modified).length > 0) { +
+
Modified
+ @for (entry of diffEntries(diffEvent()?.diff?.modified); track entry[0]) { +
+ {{ entry[0] }}: + {{ formatDiffValue(entry[1]?.before) }} + + {{ formatDiffValue(entry[1]?.after) }} +
+ } +
}
+ } @else { + +
+
+

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 }} + } +
+ } }
@@ -287,6 +385,35 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } } .btn-secondary:hover { border-color: var(--color-brand-primary); } + /* Policy sub-category chips */ + .policy-category-chips { + display: flex; gap: 0.5rem; margin-bottom: 1rem; + } + .category-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; + } + .category-chip:hover { + background: var(--color-surface-tertiary); + color: var(--color-text-primary); + } + .category-chip--active { + background: var(--color-brand-primary); + color: #fff; + border-color: var(--color-brand-primary); + } + .category-chip--active:hover { + background: var(--color-brand-primary); + color: #fff; + } + /* Loading skeleton */ .loading { text-align: center; padding: 3rem; color: var(--color-text-secondary); @@ -315,6 +442,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } .events-table tr.critical { background: var(--color-status-error-bg); } .events-table tr.warning { background: var(--color-status-warning-bg); } .mono { font-family: monospace; font-size: 0.78rem; } + .hash { max-width: 120px; overflow: hidden; text-overflow: ellipsis; } .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); } @@ -333,6 +461,10 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } .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; } + .badge.shadow { font-size: 0.72rem; } + .badge.shadow.active { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); } + .badge.shadow.completed { background: var(--color-status-success-bg); color: var(--color-status-success-text); } + .badge.shadow.disabled { background: var(--color-surface-elevated); } .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; } @@ -394,10 +526,35 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } .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; } + + /* Governance-style structured diff */ + .governance-diff { display: flex; flex-direction: column; gap: 1rem; } + .diff-group { border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--color-border-primary); } + .diff-group__title { + padding: 0.4rem 0.75rem; font-size: 0.78rem; font-weight: var(--font-weight-semibold); + text-transform: uppercase; letter-spacing: 0.03em; + } + .diff-group--added .diff-group__title { background: var(--color-status-success-bg); color: var(--color-status-success-text); } + .diff-group--removed .diff-group__title { background: var(--color-status-error-bg); color: var(--color-status-error-text); } + .diff-group--modified .diff-group__title { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); } + .diff-line { + display: flex; gap: 0.5rem; padding: 0.35rem 0.75rem; font-size: 0.8rem; + border-bottom: 1px solid var(--color-border-primary); + } + .diff-line:last-child { border-bottom: none; } + .diff-key { font-weight: var(--font-weight-semibold); font-family: monospace; font-size: 0.78rem; min-width: 140px; } + .diff-val { font-family: monospace; font-size: 0.78rem; } + .diff-before { font-family: monospace; font-size: 0.78rem; color: var(--color-status-error-text); text-decoration: line-through; } + .diff-arrow { color: var(--color-text-muted); font-size: 0.78rem; } + .diff-after { font-family: monospace; font-size: 0.78rem; color: var(--color-status-success-text); } + .diff-line--added { background: rgba(var(--color-status-success-rgb, 34, 197, 94), 0.05); } + .diff-line--removed { background: rgba(var(--color-status-error-rgb, 239, 68, 68), 0.05); } + .diff-line--modified { background: rgba(var(--color-status-warning-rgb, 234, 179, 8), 0.05); } `] }) export class AuditLogTableComponent implements OnInit { private readonly auditClient = inject(AuditLogClient); + private readonly route = inject(ActivatedRoute); readonly events = signal([]); readonly loading = signal(false); @@ -418,14 +575,48 @@ export class AuditLogTableComponent implements OnInit { searchQuery = ''; actorFilter = ''; + // Policy sub-category state + policyCategory: PolicyCategory = 'all'; + + get isPolicyOnly(): boolean { + return this.selectedModules.length === 1 && this.selectedModules[0] === 'policy'; + } + + readonly policyCategoryList: { id: PolicyCategory; label: string }[] = [ + { id: 'all', label: 'All Policy' }, + { id: 'governance', label: 'Governance' }, + { id: 'promotions', label: 'Promotions' }, + { id: 'approvals', label: 'Approvals' }, + { id: 'rejections', label: 'Rejections' }, + { id: 'simulations', label: 'Simulations' }, + ]; + readonly allModules: AuditModule[] = ['authority', 'policy', 'jobengine', 'integrations', 'vex', 'scanner', 'attestor', 'sbom', 'scheduler']; 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 { + const moduleParam = this.route.snapshot.queryParamMap.get('module'); + if (moduleParam && this.allModules.includes(moduleParam as AuditModule)) { + this.selectedModules = [moduleParam as AuditModule]; + } this.loadEvents(); } + onModuleChange(): void { + if (!this.isPolicyOnly) { + this.policyCategory = 'all'; + } + this.applyFilters(); + } + + setPolicyCategory(category: PolicyCategory): void { + this.policyCategory = category; + const actions = POLICY_CATEGORY_ACTIONS[category]; + this.selectedActions = actions ? [...actions] : []; + this.applyFilters(); + } + loadEvents(): void { this.loading.set(true); const filters = this.buildFilters(); @@ -482,6 +673,7 @@ export class AuditLogTableComponent implements OnInit { this.customEndDate = ''; this.searchQuery = ''; this.actorFilter = ''; + this.policyCategory = 'all'; this.applyFilters(); } @@ -515,6 +707,34 @@ export class AuditLogTableComponent implements OnInit { this.diffEvent.set(null); } + getDetail(event: AuditEvent, key: string): any { + return event.details?.[key]; + } + + truncateHash(hash: any): string { + const s = String(hash ?? ''); + return s.length > 12 ? s.slice(0, 12) + '...' : s || '-'; + } + + hasGovernanceDiff(event: AuditEvent): boolean { + const diff = event?.diff; + if (!diff) return false; + return !!(diff.added && Object.keys(diff.added).length) || + !!(diff.removed && Object.keys(diff.removed).length) || + !!(diff.modified && Object.keys(diff.modified).length); + } + + diffEntries(obj: Record | undefined | null): [string, any][] { + if (!obj) return []; + return Object.entries(obj); + } + + formatDiffValue(value: unknown): string { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + } + formatTimestamp(ts: string): string { return new Date(ts).toISOString().replace('T', ' ').slice(0, 19); } 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 abcd69cff..ba3103431 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 @@ -15,8 +15,8 @@ export const auditLogRoutes: Routes = [ }, // Backward-compatible redirects for old child-route URLs { path: 'events', redirectTo: '?tab=all-events', pathMatch: 'full' }, - // Module-specific tabs moved to contextual locations - { path: 'policy', redirectTo: '/ops/policy/governance?tab=audit', pathMatch: 'full' }, + // Policy audit consolidated into unified audit (filter by module=policy) + { path: 'policy', redirectTo: '', 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' }, diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-policy.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-policy.component.ts deleted file mode 100644 index 33ccab27e..000000000 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-policy.component.ts +++ /dev/null @@ -1,154 +0,0 @@ -// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer -import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core'; - -import { RouterModule } from '@angular/router'; -import { AuditLogClient } from '../../core/api/audit-log.client'; -import { AuditEvent } from '../../core/api/audit-log.models'; - -@Component({ - selector: 'app-audit-policy', - imports: [RouterModule], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
- - -
- - - - - -
- - - - - - - - - - - - - - - @for (event of events(); track event.id) { - - - - - - - - - - } - -
TimestampActionPackPolicy HashActorShadow ModeCoverage
{{ formatTime(event.timestamp) }}{{ event.action }}{{ getDetail(event, 'packName') || getDetail(event, 'packId') || '-' }}{{ truncateHash(getDetail(event, 'policyHash')) }}{{ event.actor.name }} - @if (getDetail(event, 'shadowModeStatus')) { - - {{ getDetail(event, 'shadowModeStatus') }} - @if (getDetail(event, 'shadowModeDays')) { - ({{ getDetail(event, 'shadowModeDays') }}d) - } - - } @else { - - - } - - @if (getDetail(event, 'coverage') !== undefined) { - {{ getDetail(event, 'coverage') }}% - } @else { - - - } -
- - -
- `, - styles: [` - .policy-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; } - .page-header { margin-bottom: 1.5rem; } - .breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; } - .breadcrumb a { color: var(--color-text-link); text-decoration: none; } - h1 { margin: 0 0 0.25rem; } - .description { color: var(--color-text-secondary); margin: 0; } - .event-categories { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } - .event-categories button { padding: 0.5rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); cursor: pointer; } - .event-categories button.active { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-btn-primary-bg); } - .events-table { width: 100%; border-collapse: collapse; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); } - .events-table th, .events-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); } - .events-table th { background: var(--color-surface-elevated); font-weight: var(--font-weight-semibold); font-size: 0.85rem; } - .clickable { cursor: pointer; } - .clickable:hover { background: var(--color-surface-elevated); } - .mono { font-family: monospace; font-size: 0.8rem; } - .hash { max-width: 120px; overflow: hidden; text-overflow: ellipsis; } - .badge { display: inline-block; padding: 0.15rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.75rem; } - .badge.action { background: var(--color-surface-elevated); } - .badge.action.promote { background: var(--color-status-success-bg); color: var(--color-status-success-text); } - .badge.action.approve { background: var(--color-status-info-bg); color: var(--color-status-info-text); } - .badge.action.reject { background: var(--color-status-error-bg); color: var(--color-status-error-text); } - .badge.shadow { background: var(--color-surface-elevated); } - .badge.shadow.active { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); } - .badge.shadow.completed { background: var(--color-status-success-bg); color: var(--color-status-success-text); } - .pagination { display: flex; justify-content: center; margin-top: 1rem; } - .pagination button { padding: 0.5rem 1rem; cursor: pointer; } - .pagination button:disabled { opacity: 0.5; cursor: not-allowed; } - `] -}) -export class AuditPolicyComponent implements OnInit { - private readonly auditClient = inject(AuditLogClient); - - readonly events = signal([]); - readonly cursor = signal(null); - category = 'all'; - - ngOnInit(): void { - this.loadEvents(); - } - - loadEvents(): void { - const filters = this.category !== 'all' ? { actions: [this.category as any] } : undefined; - this.auditClient.getPolicyAudit(filters).subscribe((res) => { - this.events.set(res.items); - this.cursor.set(res.cursor); - }); - } - - filterCategory(cat: string): void { - this.category = cat; - this.loadEvents(); - } - - loadMore(): void { - if (!this.cursor()) return; - const filters = this.category !== 'all' ? { actions: [this.category as any] } : undefined; - this.auditClient.getPolicyAudit(filters, this.cursor()!).subscribe((res) => { - this.events.update((list) => [...list, ...res.items]); - this.cursor.set(res.cursor); - }); - } - - getDetail(event: AuditEvent, key: string): any { - return event.details?.[key]; - } - - truncateHash(hash: string | undefined): string { - if (!hash) return '-'; - return hash.length > 16 ? hash.slice(0, 8) + '...' + hash.slice(-6) : hash; - } - - formatTime(ts: string): string { - return new Date(ts).toLocaleString(); - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts index 3ff61c7ed..a434b36c3 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts @@ -435,13 +435,7 @@ export const policyDecisioningRoutes: Routes = [ pathMatch: 'full', redirectTo: 'policy', }, - { - path: 'policy', - loadComponent: () => - import('../audit-log/audit-policy.component').then( - (m) => m.AuditPolicyComponent, - ), - }, + { path: 'policy', redirectTo: '/ops/operations/audit', pathMatch: 'full' }, { path: 'vex', loadComponent: () => diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.spec.ts deleted file mode 100644 index 32d4eccb6..000000000 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; -import { FormsModule } from '@angular/forms'; - -import { GovernanceAuditComponent } from './governance-audit.component'; - -describe('GovernanceAuditComponent', () => { - let component: GovernanceAuditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GovernanceAuditComponent, FormsModule], - providers: [provideRouter([])], - }).compileComponents(); - - fixture = TestBed.createComponent(GovernanceAuditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should render audit header', () => { - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.audit__header')).toBeTruthy(); - }); - - it('should display filter controls', () => { - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.audit__filters')).toBeTruthy(); - }); - - it('should show audit log entries', () => { - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.audit__table, .audit__list')).toBeTruthy(); - }); - - it('should display entry timestamps', () => { - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.audit__timestamp')).toBeTruthy(); - }); - - it('should show entry actions', () => { - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.audit__action')).toBeTruthy(); - }); - - it('should have export button', () => { - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Export'); - }); -}); 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 deleted file mode 100644 index c5f6c5dda..000000000 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.ts +++ /dev/null @@ -1,899 +0,0 @@ -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 { - POLICY_GOVERNANCE_API, -} from '../../core/api/policy-governance.client'; -import { - GovernanceAuditEvent, - AuditQueryOptions, - AuditResponse, - 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'; - -/** - * Governance Audit component. - * Change history with diff viewer. - * - * @sprint SPRINT_20251229_021a_FE - */ -@Component({ - selector: 'app-governance-audit', - imports: [CommonModule, FormsModule, RouterModule, LoadingStateComponent, StellaFilterChipComponent, AuditPolicyComponent], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
- -
- - - - View all audit events → -
- - @if (auditView() === 'governance') { - -
- - -
- - -
- -
- - -
- -
- - -
- - -
- - - @if (events().length > 0) { -
- @for (event of events(); track event.id) { -
-
-
- - @switch (getEventCategory(event.type)) { - @case ('config') { - - } - @case ('security') { - - } - @case ('profile') { - - } - @default { - - } - } - -
- -
-
{{ formatEventType(event.type) }}
-
{{ event.summary }}
-
- -
-
- {{ event.actorType }} - {{ event.actor }} -
-
{{ event.timestamp | date:'medium' }}
-
- -
- - - -
-
- - @if (expandedEvent() === event.id) { -
-
- Event ID: - {{ event.id }} -
-
- Target Resource: - {{ event.targetResource }} -
-
- Resource Type: - {{ event.targetResourceType }} -
- @if (event.traceId) { -
- Trace ID: - {{ event.traceId }} -
- } - - @if (event.diff) { -
-

Changes

-
- @if (getDiffEntries(event.diff.added).length > 0) { -
-
Added
- @for (entry of getDiffEntries(event.diff.added); track entry[0]) { -
- {{ entry[0] }}: - {{ formatValue(entry[1]) }} -
- } -
- } - - @if (getDiffEntries(event.diff.removed).length > 0) { -
-
Removed
- @for (entry of getDiffEntries(event.diff.removed); track entry[0]) { -
- {{ entry[0] }}: - {{ formatValue(entry[1]) }} -
- } -
- } - - @if (getDiffEntries(event.diff.modified).length > 0) { -
-
Modified
- @for (entry of getDiffEntries(event.diff.modified); track entry[0]) { -
- {{ entry[0] }}: - {{ formatValue(entry[1].before) }} - -> - {{ formatValue(entry[1].after) }} -
- } -
- } -
-
- } @else if (event.previousState || event.newState) { -
-

State Change

-
- @if (event.previousState) { -
-
Before
-
{{ formatValue(event.previousState) }}
-
- } - @if (event.newState) { -
-
After
-
{{ formatValue(event.newState) }}
-
- } -
-
- } -
- } -
- } -
- - - @if (response(); as r) { - - } - } @else if (loading()) { - - } @else { -
- - - -

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; - } - - /* Filters */ - .filters { - display: flex; - flex-wrap: wrap; - gap: 1rem; - align-items: flex-end; - margin-bottom: 1.5rem; - padding: 1rem; - background: var(--color-surface-elevated); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - overflow: visible; - } - - .filter-group { - display: flex; - flex-direction: column; - gap: 0.35rem; - } - - .filter-label { - font-size: 0.8rem; - color: var(--color-text-muted); - } - - .form-input { - padding: 0.5rem 0.75rem; - background: var(--color-surface-elevated); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - color: var(--color-text-primary); - font-size: 0.85rem; - min-width: 150px; - } - - .form-input:focus { - outline: none; - border-color: var(--color-status-info); - } - - .btn { - padding: 0.5rem 1rem; - border-radius: var(--radius-md); - font-size: 0.85rem; - font-weight: var(--font-weight-medium); - cursor: pointer; - transition: all 0.15s ease; - border: none; - } - - .btn--ghost { background: transparent; color: var(--color-text-muted); border: 1px solid var(--color-border-primary); } - .btn--ghost:hover { background: var(--color-surface-tertiary); color: var(--color-text-primary); } - .btn--ghost:disabled { opacity: 0.5; cursor: not-allowed; } - - .btn--small { padding: 0.35rem 0.75rem; font-size: 0.8rem; } - - /* Event List */ - .event-list { - display: flex; - flex-direction: column; - gap: 0.5rem; - } - - .event-card { - background: var(--color-surface-elevated); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - overflow: hidden; - } - - .event-card__header { - display: flex; - align-items: center; - gap: 1rem; - padding: 1rem; - cursor: pointer; - transition: background 0.15s ease; - } - - .event-card__header:hover { - background: var(--color-surface-elevated); - } - - .event-card__icon { - width: 36px; - height: 36px; - border-radius: var(--radius-lg); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - } - - .event-card__icon--config { background: var(--color-status-info-bg); color: var(--color-status-info); } - .event-card__icon--security { background: var(--color-status-warning-bg); color: var(--color-status-warning); } - .event-card__icon--profile { background: var(--color-brand-primary-20); color: var(--color-status-excepted); } - .event-card__icon--other { background: var(--color-surface-tertiary); color: var(--color-text-muted); } - - .event-card__content { - flex: 1; - min-width: 0; - } - - .event-card__type { - font-size: 0.75rem; - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.03em; - } - - .event-card__summary { - color: var(--color-text-heading); - font-weight: var(--font-weight-medium); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .event-card__meta { - text-align: right; - flex-shrink: 0; - } - - .event-card__actor { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.5rem; - font-size: 0.85rem; - color: var(--color-text-primary); - } - - .actor-badge { - font-size: 0.65rem; - padding: 0.1rem 0.35rem; - border-radius: var(--radius-sm); - text-transform: uppercase; - } - - .actor-badge--user { background: var(--color-status-info-text); color: #fff; } - .actor-badge--system { background: var(--color-surface-tertiary); color: var(--color-text-muted); } - .actor-badge--automation { background: var(--color-status-success-text); color: #fff; } - - .event-card__time { - font-size: 0.75rem; - color: var(--color-text-secondary); - } - - .event-card__chevron { - color: var(--color-text-secondary); - } - - .event-card__chevron svg { - transition: transform 0.2s ease; - } - - .event-card__chevron svg.rotated { - transform: rotate(180deg); - } - - /* Event Details */ - .event-card__details { - padding: 1rem; - border-top: 1px solid var(--color-border-primary); - background: var(--color-surface-elevated); - } - - .detail-row { - display: flex; - gap: 1rem; - padding: 0.35rem 0; - font-size: 0.85rem; - } - - .detail-label { - color: var(--color-text-muted); - min-width: 120px; - } - - .detail-value { - color: var(--color-text-primary); - } - - .detail-value--mono { - font-family: monospace; - font-size: 0.8rem; - } - - /* Diff Viewer */ - .diff-section, .state-section { - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid var(--color-border-primary); - } - - .diff-section h4, .state-section h4 { - margin: 0 0 0.75rem; - font-size: 0.9rem; - color: var(--color-text-heading); - } - - .diff-viewer { - display: flex; - flex-direction: column; - gap: 0.75rem; - } - - .diff-group { - background: var(--color-surface-elevated); - border-radius: var(--radius-md); - padding: 0.75rem; - border-left: 3px solid; - } - - .diff-group--added { border-color: var(--color-status-success); } - .diff-group--removed { border-color: var(--color-status-error); } - .diff-group--modified { border-color: var(--color-status-warning); } - - .diff-group__title { - font-size: 0.75rem; - color: var(--color-text-muted); - text-transform: uppercase; - margin-bottom: 0.5rem; - } - - .diff-line { - font-family: monospace; - font-size: 0.8rem; - padding: 0.25rem 0; - } - - .diff-line--added { color: var(--color-status-success-border); } - .diff-line--removed { color: var(--color-status-error-border); } - .diff-line--modified { color: var(--color-status-warning-border); } - - .diff-key { color: var(--color-text-muted); margin-right: 0.5rem; } - .diff-before { color: var(--color-status-error-border); } - .diff-arrow { color: var(--color-text-secondary); margin: 0 0.5rem; } - .diff-after { color: var(--color-status-success-border); } - - /* State Viewer */ - .state-viewer { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - } - - .state-block { - background: var(--color-surface-elevated); - border-radius: var(--radius-md); - overflow: hidden; - } - - .state-block__title { - padding: 0.5rem 0.75rem; - font-size: 0.75rem; - text-transform: uppercase; - background: var(--color-surface-tertiary); - } - - .state-block--before .state-block__title { color: var(--color-status-error-border); } - .state-block--after .state-block__title { color: var(--color-status-success-border); } - - .state-block__content { - padding: 0.75rem; - margin: 0; - font-size: 0.8rem; - color: var(--color-text-primary); - white-space: pre-wrap; - overflow-x: auto; - } - - /* Pagination */ - .pagination { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 1rem; - padding: 1rem; - background: var(--color-surface-elevated); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - } - - .pagination__info { - font-size: 0.85rem; - color: var(--color-text-muted); - } - - .pagination__controls { - display: flex; - align-items: center; - gap: 0.75rem; - } - - .pagination__current { - font-size: 0.85rem; - color: var(--color-text-primary); - } - - /* Empty State */ - .empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 3rem; - color: var(--color-text-muted); - text-align: center; - } - - .empty-state svg { - margin-bottom: 1rem; - color: var(--color-text-secondary); - } - `] -}) -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); - protected readonly expandedEvent = signal(null); - - protected readonly eventTypes: AuditEventType[] = [ - 'budget_threshold_crossed', - 'trust_weight_changed', - 'staleness_config_changed', - 'sealed_mode_toggled', - 'sealed_mode_override_created', - 'profile_created', - 'profile_activated', - 'profile_deprecated', - 'policy_validated', - 'conflict_detected', - 'conflict_resolved', - ]; - - readonly eventTypeOptions = [ - { id: '', label: 'All Types' }, - ...this.eventTypes.map(t => ({ id: t, label: this.formatEventType(t) })), - ]; - - protected filters = { - eventType: '', - actor: '', - startDate: '', - endDate: '', - }; - - ngOnInit(): void { - this.loadEvents(); - } - - private loadEvents(page = 1): void { - this.loading.set(true); - - const options: AuditQueryOptions = { - ...this.governanceScope(), - page, - pageSize: 20, - sortOrder: 'desc', - }; - - if (this.filters.eventType) { - options.eventTypes = [this.filters.eventType as AuditEventType]; - } - if (this.filters.actor) { - options.actor = this.filters.actor; - } - if (this.filters.startDate) { - options.startDate = new Date(this.filters.startDate).toISOString(); - } - if (this.filters.endDate) { - options.endDate = new Date(this.filters.endDate).toISOString(); - } - - this.api - .getAuditEvents(options) - .pipe(finalize(() => this.loading.set(false))) - .subscribe({ - next: (res) => { - const normalized = this.buildSafeResponse(res, page); - this.response.set(normalized); - this.events.set(normalized.events); - }, - error: (err) => console.error('Failed to load audit events:', err), - }); - } - - protected applyFilters(): void { - this.loadEvents(1); - } - - protected clearFilters(): void { - this.filters = { eventType: '', actor: '', startDate: '', endDate: '' }; - this.loadEvents(1); - } - - protected loadPage(page: number): void { - this.loadEvents(page); - } - - protected toggleEvent(eventId: string): void { - this.expandedEvent.set(this.expandedEvent() === eventId ? null : eventId); - } - - protected formatEventType(type: AuditEventType): string { - const labels: Record = { - budget_threshold_crossed: 'Budget Threshold Crossed', - trust_weight_changed: 'Trust Weight Changed', - staleness_config_changed: 'Staleness Config Changed', - sealed_mode_toggled: 'Sealed Mode Toggled', - sealed_mode_override_created: 'Sealed Mode Override Created', - profile_created: 'Profile Created', - profile_activated: 'Profile Activated', - profile_deprecated: 'Profile Deprecated', - policy_validated: 'Policy Validated', - conflict_detected: 'Conflict Detected', - conflict_resolved: 'Conflict Resolved', - }; - return labels[type] || type; - } - - protected getEventCategory(type: AuditEventType): string { - if (type.includes('sealed') || type.includes('trust')) return 'security'; - if (type.includes('profile')) return 'profile'; - if (type.includes('config') || type.includes('budget')) return 'config'; - return 'other'; - } - - protected getDiffEntries(obj: Record): [string, any][] { - return Object.entries(obj); - } - - protected formatValue(value: unknown): string { - if (typeof value === 'object') { - return JSON.stringify(value, null, 2); - } - return String(value); - } - - private buildSafeResponse(payload: unknown, fallbackPage: number): AuditResponse { - const container = this.asRecord(payload); - const eventSource = - Array.isArray(payload) - ? payload - : Array.isArray(container?.['events']) - ? container['events'] - : Array.isArray(container?.['items']) - ? container['items'] - : []; - - const events = eventSource.map((event, index) => this.buildSafeEvent(event, index)); - const page = this.toPositiveNumber(container?.['page'], fallbackPage); - const pageSize = this.toPositiveNumber(container?.['pageSize'], Math.max(events.length, 20)); - const total = this.toPositiveNumber(container?.['total'] ?? container?.['totalCount'], events.length); - const hasMore = - typeof container?.['hasMore'] === 'boolean' ? container['hasMore'] : page * pageSize < total; - - return { - events, - total, - page, - pageSize, - hasMore, - }; - } - - private buildSafeEvent(payload: unknown, index: number): GovernanceAuditEvent { - const record = this.asRecord(payload); - const rawType = this.toString(record?.['type']); - const type = this.toAuditEventType(rawType); - const summary = this.toString(record?.['summary']) || this.formatEventType(type); - const timestamp = this.toString(record?.['timestamp']) || new Date().toISOString(); - const actorType = this.toActorType(record?.['actorType']); - - const event: GovernanceAuditEvent = { - id: this.toString(record?.['id']) || `audit-event-${index + 1}`, - type, - timestamp, - actor: this.toString(record?.['actor']) || 'system', - actorType, - targetResource: this.toString(record?.['targetResource']) || 'unknown', - targetResourceType: this.toString(record?.['targetResourceType']) || 'unknown', - summary, - traceId: this.toString(record?.['traceId']) || undefined, - tenantId: this.toString(record?.['tenantId']) || this.governanceScope().tenantId, - projectId: this.toString(record?.['projectId']) || undefined, - }; - - if (record && 'previousState' in record) { - event.previousState = record['previousState']; - } - if (record && 'newState' in record) { - event.newState = record['newState']; - } - - const diff = this.buildSafeDiff(record?.['diff']); - if (diff) { - event.diff = diff; - } - - return event; - } - - private buildSafeDiff(payload: unknown): GovernanceAuditDiff | undefined { - const record = this.asRecord(payload); - if (!record) { - return undefined; - } - - const added = this.asRecord(record['added']) ?? {}; - const removed = this.asRecord(record['removed']) ?? {}; - const modifiedSource = this.asRecord(record['modified']) ?? {}; - const modified: Record = {}; - - for (const [key, value] of Object.entries(modifiedSource)) { - const entry = this.asRecord(value); - if (entry && ('before' in entry || 'after' in entry)) { - modified[key] = { - before: entry['before'], - after: entry['after'], - }; - continue; - } - - modified[key] = { - before: undefined, - after: value, - }; - } - - if ( - Object.keys(added).length === 0 && - Object.keys(removed).length === 0 && - Object.keys(modified).length === 0 - ) { - return undefined; - } - - return { - added, - removed, - modified, - }; - } - - private toAuditEventType(value: string): AuditEventType { - const type = this.eventTypes.find((candidate) => candidate === value); - return type ?? 'policy_validated'; - } - - private toActorType(value: unknown): GovernanceAuditEvent['actorType'] { - if (value === 'user' || value === 'system' || value === 'automation') { - return value; - } - - return 'system'; - } - - private toPositiveNumber(value: unknown, fallback: number): number { - if (typeof value === 'number' && Number.isFinite(value) && value > 0) { - return Math.floor(value); - } - - if (typeof value === 'string') { - const parsed = Number(value); - if (Number.isFinite(parsed) && parsed > 0) { - return Math.floor(parsed); - } - } - - return fallback; - } - - private toString(value: unknown): string { - if (typeof value === 'string') { - return value; - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - - return ''; - } - - private asRecord(value: unknown): Record | null { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? (value as Record) - : null; - } -} 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 128492e20..0aef86428 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 @@ -8,7 +8,8 @@ import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/co /** * Policy Governance main component with tabbed navigation. - * Rationalized from 10 tabs to 6: Risk Budget, Profiles, Configuration, Conflicts, Developer Tools, Audit. + * Rationalized to 5 tabs: Risk Budget, Profiles, Configuration, Conflicts, Developer Tools. + * Audit consolidated into unified audit page at /ops/operations/audit. * * @sprint SPRINT_20251229_021a_FE */ @@ -18,7 +19,6 @@ const GOVERNANCE_TABS: readonly StellaPageTab[] = [ { 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: '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({ @@ -96,7 +96,6 @@ export class PolicyGovernanceComponent implements OnInit { config: '/ops/policy/governance/config', conflicts: '/ops/policy/governance/conflicts', tools: '/ops/policy/governance/tools', - audit: '/ops/policy/governance/audit', }; private static readonly ROUTE_TO_TAB: Record = { @@ -114,7 +113,6 @@ export class PolicyGovernanceComponent implements OnInit { 'validator': 'tools', 'schema-playground': 'tools', 'schema-docs': 'tools', - 'audit': 'audit', }; private readonly router = inject(Router); @@ -129,7 +127,7 @@ export class PolicyGovernanceComponent implements OnInit { { 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' }, + { label: 'Audit Events', route: '/ops/operations/audit', description: 'Cross-module audit trail' }, ]; protected readonly activeSubtitle = computed(() => { @@ -139,7 +137,6 @@ export class PolicyGovernanceComponent implements OnInit { 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 ee164d3ee..315e13a63 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. - * Rationalized from 10 tabs to 6: Risk Budget, Profiles, Configuration, Conflicts, Developer Tools, Audit. + * Rationalized from 10 tabs to 5: Risk Budget, Profiles, Configuration, Conflicts, Developer Tools. + * Audit consolidated into unified audit page at /ops/operations/audit. * Legacy routes (trust-weights, staleness, sealed-mode, validator, schema-*) redirect to merged panels. * * @sprint SPRINT_20251229_021a_FE @@ -101,12 +102,8 @@ export const policyGovernanceRoutes: Routes = [ { 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), - }, + // ── Audit (redirects to unified audit page) ── + { path: 'audit', redirectTo: '/ops/operations/audit', pathMatch: 'full' }, // ── Impact preview (ancillary, no tab) ── { diff --git a/src/Web/StellaOps.Web/src/app/features/policy/policy.routes.ts b/src/Web/StellaOps.Web/src/app/features/policy/policy.routes.ts index dc70bfdbf..6ce4f0ced 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy/policy.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy/policy.routes.ts @@ -106,11 +106,7 @@ export const POLICY_ROUTES: Routes = [ loadComponent: () => import('../policy-governance/policy-validator.component').then((m) => m.PolicyValidatorComponent), }, - { - path: 'audit', - loadComponent: () => - import('../policy-governance/governance-audit.component').then((m) => m.GovernanceAuditComponent), - }, + { path: 'audit', redirectTo: '/ops/operations/audit', pathMatch: 'full' }, { path: 'conflicts', loadComponent: () => diff --git a/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts b/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts index 23f8e1c02..fe22031f6 100644 --- a/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts @@ -19,24 +19,9 @@ import { Routes } from '@angular/router'; * /evidence/audit-log - Audit log */ export const EVIDENCE_ROUTES: Routes = [ - { - path: '', - title: 'Evidence Overview', - data: { breadcrumb: 'Overview' }, - loadComponent: () => - import('../features/evidence-audit/evidence-audit-overview.component').then( - (m) => m.EvidenceAuditOverviewComponent, - ), - }, - { - path: 'overview', - title: 'Evidence Overview', - data: { breadcrumb: 'Overview' }, - loadComponent: () => - import('../features/evidence-audit/evidence-audit-overview.component').then( - (m) => m.EvidenceAuditOverviewComponent, - ), - }, + // Evidence overview and capsules list consolidated into Operations → Audit + { path: '', redirectTo: '/ops/operations/audit', pathMatch: 'full' }, + { path: 'overview', redirectTo: '/ops/operations/audit', pathMatch: 'full' }, { path: 'threads', title: 'Evidence Threads', @@ -58,13 +43,8 @@ export const EVIDENCE_ROUTES: Routes = [ loadChildren: () => import('../features/workspaces/developer/developer-workspace.routes').then((m) => m.DEVELOPER_WORKSPACE_ROUTES), }, - { - path: 'capsules', - title: 'Decision Capsules', - data: { breadcrumb: 'Capsules' }, - loadComponent: () => - import('../features/evidence-pack/evidence-pack-list.component').then((m) => m.EvidencePackListComponent), - }, + // Capsule list consolidated into Audit events tab; detail viewer stays + { path: 'capsules', redirectTo: '/ops/operations/audit?tab=all-events', pathMatch: 'full' }, { path: 'capsules/:capsuleId', title: 'Decision Capsule', @@ -94,6 +74,8 @@ export const EVIDENCE_ROUTES: Routes = [ loadChildren: () => import('../features/evidence-export/evidence-export.routes').then((m) => m.evidenceExportRoutes), }, + // Redirect old export/audit-log paths to consolidated Audit page + { path: 'audit-log/export', redirectTo: '/ops/operations/audit?tab=exports', pathMatch: 'full' }, { path: 'proof-chain', title: 'Proof Chain', diff --git a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts index 38f19d8ac..6845e4a5b 100644 --- a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts @@ -25,6 +25,12 @@ export const OPERATIONS_ROUTES: Routes = [ (m) => m.PlatformFeedsAirgapPageComponent, ), }, + { + path: 'feeds', + data: { breadcrumb: 'Feeds' }, + loadChildren: () => + import('../features/feed-mirror/feed-mirror.routes').then((m) => m.feedMirrorRoutes), + }, { path: 'data-integrity', title: 'Data Integrity', @@ -248,6 +254,16 @@ export const OPERATIONS_ROUTES: Routes = [ (m) => m.TopologyAgentGroupDetailPageComponent, ), }, + // Audit event detail (deep link) + { + path: 'audit/events/:eventId', + title: 'Audit Event Detail', + data: { breadcrumb: 'Audit Event' }, + loadComponent: () => + import('../features/audit-log/audit-event-detail.component').then( + (m) => m.AuditEventDetailComponent, + ), + }, { path: 'drift', title: 'Runtime Drift', @@ -266,4 +282,13 @@ export const OPERATIONS_ROUTES: Routes = [ (m) => m.PendingDeletionsPanelComponent, ), }, + { + path: 'audit', + title: 'Audit', + data: { breadcrumb: 'Audit' }, + loadComponent: () => + import('../features/audit-log/audit-log-dashboard.component').then( + (m) => m.AuditLogDashboardComponent, + ), + }, ];