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:
@@ -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 */
|
||||
|
||||
@@ -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">→</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);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 →</a> }
|
||||
@case ('vex') { <a routerLink="/ops/policy/vex/explorer" [queryParams]="{tab: 'audit'}">View in VEX Hub →</a> }
|
||||
@case ('integrations') { <a routerLink="/integrations" [queryParams]="{tab: 'config-audit'}">View in Integration Hub →</a> }
|
||||
@case ('jobengine') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs →</a> }
|
||||
@case ('scheduler') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs →</a> }
|
||||
@case ('scanner') { <a routerLink="/platform-ops/scanner" [queryParams]="{tab: 'audit'}">View in Scanner Ops →</a> }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -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">→</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);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 & Approvals</button>
|
||||
|
||||
<a class="audit-cross-link" routerLink="/evidence/audit-log" [queryParams]="{tab: 'all-events', module: 'policy'}">View all audit events →</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;
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) ──
|
||||
{
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user