Add call graph fixtures for various languages and scenarios
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
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (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
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
- Introduced `all-edge-reasons.json` to test edge resolution reasons in .NET. - Added `all-visibility-levels.json` to validate method visibility levels in .NET. - Created `dotnet-aspnetcore-minimal.json` for a minimal ASP.NET Core application. - Included `go-gin-api.json` for a Go Gin API application structure. - Added `java-spring-boot.json` for the Spring PetClinic application in Java. - Introduced `legacy-no-schema.json` for legacy application structure without schema. - Created `node-express-api.json` for an Express.js API application structure.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
| WEB-AIAI-31-003 | DONE (2025-12-12) | Telemetry headers + prompt hash support; documented guardrail surface for audit visibility. |
|
||||
| WEB-CONSOLE-23-002 | DONE (2025-12-04) | console/status polling + run stream client/store/UI shipped; samples verified in `docs/api/console/samples/`. |
|
||||
| WEB-CONSOLE-23-003 | DONE (2025-12-07) | Exports client/store/service + models shipped; targeted Karma specs green locally with CHROME_BIN override (`node ./node_modules/@angular/cli/bin/ng.js test --watch=false --browsers=ChromeHeadless --include console-export specs`). Backend manifest/limits v0.4 published; awaiting final Policy/DevOps sign-off but UI/client slice complete. |
|
||||
| WEB-RISK-66-001 | BLOCKED (2025-12-03) | Same implementation landed; npm ci hangs so Angular tests can’t run; waiting on stable install environment and gateway endpoints to validate. |
|
||||
| WEB-RISK-66-001 | BLOCKED (2025-12-03) | Same implementation landed; npm ci hangs so Angular tests can’t run; waiting on stable install environment and gateway endpoints to validate. |
|
||||
| WEB-EXC-25-001 | DONE (2025-12-12) | Exception contract + sample updated (`docs/api/console/exception-schema.md`); `ExceptionApiHttpClient` enforces scopes + trace/tenant headers with unit spec. |
|
||||
| WEB-EXC-25-002 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/policy-exceptions.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/policy-exceptions.client.ts`. |
|
||||
| WEB-EXC-25-003 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/exception-events.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/exception-events.client.ts`. |
|
||||
@@ -48,3 +48,5 @@
|
||||
| UI-VEX-0215-A11Y | DONE (2025-12-12) | Added dialog semantics + focus trap for `VexDecisionModalComponent` and Playwright Axe coverage in `tests/e2e/a11y-smoke.spec.ts`. |
|
||||
| UI-TRIAGE-0215-FIXTURES | DONE (2025-12-12) | Made quickstart mock fixtures deterministic for triage surfaces (VEX decisions, audit bundles, vulnerabilities) to support offline-kit hashing and stable tests. |
|
||||
| UI-TRIAGE-4601-001 | DONE (2025-12-15) | Keyboard shortcuts for triage workspace (SPRINT_4601_0001_0001_keyboard_shortcuts.md). |
|
||||
| UI-TRIAGE-4602-001 | DONE (2025-12-15) | Finish triage decision drawer/evidence pills QA: component specs + Storybook stories (SPRINT_4602_0001_0001_decision_drawer_evidence_tab.md). |
|
||||
| UI-TTFS-0340-001 | DONE (2025-12-15) | FirstSignalCard UI component + client/store/tests (SPRINT_0340_0001_0001_first_signal_card_ui.md). |
|
||||
|
||||
@@ -90,6 +90,11 @@ import {
|
||||
OrchestratorControlHttpClient,
|
||||
MockOrchestratorControlClient,
|
||||
} from './core/api/orchestrator-control.client';
|
||||
import {
|
||||
FIRST_SIGNAL_API,
|
||||
FirstSignalHttpClient,
|
||||
MockFirstSignalClient,
|
||||
} from './core/api/first-signal.client';
|
||||
import {
|
||||
EXCEPTION_EVENTS_API,
|
||||
EXCEPTION_EVENTS_API_BASE_URL,
|
||||
@@ -361,6 +366,17 @@ export const appConfig: ApplicationConfig = {
|
||||
mock: MockOrchestratorControlClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
},
|
||||
FirstSignalHttpClient,
|
||||
MockFirstSignalClient,
|
||||
{
|
||||
provide: FIRST_SIGNAL_API,
|
||||
deps: [AppConfigService, FirstSignalHttpClient, MockFirstSignalClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: FirstSignalHttpClient,
|
||||
mock: MockFirstSignalClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
},
|
||||
{
|
||||
provide: EXCEPTION_EVENTS_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
|
||||
171
src/Web/StellaOps.Web/src/app/core/api/first-signal.client.ts
Normal file
171
src/Web/StellaOps.Web/src/app/core/api/first-signal.client.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { EVENT_SOURCE_FACTORY, type EventSourceFactory } from './console-status.client';
|
||||
import { ORCHESTRATOR_API_BASE_URL } from './orchestrator.client';
|
||||
import { FirstSignalResponse, type FirstSignalRunStreamPayload } from './first-signal.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
export interface FirstSignalApi {
|
||||
getFirstSignal(
|
||||
runId: string,
|
||||
options?: { etag?: string; tenantId?: string; projectId?: string; traceId?: string }
|
||||
): Observable<{ response: FirstSignalResponse | null; etag: string | null; cacheStatus: string }>;
|
||||
|
||||
streamFirstSignal(runId: string, options?: { tenantId?: string; traceId?: string }): Observable<FirstSignalRunStreamPayload>;
|
||||
}
|
||||
|
||||
export const FIRST_SIGNAL_API = new InjectionToken<FirstSignalApi>('FIRST_SIGNAL_API');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FirstSignalHttpClient implements FirstSignalApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
private readonly tenantService: TenantActivationService,
|
||||
@Inject(ORCHESTRATOR_API_BASE_URL) private readonly baseUrl: string,
|
||||
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetch the first signal for a run.
|
||||
* Supports conditional requests via If-None-Match.
|
||||
*/
|
||||
getFirstSignal(
|
||||
runId: string,
|
||||
options: { etag?: string; tenantId?: string; projectId?: string; traceId?: string } = {}
|
||||
): Observable<{ response: FirstSignalResponse | null; etag: string | null; cacheStatus: string }> {
|
||||
const tenant = this.resolveTenant(options.tenantId);
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
if (!this.tenantService.authorize('orchestrator', 'read', ['orch:read'], options.projectId, traceId)) {
|
||||
return throwError(() => new Error('Unauthorized: missing orch:read scope'));
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<FirstSignalResponse>(`${this.baseUrl}/orchestrator/runs/${encodeURIComponent(runId)}/first-signal`, {
|
||||
headers: this.buildHeaders(tenant, traceId, options.projectId, options.etag),
|
||||
observe: 'response',
|
||||
})
|
||||
.pipe(
|
||||
map((resp: HttpResponse<FirstSignalResponse>) => ({
|
||||
response: resp.body ?? null,
|
||||
etag: resp.headers.get('ETag'),
|
||||
cacheStatus: resp.headers.get('Cache-Status') ?? 'unknown',
|
||||
})),
|
||||
catchError((err) => {
|
||||
if (err?.status === 304) {
|
||||
return of({ response: null, etag: options.etag ?? null, cacheStatus: 'not-modified' });
|
||||
}
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to run stream `first_signal` events.
|
||||
* NOTE: SSE requires tenant to be provided via query param (EventSource cannot set custom headers).
|
||||
*/
|
||||
streamFirstSignal(runId: string, options: { tenantId?: string; traceId?: string } = {}): Observable<FirstSignalRunStreamPayload> {
|
||||
const tenant = this.resolveTenant(options.tenantId);
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
|
||||
const params = new HttpParams().set('tenant', tenant).set('traceId', traceId);
|
||||
const url = `${this.baseUrl}/orchestrator/stream/runs/${encodeURIComponent(runId)}?${params.toString()}`;
|
||||
|
||||
return new Observable<FirstSignalRunStreamPayload>((observer) => {
|
||||
const source = this.eventSourceFactory(url);
|
||||
|
||||
const onFirstSignal = (event: MessageEvent) => {
|
||||
try {
|
||||
observer.next(JSON.parse(event.data) as FirstSignalRunStreamPayload);
|
||||
} catch (e) {
|
||||
observer.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
source.addEventListener('first_signal', onFirstSignal as EventListener);
|
||||
|
||||
source.onerror = (err) => {
|
||||
observer.error(err);
|
||||
source.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
source.removeEventListener('first_signal', onFirstSignal as EventListener);
|
||||
source.close();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private resolveTenant(tenantId?: string): string {
|
||||
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
if (!tenant) {
|
||||
throw new Error('FirstSignalHttpClient requires an active tenant identifier.');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
headers = headers.set('X-Stella-Project', projectId);
|
||||
}
|
||||
|
||||
if (ifNoneMatch) {
|
||||
headers = headers.set('If-None-Match', ifNoneMatch);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockFirstSignalClient implements FirstSignalApi {
|
||||
private readonly fixedTimestamp = '2025-01-01T00:00:00Z';
|
||||
|
||||
getFirstSignal(
|
||||
runId: string,
|
||||
options: { etag?: string; tenantId?: string; projectId?: string; traceId?: string } = {}
|
||||
): Observable<{ response: FirstSignalResponse | null; etag: string | null; cacheStatus: string }> {
|
||||
void options;
|
||||
|
||||
const etag = '"first-signal-mock-v1"';
|
||||
|
||||
return of({
|
||||
response: {
|
||||
runId,
|
||||
summaryEtag: etag,
|
||||
firstSignal: {
|
||||
type: 'queued',
|
||||
stage: 'resolve',
|
||||
step: 'initialize',
|
||||
message: `Mock first signal for run ${runId}`,
|
||||
at: this.fixedTimestamp,
|
||||
artifact: { kind: 'run' },
|
||||
},
|
||||
},
|
||||
etag,
|
||||
cacheStatus: 'mock',
|
||||
});
|
||||
}
|
||||
|
||||
streamFirstSignal(runId: string, options: { tenantId?: string; traceId?: string } = {}): Observable<FirstSignalRunStreamPayload> {
|
||||
void runId;
|
||||
void options;
|
||||
return new Observable<FirstSignalRunStreamPayload>(() => {
|
||||
// Intentionally no-op; mock mode relies on HTTP polling/refresh for determinism.
|
||||
return () => {};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Orchestrator First Signal API response types.
|
||||
* Mirrors `StellaOps.Orchestrator.WebService.Contracts.FirstSignalResponse`.
|
||||
*/
|
||||
|
||||
export interface FirstSignalResponse {
|
||||
runId: string;
|
||||
firstSignal: FirstSignalDto | null;
|
||||
summaryEtag: string;
|
||||
}
|
||||
|
||||
export interface FirstSignalDto {
|
||||
type: string;
|
||||
stage?: string | null;
|
||||
step?: string | null;
|
||||
message: string;
|
||||
at: string; // ISO-8601
|
||||
artifact?: FirstSignalArtifactDto | null;
|
||||
}
|
||||
|
||||
export interface FirstSignalArtifactDto {
|
||||
kind: string;
|
||||
range?: FirstSignalRangeDto | null;
|
||||
}
|
||||
|
||||
export interface FirstSignalRangeDto {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run SSE payload for `first_signal` events emitted on the run stream.
|
||||
* Current server payload includes `{ runId, etag, signal }`; clients may ignore `signal` and refetch via ETag.
|
||||
*/
|
||||
export interface FirstSignalRunStreamPayload {
|
||||
runId: string;
|
||||
etag: string;
|
||||
signal?: unknown;
|
||||
}
|
||||
|
||||
export type FirstSignalLoadState = 'idle' | 'loading' | 'loaded' | 'unavailable' | 'error' | 'offline';
|
||||
@@ -0,0 +1,77 @@
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
import type { FirstSignalApi } from './first-signal.client';
|
||||
import { FIRST_SIGNAL_API } from './first-signal.client';
|
||||
import { FirstSignalStore } from './first-signal.store';
|
||||
|
||||
describe('FirstSignalStore', () => {
|
||||
let store: FirstSignalStore;
|
||||
let api: jasmine.SpyObj<FirstSignalApi>;
|
||||
|
||||
beforeEach(() => {
|
||||
api = jasmine.createSpyObj<FirstSignalApi>('FirstSignalApi', ['getFirstSignal', 'streamFirstSignal']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [FirstSignalStore, { provide: FIRST_SIGNAL_API, useValue: api }],
|
||||
});
|
||||
|
||||
store = TestBed.inject(FirstSignalStore);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.disconnect();
|
||||
});
|
||||
|
||||
it('stores response when loaded', () => {
|
||||
api.getFirstSignal.and.returnValue(
|
||||
of({
|
||||
response: {
|
||||
runId: 'run-1',
|
||||
summaryEtag: '"etag-1"',
|
||||
firstSignal: {
|
||||
type: 'started',
|
||||
message: 'hello',
|
||||
at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
etag: '"etag-1"',
|
||||
cacheStatus: 'hit',
|
||||
})
|
||||
);
|
||||
|
||||
store.load('run-1');
|
||||
|
||||
expect(store.state()).toBe('loaded');
|
||||
expect(store.hasSignal()).toBeTrue();
|
||||
expect(store.firstSignal()?.message).toBe('hello');
|
||||
expect(store.etag()).toBe('"etag-1"');
|
||||
});
|
||||
|
||||
it('falls back to polling when SSE errors', fakeAsync(() => {
|
||||
api.streamFirstSignal.and.returnValue(throwError(() => new Error('boom')));
|
||||
api.getFirstSignal.and.returnValue(
|
||||
of({
|
||||
response: {
|
||||
runId: 'run-2',
|
||||
summaryEtag: '"etag-2"',
|
||||
firstSignal: null,
|
||||
},
|
||||
etag: '"etag-2"',
|
||||
cacheStatus: 'hit',
|
||||
})
|
||||
);
|
||||
|
||||
store.connect('run-2', { pollIntervalMs: 1000 });
|
||||
|
||||
expect(store.realtimeMode()).toBe('polling');
|
||||
|
||||
tick(999);
|
||||
expect(api.getFirstSignal).not.toHaveBeenCalled();
|
||||
|
||||
tick(1);
|
||||
expect(api.getFirstSignal).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.disconnect();
|
||||
}));
|
||||
});
|
||||
126
src/Web/StellaOps.Web/src/app/core/api/first-signal.store.ts
Normal file
126
src/Web/StellaOps.Web/src/app/core/api/first-signal.store.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { Subscription, timer } from 'rxjs';
|
||||
|
||||
import { FIRST_SIGNAL_API, type FirstSignalApi } from './first-signal.client';
|
||||
import { FirstSignalLoadState, type FirstSignalResponse } from './first-signal.models';
|
||||
|
||||
export type FirstSignalRealtimeMode = 'disconnected' | 'sse' | 'polling';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FirstSignalStore {
|
||||
private readonly client = inject(FIRST_SIGNAL_API) as FirstSignalApi;
|
||||
|
||||
private readonly responseSignal = signal<FirstSignalResponse | null>(null);
|
||||
private readonly etagSignal = signal<string | null>(null);
|
||||
private readonly cacheStatusSignal = signal<string | null>(null);
|
||||
private readonly stateSignal = signal<FirstSignalLoadState>('idle');
|
||||
private readonly errorSignal = signal<string | null>(null);
|
||||
private readonly realtimeModeSignal = signal<FirstSignalRealtimeMode>('disconnected');
|
||||
|
||||
private streamSubscription: Subscription | null = null;
|
||||
private pollSubscription: Subscription | null = null;
|
||||
|
||||
readonly response: Signal<FirstSignalResponse | null> = this.responseSignal.asReadonly();
|
||||
readonly etag: Signal<string | null> = this.etagSignal.asReadonly();
|
||||
readonly cacheStatus: Signal<string | null> = this.cacheStatusSignal.asReadonly();
|
||||
readonly state: Signal<FirstSignalLoadState> = this.stateSignal.asReadonly();
|
||||
readonly error: Signal<string | null> = this.errorSignal.asReadonly();
|
||||
readonly realtimeMode: Signal<FirstSignalRealtimeMode> = this.realtimeModeSignal.asReadonly();
|
||||
|
||||
readonly firstSignal = computed(() => this.responseSignal()?.firstSignal ?? null);
|
||||
readonly hasSignal = computed(() => !!this.responseSignal()?.firstSignal);
|
||||
|
||||
prime(entry: { response: FirstSignalResponse; etag?: string | null }): void {
|
||||
if (!entry?.response) return;
|
||||
|
||||
this.responseSignal.set(entry.response);
|
||||
this.etagSignal.set(entry.etag ?? entry.response.summaryEtag ?? null);
|
||||
this.cacheStatusSignal.set('prefetch');
|
||||
this.stateSignal.set('loaded');
|
||||
this.errorSignal.set(null);
|
||||
}
|
||||
|
||||
load(runId: string, options: { tenantId?: string; projectId?: string } = {}): void {
|
||||
this.stateSignal.set('loading');
|
||||
this.errorSignal.set(null);
|
||||
|
||||
const priorEtag = this.etagSignal();
|
||||
|
||||
this.client
|
||||
.getFirstSignal(runId, {
|
||||
etag: priorEtag ?? undefined,
|
||||
tenantId: options.tenantId,
|
||||
projectId: options.projectId,
|
||||
})
|
||||
.pipe(finalize(() => this.stateSignal.set(this.stateSignal() === 'loading' ? 'idle' : this.stateSignal())))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.cacheStatusSignal.set(result.cacheStatus);
|
||||
|
||||
if (result.cacheStatus === 'not-modified') {
|
||||
this.stateSignal.set('loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.response) {
|
||||
this.stateSignal.set('unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
this.responseSignal.set(result.response);
|
||||
this.etagSignal.set(result.etag ?? result.response.summaryEtag ?? null);
|
||||
this.stateSignal.set('loaded');
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.stateSignal.set(navigator.onLine ? 'error' : 'offline');
|
||||
this.errorSignal.set(this.normalizeError(err));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
connect(runId: string, options: { tenantId?: string; projectId?: string; pollIntervalMs?: number } = {}): void {
|
||||
this.disconnect();
|
||||
this.realtimeModeSignal.set('sse');
|
||||
|
||||
this.streamSubscription = this.client.streamFirstSignal(runId, { tenantId: options.tenantId }).subscribe({
|
||||
next: () => {
|
||||
this.realtimeModeSignal.set('sse');
|
||||
this.load(runId, options);
|
||||
},
|
||||
error: () => {
|
||||
this.startPolling(runId, options);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.streamSubscription?.unsubscribe();
|
||||
this.streamSubscription = null;
|
||||
this.pollSubscription?.unsubscribe();
|
||||
this.pollSubscription = null;
|
||||
this.realtimeModeSignal.set('disconnected');
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.disconnect();
|
||||
this.responseSignal.set(null);
|
||||
this.etagSignal.set(null);
|
||||
this.cacheStatusSignal.set(null);
|
||||
this.stateSignal.set('idle');
|
||||
this.errorSignal.set(null);
|
||||
}
|
||||
|
||||
private startPolling(runId: string, options: { tenantId?: string; projectId?: string; pollIntervalMs?: number }): void {
|
||||
const pollIntervalMs = Math.max(1000, Math.floor(options.pollIntervalMs ?? 5000));
|
||||
|
||||
this.pollSubscription?.unsubscribe();
|
||||
this.pollSubscription = timer(pollIntervalMs, pollIntervalMs).subscribe(() => this.load(runId, options));
|
||||
this.realtimeModeSignal.set('polling');
|
||||
}
|
||||
|
||||
private normalizeError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return 'Unknown error fetching first signal';
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
<input type="text" [value]="runId()" (input)="runId.set($any($event.target).value)" (change)="startRunStream()" />
|
||||
</label>
|
||||
</header>
|
||||
<app-first-signal-card [runId]="runId()" [enableRealTime]="true" [pollIntervalMs]="5000" />
|
||||
<div class="events">
|
||||
<div class="event" *ngFor="let evt of runEvents()">
|
||||
<div class="meta">
|
||||
|
||||
@@ -55,6 +55,10 @@ header {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.run-stream app-first-signal-card {
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
|
||||
.run-stream label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -3,11 +3,12 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal }
|
||||
|
||||
import { ConsoleStatusService } from '../../core/console/console-status.service';
|
||||
import { ConsoleStatusStore } from '../../core/console/console-status.store';
|
||||
import { FirstSignalCardComponent } from '../runs/components/first-signal-card/first-signal-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-console-status',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FirstSignalCardComponent],
|
||||
templateUrl: './console-status.component.html',
|
||||
styleUrls: ['./console-status.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<header class="first-signal-card__header">
|
||||
<div class="first-signal-card__title">
|
||||
<span class="first-signal-card__label">First signal</span>
|
||||
<span class="badge" [class]="badgeClass()">{{ badgeText() }}</span>
|
||||
</div>
|
||||
<div class="first-signal-card__meta">
|
||||
@if (realtimeMode() === 'sse') {
|
||||
<span class="realtime-indicator realtime-indicator--live">Live</span>
|
||||
} @else if (realtimeMode() === 'polling') {
|
||||
<span class="realtime-indicator realtime-indicator--polling">Polling</span>
|
||||
}
|
||||
@if (stageText(); as stage) {
|
||||
<span class="first-signal-card__stage">{{ stage }}</span>
|
||||
}
|
||||
<span class="first-signal-card__run-id">Run: {{ runId() }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (signal(); as sig) {
|
||||
<div class="first-signal-card__body" aria-live="polite" aria-atomic="true">
|
||||
<p class="first-signal-card__message">{{ sig.message }}</p>
|
||||
|
||||
@if (sig.artifact) {
|
||||
<div class="first-signal-card__artifact">
|
||||
<span class="first-signal-card__artifact-kind">{{ sig.artifact.kind }}</span>
|
||||
@if (sig.artifact.range) {
|
||||
<span class="first-signal-card__artifact-range">
|
||||
Range {{ sig.artifact.range.start }}–{{ sig.artifact.range.end }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<time class="first-signal-card__timestamp" [attr.datetime]="sig.at" [title]="sig.at">
|
||||
{{ sig.at | date: 'medium' }}
|
||||
</time>
|
||||
</div>
|
||||
} @else if (response()) {
|
||||
<div class="first-signal-card__empty" role="status">
|
||||
<p>Waiting for first signal…</p>
|
||||
</div>
|
||||
} @else if (state() === 'loading' && showSkeleton()) {
|
||||
<div class="first-signal-card__skeleton" aria-hidden="true">
|
||||
<div class="skeleton-line skeleton-line--wide"></div>
|
||||
<div class="skeleton-line skeleton-line--medium"></div>
|
||||
<div class="skeleton-line skeleton-line--narrow"></div>
|
||||
</div>
|
||||
} @else if (state() === 'unavailable') {
|
||||
<div class="first-signal-card__empty" role="status">
|
||||
<p>Signal not available yet.</p>
|
||||
</div>
|
||||
} @else if (state() === 'offline') {
|
||||
<div class="first-signal-card__error" role="alert">
|
||||
<p>Offline. Last known signal may be stale.</p>
|
||||
<button type="button" class="retry-button" (click)="retry()">Retry</button>
|
||||
</div>
|
||||
} @else if (state() === 'error') {
|
||||
<div class="first-signal-card__error" role="alert">
|
||||
<p>{{ error() ?? 'Failed to load signal.' }}</p>
|
||||
<button type="button" class="retry-button" (click)="retry()">Try again</button>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
.first-signal-card {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.first-signal-card__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.first-signal-card__title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.first-signal-card__label {
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.first-signal-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.realtime-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.725rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.realtime-indicator--live {
|
||||
background: rgba(20, 184, 166, 0.14);
|
||||
border: 1px solid rgba(20, 184, 166, 0.25);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.realtime-indicator--polling {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.first-signal-card__run-id {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.badge--neutral {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.badge--info {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
color: #1d4ed8;
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.badge--ok {
|
||||
background: rgba(20, 184, 166, 0.14);
|
||||
color: #0f766e;
|
||||
border-color: rgba(20, 184, 166, 0.25);
|
||||
}
|
||||
|
||||
.badge--warn {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
border-color: rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
|
||||
.badge--error {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
color: #b91c1c;
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.badge--unknown {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.first-signal-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.first-signal-card__message {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.first-signal-card__artifact {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #334155;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.first-signal-card__artifact-kind {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.first-signal-card__artifact-range {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.first-signal-card__timestamp {
|
||||
font-size: 0.825rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.first-signal-card__empty {
|
||||
padding: 0.75rem 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.first-signal-card__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
align-self: flex-start;
|
||||
border: 1px solid #fca5a5;
|
||||
background: #ffffff;
|
||||
color: #991b1b;
|
||||
border-radius: 10px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--motion-duration-sm) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-sm) var(--motion-ease-standard);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: #fee2e2;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.first-signal-card__skeleton {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #eef2ff, #f1f5f9, #eef2ff);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.8s infinite;
|
||||
}
|
||||
|
||||
.skeleton-line--wide {
|
||||
width: 92%;
|
||||
}
|
||||
|
||||
.skeleton-line--medium {
|
||||
width: 72%;
|
||||
}
|
||||
|
||||
.skeleton-line--narrow {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton-line {
|
||||
animation: none;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
OnDestroy,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import { FirstSignalStore } from '../../../../core/api/first-signal.store';
|
||||
import { FirstSignalDto } from '../../../../core/api/first-signal.models';
|
||||
import { FirstSignalPrefetchService } from '../../services/first-signal-prefetch.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-first-signal-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './first-signal-card.component.html',
|
||||
styleUrls: ['./first-signal-card.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'first-signal-card',
|
||||
role: 'region',
|
||||
'aria-label': 'First signal status',
|
||||
'[attr.aria-busy]': "state() === 'loading'",
|
||||
'[class.first-signal-card--loading]': "state() === 'loading'",
|
||||
'[class.first-signal-card--error]': "state() === 'error'",
|
||||
'[class.first-signal-card--offline]': "state() === 'offline'",
|
||||
},
|
||||
})
|
||||
export class FirstSignalCardComponent implements OnDestroy {
|
||||
private readonly store = inject(FirstSignalStore);
|
||||
private readonly prefetch = inject(FirstSignalPrefetchService);
|
||||
private lastLoadKey: string | null = null;
|
||||
|
||||
readonly runId = input.required<string>();
|
||||
readonly tenantId = input<string | null>(null);
|
||||
readonly projectId = input<string | null>(null);
|
||||
readonly enableRealTime = input<boolean>(true);
|
||||
readonly pollIntervalMs = input<number>(5000);
|
||||
readonly skeletonDelayMs = input<number>(50);
|
||||
|
||||
private skeletonDelayHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly showSkeletonSignal = signal(false);
|
||||
|
||||
readonly state = this.store.state;
|
||||
readonly error = this.store.error;
|
||||
readonly response = this.store.response;
|
||||
readonly signal = this.store.firstSignal;
|
||||
readonly hasSignal = this.store.hasSignal;
|
||||
readonly realtimeMode = this.store.realtimeMode;
|
||||
readonly showSkeleton = this.showSkeletonSignal.asReadonly();
|
||||
|
||||
readonly badgeText = computed(() => this.formatBadgeText(this.signal()?.type));
|
||||
readonly badgeClass = computed(() => this.formatBadgeClass(this.signal()?.type));
|
||||
readonly stageText = computed(() => this.formatStageText(this.signal()));
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
const runId = this.runId();
|
||||
const tenantId = this.tenantId() ?? undefined;
|
||||
const projectId = this.projectId() ?? undefined;
|
||||
const enableRealTime = this.enableRealTime();
|
||||
const pollIntervalMs = this.pollIntervalMs();
|
||||
|
||||
const loadKey = `${runId}|${tenantId ?? ''}|${projectId ?? ''}|${enableRealTime ? '1' : '0'}|${pollIntervalMs}`;
|
||||
if (this.lastLoadKey === loadKey) {
|
||||
return;
|
||||
}
|
||||
this.lastLoadKey = loadKey;
|
||||
|
||||
this.store.clear();
|
||||
|
||||
const prefetched = this.prefetch.get(runId);
|
||||
if (prefetched?.response) {
|
||||
this.store.prime({ response: prefetched.response, etag: prefetched.etag });
|
||||
}
|
||||
|
||||
this.store.load(runId, { tenantId, projectId });
|
||||
if (enableRealTime) {
|
||||
this.store.connect(runId, { tenantId, projectId, pollIntervalMs });
|
||||
}
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
|
||||
effect(
|
||||
() => {
|
||||
const state = this.state();
|
||||
const delayMs = Math.max(0, Math.floor(this.skeletonDelayMs()));
|
||||
|
||||
if (state !== 'loading' || !!this.response()) {
|
||||
this.clearSkeletonDelay();
|
||||
this.showSkeletonSignal.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showSkeletonSignal.set(false);
|
||||
this.clearSkeletonDelay();
|
||||
this.skeletonDelayHandle = setTimeout(() => this.showSkeletonSignal.set(true), delayMs);
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearSkeletonDelay();
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.store.load(this.runId(), {
|
||||
tenantId: this.tenantId() ?? undefined,
|
||||
projectId: this.projectId() ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private clearSkeletonDelay(): void {
|
||||
if (!this.skeletonDelayHandle) return;
|
||||
clearTimeout(this.skeletonDelayHandle);
|
||||
this.skeletonDelayHandle = null;
|
||||
}
|
||||
|
||||
private formatBadgeText(type: string | null | undefined): string {
|
||||
if (!type) return 'Signal';
|
||||
return type
|
||||
.trim()
|
||||
.replaceAll('_', ' ')
|
||||
.replaceAll('-', ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^./, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
private formatBadgeClass(type: string | null | undefined): string {
|
||||
const normalized = (type ?? '').trim().toLowerCase();
|
||||
if (!normalized) return 'badge badge--unknown';
|
||||
if (normalized === 'failed' || normalized === 'error') return 'badge badge--error';
|
||||
if (normalized === 'blocked') return 'badge badge--warn';
|
||||
if (normalized === 'queued' || normalized === 'pending') return 'badge badge--neutral';
|
||||
if (normalized === 'started' || normalized === 'phase' || normalized === 'running') return 'badge badge--info';
|
||||
if (normalized === 'succeeded' || normalized === 'completed' || normalized === 'done') return 'badge badge--ok';
|
||||
return 'badge badge--neutral';
|
||||
}
|
||||
|
||||
private formatStageText(signal: FirstSignalDto | null): string | null {
|
||||
if (!signal) return null;
|
||||
const stage = (signal.stage ?? '').trim();
|
||||
const step = (signal.step ?? '').trim();
|
||||
if (!stage && !step) return null;
|
||||
if (stage && step) return `${stage} · ${step}`;
|
||||
return stage || step;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, OnDestroy, inject } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { FIRST_SIGNAL_API, type FirstSignalApi } from '../../../core/api/first-signal.client';
|
||||
import type { FirstSignalResponse } from '../../../core/api/first-signal.models';
|
||||
|
||||
export interface FirstSignalPrefetchEntry {
|
||||
response: FirstSignalResponse;
|
||||
etag: string | null;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FirstSignalPrefetchService implements OnDestroy {
|
||||
private readonly api = inject(FIRST_SIGNAL_API) as FirstSignalApi;
|
||||
|
||||
private readonly cache = new Map<string, FirstSignalPrefetchEntry>();
|
||||
private readonly inFlight = new Map<string, Subscription>();
|
||||
|
||||
private observer: IntersectionObserver | null = null;
|
||||
private observed = new Map<Element, { runId: string; tenantId?: string; projectId?: string }>();
|
||||
|
||||
private readonly CACHE_TTL_MS = 60_000;
|
||||
private readonly PREFETCH_THRESHOLD = 0.1;
|
||||
|
||||
observe(element: Element, runId: string, options: { tenantId?: string; projectId?: string } = {}): void {
|
||||
const observer = this.ensureObserver();
|
||||
if (!observer) return;
|
||||
this.observed.set(element, { runId, tenantId: options.tenantId, projectId: options.projectId });
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
unobserve(element: Element): void {
|
||||
if (!this.observer) return;
|
||||
this.observed.delete(element);
|
||||
this.observer.unobserve(element);
|
||||
}
|
||||
|
||||
get(runId: string): FirstSignalPrefetchEntry | null {
|
||||
const entry = this.cache.get(runId);
|
||||
if (!entry) return null;
|
||||
|
||||
if (Date.now() - entry.fetchedAt > this.CACHE_TTL_MS) {
|
||||
this.cache.delete(runId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.observer?.disconnect();
|
||||
this.observer = null;
|
||||
this.observed.clear();
|
||||
|
||||
for (const sub of this.inFlight.values()) {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
this.inFlight.clear();
|
||||
}
|
||||
|
||||
private ensureObserver(): IntersectionObserver | null {
|
||||
if (this.observer) return this.observer;
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
|
||||
this.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const spec = this.observed.get(entry.target);
|
||||
if (!spec) continue;
|
||||
this.prefetch(spec.runId, { tenantId: spec.tenantId, projectId: spec.projectId });
|
||||
}
|
||||
},
|
||||
{ threshold: this.PREFETCH_THRESHOLD }
|
||||
);
|
||||
return this.observer;
|
||||
}
|
||||
|
||||
private prefetch(runId: string, options: { tenantId?: string; projectId?: string }): void {
|
||||
if (this.get(runId)) return;
|
||||
if (this.inFlight.has(runId)) return;
|
||||
|
||||
const sub = this.api
|
||||
.getFirstSignal(runId, {
|
||||
tenantId: options.tenantId,
|
||||
projectId: options.projectId,
|
||||
})
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
if (!result.response) return;
|
||||
this.cache.set(runId, {
|
||||
response: result.response,
|
||||
etag: result.etag ?? result.response.summaryEtag ?? null,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
// Best-effort prefetch; ignore failures and rely on normal load path.
|
||||
},
|
||||
complete: () => {
|
||||
this.inFlight.delete(runId);
|
||||
},
|
||||
});
|
||||
|
||||
this.inFlight.set(runId, sub);
|
||||
sub.add(() => this.inFlight.delete(runId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import {
|
||||
DecisionDrawerComponent,
|
||||
type DecisionFormData,
|
||||
} from './decision-drawer.component';
|
||||
|
||||
describe('DecisionDrawerComponent', () => {
|
||||
let fixture: ComponentFixture<DecisionDrawerComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DecisionDrawerComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DecisionDrawerComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('disables submit until a reason is selected', () => {
|
||||
const component = fixture.componentInstance;
|
||||
component.isOpen = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const submit = fixture.nativeElement.querySelector('button.btn-primary') as HTMLButtonElement;
|
||||
expect(component.isValid()).toBeFalse();
|
||||
expect(submit.disabled).toBeTrue();
|
||||
|
||||
component.setReasonCode('component_not_present');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isValid()).toBeTrue();
|
||||
expect(submit.disabled).toBeFalse();
|
||||
});
|
||||
|
||||
it('emits close when Escape is pressed while open', () => {
|
||||
const component = fixture.componentInstance;
|
||||
component.isOpen = true;
|
||||
|
||||
const closeSpy = jasmine.createSpy('close');
|
||||
component.close.subscribe(closeSpy);
|
||||
|
||||
fixture.detectChanges();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies A/N/U shortcuts while open', () => {
|
||||
const component = fixture.componentInstance;
|
||||
component.isOpen = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.formData().status).toBe('under_investigation');
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true }));
|
||||
expect(component.formData().status).toBe('affected');
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', bubbles: true, cancelable: true }));
|
||||
expect(component.formData().status).toBe('not_affected');
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'u', bubbles: true, cancelable: true }));
|
||||
expect(component.formData().status).toBe('under_investigation');
|
||||
});
|
||||
|
||||
it('does not apply shortcuts when typing in the textarea', () => {
|
||||
const component = fixture.componentInstance;
|
||||
component.isOpen = true;
|
||||
component.setStatus('not_affected');
|
||||
fixture.detectChanges();
|
||||
|
||||
const textarea = fixture.nativeElement.querySelector('textarea.reason-text') as HTMLTextAreaElement;
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true }));
|
||||
|
||||
expect(component.formData().status).toBe('not_affected');
|
||||
});
|
||||
|
||||
it('emits decisionSubmit when valid and submitted', () => {
|
||||
const component = fixture.componentInstance;
|
||||
component.isOpen = true;
|
||||
component.setStatus('affected');
|
||||
component.setReasonCode('vulnerable_code_reachable');
|
||||
component.setReasonText('notes');
|
||||
|
||||
const submitSpy = jasmine.createSpy('decisionSubmit');
|
||||
component.decisionSubmit.subscribe(submitSpy);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const submit = fixture.nativeElement.querySelector('button.btn-primary') as HTMLButtonElement;
|
||||
submit.click();
|
||||
|
||||
expect(submitSpy).toHaveBeenCalledWith({
|
||||
status: 'affected',
|
||||
reasonCode: 'vulnerable_code_reachable',
|
||||
reasonText: 'notes',
|
||||
} as DecisionFormData);
|
||||
});
|
||||
|
||||
it('emits close when close button is clicked', () => {
|
||||
const component = fixture.componentInstance;
|
||||
component.isOpen = true;
|
||||
|
||||
const closeSpy = jasmine.createSpy('close');
|
||||
component.close.subscribe(closeSpy);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const closeButton = fixture.nativeElement.querySelector('button.close-btn') as HTMLButtonElement;
|
||||
closeButton.click();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import type { EvidenceBundle } from '../../models/evidence.model';
|
||||
import { EvidencePillsComponent } from './evidence-pills.component';
|
||||
|
||||
describe('EvidencePillsComponent', () => {
|
||||
let fixture: ComponentFixture<EvidencePillsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidencePillsComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EvidencePillsComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('renders 4 pills and completeness badge', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
expect(element.querySelectorAll('button.pill').length).toBe(4);
|
||||
expect(element.querySelector('.completeness-badge')?.textContent?.trim()).toBe('0/4');
|
||||
});
|
||||
|
||||
it('computes pill classes and badge from evidence', () => {
|
||||
const evidence: EvidenceBundle = {
|
||||
alertId: 'alert-1',
|
||||
computedAt: new Date(0).toISOString(),
|
||||
reachability: { status: 'available', hash: 'sha256:reach' },
|
||||
callstack: { status: 'loading', hash: 'sha256:call' },
|
||||
provenance: { status: 'pending_enrichment', hash: 'sha256:prov' },
|
||||
vex: { status: 'error' },
|
||||
hashes: { combinedHash: 'sha256:all', hashes: ['sha256:reach', 'sha256:call'] },
|
||||
};
|
||||
|
||||
fixture.componentInstance.evidence = evidence;
|
||||
fixture.detectChanges();
|
||||
|
||||
const pills = Array.from(fixture.nativeElement.querySelectorAll('button.pill')) as HTMLButtonElement[];
|
||||
expect(pills.length).toBe(4);
|
||||
|
||||
expect(pills[0].classList.contains('available')).toBeTrue();
|
||||
expect(pills[1].classList.contains('loading')).toBeTrue();
|
||||
expect(pills[2].classList.contains('pending')).toBeTrue();
|
||||
expect(pills[3].classList.contains('unavailable')).toBeTrue();
|
||||
|
||||
expect(pills[0].getAttribute('aria-label')).toBe('Reachability: available');
|
||||
expect(pills[1].getAttribute('aria-label')).toBe('Call-stack: loading');
|
||||
expect(pills[2].getAttribute('aria-label')).toBe('Provenance: pending_enrichment');
|
||||
expect(pills[3].getAttribute('aria-label')).toBe('VEX: error');
|
||||
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.completeness-badge')?.textContent?.trim()).toBe('1/4');
|
||||
});
|
||||
|
||||
it('emits pillClick when a pill is clicked', () => {
|
||||
const component = fixture.componentInstance;
|
||||
const clicks: Array<'reachability' | 'callstack' | 'provenance' | 'vex'> = [];
|
||||
component.pillClick.subscribe((value) => clicks.push(value));
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const pills = (fixture.nativeElement as HTMLElement).querySelectorAll('button.pill');
|
||||
(pills[1] as HTMLButtonElement).click();
|
||||
(pills[3] as HTMLButtonElement).click();
|
||||
|
||||
expect(clicks).toEqual(['callstack', 'vex']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { EvidenceBitset, EvidenceBundle } from './evidence.model';
|
||||
|
||||
describe('EvidenceBitset', () => {
|
||||
it('computes completeness score from flags', () => {
|
||||
const bitset = EvidenceBitset.from({ reachability: true, callstack: false, provenance: true, vex: true });
|
||||
|
||||
expect(bitset.hasReachability).toBeTrue();
|
||||
expect(bitset.hasCallstack).toBeFalse();
|
||||
expect(bitset.hasProvenance).toBeTrue();
|
||||
expect(bitset.hasVex).toBeTrue();
|
||||
expect(bitset.completenessScore).toBe(3);
|
||||
expect(bitset.value).toBe(1 + 4 + 8);
|
||||
});
|
||||
|
||||
it('derives flags from evidence bundle availability', () => {
|
||||
const bundle: EvidenceBundle = {
|
||||
alertId: 'alert-1',
|
||||
computedAt: '2025-12-15T00:00:00.000Z',
|
||||
reachability: { status: 'available' },
|
||||
callstack: { status: 'unavailable' },
|
||||
provenance: { status: 'available' },
|
||||
vex: { status: 'available' },
|
||||
};
|
||||
|
||||
const bitset = EvidenceBitset.fromBundle(bundle);
|
||||
|
||||
expect(bitset.hasReachability).toBeTrue();
|
||||
expect(bitset.hasCallstack).toBeFalse();
|
||||
expect(bitset.hasProvenance).toBeTrue();
|
||||
expect(bitset.hasVex).toBeTrue();
|
||||
expect(bitset.completenessScore).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
|
||||
import { EvidenceBitset } from '../models/evidence.model';
|
||||
import { TtfsTelemetryService } from './ttfs-telemetry.service';
|
||||
|
||||
describe('TtfsTelemetryService', () => {
|
||||
let service: TtfsTelemetryService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
});
|
||||
|
||||
service = TestBed.inject(TtfsTelemetryService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('flushes batched events on decision and clears timing state', fakeAsync(() => {
|
||||
const times = [0, 300, 600, 1400, 1500, 1700, 2000];
|
||||
spyOn(performance, 'now').and.callFake(() => times.shift() ?? 0);
|
||||
|
||||
service.startTracking('alert-1', new Date('2025-12-15T00:00:00.000Z'));
|
||||
service.recordSkeletonRender('alert-1');
|
||||
service.recordFirstEvidence('alert-1', 'reachability');
|
||||
service.recordFullEvidence('alert-1', EvidenceBitset.from({ reachability: true, callstack: true }));
|
||||
service.recordInteraction('alert-1', 'click');
|
||||
service.recordInteraction('alert-1', 'click');
|
||||
service.recordDecision('alert-1', 'accepted');
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/telemetry/ttfs');
|
||||
expect(req.request.method).toBe('POST');
|
||||
|
||||
const body = req.request.body as { events: Array<Record<string, unknown>> };
|
||||
expect(Array.isArray(body.events)).toBeTrue();
|
||||
|
||||
const eventTypes = body.events.map((e) => e['event_type']);
|
||||
expect(eventTypes).toContain('ttfs.start');
|
||||
expect(eventTypes).toContain('ttfs.skeleton');
|
||||
expect(eventTypes).toContain('ttfs.first_evidence');
|
||||
expect(eventTypes).toContain('ttfs.full_evidence');
|
||||
expect(eventTypes).toContain('decision.recorded');
|
||||
|
||||
expect(body.events.filter((e) => e['event_type'] === 'budget.violation').length).toBe(2);
|
||||
|
||||
const firstEvidence = body.events.find((e) => e['event_type'] === 'ttfs.first_evidence') as Record<string, unknown>;
|
||||
expect(firstEvidence['evidence_type']).toBe('reachability');
|
||||
|
||||
const decision = body.events.find((e) => e['event_type'] === 'decision.recorded') as Record<string, unknown>;
|
||||
expect(decision['decision_status']).toBe('accepted');
|
||||
expect(decision['click_count']).toBe(2);
|
||||
|
||||
req.flush({});
|
||||
tick();
|
||||
|
||||
expect(service.getTimings('alert-1')).toBeUndefined();
|
||||
}));
|
||||
|
||||
it('flushes queued events after the timeout', fakeAsync(() => {
|
||||
spyOn(performance, 'now').and.returnValue(0);
|
||||
|
||||
service.startTracking('alert-1', new Date('2025-12-15T00:00:00.000Z'));
|
||||
|
||||
tick(4999);
|
||||
httpMock.expectNone('/api/v1/telemetry/ttfs');
|
||||
|
||||
tick(1);
|
||||
const req = httpMock.expectOne('/api/v1/telemetry/ttfs');
|
||||
expect((req.request.body as { events: unknown[] }).events.length).toBe(1);
|
||||
req.flush({});
|
||||
tick();
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ComponentFixture, TestBed, fakeAsync, flush, flushMicrotasks } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { of } from 'rxjs';
|
||||
@@ -58,7 +59,7 @@ describe('TriageWorkspaceComponent', () => {
|
||||
vexApi.listDecisions.and.returnValue(of({ items: [], count: 0, continuationToken: null }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RouterTestingModule, TriageWorkspaceComponent],
|
||||
imports: [HttpClientTestingModule, RouterTestingModule, TriageWorkspaceComponent],
|
||||
providers: [
|
||||
{ provide: VULNERABILITY_API, useValue: vulnApi },
|
||||
{ provide: VEX_DECISIONS_API, useValue: vexApi },
|
||||
@@ -70,7 +71,7 @@ describe('TriageWorkspaceComponent', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('filters findings by artifactId', fakeAsync(() => {
|
||||
@@ -123,6 +124,8 @@ describe('TriageWorkspaceComponent', () => {
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
||||
expect(component.selectedVulnId()).toBe('v-2');
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it('switches to reachability tab with /', fakeAsync(() => {
|
||||
@@ -130,7 +133,7 @@ describe('TriageWorkspaceComponent', () => {
|
||||
flushMicrotasks();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.activeTab()).toBe('overview');
|
||||
expect(component.activeTab()).toBe('evidence');
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true, cancelable: true }));
|
||||
expect(component.activeTab()).toBe('reachability');
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import { EMPTY, Observable, of, throwError } from 'rxjs';
|
||||
|
||||
import type { FirstSignalApi } from '../../app/core/api/first-signal.client';
|
||||
import { FIRST_SIGNAL_API } from '../../app/core/api/first-signal.client';
|
||||
import type { FirstSignalResponse } from '../../app/core/api/first-signal.models';
|
||||
import { FirstSignalStore } from '../../app/core/api/first-signal.store';
|
||||
import { FirstSignalCardComponent } from '../../app/features/runs/components/first-signal-card/first-signal-card.component';
|
||||
|
||||
type Scenario = 'loaded' | 'waiting' | 'error' | 'loading';
|
||||
|
||||
function createApi(scenario: Scenario): FirstSignalApi {
|
||||
return {
|
||||
getFirstSignal: (runId: string) => {
|
||||
const etag = '"first-signal-story-v1"';
|
||||
|
||||
if (scenario === 'loading') {
|
||||
return new Observable(() => {});
|
||||
}
|
||||
|
||||
if (scenario === 'error') {
|
||||
return throwError(() => new Error('Synthetic first-signal error'));
|
||||
}
|
||||
|
||||
const response: FirstSignalResponse = {
|
||||
runId,
|
||||
summaryEtag: etag,
|
||||
firstSignal:
|
||||
scenario === 'waiting'
|
||||
? null
|
||||
: {
|
||||
type: 'started',
|
||||
stage: 'fetch',
|
||||
step: 'pull',
|
||||
message: `Fetched base metadata for run ${runId}`,
|
||||
at: '2025-01-01T00:00:00Z',
|
||||
artifact: { kind: 'image', range: { start: 0, end: 123 } },
|
||||
},
|
||||
};
|
||||
|
||||
return of({ response, etag, cacheStatus: 'story' });
|
||||
},
|
||||
streamFirstSignal: () => EMPTY,
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta<FirstSignalCardComponent> = {
|
||||
title: 'Runs/First Signal Card',
|
||||
component: FirstSignalCardComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [FirstSignalCardComponent],
|
||||
providers: [FirstSignalStore, { provide: FIRST_SIGNAL_API, useValue: createApi('loaded') }],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
a11y: {
|
||||
element: '#first-signal-card-story',
|
||||
},
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div id="first-signal-card-story" style="max-width: 960px; padding: 16px;">
|
||||
<app-first-signal-card [runId]="runId" [enableRealTime]="enableRealTime" [pollIntervalMs]="pollIntervalMs" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<FirstSignalCardComponent>;
|
||||
|
||||
export const Loaded: Story = {
|
||||
args: {
|
||||
runId: 'run-1',
|
||||
enableRealTime: true,
|
||||
pollIntervalMs: 5000,
|
||||
},
|
||||
};
|
||||
|
||||
export const Waiting: Story = {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [{ provide: FIRST_SIGNAL_API, useValue: createApi('waiting') }],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
runId: 'run-2',
|
||||
enableRealTime: true,
|
||||
pollIntervalMs: 5000,
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [{ provide: FIRST_SIGNAL_API, useValue: createApi('error') }],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
runId: 'run-3',
|
||||
enableRealTime: true,
|
||||
pollIntervalMs: 5000,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [{ provide: FIRST_SIGNAL_API, useValue: createApi('loading') }],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
runId: 'run-4',
|
||||
enableRealTime: false,
|
||||
pollIntervalMs: 5000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
|
||||
import type { AlertSummary } from '../../app/features/triage/components/decision-drawer/decision-drawer.component';
|
||||
import { DecisionDrawerComponent } from '../../app/features/triage/components/decision-drawer/decision-drawer.component';
|
||||
|
||||
const sampleAlert: AlertSummary = {
|
||||
id: 'alert-1',
|
||||
artifactId: 'asset-web-prod',
|
||||
vulnId: 'CVE-2024-0001',
|
||||
severity: 'high',
|
||||
};
|
||||
|
||||
const meta: Meta<DecisionDrawerComponent> = {
|
||||
title: 'Triage/Decision Drawer',
|
||||
component: DecisionDrawerComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [DecisionDrawerComponent],
|
||||
}),
|
||||
],
|
||||
argTypes: {
|
||||
close: { action: 'close' },
|
||||
decisionSubmit: { action: 'decisionSubmit' },
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
element: '#decision-drawer-story',
|
||||
},
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div id="decision-drawer-story" style="height: 640px; background: #f5f5f5; position: relative;">
|
||||
<app-decision-drawer
|
||||
[alert]="alert"
|
||||
[isOpen]="isOpen"
|
||||
[evidenceHash]="evidenceHash"
|
||||
[policyVersion]="policyVersion"
|
||||
(close)="close()"
|
||||
(decisionSubmit)="decisionSubmit($event)">
|
||||
</app-decision-drawer>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DecisionDrawerComponent>;
|
||||
|
||||
export const Open: Story = {
|
||||
args: {
|
||||
alert: sampleAlert,
|
||||
isOpen: true,
|
||||
evidenceHash: 'sha256:deadbeef',
|
||||
policyVersion: 'policy-v1',
|
||||
},
|
||||
};
|
||||
|
||||
export const Closed: Story = {
|
||||
args: {
|
||||
alert: sampleAlert,
|
||||
isOpen: false,
|
||||
evidenceHash: 'sha256:deadbeef',
|
||||
policyVersion: 'policy-v1',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
|
||||
import type { EvidenceBundle } from '../../app/features/triage/models/evidence.model';
|
||||
import { EvidencePillsComponent } from '../../app/features/triage/components/evidence-pills/evidence-pills.component';
|
||||
|
||||
const baseEvidence: EvidenceBundle = {
|
||||
alertId: 'alert-1',
|
||||
computedAt: new Date(0).toISOString(),
|
||||
reachability: { status: 'unavailable' },
|
||||
callstack: { status: 'unavailable' },
|
||||
provenance: { status: 'unavailable' },
|
||||
vex: { status: 'unavailable' },
|
||||
hashes: { combinedHash: 'sha256:00', hashes: [] },
|
||||
};
|
||||
|
||||
const meta: Meta<EvidencePillsComponent> = {
|
||||
title: 'Triage/Evidence Pills',
|
||||
component: EvidencePillsComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [EvidencePillsComponent],
|
||||
}),
|
||||
],
|
||||
argTypes: {
|
||||
pillClick: { action: 'pillClick' },
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
element: '#evidence-pills-story',
|
||||
},
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div id="evidence-pills-story" style="max-width: 960px; padding: 16px; background: #fff;">
|
||||
<app-evidence-pills [evidence]="evidence" (pillClick)="pillClick($event)"></app-evidence-pills>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<EvidencePillsComponent>;
|
||||
|
||||
export const Unavailable: Story = {
|
||||
args: {
|
||||
evidence: baseEvidence,
|
||||
},
|
||||
};
|
||||
|
||||
export const MixedStates: Story = {
|
||||
args: {
|
||||
evidence: {
|
||||
...baseEvidence,
|
||||
reachability: { status: 'available', hash: 'sha256:reach' },
|
||||
callstack: { status: 'loading', hash: 'sha256:call' },
|
||||
provenance: { status: 'pending_enrichment', hash: 'sha256:prov' },
|
||||
vex: { status: 'error' },
|
||||
hashes: { combinedHash: 'sha256:all', hashes: ['sha256:reach', 'sha256:call'] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Complete: Story = {
|
||||
args: {
|
||||
evidence: {
|
||||
...baseEvidence,
|
||||
reachability: { status: 'available' },
|
||||
callstack: { status: 'available' },
|
||||
provenance: { status: 'available' },
|
||||
vex: { status: 'available' },
|
||||
hashes: { combinedHash: 'sha256:complete', hashes: ['sha256:r', 'sha256:c', 'sha256:p', 'sha256:v'] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
92
src/Web/StellaOps.Web/test-results/a11y-_console_status.json
Normal file
92
src/Web/StellaOps.Web/test-results/a11y-_console_status.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:4400/console/status",
|
||||
"violations": [
|
||||
{
|
||||
"id": "color-contrast",
|
||||
"impact": "serious",
|
||||
"tags": [
|
||||
"cat.color",
|
||||
"wcag2aa",
|
||||
"wcag143",
|
||||
"TTv5",
|
||||
"TT13.c",
|
||||
"EN-301-549",
|
||||
"EN-9.1.4.3",
|
||||
"ACT"
|
||||
],
|
||||
"description": "Ensures the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds",
|
||||
"help": "Elements must meet minimum color contrast ratio thresholds",
|
||||
"helpUrl": "https://dequeuniversity.com/rules/axe/4.8/color-contrast?application=playwright",
|
||||
"nodes": [
|
||||
{
|
||||
"any": [
|
||||
{
|
||||
"id": "color-contrast",
|
||||
"data": {
|
||||
"fgColor": "#f05d5d",
|
||||
"bgColor": "#f8fafc",
|
||||
"contrastRatio": 3.12,
|
||||
"fontSize": "12.0pt (16px)",
|
||||
"fontWeight": "normal",
|
||||
"messageKey": null,
|
||||
"expectedContrastRatio": "4.5:1"
|
||||
},
|
||||
"relatedNodes": [
|
||||
{
|
||||
"html": "<app-root _nghost-ng-c1556698195=\"\" ng-version=\"17.3.12\">",
|
||||
"target": [
|
||||
"app-root"
|
||||
]
|
||||
}
|
||||
],
|
||||
"impact": "serious",
|
||||
"message": "Element has insufficient color contrast of 3.12 (foreground color: #f05d5d, background color: #f8fafc, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
|
||||
}
|
||||
],
|
||||
"all": [],
|
||||
"none": [],
|
||||
"impact": "serious",
|
||||
"html": "<div _ngcontent-ng-c669690054=\"\" class=\"error\">Unable to load console status</div>",
|
||||
"target": [
|
||||
".error"
|
||||
],
|
||||
"failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 3.12 (foreground color: #f05d5d, background color: #f8fafc, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
|
||||
},
|
||||
{
|
||||
"any": [
|
||||
{
|
||||
"id": "color-contrast",
|
||||
"data": {
|
||||
"fgColor": "#69707a",
|
||||
"bgColor": "#0b0f14",
|
||||
"contrastRatio": 3.84,
|
||||
"fontSize": "12.0pt (16px)",
|
||||
"fontWeight": "normal",
|
||||
"messageKey": null,
|
||||
"expectedContrastRatio": "4.5:1"
|
||||
},
|
||||
"relatedNodes": [
|
||||
{
|
||||
"html": "<div _ngcontent-ng-c669690054=\"\" class=\"events\"><!--bindings={\n \"ng-reflect-ng-for-of\": \"\"\n}--><p _ngcontent-ng-c669690054=\"\" class=\"empty\">No events yet.</p><!--bindings={\n \"ng-reflect-ng-if\": \"true\"\n}--></div>",
|
||||
"target": [
|
||||
".events"
|
||||
]
|
||||
}
|
||||
],
|
||||
"impact": "serious",
|
||||
"message": "Element has insufficient color contrast of 3.84 (foreground color: #69707a, background color: #0b0f14, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
|
||||
}
|
||||
],
|
||||
"all": [],
|
||||
"none": [],
|
||||
"impact": "serious",
|
||||
"html": "<p _ngcontent-ng-c669690054=\"\" class=\"empty\">No events yet.</p>",
|
||||
"target": [
|
||||
".empty"
|
||||
],
|
||||
"failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 3.84 (foreground color: #69707a, background color: #0b0f14, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:4400/triage/artifacts/asset-web-prod",
|
||||
"violations": []
|
||||
}
|
||||
@@ -88,6 +88,14 @@ test.describe('a11y-smoke', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('console status first signal card', async ({ page }, testInfo) => {
|
||||
const violations = await runA11y('/console/status', page);
|
||||
testInfo.annotations.push({
|
||||
type: 'a11y',
|
||||
description: `${violations.length} violations (/console/status)`,
|
||||
});
|
||||
});
|
||||
|
||||
test('triage VEX modal', async ({ page }, testInfo) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
57
src/Web/StellaOps.Web/tests/e2e/first-signal-card.spec.ts
Normal file
57
src/Web/StellaOps.Web/tests/e2e/first-signal-card.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { policyAuthorSession } from '../../src/app/testing';
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: 'https://authority.local',
|
||||
clientId: 'stellaops-ui',
|
||||
authorizeEndpoint: 'https://authority.local/connect/authorize',
|
||||
tokenEndpoint: 'https://authority.local/connect/token',
|
||||
logoutEndpoint: 'https://authority.local/connect/logout',
|
||||
redirectUri: 'http://127.0.0.1:4400/auth/callback',
|
||||
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
|
||||
scope:
|
||||
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://authority.local',
|
||||
scanner: 'https://scanner.local',
|
||||
policy: 'https://scanner.local',
|
||||
concelier: 'https://concelier.local',
|
||||
attestor: 'https://attestor.local',
|
||||
},
|
||||
quickstartMode: true,
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors in restricted contexts
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, policyAuthorSession);
|
||||
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('https://authority.local/**', (route) => route.abort());
|
||||
});
|
||||
|
||||
test('first signal card renders on console status page (quickstart)', async ({ page }) => {
|
||||
await page.goto('/console/status');
|
||||
|
||||
const card = page.getByRole('region', { name: 'First signal status' });
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
await expect(card).toContainText('Mock first signal for run last');
|
||||
});
|
||||
Reference in New Issue
Block a user