diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index 294591d53..040ad37e2 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -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, diff --git a/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts b/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts index 193a26a5a..63be60326 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts @@ -120,8 +120,8 @@ export class SchedulerHttpClient implements SchedulerApi { // --- Schedule endpoints --- listSchedules(): Observable { - const params = new HttpParams().set('includeDisabled', 'false'); - return this.http.get(`${this.baseUrl}/schedules/`, { + const params = new HttpParams().set('includeDisabled', 'true'); + return this.http.get(`${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'], }; } diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.spec.ts index 9d452be22..38d46b3a4 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.spec.ts @@ -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'); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts index ee812b44b..a3d03ff92 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts @@ -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()) { - - - @for (entry of moduleStats(); track entry.module) { - - } - - } - - @if (allCountsZero()) { -
- -
-

No audit events have been captured for this scope yet

-

- 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. -

-

- The Evidence rail indicator shows ON - audit capture is active. -

-
- -
- } - - @if (anomalies().length > 0) { -
-

Anomaly Alerts

-
- @for (alert of anomalies(); track alert.id) { -
-
- {{ formatAnomalyType(alert.type) }} - {{ formatTime(alert.detectedAt) }} -
-

{{ alert.description }}

- -
- } -
-
- } - -
-
-

Recent Events

- -
- @if (recentEvents().length > 0) { - - - - - - - - - - - - @for (event of recentEvents(); track event.id) { - - - - - - - - } - -
TimestampModuleActionActorResource
{{ formatTime(event.timestamp) }}{{ event.module }}{{ event.action }}{{ event.actor.name }}{{ event.resource.type }}: {{ event.resource.name || event.resource.id }}
- } @else { -
-

No recent events in this time window

-

- 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. -

-
- } -
- } @case ('all-events') { } @case ('timeline') { } @case ('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('overview'); - - readonly stats = signal(null); - readonly recentEvents = signal([]); - readonly anomalies = signal([]); - readonly moduleStats = signal>([]); - 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('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 = { - 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 = { - 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()); - } } diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.spec.ts index d573b2656..eccc1ba49 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.spec.ts @@ -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'); }); diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts index 575c1b594..93a1a54b5 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts @@ -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 = { - 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: `
@@ -31,97 +21,16 @@ const POLICY_CATEGORY_ACTIONS: Record = {

Audit Events

- @if (selectedModules.length === 1) { - - } - -
-
-
- - -
-
- - -
-
- - -
-
- - -
- @if (dateRange === 'custom') { -
- - -
-
- - -
- } -
-
-
- - - -
-
- - -
- -
- -
-
-
- - @if (isPolicyOnly) { -
- @for (cat of policyCategoryList; track cat.id) { - - } -
- } + @if (loading()) {
Loading events...
@@ -131,24 +40,12 @@ const POLICY_CATEGORY_ACTIONS: Record = { Timestamp (UTC) - @if (!isPolicyOnly) { Module } + Module Action Severity - @if (isPolicyOnly) { - Pack - Policy Hash - } Actor Resource - @if (isPolicyOnly) { - Shadow Mode - Coverage - } - @if (showHttpColumns()) { - Method - Status - } - @if (!isPolicyOnly) { Description } + Description @@ -156,15 +53,9 @@ const POLICY_CATEGORY_ACTIONS: Record = { @for (event of events(); track event.id) { {{ formatTimestamp(event.timestamp) }} - @if (!isPolicyOnly) { - {{ formatModule(event.module) }} - } + {{ formatModule(event.module) }} {{ event.action }} {{ event.severity }} - @if (isPolicyOnly) { - {{ getDetail(event, 'packName') || getDetail(event, 'packId') || '-' }} - {{ truncateHash(getDetail(event, 'policyHash')) }} - } {{ event.actor.name }} @@ -172,30 +63,7 @@ const POLICY_CATEGORY_ACTIONS: Record = { {{ event.resource.type }}: {{ event.resource.name || event.resource.id }} - @if (isPolicyOnly) { - - @if (getDetail(event, 'shadowModeStatus')) { - - {{ getDetail(event, 'shadowModeStatus') }} - @if (getDetail(event, 'shadowModeDays')) { - ({{ getDetail(event, 'shadowModeDays') }}d) - } - - } @else { - } - - - @if (getDetail(event, 'coverage') !== undefined && getDetail(event, 'coverage') !== null) { - {{ getDetail(event, 'coverage') }}% - } @else { - } - - } - @if (showHttpColumns()) { - {{ getDetail(event, 'httpMethod') || '-' }} - {{ getDetail(event, 'statusCode') || '-' }} - } - @if (!isPolicyOnly) { - {{ event.description }} - } + {{ event.description }} View @if (event.diff || hasBeforeState(event)) { @@ -204,7 +72,7 @@ const POLICY_CATEGORY_ACTIONS: Record = { } @empty { - No events match the current filters. + No events match the current filters. } @@ -377,79 +245,6 @@ const POLICY_CATEGORY_ACTIONS: Record = { .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 = { .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 = { .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(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([]); + 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 = { + 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 = { + 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; diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log.routes.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log.routes.ts index ba3103431..53bfb85d9 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log.routes.ts @@ -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' }, ]; diff --git a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.spec.ts index c020f7348..7fdbb58e1 100644 --- a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.spec.ts @@ -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'); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts index 0cbdf99bd..b7410524d 100644 --- a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts @@ -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: ` -
-
- @for (opt of schedulerViews; track opt.id) { - +
+ + @switch (activeView()) { + @case ('runs') { } + @case ('schedules') { } + @case ('workers') { } } -
- @switch (activeView()) { - @case ('runs') { } - @case ('schedules') { } - @case ('workers') { } - } +
`, 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('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; }