diff --git a/src/Web/StellaOps.Web/src/app/core/api/audit-log.models.ts b/src/Web/StellaOps.Web/src/app/core/api/audit-log.models.ts
index f1ba39657..b52a32db2 100644
--- a/src/Web/StellaOps.Web/src/app/core/api/audit-log.models.ts
+++ b/src/Web/StellaOps.Web/src/app/core/api/audit-log.models.ts
@@ -10,7 +10,12 @@ export type AuditModule =
| 'scanner'
| 'attestor'
| 'sbom'
- | 'scheduler';
+ | 'scheduler'
+ | 'release'
+ | 'doctor'
+ | 'signals'
+ | 'advisory-ai'
+ | 'riskengine';
/** Audit event action types */
export type AuditAction =
diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts
index 25bf4dd6a..39475ef5d 100644
--- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts
@@ -1,13 +1,14 @@
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
-import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
+import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { AuditLogClient } from '../../core/api/audit-log.client';
-import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.models';
+import { AuditEvent, AuditCorrelationCluster, AuditDiff } from '../../core/api/audit-log.models';
+import { AuditEventDetailsPanelComponent } from './audit-event-details-panel.component';
@Component({
selector: 'app-audit-event-detail',
- imports: [CommonModule, RouterModule],
+ imports: [CommonModule, RouterModule, AuditEventDetailsPanelComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@@ -99,7 +100,7 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
Details
-
{{ (event()?.details ?? {}) | json }}
+
@if (event()?.module === 'policy') {
@@ -136,9 +137,9 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
}
- @if (event()?.diff) {
+ @if (event()?.diff || syntheticDiff()) {
-
Configuration Diff
+
{{ event()?.diff ? 'Configuration Diff' : 'State at Time of Action' }}
@if (hasGovernanceDiff()) {
@if (govDiffEntries(event()?.diff?.added).length > 0) {
@@ -178,23 +179,27 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
}
} @else {
-
-
-
Before
-
{{ (event()?.diff?.before ?? {}) | json }}
-
-
-
After
-
{{ (event()?.diff?.after ?? {}) | json }}
-
-
- @if (event()?.diff?.fields?.length) {
-
-
Changed fields:
- @for (field of event()?.diff?.fields; track field) {
-
{{ field }}
+ @if (activeDiff(); as diff) {
+
+
+
{{ event()?.diff ? 'Before' : 'State at time of action' }}
+
{{ (diff.before ?? {}) | json }}
+
+ @if (diff.after !== null && diff.after !== undefined) {
+
+
After
+
{{ (diff.after ?? {}) | json }}
+
}
+ @if (diff.fields?.length) {
+
+ {{ event()?.diff ? 'Changed fields:' : 'Fields captured:' }}
+ @for (field of diff.fields; track field) {
+ {{ field }}
+ }
+
+ }
}
}
@@ -264,6 +269,12 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
.badge.module.policy { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.badge.module.authority { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.module.vex { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
+ .badge.module.release { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
+ .badge.module.attestor { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
+ .badge.module.doctor { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
+ .badge.module.signals { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
+ .badge.module.advisory-ai { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
+ .badge.module.riskengine { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.action { background: var(--color-surface-elevated); }
.badge.action.create { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.action.update { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
@@ -323,6 +334,26 @@ export class AuditEventDetailComponent implements OnInit {
readonly event = signal
(null);
readonly correlation = signal(null);
+ /** Synthetic diff auto-constructed from details.beforeState when event.diff is absent */
+ readonly syntheticDiff = computed(() => {
+ const ev = this.event();
+ if (!ev || ev.diff) return null;
+ const beforeState = ev.details?.['beforeState'];
+ if (!beforeState || typeof beforeState !== 'object') return null;
+ const responseBody = ev.details?.['requestBody'];
+ const after = (responseBody && typeof responseBody === 'object') ? responseBody : null;
+ return {
+ before: beforeState,
+ after: after ?? undefined,
+ fields: Object.keys(beforeState as Record),
+ };
+ });
+
+ /** Returns the real diff or the synthetic diff, whichever is active */
+ readonly activeDiff = computed(() => {
+ return this.event()?.diff ?? this.syntheticDiff();
+ });
+
ngOnInit(): void {
this.route.params.subscribe((params) => {
const eventId = params['eventId'];
diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-details-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-details-panel.component.ts
new file mode 100644
index 000000000..9634e53b6
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-details-panel.component.ts
@@ -0,0 +1,453 @@
+import { Component, Input, ChangeDetectionStrategy, signal, computed } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { AuditEvent, AuditDiff } from '../../core/api/audit-log.models';
+
+@Component({
+ selector: 'app-audit-event-details-panel',
+ standalone: true,
+ imports: [CommonModule],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+ @if (event) {
+
+ @if (hasHttpContext) {
+
+ HTTP Context
+
+
+ {{ event.details?.['httpMethod'] }}
+ →
+ {{ event.details?.['statusCode'] }} {{ httpStatusText }}
+
+ @if (event.details?.['path']) {
+
+ Path
+ {{ event.details?.['path'] }}
+
+ }
+ @if (event.details?.['responseResourceId']) {
+
+ Response Resource
+ {{ event.details?.['responseResourceId'] }}
+
+ }
+
+
+ }
+
+
+ @if (hasRequestBody) {
+
+
+ 5">
+ @for (entry of requestBodyEntries; track entry[0]) {
+
+ {{ entry[0] }}
+ @if (isRedacted(entry[1])) {
+ {{ entry[1] }}
+ } @else {
+ {{ formatValue(entry[1]) }}
+ }
+
+ }
+
+
+ }
+
+
+ @if (hasBeforeState) {
+
+ State at Time of Action
+
+ @for (entry of beforeStateEntries; track entry[0]) {
+
+ {{ entry[0] }}
+ {{ formatValue(entry[1]) }}
+
+ }
+
+
+ }
+
+
+ @if (syntheticDiff) {
+
+ {{ syntheticDiffLabel }}
+
+
+
{{ hasBothStates ? 'Before' : 'State at time of action' }}
+
{{ syntheticDiff.before | json }}
+
+ @if (syntheticDiff.after !== null && syntheticDiff.after !== undefined) {
+
+
After
+
{{ syntheticDiff.after | json }}
+
+ }
+
+ @if (syntheticDiff.fields?.length) {
+
+ Fields captured:
+ @for (field of syntheticDiff.fields; track field) {
+ {{ field }}
+ }
+
+ }
+
+ }
+
+
+ @if (remainingDetails.length > 0) {
+
+
+ @if (rawExpanded()) {
+
+ @for (entry of remainingDetails; track entry[0]) {
+
+ {{ entry[0] }}
+ {{ formatValue(entry[1]) }}
+
+ }
+
+ }
+
+ }
+ }
+ `,
+ styles: [`
+ .details-section {
+ margin-bottom: 1.25rem;
+ background: var(--color-surface-primary);
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ }
+ .section-title {
+ margin: 0;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.8rem;
+ font-weight: var(--font-weight-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ background: var(--color-surface-elevated);
+ border-bottom: 1px solid var(--color-border-primary);
+ }
+ .section-header-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: var(--color-surface-elevated);
+ border-bottom: 1px solid var(--color-border-primary);
+ }
+ .section-actions {
+ display: flex;
+ gap: 0.35rem;
+ padding-right: 0.5rem;
+ }
+
+ /* HTTP Context */
+ .http-context { padding: 0.75rem; }
+ .http-summary {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+ font-size: 0.9rem;
+ font-weight: var(--font-weight-semibold);
+ }
+ .http-method {
+ font-family: monospace;
+ font-weight: var(--font-weight-bold);
+ font-size: 0.85rem;
+ }
+ .http-method.method-get { color: var(--color-status-info-text); }
+ .http-method.method-post { color: var(--color-status-success-text); }
+ .http-method.method-put, .http-method.method-patch { color: var(--color-status-warning-text); }
+ .http-method.method-delete { color: var(--color-status-error-text); }
+ .http-arrow { color: var(--color-text-muted); }
+ .http-status { font-family: monospace; font-size: 0.85rem; }
+ .http-status.status-2xx { color: var(--color-status-success-text); }
+ .http-status.status-3xx { color: var(--color-status-info-text); }
+ .http-status.status-4xx { color: var(--color-status-warning-text); }
+ .http-status.status-5xx { color: var(--color-status-error-text); }
+ .http-detail-row {
+ display: flex;
+ gap: 0.75rem;
+ padding: 0.2rem 0;
+ font-size: 0.82rem;
+ }
+ .http-label {
+ color: var(--color-text-secondary);
+ min-width: 120px;
+ font-weight: var(--font-weight-medium);
+ }
+ .http-value {
+ font-family: monospace;
+ font-size: 0.8rem;
+ word-break: break-all;
+ }
+
+ /* Key-Value Grid */
+ .kv-grid { padding: 0.5rem 0.75rem; }
+ .kv-grid--collapsed { max-height: 200px; overflow: hidden; position: relative; }
+ .kv-grid--collapsed::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 40px;
+ background: linear-gradient(transparent, var(--color-surface-primary));
+ pointer-events: none;
+ }
+ .kv-row {
+ display: flex;
+ gap: 0.75rem;
+ padding: 0.3rem 0;
+ border-bottom: 1px solid var(--color-border-primary);
+ font-size: 0.82rem;
+ }
+ .kv-row:last-child { border-bottom: none; }
+ .kv-row--changed { background: rgba(var(--color-status-warning-rgb, 234, 179, 8), 0.08); }
+ .kv-key {
+ min-width: 140px;
+ max-width: 200px;
+ font-family: monospace;
+ font-size: 0.78rem;
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+ word-break: break-all;
+ }
+ .kv-value {
+ flex: 1;
+ font-family: monospace;
+ font-size: 0.78rem;
+ word-break: break-all;
+ }
+ .kv-value--redacted {
+ color: var(--color-status-warning-text);
+ background: var(--color-status-warning-bg);
+ padding: 0.1rem 0.35rem;
+ border-radius: var(--radius-sm);
+ font-weight: var(--font-weight-semibold);
+ font-size: 0.72rem;
+ }
+
+ /* Buttons */
+ .btn-copy, .btn-toggle {
+ padding: 0.2rem 0.5rem;
+ font-size: 0.72rem;
+ cursor: pointer;
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface-primary);
+ color: var(--color-text-secondary);
+ transition: border-color 150ms ease;
+ }
+ .btn-copy:hover, .btn-toggle:hover {
+ border-color: var(--color-brand-primary);
+ color: var(--color-text-primary);
+ }
+
+ /* Synthetic Diff */
+ .diff-container {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.75rem;
+ padding: 0.75rem;
+ }
+ .diff-container:has(.diff-pane:only-child) {
+ grid-template-columns: 1fr;
+ }
+ .diff-pane {
+ background: var(--color-surface-elevated);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ }
+ .diff-pane h5 {
+ margin: 0;
+ padding: 0.4rem 0.75rem;
+ font-size: 0.78rem;
+ border-bottom: 1px solid var(--color-border-primary);
+ }
+ .diff-pane.before h5 { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
+ .diff-pane.after h5 { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
+ .diff-pane pre {
+ margin: 0;
+ padding: 0.75rem;
+ font-size: 0.75rem;
+ max-height: 300px;
+ overflow: auto;
+ }
+ .changed-fields {
+ padding: 0.5rem 0.75rem;
+ font-size: 0.82rem;
+ }
+ .field-badge {
+ display: inline-block;
+ background: var(--color-status-warning-bg);
+ color: var(--color-status-warning-text);
+ padding: 0.12rem 0.4rem;
+ border-radius: var(--radius-sm);
+ margin-left: 0.4rem;
+ font-size: 0.72rem;
+ }
+ `]
+})
+export class AuditEventDetailsPanelComponent {
+ @Input() event: AuditEvent | null = null;
+
+ /** Tracks whether the request body section is expanded */
+ readonly requestBodyExpanded = signal(false);
+ /** Tracks whether the raw details section is expanded */
+ readonly rawExpanded = signal(false);
+ /** Label for the copy button */
+ readonly copyLabel = signal('Copy as JSON');
+
+ /** Keys that are rendered in dedicated sections (not in raw fallback) */
+ private readonly handledKeys = new Set([
+ 'httpMethod', 'statusCode', 'path', 'responseResourceId',
+ 'requestBody', 'beforeState',
+ ]);
+
+ // --- HTTP Context ---
+
+ get hasHttpContext(): boolean {
+ return !!this.event?.details?.['httpMethod'];
+ }
+
+ get httpMethodClass(): string {
+ const method = String(this.event?.details?.['httpMethod'] ?? '').toUpperCase();
+ return 'method-' + method.toLowerCase();
+ }
+
+ get httpStatusClass(): string {
+ const code = Number(this.event?.details?.['statusCode'] ?? 0);
+ if (code >= 200 && code < 300) return 'status-2xx';
+ if (code >= 300 && code < 400) return 'status-3xx';
+ if (code >= 400 && code < 500) return 'status-4xx';
+ if (code >= 500) return 'status-5xx';
+ return '';
+ }
+
+ get httpStatusText(): string {
+ const code = Number(this.event?.details?.['statusCode'] ?? 0);
+ const texts: Record = {
+ 200: 'OK', 201: 'Created', 204: 'No Content',
+ 301: 'Moved Permanently', 302: 'Found', 304: 'Not Modified',
+ 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict',
+ 500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable',
+ };
+ return texts[code] ?? '';
+ }
+
+ // --- Request Body ---
+
+ get hasRequestBody(): boolean {
+ const body = this.event?.details?.['requestBody'];
+ return !!body && typeof body === 'object';
+ }
+
+ get requestBodyEntries(): [string, unknown][] {
+ const body = this.event?.details?.['requestBody'] as Record | undefined;
+ if (!body || typeof body !== 'object') return [];
+ return Object.entries(body);
+ }
+
+ copyRequestBody(): void {
+ const body = this.event?.details?.['requestBody'];
+ if (body) {
+ navigator.clipboard.writeText(JSON.stringify(body, null, 2)).then(() => {
+ this.copyLabel.set('Copied!');
+ setTimeout(() => this.copyLabel.set('Copy as JSON'), 2000);
+ });
+ }
+ }
+
+ // --- Before State ---
+
+ get hasBeforeState(): boolean {
+ const state = this.event?.details?.['beforeState'];
+ return !!state && typeof state === 'object';
+ }
+
+ get beforeStateEntries(): [string, unknown][] {
+ const state = this.event?.details?.['beforeState'] as Record | undefined;
+ if (!state || typeof state !== 'object') return [];
+ return Object.entries(state);
+ }
+
+ isFieldChanged(field: string): boolean {
+ // A field is "changed" if there is no diff but the event has afterState to compare,
+ // or if the diff references this field
+ const diff = this.event?.diff;
+ if (diff?.fields?.includes(field)) return true;
+ if (diff?.modified && field in diff.modified) return true;
+ return false;
+ }
+
+ // --- Synthetic Diff (Gap 5) ---
+
+ get syntheticDiff(): { before: unknown; after: unknown; fields: string[] } | null {
+ // Only synthesize if there is no real diff but beforeState exists
+ if (this.event?.diff) return null;
+ const beforeState = this.event?.details?.['beforeState'];
+ if (!beforeState || typeof beforeState !== 'object') return null;
+
+ // If we have both beforeState and a response body, show both panes
+ const responseBody = this.event?.details?.['requestBody'];
+ const after = (responseBody && typeof responseBody === 'object') ? responseBody : null;
+
+ return {
+ before: beforeState,
+ after,
+ fields: Object.keys(beforeState as Record),
+ };
+ }
+
+ get syntheticDiffLabel(): string {
+ return this.hasBothStates ? 'State Comparison' : 'State at Time of Action';
+ }
+
+ get hasBothStates(): boolean {
+ const diff = this.syntheticDiff;
+ return !!diff && diff.after !== null && diff.after !== undefined;
+ }
+
+ // --- Raw Details Fallback ---
+
+ get remainingDetails(): [string, unknown][] {
+ const details = this.event?.details;
+ if (!details) return [];
+ return Object.entries(details).filter(([key]) => !this.handledKeys.has(key));
+ }
+
+ // --- Helpers ---
+
+ isRedacted(value: unknown): boolean {
+ return typeof value === 'string' && value === '[REDACTED]';
+ }
+
+ formatValue(value: unknown): string {
+ if (value === null || value === undefined) return 'null';
+ if (typeof value === 'object') return JSON.stringify(value);
+ return String(value);
+ }
+}
diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts
index e742eba94..ee812b44b 100644
--- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts
@@ -283,6 +283,12 @@ const AUDIT_TABS: StellaPageTab[] = [
.badge.module.authority { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.module.vex { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.module.integrations { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
+ .badge.module.release { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
+ .badge.module.attestor { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
+ .badge.module.doctor { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
+ .badge.module.signals { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
+ .badge.module.advisory-ai { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
+ .badge.module.riskengine { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.action { background: var(--color-surface-elevated); }
.badge.action.create { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.action.update { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
@@ -487,6 +493,14 @@ export class AuditLogDashboardComponent implements OnInit {
integrations: 'Integrations',
release: 'Release',
scanner: 'Scanner',
+ attestor: 'Attestor',
+ sbom: 'SBOM',
+ scheduler: 'Scheduler',
+ jobengine: 'JobEngine',
+ doctor: 'Doctor',
+ signals: 'Signals',
+ 'advisory-ai': 'Advisory AI',
+ riskengine: 'Risk Engine',
};
return labels[module] || module;
}
@@ -499,6 +513,11 @@ export class AuditLogDashboardComponent implements OnInit {
integrations: 'M16 18l6-6-6-6|||M8 6l-6 6 6 6',
release: 'M12 2L2 7l10 5 10-5-10-5z|||M2 17l10 5 10-5|||M2 12l10 5 10-5',
scanner: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0',
+ attestor: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0 1 12 2.944a11.955 11.955 0 0 1-8.618 3.04A12.02 12.02 0 0 0 3 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
+ doctor: 'M22 12h-4l-3 9L9 3l-3 9H2',
+ signals: 'M2 20h.01|||M7 20v-4|||M12 20v-8|||M17 20V8|||M22 20V4',
+ 'advisory-ai': 'M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1.27A7 7 0 0 1 14 22h-4a7 7 0 0 1-6.73-3H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z|||M10 15.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z|||M14 15.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z',
+ riskengine: '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',
};
return icons[module] || 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0';
}
diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts
index 119c4f439..575c1b594 100644
--- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts
@@ -1,10 +1,11 @@
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
-import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
+import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
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';
+import { AuditEvent, AuditDiff, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } from '../../core/api/audit-log.models';
+import { AuditEventDetailsPanelComponent } from './audit-event-details-panel.component';
type PolicyCategory = 'all' | 'governance' | 'promotions' | 'approvals' | 'rejections' | 'simulations';
@@ -19,7 +20,7 @@ const POLICY_CATEGORY_ACTIONS: Record = {
@Component({
selector: 'app-audit-log-table',
- imports: [CommonModule, RouterModule, FormsModule],
+ imports: [CommonModule, RouterModule, FormsModule, AuditEventDetailsPanelComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
+
+
+
@@ -137,6 +144,10 @@ const POLICY_CATEGORY_ACTIONS: Record = {
Shadow Mode |
Coverage |
}
+ @if (showHttpColumns()) {
+ Method |
+ Status |
+ }
@if (!isPolicyOnly) { Description | }
|
@@ -178,18 +189,22 @@ const POLICY_CATEGORY_ACTIONS: Record = {
} @else { - }
}
+ @if (showHttpColumns()) {
+ {{ getDetail(event, 'httpMethod') || '-' }} |
+ {{ getDetail(event, 'statusCode') || '-' }} |
+ }
@if (!isPolicyOnly) {
{{ event.description }} |
}
View
- @if (event.diff) {
+ @if (event.diff || hasBeforeState(event)) {
}
|
} @empty {
- | No events match the current filters. |
+ | No events match the current filters. |
}
@@ -263,10 +278,10 @@ const POLICY_CATEGORY_ACTIONS: Record = {
}
Details
-
{{ (selectedEvent()?.details ?? {}) | json }}
+
- @if (selectedEvent()?.diff) {
-
+ @if (selectedEvent()?.diff || hasBeforeState(selectedEvent()!)) {
+
}
@@ -276,7 +291,7 @@ const POLICY_CATEGORY_ACTIONS: Record = {
@@ -325,24 +340,28 @@ const POLICY_CATEGORY_ACTIONS: Record
= {
}
} @else {
-
-
-
-
Before
-
{{ (diffEvent()?.diff?.before ?? {}) | json }}
-
-
-
After
-
{{ (diffEvent()?.diff?.after ?? {}) | json }}
-
-
- @if (diffEvent()?.diff?.fields?.length) {
-
-
Changed fields:
- @for (field of diffEvent()?.diff?.fields; track field) {
-
{{ field }}
+
+ @if (activeDiffFor(diffEvent()!); as diff) {
+
+
+
{{ diffEvent()?.diff ? 'Before' : 'State at time of action' }}
+
{{ (diff.before ?? {}) | json }}
+
+ @if (diff.after !== null && diff.after !== undefined) {
+
+
After
+
{{ (diff.after ?? {}) | json }}
+
}
+ @if (diff.fields?.length) {
+
+ {{ diffEvent()?.diff ? 'Changed fields:' : 'Fields captured:' }}
+ @for (field of diff.fields; track field) {
+ {{ field }}
+ }
+
+ }
}
}
@@ -385,6 +404,23 @@ const POLICY_CATEGORY_ACTIONS: Record
= {
}
.btn-secondary:hover { border-color: var(--color-brand-primary); }
+ /* Column toggles */
+ .column-toggles {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-left: auto;
+ }
+ .column-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.78rem;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ }
+ .column-toggle input { cursor: pointer; }
+
/* Policy sub-category chips */
.policy-category-chips {
display: flex; gap: 0.5rem; margin-bottom: 1rem;
@@ -451,6 +487,11 @@ const POLICY_CATEGORY_ACTIONS: Record = {
.badge.module.integrations { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.module.jobengine { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.module.scanner { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
+ .badge.module.release { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
+ .badge.module.doctor { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
+ .badge.module.signals { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
+ .badge.module.advisory-ai { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
+ .badge.module.riskengine { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.action { background: var(--color-surface-elevated); }
.badge.action.create, .badge.action.issue { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.action.update, .badge.action.refresh { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
@@ -563,6 +604,7 @@ export class AuditLogTableComponent implements OnInit {
readonly cursor = signal(null);
readonly hasMore = signal(false);
readonly hasPrev = signal(false);
+ readonly showHttpColumns = signal(false);
private cursorStack: string[] = [];
// Filter state
@@ -591,7 +633,7 @@ export class AuditLogTableComponent implements OnInit {
{ id: 'simulations', label: 'Simulations' },
];
- readonly allModules: AuditModule[] = ['authority', 'policy', 'jobengine', 'integrations', 'vex', 'scanner', 'attestor', 'sbom', 'scheduler'];
+ readonly allModules: AuditModule[] = ['authority', 'policy', 'jobengine', 'integrations', 'vex', 'scanner', 'attestor', 'sbom', 'scheduler', 'release', 'doctor', 'signals', 'advisory-ai', 'riskengine'];
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'];
@@ -724,6 +766,25 @@ export class AuditLogTableComponent implements OnInit {
!!(diff.modified && Object.keys(diff.modified).length);
}
+ hasBeforeState(event: AuditEvent): boolean {
+ const state = event?.details?.['beforeState'];
+ return !!state && typeof state === 'object';
+ }
+
+ /** Returns the real diff or a synthetic diff auto-constructed from beforeState */
+ activeDiffFor(event: AuditEvent): AuditDiff | null {
+ if (event.diff) return event.diff;
+ const beforeState = event.details?.['beforeState'];
+ if (!beforeState || typeof beforeState !== 'object') return null;
+ const responseBody = event.details?.['requestBody'];
+ const after = (responseBody && typeof responseBody === 'object') ? responseBody : undefined;
+ return {
+ before: beforeState,
+ after,
+ fields: Object.keys(beforeState as Record),
+ };
+ }
+
diffEntries(obj: Record | undefined | null): [string, any][] {
if (!obj) return [];
return Object.entries(obj);
@@ -750,6 +811,11 @@ export class AuditLogTableComponent implements OnInit {
attestor: 'Attestor',
sbom: 'SBOM',
scheduler: 'Scheduler',
+ release: 'Release',
+ doctor: 'Doctor',
+ signals: 'Signals',
+ 'advisory-ai': 'Advisory AI',
+ riskengine: 'Risk Engine',
};
return labels[module] || module;
}
diff --git a/src/Web/StellaOps.Web/src/app/shared/components/audit-module-events/audit-module-events.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/audit-module-events/audit-module-events.component.ts
index 71f44ef02..344a72e48 100644
--- a/src/Web/StellaOps.Web/src/app/shared/components/audit-module-events/audit-module-events.component.ts
+++ b/src/Web/StellaOps.Web/src/app/shared/components/audit-module-events/audit-module-events.component.ts
@@ -519,6 +519,11 @@ export class AuditModuleEventsComponent implements OnInit, OnChanges {
attestor: 'Attestor',
sbom: 'SBOM',
scheduler: 'Scheduler',
+ release: 'Release',
+ doctor: 'Doctor',
+ signals: 'Signals',
+ 'advisory-ai': 'Advisory AI',
+ riskengine: 'Risk Engine',
};
return labels[m] || m;
}