feat: Add tests for RichGraphPublisher and RichGraphWriter
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled

- Implement unit tests for RichGraphPublisher to verify graph publishing to CAS.
- Implement unit tests for RichGraphWriter to ensure correct writing of canonical graphs and metadata.

feat: Implement AOC Guard validation logic

- Add AOC Guard validation logic to enforce document structure and field constraints.
- Introduce violation codes for various validation errors.
- Implement tests for AOC Guard to validate expected behavior.

feat: Create Console Status API client and service

- Implement ConsoleStatusClient for fetching console status and streaming run events.
- Create ConsoleStatusService to manage console status polling and event subscriptions.
- Add tests for ConsoleStatusClient to verify API interactions.

feat: Develop Console Status component

- Create ConsoleStatusComponent for displaying console status and run events.
- Implement UI for showing status metrics and handling user interactions.
- Add styles for console status display.

test: Add tests for Console Status store

- Implement tests for ConsoleStatusStore to verify event handling and state management.
This commit is contained in:
StellaOps Bot
2025-12-01 07:34:50 +02:00
parent 7df0677e34
commit c11d87d252
108 changed files with 4773 additions and 351 deletions

View File

@@ -3,6 +3,6 @@
| Task ID | State | Notes |
| --- | --- | --- |
| WEB-AOC-19-002 | DONE (2025-11-30) | Added provenance builder, checksum utilities, and DSSE/CMS signature verification helpers with unit tests. |
| WEB-AOC-19-003 | TODO | Analyzer/guard validation remains; will align once helper APIs settle. |
| WEB-CONSOLE-23-002 | TODO | Status/stream endpoints to proxy Scheduler once contracts finalized. |
| WEB-AOC-19-003 | DONE (2025-11-30) | Added client-side guard validator (forbidden/derived/unknown fields, provenance/signature checks) with unit fixtures. |
| WEB-CONSOLE-23-002 | DOING (2025-12-01) | Console status polling + SSE run stream client/store/UI added; tests pending once env fixed. |
| WEB-EXC-25-001 | TODO | Exceptions workflow CRUD pending policy scopes. |

View File

@@ -3,22 +3,28 @@ import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client';
import {
AUTHORITY_CONSOLE_API,
AUTHORITY_CONSOLE_API_BASE_URL,
AuthorityConsoleApiHttpClient,
} from './core/api/authority-console.client';
import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client';
import {
AUTHORITY_CONSOLE_API,
AUTHORITY_CONSOLE_API_BASE_URL,
AuthorityConsoleApiHttpClient,
} from './core/api/authority-console.client';
import {
CONSOLE_API_BASE_URL,
DEFAULT_EVENT_SOURCE_FACTORY,
EVENT_SOURCE_FACTORY,
} from './core/api/console-status.client';
import {
NOTIFY_API,
NOTIFY_API_BASE_URL,
NOTIFY_TENANT_ID,
} from './core/api/notify.client';
import { AppConfigService } from './core/config/app-config.service';
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
import { MockNotifyApiService } from './testing/mock-notify-api.service';
NOTIFY_TENANT_ID,
} from './core/api/notify.client';
import { CONSOLE_API_BASE_URL } from './core/api/console-status.client';
import { AppConfigService } from './core/config/app-config.service';
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
import { MockNotifyApiService } from './testing/mock-notify-api.service';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
@@ -64,14 +70,33 @@ export const appConfig: ApplicationConfig = {
provide: AUTHORITY_CONSOLE_API,
useExisting: AuthorityConsoleApiHttpClient,
},
{
provide: NOTIFY_API_BASE_URL,
useValue: '/api/v1/notify',
},
{
provide: NOTIFY_TENANT_ID,
useValue: 'tenant-dev',
},
{
provide: NOTIFY_API_BASE_URL,
useValue: '/api/v1/notify',
},
{
provide: CONSOLE_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const authorityBase = config.config.apiBaseUrls.authority;
try {
return new URL('/console', authorityBase).toString();
} catch {
const normalized = authorityBase.endsWith('/')
? authorityBase.slice(0, -1)
: authorityBase;
return `${normalized}/console`;
}
},
},
{
provide: EVENT_SOURCE_FACTORY,
useValue: DEFAULT_EVENT_SOURCE_FACTORY,
},
{
provide: NOTIFY_TENANT_ID,
useValue: 'tenant-dev',
},
MockNotifyApiService,
{
provide: NOTIFY_API,

View File

@@ -0,0 +1,56 @@
import { validateAocDocument } from './aoc-guard';
describe('AOC Guard (frontend)', () => {
const baseDoc = {
tenant: 'tenant-1',
source: { id: 'source-1' },
upstream: {
content_hash: 'sha256:abc',
signature: { present: true, format: 'cms+dsse', sig: 'c2ln', key_id: 'k1' },
},
content: {
raw: { advisory: 'example' },
},
linkset: {},
} as const;
it('rejects forbidden top-level fields with ERR_AOC_001', () => {
const result = validateAocDocument({ ...baseDoc, severity: 'critical' });
expect(result.some((v) => v.code === 'ERR_AOC_001' && v.path === '/severity')).toBeTrue();
});
it('rejects derived fields with ERR_AOC_006', () => {
const result = validateAocDocument({ ...baseDoc, effective_status: 'open' });
expect(result.some((v) => v.code === 'ERR_AOC_006' && v.path === '/effective_status')).toBeTrue();
});
it('rejects unknown fields with ERR_AOC_007', () => {
const result = validateAocDocument({ ...baseDoc, unexpected: true });
expect(result.some((v) => v.code === 'ERR_AOC_007' && v.path === '/unexpected')).toBeTrue();
});
it('enforces tenant presence and non-empty string', () => {
const missingTenant = validateAocDocument({ ...baseDoc, tenant: undefined });
expect(missingTenant.some((v) => v.code === 'ERR_AOC_004' && v.path === '/tenant')).toBeTrue();
const emptyTenant = validateAocDocument({ ...baseDoc, tenant: ' ' });
expect(emptyTenant.some((v) => v.code === 'ERR_AOC_004' && v.path === '/tenant')).toBeTrue();
});
it('validates signature subfields when present', () => {
const result = validateAocDocument({
...baseDoc,
upstream: { content_hash: 'sha256:abc', signature: { present: true } },
});
expect(result.some((v) => v.path === '/upstream/signature/format')).toBeTrue();
expect(result.some((v) => v.path === '/upstream/signature/sig')).toBeTrue();
expect(result.some((v) => v.path === '/upstream/signature/key_id')).toBeTrue();
expect(result.every((v) => v.code === 'ERR_AOC_005')).toBeTrue();
});
it('accepts a minimal valid document', () => {
const result = validateAocDocument({ ...baseDoc });
expect(result.length).toBe(0);
});
});

View File

@@ -0,0 +1,213 @@
export type AocViolationCode =
| 'ERR_AOC_000'
| 'ERR_AOC_001'
| 'ERR_AOC_002'
| 'ERR_AOC_003'
| 'ERR_AOC_004'
| 'ERR_AOC_005'
| 'ERR_AOC_006'
| 'ERR_AOC_007';
export interface AocGuardOptions {
requireSignatureMetadata?: boolean;
requireTenant?: boolean;
allowedTopLevelFields?: string[];
requiredTopLevelFields?: string[];
}
export interface AocViolation {
code: AocViolationCode;
path: string;
message: string;
}
const DEFAULT_REQUIRED = ['tenant', 'source', 'upstream', 'content', 'linkset'];
const DEFAULT_ALLOWED = new Set(
[
...DEFAULT_REQUIRED,
'_id',
'identifiers',
'attributes',
'supersedes',
'createdAt',
'created_at',
'ingestedAt',
'ingested_at',
'links',
'advisory_key',
].map((k) => k.toLowerCase())
);
const FORBIDDEN_FIELDS = new Set(
[
'severity',
'cvss',
'cvss_vector',
'effective_status',
'effective_range',
'merged_from',
'consensus_provider',
'reachability',
'asset_criticality',
'risk_score',
].map((k) => k.toLowerCase())
);
const isDerivedField = (name: string) => name.toLowerCase().startsWith('effective_');
const asObject = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
export function validateAocDocument(
document: Record<string, unknown>,
options: AocGuardOptions = {}
): AocViolation[] {
const violations: AocViolation[] = [];
const allowed = new Set(
(options.allowedTopLevelFields ?? Array.from(DEFAULT_ALLOWED)).map((k) => k.toLowerCase())
);
const required = options.requiredTopLevelFields ?? DEFAULT_REQUIRED;
const entries = Object.entries(document);
for (const [keyRaw, value] of entries) {
const key = keyRaw.toLowerCase();
if (FORBIDDEN_FIELDS.has(key)) {
violations.push({
code: 'ERR_AOC_001',
path: `/${keyRaw}`,
message: `Field '${keyRaw}' is forbidden in AOC documents.`,
});
continue;
}
if (isDerivedField(keyRaw)) {
violations.push({
code: 'ERR_AOC_006',
path: `/${keyRaw}`,
message: `Derived field '${keyRaw}' must not be written during ingestion.`,
});
}
if (!allowed.has(key)) {
violations.push({
code: 'ERR_AOC_007',
path: `/${keyRaw}`,
message: `Field '${keyRaw}' is not allowed in AOC documents.`,
});
continue;
}
}
for (const field of required) {
const value = document[field];
if (value === undefined || value === null) {
violations.push({
code: 'ERR_AOC_004',
path: `/${field}`,
message: `Required field '${field}' is missing.`,
});
continue;
}
if ((options.requireTenant ?? true) && field.toLowerCase() === 'tenant') {
if (typeof value !== 'string' || value.trim().length === 0) {
violations.push({
code: 'ERR_AOC_004',
path: '/tenant',
message: 'Tenant must be a non-empty string.',
});
}
}
}
const upstream = asObject(document['upstream']);
if (!upstream) {
violations.push({
code: 'ERR_AOC_004',
path: '/upstream',
message: 'Upstream metadata is required.',
});
} else {
const contentHash = upstream['content_hash'];
if (typeof contentHash !== 'string' || contentHash.trim().length === 0) {
violations.push({
code: 'ERR_AOC_004',
path: '/upstream/content_hash',
message: 'Upstream content hash is required.',
});
}
const requireSig = options.requireSignatureMetadata ?? true;
const signature = asObject(upstream['signature']);
if (requireSig && !signature) {
violations.push({
code: 'ERR_AOC_004',
path: '/upstream/signature',
message: 'Signature metadata is required.',
});
} else if (requireSig && signature) {
const present = signature['present'];
if (typeof present !== 'boolean') {
violations.push({
code: 'ERR_AOC_005',
path: '/upstream/signature/present',
message: "Signature metadata must include 'present' boolean.",
});
} else if (present) {
if (typeof signature['format'] !== 'string' || (signature['format'] as string).trim().length === 0) {
violations.push({
code: 'ERR_AOC_005',
path: '/upstream/signature/format',
message: 'Signature format is required when signature is present.',
});
}
if (typeof signature['sig'] !== 'string' || (signature['sig'] as string).trim().length === 0) {
violations.push({
code: 'ERR_AOC_005',
path: '/upstream/signature/sig',
message: 'Signature payload is required when signature is present.',
});
}
if (typeof signature['key_id'] !== 'string' || (signature['key_id'] as string).trim().length === 0) {
violations.push({
code: 'ERR_AOC_005',
path: '/upstream/signature/key_id',
message: 'Signature key identifier is required when signature is present.',
});
}
}
}
}
const content = asObject(document['content']);
if (!content) {
violations.push({
code: 'ERR_AOC_004',
path: '/content',
message: 'Content metadata is required.',
});
} else if (content['raw'] === undefined || content['raw'] === null) {
violations.push({
code: 'ERR_AOC_004',
path: '/content/raw',
message: 'Raw upstream payload must be preserved.',
});
}
const linkset = asObject(document['linkset']);
if (!linkset) {
violations.push({
code: 'ERR_AOC_004',
path: '/linkset',
message: 'Linkset metadata is required.',
});
}
return violations.sort((a, b) => a.path.localeCompare(b.path));
}

View File

@@ -0,0 +1,52 @@
import { assertKeyWriteAllowed, isKeyWriteAllowed, KeyWriteRequest } from './key-guard';
const unsafePersistentTargets: KeyWriteRequest[] = [
{ kind: 'private', target: 'local-storage', label: 'tenant signing key' },
{ kind: 'private', target: 'indexeddb', label: 'orchestrator control key' },
{ kind: 'symmetric', target: 'filesystem', label: 'session token key' },
];
const unsafeSessionTargets: KeyWriteRequest[] = [
{ kind: 'symmetric', target: 'session-storage', label: 'crypto session key' },
];
const unknownKind: KeyWriteRequest = {
kind: 'unknown',
target: 'memory',
label: 'unclassified key',
};
const safeTargets: KeyWriteRequest[] = [
{ kind: 'public', target: 'local-storage', label: 'public attestation key' },
{ kind: 'public', target: 'indexeddb', label: 'cacheable public key' },
{ kind: 'private', target: 'memory', label: 'ephemeral signing key' },
{ kind: 'symmetric', target: 'memory', label: 'in-memory session key' },
];
describe('key-guard', () => {
it('rejects private/symmetric key writes to persistent storage', () => {
for (const request of unsafePersistentTargets) {
expect(() => assertKeyWriteAllowed(request)).toThrowError(/may not be written/);
expect(isKeyWriteAllowed(request)).toBeFalse();
}
});
it('rejects symmetric keys in session storage to prevent browser persistence', () => {
for (const request of unsafeSessionTargets) {
expect(() => assertKeyWriteAllowed(request)).toThrowError(/session storage/);
expect(isKeyWriteAllowed(request)).toBeFalse();
}
});
it('rejects unknown key kinds regardless of target', () => {
expect(() => assertKeyWriteAllowed(unknownKind)).toThrowError(/key kind unknown/);
expect(isKeyWriteAllowed(unknownKind)).toBeFalse();
});
it('allows public keys in any storage and private/symmetric keys in memory only', () => {
for (const request of safeTargets) {
expect(() => assertKeyWriteAllowed(request)).not.toThrow();
expect(isKeyWriteAllowed(request)).toBeTrue();
}
});
});

View File

@@ -0,0 +1,75 @@
export type KeyMaterialKind = 'private' | 'public' | 'symmetric' | 'unknown';
export type KeyStorageTarget =
| 'memory'
| 'session-storage'
| 'local-storage'
| 'indexeddb'
| 'filesystem';
export interface KeyWriteRequest {
readonly kind: KeyMaterialKind;
readonly target: KeyStorageTarget;
/**
* Optional human-readable label for audit/error messages
* (e.g. "orchestrator signing key").
*/
readonly label?: string;
}
export class ForbiddenKeyWriteError extends Error {
constructor(message: string) {
super(message);
this.name = 'ForbiddenKeyWriteError';
}
}
const persistentTargets: KeyStorageTarget[] = [
'local-storage',
'indexeddb',
'filesystem',
];
/**
* Guard to prevent storing private or symmetric key material in persistent browser storage.
* Throws an error if the write is not permitted; callers must handle/abort accordingly.
*/
export function assertKeyWriteAllowed(request: KeyWriteRequest): void {
const label = request.label ?? 'key material';
if (request.kind === 'unknown') {
throw new ForbiddenKeyWriteError(
`${label}: key kind unknown; refusing to persist without explicit classification.`
);
}
if (request.kind === 'public') {
return; // public keys are safe to persist in any target
}
if (request.target === 'memory') {
return; // ephemeral use is allowed
}
if (request.target === 'session-storage' && request.kind === 'symmetric') {
throw new ForbiddenKeyWriteError(
`${label}: symmetric keys may not be written to session storage.`
);
}
if (persistentTargets.includes(request.target)) {
throw new ForbiddenKeyWriteError(
`${label}: ${request.kind} keys may not be written to ${request.target}.`
);
}
}
/** Convenience helper for tests and callers to check allowance without throwing. */
export function isKeyWriteAllowed(request: KeyWriteRequest): boolean {
try {
assertKeyWriteAllowed(request);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,90 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { ConsoleStatusClient, CONSOLE_API_BASE_URL, EVENT_SOURCE_FACTORY } from './console-status.client';
import { ConsoleRunEventDto, ConsoleStatusDto } from './console-status.models';
class FakeAuthSessionStore {
getActiveTenantId(): string | null {
return 'tenant-dev';
}
}
class FakeEventSource {
public onmessage: ((this: EventSource, ev: MessageEvent) => any) | null = null;
public onerror: ((this: EventSource, ev: Event) => any) | null = null;
constructor(public readonly url: string) {}
close(): void {
// no-op for tests
}
}
describe('ConsoleStatusClient', () => {
let httpMock: HttpTestingController;
let client: ConsoleStatusClient;
let eventSourceFactory: jasmine.Spy<(url: string) => EventSource>;
beforeEach(() => {
eventSourceFactory = jasmine.createSpy('eventSourceFactory').and.callFake((url: string) => new FakeEventSource(url) as unknown as EventSource);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
ConsoleStatusClient,
{ provide: CONSOLE_API_BASE_URL, useValue: '/console' },
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: EVENT_SOURCE_FACTORY, useValue: eventSourceFactory },
],
});
httpMock = TestBed.inject(HttpTestingController);
client = TestBed.inject(ConsoleStatusClient);
});
afterEach(() => {
httpMock.verify();
});
it('adds tenant header and normalizes status response', () => {
const sample: Partial<ConsoleStatusDto> = {
backlog: 2,
queueLagMs: 1200,
activeRuns: 1,
pendingRuns: 1,
healthy: true,
lastCompletedRunId: null,
lastCompletedAt: null,
};
client.getStatus().subscribe((result) => {
expect(result.backlog).toBe(2);
expect(result.queueLagMs).toBe(1200);
expect(result.healthy).toBeTrue();
});
const req = httpMock.expectOne('/console/status');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-dev');
req.flush(sample);
});
it('creates SSE stream URL with tenant param and closes on unsubscribe', () => {
const events: ConsoleRunEventDto[] = [];
const subscription = client.streamRun('run-123').subscribe((evt) => events.push(evt));
expect(eventSourceFactory).toHaveBeenCalled();
const url = eventSourceFactory.calls.mostRecent().args[0];
expect(url).toBe('/console/runs/run-123/stream?tenant=tenant-dev');
// Simulate incoming message
const fakeSource = eventSourceFactory.calls.mostRecent().returnValue as unknown as FakeEventSource;
const message = { data: JSON.stringify({ runId: 'run-123', kind: 'progress', progressPercent: 50, updatedAt: '2025-12-01T00:00:00Z' }) } as MessageEvent;
fakeSource.onmessage?.(message);
expect(events.length).toBe(1);
expect(events[0].kind).toBe('progress');
subscription.unsubscribe();
});
});

View File

@@ -0,0 +1,86 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { ConsoleRunEventDto, ConsoleStatusDto } from './console-status.models';
export const CONSOLE_API_BASE_URL = new InjectionToken<string>('CONSOLE_API_BASE_URL');
export type EventSourceFactory = (url: string) => EventSource;
export const EVENT_SOURCE_FACTORY = new InjectionToken<EventSourceFactory>('EVENT_SOURCE_FACTORY');
export const DEFAULT_EVENT_SOURCE_FACTORY: EventSourceFactory = (url: string) =>
new EventSource(url, { withCredentials: true });
@Injectable({
providedIn: 'root',
})
export class ConsoleStatusClient {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
@Inject(CONSOLE_API_BASE_URL) private readonly baseUrl: string,
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory
) {}
/**
* Poll console status (queue lag, backlog, run counts).
*/
getStatus(tenantId?: string): Observable<ConsoleStatusDto> {
const tenant = this.resolveTenant(tenantId);
const headers = new HttpHeaders({ 'X-StellaOps-Tenant': tenant });
return this.http.get<ConsoleStatusDto>(`${this.baseUrl}/status`, { headers }).pipe(
map((dto) => ({
...dto,
backlog: dto.backlog ?? 0,
queueLagMs: dto.queueLagMs ?? 0,
activeRuns: dto.activeRuns ?? 0,
pendingRuns: dto.pendingRuns ?? 0,
healthy: dto.healthy ?? false,
lastCompletedRunId: dto.lastCompletedRunId ?? null,
lastCompletedAt: dto.lastCompletedAt ?? null,
}))
);
}
/**
* Subscribe to streaming updates for a specific run via SSE.
* Caller is responsible for unsubscribing to close the connection.
*/
streamRun(runId: string, tenantId?: string): Observable<ConsoleRunEventDto> {
const tenant = this.resolveTenant(tenantId);
const params = new HttpParams().set('tenant', tenant);
const url = `${this.baseUrl}/runs/${encodeURIComponent(runId)}/stream?${params.toString()}`;
return new Observable<ConsoleRunEventDto>((observer) => {
const source = this.eventSourceFactory(url);
source.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as ConsoleRunEventDto;
observer.next(parsed);
} catch (err) {
observer.error(err);
}
};
source.onerror = (err) => {
observer.error(err);
source.close();
};
return () => source.close();
});
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('ConsoleStatusClient requires an active tenant identifier.');
}
return tenant;
}
}

View File

@@ -0,0 +1,26 @@
export interface ConsoleStatusDto {
/** Current queue backlog size for console jobs. */
backlog: number;
/** Estimated queue lag in milliseconds. */
queueLagMs: number;
/** Active runs currently streaming. */
activeRuns: number;
/** Pending runs waiting to be processed. */
pendingRuns: number;
/** Identifier of the last completed run, if any. */
lastCompletedRunId: string | null;
/** Completion timestamp of the last run. */
lastCompletedAt: string | null;
/** Health flag emitted by backend readiness checks. */
healthy: boolean;
}
export type ConsoleRunEventKind = 'queued' | 'started' | 'progress' | 'completed' | 'failed';
export interface ConsoleRunEventDto {
runId: string;
kind: ConsoleRunEventKind;
progressPercent?: number;
message?: string;
updatedAt: string;
}

View File

@@ -0,0 +1,75 @@
import { Injectable, inject } from '@angular/core';
import { Subscription, firstValueFrom, timer } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { ConsoleStatusClient } from '../api/console-status.client';
import { ConsoleRunEventDto, ConsoleStatusDto } from '../api/console-status.models';
import { ConsoleStatusStore } from './console-status.store';
@Injectable({
providedIn: 'root',
})
export class ConsoleStatusService {
private readonly client = inject(ConsoleStatusClient);
private readonly store = inject(ConsoleStatusStore);
/**
* Fetch a single snapshot of console status.
*/
async fetchStatus(): Promise<void> {
this.store.setLoading(true);
this.store.setError(null);
try {
const status = await firstValueFrom(this.client.getStatus());
this.store.setStatus(status as ConsoleStatusDto);
} catch (err) {
console.error('console status fetch failed', err);
this.store.setError('Unable to load console status');
} finally {
this.store.setLoading(false);
}
}
/**
* Start polling console status at the given interval (ms).
* Returns a subscription that must be unsubscribed by the caller.
*/
startPolling(intervalMs = 30000): Subscription {
this.store.setLoading(true);
this.store.setError(null);
const sub = timer(0, intervalMs)
.pipe(switchMap(() => this.client.getStatus()))
.subscribe({
next: (status) => {
this.store.setStatus(status);
this.store.setLoading(false);
},
error: (err) => {
console.error('console status poll failed', err);
this.store.setError('Unable to load console status');
this.store.setLoading(false);
},
});
return sub;
}
/**
* Subscribe to run stream events for a given run id.
*/
subscribeToRun(runId: string): Subscription {
this.store.clearEvents();
return this.client.streamRun(runId).subscribe({
next: (evt: ConsoleRunEventDto) => this.store.appendRunEvent(evt),
error: (err) => {
console.error('console run stream error', err);
this.store.setError('Run stream disconnected');
},
});
}
clear(): void {
this.store.clear();
}
}

View File

@@ -0,0 +1,18 @@
import { ConsoleStatusStore } from './console-status.store';
describe('ConsoleStatusStore', () => {
it('appends events with cap of 50', () => {
const store = new ConsoleStatusStore();
for (let i = 0; i < 60; i += 1) {
store.appendRunEvent({
runId: `r-${i}`,
kind: 'progress',
progressPercent: i,
updatedAt: `2025-12-01T00:00:${i.toString().padStart(2, '0')}Z`,
});
}
expect(store.runEvents().length).toBe(50);
expect(store.runEvents()[0].runId).toBe('r-10');
});
});

View File

@@ -0,0 +1,46 @@
import { Injectable, computed, signal } from '@angular/core';
import { ConsoleRunEventDto, ConsoleStatusDto } from '../api/console-status.models';
@Injectable({
providedIn: 'root',
})
export class ConsoleStatusStore {
private readonly statusSignal = signal<ConsoleStatusDto | null>(null);
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
private readonly runEventsSignal = signal<ConsoleRunEventDto[]>([]);
readonly status = computed(() => this.statusSignal());
readonly loading = computed(() => this.loadingSignal());
readonly error = computed(() => this.errorSignal());
readonly runEvents = computed(() => this.runEventsSignal());
setLoading(value: boolean): void {
this.loadingSignal.set(value);
}
setError(message: string | null): void {
this.errorSignal.set(message);
}
setStatus(status: ConsoleStatusDto | null): void {
this.statusSignal.set(status);
}
appendRunEvent(evt: ConsoleRunEventDto): void {
const next = [...this.runEventsSignal(), evt].slice(-50); // keep last 50 for UI
this.runEventsSignal.set(next);
}
clearEvents(): void {
this.runEventsSignal.set([]);
}
clear(): void {
this.statusSignal.set(null);
this.loadingSignal.set(false);
this.errorSignal.set(null);
this.runEventsSignal.set([]);
}
}

View File

@@ -0,0 +1,69 @@
<section class="console-status">
<header>
<div>
<h2>Console Status</h2>
<p class="hint">Queue lag, backlog, and active runs updated every 30s.</p>
</div>
<button type="button" (click)="refresh()" [disabled]="loading()">Refresh</button>
</header>
<div class="status-cards" *ngIf="status(); else statusSkeleton">
<article>
<p class="label">Queue Lag</p>
<p class="value">{{ status()?.queueLagMs ?? 0 | number }} ms</p>
</article>
<article>
<p class="label">Backlog</p>
<p class="value">{{ status()?.backlog ?? 0 }}</p>
</article>
<article>
<p class="label">Active Runs</p>
<p class="value">{{ status()?.activeRuns ?? 0 }}</p>
</article>
<article>
<p class="label">Pending Runs</p>
<p class="value">{{ status()?.pendingRuns ?? 0 }}</p>
</article>
<article>
<p class="label">Health</p>
<p class="value" [class.ok]="status()?.healthy" [class.warn]="!status()?.healthy">
{{ status()?.healthy ? 'Healthy' : 'Degraded' }}
</p>
</article>
</div>
<div class="error" *ngIf="error()">{{ error() }}</div>
<section class="run-stream">
<header>
<h3>Run Stream</h3>
<label>
Run ID
<input type="text" [value]="runId()" (input)="runId.set(($event.target as HTMLInputElement).value)" (change)="startRunStream()" />
</label>
</header>
<div class="events">
<div class="event" *ngFor="let evt of runEvents()">
<div class="meta">
<span class="kind">{{ evt.kind }}</span>
<span class="time">{{ evt.updatedAt }}</span>
</div>
<div class="detail">
<span class="run">Run {{ evt.runId }}</span>
<span class="message">{{ evt.message || '...' }}</span>
<span *ngIf="evt.progressPercent != null" class="progress">{{ evt.progressPercent }}%</span>
</div>
</div>
<p *ngIf="runEvents().length === 0" class="empty">No events yet.</p>
</div>
</section>
</section>
<ng-template #statusSkeleton>
<div class="status-cards skeleton">
<article *ngFor="let i of [1,2,3,4,5]">
<p class="label">Loading…</p>
<p class="value"></p>
</article>
</div>
</ng-template>

View File

@@ -0,0 +1,125 @@
.console-status {
display: flex;
flex-direction: column;
gap: 1rem;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.hint {
color: #69707a;
margin: 0;
}
.status-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.status-cards article {
background: #0d1117;
border: 1px solid #1f2a36;
border-radius: 8px;
padding: 0.75rem;
}
.status-cards .label {
margin: 0;
color: #9da9bb;
font-size: 0.85rem;
}
.status-cards .value {
margin: 0.2rem 0 0;
font-size: 1.3rem;
font-weight: 600;
}
.value.ok {
color: #2dc98c;
}
.value.warn {
color: #f0ad4e;
}
.run-stream header {
display: flex;
align-items: center;
gap: 1rem;
}
.run-stream label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.events {
border: 1px solid #1f2a36;
border-radius: 8px;
padding: 0.5rem;
background: #0b0f14;
}
.event {
border-bottom: 1px solid #1f2a36;
padding: 0.5rem 0;
}
.event:last-child {
border-bottom: none;
}
.meta {
display: flex;
gap: 0.75rem;
font-size: 0.85rem;
color: #9da9bb;
}
.detail {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.25rem;
}
.kind {
text-transform: uppercase;
letter-spacing: 0.08em;
}
.progress {
color: #2dc98c;
}
.empty {
color: #69707a;
margin: 0.5rem 0 0;
}
.error {
color: #f05d5d;
}
.skeleton article {
background: linear-gradient(90deg, #0d1117, #111824, #0d1117);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

View File

@@ -0,0 +1,46 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { ConsoleStatusService } from '../../core/console/console-status.service';
import { ConsoleStatusStore } from '../../core/console/console-status.store';
@Component({
selector: 'app-console-status',
standalone: true,
imports: [CommonModule],
templateUrl: './console-status.component.html',
styleUrls: ['./console-status.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConsoleStatusComponent implements OnInit, OnDestroy {
private readonly service = inject(ConsoleStatusService);
private readonly store = inject(ConsoleStatusStore);
private pollSub: ReturnType<ConsoleStatusService['startPolling']> | null = null;
private runSub: ReturnType<ConsoleStatusService['subscribeToRun']> | null = null;
readonly status = this.store.status;
readonly loading = this.store.loading;
readonly error = this.store.error;
readonly runEvents = this.store.runEvents;
readonly runId = signal<string>('last');
ngOnInit(): void {
this.pollSub = this.service.startPolling(30000);
this.startRunStream();
}
ngOnDestroy(): void {
this.pollSub?.unsubscribe();
this.runSub?.unsubscribe();
}
refresh(): void {
this.service.fetchStatus();
}
startRunStream(): void {
this.runSub?.unsubscribe();
this.runSub = this.service.subscribeToRun(this.runId());
}
}