This commit is contained in:
StellaOps Bot
2025-12-26 15:19:07 +02:00
25 changed files with 3377 additions and 132 deletions

View File

@@ -1,94 +1,156 @@
import { Injectable, inject } from '@angular/core';
// -----------------------------------------------------------------------------
// compare.service.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-01 — Create CompareService with baseline recommendations API
// -----------------------------------------------------------------------------
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, firstValueFrom } from 'rxjs';
import { Observable, of, catchError, tap } from 'rxjs';
export interface CompareTarget {
id: string;
type: 'artifact' | 'snapshot' | 'verdict';
export interface BaselineRecommendation {
digest: string;
label: string;
digest?: string;
timestamp: Date;
reason: string;
scanDate: string;
isPrimary: boolean;
confidenceScore: number;
}
export interface DeltaCategory {
export interface BaselineRationale {
selectedDigest: string;
selectionReason: string;
alternatives: BaselineRecommendation[];
autoSelectEnabled: boolean;
}
export interface ScanDigest {
digest: string;
imageRef: string;
scanDate: string;
policyVersion: string;
determinismHash: string;
feedSnapshotId: string;
signatureStatus: 'valid' | 'invalid' | 'missing' | 'unknown';
}
export interface CompareRequest {
currentDigest: string;
baselineDigest?: string;
includeUnchanged?: boolean;
}
export interface CompareSession {
id: string;
name: string;
icon: string;
added: number;
removed: number;
changed: number;
current: ScanDigest;
baseline: ScanDigest | null;
rationale: BaselineRationale | null;
createdAt: string;
}
export interface DeltaItem {
id: string;
category: string;
changeType: 'added' | 'removed' | 'changed';
title: string;
severity?: 'critical' | 'high' | 'medium' | 'low';
beforeValue?: string;
afterValue?: string;
}
export interface EvidencePane {
itemId: string;
title: string;
beforeEvidence?: object;
afterEvidence?: object;
}
export interface DeltaComputation {
categories: DeltaCategory[];
items: DeltaItem[];
}
@Injectable({
providedIn: 'root'
})
@Injectable({ providedIn: 'root' })
export class CompareService {
private readonly http = inject(HttpClient);
private readonly apiBase = '/api/v1/compare';
private readonly baseUrl = '/api/compare';
async getTarget(id: string): Promise<CompareTarget> {
return firstValueFrom(
this.http.get<CompareTarget>(`${this.apiBase}/targets/${id}`)
);
// State signals
private readonly _currentSession = signal<CompareSession | null>(null);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
// Computed selectors
readonly currentSession = computed(() => this._currentSession());
readonly loading = computed(() => this._loading());
readonly error = computed(() => this._error());
readonly hasBaseline = computed(() => {
const session = this._currentSession();
return session?.baseline !== null;
});
readonly policyDrift = computed(() => {
const session = this._currentSession();
if (!session?.baseline) return false;
return session.current.policyVersion !== session.baseline.policyVersion;
});
/**
* Fetches recommended baselines for a scan digest.
*/
getBaselineRecommendations(scanDigest: string): Observable<BaselineRationale> {
return this.http
.get<BaselineRationale>(\`\${this.baseUrl}/baselines/\${scanDigest}\`)
.pipe(
catchError(() =>
of({
selectedDigest: '',
selectionReason: 'No previous scans found for comparison',
alternatives: [],
autoSelectEnabled: true,
})
)
);
}
async computeDelta(currentId: string, baselineId: string): Promise<DeltaComputation> {
return firstValueFrom(
this.http.post<DeltaComputation>(`${this.apiBase}/delta`, {
current: currentId,
baseline: baselineId
/**
* Initializes a compare session with optional baseline.
*/
initSession(request: CompareRequest): Observable<CompareSession> {
this._loading.set(true);
this._error.set(null);
return this.http.post<CompareSession>(\`\${this.baseUrl}/sessions\`, request).pipe(
tap((session) => {
this._currentSession.set(session);
this._loading.set(false);
}),
catchError((err) => {
this._error.set(err?.message || 'Failed to initialize compare session');
this._loading.set(false);
throw err;
})
);
}
async getItemEvidence(
itemId: string,
baselineId: string,
currentId: string
): Promise<EvidencePane> {
return firstValueFrom(
this.http.get<EvidencePane>(`${this.apiBase}/evidence/${itemId}`, {
params: {
baseline: baselineId,
current: currentId
}
/**
* Updates the baseline for current session.
*/
selectBaseline(baselineDigest: string): Observable<CompareSession> {
const session = this._currentSession();
if (!session) {
throw new Error('No active session');
}
this._loading.set(true);
return this.http
.patch<CompareSession>(\`\${this.baseUrl}/sessions/\${session.id}/baseline\`, {
baselineDigest,
})
);
.pipe(
tap((updated) => {
this._currentSession.set(updated);
this._loading.set(false);
}),
catchError((err) => {
this._error.set(err?.message || 'Failed to update baseline');
this._loading.set(false);
throw err;
})
);
}
async getRecommendedBaselines(currentId: string): Promise<CompareTarget[]> {
return firstValueFrom(
this.http.get<CompareTarget[]>(`${this.apiBase}/baselines/recommended`, {
params: { current: currentId }
})
);
/**
* Fetches scan digest details.
*/
getScanDigest(digest: string): Observable<ScanDigest> {
return this.http.get<ScanDigest>(\`\${this.baseUrl}/scans/\${digest}\`);
}
async getBaselineRationale(baselineId: string): Promise<string> {
return firstValueFrom(
this.http.get<{ rationale: string }>(`${this.apiBase}/baselines/${baselineId}/rationale`)
).then(r => r.rationale);
/**
* Clears the current session.
*/
clearSession(): void {
this._currentSession.set(null);
this._error.set(null);
}
}

View File

@@ -0,0 +1,217 @@
// -----------------------------------------------------------------------------
// delta-compute.service.ts
// Sprint: SPRINT_20251226_012_FE_smart_diff_compare
// Task: SDIFF-02 — Create DeltaComputeService for idempotent delta computation
// -----------------------------------------------------------------------------
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, catchError, tap, shareReplay } from 'rxjs';
export type DeltaStatus = 'added' | 'removed' | 'changed' | 'unchanged';
export type DeltaCategory = 'sbom' | 'reachability' | 'vex' | 'policy' | 'unknowns';
export interface DeltaItem {
id: string;
category: DeltaCategory;
status: DeltaStatus;
finding: {
cveId: string;
packageName: string;
severity: 'critical' | 'high' | 'medium' | 'low' | 'none';
priorityScore: number;
};
baseline?: {
status: string;
confidence: number;
reason: string;
};
current: {
status: string;
confidence: number;
reason: string;
};
changeReason?: string;
}
export interface DeltaSummary {
added: number;
removed: number;
changed: number;
unchanged: number;
byCategory: Record<DeltaCategory, {
added: number;
removed: number;
changed: number;
}>;
}
export interface DeltaResult {
sessionId: string;
currentDigest: string;
baselineDigest: string;
summary: DeltaSummary;
items: DeltaItem[];
computedAt: string;
determinismHash: string;
}
export interface DeltaFilter {
categories?: DeltaCategory[];
statuses?: DeltaStatus[];
severities?: string[];
searchTerm?: string;
}
@Injectable({ providedIn: 'root' })
export class DeltaComputeService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/compare';
// Cached delta results keyed by session ID
private readonly deltaCache = new Map<string, Observable<DeltaResult>>();
// State signals
private readonly _currentDelta = signal<DeltaResult | null>(null);
private readonly _filter = signal<DeltaFilter>({});
private readonly _loading = signal(false);
// Computed selectors
readonly currentDelta = computed(() => this._currentDelta());
readonly loading = computed(() => this._loading());
readonly filter = computed(() => this._filter());
readonly summary = computed((): DeltaSummary | null => {
return this._currentDelta()?.summary ?? null;
});
readonly filteredItems = computed((): DeltaItem[] => {
const delta = this._currentDelta();
if (!delta) return [];
const f = this._filter();
let items = delta.items;
if (f.categories?.length) {
items = items.filter(i => f.categories!.includes(i.category));
}
if (f.statuses?.length) {
items = items.filter(i => f.statuses!.includes(i.status));
}
if (f.severities?.length) {
items = items.filter(i => f.severities!.includes(i.finding.severity));
}
if (f.searchTerm) {
const term = f.searchTerm.toLowerCase();
items = items.filter(i =>
i.finding.cveId.toLowerCase().includes(term) ||
i.finding.packageName.toLowerCase().includes(term)
);
}
// Sort by priority score descending
return items.sort((a, b) => b.finding.priorityScore - a.finding.priorityScore);
});
readonly categoryCounts = computed((): Record<DeltaCategory, number> => {
const delta = this._currentDelta();
if (!delta) {
return { sbom: 0, reachability: 0, vex: 0, policy: 0, unknowns: 0 };
}
return delta.items.reduce((acc, item) => {
acc[item.category]++;
return acc;
}, { sbom: 0, reachability: 0, vex: 0, policy: 0, unknowns: 0 } as Record<DeltaCategory, number>);
});
/**
* Computes delta between current and baseline scans.
* Results are cached and idempotent.
*/
computeDelta(sessionId: string): Observable<DeltaResult> {
// Check cache first
if (this.deltaCache.has(sessionId)) {
return this.deltaCache.get(sessionId)!;
}
this._loading.set(true);
const request$ = this.http
.get<DeltaResult>(\`\${this.baseUrl}/sessions/\${sessionId}/delta\`)
.pipe(
tap((result) => {
this._currentDelta.set(result);
this._loading.set(false);
}),
catchError((err) => {
this._loading.set(false);
throw err;
}),
shareReplay(1)
);
this.deltaCache.set(sessionId, request$);
return request$;
}
/**
* Updates the filter criteria.
*/
setFilter(filter: DeltaFilter): void {
this._filter.set(filter);
}
/**
* Clears filter to show all items.
*/
clearFilter(): void {
this._filter.set({});
}
/**
* Toggles a category filter.
*/
toggleCategory(category: DeltaCategory): void {
const current = this._filter();
const categories = current.categories ?? [];
if (categories.includes(category)) {
this.setFilter({
...current,
categories: categories.filter(c => c !== category)
});
} else {
this.setFilter({
...current,
categories: [...categories, category]
});
}
}
/**
* Sets search term filter.
*/
setSearchTerm(term: string): void {
this.setFilter({
...this._filter(),
searchTerm: term || undefined
});
}
/**
* Invalidates cache for a session.
*/
invalidateCache(sessionId: string): void {
this.deltaCache.delete(sessionId);
}
/**
* Clears all state.
*/
clear(): void {
this._currentDelta.set(null);
this._filter.set({});
this.deltaCache.clear();
}
}