Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user