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:
@@ -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). |
|
||||
|
||||
16
src/Web/StellaOps.Web/src/app/core/api/triage-api.index.ts
Normal file
16
src/Web/StellaOps.Web/src/app/core/api/triage-api.index.ts
Normal 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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
104
src/Web/StellaOps.Web/src/app/core/i18n/i18n.service.ts
Normal file
104
src/Web/StellaOps.Web/src/app/core/i18n/i18n.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
8
src/Web/StellaOps.Web/src/app/core/i18n/index.ts
Normal file
8
src/Web/StellaOps.Web/src/app/core/i18n/index.ts
Normal 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';
|
||||
23
src/Web/StellaOps.Web/src/app/core/i18n/translate.pipe.ts
Normal file
23
src/Web/StellaOps.Web/src/app/core/i18n/translate.pipe.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
209
src/Web/StellaOps.Web/src/app/core/telemetry/telemetry.client.ts
Normal file
209
src/Web/StellaOps.Web/src/app/core/telemetry/telemetry.client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user