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
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:
@@ -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. |
|
||||
|
||||
@@ -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,
|
||||
|
||||
56
src/Web/StellaOps.Web/src/app/core/aoc/aoc-guard.spec.ts
Normal file
56
src/Web/StellaOps.Web/src/app/core/aoc/aoc-guard.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
213
src/Web/StellaOps.Web/src/app/core/aoc/aoc-guard.ts
Normal file
213
src/Web/StellaOps.Web/src/app/core/aoc/aoc-guard.ts
Normal 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));
|
||||
}
|
||||
52
src/Web/StellaOps.Web/src/app/core/aoc/key-guard.spec.ts
Normal file
52
src/Web/StellaOps.Web/src/app/core/aoc/key-guard.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
75
src/Web/StellaOps.Web/src/app/core/aoc/key-guard.ts
Normal file
75
src/Web/StellaOps.Web/src/app/core/aoc/key-guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user