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) { +
+
+

Request Body

+
+ + @if (requestBodyEntries.length > 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) { +
+
+

Additional Details

+ +
+ @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: `
@@ -101,6 +102,12 @@ const POLICY_CATEGORY_ACTIONS: Record = {
+
+ +
@@ -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 = {
} @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; }