feat(scheduler): audit cleanse plugin + JSON Schema config forms + UI enrichment

Scheduler plugins:
- AuditCleanseJobPlugin: purge audit data older than retention (default 365 days)
- ScanJobPlugin: proper JSON Schema for mode/scope/parallelism
- Plugin discovery endpoints: list, schema, defaults
- ISchedulerJobPlugin gains GetDefaultConfig()
- Dynamic plugin-config-form Angular component
- Schedule create dialog with plugin-aware config

Audit UI (Gaps 4+5):
- Structured details panel: HTTP context, request body, before state
- [REDACTED] PII highlighting with warning badges
- Auto-construct diff from details.beforeState
- New module types: release, attestor, doctor, signals, advisory-ai, riskengine

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-09 12:13:42 +03:00
parent 7f40f8d678
commit 786aaa765d
6 changed files with 627 additions and 48 deletions

View File

@@ -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 =

View File

@@ -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: `
<div class="event-detail-page">
@@ -99,7 +100,7 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
<div class="details-section">
<h3>Details</h3>
<pre class="json-block">{{ (event()?.details ?? {}) | json }}</pre>
<app-audit-event-details-panel [event]="event()" />
</div>
@if (event()?.module === 'policy') {
@@ -136,9 +137,9 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
</div>
}
@if (event()?.diff) {
@if (event()?.diff || syntheticDiff()) {
<div class="diff-section">
<h3>Configuration Diff</h3>
<h3>{{ event()?.diff ? 'Configuration Diff' : 'State at Time of Action' }}</h3>
@if (hasGovernanceDiff()) {
<div class="governance-diff">
@if (govDiffEntries(event()?.diff?.added).length > 0) {
@@ -178,23 +179,27 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
}
</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>
@if (activeDiff(); as diff) {
<div class="diff-container">
<div class="diff-pane before">
<h4>{{ event()?.diff ? 'Before' : 'State at time of action' }}</h4>
<pre>{{ (diff.before ?? {}) | json }}</pre>
</div>
@if (diff.after !== null && diff.after !== undefined) {
<div class="diff-pane after">
<h4>After</h4>
<pre>{{ (diff.after ?? {}) | json }}</pre>
</div>
}
</div>
@if (diff.fields?.length) {
<div class="changed-fields">
<strong>{{ event()?.diff ? 'Changed fields:' : 'Fields captured:' }}</strong>
@for (field of diff.fields; track field) {
<span class="field-badge">{{ field }}</span>
}
</div>
}
}
}
</div>
@@ -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<AuditEvent | null>(null);
readonly correlation = signal<AuditCorrelationCluster | null>(null);
/** Synthetic diff auto-constructed from details.beforeState when event.diff is absent */
readonly syntheticDiff = computed<AuditDiff | null>(() => {
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<string, unknown>),
};
});
/** Returns the real diff or the synthetic diff, whichever is active */
readonly activeDiff = computed<AuditDiff | null>(() => {
return this.event()?.diff ?? this.syntheticDiff();
});
ngOnInit(): void {
this.route.params.subscribe((params) => {
const eventId = params['eventId'];

View File

@@ -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) {
<!-- HTTP Context Section -->
@if (hasHttpContext) {
<section class="details-section">
<h4 class="section-title">HTTP Context</h4>
<div class="http-context">
<div class="http-summary">
<span class="http-method" [class]="httpMethodClass">{{ event.details?.['httpMethod'] }}</span>
<span class="http-arrow">&rarr;</span>
<span class="http-status" [class]="httpStatusClass">{{ event.details?.['statusCode'] }} {{ httpStatusText }}</span>
</div>
@if (event.details?.['path']) {
<div class="http-detail-row">
<span class="http-label">Path</span>
<code class="http-value">{{ event.details?.['path'] }}</code>
</div>
}
@if (event.details?.['responseResourceId']) {
<div class="http-detail-row">
<span class="http-label">Response Resource</span>
<code class="http-value">{{ event.details?.['responseResourceId'] }}</code>
</div>
}
</div>
</section>
}
<!-- Request Body Section -->
@if (hasRequestBody) {
<section class="details-section">
<div class="section-header-row">
<h4 class="section-title">Request Body</h4>
<div class="section-actions">
<button class="btn-copy" type="button" (click)="copyRequestBody()" [title]="copyLabel()">
{{ copyLabel() }}
</button>
@if (requestBodyEntries.length > 5) {
<button class="btn-toggle" type="button" (click)="requestBodyExpanded.set(!requestBodyExpanded())">
{{ requestBodyExpanded() ? 'Collapse' : 'Expand' }}
</button>
}
</div>
</div>
<div class="kv-grid" [class.kv-grid--collapsed]="!requestBodyExpanded() && requestBodyEntries.length > 5">
@for (entry of requestBodyEntries; track entry[0]) {
<div class="kv-row">
<span class="kv-key">{{ entry[0] }}</span>
@if (isRedacted(entry[1])) {
<span class="kv-value kv-value--redacted">{{ entry[1] }}</span>
} @else {
<span class="kv-value">{{ formatValue(entry[1]) }}</span>
}
</div>
}
</div>
</section>
}
<!-- Before State Section -->
@if (hasBeforeState) {
<section class="details-section">
<h4 class="section-title">State at Time of Action</h4>
<div class="kv-grid">
@for (entry of beforeStateEntries; track entry[0]) {
<div class="kv-row" [class.kv-row--changed]="isFieldChanged(entry[0])">
<span class="kv-key">{{ entry[0] }}</span>
<span class="kv-value" [class.kv-value--redacted]="isRedacted(entry[1])">{{ formatValue(entry[1]) }}</span>
</div>
}
</div>
</section>
}
<!-- Synthetic Diff Section (auto-constructed from beforeState when diff is absent) -->
@if (syntheticDiff) {
<section class="details-section">
<h4 class="section-title">{{ syntheticDiffLabel }}</h4>
<div class="diff-container">
<div class="diff-pane before">
<h5>{{ hasBothStates ? 'Before' : 'State at time of action' }}</h5>
<pre>{{ syntheticDiff.before | json }}</pre>
</div>
@if (syntheticDiff.after !== null && syntheticDiff.after !== undefined) {
<div class="diff-pane after">
<h5>After</h5>
<pre>{{ syntheticDiff.after | json }}</pre>
</div>
}
</div>
@if (syntheticDiff.fields?.length) {
<div class="changed-fields">
<strong>Fields captured:</strong>
@for (field of syntheticDiff.fields; track field) {
<span class="field-badge">{{ field }}</span>
}
</div>
}
</section>
}
<!-- Raw Details Fallback -->
@if (remainingDetails.length > 0) {
<section class="details-section">
<div class="section-header-row">
<h4 class="section-title">Additional Details</h4>
<button class="btn-toggle" type="button" (click)="rawExpanded.set(!rawExpanded())">
{{ rawExpanded() ? 'Collapse' : 'Expand' }}
</button>
</div>
@if (rawExpanded()) {
<div class="kv-grid">
@for (entry of remainingDetails; track entry[0]) {
<div class="kv-row">
<span class="kv-key">{{ entry[0] }}</span>
<span class="kv-value" [class.kv-value--redacted]="isRedacted(entry[1])">{{ formatValue(entry[1]) }}</span>
</div>
}
</div>
}
</section>
}
}
`,
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<number, string> = {
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<string, unknown> | 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<string, unknown> | 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<string, unknown>),
};
}
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);
}
}

View File

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

View File

@@ -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<PolicyCategory, AuditAction[] | null> = {
@Component({
selector: 'app-audit-log-table',
imports: [CommonModule, RouterModule, FormsModule],
imports: [CommonModule, RouterModule, FormsModule, AuditEventDetailsPanelComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="audit-table-page">
@@ -101,6 +102,12 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
<input type="text" [(ngModel)]="actorFilter" placeholder="Username or email" (keyup.enter)="applyFilters()" />
</div>
<button class="btn-secondary" (click)="clearFilters()">Clear Filters</button>
<div class="column-toggles">
<label class="column-toggle">
<input type="checkbox" [checked]="showHttpColumns()" (change)="showHttpColumns.set(!showHttpColumns())" />
HTTP columns
</label>
</div>
</div>
</div>
@@ -137,6 +144,10 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
<th>Shadow Mode</th>
<th>Coverage</th>
}
@if (showHttpColumns()) {
<th>Method</th>
<th>Status</th>
}
@if (!isPolicyOnly) { <th>Description</th> }
<th></th>
</tr>
@@ -178,18 +189,22 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
} @else { - }
</td>
}
@if (showHttpColumns()) {
<td><span class="mono">{{ getDetail(event, 'httpMethod') || '-' }}</span></td>
<td><span class="mono">{{ getDetail(event, 'statusCode') || '-' }}</span></td>
}
@if (!isPolicyOnly) {
<td class="description">{{ event.description }}</td>
}
<td>
<a [routerLink]="[event.id]" class="link">View</a>
@if (event.diff) {
@if (event.diff || hasBeforeState(event)) {
<button class="btn-xs" (click)="openDiffViewer(event); $event.stopPropagation()">Diff</button>
}
</td>
</tr>
} @empty {
<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>
<tr><td [attr.colspan]="(isPolicyOnly ? 10 : 8) + (showHttpColumns() ? 2 : 0)" style="text-align:center;padding:2rem;color:var(--color-text-muted)">No events match the current filters.</td></tr>
}
</tbody>
</table>
@@ -263,10 +278,10 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
}
<div class="detail-section">
<h4>Details</h4>
<pre class="json-block">{{ (selectedEvent()?.details ?? {}) | json }}</pre>
<app-audit-event-details-panel [event]="selectedEvent()" />
</div>
@if (selectedEvent()?.diff) {
<button class="btn-primary" (click)="openDiffViewer(selectedEvent()!)">View Diff</button>
@if (selectedEvent()?.diff || hasBeforeState(selectedEvent()!)) {
<button class="btn-primary" (click)="openDiffViewer(selectedEvent()!)">{{ selectedEvent()?.diff ? 'View Diff' : 'View State' }}</button>
}
</div>
</div>
@@ -276,7 +291,7 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
<div class="diff-modal-backdrop" (click)="closeDiffViewer()">
<div class="diff-modal" (click)="$event.stopPropagation()">
<header class="modal-header">
<h3>Configuration Diff</h3>
<h3>{{ diffEvent()?.diff ? 'Configuration Diff' : 'State at Time of Action' }}</h3>
<button class="close-btn" (click)="closeDiffViewer()">&times;</button>
</header>
<div class="modal-content">
@@ -325,24 +340,28 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
}
</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>
<!-- Standard before/after diff or synthetic diff from beforeState -->
@if (activeDiffFor(diffEvent()!); as diff) {
<div class="diff-container">
<div class="diff-pane before">
<h4>{{ diffEvent()?.diff ? 'Before' : 'State at time of action' }}</h4>
<pre>{{ (diff.before ?? {}) | json }}</pre>
</div>
@if (diff.after !== null && diff.after !== undefined) {
<div class="diff-pane after">
<h4>After</h4>
<pre>{{ (diff.after ?? {}) | json }}</pre>
</div>
}
</div>
@if (diff.fields?.length) {
<div class="changed-fields">
<strong>{{ diffEvent()?.diff ? 'Changed fields:' : 'Fields captured:' }}</strong>
@for (field of diff.fields; track field) {
<span class="field-badge">{{ field }}</span>
}
</div>
}
}
}
</div>
@@ -385,6 +404,23 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
}
.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<PolicyCategory, AuditAction[] | null> = {
.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<string | null>(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<string, unknown>),
};
}
diffEntries(obj: Record<string, unknown> | 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;
}

View File

@@ -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;
}