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 { // Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
ChangeDetectionStrategy, import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, effect, inject, signal } from '@angular/core';
Component,
DestroyRef,
OnInit,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AuditLogClient } from '../../core/api/audit-log.client'; import { AuditLogClient } from '../../core/api/audit-log.client';
import { import { AuditAnomalyAlert, AuditEvent, AuditModule, AuditStatsSummary } from '../../core/api/audit-log.models';
AuditAnomalyAlert, import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service';
AuditEvent,
AuditModule,
AuditStatsSummary,
} from '../../core/api/audit-log.models';
import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; 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 { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component';
import { import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
StellaPageTab, import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
StellaPageTabsComponent,
} from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service';
import { AuditCorrelationsComponent } from './audit-correlations.component'; import { AuditCorrelationsComponent } from './audit-correlations.component';
import { AuditLogTableComponent } from './audit-log-table.component'; import { AuditLogTableComponent } from './audit-log-table.component';
import { AuditTimelineSearchComponent } from './audit-timeline-search.component'; import { AuditTimelineSearchComponent } from './audit-timeline-search.component';
const AUDIT_TABS: StellaPageTab[] = [ 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: 'overview', { 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' },
label: 'Overview', { 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' },
icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z|||M9 22V12h6v10', { id: 'correlations', label: 'Correlations', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
},
{
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({ @Component({
@@ -57,10 +26,11 @@ const AUDIT_TABS: StellaPageTab[] = [
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
RouterLink, RouterModule,
StellaMetricCardComponent, StellaMetricCardComponent,
StellaMetricGridComponent, StellaMetricGridComponent,
StellaPageTabsComponent, StellaPageTabsComponent,
StellaQuickLinksComponent,
AuditTimelineSearchComponent, AuditTimelineSearchComponent,
AuditCorrelationsComponent, AuditCorrelationsComponent,
AuditLogTableComponent, AuditLogTableComponent,
@@ -69,16 +39,13 @@ const AUDIT_TABS: StellaPageTab[] = [
template: ` template: `
<div class="audit-dashboard"> <div class="audit-dashboard">
<header class="page-header"> <header class="page-header">
<div class="page-copy"> <div>
<h1>Audit & Compliance</h1> <h1>Audit & Compliance</h1>
<p class="description"> <p class="description">Cross-module audit trail, anomaly detection, timeline search, and event correlation.</p>
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>
</div> </div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
</aside>
</header> </header>
<stella-page-tabs <stella-page-tabs
@@ -100,50 +67,41 @@ const AUDIT_TABS: StellaPageTab[] = [
@for (entry of moduleStats(); track entry.module) { @for (entry of moduleStats(); track entry.module) {
<stella-metric-card <stella-metric-card
[label]="formatModule(entry.module)" [label]="formatModule(entry.module)"
[value]="entry.count | number" [value]="(entry.count | number) ?? '0'"
[icon]="getModuleIcon(entry.module)" [icon]="getModuleIcon(entry.module)"
/> />
} }
</stella-metric-grid> </stella-metric-grid>
} }
@if (showOverviewGuidance()) { @if (allCountsZero()) {
<section class="audit-log__empty-guidance"> <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"> <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> <p>
Audit data appears automatically as operators use the platform. The first useful Audit events appear automatically when operators create releases, change policy packs,
events usually come from release creation, policy changes, approvals, scans, and approve promotions, manage integrations, or update access controls. The log is empty
integration updates. because that activity has not happened in the selected window yet, not because audit
capture is disabled.
</p> </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"> <p class="audit-log__status-note">
This is usually a first-run state, not a failure. Once work starts flowing through The Evidence rail indicator shows <strong>ON</strong> - audit capture is active.
Stella, this page becomes the evidence trail for who changed what and when.
</p> </p>
<div class="audit-log__empty-actions"> </div>
<a routerLink="/releases" class="btn-primary">Open Releases</a> <div class="audit-log__empty-actions">
<a routerLink="/ops/policy/packs" class="btn-secondary">Review Policy Packs</a> <a routerLink="/releases" class="btn-sm">Open releases</a>
</div> <a routerLink="/ops/policy/packs" class="btn-sm btn-sm--secondary">Open policy packs</a>
</div> </div>
</section> </section>
} }
@if (anomalies().length > 0) { @if (anomalies().length > 0) {
<section class="anomaly-alerts"> <section class="anomaly-alerts">
<div class="section-header section-header--plain"> <h2>Anomaly Alerts</h2>
<h2>Anomaly Alerts</h2>
</div>
<div class="alert-list"> <div class="alert-list">
@for (alert of anomalies(); track alert.id) { @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"> <div class="alert-header">
<span class="alert-type">{{ formatAnomalyType(alert.type) }}</span> <span class="alert-type">{{ formatAnomalyType(alert.type) }}</span>
<span class="alert-time">{{ formatTime(alert.detectedAt) }}</span> <span class="alert-time">{{ formatTime(alert.detectedAt) }}</span>
@@ -152,12 +110,12 @@ const AUDIT_TABS: StellaPageTab[] = [
<div class="alert-footer"> <div class="alert-footer">
<span class="affected">{{ alert.affectedEvents.length }} events</span> <span class="affected">{{ alert.affectedEvents.length }} events</span>
@if (!alert.acknowledged) { @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 { } @else {
<span class="ack">Acked by {{ alert.acknowledgedBy }}</span> <span class="ack">Acked by {{ alert.acknowledgedBy }}</span>
} }
</div> </div>
</article> </div>
} }
</div> </div>
</section> </section>
@@ -168,282 +126,98 @@ const AUDIT_TABS: StellaPageTab[] = [
<h2>Recent Events</h2> <h2>Recent Events</h2>
<button class="link" type="button" (click)="activeTab.set('all-events')">View all</button> <button class="link" type="button" (click)="activeTab.set('all-events')">View all</button>
</div> </div>
<table class="events-table"> @if (recentEvents().length > 0) {
<thead> <table class="events-table">
<tr> <thead>
<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 {
<tr> <tr>
<td colspan="5" class="recent-events__empty-cell"> <th>Timestamp</th>
<div class="recent-events__empty"> <th>Module</th>
<p class="recent-events__empty-title">Nothing has reached the audit stream yet.</p> <th>Action</th>
<p class="recent-events__empty-copy"> <th>Actor</th>
Start with a release, scan, or policy change. The newest events will appear <th>Resource</th>
here first for quick operator review.
</p>
</div>
</td>
</tr> </tr>
} </thead>
</tbody> <tbody>
</table> @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> </section>
} }
@case ('all-events') { @case ('all-events') { <app-audit-log-table /> }
<app-audit-log-table /> @case ('timeline') { <app-audit-timeline-search /> }
} @case ('correlations') { <app-audit-correlations /> }
@case ('timeline') {
<app-audit-timeline-search />
}
@case ('correlations') {
<app-audit-correlations />
}
} }
</stella-page-tabs> </stella-page-tabs>
</div> </div>
`, `,
styles: [` styles: [`
.audit-dashboard { .audit-dashboard { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
padding: 1.5rem; .page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; gap: 1.5rem; }
max-width: 1400px; .page-header h1 { margin: 0 0 0.25rem; font-size: 1.5rem; }
margin: 0 auto; .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 { .anomaly-alerts { margin-bottom: 1.5rem; }
display: flex; .anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
justify-content: space-between; .alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }
align-items: flex-start; .alert-card {
gap: 1rem; background: var(--color-surface-primary);
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;
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: linear-gradient( padding: 1rem;
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 {
min-width: 280px; min-width: 280px;
flex: 1; 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; 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:hover { .alert-card.warning { border-left: 4px solid var(--color-status-warning); }
transform: translateY(-2px); .alert-card.error, .alert-card.critical { border-left: 4px solid var(--color-status-error); }
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); .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-card.warning { .alert-desc { font-size: 0.85rem; margin: 0 0 0.75rem; color: var(--color-text-secondary); }
border-left: 4px solid var(--color-status-warning); .alert-footer { display: flex; justify-content: space-between; align-items: center; }
} .affected { font-size: 0.75rem; color: var(--color-text-muted); }
.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;
}
.btn-sm { .btn-sm {
padding: 0.3rem 0.6rem; display: inline-flex;
border: none; align-items: center;
justify-content: center;
padding: 0.35rem 0.65rem;
font-size: 0.8rem;
cursor: pointer;
background: var(--color-btn-primary-bg); background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text); 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 { .recent-events {
background: var(--color-surface-primary); background: var(--color-surface-primary);
@@ -451,33 +225,31 @@ const AUDIT_TABS: StellaPageTab[] = [
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; 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 { .link {
border: none;
background: none;
color: var(--color-text-link);
cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-link);
text-decoration: none; text-decoration: none;
background: none;
border: none;
cursor: pointer;
} }
.link:hover { text-decoration: underline; }
.link:hover { .events-table { width: 100%; border-collapse: collapse; }
text-decoration: underline; .events-table th, .events-table td {
}
.events-table {
width: 100%;
border-collapse: collapse;
}
.events-table th,
.events-table td {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
text-align: left; text-align: left;
border-bottom: 1px solid var(--color-border-primary); border-bottom: 1px solid var(--color-border-primary);
font-size: 0.84rem; font-size: 0.84rem;
} }
.events-table th { .events-table th {
background: var(--color-surface-elevated); background: var(--color-surface-elevated);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
@@ -485,25 +257,10 @@ const AUDIT_TABS: StellaPageTab[] = [
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
.events-table tbody tr:nth-child(even) { background: var(--color-surface-elevated); }
.events-table tbody tr:nth-child(even) { .mono { font-family: monospace; font-size: 0.78rem; }
background: var(--color-surface-elevated); .clickable { cursor: pointer; transition: background 150ms ease; }
} .clickable:hover { background: rgba(59, 130, 246, 0.06); }
.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;
}
.badge { .badge {
display: inline-block; display: inline-block;
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
@@ -513,110 +270,97 @@ const AUDIT_TABS: StellaPageTab[] = [
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.02em; 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 { .audit-log__empty-guidance {
background: var(--color-surface-elevated); 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);
} }
.audit-log__empty-icon {
.badge.module.policy { width: 3rem;
background: var(--color-status-info-bg); height: 3rem;
color: var(--color-status-info-text); 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;
} }
.audit-log__empty-copy {
.badge.module.authority { display: grid;
background: var(--color-status-excepted-bg); gap: 0.5rem;
color: var(--color-status-excepted); color: var(--color-text-secondary);
line-height: 1.6;
} }
.audit-log__empty-copy h2,
.badge.module.vex { .audit-log__empty-copy p {
background: var(--color-status-success-bg); margin: 0;
color: var(--color-status-success-text);
} }
.audit-log__empty-copy h2 {
.badge.module.integrations { font-size: 1rem;
background: var(--color-status-warning-bg); color: var(--color-text-heading, var(--color-text-primary));
color: var(--color-status-warning-text);
} }
.audit-log__empty-actions {
.badge.action { grid-column: 2;
background: var(--color-surface-elevated); display: flex;
} flex-wrap: wrap;
gap: 0.75rem;
.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__status-note { font-size: 0.85rem; }
.recent-events__empty { .recent-events__empty {
padding: 1.25rem 1rem; display: grid;
text-align: center; gap: 0.5rem;
padding: 1.5rem 1rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.recent-events__empty-title, .recent-events__empty-title,
.recent-events__empty-copy { .recent-events__empty-copy {
margin: 0; margin: 0;
} }
.recent-events__empty-title { .recent-events__empty-title {
font-size: 0.95rem;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
color: var(--color-text-primary); color: var(--color-text-heading, var(--color-text-primary));
} }
.recent-events__empty-copy { .recent-events__empty-copy {
margin-top: 0.35rem; font-size: 0.85rem;
line-height: 1.55; line-height: 1.6;
}
@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%;
}
} }
`], `],
}) })
export class AuditLogDashboardComponent implements OnInit { export class AuditLogDashboardComponent implements OnInit {
private readonly auditClient = inject(AuditLogClient); private readonly auditClient = inject(AuditLogClient);
private readonly helperCtx = inject(StellaHelperContextService);
private readonly destroyRef = inject(DestroyRef); 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 auditTabs = AUDIT_TABS;
readonly activeTab = signal<string>('overview'); readonly activeTab = signal<string>('overview');
@@ -629,27 +373,28 @@ export class AuditLogDashboardComponent implements OnInit {
readonly eventsLoaded = signal(false); readonly eventsLoaded = signal(false);
readonly anomaliesLoaded = signal(false); readonly anomaliesLoaded = signal(false);
readonly overviewLoaded = computed(
() => this.statsLoaded() && this.eventsLoaded() && this.anomaliesLoaded(),
);
readonly allCountsZero = computed(() => { readonly allCountsZero = computed(() => {
const stats = this.stats(); const stats = this.stats();
if (!stats) { if (!stats) {
return false; return false;
} }
return stats.totalEvents === 0 && this.moduleStats().every((entry) => entry.count === 0); return stats.totalEvents === 0 && this.moduleStats().every((entry) => entry.count === 0);
}); });
readonly showOverviewGuidance = computed( readonly overviewLoaded = computed(() =>
() => this.overviewLoaded() && this.allCountsZero() && this.recentEvents().length === 0, this.statsLoaded() && this.eventsLoaded() && this.anomaliesLoaded()
); );
readonly helperContexts = computed(() => { readonly helperContexts = computed(() => {
const contexts: string[] = []; const contexts: string[] = [];
if (this.activeTab() === 'overview' && this.showOverviewGuidance()) { if (this.activeTab() !== 'overview' || !this.overviewLoaded()) {
contexts.push('empty-table', 'no-audit-events'); 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; return contexts;
}); });
@@ -658,7 +403,6 @@ export class AuditLogDashboardComponent implements OnInit {
effect(() => { effect(() => {
this.helperCtx.setScope('audit-log-dashboard', this.helperContexts()); this.helperCtx.setScope('audit-log-dashboard', this.helperContexts());
}, { allowSignalWrites: true }); }, { allowSignalWrites: true });
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('audit-log-dashboard')); this.destroyRef.onDestroy(() => this.helperCtx.clearScope('audit-log-dashboard'));
} }
@@ -669,10 +413,14 @@ export class AuditLogDashboardComponent implements OnInit {
loadData(): void { loadData(): void {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); 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({ this.auditClient.getStatsSummary(sevenDaysAgo).subscribe({
next: (stats) => { next: (stats) => {
this.stats.set(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, module: module as AuditModule,
count: count as number, count: count as number,
})); }));
@@ -688,7 +436,7 @@ export class AuditLogDashboardComponent implements OnInit {
this.auditClient.getEvents(undefined, undefined, 10).subscribe({ this.auditClient.getEvents(undefined, undefined, 10).subscribe({
next: (response) => { next: (response) => {
this.recentEvents.set(this.sortEventsDeterministically(response.items ?? [])); this.recentEvents.set(this.sortEventsDeterministically(response.items));
this.eventsLoaded.set(true); this.eventsLoaded.set(true);
}, },
error: () => { error: () => {
@@ -712,11 +460,7 @@ export class AuditLogDashboardComponent implements OnInit {
acknowledgeAlert(alertId: string): void { acknowledgeAlert(alertId: string): void {
this.auditClient.acknowledgeAnomaly(alertId).subscribe(() => { this.auditClient.acknowledgeAnomaly(alertId).subscribe(() => {
this.anomalies.update((alerts) => this.anomalies.update((alerts) =>
alerts.map((alert) => ( alerts.map((alert) => (alert.id === alertId ? { ...alert, acknowledged: true } : alert))
alert.id === alertId
? { ...alert, acknowledged: true }
: alert
)),
); );
}); });
} }
@@ -726,48 +470,38 @@ export class AuditLogDashboardComponent implements OnInit {
} }
formatAnomalyType(type: string): string { 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 { formatModule(module: AuditModule): string {
const labels: Partial<Record<AuditModule, string>> = { const labels: Record<string, string> = {
authority: 'Authority',
policy: 'Policy', policy: 'Policy',
jobengine: 'Job Engine', authority: 'Authority',
integrations: 'Integrations',
vex: 'VEX', vex: 'VEX',
integrations: 'Integrations',
release: 'Release',
scanner: 'Scanner', scanner: 'Scanner',
attestor: 'Attestor',
sbom: 'SBOM',
scheduler: 'Scheduler',
}; };
return labels[module] ?? module; return labels[module] || module;
} }
getModuleIcon(module: AuditModule): string { 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', 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', 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', 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', 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', 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( private sortModuleStatsDeterministically(entries: Array<{ module: AuditModule; count: number }>): Array<{ module: AuditModule; count: number }> {
entries: Array<{ module: AuditModule; count: number }>, return entries.sort((left, right) => right.count - left.count || left.module.localeCompare(right.module));
): Array<{ module: AuditModule; count: number }> {
return [...entries].sort((left, right) => right.count - left.count || left.module.localeCompare(right.module));
} }
private sortEventsDeterministically(events: AuditEvent[]): AuditEvent[] { private sortEventsDeterministically(events: AuditEvent[]): AuditEvent[] {
return [...events].sort( return events.sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime());
(left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime(),
);
} }
} }