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:
@@ -823,13 +823,16 @@ export const appConfig: ApplicationConfig = {
|
||||
provide: SCHEDULER_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
||||
try {
|
||||
return new URL('/scheduler/api/v1/scheduler', gatewayBase).toString();
|
||||
} catch {
|
||||
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
|
||||
return `${normalized}/scheduler/api/v1/scheduler`;
|
||||
// Prefer the dedicated scheduler config key (normalizes to '/scheduler'),
|
||||
// fall back to constructing via gateway base.
|
||||
const schedulerBase = config.config.apiBaseUrls.scheduler;
|
||||
if (schedulerBase) {
|
||||
const normalized = schedulerBase.endsWith('/') ? schedulerBase.slice(0, -1) : schedulerBase;
|
||||
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,
|
||||
|
||||
@@ -120,8 +120,8 @@ export class SchedulerHttpClient implements SchedulerApi {
|
||||
// --- Schedule endpoints ---
|
||||
|
||||
listSchedules(): Observable<Schedule[]> {
|
||||
const params = new HttpParams().set('includeDisabled', 'false');
|
||||
return this.http.get<BackendScheduleCollectionResponse | Schedule[]>(`${this.baseUrl}/schedules/`, {
|
||||
const params = new HttpParams().set('includeDisabled', 'true');
|
||||
return this.http.get<BackendScheduleCollectionResponse | Schedule[]>(`${this.baseUrl}/schedules`, {
|
||||
headers: this.buildHeaders(),
|
||||
params,
|
||||
}).pipe(
|
||||
@@ -297,6 +297,7 @@ export class SchedulerHttpClient implements SchedulerApi {
|
||||
createdAt: this.readString(schedule, 'createdAt') || new Date().toISOString(),
|
||||
updatedAt: this.readString(schedule, 'updatedAt') || new Date().toISOString(),
|
||||
createdBy: this.readString(schedule, 'createdBy') || 'system',
|
||||
source: (this.readString(schedule, 'source') || undefined) as Schedule['source'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,95 +15,36 @@ describe('AuditLogDashboardComponent', () => {
|
||||
{
|
||||
provide: AuditLogClient,
|
||||
useValue: {
|
||||
getStatsSummary: () => of({
|
||||
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),
|
||||
getEvents: () => of({ items: [], cursor: null, hasMore: false }),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('routes the header export action to the canonical export center', () => {
|
||||
it('should create', () => {
|
||||
const fixture = TestBed.createComponent(AuditLogDashboardComponent);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
const exportLink = fixture.debugElement
|
||||
.query(By.css('.header-actions .btn-secondary'))
|
||||
.nativeElement as HTMLAnchorElement;
|
||||
|
||||
expect(exportLink.getAttribute('href')).toBe('/evidence/exports');
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show empty guidance when events exist', () => {
|
||||
it('defaults to all-events tab', () => {
|
||||
const fixture = TestBed.createComponent(AuditLogDashboardComponent);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.activeTab()).toBe('all-events');
|
||||
});
|
||||
|
||||
const guidance = fixture.debugElement.query(By.css('.audit-log__empty-guidance'));
|
||||
expect(guidance).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuditLogDashboardComponent (zero counts)', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AuditLogDashboardComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
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');
|
||||
it('does not render an overview tab', () => {
|
||||
const fixture = TestBed.createComponent(AuditLogDashboardComponent);
|
||||
fixture.detectChanges();
|
||||
const tabs = fixture.componentInstance.auditTabs;
|
||||
expect(tabs.find(t => t.id === 'overview')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders the page header', () => {
|
||||
const fixture = TestBed.createComponent(AuditLogDashboardComponent);
|
||||
fixture.detectChanges();
|
||||
const header = fixture.debugElement.query(By.css('.page-header h1'));
|
||||
expect(header.nativeElement.textContent).toContain('Audit');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// 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 { 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 { 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 { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.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';
|
||||
|
||||
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' },
|
||||
@@ -31,8 +26,6 @@ const AUDIT_TABS: StellaPageTab[] = [
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
StellaMetricCardComponent,
|
||||
StellaMetricGridComponent,
|
||||
StellaPageTabsComponent,
|
||||
StellaQuickLinksComponent,
|
||||
AuditTimelineSearchComponent,
|
||||
@@ -62,110 +55,6 @@ const AUDIT_TABS: StellaPageTab[] = [
|
||||
ariaLabel="Audit log sections"
|
||||
>
|
||||
@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 ('timeline') { <app-audit-timeline-search /> }
|
||||
@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-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; }
|
||||
|
||||
.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 {
|
||||
private readonly auditClient = inject(AuditLogClient);
|
||||
export class AuditLogDashboardComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly helperCtx = inject(StellaHelperContextService);
|
||||
|
||||
@@ -375,159 +83,13 @@ export class AuditLogDashboardComponent implements OnInit {
|
||||
];
|
||||
|
||||
readonly auditTabs = AUDIT_TABS;
|
||||
readonly activeTab = signal<string>('overview');
|
||||
|
||||
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;
|
||||
});
|
||||
readonly activeTab = signal<string>('all-events');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.helperCtx.setScope('audit-log-dashboard', this.helperContexts());
|
||||
this.helperCtx.setScope('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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,44 +43,33 @@ describe('AuditLogTableComponent', () => {
|
||||
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;
|
||||
expect(element.querySelector('.filters-bar')).toBeTruthy();
|
||||
expect(element.textContent).toContain('Actor');
|
||||
expect(element.querySelector('app-filter-bar')).toBeFalsy();
|
||||
expect(element.querySelector('app-filter-bar')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('builds filters with multi-select arrays and actor name', () => {
|
||||
component.selectedModules = ['policy', 'scanner'];
|
||||
component.selectedActions = ['approve', 'replay'];
|
||||
component.selectedSeverities = ['warning', 'critical'];
|
||||
component.actorFilter = 'ops@stella.local';
|
||||
it('builds filters with single-value dropdowns', () => {
|
||||
component.selectedModule = 'policy';
|
||||
component.selectedAction = 'approve';
|
||||
component.selectedSeverity = 'warning';
|
||||
|
||||
expect(component.buildFilters()).toEqual(
|
||||
jasmine.objectContaining({
|
||||
modules: ['policy', 'scanner'],
|
||||
actions: ['approve', 'replay'],
|
||||
severities: ['warning', 'critical'],
|
||||
actorName: 'ops@stella.local',
|
||||
modules: ['policy'],
|
||||
actions: ['approve'],
|
||||
severities: ['warning'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('builds custom start and end dates when custom range is selected', () => {
|
||||
component.dateRange = 'custom';
|
||||
component.customStartDate = '2026-03-01';
|
||||
component.customEndDate = '2026-03-07';
|
||||
|
||||
expect(component.buildFilters()).toEqual(
|
||||
jasmine.objectContaining({
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-07',
|
||||
}),
|
||||
);
|
||||
it('builds date range filter for preset values', () => {
|
||||
component.dateRange = '24h';
|
||||
const filters = component.buildFilters();
|
||||
expect(filters.startDate).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resets paging state when applyFilters is called', () => {
|
||||
component.selectedModules = ['scanner'];
|
||||
component.selectedModule = 'scanner';
|
||||
component.cursor.set('cursor-1');
|
||||
(component as { cursorStack: string[] }).cursorStack = ['cursor-0'];
|
||||
component.hasPrev.set(true);
|
||||
@@ -92,29 +81,23 @@ describe('AuditLogTableComponent', () => {
|
||||
expect(mockClient.getEvents).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears all restored filters', () => {
|
||||
component.selectedModules = ['scanner'];
|
||||
component.selectedActions = ['replay'];
|
||||
component.selectedSeverities = ['error'];
|
||||
component.dateRange = 'custom';
|
||||
component.customStartDate = '2026-03-01';
|
||||
component.customEndDate = '2026-03-07';
|
||||
it('clears all filter state', () => {
|
||||
component.selectedModule = 'scanner';
|
||||
component.selectedAction = 'replay';
|
||||
component.selectedSeverity = 'error';
|
||||
component.dateRange = '30d';
|
||||
component.searchQuery = 'digest';
|
||||
component.actorFilter = 'operator';
|
||||
|
||||
component.clearFilters();
|
||||
|
||||
expect(component.selectedModules.length).toBe(0);
|
||||
expect(component.selectedActions.length).toBe(0);
|
||||
expect(component.selectedSeverities.length).toBe(0);
|
||||
expect(component.selectedModule).toBe('');
|
||||
expect(component.selectedAction).toBe('');
|
||||
expect(component.selectedSeverity).toBe('');
|
||||
expect(component.dateRange).toBe('7d');
|
||||
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('jobengine')).toBe('JobEngine');
|
||||
});
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
// 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 { RouterModule, ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuditLogClient } from '../../core/api/audit-log.client';
|
||||
import { AuditEvent, AuditDiff, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } from '../../core/api/audit-log.models';
|
||||
import { AuditEventDetailsPanelComponent } from './audit-event-details-panel.component';
|
||||
|
||||
type PolicyCategory = 'all' | 'governance' | 'promotions' | 'approvals' | 'rejections' | 'simulations';
|
||||
|
||||
const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
all: null,
|
||||
governance: ['create', 'update', 'delete', 'enable', 'disable'],
|
||||
promotions: ['promote'],
|
||||
approvals: ['approve'],
|
||||
rejections: ['reject'],
|
||||
simulations: ['test'],
|
||||
};
|
||||
import { FilterBarComponent, type FilterOption, type ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-audit-log-table',
|
||||
imports: [CommonModule, RouterModule, FormsModule, AuditEventDetailsPanelComponent],
|
||||
imports: [CommonModule, RouterModule, FormsModule, AuditEventDetailsPanelComponent, FilterBarComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="audit-table-page">
|
||||
@@ -31,97 +21,16 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
<h1>Audit Events</h1>
|
||||
</header>
|
||||
|
||||
@if (selectedModules.length === 1) {
|
||||
<div class="module-context-link">
|
||||
@switch (selectedModules[0]) {
|
||||
@case ('authority') { <a routerLink="/console/admin" [queryParams]="{tab: 'audit'}">View in Console Admin →</a> }
|
||||
@case ('vex') { <a routerLink="/ops/policy/vex/explorer" [queryParams]="{tab: 'audit'}">View in VEX Hub →</a> }
|
||||
@case ('integrations') { <a routerLink="/integrations" [queryParams]="{tab: 'config-audit'}">View in Integration Hub →</a> }
|
||||
@case ('jobengine') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs →</a> }
|
||||
@case ('scheduler') { <a routerLink="/platform-ops/jobs" [queryParams]="{tab: 'audit'}">View in Platform Jobs →</a> }
|
||||
@case ('scanner') { <a routerLink="/platform-ops/scanner" [queryParams]="{tab: 'audit'}">View in Scanner Ops →</a> }
|
||||
}
|
||||
</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>
|
||||
}
|
||||
<app-filter-bar
|
||||
searchPlaceholder="Search events by description, resource, or actor..."
|
||||
[filters]="filterBarOptions"
|
||||
[activeFilters]="activeFilterPills()"
|
||||
(searchChange)="onFilterBarSearch($event)"
|
||||
(searchSubmit)="onFilterBarSearchSubmit($event)"
|
||||
(filterChange)="onFilterBarChanged($event)"
|
||||
(filterRemove)="onFilterBarRemoved($event)"
|
||||
(filtersCleared)="clearFilters()"
|
||||
></app-filter-bar>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading events...</div>
|
||||
@@ -131,24 +40,12 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp (UTC)</th>
|
||||
@if (!isPolicyOnly) { <th>Module</th> }
|
||||
<th>Module</th>
|
||||
<th>Action</th>
|
||||
<th>Severity</th>
|
||||
@if (isPolicyOnly) {
|
||||
<th>Pack</th>
|
||||
<th>Policy Hash</th>
|
||||
}
|
||||
<th>Actor</th>
|
||||
<th>Resource</th>
|
||||
@if (isPolicyOnly) {
|
||||
<th>Shadow Mode</th>
|
||||
<th>Coverage</th>
|
||||
}
|
||||
@if (showHttpColumns()) {
|
||||
<th>Method</th>
|
||||
<th>Status</th>
|
||||
}
|
||||
@if (!isPolicyOnly) { <th>Description</th> }
|
||||
<th>Description</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -156,15 +53,9 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
@for (event of events(); track event.id) {
|
||||
<tr [class]="event.severity" (click)="selectEvent(event)" [class.selected]="selectedEvent()?.id === event.id">
|
||||
<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 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>
|
||||
<span class="actor" [title]="event.actor.email || ''">
|
||||
{{ event.actor.name }}
|
||||
@@ -172,30 +63,7 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
</span>
|
||||
</td>
|
||||
<td class="resource">{{ event.resource.type }}: {{ event.resource.name || event.resource.id }}</td>
|
||||
@if (isPolicyOnly) {
|
||||
<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 class="description">{{ event.description }}</td>
|
||||
<td>
|
||||
<a [routerLink]="[event.id]" class="link">View</a>
|
||||
@if (event.diff || hasBeforeState(event)) {
|
||||
@@ -204,7 +72,7 @@ const POLICY_CATEGORY_ACTIONS: Record<PolicyCategory, AuditAction[] | null> = {
|
||||
</td>
|
||||
</tr>
|
||||
} @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>
|
||||
</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 a { color: var(--color-text-link); text-decoration: none; }
|
||||
.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 {
|
||||
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.warning { background: var(--color-status-warning-bg); }
|
||||
.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.module { background: var(--color-surface-elevated); }
|
||||
.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.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.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); }
|
||||
.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; }
|
||||
@@ -604,58 +394,126 @@ export class AuditLogTableComponent implements OnInit {
|
||||
readonly cursor = signal<string | null>(null);
|
||||
readonly hasMore = signal(false);
|
||||
readonly hasPrev = signal(false);
|
||||
readonly showHttpColumns = signal(false);
|
||||
private cursorStack: string[] = [];
|
||||
|
||||
// Filter state
|
||||
selectedModules: AuditModule[] = [];
|
||||
selectedActions: AuditAction[] = [];
|
||||
selectedSeverities: AuditSeverity[] = [];
|
||||
selectedModule = '';
|
||||
selectedAction = '';
|
||||
selectedSeverity = '';
|
||||
dateRange = '7d';
|
||||
customStartDate = '';
|
||||
customEndDate = '';
|
||||
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 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'];
|
||||
|
||||
// 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 {
|
||||
const moduleParam = this.route.snapshot.queryParamMap.get('module');
|
||||
if (moduleParam && this.allModules.includes(moduleParam as AuditModule)) {
|
||||
this.selectedModules = [moduleParam as AuditModule];
|
||||
this.selectedModule = moduleParam;
|
||||
this.rebuildPills();
|
||||
}
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
onModuleChange(): void {
|
||||
if (!this.isPolicyOnly) {
|
||||
this.policyCategory = 'all';
|
||||
}
|
||||
onFilterBarSearch(value: string): void {
|
||||
this.searchQuery = value;
|
||||
}
|
||||
|
||||
onFilterBarSearchSubmit(value: string): void {
|
||||
this.searchQuery = value;
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
setPolicyCategory(category: PolicyCategory): void {
|
||||
this.policyCategory = category;
|
||||
const actions = POLICY_CATEGORY_ACTIONS[category];
|
||||
this.selectedActions = actions ? [...actions] : [];
|
||||
onFilterBarChanged(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.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();
|
||||
}
|
||||
|
||||
@@ -676,11 +534,10 @@ export class AuditLogTableComponent implements OnInit {
|
||||
buildFilters(): AuditLogFilters {
|
||||
const filters: AuditLogFilters = {};
|
||||
|
||||
if (this.selectedModules.length) filters.modules = this.selectedModules;
|
||||
if (this.selectedActions.length) filters.actions = this.selectedActions;
|
||||
if (this.selectedSeverities.length) filters.severities = this.selectedSeverities;
|
||||
if (this.selectedModule) filters.modules = [this.selectedModule as AuditModule];
|
||||
if (this.selectedAction) filters.actions = [this.selectedAction as AuditAction];
|
||||
if (this.selectedSeverity) filters.severities = [this.selectedSeverity as AuditSeverity];
|
||||
if (this.searchQuery) filters.search = this.searchQuery;
|
||||
if (this.actorFilter) filters.actorName = this.actorFilter;
|
||||
|
||||
const now = new Date();
|
||||
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();
|
||||
} else if (this.dateRange === '90d') {
|
||||
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;
|
||||
@@ -707,18 +561,33 @@ export class AuditLogTableComponent implements OnInit {
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.selectedModules = [];
|
||||
this.selectedActions = [];
|
||||
this.selectedSeverities = [];
|
||||
this.selectedModule = '';
|
||||
this.selectedAction = '';
|
||||
this.selectedSeverity = '';
|
||||
this.dateRange = '7d';
|
||||
this.customStartDate = '';
|
||||
this.customEndDate = '';
|
||||
this.searchQuery = '';
|
||||
this.actorFilter = '';
|
||||
this.policyCategory = 'all';
|
||||
this.activeFilterPills.set([]);
|
||||
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 {
|
||||
if (this.cursor()) {
|
||||
this.cursorStack.push(this.cursor()!);
|
||||
@@ -753,11 +622,6 @@ export class AuditLogTableComponent implements OnInit {
|
||||
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 {
|
||||
const diff = event?.diff;
|
||||
if (!diff) return false;
|
||||
|
||||
@@ -24,6 +24,6 @@ export const auditLogRoutes: Routes = [
|
||||
// Cross-cutting tabs remain in unified dashboard
|
||||
{ path: 'timeline', redirectTo: '?tab=timeline', 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' },
|
||||
];
|
||||
|
||||
@@ -3,11 +3,11 @@ import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
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';
|
||||
|
||||
describe('JobEngineDashboardComponent', () => {
|
||||
function configure(canManageJobEngineQuotas: boolean) {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [JobEngineDashboardComponent],
|
||||
providers: [
|
||||
@@ -17,56 +17,39 @@ describe('JobEngineDashboardComponent', () => {
|
||||
useValue: {
|
||||
canViewOrchestrator: () => true,
|
||||
canOperateOrchestrator: () => true,
|
||||
canManageJobEngineQuotas: () => canManageJobEngineQuotas,
|
||||
canManageJobEngineQuotas: () => false,
|
||||
canInitiateBackfill: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ORCHESTRATOR_CONTROL_API,
|
||||
provide: SCHEDULER_API,
|
||||
useValue: {
|
||||
getJobSummary: () => of({
|
||||
totalJobs: 12,
|
||||
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,
|
||||
}),
|
||||
listSchedules: () => of([]),
|
||||
listRuns: () => of({ runs: [], nextCursor: undefined }),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
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', () => {
|
||||
configure(true);
|
||||
it('renders the tab bar with Runs, Schedules, and Workers tabs', () => {
|
||||
const fixture = TestBed.createComponent(JobEngineDashboardComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const quotasCard = fixture.nativeElement.querySelector('[data-testid="jobengine-quotas-card"]') as HTMLElement;
|
||||
expect(quotasCard.tagName).toBe('A');
|
||||
expect(quotasCard.getAttribute('href')).toContain('/ops/operations/jobengine/quotas');
|
||||
const tabs = fixture.nativeElement.querySelectorAll('.spt__tab');
|
||||
expect(tabs.length).toBe(3);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,96 +3,62 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { SchedulerRunsComponent } from '../scheduler-ops/scheduler-runs.component';
|
||||
import { SchedulerSchedulesPanelComponent } from './scheduler-schedules-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';
|
||||
|
||||
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({
|
||||
selector: 'app-jobengine-dashboard',
|
||||
imports: [
|
||||
SchedulerRunsComponent,
|
||||
SchedulerSchedulesPanelComponent,
|
||||
SchedulerWorkersPanelComponent,
|
||||
StellaPageTabsComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="schedules">
|
||||
<div class="schedules__segmented" role="radiogroup" aria-label="Scheduler view">
|
||||
@for (opt of schedulerViews; track opt.id) {
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
class="schedules__seg-btn"
|
||||
[class.schedules__seg-btn--active]="activeView() === opt.id"
|
||||
[attr.aria-checked]="activeView() === opt.id"
|
||||
(click)="activeView.set(opt.id)"
|
||||
>{{ opt.label }}</button>
|
||||
<div class="jobengine-dashboard">
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs"
|
||||
[activeTab]="activeView()"
|
||||
urlParam="tab"
|
||||
(tabChange)="activeView.set($any($event))"
|
||||
ariaLabel="JobEngine sections"
|
||||
>
|
||||
@switch (activeView()) {
|
||||
@case ('runs') { <app-scheduler-runs /> }
|
||||
@case ('schedules') { <app-scheduler-schedules-panel /> }
|
||||
@case ('workers') { <app-scheduler-workers-panel /> }
|
||||
}
|
||||
</div>
|
||||
@switch (activeView()) {
|
||||
@case ('runs') { <app-scheduler-runs /> }
|
||||
@case ('schedules') { <app-scheduler-schedules-panel /> }
|
||||
@case ('workers') { <app-scheduler-workers-panel /> }
|
||||
}
|
||||
</stella-page-tabs>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.schedules {
|
||||
.jobengine-dashboard {
|
||||
max-width: 1200px;
|
||||
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 */
|
||||
.schedules ::ng-deep .page-header,
|
||||
.schedules ::ng-deep .back-link {
|
||||
.jobengine-dashboard ::ng-deep .page-header,
|
||||
.jobengine-dashboard ::ng-deep .back-link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.schedules ::ng-deep .stats-row,
|
||||
.schedules ::ng-deep .stat-card,
|
||||
.schedules ::ng-deep .connection-banner {
|
||||
.jobengine-dashboard ::ng-deep .stats-row,
|
||||
.jobengine-dashboard ::ng-deep .stat-card,
|
||||
.jobengine-dashboard ::ng-deep .connection-banner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.schedules ::ng-deep .filter-bar__select,
|
||||
.schedules ::ng-deep .filter-bar__btn {
|
||||
.jobengine-dashboard ::ng-deep .filter-bar__select,
|
||||
.jobengine-dashboard ::ng-deep .filter-bar__btn {
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
}
|
||||
@@ -100,10 +66,5 @@ type SchedulerView = 'runs' | 'schedules' | 'workers';
|
||||
})
|
||||
export class JobEngineDashboardComponent {
|
||||
protected readonly activeView = signal<SchedulerView>('runs');
|
||||
|
||||
protected readonly schedulerViews: { id: SchedulerView; label: string }[] = [
|
||||
{ id: 'runs', label: 'Runs' },
|
||||
{ id: 'schedules', label: 'Schedules' },
|
||||
{ id: 'workers', label: 'Workers' },
|
||||
];
|
||||
protected readonly pageTabs = PAGE_TABS;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user