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) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-07 15:33:32 +03:00
parent 3a95f315bd
commit 8beed2afb4
14 changed files with 441 additions and 1213 deletions

View File

@@ -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<string, unknown>;
removed?: Record<string, unknown>;
modified?: Record<string, { before: unknown; after: unknown }>;
}
/** Full audit event record */

View File

@@ -102,26 +102,100 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
<pre class="json-block">{{ (event()?.details ?? {}) | json }}</pre>
</div>
@if (event()?.module === 'policy') {
<div class="policy-details-section">
<h3>Policy Details</h3>
<div class="detail-grid">
@if (event()?.details?.['packName'] || event()?.details?.['packId']) {
<div class="detail-item">
<span class="label">Pack</span>
<span class="value">{{ event()?.details?.['packName'] || event()?.details?.['packId'] }}</span>
</div>
}
@if (event()?.details?.['policyHash']) {
<div class="detail-item">
<span class="label">Policy Hash</span>
<span class="value mono">{{ event()?.details?.['policyHash'] }}</span>
</div>
}
@if (event()?.details?.['shadowModeStatus']) {
<div class="detail-item">
<span class="label">Shadow Mode</span>
<span class="value">{{ event()?.details?.['shadowModeStatus'] }}
@if (event()?.details?.['shadowModeDays']) { ({{ event()?.details?.['shadowModeDays'] }}d) }
</span>
</div>
}
@if (event()?.details?.['coverage'] !== undefined && event()?.details?.['coverage'] !== null) {
<div class="detail-item">
<span class="label">Coverage</span>
<span class="value">{{ event()?.details?.['coverage'] }}%</span>
</div>
}
</div>
</div>
}
@if (event()?.diff) {
<div class="diff-section">
<h3>Configuration Diff</h3>
<div class="diff-container">
<div class="diff-pane before">
<h4>Before</h4>
<pre>{{ (event()?.diff?.before ?? {}) | json }}</pre>
</div>
<div class="diff-pane after">
<h4>After</h4>
<pre>{{ (event()?.diff?.after ?? {}) | json }}</pre>
</div>
</div>
@if (event()?.diff?.fields?.length) {
<div class="changed-fields">
<strong>Changed fields:</strong>
@for (field of event()?.diff?.fields; track field) {
<span class="field-badge">{{ field }}</span>
@if (hasGovernanceDiff()) {
<div class="governance-diff">
@if (govDiffEntries(event()?.diff?.added).length > 0) {
<div class="diff-group diff-group--added">
<div class="diff-group__title">Added</div>
@for (entry of govDiffEntries(event()?.diff?.added); track entry[0]) {
<div class="diff-line diff-line--added">
<span class="diff-key">{{ entry[0] }}:</span>
<span class="diff-val">{{ fmtDiffVal(entry[1]) }}</span>
</div>
}
</div>
}
@if (govDiffEntries(event()?.diff?.removed).length > 0) {
<div class="diff-group diff-group--removed">
<div class="diff-group__title">Removed</div>
@for (entry of govDiffEntries(event()?.diff?.removed); track entry[0]) {
<div class="diff-line diff-line--removed">
<span class="diff-key">{{ entry[0] }}:</span>
<span class="diff-val">{{ fmtDiffVal(entry[1]) }}</span>
</div>
}
</div>
}
@if (govDiffEntries(event()?.diff?.modified).length > 0) {
<div class="diff-group diff-group--modified">
<div class="diff-group__title">Modified</div>
@for (entry of govDiffEntries(event()?.diff?.modified); track entry[0]) {
<div class="diff-line diff-line--modified">
<span class="diff-key">{{ entry[0] }}:</span>
<span class="diff-before">{{ fmtDiffVal(entry[1]?.before) }}</span>
<span class="diff-arrow">&rarr;</span>
<span class="diff-after">{{ fmtDiffVal(entry[1]?.after) }}</span>
</div>
}
</div>
}
</div>
} @else {
<div class="diff-container">
<div class="diff-pane before">
<h4>Before</h4>
<pre>{{ (event()?.diff?.before ?? {}) | json }}</pre>
</div>
<div class="diff-pane after">
<h4>After</h4>
<pre>{{ (event()?.diff?.after ?? {}) | json }}</pre>
</div>
</div>
@if (event()?.diff?.fields?.length) {
<div class="changed-fields">
<strong>Changed fields:</strong>
@for (field of event()?.diff?.fields; track field) {
<span class="field-badge">{{ field }}</span>
}
</div>
}
}
</div>
}
@@ -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<string, unknown> | 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);
}

View File

@@ -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: `
<div class="audit-dashboard">
<header class="page-header">
<div>
<h1>Audit & Compliance</h1>
<p class="description">Cross-module audit trail, anomaly detection, timeline search, and event correlation.</p>
<h1>Audit</h1>
<p class="description">Cross-module audit trail, anomaly detection, evidence exports, and compliance bundles.</p>
</div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
@@ -163,6 +169,8 @@ const AUDIT_TABS: StellaPageTab[] = [
@case ('all-events') { <app-audit-log-table /> }
@case ('timeline') { <app-audit-timeline-search /> }
@case ('correlations') { <app-audit-correlations /> }
@case ('exports') { <app-export-center /> }
@case ('bundles') { <app-triage-audit-bundles /> }
}
</stella-page-tabs>
</div>
@@ -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' },
];

View File

@@ -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<PolicyCategory, AuditAction[] | null> = {
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) {
<div class="module-context-link">
@switch (selectedModules[0]) {
@case ('policy') { <a routerLink="/ops/policy/governance" [queryParams]="{tab: 'audit'}">View in Policy Governance →</a> }
@case ('authority') { <a routerLink="/console/admin" [queryParams]="{tab: 'audit'}">View in Console Admin →</a> }
@case ('vex') { <a routerLink="/ops/policy/vex/explorer" [queryParams]="{tab: 'audit'}">View in VEX Hub →</a> }
@case ('integrations') { <a routerLink="/integrations" [queryParams]="{tab: 'config-audit'}">View in Integration Hub →</a> }
@case ('jobengine') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs </a> }
@case ('scheduler') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs →</a> }
@case ('scanner') { <a routerLink="/platform-ops/scanner" [queryParams]="{tab: 'audit'}">View in Scanner Ops →</a> }
@case ('authority') { <a routerLink="/console/admin" [queryParams]="{tab: 'audit'}">View in Console Admin &rarr;</a> }
@case ('vex') { <a routerLink="/ops/policy/vex/explorer" [queryParams]="{tab: 'audit'}">View in VEX Hub &rarr;</a> }
@case ('integrations') { <a routerLink="/integrations" [queryParams]="{tab: 'config-audit'}">View in Integration Hub &rarr;</a> }
@case ('jobengine') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs &rarr;</a> }
@case ('scheduler') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs &rarr;</a> }
@case ('scanner') { <a routerLink="/platform-ops/scanner" [queryParams]="{tab: 'audit'}">View in Scanner Ops &rarr;</a> }
}
</div>
}
@@ -37,7 +47,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
<div class="filter-row">
<div class="filter-group">
<label>Modules</label>
<select multiple [(ngModel)]="selectedModules" (change)="applyFilters()">
<select multiple [(ngModel)]="selectedModules" (change)="onModuleChange()">
@for (m of allModules; track m) {
<option [value]="m">{{ formatModule(m) }}</option>
}
@@ -45,7 +55,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
</div>
<div class="filter-group">
<label>Actions</label>
<select multiple [(ngModel)]="selectedActions" (change)="applyFilters()">
<select multiple [(ngModel)]="selectedActions" (change)="applyFilters()" [disabled]="isPolicyOnly && policyCategory !== 'all'">
@for (a of allActions; track a) {
<option [value]="a">{{ a }}</option>
}
@@ -94,6 +104,18 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
</div>
</div>
@if (isPolicyOnly) {
<div class="policy-category-chips">
@for (cat of policyCategoryList; track cat.id) {
<button
class="category-chip"
[class.category-chip--active]="policyCategory === cat.id"
(click)="setPolicyCategory(cat.id)"
>{{ cat.label }}</button>
}
</div>
}
@if (loading()) {
<div class="loading">Loading events...</div>
}
@@ -102,12 +124,20 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
<thead>
<tr>
<th>Timestamp (UTC)</th>
<th>Module</th>
@if (!isPolicyOnly) { <th>Module</th> }
<th>Action</th>
<th>Severity</th>
@if (isPolicyOnly) {
<th>Pack</th>
<th>Policy Hash</th>
}
<th>Actor</th>
<th>Resource</th>
<th>Description</th>
@if (isPolicyOnly) {
<th>Shadow Mode</th>
<th>Coverage</th>
}
@if (!isPolicyOnly) { <th>Description</th> }
<th></th>
</tr>
</thead>
@@ -115,9 +145,15 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
@for (event of events(); track event.id) {
<tr [class]="event.severity" (click)="selectEvent(event)" [class.selected]="selectedEvent()?.id === event.id">
<td class="mono">{{ formatTimestamp(event.timestamp) }}</td>
<td><span class="badge module" [class]="event.module">{{ formatModule(event.module) }}</span></td>
@if (!isPolicyOnly) {
<td><span class="badge module" [class]="event.module">{{ formatModule(event.module) }}</span></td>
}
<td><span class="badge action" [class]="event.action">{{ event.action }}</span></td>
<td><span class="badge severity" [class]="event.severity">{{ event.severity }}</span></td>
@if (isPolicyOnly) {
<td>{{ getDetail(event, 'packName') || getDetail(event, 'packId') || '-' }}</td>
<td class="mono hash">{{ truncateHash(getDetail(event, 'policyHash')) }}</td>
}
<td>
<span class="actor" [title]="event.actor.email || ''">
{{ event.actor.name }}
@@ -125,7 +161,26 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
</span>
</td>
<td class="resource">{{ event.resource.type }}: {{ event.resource.name || event.resource.id }}</td>
<td class="description">{{ event.description }}</td>
@if (isPolicyOnly) {
<td>
@if (getDetail(event, 'shadowModeStatus')) {
<span class="badge shadow" [class]="getDetail(event, 'shadowModeStatus')">
{{ getDetail(event, 'shadowModeStatus') }}
@if (getDetail(event, 'shadowModeDays')) {
({{ getDetail(event, 'shadowModeDays') }}d)
}
</span>
} @else { - }
</td>
<td>
@if (getDetail(event, 'coverage') !== undefined && getDetail(event, 'coverage') !== null) {
{{ getDetail(event, 'coverage') }}%
} @else { - }
</td>
}
@if (!isPolicyOnly) {
<td class="description">{{ event.description }}</td>
}
<td>
<a [routerLink]="[event.id]" class="link">View</a>
@if (event.diff) {
@@ -134,7 +189,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
</td>
</tr>
} @empty {
<tr><td colspan="8" style="text-align:center;padding:2rem;color:var(--color-text-muted)">No events match the current filters.</td></tr>
<tr><td [attr.colspan]="isPolicyOnly ? 10 : 8" style="text-align:center;padding:2rem;color:var(--color-text-muted)">No events match the current filters.</td></tr>
}
</tbody>
</table>
@@ -229,23 +284,66 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
<span>{{ diffEvent()?.resource?.type }}: {{ diffEvent()?.resource?.name || diffEvent()?.resource?.id }}</span>
<span>Changed by {{ diffEvent()?.actor?.name }} at {{ formatTimestamp(diffEvent()?.timestamp!) }}</span>
</div>
<div class="diff-container">
<div class="diff-pane before">
<h4>Before</h4>
<pre>{{ (diffEvent()?.diff?.before ?? {}) | json }}</pre>
</div>
<div class="diff-pane after">
<h4>After</h4>
<pre>{{ (diffEvent()?.diff?.after ?? {}) | json }}</pre>
</div>
</div>
@if (diffEvent()?.diff?.fields?.length) {
<div class="changed-fields">
<strong>Changed fields:</strong>
@for (field of diffEvent()?.diff?.fields; track field) {
<span class="field-badge">{{ field }}</span>
@if (hasGovernanceDiff(diffEvent()!)) {
<!-- Governance-style structured diff -->
<div class="governance-diff">
@if (diffEntries(diffEvent()?.diff?.added).length > 0) {
<div class="diff-group diff-group--added">
<div class="diff-group__title">Added</div>
@for (entry of diffEntries(diffEvent()?.diff?.added); track entry[0]) {
<div class="diff-line diff-line--added">
<span class="diff-key">{{ entry[0] }}:</span>
<span class="diff-val">{{ formatDiffValue(entry[1]) }}</span>
</div>
}
</div>
}
@if (diffEntries(diffEvent()?.diff?.removed).length > 0) {
<div class="diff-group diff-group--removed">
<div class="diff-group__title">Removed</div>
@for (entry of diffEntries(diffEvent()?.diff?.removed); track entry[0]) {
<div class="diff-line diff-line--removed">
<span class="diff-key">{{ entry[0] }}:</span>
<span class="diff-val">{{ formatDiffValue(entry[1]) }}</span>
</div>
}
</div>
}
@if (diffEntries(diffEvent()?.diff?.modified).length > 0) {
<div class="diff-group diff-group--modified">
<div class="diff-group__title">Modified</div>
@for (entry of diffEntries(diffEvent()?.diff?.modified); track entry[0]) {
<div class="diff-line diff-line--modified">
<span class="diff-key">{{ entry[0] }}:</span>
<span class="diff-before">{{ formatDiffValue(entry[1]?.before) }}</span>
<span class="diff-arrow">&rarr;</span>
<span class="diff-after">{{ formatDiffValue(entry[1]?.after) }}</span>
</div>
}
</div>
}
</div>
} @else {
<!-- Standard before/after diff -->
<div class="diff-container">
<div class="diff-pane before">
<h4>Before</h4>
<pre>{{ (diffEvent()?.diff?.before ?? {}) | json }}</pre>
</div>
<div class="diff-pane after">
<h4>After</h4>
<pre>{{ (diffEvent()?.diff?.after ?? {}) | json }}</pre>
</div>
</div>
@if (diffEvent()?.diff?.fields?.length) {
<div class="changed-fields">
<strong>Changed fields:</strong>
@for (field of diffEvent()?.diff?.fields; track field) {
<span class="field-badge">{{ field }}</span>
}
</div>
}
}
</div>
</div>
@@ -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<AuditEvent[]>([]);
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<string, unknown> | 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);
}

View File

@@ -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' },

View File

@@ -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: `
<div class="policy-audit-page">
<header class="page-header">
<div class="breadcrumb">
<a routerLink="/evidence/audit-log">Audit Log</a> / Policy Audit
</div>
<h1>Policy Audit Events</h1>
<p class="description">Policy promotions, simulations, approvals, and lint events</p>
</header>
<div class="event-categories">
<button [class.active]="category === 'all'" (click)="filterCategory('all')">All</button>
<button [class.active]="category === 'promote'" (click)="filterCategory('promote')">Promotions</button>
<button [class.active]="category === 'approve'" (click)="filterCategory('approve')">Approvals</button>
<button [class.active]="category === 'reject'" (click)="filterCategory('reject')">Rejections</button>
<button [class.active]="category === 'simulate'" (click)="filterCategory('simulate')">Simulations</button>
</div>
<table class="events-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Pack</th>
<th>Policy Hash</th>
<th>Actor</th>
<th>Shadow Mode</th>
<th>Coverage</th>
</tr>
</thead>
<tbody>
@for (event of events(); track event.id) {
<tr [routerLink]="['/evidence/audit-log/events', event.id]" class="clickable">
<td class="mono">{{ formatTime(event.timestamp) }}</td>
<td><span class="badge action" [class]="event.action">{{ event.action }}</span></td>
<td>{{ getDetail(event, 'packName') || getDetail(event, 'packId') || '-' }}</td>
<td class="mono hash">{{ truncateHash(getDetail(event, 'policyHash')) }}</td>
<td>{{ event.actor.name }}</td>
<td>
@if (getDetail(event, 'shadowModeStatus')) {
<span class="badge shadow" [class]="getDetail(event, 'shadowModeStatus')">
{{ getDetail(event, 'shadowModeStatus') }}
@if (getDetail(event, 'shadowModeDays')) {
({{ getDetail(event, 'shadowModeDays') }}d)
}
</span>
} @else {
-
}
</td>
<td>
@if (getDetail(event, 'coverage') !== undefined) {
{{ getDetail(event, 'coverage') }}%
} @else {
-
}
</td>
</tr>
}
</tbody>
</table>
<div class="pagination">
<button [disabled]="!cursor()" (click)="loadMore()">Load More</button>
</div>
</div>
`,
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<AuditEvent[]>([]);
readonly cursor = signal<string | null>(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();
}
}

View File

@@ -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: () =>

View File

@@ -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<GovernanceAuditComponent>;
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');
});
});

View File

@@ -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: `
<div class="audit" [attr.aria-busy]="loading()">
<!-- Sub-view toggle -->
<div class="audit-view-toggle">
<button
class="audit-view-chip"
[class.audit-view-chip--active]="auditView() === 'governance'"
(click)="auditView.set('governance')"
>Governance Changes</button>
<button
class="audit-view-chip"
[class.audit-view-chip--active]="auditView() === 'promotions'"
(click)="auditView.set('promotions')"
>Promotions &amp; Approvals</button>
<a class="audit-cross-link" routerLink="/evidence/audit-log" [queryParams]="{tab: 'all-events', module: 'policy'}">View all audit events &rarr;</a>
</div>
@if (auditView() === 'governance') {
<!-- Filters -->
<div class="filters">
<stella-filter-chip
label="Event Type"
[value]="filters.eventType"
[options]="eventTypeOptions"
(valueChange)="filters.eventType = $event; applyFilters()"
/>
<div class="filter-group">
<label class="filter-label">Actor</label>
<input type="text" [(ngModel)]="filters.actor" (input)="applyFilters()" class="form-input" placeholder="Search actor..." />
</div>
<div class="filter-group">
<label class="filter-label">Start Date</label>
<input type="date" [(ngModel)]="filters.startDate" (change)="applyFilters()" class="form-input" />
</div>
<div class="filter-group">
<label class="filter-label">End Date</label>
<input type="date" [(ngModel)]="filters.endDate" (change)="applyFilters()" class="form-input" />
</div>
<button class="btn btn--ghost" (click)="clearFilters()">Clear Filters</button>
</div>
<!-- Event List -->
@if (events().length > 0) {
<div class="event-list">
@for (event of events(); track event.id) {
<div class="event-card" [class.event-card--expanded]="expandedEvent() === event.id">
<div class="event-card__header" (click)="toggleEvent(event.id)">
<div class="event-card__icon" [class]="'event-card__icon--' + getEventCategory(event.type)">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="18" height="18">
@switch (getEventCategory(event.type)) {
@case ('config') {
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
}
@case ('security') {
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
}
@case ('profile') {
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
}
@default {
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
}
}
</svg>
</div>
<div class="event-card__content">
<div class="event-card__type">{{ formatEventType(event.type) }}</div>
<div class="event-card__summary" [title]="event.summary">{{ event.summary }}</div>
</div>
<div class="event-card__meta">
<div class="event-card__actor">
<span class="actor-badge" [class]="'actor-badge--' + event.actorType">{{ event.actorType }}</span>
{{ event.actor }}
</div>
<div class="event-card__time">{{ event.timestamp | date:'medium' }}</div>
</div>
<div class="event-card__chevron">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="20" height="20"
[class.rotated]="expandedEvent() === event.id">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</div>
</div>
@if (expandedEvent() === event.id) {
<div class="event-card__details">
<div class="detail-row">
<span class="detail-label">Event ID:</span>
<span class="detail-value detail-value--mono">{{ event.id }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Target Resource:</span>
<span class="detail-value detail-value--mono">{{ event.targetResource }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Resource Type:</span>
<span class="detail-value">{{ event.targetResourceType }}</span>
</div>
@if (event.traceId) {
<div class="detail-row">
<span class="detail-label">Trace ID:</span>
<span class="detail-value detail-value--mono">{{ event.traceId }}</span>
</div>
}
@if (event.diff) {
<div class="diff-section">
<h4>Changes</h4>
<div class="diff-viewer">
@if (getDiffEntries(event.diff.added).length > 0) {
<div class="diff-group diff-group--added">
<div class="diff-group__title">Added</div>
@for (entry of getDiffEntries(event.diff.added); track entry[0]) {
<div class="diff-line diff-line--added">
<span class="diff-key">{{ entry[0] }}:</span>
<span class="diff-value">{{ formatValue(entry[1]) }}</span>
</div>
}
</div>
}
@if (getDiffEntries(event.diff.removed).length > 0) {
<div class="diff-group diff-group--removed">
<div class="diff-group__title">Removed</div>
@for (entry of getDiffEntries(event.diff.removed); track entry[0]) {
<div class="diff-line diff-line--removed">
<span class="diff-key">{{ entry[0] }}:</span>
<span class="diff-value">{{ formatValue(entry[1]) }}</span>
</div>
}
</div>
}
@if (getDiffEntries(event.diff.modified).length > 0) {
<div class="diff-group diff-group--modified">
<div class="diff-group__title">Modified</div>
@for (entry of getDiffEntries(event.diff.modified); track entry[0]) {
<div class="diff-line diff-line--modified">
<span class="diff-key">{{ entry[0] }}:</span>
<span class="diff-before">{{ formatValue(entry[1].before) }}</span>
<span class="diff-arrow">-></span>
<span class="diff-after">{{ formatValue(entry[1].after) }}</span>
</div>
}
</div>
}
</div>
</div>
} @else if (event.previousState || event.newState) {
<div class="state-section">
<h4>State Change</h4>
<div class="state-viewer">
@if (event.previousState) {
<div class="state-block state-block--before">
<div class="state-block__title">Before</div>
<pre class="state-block__content">{{ formatValue(event.previousState) }}</pre>
</div>
}
@if (event.newState) {
<div class="state-block state-block--after">
<div class="state-block__title">After</div>
<pre class="state-block__content">{{ formatValue(event.newState) }}</pre>
</div>
}
</div>
</div>
}
</div>
}
</div>
}
</div>
<!-- Pagination -->
@if (response(); as r) {
<div class="pagination">
<span class="pagination__info">Showing {{ events().length }} of {{ r.total }} events</span>
<div class="pagination__controls">
<button
class="btn btn--ghost btn--small"
(click)="loadPage(r.page - 1)"
[disabled]="r.page <= 1"
>Previous</button>
<span class="pagination__current">Page {{ r.page }}</span>
<button
class="btn btn--ghost btn--small"
(click)="loadPage(r.page + 1)"
[disabled]="!r.hasMore"
>Next</button>
</div>
</div>
}
} @else if (loading()) {
<app-loading-state message="Loading audit events..." />
} @else {
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="48" height="48">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<p>No audit events found matching your filters.</p>
</div>
}
}
@if (auditView() === 'promotions') {
<app-audit-policy />
}
</div>
`,
styles: [`
:host { display: block; }
/* Sub-view toggle chips */
.audit-view-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.audit-view-chip {
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-muted);
transition: all 0.15s ease;
}
.audit-view-chip:hover {
background: var(--color-surface-tertiary);
color: var(--color-text-primary);
}
.audit-view-chip--active {
background: var(--color-brand-primary);
color: #fff;
border-color: var(--color-brand-primary);
}
.audit-view-chip--active:hover {
background: var(--color-brand-primary);
color: #fff;
}
.audit-cross-link {
margin-left: auto;
font-size: 0.85rem;
color: var(--color-text-link);
text-decoration: none;
}
.audit-cross-link:hover {
text-decoration: underline;
}
.audit {
padding: 1.5rem;
}
/* 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<GovernanceAuditEvent[]>([]);
protected readonly response = signal<AuditResponse | null>(null);
protected readonly expandedEvent = signal<string | null>(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<AuditEventType, string> = {
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, unknown>): [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<string, { before: unknown; after: unknown }> = {};
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<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
}

View File

@@ -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<string, string> = {
@@ -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.';
}
});

View File

@@ -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) ──
{

View File

@@ -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: () =>

View File

@@ -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',

View File

@@ -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,
),
},
];