fix(ui): JobEngine standard tabs + schedules loading + audit filter bar

JobEngine page:
- Replace custom segmented toggle with StellaPageTabsComponent
- Fix SCHEDULER_API_BASE_URL factory (new URL() always threw on relative paths)
- Fix listSchedules to include disabled schedules
- Add source field mapping for system schedule badge

Audit log page:
- Remove Overview tab, default to All Events
- Replace custom filters with standard app-filter-bar (matching other pages)
- Remove policy-specific column toggles and category chips

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-10 11:16:17 +03:00
parent d0e67e59fb
commit d25d0d60b9
9 changed files with 253 additions and 955 deletions

View File

@@ -823,13 +823,16 @@ export const appConfig: ApplicationConfig = {
provide: SCHEDULER_API_BASE_URL, provide: SCHEDULER_API_BASE_URL,
deps: [AppConfigService], deps: [AppConfigService],
useFactory: (config: AppConfigService) => { useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; // Prefer the dedicated scheduler config key (normalizes to '/scheduler'),
try { // fall back to constructing via gateway base.
return new URL('/scheduler/api/v1/scheduler', gatewayBase).toString(); const schedulerBase = config.config.apiBaseUrls.scheduler;
} catch { if (schedulerBase) {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; const normalized = schedulerBase.endsWith('/') ? schedulerBase.slice(0, -1) : schedulerBase;
return `${normalized}/scheduler/api/v1/scheduler`; return `${normalized}/api/v1/scheduler`;
} }
const gatewayBase = config.config.apiBaseUrls.gateway ?? '';
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/scheduler/api/v1/scheduler`;
}, },
}, },
SchedulerHttpClient, SchedulerHttpClient,

View File

@@ -120,8 +120,8 @@ export class SchedulerHttpClient implements SchedulerApi {
// --- Schedule endpoints --- // --- Schedule endpoints ---
listSchedules(): Observable<Schedule[]> { listSchedules(): Observable<Schedule[]> {
const params = new HttpParams().set('includeDisabled', 'false'); const params = new HttpParams().set('includeDisabled', 'true');
return this.http.get<BackendScheduleCollectionResponse | Schedule[]>(`${this.baseUrl}/schedules/`, { return this.http.get<BackendScheduleCollectionResponse | Schedule[]>(`${this.baseUrl}/schedules`, {
headers: this.buildHeaders(), headers: this.buildHeaders(),
params, params,
}).pipe( }).pipe(
@@ -297,6 +297,7 @@ export class SchedulerHttpClient implements SchedulerApi {
createdAt: this.readString(schedule, 'createdAt') || new Date().toISOString(), createdAt: this.readString(schedule, 'createdAt') || new Date().toISOString(),
updatedAt: this.readString(schedule, 'updatedAt') || new Date().toISOString(), updatedAt: this.readString(schedule, 'updatedAt') || new Date().toISOString(),
createdBy: this.readString(schedule, 'createdBy') || 'system', createdBy: this.readString(schedule, 'createdBy') || 'system',
source: (this.readString(schedule, 'source') || undefined) as Schedule['source'],
}; };
} }

View File

@@ -15,95 +15,36 @@ describe('AuditLogDashboardComponent', () => {
{ {
provide: AuditLogClient, provide: AuditLogClient,
useValue: { useValue: {
getStatsSummary: () => of({ getEvents: () => of({ items: [], cursor: null, hasMore: false }),
totalEvents: 10,
byModule: {
policy: 5,
authority: 3,
},
}),
getEvents: () => of({
items: [
{
id: 'evt-1',
timestamp: '2026-03-12T00:00:00Z',
module: 'policy',
action: 'create',
actor: { name: 'qa@example.com' },
resource: { type: 'release', id: 'rel-1', name: 'rel-1' },
},
],
}),
getAnomalyAlerts: () => of([]),
acknowledgeAnomaly: () => of(void 0),
}, },
}, },
], ],
}); });
}); });
it('routes the header export action to the canonical export center', () => { it('should create', () => {
const fixture = TestBed.createComponent(AuditLogDashboardComponent); const fixture = TestBed.createComponent(AuditLogDashboardComponent);
fixture.detectChanges(); fixture.detectChanges();
fixture.detectChanges(); expect(fixture.componentInstance).toBeTruthy();
const exportLink = fixture.debugElement
.query(By.css('.header-actions .btn-secondary'))
.nativeElement as HTMLAnchorElement;
expect(exportLink.getAttribute('href')).toBe('/evidence/exports');
}); });
it('does not show empty guidance when events exist', () => { it('defaults to all-events tab', () => {
const fixture = TestBed.createComponent(AuditLogDashboardComponent); const fixture = TestBed.createComponent(AuditLogDashboardComponent);
fixture.detectChanges(); fixture.detectChanges();
fixture.detectChanges(); expect(fixture.componentInstance.activeTab()).toBe('all-events');
});
const guidance = fixture.debugElement.query(By.css('.audit-log__empty-guidance')); it('does not render an overview tab', () => {
expect(guidance).toBeNull(); const fixture = TestBed.createComponent(AuditLogDashboardComponent);
}); fixture.detectChanges();
}); const tabs = fixture.componentInstance.auditTabs;
expect(tabs.find(t => t.id === 'overview')).toBeUndefined();
describe('AuditLogDashboardComponent (zero counts)', () => { });
beforeEach(() => {
TestBed.configureTestingModule({ it('renders the page header', () => {
imports: [AuditLogDashboardComponent], const fixture = TestBed.createComponent(AuditLogDashboardComponent);
providers: [ fixture.detectChanges();
provideRouter([]), const header = fixture.debugElement.query(By.css('.page-header h1'));
{ expect(header.nativeElement.textContent).toContain('Audit');
provide: AuditLogClient,
useValue: {
getStatsSummary: () => of({
totalEvents: 0,
byModule: {
policy: 0,
authority: 0,
vex: 0,
integrations: 0,
jobengine: 0,
scanner: 0,
attestor: 0,
sbom: 0,
scheduler: 0,
},
}),
getEvents: () => of({ items: [] }),
getAnomalyAlerts: () => of([]),
acknowledgeAnomaly: () => of(void 0),
},
},
],
});
});
it('shows empty guidance when all module counts are zero', () => {
const fixture = TestBed.createComponent(AuditLogDashboardComponent);
fixture.detectChanges();
fixture.detectChanges();
const guidance = fixture.debugElement.query(By.css('.audit-log__empty-guidance'));
expect(guidance).toBeTruthy();
expect(guidance.nativeElement.textContent).toContain('Audit events will appear as the platform is used');
expect(guidance.nativeElement.textContent).toContain('audit capture is active');
}); });
}); });

View File

@@ -1,13 +1,9 @@
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer // Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, effect, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterModule } 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 { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service'; 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 { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; 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 { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
import { AuditCorrelationsComponent } from './audit-correlations.component'; import { AuditCorrelationsComponent } from './audit-correlations.component';
@@ -17,7 +13,6 @@ import { ExportCenterComponent } from '../evidence-export/export-center.componen
import { TriageAuditBundlesComponent } from '../triage/triage-audit-bundles.component'; import { TriageAuditBundlesComponent } from '../triage/triage-audit-bundles.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: '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: '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: '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: 'correlations', label: 'Correlations', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
@@ -31,8 +26,6 @@ const AUDIT_TABS: StellaPageTab[] = [
imports: [ imports: [
CommonModule, CommonModule,
RouterModule, RouterModule,
StellaMetricCardComponent,
StellaMetricGridComponent,
StellaPageTabsComponent, StellaPageTabsComponent,
StellaQuickLinksComponent, StellaQuickLinksComponent,
AuditTimelineSearchComponent, AuditTimelineSearchComponent,
@@ -62,110 +55,6 @@ const AUDIT_TABS: StellaPageTab[] = [
ariaLabel="Audit log sections" ariaLabel="Audit log sections"
> >
@switch (activeTab()) { @switch (activeTab()) {
@case ('overview') {
@if (stats()) {
<stella-metric-grid [columns]="moduleStats().length + 1">
<stella-metric-card
label="Total Events (7d)"
[value]="(stats()?.totalEvents | number) ?? '0'"
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"
/>
@for (entry of moduleStats(); track entry.module) {
<stella-metric-card
[label]="formatModule(entry.module)"
[value]="(entry.count | number) ?? '0'"
[icon]="getModuleIcon(entry.module)"
/>
}
</stella-metric-grid>
}
@if (allCountsZero()) {
<section class="audit-log__empty-guidance">
<div class="audit-log__empty-icon" aria-hidden="true">AU</div>
<div class="audit-log__empty-copy">
<h2>No audit events have been captured for this scope yet</h2>
<p>
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>
<p class="audit-log__status-note">
The Evidence rail indicator shows <strong>ON</strong> - audit capture is active.
</p>
</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">
<h2>Anomaly Alerts</h2>
<div class="alert-list">
@for (alert of anomalies(); track alert.id) {
<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>
</div>
<p class="alert-desc">{{ alert.description }}</p>
<div class="alert-footer">
<span class="affected">{{ alert.affectedEvents.length }} events</span>
@if (!alert.acknowledged) {
<button class="btn-sm" type="button" (click)="acknowledgeAlert(alert.id)">Acknowledge</button>
} @else {
<span class="ack">Acked by {{ alert.acknowledgedBy }}</span>
}
</div>
</div>
}
</div>
</section>
}
<section class="recent-events">
<div class="section-header">
<h2>Recent Events</h2>
<button class="link" type="button" (click)="activeTab.set('all-events')">View all</button>
</div>
@if (recentEvents().length > 0) {
<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>
}
</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 ('all-events') { <app-audit-log-table /> }
@case ('timeline') { <app-audit-timeline-search /> } @case ('timeline') { <app-audit-timeline-search /> }
@case ('correlations') { <app-audit-correlations /> } @case ('correlations') { <app-audit-correlations /> }
@@ -181,190 +70,9 @@ const AUDIT_TABS: StellaPageTab[] = [
.page-header h1 { margin: 0 0 0.25rem; font-size: 1.5rem; } .page-header h1 { margin: 0 0 0.25rem; font-size: 1.5rem; }
.page-aside { flex: 0 1 60%; min-width: 0; } .page-aside { flex: 0 1 60%; min-width: 0; }
.description { color: var(--color-text-secondary); margin: 0; font-size: 0.9rem; } .description { color: var(--color-text-secondary); margin: 0; font-size: 0.9rem; }
stella-metric-grid { margin-bottom: 1.5rem; }
.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);
padding: 1rem;
min-width: 280px;
flex: 1;
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 { 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 {
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);
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);
border: 1px solid var(--color-border-primary);
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 {
font-size: 0.85rem;
color: var(--color-text-link);
text-decoration: none;
background: none;
border: none;
cursor: pointer;
}
.link:hover { text-decoration: underline; }
.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);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.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;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: var(--font-weight-medium);
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.module.release { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.module.attestor { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.module.doctor { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.badge.module.signals { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.badge.module.advisory-ai { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.module.riskengine { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.action { background: var(--color-surface-elevated); }
.badge.action.create { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.action.update { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.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; }
.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);
}
.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;
}
.audit-log__empty-copy {
display: grid;
gap: 0.5rem;
color: var(--color-text-secondary);
line-height: 1.6;
}
.audit-log__empty-copy h2,
.audit-log__empty-copy p {
margin: 0;
}
.audit-log__empty-copy h2 {
font-size: 1rem;
color: var(--color-text-heading, var(--color-text-primary));
}
.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 {
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-heading, var(--color-text-primary));
}
.recent-events__empty-copy {
font-size: 0.85rem;
line-height: 1.6;
}
`], `],
}) })
export class AuditLogDashboardComponent implements OnInit { export class AuditLogDashboardComponent {
private readonly auditClient = inject(AuditLogClient);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly helperCtx = inject(StellaHelperContextService); private readonly helperCtx = inject(StellaHelperContextService);
@@ -375,159 +83,13 @@ export class AuditLogDashboardComponent implements OnInit {
]; ];
readonly auditTabs = AUDIT_TABS; readonly auditTabs = AUDIT_TABS;
readonly activeTab = signal<string>('overview'); readonly activeTab = signal<string>('all-events');
readonly stats = signal<AuditStatsSummary | null>(null);
readonly recentEvents = signal<AuditEvent[]>([]);
readonly anomalies = signal<AuditAnomalyAlert[]>([]);
readonly moduleStats = signal<Array<{ module: AuditModule; count: number }>>([]);
readonly statsLoaded = signal(false);
readonly eventsLoaded = signal(false);
readonly anomaliesLoaded = signal(false);
readonly allCountsZero = computed(() => {
const stats = this.stats();
if (!stats) {
return false;
}
return stats.totalEvents === 0 && this.moduleStats().every((entry) => entry.count === 0);
});
readonly overviewLoaded = computed(() =>
this.statsLoaded() && this.eventsLoaded() && this.anomaliesLoaded()
);
readonly helperContexts = computed(() => {
const contexts: string[] = [];
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;
});
constructor() { constructor() {
effect(() => { effect(() => {
this.helperCtx.setScope('audit-log-dashboard', this.helperContexts()); this.helperCtx.setScope('audit-log-dashboard', []);
}); });
this.destroyRef.onDestroy(() => this.helperCtx.clearScope('audit-log-dashboard')); this.destroyRef.onDestroy(() => this.helperCtx.clearScope('audit-log-dashboard'));
} }
ngOnInit(): void {
this.loadData();
}
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]) => ({
module: module as AuditModule,
count: count as number,
}));
this.moduleStats.set(this.sortModuleStatsDeterministically(moduleEntries));
this.statsLoaded.set(true);
},
error: () => {
this.stats.set(null);
this.moduleStats.set([]);
this.statsLoaded.set(true);
},
});
this.auditClient.getEvents(undefined, undefined, 10).subscribe({
next: (response) => {
this.recentEvents.set(this.sortEventsDeterministically(response.items));
this.eventsLoaded.set(true);
},
error: () => {
this.recentEvents.set([]);
this.eventsLoaded.set(true);
},
});
this.auditClient.getAnomalyAlerts(false, 5).subscribe({
next: (alerts) => {
this.anomalies.set(alerts);
this.anomaliesLoaded.set(true);
},
error: () => {
this.anomalies.set([]);
this.anomaliesLoaded.set(true);
},
});
}
acknowledgeAlert(alertId: string): void {
this.auditClient.acknowledgeAnomaly(alertId).subscribe(() => {
this.anomalies.update((alerts) =>
alerts.map((alert) => (alert.id === alertId ? { ...alert, acknowledged: true } : alert))
);
});
}
formatTime(timestamp: string): string {
return new Date(timestamp).toLocaleString();
}
formatAnomalyType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (value) => value.toUpperCase());
}
formatModule(module: AuditModule): string {
const labels: Record<string, string> = {
policy: 'Policy',
authority: 'Authority',
vex: 'VEX',
integrations: 'Integrations',
release: 'Release',
scanner: 'Scanner',
attestor: 'Attestor',
sbom: 'SBOM',
scheduler: 'Scheduler',
jobengine: 'JobEngine',
doctor: 'Doctor',
signals: 'Signals',
'advisory-ai': 'Advisory AI',
riskengine: 'Risk Engine',
};
return labels[module] || module;
}
getModuleIcon(module: 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',
release: 'M12 2L2 7l10 5 10-5-10-5z|||M2 17l10 5 10-5|||M2 12l10 5 10-5',
scanner: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0',
attestor: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0 1 12 2.944a11.955 11.955 0 0 1-8.618 3.04A12.02 12.02 0 0 0 3 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
doctor: 'M22 12h-4l-3 9L9 3l-3 9H2',
signals: 'M2 20h.01|||M7 20v-4|||M12 20v-8|||M17 20V8|||M22 20V4',
'advisory-ai': 'M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1.27A7 7 0 0 1 14 22h-4a7 7 0 0 1-6.73-3H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z|||M10 15.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z|||M14 15.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z',
riskengine: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01',
};
return icons[module] || 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0';
}
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());
}
} }

View File

@@ -43,44 +43,33 @@ describe('AuditLogTableComponent', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it('renders the audit-specific filter toolbar with actor search', () => { it('renders the standard filter bar component', () => {
const element: HTMLElement = fixture.nativeElement; const element: HTMLElement = fixture.nativeElement;
expect(element.querySelector('.filters-bar')).toBeTruthy(); expect(element.querySelector('app-filter-bar')).toBeTruthy();
expect(element.textContent).toContain('Actor');
expect(element.querySelector('app-filter-bar')).toBeFalsy();
}); });
it('builds filters with multi-select arrays and actor name', () => { it('builds filters with single-value dropdowns', () => {
component.selectedModules = ['policy', 'scanner']; component.selectedModule = 'policy';
component.selectedActions = ['approve', 'replay']; component.selectedAction = 'approve';
component.selectedSeverities = ['warning', 'critical']; component.selectedSeverity = 'warning';
component.actorFilter = 'ops@stella.local';
expect(component.buildFilters()).toEqual( expect(component.buildFilters()).toEqual(
jasmine.objectContaining({ jasmine.objectContaining({
modules: ['policy', 'scanner'], modules: ['policy'],
actions: ['approve', 'replay'], actions: ['approve'],
severities: ['warning', 'critical'], severities: ['warning'],
actorName: 'ops@stella.local',
}), }),
); );
}); });
it('builds custom start and end dates when custom range is selected', () => { it('builds date range filter for preset values', () => {
component.dateRange = 'custom'; component.dateRange = '24h';
component.customStartDate = '2026-03-01'; const filters = component.buildFilters();
component.customEndDate = '2026-03-07'; expect(filters.startDate).toBeTruthy();
expect(component.buildFilters()).toEqual(
jasmine.objectContaining({
startDate: '2026-03-01',
endDate: '2026-03-07',
}),
);
}); });
it('resets paging state when applyFilters is called', () => { it('resets paging state when applyFilters is called', () => {
component.selectedModules = ['scanner']; component.selectedModule = 'scanner';
component.cursor.set('cursor-1'); component.cursor.set('cursor-1');
(component as { cursorStack: string[] }).cursorStack = ['cursor-0']; (component as { cursorStack: string[] }).cursorStack = ['cursor-0'];
component.hasPrev.set(true); component.hasPrev.set(true);
@@ -92,29 +81,23 @@ describe('AuditLogTableComponent', () => {
expect(mockClient.getEvents).toHaveBeenCalled(); expect(mockClient.getEvents).toHaveBeenCalled();
}); });
it('clears all restored filters', () => { it('clears all filter state', () => {
component.selectedModules = ['scanner']; component.selectedModule = 'scanner';
component.selectedActions = ['replay']; component.selectedAction = 'replay';
component.selectedSeverities = ['error']; component.selectedSeverity = 'error';
component.dateRange = 'custom'; component.dateRange = '30d';
component.customStartDate = '2026-03-01';
component.customEndDate = '2026-03-07';
component.searchQuery = 'digest'; component.searchQuery = 'digest';
component.actorFilter = 'operator';
component.clearFilters(); component.clearFilters();
expect(component.selectedModules.length).toBe(0); expect(component.selectedModule).toBe('');
expect(component.selectedActions.length).toBe(0); expect(component.selectedAction).toBe('');
expect(component.selectedSeverities.length).toBe(0); expect(component.selectedSeverity).toBe('');
expect(component.dateRange).toBe('7d'); expect(component.dateRange).toBe('7d');
expect(component.searchQuery).toBe(''); expect(component.searchQuery).toBe('');
expect(component.actorFilter).toBe('');
expect(component.customStartDate).toBe('');
expect(component.customEndDate).toBe('');
}); });
it('formats modules for the multi-select options', () => { it('formats modules for display', () => {
expect(component.formatModule('authority')).toBe('Authority'); expect(component.formatModule('authority')).toBe('Authority');
expect(component.formatModule('jobengine')).toBe('JobEngine'); expect(component.formatModule('jobengine')).toBe('JobEngine');
}); });

View File

@@ -1,26 +1,16 @@
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer // Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core'; import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute } from '@angular/router'; import { RouterModule, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AuditLogClient } from '../../core/api/audit-log.client'; import { AuditLogClient } from '../../core/api/audit-log.client';
import { AuditEvent, AuditDiff, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } from '../../core/api/audit-log.models'; import { AuditEvent, AuditDiff, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } from '../../core/api/audit-log.models';
import { AuditEventDetailsPanelComponent } from './audit-event-details-panel.component'; import { AuditEventDetailsPanelComponent } from './audit-event-details-panel.component';
import { FilterBarComponent, type FilterOption, type ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component';
type PolicyCategory = 'all' | 'governance' | 'promotions' | 'approvals' | 'rejections' | 'simulations';
const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
all: null,
governance: ['create', 'update', 'delete', 'enable', 'disable'],
promotions: ['promote'],
approvals: ['approve'],
rejections: ['reject'],
simulations: ['test'],
};
@Component({ @Component({
selector: 'app-audit-log-table', selector: 'app-audit-log-table',
imports: [CommonModule, RouterModule, FormsModule, AuditEventDetailsPanelComponent], imports: [CommonModule, RouterModule, FormsModule, AuditEventDetailsPanelComponent, FilterBarComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="audit-table-page"> <div class="audit-table-page">
@@ -31,97 +21,16 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
<h1>Audit Events</h1> <h1>Audit Events</h1>
</header> </header>
@if (selectedModules.length === 1) { <app-filter-bar
<div class="module-context-link"> searchPlaceholder="Search events by description, resource, or actor..."
@switch (selectedModules[0]) { [filters]="filterBarOptions"
@case ('authority') { <a routerLink="/console/admin" [queryParams]="{tab: 'audit'}">View in Console Admin &rarr;</a> } [activeFilters]="activeFilterPills()"
@case ('vex') { <a routerLink="/ops/policy/vex/explorer" [queryParams]="{tab: 'audit'}">View in VEX Hub &rarr;</a> } (searchChange)="onFilterBarSearch($event)"
@case ('integrations') { <a routerLink="/integrations" [queryParams]="{tab: 'config-audit'}">View in Integration Hub &rarr;</a> } (searchSubmit)="onFilterBarSearchSubmit($event)"
@case ('jobengine') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs &rarr;</a> } (filterChange)="onFilterBarChanged($event)"
@case ('scheduler') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs &rarr;</a> } (filterRemove)="onFilterBarRemoved($event)"
@case ('scanner') { <a routerLink="/platform-ops/scanner" [queryParams]="{tab: 'audit'}">View in Scanner Ops &rarr;</a> } (filtersCleared)="clearFilters()"
} ></app-filter-bar>
</div>
}
<div class="filters-bar">
<div class="filter-row">
<div class="filter-group">
<label>Modules</label>
<select multiple [(ngModel)]="selectedModules" (change)="onModuleChange()">
@for (m of allModules; track m) {
<option [value]="m">{{ formatModule(m) }}</option>
}
</select>
</div>
<div class="filter-group">
<label>Actions</label>
<select multiple [(ngModel)]="selectedActions" (change)="applyFilters()" [disabled]="isPolicyOnly && policyCategory !== 'all'">
@for (a of allActions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</div>
<div class="filter-group">
<label>Severity</label>
<select multiple [(ngModel)]="selectedSeverities" (change)="applyFilters()">
@for (s of allSeverities; track s) {
<option [value]="s">{{ s }}</option>
}
</select>
</div>
<div class="filter-group">
<label>Date Range</label>
<select [(ngModel)]="dateRange" (change)="applyFilters()">
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
<option value="custom">Custom</option>
</select>
</div>
@if (dateRange === 'custom') {
<div class="filter-group">
<label>Start</label>
<input type="date" [(ngModel)]="customStartDate" (change)="applyFilters()" />
</div>
<div class="filter-group">
<label>End</label>
<input type="date" [(ngModel)]="customEndDate" (change)="applyFilters()" />
</div>
}
</div>
<div class="filter-row">
<div class="filter-group search-group">
<label>Search</label>
<input type="text" [(ngModel)]="searchQuery" placeholder="Search events..." (keyup.enter)="applyFilters()" />
<button class="btn-sm" (click)="applyFilters()">Search</button>
</div>
<div class="filter-group">
<label>Actor</label>
<input type="text" [(ngModel)]="actorFilter" placeholder="Username or email" (keyup.enter)="applyFilters()" />
</div>
<button class="btn-secondary" (click)="clearFilters()">Clear Filters</button>
<div class="column-toggles">
<label class="column-toggle">
<input type="checkbox" [checked]="showHttpColumns()" (change)="showHttpColumns.set(!showHttpColumns())" />
HTTP columns
</label>
</div>
</div>
</div>
@if (isPolicyOnly) {
<div class="policy-category-chips">
@for (cat of policyCategoryList; track cat.id) {
<button
class="category-chip"
[class.category-chip--active]="policyCategory === cat.id"
(click)="setPolicyCategory(cat.id)"
>{{ cat.label }}</button>
}
</div>
}
@if (loading()) { @if (loading()) {
<div class="loading">Loading events...</div> <div class="loading">Loading events...</div>
@@ -131,24 +40,12 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
<thead> <thead>
<tr> <tr>
<th>Timestamp (UTC)</th> <th>Timestamp (UTC)</th>
@if (!isPolicyOnly) { <th>Module</th> } <th>Module</th>
<th>Action</th> <th>Action</th>
<th>Severity</th> <th>Severity</th>
@if (isPolicyOnly) {
<th>Pack</th>
<th>Policy Hash</th>
}
<th>Actor</th> <th>Actor</th>
<th>Resource</th> <th>Resource</th>
@if (isPolicyOnly) { <th>Description</th>
<th>Shadow Mode</th>
<th>Coverage</th>
}
@if (showHttpColumns()) {
<th>Method</th>
<th>Status</th>
}
@if (!isPolicyOnly) { <th>Description</th> }
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -156,15 +53,9 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
@for (event of events(); track event.id) { @for (event of events(); track event.id) {
<tr [class]="event.severity" (click)="selectEvent(event)" [class.selected]="selectedEvent()?.id === event.id"> <tr [class]="event.severity" (click)="selectEvent(event)" [class.selected]="selectedEvent()?.id === event.id">
<td class="mono">{{ formatTimestamp(event.timestamp) }}</td> <td class="mono">{{ formatTimestamp(event.timestamp) }}</td>
@if (!isPolicyOnly) { <td><span class="badge module" [class]="event.module">{{ formatModule(event.module) }}</span></td>
<td><span class="badge module" [class]="event.module">{{ formatModule(event.module) }}</span></td>
}
<td><span class="badge action" [class]="event.action">{{ event.action }}</span></td> <td><span class="badge action" [class]="event.action">{{ event.action }}</span></td>
<td><span class="badge severity" [class]="event.severity">{{ event.severity }}</span></td> <td><span class="badge severity" [class]="event.severity">{{ event.severity }}</span></td>
@if (isPolicyOnly) {
<td>{{ getDetail(event, 'packName') || getDetail(event, 'packId') || '-' }}</td>
<td class="mono hash">{{ truncateHash(getDetail(event, 'policyHash')) }}</td>
}
<td> <td>
<span class="actor" [title]="event.actor.email || ''"> <span class="actor" [title]="event.actor.email || ''">
{{ event.actor.name }} {{ event.actor.name }}
@@ -172,30 +63,7 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
</span> </span>
</td> </td>
<td class="resource">{{ event.resource.type }}: {{ event.resource.name || event.resource.id }}</td> <td class="resource">{{ event.resource.type }}: {{ event.resource.name || event.resource.id }}</td>
@if (isPolicyOnly) { <td class="description">{{ event.description }}</td>
<td>
@if (getDetail(event, 'shadowModeStatus')) {
<span class="badge shadow" [class]="getDetail(event, 'shadowModeStatus')">
{{ getDetail(event, 'shadowModeStatus') }}
@if (getDetail(event, 'shadowModeDays')) {
({{ getDetail(event, 'shadowModeDays') }}d)
}
</span>
} @else { - }
</td>
<td>
@if (getDetail(event, 'coverage') !== undefined && getDetail(event, 'coverage') !== null) {
{{ getDetail(event, 'coverage') }}%
} @else { - }
</td>
}
@if (showHttpColumns()) {
<td><span class="mono">{{ getDetail(event, 'httpMethod') || '-' }}</span></td>
<td><span class="mono">{{ getDetail(event, 'statusCode') || '-' }}</span></td>
}
@if (!isPolicyOnly) {
<td class="description">{{ event.description }}</td>
}
<td> <td>
<a [routerLink]="[event.id]" class="link">View</a> <a [routerLink]="[event.id]" class="link">View</a>
@if (event.diff || hasBeforeState(event)) { @if (event.diff || hasBeforeState(event)) {
@@ -204,7 +72,7 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
</td> </td>
</tr> </tr>
} @empty { } @empty {
<tr><td [attr.colspan]="(isPolicyOnly ? 10 : 8) + (showHttpColumns() ? 2 : 0)" style="text-align:center;padding:2rem;color:var(--color-text-muted)">No events match the current filters.</td></tr> <tr><td colspan="8" style="text-align:center;padding:2rem;color:var(--color-text-muted)">No events match the current filters.</td></tr>
} }
</tbody> </tbody>
</table> </table>
@@ -377,79 +245,6 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; } .breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; } .breadcrumb a { color: var(--color-text-link); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; } .breadcrumb a:hover { text-decoration: underline; }
.module-context-link { text-align: right; margin-bottom: 0.5rem; font-size: 0.85rem; }
.module-context-link a { color: var(--color-text-link); text-decoration: none; }
.module-context-link a:hover { text-decoration: underline; }
.filters-bar { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 0.85rem 1rem; margin-bottom: 1.5rem; }
.filter-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.6rem; align-items: flex-end; }
.filter-row:last-child { margin-bottom: 0; }
.filter-group { display: flex; flex-direction: column; gap: 0.2rem; }
.filter-group select[multiple] { height: 72px; }
.search-group { flex: 1; min-width: 200px; flex-direction: row; align-items: flex-end; }
.search-group label { display: none; }
.search-group input { flex: 1; }
.btn-sm {
padding: 0.4rem 0.75rem; cursor: pointer;
background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text);
border: none; border-radius: var(--radius-sm);
font-weight: var(--font-weight-medium); font-size: 0.84rem;
transition: opacity 150ms ease;
}
.btn-sm:hover { opacity: 0.9; }
.btn-secondary {
padding: 0.4rem 0.85rem; cursor: pointer;
background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm); align-self: flex-end; font-size: 0.84rem;
transition: border-color 150ms ease;
}
.btn-secondary:hover { border-color: var(--color-brand-primary); }
/* Column toggles */
.column-toggles {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.column-toggle {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.78rem;
color: var(--color-text-secondary);
cursor: pointer;
}
.column-toggle input { cursor: pointer; }
/* Policy sub-category chips */
.policy-category-chips {
display: flex; gap: 0.5rem; margin-bottom: 1rem;
}
.category-chip {
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-muted);
transition: all 0.15s ease;
}
.category-chip:hover {
background: var(--color-surface-tertiary);
color: var(--color-text-primary);
}
.category-chip--active {
background: var(--color-brand-primary);
color: #fff;
border-color: var(--color-brand-primary);
}
.category-chip--active:hover {
background: var(--color-brand-primary);
color: #fff;
}
/* Loading skeleton */ /* Loading skeleton */
.loading { .loading {
text-align: center; padding: 3rem; color: var(--color-text-secondary); text-align: center; padding: 3rem; color: var(--color-text-secondary);
@@ -478,7 +273,6 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
.events-table tr.critical { background: var(--color-status-error-bg); } .events-table tr.critical { background: var(--color-status-error-bg); }
.events-table tr.warning { background: var(--color-status-warning-bg); } .events-table tr.warning { background: var(--color-status-warning-bg); }
.mono { font-family: monospace; font-size: 0.78rem; } .mono { font-family: monospace; font-size: 0.78rem; }
.hash { max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 9999px; font-size: 0.68rem; font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; } .badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 9999px; font-size: 0.68rem; font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; }
.badge.module { background: var(--color-surface-elevated); } .badge.module { background: var(--color-surface-elevated); }
.badge.module.policy { background: var(--color-status-info-bg); color: var(--color-status-info-text); } .badge.module.policy { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
@@ -502,10 +296,6 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
.badge.severity.warning { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); } .badge.severity.warning { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.severity.error { background: var(--color-status-error-bg); color: var(--color-status-error-text); } .badge.severity.error { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.badge.severity.critical { background: var(--color-status-error-text); color: white; } .badge.severity.critical { background: var(--color-status-error-text); color: white; }
.badge.shadow { font-size: 0.72rem; }
.badge.shadow.active { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.badge.shadow.completed { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.shadow.disabled { background: var(--color-surface-elevated); }
.actor-type { font-size: 0.7rem; color: var(--color-text-muted); } .actor-type { font-size: 0.7rem; color: var(--color-text-muted); }
.resource, .description { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .resource, .description { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { color: var(--color-text-link); text-decoration: none; font-size: 0.8rem; } .link { color: var(--color-text-link); text-decoration: none; font-size: 0.8rem; }
@@ -604,58 +394,126 @@ export class AuditLogTableComponent implements OnInit {
readonly cursor = signal<string | null>(null); readonly cursor = signal<string | null>(null);
readonly hasMore = signal(false); readonly hasMore = signal(false);
readonly hasPrev = signal(false); readonly hasPrev = signal(false);
readonly showHttpColumns = signal(false);
private cursorStack: string[] = []; private cursorStack: string[] = [];
// Filter state // Filter state
selectedModules: AuditModule[] = []; selectedModule = '';
selectedActions: AuditAction[] = []; selectedAction = '';
selectedSeverities: AuditSeverity[] = []; selectedSeverity = '';
dateRange = '7d'; dateRange = '7d';
customStartDate = '';
customEndDate = '';
searchQuery = ''; searchQuery = '';
actorFilter = '';
// Policy sub-category state
policyCategory: PolicyCategory = 'all';
get isPolicyOnly(): boolean {
return this.selectedModules.length === 1 && this.selectedModules[0] === 'policy';
}
readonly policyCategoryList: { id: PolicyCategory; label: string }[] = [
{ id: 'all', label: 'All Policy' },
{ id: 'governance', label: 'Governance' },
{ id: 'promotions', label: 'Promotions' },
{ id: 'approvals', label: 'Approvals' },
{ id: 'rejections', label: 'Rejections' },
{ id: 'simulations', label: 'Simulations' },
];
readonly allModules: AuditModule[] = ['authority', 'policy', 'jobengine', 'integrations', 'vex', 'scanner', 'attestor', 'sbom', 'scheduler', 'release', 'doctor', 'signals', 'advisory-ai', 'riskengine']; readonly allModules: AuditModule[] = ['authority', 'policy', 'jobengine', 'integrations', 'vex', 'scanner', 'attestor', 'sbom', 'scheduler', 'release', 'doctor', 'signals', 'advisory-ai', 'riskengine'];
readonly allActions: AuditAction[] = ['create', 'update', 'delete', 'promote', 'demote', 'revoke', 'issue', 'refresh', 'test', 'fail', 'complete', 'start', 'submit', 'approve', 'reject', 'sign', 'verify', 'rotate', 'enable', 'disable', 'deadletter', 'replay']; readonly allActions: AuditAction[] = ['create', 'update', 'delete', 'promote', 'demote', 'revoke', 'issue', 'refresh', 'test', 'fail', 'complete', 'start', 'submit', 'approve', 'reject', 'sign', 'verify', 'rotate', 'enable', 'disable', 'deadletter', 'replay'];
readonly allSeverities: AuditSeverity[] = ['info', 'warning', 'error', 'critical']; readonly allSeverities: AuditSeverity[] = ['info', 'warning', 'error', 'critical'];
// Standard filter bar integration
readonly filterBarOptions: FilterOption[] = [
{
key: 'module', label: 'Module',
options: [
{ value: 'authority', label: 'Authority' },
{ value: 'policy', label: 'Policy' },
{ value: 'jobengine', label: 'JobEngine' },
{ value: 'integrations', label: 'Integrations' },
{ value: 'vex', label: 'VEX' },
{ value: 'scanner', label: 'Scanner' },
{ value: 'attestor', label: 'Attestor' },
{ value: 'sbom', label: 'SBOM' },
{ value: 'scheduler', label: 'Scheduler' },
{ value: 'release', label: 'Release' },
{ value: 'doctor', label: 'Doctor' },
{ value: 'signals', label: 'Signals' },
{ value: 'advisory-ai', label: 'Advisory AI' },
{ value: 'riskengine', label: 'Risk Engine' },
],
},
{
key: 'action', label: 'Action',
options: [
{ value: 'create', label: 'Create' },
{ value: 'update', label: 'Update' },
{ value: 'delete', label: 'Delete' },
{ value: 'promote', label: 'Promote' },
{ value: 'approve', label: 'Approve' },
{ value: 'reject', label: 'Reject' },
{ value: 'revoke', label: 'Revoke' },
{ value: 'issue', label: 'Issue' },
{ value: 'sign', label: 'Sign' },
{ value: 'verify', label: 'Verify' },
{ value: 'fail', label: 'Fail' },
{ value: 'complete', label: 'Complete' },
{ value: 'enable', label: 'Enable' },
{ value: 'disable', label: 'Disable' },
],
},
{
key: 'severity', label: 'Severity',
options: [
{ value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warning' },
{ value: 'error', label: 'Error' },
{ value: 'critical', label: 'Critical' },
],
},
{
key: 'dateRange', label: 'Date Range',
options: [
{ value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: '90d', label: 'Last 90 days' },
],
},
];
readonly activeFilterPills = signal<ActiveFilter[]>([]);
ngOnInit(): void { ngOnInit(): void {
const moduleParam = this.route.snapshot.queryParamMap.get('module'); const moduleParam = this.route.snapshot.queryParamMap.get('module');
if (moduleParam && this.allModules.includes(moduleParam as AuditModule)) { if (moduleParam && this.allModules.includes(moduleParam as AuditModule)) {
this.selectedModules = [moduleParam as AuditModule]; this.selectedModule = moduleParam;
this.rebuildPills();
} }
this.loadEvents(); this.loadEvents();
} }
onModuleChange(): void { onFilterBarSearch(value: string): void {
if (!this.isPolicyOnly) { this.searchQuery = value;
this.policyCategory = 'all'; }
}
onFilterBarSearchSubmit(value: string): void {
this.searchQuery = value;
this.applyFilters(); this.applyFilters();
} }
setPolicyCategory(category: PolicyCategory): void { onFilterBarChanged(filter: ActiveFilter): void {
this.policyCategory = category; const map: Record<string, string> = {
const actions = POLICY_CATEGORY_ACTIONS[category]; module: 'selectedModule',
this.selectedActions = actions ? [...actions] : []; action: 'selectedAction',
severity: 'selectedSeverity',
dateRange: 'dateRange',
};
const prop = map[filter.key];
if (prop) {
(this as any)[prop] = filter.value;
}
this.rebuildPills();
this.applyFilters();
}
onFilterBarRemoved(filter: ActiveFilter): void {
const map: Record<string, string> = {
module: 'selectedModule',
action: 'selectedAction',
severity: 'selectedSeverity',
dateRange: 'dateRange',
};
const prop = map[filter.key];
if (prop) {
(this as any)[prop] = filter.key === 'dateRange' ? '7d' : '';
}
this.rebuildPills();
this.applyFilters(); this.applyFilters();
} }
@@ -676,11 +534,10 @@ export class AuditLogTableComponent implements OnInit {
buildFilters(): AuditLogFilters { buildFilters(): AuditLogFilters {
const filters: AuditLogFilters = {}; const filters: AuditLogFilters = {};
if (this.selectedModules.length) filters.modules = this.selectedModules; if (this.selectedModule) filters.modules = [this.selectedModule as AuditModule];
if (this.selectedActions.length) filters.actions = this.selectedActions; if (this.selectedAction) filters.actions = [this.selectedAction as AuditAction];
if (this.selectedSeverities.length) filters.severities = this.selectedSeverities; if (this.selectedSeverity) filters.severities = [this.selectedSeverity as AuditSeverity];
if (this.searchQuery) filters.search = this.searchQuery; if (this.searchQuery) filters.search = this.searchQuery;
if (this.actorFilter) filters.actorName = this.actorFilter;
const now = new Date(); const now = new Date();
if (this.dateRange === '24h') { if (this.dateRange === '24h') {
@@ -691,9 +548,6 @@ export class AuditLogTableComponent implements OnInit {
filters.startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); filters.startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
} else if (this.dateRange === '90d') { } else if (this.dateRange === '90d') {
filters.startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(); filters.startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString();
} else if (this.dateRange === 'custom') {
if (this.customStartDate) filters.startDate = this.customStartDate;
if (this.customEndDate) filters.endDate = this.customEndDate;
} }
return filters; return filters;
@@ -707,18 +561,33 @@ export class AuditLogTableComponent implements OnInit {
} }
clearFilters(): void { clearFilters(): void {
this.selectedModules = []; this.selectedModule = '';
this.selectedActions = []; this.selectedAction = '';
this.selectedSeverities = []; this.selectedSeverity = '';
this.dateRange = '7d'; this.dateRange = '7d';
this.customStartDate = '';
this.customEndDate = '';
this.searchQuery = ''; this.searchQuery = '';
this.actorFilter = ''; this.activeFilterPills.set([]);
this.policyCategory = 'all';
this.applyFilters(); this.applyFilters();
} }
private rebuildPills(): void {
const pills: ActiveFilter[] = [];
const defs = [
{ key: 'module', prop: 'selectedModule', label: 'Module' },
{ key: 'action', prop: 'selectedAction', label: 'Action' },
{ key: 'severity', prop: 'selectedSeverity', label: 'Severity' },
{ key: 'dateRange', prop: 'dateRange', label: 'Date Range' },
];
for (const def of defs) {
const val = (this as any)[def.prop] as string;
if (val && (def.key !== 'dateRange' || val !== '7d')) {
const opt = this.filterBarOptions.find(f => f.key === def.key)?.options.find(o => o.value === val);
pills.push({ key: def.key, value: val, label: def.label + ': ' + (opt?.label || val) });
}
}
this.activeFilterPills.set(pills);
}
nextPage(): void { nextPage(): void {
if (this.cursor()) { if (this.cursor()) {
this.cursorStack.push(this.cursor()!); this.cursorStack.push(this.cursor()!);
@@ -753,11 +622,6 @@ export class AuditLogTableComponent implements OnInit {
return event.details?.[key]; return event.details?.[key];
} }
truncateHash(hash: any): string {
const s = String(hash ?? '');
return s.length > 12 ? s.slice(0, 12) + '...' : s || '-';
}
hasGovernanceDiff(event: AuditEvent): boolean { hasGovernanceDiff(event: AuditEvent): boolean {
const diff = event?.diff; const diff = event?.diff;
if (!diff) return false; if (!diff) return false;

View File

@@ -24,6 +24,6 @@ export const auditLogRoutes: Routes = [
// Cross-cutting tabs remain in unified dashboard // Cross-cutting tabs remain in unified dashboard
{ path: 'timeline', redirectTo: '?tab=timeline', pathMatch: 'full' }, { path: 'timeline', redirectTo: '?tab=timeline', pathMatch: 'full' },
{ path: 'correlations', redirectTo: '?tab=correlations', pathMatch: 'full' }, { path: 'correlations', redirectTo: '?tab=correlations', pathMatch: 'full' },
{ path: 'anomalies', redirectTo: '?tab=overview', pathMatch: 'full' }, { path: 'anomalies', redirectTo: '?tab=all-events', pathMatch: 'full' },
{ path: 'export', redirectTo: '/evidence/exports', pathMatch: 'full' }, { path: 'export', redirectTo: '/evidence/exports', pathMatch: 'full' },
]; ];

View File

@@ -3,11 +3,11 @@ import { provideRouter } from '@angular/router';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { AUTH_SERVICE } from '../../core/auth'; import { AUTH_SERVICE } from '../../core/auth';
import { ORCHESTRATOR_CONTROL_API } from '../../core/api/jobengine-control.client'; import { SCHEDULER_API } from '../../core/api/scheduler.client';
import { JobEngineDashboardComponent } from './jobengine-dashboard.component'; import { JobEngineDashboardComponent } from './jobengine-dashboard.component';
describe('JobEngineDashboardComponent', () => { describe('JobEngineDashboardComponent', () => {
function configure(canManageJobEngineQuotas: boolean) { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [JobEngineDashboardComponent], imports: [JobEngineDashboardComponent],
providers: [ providers: [
@@ -17,56 +17,39 @@ describe('JobEngineDashboardComponent', () => {
useValue: { useValue: {
canViewOrchestrator: () => true, canViewOrchestrator: () => true,
canOperateOrchestrator: () => true, canOperateOrchestrator: () => true,
canManageJobEngineQuotas: () => canManageJobEngineQuotas, canManageJobEngineQuotas: () => false,
canInitiateBackfill: () => false, canInitiateBackfill: () => false,
}, },
}, },
{ {
provide: ORCHESTRATOR_CONTROL_API, provide: SCHEDULER_API,
useValue: { useValue: {
getJobSummary: () => of({ listSchedules: () => of([]),
totalJobs: 12, listRuns: () => of({ runs: [], nextCursor: undefined }),
leasedJobs: 2,
failedJobs: 1,
pendingJobs: 3,
scheduledJobs: 4,
succeededJobs: 5,
}),
getQuotaSummary: () => of({
totalQuotas: 3,
pausedQuotas: 1,
averageTokenUtilization: 0.42,
averageConcurrencyUtilization: 0.33,
}),
getDeadLetterStats: () => of({
totalEntries: 4,
retryableEntries: 2,
replayedEntries: 1,
resolvedEntries: 1,
}),
}, },
}, },
], ],
}); });
}
it('renders the quotas card as read-only when the user lacks quota scope', () => {
configure(false);
const fixture = TestBed.createComponent(JobEngineDashboardComponent);
fixture.detectChanges();
const quotasCard = fixture.nativeElement.querySelector('[data-testid="jobengine-quotas-card"]') as HTMLElement;
expect(quotasCard.tagName).toBe('ARTICLE');
expect(quotasCard.textContent).toContain('Access required to manage quotas.');
}); });
it('renders the quotas card as a link when the user can manage quotas', () => { it('renders the tab bar with Runs, Schedules, and Workers tabs', () => {
configure(true);
const fixture = TestBed.createComponent(JobEngineDashboardComponent); const fixture = TestBed.createComponent(JobEngineDashboardComponent);
fixture.detectChanges(); fixture.detectChanges();
const quotasCard = fixture.nativeElement.querySelector('[data-testid="jobengine-quotas-card"]') as HTMLElement; const tabs = fixture.nativeElement.querySelectorAll('.spt__tab');
expect(quotasCard.tagName).toBe('A'); expect(tabs.length).toBe(3);
expect(quotasCard.getAttribute('href')).toContain('/ops/operations/jobengine/quotas');
const labels = Array.from(tabs).map((t: any) =>
t.querySelector('.spt__label')?.textContent?.trim()
);
expect(labels).toEqual(['Runs', 'Schedules', 'Workers']);
});
it('defaults to the Runs tab', () => {
const fixture = TestBed.createComponent(JobEngineDashboardComponent);
fixture.detectChanges();
const activeTab = fixture.nativeElement.querySelector('.spt__tab--active .spt__label');
expect(activeTab?.textContent?.trim()).toBe('Runs');
}); });
}); });

View File

@@ -3,96 +3,62 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { SchedulerRunsComponent } from '../scheduler-ops/scheduler-runs.component'; import { SchedulerRunsComponent } from '../scheduler-ops/scheduler-runs.component';
import { SchedulerSchedulesPanelComponent } from './scheduler-schedules-panel.component'; import { SchedulerSchedulesPanelComponent } from './scheduler-schedules-panel.component';
import { SchedulerWorkersPanelComponent } from './scheduler-workers-panel.component'; import { SchedulerWorkersPanelComponent } from './scheduler-workers-panel.component';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
type SchedulerView = 'runs' | 'schedules' | 'workers'; type SchedulerView = 'runs' | 'schedules' | 'workers';
const PAGE_TABS: readonly StellaPageTab[] = [
{ id: 'runs', label: 'Runs', icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8' },
{ id: 'schedules', label: 'Schedules', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' },
{ id: 'workers', label: 'Workers', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|||M9 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M23 21v-2a4 4 0 0 0-3-3.87|||M16 3.13a4 4 0 0 1 0 7.75' },
];
@Component({ @Component({
selector: 'app-jobengine-dashboard', selector: 'app-jobengine-dashboard',
imports: [ imports: [
SchedulerRunsComponent, SchedulerRunsComponent,
SchedulerSchedulesPanelComponent, SchedulerSchedulesPanelComponent,
SchedulerWorkersPanelComponent, SchedulerWorkersPanelComponent,
StellaPageTabsComponent,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="schedules"> <div class="jobengine-dashboard">
<div class="schedules__segmented" role="radiogroup" aria-label="Scheduler view"> <stella-page-tabs
@for (opt of schedulerViews; track opt.id) { [tabs]="pageTabs"
<button [activeTab]="activeView()"
type="button" urlParam="tab"
role="radio" (tabChange)="activeView.set($any($event))"
class="schedules__seg-btn" ariaLabel="JobEngine sections"
[class.schedules__seg-btn--active]="activeView() === opt.id" >
[attr.aria-checked]="activeView() === opt.id" @switch (activeView()) {
(click)="activeView.set(opt.id)" @case ('runs') { <app-scheduler-runs /> }
>{{ opt.label }}</button> @case ('schedules') { <app-scheduler-schedules-panel /> }
@case ('workers') { <app-scheduler-workers-panel /> }
} }
</div> </stella-page-tabs>
@switch (activeView()) {
@case ('runs') { <app-scheduler-runs /> }
@case ('schedules') { <app-scheduler-schedules-panel /> }
@case ('workers') { <app-scheduler-workers-panel /> }
}
</div> </div>
`, `,
styles: [` styles: [`
.schedules { .jobengine-dashboard {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
display: grid;
gap: 0.75rem;
}
.schedules__segmented {
display: inline-flex;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
overflow: hidden;
align-self: start;
}
.schedules__seg-btn {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.85rem;
border: none;
background: transparent;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.schedules__seg-btn:hover:not(.schedules__seg-btn--active) {
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
.schedules__seg-btn--active {
background: var(--color-surface-primary);
color: var(--color-text-heading);
font-weight: 600;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
.schedules__seg-btn:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: -2px;
} }
/* Hide redundant headers from embedded sub-components */ /* Hide redundant headers from embedded sub-components */
.schedules ::ng-deep .page-header, .jobengine-dashboard ::ng-deep .page-header,
.schedules ::ng-deep .back-link { .jobengine-dashboard ::ng-deep .back-link {
display: none; display: none;
} }
.schedules ::ng-deep .stats-row, .jobengine-dashboard ::ng-deep .stats-row,
.schedules ::ng-deep .stat-card, .jobengine-dashboard ::ng-deep .stat-card,
.schedules ::ng-deep .connection-banner { .jobengine-dashboard ::ng-deep .connection-banner {
display: none; display: none;
} }
.schedules ::ng-deep .filter-bar__select, .jobengine-dashboard ::ng-deep .filter-bar__select,
.schedules ::ng-deep .filter-bar__btn { .jobengine-dashboard ::ng-deep .filter-bar__btn {
flex: 0 0 auto !important; flex: 0 0 auto !important;
width: auto !important; width: auto !important;
} }
@@ -100,10 +66,5 @@ type SchedulerView = 'runs' | 'schedules' | 'workers';
}) })
export class JobEngineDashboardComponent { export class JobEngineDashboardComponent {
protected readonly activeView = signal<SchedulerView>('runs'); protected readonly activeView = signal<SchedulerView>('runs');
protected readonly pageTabs = PAGE_TABS;
protected readonly schedulerViews: { id: SchedulerView; label: string }[] = [
{ id: 'runs', label: 'Runs' },
{ id: 'schedules', label: 'Schedules' },
{ id: 'workers', label: 'Workers' },
];
} }