feat(telemetry): add telemetry client and services for tracking events

- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint.
- Created TtfsTelemetryService for emitting specific telemetry events related to TTFS.
- Added tests for TelemetryClient to ensure event queuing and flushing functionality.
- Introduced models for reachability drift detection, including DriftResult and DriftedSink.
- Developed DriftApiService for interacting with the drift detection API.
- Updated FirstSignalCardComponent to emit telemetry events on signal appearance.
- Enhanced localization support for first signal component with i18n strings.
This commit is contained in:
master
2025-12-18 16:19:16 +02:00
parent 00d2c99af9
commit 811f35cba7
114 changed files with 13702 additions and 268 deletions

View File

@@ -49,6 +49,6 @@
| 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). |
| UI-TTFS-0340-001 | DONE (2025-12-18) | FirstSignalCard UI component + client/store/tests + TTFS telemetry client/sampling + i18n micro-copy (SPRINT_0340_0001_0001_first_signal_card_ui.md). |
| WEB-TTFS-0341-001 | DONE (2025-12-18) | Extend FirstSignal client models with `lastKnownOutcome` (SPRINT_0341_0001_0001_ttfs_enhancements.md). |
| TRI-MASTER-0009 | DONE (2025-12-17) | Added Playwright E2E coverage for triage workflow (tabs, VEX modal, decision drawer, evidence pills). |

View File

@@ -0,0 +1,16 @@
/**
* Core API exports
* Sprint: SPRINT_4100_0001_0001_triage_models
*/
// Triage Evidence
export * from './triage-evidence.models';
export * from './triage-evidence.client';
// Attestation Chain
export * from './attestation-chain.models';
export * from './attestation-chain.client';
// Re-export commonly used types from existing modules
export type { FindingEvidenceResponse, ComponentRef, ScoreExplanation } from './triage-evidence.models';
export type { AttestationChain, DsseEnvelope, InTotoStatement } from './attestation-chain.models';

View File

@@ -0,0 +1,239 @@
/**
* Triage Evidence Client Tests
* Sprint: SPRINT_4100_0001_0001_triage_models
*/
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import {
TriageEvidenceHttpClient,
TriageEvidenceMockClient,
TRIAGE_EVIDENCE_API,
} from './triage-evidence.client';
import {
FindingEvidenceResponse,
ScoreExplanation,
getSeverityLabel,
getSeverityClass,
isVexNotAffected,
isVexValid,
} from './triage-evidence.models';
describe('TriageEvidenceHttpClient', () => {
let client: TriageEvidenceHttpClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [TriageEvidenceHttpClient],
});
client = TestBed.inject(TriageEvidenceHttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
describe('getFindingEvidence', () => {
it('should fetch evidence for a finding', () => {
const mockResponse: FindingEvidenceResponse = {
finding_id: 'finding-001',
cve: 'CVE-2021-44228',
last_seen: new Date().toISOString(),
};
client.getFindingEvidence('finding-001').subscribe((result) => {
expect(result.finding_id).toBe('finding-001');
expect(result.cve).toBe('CVE-2021-44228');
});
const req = httpMock.expectOne('/api/v1/scanner/evidence/finding-001');
expect(req.request.method).toBe('GET');
req.flush(mockResponse);
});
it('should cache repeated requests', () => {
const mockResponse: FindingEvidenceResponse = {
finding_id: 'finding-002',
cve: 'CVE-2023-12345',
last_seen: new Date().toISOString(),
};
// First request
client.getFindingEvidence('finding-002').subscribe();
const req = httpMock.expectOne('/api/v1/scanner/evidence/finding-002');
req.flush(mockResponse);
// Second request should use cache
client.getFindingEvidence('finding-002').subscribe((result) => {
expect(result.finding_id).toBe('finding-002');
});
// No new HTTP request should be made
httpMock.expectNone('/api/v1/scanner/evidence/finding-002');
});
it('should include query params for options', () => {
client
.getFindingEvidence('finding-003', {
include_path: true,
include_score: true,
})
.subscribe();
const req = httpMock.expectOne(
(request) =>
request.url === '/api/v1/scanner/evidence/finding-003' &&
request.params.get('include_path') === 'true' &&
request.params.get('include_score') === 'true'
);
expect(req.request.method).toBe('GET');
req.flush({ finding_id: 'finding-003', cve: 'CVE-2023-00001', last_seen: '' });
});
});
describe('getEvidenceByCve', () => {
it('should fetch evidence by CVE', () => {
client.getEvidenceByCve('CVE-2021-44228').subscribe((result) => {
expect(result.items.length).toBe(1);
expect(result.total).toBe(1);
});
const req = httpMock.expectOne((request) => request.url === '/api/v1/scanner/evidence');
expect(req.request.params.get('cve')).toBe('CVE-2021-44228');
req.flush({
items: [{ finding_id: 'f1', cve: 'CVE-2021-44228', last_seen: '' }],
total: 1,
page: 1,
page_size: 20,
});
});
});
describe('getScoreExplanation', () => {
it('should return score explanation from evidence', () => {
const mockScore: ScoreExplanation = {
kind: 'stellaops_risk_v1',
risk_score: 75.0,
contributions: [],
last_seen: new Date().toISOString(),
};
client.getScoreExplanation('finding-004').subscribe((result) => {
expect(result.risk_score).toBe(75.0);
expect(result.kind).toBe('stellaops_risk_v1');
});
const req = httpMock.expectOne(
(request) =>
request.url === '/api/v1/scanner/evidence/finding-004' &&
request.params.get('include_score') === 'true'
);
req.flush({
finding_id: 'finding-004',
cve: 'CVE-2023-00001',
score_explain: mockScore,
last_seen: '',
});
});
});
describe('invalidateCache', () => {
it('should clear cache for specific finding', () => {
const mockResponse: FindingEvidenceResponse = {
finding_id: 'finding-005',
cve: 'CVE-2023-99999',
last_seen: new Date().toISOString(),
};
// First request
client.getFindingEvidence('finding-005').subscribe();
httpMock.expectOne('/api/v1/scanner/evidence/finding-005').flush(mockResponse);
// Invalidate cache
client.invalidateCache('finding-005');
// Next request should make new HTTP call
client.getFindingEvidence('finding-005').subscribe();
httpMock.expectOne('/api/v1/scanner/evidence/finding-005').flush(mockResponse);
});
});
});
describe('TriageEvidenceMockClient', () => {
let client: TriageEvidenceMockClient;
beforeEach(() => {
client = new TriageEvidenceMockClient();
});
it('should return mock evidence', (done) => {
client.getFindingEvidence('test-finding').subscribe((result) => {
expect(result.finding_id).toBe('test-finding');
expect(result.cve).toBe('CVE-2021-44228');
expect(result.component).toBeDefined();
expect(result.score_explain).toBeDefined();
done();
});
});
it('should return mock list response', (done) => {
client.list({ page: 1, page_size: 10 }).subscribe((result) => {
expect(result.items.length).toBeGreaterThan(0);
expect(result.page).toBe(1);
expect(result.page_size).toBe(10);
done();
});
});
});
describe('Triage Evidence Model Helpers', () => {
describe('getSeverityLabel', () => {
it('should return correct severity labels', () => {
expect(getSeverityLabel(85)).toBe('critical');
expect(getSeverityLabel(65)).toBe('high');
expect(getSeverityLabel(45)).toBe('medium');
expect(getSeverityLabel(25)).toBe('low');
expect(getSeverityLabel(10)).toBe('minimal');
});
});
describe('getSeverityClass', () => {
it('should return CSS class with severity prefix', () => {
expect(getSeverityClass(90)).toBe('severity-critical');
expect(getSeverityClass(30)).toBe('severity-low');
});
});
describe('isVexNotAffected', () => {
it('should return true for not_affected status', () => {
expect(isVexNotAffected({ status: 'not_affected' })).toBe(true);
expect(isVexNotAffected({ status: 'affected' })).toBe(false);
expect(isVexNotAffected(undefined)).toBe(false);
});
});
describe('isVexValid', () => {
it('should return true for non-expired VEX', () => {
const futureDate = new Date(Date.now() + 86400000).toISOString();
expect(isVexValid({ status: 'not_affected', expires_at: futureDate })).toBe(true);
});
it('should return false for expired VEX', () => {
const pastDate = new Date(Date.now() - 86400000).toISOString();
expect(isVexValid({ status: 'not_affected', expires_at: pastDate })).toBe(false);
});
it('should return true for VEX without expiration', () => {
expect(isVexValid({ status: 'not_affected' })).toBe(true);
});
it('should return false for undefined VEX', () => {
expect(isVexValid(undefined)).toBe(false);
});
});
});

View File

@@ -19,6 +19,7 @@ const DEFAULT_CONFIG_URL = '/config.json';
const DEFAULT_DPOP_ALG: DPoPAlgorithm = 'ES256';
const DEFAULT_REFRESH_LEEWAY_SECONDS = 60;
const DEFAULT_QUICKSTART = false;
const DEFAULT_TELEMETRY_SAMPLE_RATE = 0;
@Injectable({
providedIn: 'root',
@@ -91,15 +92,23 @@ export class AppConfigService {
...config.authority,
dpopAlgorithms:
config.authority.dpopAlgorithms?.length ?? 0
? config.authority.dpopAlgorithms
: [DEFAULT_DPOP_ALG],
refreshLeewaySeconds:
config.authority.refreshLeewaySeconds ?? DEFAULT_REFRESH_LEEWAY_SECONDS,
? config.authority.dpopAlgorithms
: [DEFAULT_DPOP_ALG],
refreshLeewaySeconds:
config.authority.refreshLeewaySeconds ?? DEFAULT_REFRESH_LEEWAY_SECONDS,
};
const telemetry = config.telemetry
? {
...config.telemetry,
sampleRate: Math.min(1, Math.max(0, config.telemetry.sampleRate ?? DEFAULT_TELEMETRY_SAMPLE_RATE)),
}
: undefined;
return {
...config,
authority,
telemetry,
quickstartMode: config.quickstartMode ?? DEFAULT_QUICKSTART,
};
}

View File

@@ -0,0 +1,104 @@
/**
* i18n Service for StellaOps Console
* Sprint: SPRINT_0340_0001_0001_first_signal_card_ui
* Task: T17
*
* Provides translation lookup and interpolation for UI micro-copy.
*/
import { Injectable, computed, signal } from '@angular/core';
import enTranslations from '../../../i18n/micro-interactions.en.json';
export type Locale = 'en' | 'en-US';
export interface TranslationParams {
[key: string]: string | number;
}
@Injectable({ providedIn: 'root' })
export class I18nService {
private readonly _translations = signal<Record<string, unknown>>(enTranslations as Record<string, unknown>);
private readonly _locale = signal<Locale>('en');
/** Current locale */
readonly locale = computed(() => this._locale());
/** Whether translations are loaded */
readonly isLoaded = computed(() => Object.keys(this._translations()).length > 0);
constructor() {
// Translations are shipped as local assets for offline-first operation.
}
/**
* Load translations for the current locale.
* In production, this would fetch from a CDN or local asset.
*/
async loadTranslations(locale: Locale = 'en'): Promise<void> {
try {
void locale;
this._translations.set(enTranslations as Record<string, unknown>);
this._locale.set(locale);
} catch (error) {
console.error('Failed to load translations:', error);
// Fallback to empty - will use keys as fallback
}
}
/**
* Get a translation by key path (e.g., 'firstSignal.label').
* Returns the key itself if translation not found.
*
* @param key Dot-separated key path
* @param params Optional interpolation parameters
*/
t(key: string, params?: TranslationParams): string {
const value = this.getNestedValue(this._translations(), key);
if (typeof value !== 'string') {
if (this.isLoaded()) {
console.warn(`Translation key not found: ${key}`);
}
return key;
}
return params ? this.interpolate(value, params) : value;
}
/**
* Attempts to translate without emitting warnings when missing.
*/
tryT(key: string, params?: TranslationParams): string | null {
const value = this.getNestedValue(this._translations(), key);
if (typeof value !== 'string') {
return null;
}
return params ? this.interpolate(value, params) : value;
}
/**
* Get nested value from object using dot notation.
*/
private getNestedValue(obj: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce((current, key) => {
if (current && typeof current === 'object' && key in current) {
return (current as Record<string, unknown>)[key];
}
return undefined;
}, obj as unknown);
}
/**
* Interpolate parameters into a translation string.
* Uses {param} syntax.
*/
private interpolate(template: string, params: TranslationParams): string {
return template.replace(/\{(\w+)\}/g, (match, key) => {
const value = params[key];
return value !== undefined ? String(value) : match;
});
}
}

View File

@@ -0,0 +1,8 @@
/**
* i18n Module Barrel Export
* Sprint: SPRINT_0340_0001_0001_first_signal_card_ui
* Task: T17
*/
export { I18nService, type Locale, type TranslationParams } from './i18n.service';
export { TranslatePipe } from './translate.pipe';

View File

@@ -0,0 +1,23 @@
/**
* Translate Pipe for StellaOps Console
* Sprint: SPRINT_0340_0001_0001_first_signal_card_ui
* Task: T17
*
* Angular pipe for template translations.
*/
import { Pipe, PipeTransform, inject } from '@angular/core';
import { I18nService, TranslationParams } from './i18n.service';
@Pipe({
name: 'translate',
standalone: true,
pure: true
})
export class TranslatePipe implements PipeTransform {
private readonly i18n = inject(I18nService);
transform(key: string, params?: TranslationParams): string {
return this.i18n.t(key, params);
}
}

View File

@@ -0,0 +1,95 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { APP_CONFIG, AppConfig } from '../config/app-config.model';
import { AppConfigService } from '../config/app-config.service';
import { TelemetrySamplerService } from './telemetry-sampler.service';
describe('TelemetrySamplerService', () => {
const baseConfig: AppConfig = {
authority: {
issuer: 'https://auth.stellaops.test/',
clientId: 'ui-client',
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
redirectUri: 'https://ui.stellaops.test/auth/callback',
scope: 'openid profile email ui.read',
audience: 'https://scanner.stellaops.test',
},
apiBaseUrls: {
authority: 'https://auth.stellaops.test',
scanner: 'https://scanner.stellaops.test',
policy: 'https://policy.stellaops.test',
concelier: 'https://concelier.stellaops.test',
attestor: 'https://attestor.stellaops.test',
},
};
let appConfig: AppConfigService;
let sampler: TelemetrySamplerService;
beforeEach(() => {
sessionStorage.clear();
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
AppConfigService,
TelemetrySamplerService,
{
provide: APP_CONFIG,
useValue: baseConfig,
},
],
});
appConfig = TestBed.inject(AppConfigService);
sampler = TestBed.inject(TelemetrySamplerService);
});
it('does not sample when sampleRate is 0', () => {
appConfig.setConfigForTesting({
...baseConfig,
telemetry: { otlpEndpoint: 'https://collector.stellaops.test', sampleRate: 0 },
});
const decision = sampler.decide('ttfs_start');
expect(decision.sampled).toBeFalse();
});
it('samples when sampleRate is 1', () => {
appConfig.setConfigForTesting({
...baseConfig,
telemetry: { otlpEndpoint: 'https://collector.stellaops.test', sampleRate: 1 },
});
const decision = sampler.decide('ttfs_signal_rendered');
expect(decision.sampled).toBeTrue();
});
it('always samples critical events regardless of sampleRate', () => {
appConfig.setConfigForTesting({
...baseConfig,
telemetry: { otlpEndpoint: 'https://collector.stellaops.test', sampleRate: 0 },
});
const decision = sampler.decide('error');
expect(decision.sampled).toBeTrue();
expect(decision.sampleRate).toBe(1);
});
it('uses session-consistent sampling decisions', () => {
sessionStorage.setItem('stellaops.telemetry.sample_value.v1', '0.25');
appConfig.setConfigForTesting({
...baseConfig,
telemetry: { otlpEndpoint: 'https://collector.stellaops.test', sampleRate: 0.5 },
});
const decision1 = sampler.decide('ttfs_start');
const decision2 = sampler.decide('ttfs_signal_rendered');
expect(decision1.sampled).toBeTrue();
expect(decision2.sampled).toBeTrue();
});
});

View File

@@ -0,0 +1,109 @@
import { Injectable, inject } from '@angular/core';
import { AppConfigService } from '../config/app-config.service';
export interface TelemetrySamplingDecision {
readonly sampled: boolean;
readonly sampleRate: number;
readonly sessionId: string;
}
@Injectable({ providedIn: 'root' })
export class TelemetrySamplerService {
private static readonly SessionIdStorageKey = 'stellaops.telemetry.session_id.v1';
private static readonly SessionSampleValueStorageKey = 'stellaops.telemetry.sample_value.v1';
private readonly config = inject(AppConfigService);
decide(eventType: string): TelemetrySamplingDecision {
const resolvedEventType = (eventType ?? '').trim();
const sessionId = this.getOrCreateSessionId();
if (this.isAlwaysSampleEvent(resolvedEventType)) {
return { sampled: true, sampleRate: 1, sessionId };
}
const sampleRate = this.getSampleRate();
if (sampleRate <= 0) {
return { sampled: false, sampleRate, sessionId };
}
if (sampleRate >= 1) {
return { sampled: true, sampleRate, sessionId };
}
const sampleValue = this.getOrCreateSessionSampleValue();
return { sampled: sampleValue < sampleRate, sampleRate, sessionId };
}
private getSampleRate(): number {
try {
const rate = this.config.config.telemetry?.sampleRate;
if (typeof rate !== 'number' || Number.isNaN(rate)) {
return 0;
}
return Math.min(1, Math.max(0, rate));
} catch {
return 0;
}
}
private isAlwaysSampleEvent(eventType: string): boolean {
if (!eventType) return false;
const normalized = eventType.trim().toLowerCase();
return normalized === 'error' || normalized === 'slo_breach' || normalized.startsWith('error.');
}
private getOrCreateSessionId(): string {
if (typeof sessionStorage === 'undefined') return 'unknown';
const existing = sessionStorage.getItem(TelemetrySamplerService.SessionIdStorageKey);
if (existing && existing.trim()) return existing;
const sessionId = this.createSessionId();
sessionStorage.setItem(TelemetrySamplerService.SessionIdStorageKey, sessionId);
return sessionId;
}
private getOrCreateSessionSampleValue(): number {
if (typeof sessionStorage === 'undefined') return 1;
const existing = sessionStorage.getItem(TelemetrySamplerService.SessionSampleValueStorageKey);
if (existing) {
const parsed = Number.parseFloat(existing);
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) {
return parsed;
}
}
const sampleValue = this.createSampleValue();
sessionStorage.setItem(TelemetrySamplerService.SessionSampleValueStorageKey, sampleValue.toString());
return sampleValue;
}
private createSessionId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
if (typeof crypto !== 'undefined' && 'getRandomValues' in crypto) {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
return Math.random().toString(16).slice(2) + Date.now().toString(16);
}
private createSampleValue(): number {
if (typeof crypto !== 'undefined' && 'getRandomValues' in crypto) {
const bytes = new Uint32Array(1);
crypto.getRandomValues(bytes);
return bytes[0] / 0x1_0000_0000;
}
return Math.random();
}
}

View File

@@ -0,0 +1,91 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { APP_CONFIG, AppConfig } from '../config/app-config.model';
import { AppConfigService } from '../config/app-config.service';
import { TelemetryClient } from './telemetry.client';
import { TelemetrySamplerService } from './telemetry-sampler.service';
describe('TelemetryClient', () => {
const baseConfig: AppConfig = {
authority: {
issuer: 'https://auth.stellaops.test/',
clientId: 'ui-client',
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
redirectUri: 'https://ui.stellaops.test/auth/callback',
scope: 'openid profile email ui.read',
audience: 'https://scanner.stellaops.test',
},
apiBaseUrls: {
authority: 'https://auth.stellaops.test',
scanner: 'https://scanner.stellaops.test',
policy: 'https://policy.stellaops.test',
concelier: 'https://concelier.stellaops.test',
attestor: 'https://attestor.stellaops.test',
},
telemetry: {
otlpEndpoint: 'https://collector.stellaops.test/ingest',
sampleRate: 1,
},
};
let appConfig: AppConfigService;
let client: TelemetryClient;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
AppConfigService,
TelemetrySamplerService,
TelemetryClient,
{
provide: APP_CONFIG,
useValue: baseConfig,
},
],
});
appConfig = TestBed.inject(AppConfigService);
appConfig.setConfigForTesting(baseConfig);
client = TestBed.inject(TelemetryClient);
});
it('queues sampled events and flushes them via fetch', async () => {
const fetchSpy = spyOn(window as any, 'fetch').and.returnValue(
Promise.resolve(new Response('{}', { status: 200 })) as any
);
client.emit('ttfs_start', { runId: 'run-1' });
await client.flush();
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.calls.mostRecent().args as [string, RequestInit];
expect(url).toBe('https://collector.stellaops.test/ingest');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body as string) as { events: Array<{ type: string }> };
expect(body.events.length).toBe(1);
expect(body.events[0].type).toBe('ttfs_start');
expect(localStorage.getItem('stellaops.telemetry.queue.v1')).toBe('[]');
});
it('does not queue events when endpoint is missing', () => {
appConfig.setConfigForTesting({
...baseConfig,
telemetry: {
otlpEndpoint: '',
sampleRate: 1,
},
});
client.emit('ttfs_start', { runId: 'run-1' });
expect(localStorage.getItem('stellaops.telemetry.queue.v1')).toBeNull();
});
});

View File

@@ -0,0 +1,209 @@
import { Injectable, inject } from '@angular/core';
import { AppConfigService } from '../config/app-config.service';
import { TelemetrySamplerService } from './telemetry-sampler.service';
export interface TelemetryEvent {
readonly type: string;
readonly timestamp: string;
readonly sessionId: string;
readonly sampleRate: number;
readonly payload: Record<string, unknown>;
}
@Injectable({ providedIn: 'root' })
export class TelemetryClient {
private static readonly QueueStorageKey = 'stellaops.telemetry.queue.v1';
private readonly config = inject(AppConfigService);
private readonly sampler = inject(TelemetrySamplerService);
private readonly queue: TelemetryEvent[] = [];
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
private flushing = false;
constructor() {
this.queue.push(...this.loadQueue());
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
void this.flush();
});
window.addEventListener('beforeunload', () => {
void this.flush({ useBeacon: true });
});
}
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
void this.flush({ useBeacon: true });
}
});
}
}
emit(eventType: string, payload: Record<string, unknown> = {}): void {
const endpoint = this.getIngestEndpoint();
if (!endpoint) return;
const resolvedType = (eventType ?? '').trim();
if (!resolvedType) return;
const decision = this.sampler.decide(resolvedType);
if (!decision.sampled) return;
this.queue.push({
type: resolvedType,
timestamp: new Date().toISOString(),
sessionId: decision.sessionId,
sampleRate: decision.sampleRate,
payload,
});
this.trimQueue();
this.persistQueue();
this.scheduleFlush();
}
async flush(options: { useBeacon?: boolean } = {}): Promise<void> {
const endpoint = this.getIngestEndpoint();
if (!endpoint) return;
if (this.queue.length === 0) return;
if (this.flushing) return;
if (typeof navigator !== 'undefined' && navigator.onLine === false) return;
this.flushing = true;
try {
this.clearFlushTimeout();
const batch = this.queue.slice(0, 50);
const body = JSON.stringify({
schemaVersion: '1.0',
emittedAt: new Date().toISOString(),
events: batch,
});
const sent = options.useBeacon && this.trySendBeacon(endpoint, body);
if (sent) {
this.queue.splice(0, batch.length);
this.persistQueue();
this.scheduleFlush();
return;
}
if (typeof fetch === 'undefined') return;
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: options.useBeacon === true,
});
if (!resp.ok) return;
this.queue.splice(0, batch.length);
this.persistQueue();
this.scheduleFlush();
} catch {
// Telemetry must never block UI flows.
} finally {
this.flushing = false;
}
}
private getIngestEndpoint(): string | null {
try {
const endpoint = this.config.config.telemetry?.otlpEndpoint;
if (typeof endpoint !== 'string') return null;
const trimmed = endpoint.trim();
return trimmed.length ? trimmed : null;
} catch {
return null;
}
}
private scheduleFlush(): void {
if (this.queue.length === 0) return;
if (this.queue.length >= 20) {
void this.flush();
return;
}
if (this.flushTimeout) return;
this.flushTimeout = setTimeout(() => void this.flush(), 5000);
}
private clearFlushTimeout(): void {
if (!this.flushTimeout) return;
clearTimeout(this.flushTimeout);
this.flushTimeout = null;
}
private trimQueue(): void {
const max = 250;
if (this.queue.length <= max) return;
this.queue.splice(0, this.queue.length - max);
}
private persistQueue(): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(TelemetryClient.QueueStorageKey, JSON.stringify(this.queue));
} catch {
// ignore quota errors
}
}
private loadQueue(): TelemetryEvent[] {
if (typeof localStorage === 'undefined') return [];
try {
const raw = localStorage.getItem(TelemetryClient.QueueStorageKey);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
const events: TelemetryEvent[] = [];
for (const e of parsed) {
if (!e || typeof e !== 'object') continue;
const event = e as Record<string, unknown>;
if (typeof event['type'] !== 'string') continue;
if (typeof event['timestamp'] !== 'string') continue;
if (typeof event['sessionId'] !== 'string') continue;
if (typeof event['sampleRate'] !== 'number') continue;
if (!event['payload'] || typeof event['payload'] !== 'object') continue;
events.push({
type: event['type'],
timestamp: event['timestamp'],
sessionId: event['sessionId'],
sampleRate: event['sampleRate'],
payload: event['payload'] as Record<string, unknown>,
});
}
return events;
} catch {
return [];
}
}
private trySendBeacon(endpoint: string, body: string): boolean {
if (typeof navigator === 'undefined') return false;
if (typeof navigator.sendBeacon !== 'function') return false;
try {
const blob = new Blob([body], { type: 'application/json' });
return navigator.sendBeacon(endpoint, blob);
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,40 @@
import { Injectable, inject } from '@angular/core';
import { TelemetryClient } from './telemetry.client';
export interface TtfsSignalRenderedOptions {
cacheHit: boolean;
source: 'snapshot' | 'cold_start' | 'failure_index';
kind: string;
ttfsMs: number;
cacheStatus?: string;
}
@Injectable({ providedIn: 'root' })
export class TtfsTelemetryService {
private readonly telemetry = inject(TelemetryClient);
emitTtfsStart(runId: string, surface: 'ui' | 'cli' | 'ci'): void {
this.telemetry.emit('ttfs_start', {
runId,
surface,
t: performance.now(),
timestamp: new Date().toISOString(),
});
}
emitSignalRendered(runId: string, surface: 'ui' | 'cli' | 'ci', options: TtfsSignalRenderedOptions): void {
this.telemetry.emit('ttfs_signal_rendered', {
runId,
surface,
cacheHit: options.cacheHit,
signalSource: options.source,
kind: options.kind,
ttfsMs: options.ttfsMs,
cacheStatus: options.cacheStatus,
t: performance.now(),
timestamp: new Date().toISOString(),
});
}
}

View File

@@ -0,0 +1,189 @@
/**
* Drift Detection TypeScript Models
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
* Tasks: UI-005, UI-006
*
* Models for reachability drift detection UI.
*/
import type { CompressedPath, PathNode } from './path-viewer.models';
/**
* Represents a sink that has drifted (new or changed reachability).
*/
export interface DriftedSink {
/** Sink node details */
sink: PathNode;
/** Previous reachability bucket before drift */
previousBucket: ReachabilityBucket | null;
/** Current reachability bucket after drift */
currentBucket: ReachabilityBucket;
/** CVE ID if sink is a vulnerability */
cveId?: string;
/** CVSS score if available */
cvssScore?: number;
/** Severity classification */
severity?: 'critical' | 'high' | 'medium' | 'low' | 'info';
/** Paths to this sink */
paths: CompressedPath[];
/** Whether this represents a risk increase */
isRiskIncrease: boolean;
/** Risk delta (positive = worse, negative = better) */
riskDelta: number;
/** Number of new paths to this sink */
newPathCount: number;
/** Number of removed paths to this sink */
removedPathCount: number;
}
/**
* Reachability bucket classifications.
*/
export type ReachabilityBucket =
| 'entrypoint'
| 'direct'
| 'runtime'
| 'unknown'
| 'unreachable';
/**
* Result of a drift detection comparison.
*/
export interface DriftResult {
/** Unique ID for this drift result */
id: string;
/** Timestamp of the comparison */
comparedAt: string;
/** Base graph ID (before) */
baseGraphId: string;
/** Head graph ID (after) */
headGraphId: string;
/** Base commit SHA if from Git */
baseCommitSha?: string;
/** Head commit SHA if from Git */
headCommitSha?: string;
/** Repository reference */
repository?: string;
/** PR number if this is a PR check */
pullRequestNumber?: number;
/** Sinks that have drifted */
driftedSinks: DriftedSink[];
/** Summary statistics */
summary: DriftSummary;
/** DSSE attestation digest if signed */
attestationDigest?: string;
/** Link to full attestation */
attestationUrl?: string;
}
/**
* Summary statistics for drift detection.
*/
export interface DriftSummary {
/** Total number of sinks analyzed */
totalSinks: number;
/** Sinks with increased reachability */
increasedReachability: number;
/** Sinks with decreased reachability */
decreasedReachability: number;
/** Sinks with unchanged reachability */
unchangedReachability: number;
/** New sinks (not present in base) */
newSinks: number;
/** Removed sinks (not present in head) */
removedSinks: number;
/** Overall risk trend: 'increasing' | 'decreasing' | 'stable' */
riskTrend: 'increasing' | 'decreasing' | 'stable';
/** Net risk delta */
netRiskDelta: number;
/** Count by severity */
bySeverity: {
critical: number;
high: number;
medium: number;
low: number;
info: number;
};
/** Gate effectiveness metrics */
gateMetrics?: {
/** Paths blocked by auth gates */
authGateBlocked: number;
/** Paths blocked by feature flags */
featureFlagBlocked: number;
/** Paths blocked by admin-only checks */
adminOnlyBlocked: number;
};
}
/**
* Filter options for drift results.
*/
export interface DriftFilter {
/** Filter by severity */
severity?: ('critical' | 'high' | 'medium' | 'low' | 'info')[];
/** Filter by bucket transition */
bucketTransition?: {
from?: ReachabilityBucket;
to?: ReachabilityBucket;
};
/** Only show risk increases */
riskIncreasesOnly?: boolean;
/** Search by CVE ID */
cveId?: string;
/** Search by package name */
packageName?: string;
}
/**
* Drift comparison request.
*/
export interface DriftCompareRequest {
/** Base graph or commit reference */
base: string;
/** Head graph or commit reference */
head: string;
/** Optional repository context */
repository?: string;
/** Whether to create DSSE attestation */
createAttestation?: boolean;
/** Whether to include full paths in response */
includeFullPaths?: boolean;
}

View File

@@ -0,0 +1,7 @@
/**
* Reachability Models Barrel Export
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
*/
export * from './path-viewer.models';
export * from './drift.models';

View File

@@ -0,0 +1,103 @@
/**
* Path Viewer TypeScript Models
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
* Tasks: UI-001, UI-002
*
* Models for call path visualization in the UI.
*/
/**
* Represents a node in a reachability call path.
*/
export interface PathNode {
/** Unique identifier for the node */
nodeId: string;
/** Symbol name (function, method, class) */
symbol: string;
/** Source file path (relative) */
file?: string;
/** Line number in source file */
line?: number;
/** Package or module containing the symbol */
package?: string;
/** Whether this node has changed in a drift comparison */
isChanged: boolean;
/** Kind of change: 'added' | 'removed' | 'modified' | 'unchanged' */
changeKind?: 'added' | 'removed' | 'modified' | 'unchanged';
/** Node type for styling */
nodeType?: 'entrypoint' | 'sink' | 'gate' | 'intermediate';
/** Confidence score for this node [0, 1] */
confidence?: number;
}
/**
* Compressed representation of a call path.
* Shows entrypoint, sink, and key intermediate nodes.
*/
export interface CompressedPath {
/** Entry point of the path (first node) */
entrypoint: PathNode;
/** Sink (vulnerable node) at the end of the path */
sink: PathNode;
/** Number of intermediate nodes between entrypoint and sink */
intermediateCount: number;
/** Key nodes to highlight (gates, changed nodes) */
keyNodes: PathNode[];
/** Full node ID path for expansion */
fullPath?: string[];
/** Path length (hop count) */
length: number;
/** Overall path confidence [0, 1] */
confidence: number;
/** Whether the path has gates that reduce risk */
hasGates: boolean;
/** Gate types present in the path */
gateTypes?: string[];
}
/**
* Full expanded path with all nodes.
*/
export interface ExpandedPath {
/** All nodes in order from entrypoint to sink */
nodes: PathNode[];
/** Edges connecting nodes */
edges: PathEdge[];
}
/**
* Edge between two nodes in a path.
*/
export interface PathEdge {
/** Source node ID */
from: string;
/** Target node ID */
to: string;
/** Edge type: 'call' | 'import' | 'inherit' */
edgeType: 'call' | 'import' | 'inherit' | 'unknown';
/** Whether this edge is new (added in drift) */
isNew?: boolean;
/** Whether this edge was removed (in drift) */
isRemoved?: boolean;
}

View File

@@ -0,0 +1,168 @@
/**
* Drift API Service
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
* Task: UI-009
*
* HTTP service for reachability drift detection API.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import type {
DriftResult,
DriftCompareRequest,
DriftFilter,
DriftedSink,
CompressedPath,
} from '../models';
/** API response wrapper */
interface ApiResponse<T> {
data: T;
meta?: {
total?: number;
page?: number;
pageSize?: number;
};
}
@Injectable({ providedIn: 'root' })
export class DriftApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/reachability/drift';
/**
* Compare two graph snapshots for drift.
*/
compare(request: DriftCompareRequest): Observable<DriftResult> {
return this.http
.post<ApiResponse<DriftResult>>(`${this.baseUrl}/compare`, request)
.pipe(map((res) => res.data));
}
/**
* Get a drift result by ID.
*/
getById(id: string): Observable<DriftResult> {
return this.http
.get<ApiResponse<DriftResult>>(`${this.baseUrl}/${encodeURIComponent(id)}`)
.pipe(map((res) => res.data));
}
/**
* Get drift results for a repository.
*/
listByRepository(
repository: string,
options?: {
limit?: number;
offset?: number;
since?: string;
}
): Observable<DriftResult[]> {
let params = new HttpParams().set('repository', repository);
if (options?.limit) {
params = params.set('limit', options.limit.toString());
}
if (options?.offset) {
params = params.set('offset', options.offset.toString());
}
if (options?.since) {
params = params.set('since', options.since);
}
return this.http
.get<ApiResponse<DriftResult[]>>(this.baseUrl, { params })
.pipe(map((res) => res.data));
}
/**
* Get drift results for a pull request.
*/
getByPullRequest(
repository: string,
prNumber: number
): Observable<DriftResult | null> {
const params = new HttpParams()
.set('repository', repository)
.set('pr', prNumber.toString());
return this.http
.get<ApiResponse<DriftResult | null>>(`${this.baseUrl}/pr`, { params })
.pipe(map((res) => res.data));
}
/**
* Get drifted sinks with filtering.
*/
getDriftedSinks(
driftId: string,
filter?: DriftFilter
): Observable<DriftedSink[]> {
let params = new HttpParams();
if (filter?.severity?.length) {
params = params.set('severity', filter.severity.join(','));
}
if (filter?.riskIncreasesOnly) {
params = params.set('riskIncreasesOnly', 'true');
}
if (filter?.cveId) {
params = params.set('cveId', filter.cveId);
}
if (filter?.packageName) {
params = params.set('packageName', filter.packageName);
}
return this.http
.get<ApiResponse<DriftedSink[]>>(
`${this.baseUrl}/${encodeURIComponent(driftId)}/sinks`,
{ params }
)
.pipe(map((res) => res.data));
}
/**
* Get full paths for a drifted sink.
*/
getPathsForSink(
driftId: string,
sinkNodeId: string
): Observable<CompressedPath[]> {
return this.http
.get<ApiResponse<CompressedPath[]>>(
`${this.baseUrl}/${encodeURIComponent(driftId)}/sinks/${encodeURIComponent(sinkNodeId)}/paths`
)
.pipe(map((res) => res.data));
}
/**
* Request DSSE attestation for a drift result.
*/
createAttestation(driftId: string): Observable<{ digest: string; url: string }> {
return this.http
.post<ApiResponse<{ digest: string; url: string }>>(
`${this.baseUrl}/${encodeURIComponent(driftId)}/attest`,
{}
)
.pipe(map((res) => res.data));
}
/**
* Get attestation for a drift result.
*/
getAttestation(driftId: string): Observable<{
digest: string;
url: string;
predicate: unknown;
} | null> {
return this.http
.get<ApiResponse<{ digest: string; url: string; predicate: unknown } | null>>(
`${this.baseUrl}/${encodeURIComponent(driftId)}/attestation`
)
.pipe(map((res) => res.data));
}
}

View File

@@ -1,18 +1,18 @@
<header class="first-signal-card__header">
<div class="first-signal-card__title">
<span class="first-signal-card__label">First signal</span>
<span class="first-signal-card__label">{{ 'firstSignal.label' | translate }}</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>
<span class="realtime-indicator realtime-indicator--live">{{ 'firstSignal.live' | translate }}</span>
} @else if (realtimeMode() === 'polling') {
<span class="realtime-indicator realtime-indicator--polling">Polling</span>
<span class="realtime-indicator realtime-indicator--polling">{{ 'firstSignal.polling' | translate }}</span>
}
@if (stageText(); as stage) {
<span class="first-signal-card__stage">{{ stage }}</span>
}
<span class="first-signal-card__run-id">Run: {{ runId() }}</span>
<span class="first-signal-card__run-id">{{ 'firstSignal.runPrefix' | translate }} {{ runId() }}</span>
</div>
</header>
@@ -25,7 +25,7 @@
<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 }}
{{ 'firstSignal.rangePrefix' | translate }} {{ sig.artifact.range.start }}{{ 'firstSignal.rangeSeparator' | translate }}{{ sig.artifact.range.end }}
</span>
}
</div>
@@ -37,7 +37,7 @@
</div>
} @else if (response()) {
<div class="first-signal-card__empty" role="status">
<p>Waiting for first signal…</p>
<p>{{ 'firstSignal.waiting' | translate }}</p>
</div>
} @else if (state() === 'loading' && showSkeleton()) {
<div class="first-signal-card__skeleton" aria-hidden="true">
@@ -47,16 +47,17 @@
</div>
} @else if (state() === 'unavailable') {
<div class="first-signal-card__empty" role="status">
<p>Signal not available yet.</p>
<p>{{ 'firstSignal.notAvailable' | translate }}</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>
<p>{{ 'firstSignal.offline' | translate }}</p>
<button type="button" class="retry-button" (click)="retry()">{{ 'firstSignal.retry' | translate }}</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>
<p>{{ error() ?? ('firstSignal.failed' | translate) }}</p>
<button type="button" class="retry-button" (click)="retry()">{{ 'firstSignal.tryAgain' | translate }}</button>
</div>
}

View File

@@ -0,0 +1,84 @@
import { computed, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { FirstSignalDto } from '../../../../core/api/first-signal.models';
import { FirstSignalStore } from '../../../../core/api/first-signal.store';
import { I18nService } from '../../../../core/i18n';
import { TtfsTelemetryService } from '../../../../core/telemetry/ttfs-telemetry.service';
import { FirstSignalPrefetchService } from '../../services/first-signal-prefetch.service';
import { FirstSignalCardComponent } from './first-signal-card.component';
describe('FirstSignalCardComponent', () => {
it('emits TTFS start and rendered events when signal appears', () => {
const times = [100, 150];
spyOn(performance, 'now').and.callFake(() => times.shift() ?? 150);
const stateSignal = signal<'idle' | 'loading' | 'loaded' | 'unavailable' | 'error' | 'offline'>('idle');
const errorSignal = signal<string | null>(null);
const responseSignal = signal<{ firstSignal: FirstSignalDto | null } | null>(null);
const firstSignalSignal = signal<FirstSignalDto | null>(null);
const cacheStatusSignal = signal<string | null>('hit');
const realtimeModeSignal = signal<'disconnected' | 'sse' | 'polling'>('disconnected');
const storeMock = {
state: stateSignal.asReadonly(),
error: errorSignal.asReadonly(),
response: responseSignal.asReadonly(),
firstSignal: firstSignalSignal.asReadonly(),
hasSignal: computed(() => !!firstSignalSignal()),
cacheStatus: cacheStatusSignal.asReadonly(),
realtimeMode: realtimeModeSignal.asReadonly(),
clear: jasmine.createSpy('clear'),
prime: jasmine.createSpy('prime'),
load: jasmine.createSpy('load'),
connect: jasmine.createSpy('connect'),
} as unknown as FirstSignalStore;
const telemetryMock = {
emitTtfsStart: jasmine.createSpy('emitTtfsStart'),
emitSignalRendered: jasmine.createSpy('emitSignalRendered'),
};
TestBed.configureTestingModule({
imports: [FirstSignalCardComponent],
providers: [
{ provide: FirstSignalStore, useValue: storeMock },
{ provide: FirstSignalPrefetchService, useValue: { get: () => null } },
{ provide: TtfsTelemetryService, useValue: telemetryMock },
{ provide: I18nService, useValue: { t: (k: string) => k, tryT: () => null } },
],
});
const fixture = TestBed.createComponent(FirstSignalCardComponent);
fixture.componentRef.setInput('runId', 'run-1');
fixture.detectChanges();
expect(telemetryMock.emitTtfsStart).toHaveBeenCalledWith('run-1', 'ui');
firstSignalSignal.set({
type: 'queued',
stage: 'resolve',
step: 'initialize',
message: 'Mock first signal',
at: '2025-01-01T00:00:00Z',
artifact: { kind: 'run' },
});
fixture.detectChanges();
expect(telemetryMock.emitSignalRendered).toHaveBeenCalled();
const args = telemetryMock.emitSignalRendered.calls.mostRecent().args as [
string,
string,
{ cacheHit: boolean; source: string; kind: string; ttfsMs: number }
];
expect(args[0]).toBe('run-1');
expect(args[1]).toBe('ui');
expect(args[2].cacheHit).toBeTrue();
expect(args[2].source).toBe('snapshot');
expect(args[2].kind).toBe('queued');
expect(args[2].ttfsMs).toBe(50);
});
});

View File

@@ -10,21 +10,23 @@ import {
signal,
} from '@angular/core';
import { FirstSignalStore } from '../../../../core/api/first-signal.store';
import { FirstSignalDto } from '../../../../core/api/first-signal.models';
import { FirstSignalStore } from '../../../../core/api/first-signal.store';
import { I18nService, TranslatePipe } from '../../../../core/i18n';
import { TtfsTelemetryService } from '../../../../core/telemetry/ttfs-telemetry.service';
import { FirstSignalPrefetchService } from '../../services/first-signal-prefetch.service';
@Component({
selector: 'app-first-signal-card',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, TranslatePipe],
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-label]': 'cardAriaLabel()',
'[attr.aria-busy]': "state() === 'loading'",
'[class.first-signal-card--loading]': "state() === 'loading'",
'[class.first-signal-card--error]': "state() === 'error'",
@@ -34,7 +36,14 @@ import { FirstSignalPrefetchService } from '../../services/first-signal-prefetch
export class FirstSignalCardComponent implements OnDestroy {
private readonly store = inject(FirstSignalStore);
private readonly prefetch = inject(FirstSignalPrefetchService);
private readonly telemetry = inject(TtfsTelemetryService);
private readonly i18n = inject(I18nService);
private lastLoadKey: string | null = null;
private ttfsTrackingKey: string | null = null;
private ttfsStartAt: number | null = null;
private ttfsEmittedKey: string | null = null;
private ttfsPrefetchHit = false;
readonly runId = input.required<string>();
readonly tenantId = input<string | null>(null);
@@ -51,9 +60,12 @@ export class FirstSignalCardComponent implements OnDestroy {
readonly response = this.store.response;
readonly signal = this.store.firstSignal;
readonly hasSignal = this.store.hasSignal;
readonly cacheStatus = this.store.cacheStatus;
readonly realtimeMode = this.store.realtimeMode;
readonly showSkeleton = this.showSkeletonSignal.asReadonly();
readonly cardAriaLabel = computed(() => this.i18n.t('firstSignal.aria.cardLabel'));
readonly badgeText = computed(() => this.formatBadgeText(this.signal()?.type));
readonly badgeClass = computed(() => this.formatBadgeClass(this.signal()?.type));
readonly stageText = computed(() => this.formatStageText(this.signal()));
@@ -73,6 +85,10 @@ export class FirstSignalCardComponent implements OnDestroy {
}
this.lastLoadKey = loadKey;
this.ttfsTrackingKey = loadKey;
this.ttfsStartAt = performance.now();
this.ttfsEmittedKey = null;
this.store.clear();
const prefetched = this.prefetch.get(runId);
@@ -80,6 +96,9 @@ export class FirstSignalCardComponent implements OnDestroy {
this.store.prime({ response: prefetched.response, etag: prefetched.etag });
}
this.ttfsPrefetchHit = !!prefetched?.response?.firstSignal;
this.telemetry.emitTtfsStart(runId, 'ui');
this.store.load(runId, { tenantId, projectId });
if (enableRealTime) {
this.store.connect(runId, { tenantId, projectId, pollIntervalMs });
@@ -88,6 +107,35 @@ export class FirstSignalCardComponent implements OnDestroy {
{ allowSignalWrites: true }
);
effect(() => {
const sig = this.signal();
const trackingKey = this.ttfsTrackingKey;
const startAt = this.ttfsStartAt;
if (!sig || !trackingKey || startAt === null) return;
if (this.ttfsEmittedKey === trackingKey) return;
const cacheStatus = this.cacheStatus();
const normalizedCacheStatus = (cacheStatus ?? '').trim().toLowerCase();
const cacheHit =
this.ttfsPrefetchHit ||
normalizedCacheStatus === 'prefetch' ||
normalizedCacheStatus === 'hit' ||
normalizedCacheStatus === 'not-modified' ||
normalizedCacheStatus === 'mock';
this.telemetry.emitSignalRendered(this.runId(), 'ui', {
cacheHit,
source: this.mapCacheStatusToSource(normalizedCacheStatus),
kind: (sig.type ?? '').trim().toLowerCase() || 'unknown',
ttfsMs: Math.max(0, performance.now() - startAt),
cacheStatus: cacheStatus ?? undefined,
});
this.ttfsEmittedKey = trackingKey;
});
effect(
() => {
const state = this.state();
@@ -126,13 +174,17 @@ export class FirstSignalCardComponent implements OnDestroy {
}
private formatBadgeText(type: string | null | undefined): string {
if (!type) return 'Signal';
return type
.trim()
.replaceAll('_', ' ')
.replaceAll('-', ' ')
.replace(/\s+/g, ' ')
.replace(/^./, (c) => c.toUpperCase());
const normalized = (type ?? '').trim().toLowerCase();
if (!normalized) {
return this.i18n.t('firstSignal.kind.unknown');
}
return this.i18n.tryT(`firstSignal.kind.${normalized}`)
?? normalized
.replaceAll('_', ' ')
.replaceAll('-', ' ')
.replace(/\s+/g, ' ')
.replace(/^./, (c) => c.toUpperCase());
}
private formatBadgeClass(type: string | null | undefined): string {
@@ -148,10 +200,28 @@ export class FirstSignalCardComponent implements OnDestroy {
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;
const stageLabel = stage ? this.i18n.tryT(`firstSignal.stage.${stage.toLowerCase()}`) ?? stage : '';
const separator = this.i18n.t('firstSignal.stageSeparator');
if (stageLabel && step) return `${stageLabel}${separator}${step}`;
return stageLabel || step;
}
private mapCacheStatusToSource(cacheStatus: string): 'snapshot' | 'cold_start' | 'failure_index' {
if (cacheStatus === 'prefetch' || cacheStatus === 'hit' || cacheStatus === 'not-modified' || cacheStatus === 'mock') {
return 'snapshot';
}
if (cacheStatus === 'miss') {
return 'cold_start';
}
return 'failure_index';
}
}

View File

@@ -82,5 +82,44 @@
"motion": {
"reducedMotion": "Animations reduced",
"motionEnabled": "Animations enabled"
},
"firstSignal": {
"label": "First signal",
"runPrefix": "Run:",
"live": "Live",
"polling": "Polling",
"rangePrefix": "Range",
"rangeSeparator": "",
"stageSeparator": " · ",
"waiting": "Waiting for first signal…",
"notAvailable": "Signal not available yet.",
"offline": "Offline. Last known signal may be stale.",
"failed": "Failed to load signal.",
"retry": "Retry",
"tryAgain": "Try again",
"kind": {
"queued": "Queued",
"started": "Started",
"phase": "In progress",
"blocked": "Blocked",
"failed": "Failed",
"succeeded": "Succeeded",
"canceled": "Canceled",
"unavailable": "Unavailable",
"unknown": "Signal"
},
"stage": {
"resolve": "Resolving",
"fetch": "Fetching",
"restore": "Restoring",
"analyze": "Analyzing",
"policy": "Evaluating policy",
"report": "Generating report",
"unknown": "Processing"
},
"aria": {
"cardLabel": "First signal status"
}
}
}