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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user