feat(web): audit-log dashboard — quick links, simplified empty state, module label refresh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-01 10:49:16 +03:00
parent 14029c7e56
commit 79a214d259

View File

@@ -1,55 +1,24 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnInit,
computed,
effect,
inject,
signal,
} from '@angular/core';
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, effect, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { RouterModule } from '@angular/router';
import { AuditLogClient } from '../../core/api/audit-log.client';
import {
AuditAnomalyAlert,
AuditEvent,
AuditModule,
AuditStatsSummary,
} from '../../core/api/audit-log.models';
import { AuditAnomalyAlert, AuditEvent, AuditModule, AuditStatsSummary } from '../../core/api/audit-log.models';
import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service';
import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component';
import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component';
import {
StellaPageTab,
StellaPageTabsComponent,
} from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
import { AuditCorrelationsComponent } from './audit-correlations.component';
import { AuditLogTableComponent } from './audit-log-table.component';
import { AuditTimelineSearchComponent } from './audit-timeline-search.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: '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' },
];
@Component({
@@ -57,10 +26,11 @@ const AUDIT_TABS: StellaPageTab[] = [
standalone: true,
imports: [
CommonModule,
RouterLink,
RouterModule,
StellaMetricCardComponent,
StellaMetricGridComponent,
StellaPageTabsComponent,
StellaQuickLinksComponent,
AuditTimelineSearchComponent,
AuditCorrelationsComponent,
AuditLogTableComponent,
@@ -69,16 +39,13 @@ const AUDIT_TABS: StellaPageTab[] = [
template: `
<div class="audit-dashboard">
<header class="page-header">
<div class="page-copy">
<div>
<h1>Audit & Compliance</h1>
<p class="description">
Cross-module audit trail, anomaly detection, timeline search, and event correlation.
</p>
</div>
<div class="header-actions">
<a routerLink="/evidence/exports" class="btn-secondary">Export Center</a>
<a routerLink="/ops/policy/packs" class="btn-secondary">Policy Packs</a>
<p class="description">Cross-module audit trail, anomaly detection, timeline search, and event correlation.</p>
</div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
</aside>
</header>
<stella-page-tabs
@@ -100,50 +67,41 @@ const AUDIT_TABS: StellaPageTab[] = [
@for (entry of moduleStats(); track entry.module) {
<stella-metric-card
[label]="formatModule(entry.module)"
[value]="entry.count | number"
[value]="(entry.count | number) ?? '0'"
[icon]="getModuleIcon(entry.module)"
/>
}
</stella-metric-grid>
}
@if (showOverviewGuidance()) {
@if (allCountsZero()) {
<section class="audit-log__empty-guidance">
<div class="audit-log__empty-badge" aria-hidden="true">AU</div>
<div class="audit-log__empty-icon" aria-hidden="true">AU</div>
<div class="audit-log__empty-copy">
<h2>No audit events have been recorded yet</h2>
<h2>No audit events have been captured for this scope yet</h2>
<p>
Audit data appears automatically as operators use the platform. The first useful
events usually come from release creation, policy changes, approvals, scans, and
integration updates.
Audit events appear automatically when operators create releases, change policy packs,
approve promotions, manage integrations, or update access controls. The log is empty
because that activity has not happened in the selected window yet, not because audit
capture is disabled.
</p>
<ul>
<li>Release seals, promotions, and approvals</li>
<li>Policy activations, simulations, and rollbacks</li>
<li>VEX decisions and consensus votes</li>
<li>Integration configuration changes</li>
<li>Identity, signing, and evidence actions</li>
</ul>
<p class="audit-log__status-note">
This is usually a first-run state, not a failure. Once work starts flowing through
Stella, this page becomes the evidence trail for who changed what and when.
The Evidence rail indicator shows <strong>ON</strong> - audit capture is active.
</p>
<div class="audit-log__empty-actions">
<a routerLink="/releases" class="btn-primary">Open Releases</a>
<a routerLink="/ops/policy/packs" class="btn-secondary">Review Policy Packs</a>
</div>
</div>
<div class="audit-log__empty-actions">
<a routerLink="/releases" class="btn-sm">Open releases</a>
<a routerLink="/ops/policy/packs" class="btn-sm btn-sm--secondary">Open policy packs</a>
</div>
</section>
}
@if (anomalies().length > 0) {
<section class="anomaly-alerts">
<div class="section-header section-header--plain">
<h2>Anomaly Alerts</h2>
</div>
<h2>Anomaly Alerts</h2>
<div class="alert-list">
@for (alert of anomalies(); track alert.id) {
<article class="alert-card" [class]="alert.severity">
<div class="alert-card" [class]="alert.severity">
<div class="alert-header">
<span class="alert-type">{{ formatAnomalyType(alert.type) }}</span>
<span class="alert-time">{{ formatTime(alert.detectedAt) }}</span>
@@ -152,12 +110,12 @@ const AUDIT_TABS: StellaPageTab[] = [
<div class="alert-footer">
<span class="affected">{{ alert.affectedEvents.length }} events</span>
@if (!alert.acknowledged) {
<button class="btn-sm" (click)="acknowledgeAlert(alert.id)">Acknowledge</button>
<button class="btn-sm" type="button" (click)="acknowledgeAlert(alert.id)">Acknowledge</button>
} @else {
<span class="ack">Acked by {{ alert.acknowledgedBy }}</span>
}
</div>
</article>
</div>
}
</div>
</section>
@@ -168,282 +126,98 @@ const AUDIT_TABS: StellaPageTab[] = [
<h2>Recent Events</h2>
<button class="link" type="button" (click)="activeTab.set('all-events')">View all</button>
</div>
<table class="events-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Module</th>
<th>Action</th>
<th>Actor</th>
<th>Resource</th>
</tr>
</thead>
<tbody>
@for (event of recentEvents(); track event.id) {
<tr class="clickable">
<td class="mono">{{ formatTime(event.timestamp) }}</td>
<td><span class="badge module" [class]="event.module">{{ event.module }}</span></td>
<td><span class="badge action" [class]="event.action">{{ event.action }}</span></td>
<td>{{ event.actor.name }}</td>
<td class="resource">{{ event.resource.type }}: {{ event.resource.name || event.resource.id }}</td>
</tr>
} @empty {
@if (recentEvents().length > 0) {
<table class="events-table">
<thead>
<tr>
<td colspan="5" class="recent-events__empty-cell">
<div class="recent-events__empty">
<p class="recent-events__empty-title">Nothing has reached the audit stream yet.</p>
<p class="recent-events__empty-copy">
Start with a release, scan, or policy change. The newest events will appear
here first for quick operator review.
</p>
</div>
</td>
<th>Timestamp</th>
<th>Module</th>
<th>Action</th>
<th>Actor</th>
<th>Resource</th>
</tr>
}
</tbody>
</table>
</thead>
<tbody>
@for (event of recentEvents(); track event.id) {
<tr class="clickable">
<td class="mono">{{ formatTime(event.timestamp) }}</td>
<td><span class="badge module" [class]="event.module">{{ event.module }}</span></td>
<td><span class="badge action" [class]="event.action">{{ event.action }}</span></td>
<td>{{ event.actor.name }}</td>
<td class="resource">{{ event.resource.type }}: {{ event.resource.name || event.resource.id }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="recent-events__empty">
<p class="recent-events__empty-title">No recent events in this time window</p>
<p class="recent-events__empty-copy">
Recent events show the latest release, policy, VEX, and integration actions as they happen.
When this panel is empty, there has simply been no qualifying activity to summarize yet.
</p>
</div>
}
</section>
}
@case ('all-events') {
<app-audit-log-table />
}
@case ('timeline') {
<app-audit-timeline-search />
}
@case ('correlations') {
<app-audit-correlations />
}
@case ('all-events') { <app-audit-log-table /> }
@case ('timeline') { <app-audit-timeline-search /> }
@case ('correlations') { <app-audit-correlations /> }
}
</stella-page-tabs>
</div>
`,
styles: [`
.audit-dashboard {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.audit-dashboard { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; gap: 1.5rem; }
.page-header h1 { margin: 0 0 0.25rem; font-size: 1.5rem; }
.page-aside { flex: 0 1 60%; min-width: 0; }
.description { color: var(--color-text-secondary); margin: 0; font-size: 0.9rem; }
stella-metric-grid { margin-bottom: 1.5rem; }
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.page-copy h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
}
.description {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.btn-primary,
.btn-secondary,
.btn-sm {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
text-decoration: none;
cursor: pointer;
transition: opacity 150ms ease, transform 150ms ease;
font: inherit;
}
.btn-primary:hover,
.btn-secondary:hover,
.btn-sm:hover {
opacity: 0.92;
transform: translateY(-1px);
}
.btn-primary,
.btn-secondary {
min-height: 2.25rem;
padding: 0.5rem 0.9rem;
border: 1px solid var(--color-btn-secondary-border, var(--color-border-primary));
}
.btn-primary {
background: var(--color-btn-primary-bg);
border-color: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
}
.btn-secondary {
background: var(--color-btn-secondary-bg, var(--color-surface-primary));
color: var(--color-btn-secondary-text, var(--color-text-primary));
}
stella-metric-grid {
margin-bottom: 1.5rem;
}
.audit-log__empty-guidance {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 1rem;
margin: 0 0 1.5rem;
padding: 1rem 1.1rem;
.anomaly-alerts { margin-bottom: 1.5rem; }
.anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
.alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }
.alert-card {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--color-surface-primary) 92%, var(--color-brand-primary) 8%),
var(--color-surface-primary)
);
}
.audit-log__empty-badge {
width: 2.75rem;
height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--color-brand-primary) 14%, transparent);
color: var(--color-text-link);
font-size: 0.78rem;
font-weight: var(--font-weight-bold);
letter-spacing: 0.08em;
}
.audit-log__empty-copy {
display: grid;
gap: 0.65rem;
}
.audit-log__empty-copy h2,
.audit-log__empty-copy p {
margin: 0;
}
.audit-log__empty-copy ul {
margin: 0;
padding-left: 1.2rem;
color: var(--color-text-secondary);
}
.audit-log__empty-copy li + li {
margin-top: 0.25rem;
}
.audit-log__status-note {
color: var(--color-text-secondary);
font-size: 0.85rem;
line-height: 1.55;
}
.audit-log__empty-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.15rem;
}
.anomaly-alerts {
margin-bottom: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border-primary);
}
.section-header--plain {
padding: 0 0 1rem;
border-bottom: 0;
}
.section-header h2 {
margin: 0;
font-size: 1rem;
}
.alert-list {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.alert-card {
padding: 1rem;
min-width: 280px;
flex: 1;
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
transition: transform 150ms ease, box-shadow 150ms ease;
}
.alert-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.alert-card.warning {
border-left: 4px solid var(--color-status-warning);
}
.alert-card.error,
.alert-card.critical {
border-left: 4px solid var(--color-status-error);
}
.alert-header,
.alert-footer {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.alert-header {
margin-bottom: 0.5rem;
}
.alert-type {
font-weight: var(--font-weight-semibold);
font-size: 0.9rem;
}
.alert-time,
.affected,
.ack {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.alert-desc {
margin: 0 0 0.75rem;
color: var(--color-text-secondary);
font-size: 0.85rem;
line-height: 1.5;
}
.alert-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
.alert-card.warning { border-left: 4px solid var(--color-status-warning); }
.alert-card.error, .alert-card.critical { border-left: 4px solid var(--color-status-error); }
.alert-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }
.alert-type { font-weight: var(--font-weight-semibold); font-size: 0.9rem; }
.alert-time { font-size: 0.75rem; color: var(--color-text-muted); }
.alert-desc { font-size: 0.85rem; margin: 0 0 0.75rem; color: var(--color-text-secondary); }
.alert-footer { display: flex; justify-content: space-between; align-items: center; }
.affected { font-size: 0.75rem; color: var(--color-text-muted); }
.btn-sm {
padding: 0.3rem 0.6rem;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.35rem 0.65rem;
font-size: 0.8rem;
cursor: pointer;
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
font-size: 0.8rem;
border: 1px solid var(--color-btn-primary-bg);
border-radius: var(--radius-sm);
text-decoration: none;
transition: opacity 150ms ease, transform 150ms ease;
}
.btn-sm:hover { opacity: 0.9; transform: translateY(-1px); }
.btn-sm--secondary {
background: var(--color-surface-secondary);
color: var(--color-text-primary);
border-color: var(--color-border-primary);
}
.ack { font-size: 0.75rem; color: var(--color-text-muted); }
.recent-events {
background: var(--color-surface-primary);
@@ -451,33 +225,31 @@ const AUDIT_TABS: StellaPageTab[] = [
border-radius: var(--radius-lg);
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border-primary);
}
.section-header h2 { margin: 0; font-size: 1rem; }
.link {
border: none;
background: none;
color: var(--color-text-link);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-link);
text-decoration: none;
background: none;
border: none;
cursor: pointer;
}
.link:hover { text-decoration: underline; }
.link:hover {
text-decoration: underline;
}
.events-table {
width: 100%;
border-collapse: collapse;
}
.events-table th,
.events-table td {
.events-table { width: 100%; border-collapse: collapse; }
.events-table th, .events-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
font-size: 0.84rem;
}
.events-table th {
background: var(--color-surface-elevated);
font-weight: var(--font-weight-semibold);
@@ -485,25 +257,10 @@ const AUDIT_TABS: StellaPageTab[] = [
text-transform: uppercase;
letter-spacing: 0.03em;
}
.events-table tbody tr:nth-child(even) {
background: var(--color-surface-elevated);
}
.clickable {
cursor: pointer;
transition: background 150ms ease;
}
.clickable:hover {
background: color-mix(in srgb, var(--color-brand-primary) 6%, transparent);
}
.mono {
font-family: monospace;
font-size: 0.78rem;
}
.events-table tbody tr:nth-child(even) { background: var(--color-surface-elevated); }
.mono { font-family: monospace; font-size: 0.78rem; }
.clickable { cursor: pointer; transition: background 150ms ease; }
.clickable:hover { background: rgba(59, 130, 246, 0.06); }
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
@@ -513,110 +270,97 @@ const AUDIT_TABS: StellaPageTab[] = [
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); }
.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.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); }
.badge.action.delete { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.badge.action.promote { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.resource { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge.module {
background: var(--color-surface-elevated);
.audit-log__empty-guidance {
display: grid;
gap: 1rem;
align-items: start;
grid-template-columns: auto 1fr;
margin: 0 0 1.5rem;
padding: 1rem 1.25rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
}
.badge.module.policy {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
.audit-log__empty-icon {
width: 3rem;
height: 3rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
background: var(--color-brand-soft, var(--color-surface-secondary));
color: var(--color-text-link);
font-size: 0.875rem;
font-weight: var(--font-weight-bold);
letter-spacing: 0.08em;
}
.badge.module.authority {
background: var(--color-status-excepted-bg);
color: var(--color-status-excepted);
.audit-log__empty-copy {
display: grid;
gap: 0.5rem;
color: var(--color-text-secondary);
line-height: 1.6;
}
.badge.module.vex {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
.audit-log__empty-copy h2,
.audit-log__empty-copy p {
margin: 0;
}
.badge.module.integrations {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
.audit-log__empty-copy h2 {
font-size: 1rem;
color: var(--color-text-heading, var(--color-text-primary));
}
.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,
.badge.action.approve,
.badge.action.enable {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
.badge.action.delete,
.badge.action.fail,
.badge.action.reject {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.badge.action.promote,
.badge.action.start {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.resource {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-events__empty-cell {
padding: 0;
.audit-log__empty-actions {
grid-column: 2;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.audit-log__status-note { font-size: 0.85rem; }
.recent-events__empty {
padding: 1.25rem 1rem;
text-align: center;
display: grid;
gap: 0.5rem;
padding: 1.5rem 1rem;
color: var(--color-text-secondary);
}
.recent-events__empty-title,
.recent-events__empty-copy {
margin: 0;
}
.recent-events__empty-title {
font-size: 0.95rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
color: var(--color-text-heading, var(--color-text-primary));
}
.recent-events__empty-copy {
margin-top: 0.35rem;
line-height: 1.55;
}
@media (max-width: 900px) {
.page-header,
.audit-log__empty-guidance {
grid-template-columns: 1fr;
flex-direction: column;
}
.header-actions,
.audit-log__empty-actions {
width: 100%;
}
font-size: 0.85rem;
line-height: 1.6;
}
`],
})
export class AuditLogDashboardComponent implements OnInit {
private readonly auditClient = inject(AuditLogClient);
private readonly helperCtx = inject(StellaHelperContextService);
private readonly destroyRef = inject(DestroyRef);
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: 'Trust & Signing', route: '/setup/trust-signing', description: 'Signing keys and certificate management' },
];
readonly auditTabs = AUDIT_TABS;
readonly activeTab = signal<string>('overview');
@@ -629,27 +373,28 @@ export class AuditLogDashboardComponent implements OnInit {
readonly eventsLoaded = signal(false);
readonly anomaliesLoaded = signal(false);
readonly overviewLoaded = computed(
() => this.statsLoaded() && this.eventsLoaded() && this.anomaliesLoaded(),
);
readonly allCountsZero = computed(() => {
const stats = this.stats();
if (!stats) {
return false;
}
return stats.totalEvents === 0 && this.moduleStats().every((entry) => entry.count === 0);
});
readonly showOverviewGuidance = computed(
() => this.overviewLoaded() && this.allCountsZero() && this.recentEvents().length === 0,
readonly overviewLoaded = computed(() =>
this.statsLoaded() && this.eventsLoaded() && this.anomaliesLoaded()
);
readonly helperContexts = computed(() => {
const contexts: string[] = [];
if (this.activeTab() === 'overview' && this.showOverviewGuidance()) {
contexts.push('empty-table', 'no-audit-events');
if (this.activeTab() !== 'overview' || !this.overviewLoaded()) {
return contexts;
}
if (this.recentEvents().length === 0) {
contexts.push('empty-table');
}
if (this.allCountsZero() && this.recentEvents().length === 0 && this.anomalies().length === 0) {
contexts.push('no-audit-events');
}
return contexts;
});
@@ -658,7 +403,6 @@ export class AuditLogDashboardComponent implements OnInit {
effect(() => {
this.helperCtx.setScope('audit-log-dashboard', this.helperContexts());
}, { allowSignalWrites: true });
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('audit-log-dashboard'));
}
@@ -669,10 +413,14 @@ export class AuditLogDashboardComponent implements OnInit {
loadData(): void {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
this.statsLoaded.set(false);
this.eventsLoaded.set(false);
this.anomaliesLoaded.set(false);
this.auditClient.getStatsSummary(sevenDaysAgo).subscribe({
next: (stats) => {
this.stats.set(stats);
const moduleEntries = Object.entries(stats.byModule ?? {}).map(([module, count]) => ({
const moduleEntries = Object.entries(stats.byModule || {}).map(([module, count]) => ({
module: module as AuditModule,
count: count as number,
}));
@@ -688,7 +436,7 @@ export class AuditLogDashboardComponent implements OnInit {
this.auditClient.getEvents(undefined, undefined, 10).subscribe({
next: (response) => {
this.recentEvents.set(this.sortEventsDeterministically(response.items ?? []));
this.recentEvents.set(this.sortEventsDeterministically(response.items));
this.eventsLoaded.set(true);
},
error: () => {
@@ -712,11 +460,7 @@ export class AuditLogDashboardComponent implements OnInit {
acknowledgeAlert(alertId: string): void {
this.auditClient.acknowledgeAnomaly(alertId).subscribe(() => {
this.anomalies.update((alerts) =>
alerts.map((alert) => (
alert.id === alertId
? { ...alert, acknowledged: true }
: alert
)),
alerts.map((alert) => (alert.id === alertId ? { ...alert, acknowledged: true } : alert))
);
});
}
@@ -726,48 +470,38 @@ export class AuditLogDashboardComponent implements OnInit {
}
formatAnomalyType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
return type.replace(/_/g, ' ').replace(/\b\w/g, (value) => value.toUpperCase());
}
formatModule(module: AuditModule): string {
const labels: Partial<Record<AuditModule, string>> = {
authority: 'Authority',
const labels: Record<string, string> = {
policy: 'Policy',
jobengine: 'Job Engine',
integrations: 'Integrations',
authority: 'Authority',
vex: 'VEX',
integrations: 'Integrations',
release: 'Release',
scanner: 'Scanner',
attestor: 'Attestor',
sbom: 'SBOM',
scheduler: 'Scheduler',
};
return labels[module] ?? module;
return labels[module] || module;
}
getModuleIcon(module: AuditModule): string {
const icons: Partial<Record<AuditModule, string>> = {
const icons: Record<string, string> = {
policy: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
authority: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4',
vex: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11',
integrations: 'M16 18l6-6-6-6|||M8 6l-6 6 6 6',
jobengine: '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.3 7l8.7 5 8.7-5',
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: 'M12 2l8 4v6c0 5.25-3.5 9.5-8 10-4.5-.5-8-4.75-8-10V6l8-4z|||M9 12l2 2 4-4',
sbom: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M8 13h8|||M8 17h5',
scheduler: 'M8 2v4|||M16 2v4|||M3 10h18|||M5 6h14a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z',
};
return icons[module] ?? 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0';
return icons[module] || 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0';
}
private sortModuleStatsDeterministically(
entries: Array<{ module: AuditModule; count: number }>,
): Array<{ module: AuditModule; count: number }> {
return [...entries].sort((left, right) => right.count - left.count || left.module.localeCompare(right.module));
private sortModuleStatsDeterministically(entries: Array<{ module: AuditModule; count: number }>): Array<{ module: AuditModule; count: number }> {
return entries.sort((left, right) => right.count - left.count || left.module.localeCompare(right.module));
}
private sortEventsDeterministically(events: AuditEvent[]): AuditEvent[] {
return [...events].sort(
(left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime(),
);
return events.sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime());
}
}