Fix live evidence and registry auth contracts
This commit is contained in:
@@ -2,160 +2,107 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of, BehaviorSubject } from 'rxjs';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
|
||||
import { EvidenceThreadViewComponent } from '../components/evidence-thread-view/evidence-thread-view.component';
|
||||
import { EvidenceThreadService, EvidenceThreadGraph, EvidenceNode } from '../services/evidence-thread.service';
|
||||
import { EvidenceThreadService } from '../services/evidence-thread.service';
|
||||
|
||||
describe('EvidenceThreadViewComponent', () => {
|
||||
let component: EvidenceThreadViewComponent;
|
||||
let fixture: ComponentFixture<EvidenceThreadViewComponent>;
|
||||
let mockEvidenceService: jasmine.SpyObj<EvidenceThreadService>;
|
||||
let routeParams$: BehaviorSubject<any>;
|
||||
let router: Router;
|
||||
let routeParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let evidenceServiceStub: any;
|
||||
|
||||
const mockThread: EvidenceThreadGraph = {
|
||||
thread: {
|
||||
id: 'thread-1',
|
||||
tenantId: 'tenant-1',
|
||||
artifactDigest: 'sha256:abc123',
|
||||
artifactName: 'test-image:latest',
|
||||
status: 'active',
|
||||
verdict: 'allow',
|
||||
riskScore: 2.5,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z'
|
||||
const mockThread = {
|
||||
canonicalId: 'canon-1',
|
||||
format: 'dsse-envelope',
|
||||
artifactDigest: 'sha256:artifact-1',
|
||||
purl: 'pkg:oci/acme/api@sha256:abc123',
|
||||
createdAt: '2026-03-08T09:00:00Z',
|
||||
transparencyStatus: {
|
||||
mode: 'rekor',
|
||||
reason: 'entry-confirmed',
|
||||
},
|
||||
nodes: [
|
||||
attestations: [
|
||||
{
|
||||
id: 'node-1',
|
||||
tenantId: 'tenant-1',
|
||||
threadId: 'thread-1',
|
||||
kind: 'sbom_diff',
|
||||
refId: 'ref-1',
|
||||
title: 'SBOM Comparison',
|
||||
anchors: [],
|
||||
content: {},
|
||||
createdAt: '2024-01-01T00:00:00Z'
|
||||
}
|
||||
predicateType: 'https://slsa.dev/provenance/v1',
|
||||
dsseDigest: 'sha256:dsse-1',
|
||||
signerKeyId: 'signer-1',
|
||||
rekorEntryId: 'entry-1',
|
||||
signedAt: '2026-03-08T09:05:00Z',
|
||||
},
|
||||
],
|
||||
links: []
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
routeParams$ = new BehaviorSubject({ artifactDigest: 'sha256:abc123' });
|
||||
routeParamMap$ = new BehaviorSubject(convertToParamMap({ canonicalId: 'canon-1' }));
|
||||
queryParamMap$ = new BehaviorSubject(
|
||||
convertToParamMap({ purl: 'pkg:oci/acme/api@sha256:abc123' })
|
||||
);
|
||||
|
||||
mockEvidenceService = jasmine.createSpyObj('EvidenceThreadService', [
|
||||
'getThreadByDigest',
|
||||
'clearCurrentThread',
|
||||
'getVerdictColor',
|
||||
'getNodeKindLabel',
|
||||
'getNodeKindIcon'
|
||||
], {
|
||||
currentThread: jasmine.createSpy().and.returnValue(mockThread),
|
||||
loading: jasmine.createSpy().and.returnValue(false),
|
||||
error: jasmine.createSpy().and.returnValue(null),
|
||||
currentNodes: jasmine.createSpy().and.returnValue(mockThread.nodes),
|
||||
currentLinks: jasmine.createSpy().and.returnValue([]),
|
||||
nodesByKind: jasmine.createSpy().and.returnValue({ sbom_diff: mockThread.nodes })
|
||||
});
|
||||
|
||||
mockEvidenceService.getThreadByDigest.and.returnValue(of(mockThread));
|
||||
mockEvidenceService.getVerdictColor.and.returnValue('success');
|
||||
evidenceServiceStub = {
|
||||
currentThread: signal(mockThread),
|
||||
loading: signal(false),
|
||||
error: signal<string | null>(null),
|
||||
getThreadByCanonicalId: jasmine
|
||||
.createSpy('getThreadByCanonicalId')
|
||||
.and.returnValue(of(mockThread)),
|
||||
clearCurrentThread: jasmine.createSpy('clearCurrentThread'),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
EvidenceThreadViewComponent,
|
||||
NoopAnimationsModule
|
||||
],
|
||||
imports: [EvidenceThreadViewComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: EvidenceThreadService, useValue: mockEvidenceService },
|
||||
{ provide: EvidenceThreadService, useValue: evidenceServiceStub },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: routeParams$.asObservable()
|
||||
}
|
||||
}
|
||||
]
|
||||
paramMap: routeParamMap$.asObservable(),
|
||||
queryParamMap: queryParamMap$.asObservable(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
fixture = TestBed.createComponent(EvidenceThreadViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load thread on init', () => {
|
||||
fixture.detectChanges();
|
||||
expect(mockEvidenceService.getThreadByDigest).toHaveBeenCalledWith('sha256:abc123');
|
||||
});
|
||||
|
||||
it('should set artifact digest from route params', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.artifactDigest()).toBe('sha256:abc123');
|
||||
it('loads the canonical record selected by the route parameter', () => {
|
||||
expect(component.canonicalId()).toBe('canon-1');
|
||||
expect(component.thread()?.canonicalId).toBe('canon-1');
|
||||
expect(evidenceServiceStub.getThreadByCanonicalId).toHaveBeenCalledWith('canon-1');
|
||||
});
|
||||
|
||||
it('should clear thread on destroy', () => {
|
||||
fixture.detectChanges();
|
||||
component.ngOnDestroy();
|
||||
expect(mockEvidenceService.clearCurrentThread).toHaveBeenCalled();
|
||||
it('preserves the current PURL lookup when navigating back to the list', () => {
|
||||
component.onBack();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/evidence/threads'], {
|
||||
queryParams: { purl: 'pkg:oci/acme/api@sha256:abc123' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should refresh thread when onRefresh is called', () => {
|
||||
fixture.detectChanges();
|
||||
mockEvidenceService.getThreadByDigest.calls.reset();
|
||||
it('reloads the same canonical record when refreshed', () => {
|
||||
evidenceServiceStub.getThreadByCanonicalId.calls.reset();
|
||||
|
||||
component.onRefresh();
|
||||
expect(mockEvidenceService.getThreadByDigest).toHaveBeenCalled();
|
||||
|
||||
expect(evidenceServiceStub.getThreadByCanonicalId).toHaveBeenCalledWith('canon-1');
|
||||
});
|
||||
|
||||
it('should update selected tab index', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedTabIndex()).toBe(0);
|
||||
it('clears the cached record state on destroy', () => {
|
||||
component.ngOnDestroy();
|
||||
|
||||
component.onTabChange(1);
|
||||
expect(component.selectedTabIndex()).toBe(1);
|
||||
});
|
||||
|
||||
it('should update selected node ID', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedNodeId()).toBeNull();
|
||||
|
||||
component.onNodeSelect('node-1');
|
||||
expect(component.selectedNodeId()).toBe('node-1');
|
||||
});
|
||||
|
||||
it('should return correct verdict label', () => {
|
||||
expect(component.getVerdictLabel('allow')).toBe('Allow');
|
||||
expect(component.getVerdictLabel('block')).toBe('Block');
|
||||
expect(component.getVerdictLabel(undefined)).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should return correct verdict icon', () => {
|
||||
expect(component.getVerdictIcon('allow')).toBe('check_circle');
|
||||
expect(component.getVerdictIcon('warn')).toBe('warning');
|
||||
expect(component.getVerdictIcon('block')).toBe('block');
|
||||
expect(component.getVerdictIcon('pending')).toBe('schedule');
|
||||
expect(component.getVerdictIcon('unknown')).toBe('help_outline');
|
||||
});
|
||||
|
||||
it('should set artifact digest for the digest chip', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.artifactDigest()).toBe('sha256:abc123');
|
||||
});
|
||||
|
||||
it('should compute node count correctly', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.nodeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should compute link count correctly', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.linkCount()).toBe(0);
|
||||
expect(evidenceServiceStub.clearCurrentThread).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,58 +2,19 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import {
|
||||
EvidenceThreadService,
|
||||
EvidenceThread,
|
||||
EvidenceThreadGraph,
|
||||
EvidenceNode,
|
||||
EvidenceTranscript
|
||||
} from '../services/evidence-thread.service';
|
||||
import { EvidenceThreadService } from '../services/evidence-thread.service';
|
||||
|
||||
describe('EvidenceThreadService', () => {
|
||||
let service: EvidenceThreadService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
const mockThread: EvidenceThread = {
|
||||
id: 'thread-1',
|
||||
tenantId: 'tenant-1',
|
||||
artifactDigest: 'sha256:abc123',
|
||||
artifactName: 'test-image',
|
||||
status: 'active',
|
||||
verdict: 'allow',
|
||||
riskScore: 2.5,
|
||||
reachabilityMode: 'unreachable',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
const mockNode: EvidenceNode = {
|
||||
id: 'node-1',
|
||||
tenantId: 'tenant-1',
|
||||
threadId: 'thread-1',
|
||||
kind: 'sbom_diff',
|
||||
refId: 'ref-1',
|
||||
title: 'SBOM Comparison',
|
||||
summary: 'Test summary',
|
||||
confidence: 0.95,
|
||||
anchors: [],
|
||||
content: {},
|
||||
createdAt: '2024-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
const mockGraph: EvidenceThreadGraph = {
|
||||
thread: mockThread,
|
||||
nodes: [mockNode],
|
||||
links: []
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [EvidenceThreadService]
|
||||
providers: [EvidenceThreadService],
|
||||
});
|
||||
|
||||
service = TestBed.inject(EvidenceThreadService);
|
||||
@@ -64,247 +25,127 @@ describe('EvidenceThreadService', () => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
it('fetches canonical thread summaries by PURL', () => {
|
||||
let actualCount = 0;
|
||||
|
||||
describe('getThreads', () => {
|
||||
it('should fetch threads with default parameters', () => {
|
||||
const mockResponse = {
|
||||
items: [mockThread],
|
||||
service.getThreads({ purl: 'pkg:oci/acme/api@sha256:abc123' }).subscribe((response) => {
|
||||
actualCount = response.threads.length;
|
||||
expect(response.pagination.total).toBe(1);
|
||||
expect(response.threads[0]).toEqual({
|
||||
canonicalId: 'canon-1',
|
||||
format: 'dsse-envelope',
|
||||
purl: 'pkg:oci/acme/api@sha256:abc123',
|
||||
attestationCount: 2,
|
||||
createdAt: '2026-03-08T09:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
const request = httpMock.expectOne(
|
||||
(candidate) =>
|
||||
candidate.url === '/api/v1/evidence/thread/' &&
|
||||
candidate.params.get('purl') === 'pkg:oci/acme/api@sha256:abc123'
|
||||
);
|
||||
|
||||
expect(request.request.method).toBe('GET');
|
||||
expect(service.loading()).toBeTrue();
|
||||
|
||||
request.flush({
|
||||
threads: [
|
||||
{
|
||||
canonical_id: 'canon-1',
|
||||
format: 'dsse-envelope',
|
||||
purl: 'pkg:oci/acme/api@sha256:abc123',
|
||||
attestation_count: 2,
|
||||
created_at: '2026-03-08T09:00:00Z',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
service.getThreads().subscribe(response => {
|
||||
expect(response.items.length).toBe(1);
|
||||
expect(response.items[0].id).toBe('thread-1');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/evidence');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockResponse);
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
it('should include filter parameters in request', () => {
|
||||
const mockResponse = {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
service.getThreads({
|
||||
status: 'active',
|
||||
verdict: 'allow',
|
||||
page: 2,
|
||||
pageSize: 50
|
||||
}).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(request => {
|
||||
return request.url === '/api/v1/evidence' &&
|
||||
request.params.get('status') === 'active' &&
|
||||
request.params.get('verdict') === 'allow' &&
|
||||
request.params.get('page') === '2' &&
|
||||
request.params.get('pageSize') === '50';
|
||||
});
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
it('should update loading state', () => {
|
||||
const mockResponse = { items: [], total: 0, page: 1, pageSize: 20 };
|
||||
|
||||
expect(service.loading()).toBe(false);
|
||||
|
||||
service.getThreads().subscribe();
|
||||
|
||||
// Loading should be true during request
|
||||
expect(service.loading()).toBe(true);
|
||||
|
||||
httpMock.expectOne('/api/v1/evidence').flush(mockResponse);
|
||||
|
||||
// Loading should be false after response
|
||||
expect(service.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
service.getThreads().subscribe(response => {
|
||||
expect(response.items.length).toBe(0);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/evidence');
|
||||
req.error(new ErrorEvent('Network error'));
|
||||
|
||||
expect(service.error()).toBeTruthy();
|
||||
expect(service.loading()).toBe(false);
|
||||
});
|
||||
expect(actualCount).toBe(1);
|
||||
expect(service.loading()).toBeFalse();
|
||||
expect(service.threads()[0]?.canonicalId).toBe('canon-1');
|
||||
});
|
||||
|
||||
describe('getThreadByDigest', () => {
|
||||
it('should fetch thread graph by digest', () => {
|
||||
const digest = 'sha256:abc123';
|
||||
it('returns an empty result without issuing a request when the PURL is blank', () => {
|
||||
let actualTotal = -1;
|
||||
|
||||
service.getThreadByDigest(digest).subscribe(graph => {
|
||||
expect(graph).toBeTruthy();
|
||||
expect(graph?.thread.id).toBe('thread-1');
|
||||
expect(graph?.nodes.length).toBe(1);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockGraph);
|
||||
service.getThreads({ purl: ' ' }).subscribe((response) => {
|
||||
actualTotal = response.pagination.total;
|
||||
expect(response.threads).toEqual([]);
|
||||
});
|
||||
|
||||
it('should update current thread state', () => {
|
||||
const digest = 'sha256:abc123';
|
||||
|
||||
service.getThreadByDigest(digest).subscribe();
|
||||
httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`).flush(mockGraph);
|
||||
|
||||
expect(service.currentThread()).toEqual(mockGraph);
|
||||
expect(service.currentNodes().length).toBe(1);
|
||||
});
|
||||
expect(actualTotal).toBe(0);
|
||||
expect(service.threads()).toEqual([]);
|
||||
expect(httpMock.match(() => true).length).toBe(0);
|
||||
});
|
||||
|
||||
describe('generateTranscript', () => {
|
||||
it('should generate transcript with options', () => {
|
||||
const digest = 'sha256:abc123';
|
||||
const mockTranscript: EvidenceTranscript = {
|
||||
id: 'transcript-1',
|
||||
tenantId: 'tenant-1',
|
||||
threadId: 'thread-1',
|
||||
transcriptType: 'summary',
|
||||
templateVersion: '1.0',
|
||||
content: 'Test transcript content',
|
||||
anchors: [],
|
||||
generatedAt: '2024-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
service.generateTranscript(digest, {
|
||||
transcriptType: 'summary',
|
||||
useLlm: true
|
||||
}).subscribe(result => {
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.content).toBe('Test transcript content');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}/transcript`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({
|
||||
transcriptType: 'summary',
|
||||
useLlm: true
|
||||
});
|
||||
req.flush(mockTranscript);
|
||||
it('loads a canonical thread by canonical id and normalizes the record', () => {
|
||||
service.getThreadByCanonicalId('canon-1').subscribe((thread) => {
|
||||
expect(thread?.canonicalId).toBe('canon-1');
|
||||
expect(thread?.attestations.length).toBe(1);
|
||||
expect(thread?.transparencyStatus?.mode).toBe('rekor');
|
||||
});
|
||||
|
||||
const request = httpMock.expectOne('/api/v1/evidence/thread/canon-1');
|
||||
expect(request.request.method).toBe('GET');
|
||||
|
||||
request.flush({
|
||||
canonical_id: 'canon-1',
|
||||
format: 'dsse-envelope',
|
||||
artifact_digest: 'sha256:artifact-1',
|
||||
purl: 'pkg:oci/acme/api@sha256:abc123',
|
||||
created_at: '2026-03-08T09:00:00Z',
|
||||
transparency_status: {
|
||||
mode: 'rekor',
|
||||
reason: 'entry-confirmed',
|
||||
},
|
||||
attestations: [
|
||||
{
|
||||
predicate_type: 'https://slsa.dev/provenance/v1',
|
||||
dsse_digest: 'sha256:dsse-1',
|
||||
signer_keyid: 'signer-1',
|
||||
rekor_entry_id: 'entry-1',
|
||||
signed_at: '2026-03-08T09:05:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(service.currentThread()?.canonicalId).toBe('canon-1');
|
||||
expect(service.currentNodes()).toEqual([]);
|
||||
expect(service.currentLinks()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('exportThread', () => {
|
||||
it('should export thread with signing options', () => {
|
||||
const digest = 'sha256:abc123';
|
||||
const mockExport = {
|
||||
id: 'export-1',
|
||||
tenantId: 'tenant-1',
|
||||
threadId: 'thread-1',
|
||||
exportFormat: 'dsse',
|
||||
contentHash: 'sha256:export123',
|
||||
storagePath: '/exports/export-1.dsse',
|
||||
createdAt: '2024-01-01T00:00:00Z'
|
||||
};
|
||||
it('fails closed for transcript and export actions that are not supported by the shipped API', () => {
|
||||
let transcriptResult: unknown = 'pending';
|
||||
let exportResult: unknown = 'pending';
|
||||
|
||||
service.exportThread(digest, {
|
||||
format: 'dsse',
|
||||
sign: true,
|
||||
keyRef: 'my-key'
|
||||
}).subscribe(result => {
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.exportFormat).toBe('dsse');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}/export`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body.sign).toBe(true);
|
||||
req.flush(mockExport);
|
||||
service.generateTranscript('canon-1', { transcriptType: 'summary' }).subscribe((value) => {
|
||||
transcriptResult = value;
|
||||
});
|
||||
service.exportThread('canon-1', { format: 'json', sign: false }).subscribe((value) => {
|
||||
exportResult = value;
|
||||
});
|
||||
|
||||
expect(transcriptResult).toBeNull();
|
||||
expect(exportResult).toBeNull();
|
||||
expect(service.error()).toContain('not supported');
|
||||
expect(httpMock.match(() => true).length).toBe(0);
|
||||
});
|
||||
|
||||
describe('helper methods', () => {
|
||||
it('should return correct node kind labels', () => {
|
||||
expect(service.getNodeKindLabel('sbom_diff')).toBe('SBOM Diff');
|
||||
expect(service.getNodeKindLabel('reachability')).toBe('Reachability');
|
||||
expect(service.getNodeKindLabel('vex')).toBe('VEX');
|
||||
expect(service.getNodeKindLabel('attestation')).toBe('Attestation');
|
||||
it('surfaces not-found detail errors with a stable message', () => {
|
||||
service.getThreadByCanonicalId('missing-canon').subscribe((thread) => {
|
||||
expect(thread).toBeNull();
|
||||
});
|
||||
|
||||
it('should return correct node kind icons', () => {
|
||||
expect(service.getNodeKindIcon('sbom_diff')).toBe('compare_arrows');
|
||||
expect(service.getNodeKindIcon('reachability')).toBe('route');
|
||||
expect(service.getNodeKindIcon('vex')).toBe('security');
|
||||
});
|
||||
const request = httpMock.expectOne('/api/v1/evidence/thread/missing-canon');
|
||||
request.flush({ error: 'missing' }, { status: 404, statusText: 'Not Found' });
|
||||
|
||||
it('should return correct verdict colors', () => {
|
||||
expect(service.getVerdictColor('allow')).toBe('success');
|
||||
expect(service.getVerdictColor('warn')).toBe('warning');
|
||||
expect(service.getVerdictColor('block')).toBe('error');
|
||||
expect(service.getVerdictColor('pending')).toBe('info');
|
||||
expect(service.getVerdictColor('unknown')).toBe('neutral');
|
||||
expect(service.getVerdictColor(undefined)).toBe('neutral');
|
||||
});
|
||||
|
||||
it('should return correct link relation labels', () => {
|
||||
expect(service.getLinkRelationLabel('supports')).toBe('Supports');
|
||||
expect(service.getLinkRelationLabel('contradicts')).toBe('Contradicts');
|
||||
expect(service.getLinkRelationLabel('derived_from')).toBe('Derived From');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computed signals', () => {
|
||||
it('should compute nodesByKind correctly', () => {
|
||||
const digest = 'sha256:abc123';
|
||||
const graphWithMultipleNodes: EvidenceThreadGraph = {
|
||||
thread: mockThread,
|
||||
nodes: [
|
||||
{ ...mockNode, id: 'node-1', kind: 'sbom_diff' },
|
||||
{ ...mockNode, id: 'node-2', kind: 'vex' },
|
||||
{ ...mockNode, id: 'node-3', kind: 'sbom_diff' }
|
||||
],
|
||||
links: []
|
||||
};
|
||||
|
||||
service.getThreadByDigest(digest).subscribe();
|
||||
httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`).flush(graphWithMultipleNodes);
|
||||
|
||||
const byKind = service.nodesByKind();
|
||||
expect(byKind['sbom_diff']?.length).toBe(2);
|
||||
expect(byKind['vex']?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCurrentThread', () => {
|
||||
it('should clear current thread state', () => {
|
||||
const digest = 'sha256:abc123';
|
||||
|
||||
service.getThreadByDigest(digest).subscribe();
|
||||
httpMock.expectOne(`/api/v1/evidence/${encodeURIComponent(digest)}`).flush(mockGraph);
|
||||
|
||||
expect(service.currentThread()).toBeTruthy();
|
||||
|
||||
service.clearCurrentThread();
|
||||
|
||||
expect(service.currentThread()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearError', () => {
|
||||
it('should clear error state', () => {
|
||||
service.getThreads().subscribe();
|
||||
httpMock.expectOne('/api/v1/evidence').error(new ErrorEvent('Error'));
|
||||
|
||||
expect(service.error()).toBeTruthy();
|
||||
|
||||
service.clearError();
|
||||
|
||||
expect(service.error()).toBeNull();
|
||||
});
|
||||
expect(service.currentThread()).toBeNull();
|
||||
expect(service.error()).toBe('missing');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!-- Evidence Thread List Component -->
|
||||
<div class="evidence-thread-list">
|
||||
<!-- Header -->
|
||||
<header class="list-header">
|
||||
<div class="header-left">
|
||||
<h1>Evidence Threads</h1>
|
||||
<p class="subtitle">View and manage evidence chains for your artifacts</p>
|
||||
<p class="subtitle">
|
||||
Search canonical evidence records by package URL against the shipped EvidenceLocker API.
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button mat-icon-button (click)="onRefresh()" matTooltip="Refresh" [disabled]="loading()">
|
||||
@@ -13,50 +13,33 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Filters -->
|
||||
<mat-card class="filters-card">
|
||||
<div class="filters-row">
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-label>Search artifacts</mat-label>
|
||||
<mat-label>Package URL</mat-label>
|
||||
<svg matPrefix xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="searchQuery"
|
||||
(keyup.enter)="onSearch()"
|
||||
placeholder="Search by artifact name or digest...">
|
||||
placeholder="pkg:oci/acme/api@sha256:...">
|
||||
@if (searchQuery) {
|
||||
<button mat-icon-button matSuffix (click)="searchQuery = ''; onSearch()">
|
||||
<button mat-icon-button matSuffix (click)="onClearSearch()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select [(value)]="statusFilter" (selectionChange)="onFilterChange()">
|
||||
@for (option of statusOptions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Verdict</mat-label>
|
||||
<mat-select [(value)]="verdictFilter" (selectionChange)="onFilterChange()">
|
||||
@for (option of verdictOptions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-raised-button color="primary" (click)="onSearch()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
<p class="filters-hint">
|
||||
EvidenceLocker lists threads only for a specific PURL. Enter the exact package URL you want to inspect.
|
||||
</p>
|
||||
</mat-card>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
@@ -64,7 +47,6 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error State -->
|
||||
@if (error() && !loading()) {
|
||||
<div class="error-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
@@ -75,76 +57,59 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Data Table -->
|
||||
@if (!loading() && !error()) {
|
||||
<mat-card class="table-card">
|
||||
@if (threads().length === 0) {
|
||||
@if (!searchedPurl()) {
|
||||
<div class="empty-state">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
<p>Enter a package URL to search for canonical evidence records.</p>
|
||||
<p class="hint">Example: <code>pkg:oci/stellaops/api@sha256:...</code></p>
|
||||
</div>
|
||||
} @else if (threads().length === 0) {
|
||||
<div class="empty-state">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<p>No evidence threads found</p>
|
||||
<p class="hint">Evidence threads are created when artifacts are scanned and evaluated.</p>
|
||||
<p>No evidence threads matched this package URL.</p>
|
||||
<p class="hint">Try the exact PURL stored by EvidenceLocker for the artifact you are investigating.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<table mat-table [dataSource]="threads()" class="threads-table">
|
||||
<!-- Artifact Name Column -->
|
||||
<ng-container matColumnDef="artifactName">
|
||||
<th mat-header-cell *matHeaderCellDef>Artifact</th>
|
||||
<ng-container matColumnDef="canonicalId">
|
||||
<th mat-header-cell *matHeaderCellDef>Canonical ID</th>
|
||||
<td mat-cell *matCellDef="let thread">
|
||||
<div class="artifact-cell">
|
||||
<span class="artifact-name">{{ thread.artifactName ?? 'Unnamed' }}</span>
|
||||
<app-digest-chip
|
||||
[digest]="thread.artifactDigest"
|
||||
variant="artifact"
|
||||
></app-digest-chip>
|
||||
<span class="artifact-name">{{ thread.canonicalId }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Verdict Column -->
|
||||
<ng-container matColumnDef="verdict">
|
||||
<th mat-header-cell *matHeaderCellDef>Verdict</th>
|
||||
<ng-container matColumnDef="format">
|
||||
<th mat-header-cell *matHeaderCellDef>Format</th>
|
||||
<td mat-cell *matCellDef="let thread">
|
||||
<mat-chip [ngClass]="'verdict-' + getVerdictColor(thread.verdict)">
|
||||
<span matChipAvatar [innerHTML]="getVerdictIconSvg(thread.verdict)"></span>
|
||||
{{ thread.verdict ?? 'Unknown' | titlecase }}
|
||||
</mat-chip>
|
||||
{{ thread.format }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<ng-container matColumnDef="purl">
|
||||
<th mat-header-cell *matHeaderCellDef>PURL</th>
|
||||
<td mat-cell *matCellDef="let thread">
|
||||
<span class="status-badge" [class]="'status-' + thread.status">
|
||||
<span [innerHTML]="getStatusIconSvg(thread.status)"></span>
|
||||
{{ thread.status | titlecase }}
|
||||
</span>
|
||||
<code class="cell-code">{{ thread.purl ?? '-' }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Risk Score Column -->
|
||||
<ng-container matColumnDef="riskScore">
|
||||
<th mat-header-cell *matHeaderCellDef>Risk Score</th>
|
||||
<ng-container matColumnDef="attestationCount">
|
||||
<th mat-header-cell *matHeaderCellDef>Attestations</th>
|
||||
<td mat-cell *matCellDef="let thread">
|
||||
@if (thread.riskScore !== undefined && thread.riskScore !== null) {
|
||||
<span class="risk-score" [class]="getRiskClass(thread.riskScore)">
|
||||
{{ thread.riskScore | number:'1.1-1' }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="no-score">-</span>
|
||||
}
|
||||
{{ formatCount(thread.attestationCount) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Updated At Column -->
|
||||
<ng-container matColumnDef="updatedAt">
|
||||
<th mat-header-cell *matHeaderCellDef>Last Updated</th>
|
||||
<ng-container matColumnDef="createdAt">
|
||||
<th mat-header-cell *matHeaderCellDef>Created</th>
|
||||
<td mat-cell *matCellDef="let thread">
|
||||
{{ formatDate(thread.updatedAt) }}
|
||||
{{ formatDate(thread.createdAt) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let thread">
|
||||
@@ -162,15 +127,6 @@
|
||||
class="clickable-row">
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageIndex]="pageIndex()"
|
||||
[pageSizeOptions]="[10, 20, 50, 100]"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons>
|
||||
</mat-paginator>
|
||||
}
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Evidence Thread List Component Styles
|
||||
* Migrated to design system tokens
|
||||
*/
|
||||
|
||||
.evidence-thread-list {
|
||||
padding: var(--space-6);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// Header
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -34,7 +28,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
.filters-card {
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
@@ -48,81 +41,42 @@
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-16) var(--space-6);
|
||||
gap: var(--space-4);
|
||||
|
||||
p {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.filters-hint {
|
||||
margin: var(--space-3) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
// Error state
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-16) var(--space-6);
|
||||
gap: var(--space-4);
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-status-error);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-16) var(--space-6);
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.3;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.hint {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.hint {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.error-container {
|
||||
p {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
}
|
||||
|
||||
// Table
|
||||
.table-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -137,11 +91,6 @@
|
||||
&:hover {
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.artifact-cell {
|
||||
@@ -149,115 +98,24 @@
|
||||
display: block;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.artifact-digest {
|
||||
display: block;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verdict chips
|
||||
.verdict-success {
|
||||
--mat-chip-elevated-container-color: var(--color-status-success-bg);
|
||||
--mat-chip-label-text-color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.verdict-warning {
|
||||
--mat-chip-elevated-container-color: var(--color-status-warning-bg);
|
||||
--mat-chip-label-text-color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
.verdict-error {
|
||||
--mat-chip-elevated-container-color: var(--color-status-error-bg);
|
||||
--mat-chip-label-text-color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.verdict-info {
|
||||
--mat-chip-elevated-container-color: var(--color-status-info-bg);
|
||||
--mat-chip-label-text-color: var(--color-status-info);
|
||||
}
|
||||
|
||||
.verdict-neutral {
|
||||
--mat-chip-elevated-container-color: var(--color-surface-tertiary);
|
||||
--mat-chip-label-text-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Status badges
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
.cell-code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&.status-active {
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
|
||||
&.status-archived {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&.status-exported {
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info);
|
||||
}
|
||||
}
|
||||
|
||||
// Risk scores
|
||||
.risk-score {
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&.risk-critical {
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
&.risk-high {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
&.risk-medium {
|
||||
background: var(--color-severity-medium-bg);
|
||||
color: var(--color-severity-medium);
|
||||
}
|
||||
|
||||
&.risk-low {
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success);
|
||||
}
|
||||
}
|
||||
|
||||
.no-score {
|
||||
color: var(--color-text-muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* High contrast mode */
|
||||
@media (prefers-contrast: high) {
|
||||
.status-badge,
|
||||
.risk-score {
|
||||
.cell-code {
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.clickable-row {
|
||||
transition: none;
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSortModule, Sort } from '@angular/material/sort';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
import {
|
||||
EvidenceThread,
|
||||
EvidenceThreadService,
|
||||
EvidenceThreadStatus,
|
||||
EvidenceVerdict,
|
||||
EvidenceThreadFilter
|
||||
EvidenceThreadSummary,
|
||||
} from '../../services/evidence-thread.service';
|
||||
import { DigestChipComponent } from '../../../../shared/domain/digest-chip/digest-chip.component';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-evidence-thread-list',
|
||||
@@ -36,170 +35,103 @@ import { DigestChipComponent } from '../../../../shared/domain/digest-chip/diges
|
||||
RouterModule,
|
||||
FormsModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatInputModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
MatCardModule,
|
||||
DigestChipComponent
|
||||
],
|
||||
templateUrl: './evidence-thread-list.component.html',
|
||||
styleUrls: ['./evidence-thread-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidenceThreadListComponent implements OnInit {
|
||||
export class EvidenceThreadListComponent implements OnInit, OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
readonly evidenceService = inject(EvidenceThreadService);
|
||||
|
||||
private readonly verdictIconSvgMap: Record<string, string> = {
|
||||
check_circle: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
|
||||
warning: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
||||
block: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>',
|
||||
schedule: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||
help_outline: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
||||
};
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
private readonly statusIconSvgMap: Record<string, string> = {
|
||||
play_circle: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>',
|
||||
archive: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>',
|
||||
cloud_done: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/><polyline points="10 15 12 17 16 13"/></svg>',
|
||||
};
|
||||
|
||||
getVerdictIconSvg(verdict?: EvidenceVerdict): SafeHtml {
|
||||
const iconName = this.getVerdictIcon(verdict);
|
||||
return this.sanitizer.bypassSecurityTrustHtml(this.verdictIconSvgMap[iconName] || this.verdictIconSvgMap['help_outline']);
|
||||
}
|
||||
|
||||
getStatusIconSvg(status: EvidenceThreadStatus): SafeHtml {
|
||||
const iconName = this.getStatusIcon(status);
|
||||
return this.sanitizer.bypassSecurityTrustHtml(this.statusIconSvgMap[iconName] || this.statusIconSvgMap['play_circle']);
|
||||
}
|
||||
|
||||
readonly displayedColumns = ['artifactName', 'verdict', 'status', 'riskScore', 'updatedAt', 'actions'];
|
||||
readonly displayedColumns = [
|
||||
'canonicalId',
|
||||
'format',
|
||||
'purl',
|
||||
'attestationCount',
|
||||
'createdAt',
|
||||
'actions',
|
||||
];
|
||||
|
||||
readonly threads = this.evidenceService.threads;
|
||||
readonly loading = this.evidenceService.loading;
|
||||
readonly error = this.evidenceService.error;
|
||||
readonly searchedPurl = signal<string | null>(null);
|
||||
|
||||
// Pagination
|
||||
readonly totalItems = signal<number>(0);
|
||||
readonly pageSize = signal<number>(20);
|
||||
readonly pageIndex = signal<number>(0);
|
||||
|
||||
// Filters
|
||||
searchQuery = '';
|
||||
statusFilter: EvidenceThreadStatus | '' = '';
|
||||
verdictFilter: EvidenceVerdict | '' = '';
|
||||
|
||||
readonly statusOptions: { value: EvidenceThreadStatus | ''; label: string }[] = [
|
||||
{ value: '', label: 'All Statuses' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
{ value: 'exported', label: 'Exported' }
|
||||
];
|
||||
|
||||
readonly verdictOptions: { value: EvidenceVerdict | ''; label: string }[] = [
|
||||
{ value: '', label: 'All Verdicts' },
|
||||
{ value: 'allow', label: 'Allow' },
|
||||
{ value: 'warn', label: 'Warn' },
|
||||
{ value: 'block', label: 'Block' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'unknown', label: 'Unknown' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadThreads();
|
||||
this.route.queryParamMap
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((queryParams) => {
|
||||
const purl = queryParams.get('purl')?.trim() ?? '';
|
||||
this.searchQuery = purl;
|
||||
this.searchedPurl.set(purl || null);
|
||||
this.loadThreads(purl);
|
||||
});
|
||||
}
|
||||
|
||||
loadThreads(): void {
|
||||
const filter: EvidenceThreadFilter = {
|
||||
page: this.pageIndex() + 1,
|
||||
pageSize: this.pageSize()
|
||||
};
|
||||
|
||||
if (this.statusFilter) {
|
||||
filter.status = this.statusFilter;
|
||||
}
|
||||
if (this.verdictFilter) {
|
||||
filter.verdict = this.verdictFilter;
|
||||
}
|
||||
if (this.searchQuery) {
|
||||
filter.artifactName = this.searchQuery;
|
||||
}
|
||||
|
||||
this.evidenceService.getThreads(filter).subscribe(response => {
|
||||
this.totalItems.set(response.total);
|
||||
});
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
onSearch(): void {
|
||||
this.pageIndex.set(0);
|
||||
this.loadThreads();
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.pageIndex.set(0);
|
||||
this.loadThreads();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageIndex.set(event.pageIndex);
|
||||
this.pageSize.set(event.pageSize);
|
||||
this.loadThreads();
|
||||
}
|
||||
|
||||
onRowClick(thread: EvidenceThread): void {
|
||||
const encodedDigest = encodeURIComponent(thread.artifactDigest);
|
||||
this.router.navigate(['/evidence/threads', encodedDigest]);
|
||||
}
|
||||
|
||||
onRefresh(): void {
|
||||
this.loadThreads();
|
||||
}
|
||||
|
||||
getVerdictColor(verdict?: EvidenceVerdict): string {
|
||||
return this.evidenceService.getVerdictColor(verdict);
|
||||
}
|
||||
|
||||
getVerdictIcon(verdict?: EvidenceVerdict): string {
|
||||
const icons: Record<EvidenceVerdict, string> = {
|
||||
allow: 'check_circle',
|
||||
warn: 'warning',
|
||||
block: 'block',
|
||||
pending: 'schedule',
|
||||
unknown: 'help_outline'
|
||||
};
|
||||
return icons[verdict ?? 'unknown'] ?? 'help_outline';
|
||||
}
|
||||
|
||||
getStatusIcon(status: EvidenceThreadStatus): string {
|
||||
const icons: Record<EvidenceThreadStatus, string> = {
|
||||
active: 'play_circle',
|
||||
archived: 'archive',
|
||||
exported: 'cloud_done'
|
||||
};
|
||||
return icons[status] ?? 'help_outline';
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
const purl = this.searchQuery.trim();
|
||||
void this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { purl: purl || null },
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
}
|
||||
|
||||
getRiskClass(riskScore: number): string {
|
||||
if (riskScore >= 7) return 'risk-critical';
|
||||
if (riskScore >= 4) return 'risk-high';
|
||||
if (riskScore >= 2) return 'risk-medium';
|
||||
return 'risk-low';
|
||||
onClearSearch(): void {
|
||||
this.searchQuery = '';
|
||||
this.onSearch();
|
||||
}
|
||||
|
||||
onRowClick(thread: EvidenceThreadSummary): void {
|
||||
const purl = this.searchedPurl();
|
||||
void this.router.navigate(['/evidence/threads', encodeURIComponent(thread.canonicalId)], {
|
||||
queryParams: purl ? { purl } : {},
|
||||
});
|
||||
}
|
||||
|
||||
onRefresh(): void {
|
||||
this.loadThreads(this.searchedPurl() ?? this.searchQuery);
|
||||
}
|
||||
|
||||
formatDate(value: string): string {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
formatCount(count: number): string {
|
||||
return `${count} attestation${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
private loadThreads(purl: string): void {
|
||||
this.evidenceService.getThreads({ purl }).subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,169 +1,146 @@
|
||||
<!-- Evidence Thread View Component -->
|
||||
<div class="evidence-thread-view">
|
||||
<!-- Header -->
|
||||
<header class="thread-header">
|
||||
<div class="header-left">
|
||||
<button mat-icon-button (click)="onBack()" [matTooltip]="'ui.evidence_thread.back_to_list' | translate">
|
||||
<button mat-icon-button (click)="onBack()" matTooltip="Back to evidence thread search">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="thread-info">
|
||||
<h1 class="thread-title">
|
||||
@if (thread()?.thread?.artifactName) {
|
||||
{{ thread()?.thread?.artifactName }}
|
||||
} @else {
|
||||
{{ 'ui.evidence_thread.title_default' | translate }}
|
||||
}
|
||||
</h1>
|
||||
<h1 class="thread-title">{{ thread()?.purl ?? canonicalId() }}</h1>
|
||||
<div class="thread-digest">
|
||||
<app-digest-chip
|
||||
[digest]="artifactDigest()"
|
||||
variant="artifact"
|
||||
></app-digest-chip>
|
||||
<code>{{ canonicalId() }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
@if (thread()?.thread) {
|
||||
@if (thread()) {
|
||||
<mat-chip-set>
|
||||
<mat-chip [ngClass]="'verdict-' + verdictClass()">
|
||||
<span matChipAvatar [innerHTML]="getVerdictIconSvg(thread()?.thread?.verdict)"></span>
|
||||
{{ getVerdictLabel(thread()?.thread?.verdict) }}
|
||||
<mat-chip>
|
||||
{{ thread()?.format | uppercase }}
|
||||
</mat-chip>
|
||||
|
||||
@if (thread()?.thread?.riskScore !== undefined && thread()?.thread?.riskScore !== null) {
|
||||
<mat-chip>
|
||||
{{ attestationCount() }} attestations
|
||||
</mat-chip>
|
||||
@if (thread()?.transparencyStatus?.mode) {
|
||||
<mat-chip>
|
||||
<svg matChipAvatar xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
{{ 'ui.evidence_thread.risk_label' | translate }} {{ thread()?.thread?.riskScore | number:'1.1-1' }}
|
||||
{{ formatTransparencyMode(thread()?.transparencyStatus?.mode) }} transparency
|
||||
</mat-chip>
|
||||
}
|
||||
|
||||
<mat-chip>
|
||||
<svg matChipAvatar xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
|
||||
{{ nodeCount() }} {{ 'ui.evidence_thread.nodes' | translate }}
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
}
|
||||
|
||||
<div class="header-actions">
|
||||
<button mat-icon-button (click)="onRefresh()" [matTooltip]="'ui.actions.refresh' | translate" [disabled]="loading()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
</button>
|
||||
|
||||
<button mat-raised-button color="primary" (click)="onExport()" [disabled]="loading() || !thread()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
{{ 'ui.actions.export' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<button mat-icon-button (click)="onRefresh()" matTooltip="Refresh" [disabled]="loading()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
<p>{{ 'ui.evidence_thread.loading' | translate }}</p>
|
||||
<p>Loading evidence thread...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error State -->
|
||||
@if (error() && !loading()) {
|
||||
<div class="error-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<p>{{ error() }}</p>
|
||||
<button mat-raised-button color="primary" (click)="onRefresh()">
|
||||
{{ 'ui.error.try_again' | translate }}
|
||||
Try Again
|
||||
</button>
|
||||
<button mat-button (click)="onBack()">Back to Search</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Content -->
|
||||
@if (thread() && !loading()) {
|
||||
<div class="thread-content">
|
||||
<!-- Tab Navigation -->
|
||||
<mat-tab-group
|
||||
[selectedIndex]="selectedTabIndex()"
|
||||
(selectedIndexChange)="onTabChange($event)"
|
||||
animationDuration="200ms">
|
||||
<mat-card class="summary-card">
|
||||
<h2>Record Summary</h2>
|
||||
<dl class="summary-grid">
|
||||
<div>
|
||||
<dt>Canonical ID</dt>
|
||||
<dd><code>{{ thread()?.canonicalId }}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Format</dt>
|
||||
<dd>{{ thread()?.format }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ formatDate(thread()?.createdAt ?? '') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Package URL</dt>
|
||||
<dd><code>{{ thread()?.purl ?? '-' }}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Artifact Digest</dt>
|
||||
<dd>
|
||||
@if (thread()?.artifactDigest) {
|
||||
<app-digest-chip [digest]="thread()!.artifactDigest!" variant="artifact"></app-digest-chip>
|
||||
} @else {
|
||||
-
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Transparency</dt>
|
||||
<dd>
|
||||
@if (thread()?.transparencyStatus?.mode) {
|
||||
{{ formatTransparencyMode(thread()?.transparencyStatus?.mode) }}
|
||||
@if (thread()?.transparencyStatus?.reason) {
|
||||
<span class="transparency-reason">({{ thread()?.transparencyStatus?.reason }})</span>
|
||||
}
|
||||
} @else {
|
||||
-
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</mat-card>
|
||||
|
||||
<!-- Graph Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
|
||||
<span>{{ 'ui.evidence_thread.graph_tab' | translate }}</span>
|
||||
</ng-template>
|
||||
<ng-template matTabContent>
|
||||
<div class="tab-content">
|
||||
<stella-evidence-graph-panel
|
||||
[nodes]="nodes()"
|
||||
[links]="links()"
|
||||
[selectedNodeId]="selectedNodeId()"
|
||||
(nodeSelect)="onNodeSelect($event)">
|
||||
</stella-evidence-graph-panel>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Timeline Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
<span>{{ 'ui.evidence_thread.timeline_tab' | translate }}</span>
|
||||
</ng-template>
|
||||
<ng-template matTabContent>
|
||||
<div class="tab-content">
|
||||
<stella-evidence-timeline-panel
|
||||
[nodes]="nodes()"
|
||||
[selectedNodeId]="selectedNodeId()"
|
||||
(nodeSelect)="onNodeSelect($event)">
|
||||
</stella-evidence-timeline-panel>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Transcript Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<span>{{ 'ui.evidence_thread.transcript_tab' | translate }}</span>
|
||||
</ng-template>
|
||||
<ng-template matTabContent>
|
||||
<div class="tab-content">
|
||||
<stella-evidence-transcript-panel
|
||||
[artifactDigest]="artifactDigest()"
|
||||
[thread]="thread()?.thread"
|
||||
[nodes]="nodes()">
|
||||
</stella-evidence-transcript-panel>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
<!-- Selected Node Detail Panel (Side Panel) -->
|
||||
@if (selectedNodeId()) {
|
||||
<aside class="node-detail-panel">
|
||||
@for (node of nodes(); track node.id) {
|
||||
@if (node.id === selectedNodeId()) {
|
||||
<stella-evidence-node-card
|
||||
[node]="node"
|
||||
[expanded]="true"
|
||||
(close)="selectedNodeId.set(null)">
|
||||
</stella-evidence-node-card>
|
||||
}
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
<mat-card class="summary-card">
|
||||
<h2>Attestations</h2>
|
||||
@if (thread()?.attestations?.length === 0) {
|
||||
<div class="empty-container compact">
|
||||
<p>No attestations are currently attached to this canonical record.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<table class="attestations-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Predicate Type</th>
|
||||
<th>Signed</th>
|
||||
<th>DSSE Digest</th>
|
||||
<th>Signer</th>
|
||||
<th>Rekor Entry</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (attestation of thread()?.attestations ?? []; track trackAttestation($index, attestation)) {
|
||||
<tr>
|
||||
<td>{{ attestation.predicateType }}</td>
|
||||
<td>{{ formatDate(attestation.signedAt) }}</td>
|
||||
<td><code>{{ attestation.dsseDigest }}</code></td>
|
||||
<td><code>{{ attestation.signerKeyId ?? '-' }}</code></td>
|
||||
<td><code>{{ attestation.rekorEntryId ?? '-' }}</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Empty State -->
|
||||
@if (!thread() && !loading() && !error()) {
|
||||
<div class="empty-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
|
||||
<p>{{ 'ui.evidence_thread.not_found' | translate }}</p>
|
||||
<p>No evidence thread is selected.</p>
|
||||
<button mat-raised-button (click)="onBack()">
|
||||
{{ 'ui.actions.back_to_list' | translate }}
|
||||
Back to Search
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Evidence Thread View Component Styles
|
||||
* Migrated to design system tokens
|
||||
*/
|
||||
|
||||
.evidence-thread-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
// Header
|
||||
.thread-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -41,15 +34,13 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.thread-digest {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
|
||||
code {
|
||||
@@ -59,18 +50,7 @@
|
||||
background: var(--color-surface-tertiary);
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,80 +60,10 @@
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
// Verdict chips styling
|
||||
.verdict-success {
|
||||
--mat-chip-elevated-container-color: var(--color-status-success-bg);
|
||||
--mat-chip-label-text-color: var(--color-status-success);
|
||||
}
|
||||
|
||||
.verdict-warning {
|
||||
--mat-chip-elevated-container-color: var(--color-status-warning-bg);
|
||||
--mat-chip-label-text-color: var(--color-status-warning);
|
||||
}
|
||||
|
||||
.verdict-error {
|
||||
--mat-chip-elevated-container-color: var(--color-status-error-bg);
|
||||
--mat-chip-label-text-color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.verdict-info {
|
||||
--mat-chip-elevated-container-color: var(--color-status-info-bg);
|
||||
--mat-chip-label-text-color: var(--color-status-info);
|
||||
}
|
||||
|
||||
.verdict-neutral {
|
||||
--mat-chip-elevated-container-color: var(--color-surface-tertiary);
|
||||
--mat-chip-label-text-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-16) var(--space-6);
|
||||
gap: var(--space-4);
|
||||
|
||||
p {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Error state
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-16) var(--space-6);
|
||||
gap: var(--space-4);
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-status-error);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -162,96 +72,91 @@
|
||||
padding: var(--space-16) var(--space-6);
|
||||
gap: var(--space-4);
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.compact {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: var(--space-6) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-container {
|
||||
p {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
}
|
||||
|
||||
// Content area
|
||||
.thread-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-6) var(--space-6);
|
||||
}
|
||||
|
||||
mat-tab-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.summary-card {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-tab-body-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--space-4);
|
||||
|
||||
dt {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
height: 100%;
|
||||
padding: var(--space-4);
|
||||
overflow: auto;
|
||||
dd {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
// Tab label styling
|
||||
::ng-deep .mat-mdc-tab {
|
||||
.mat-mdc-tab-label-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
.attestations-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-3);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
// Node detail side panel
|
||||
.node-detail-panel {
|
||||
width: 400px;
|
||||
max-width: 40%;
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
|
||||
@include screen-below-md {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
.transparency-reason {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* High contrast mode */
|
||||
@media (prefers-contrast: high) {
|
||||
.thread-header {
|
||||
border-bottom-width: 2px;
|
||||
@include screen-below-md {
|
||||
.thread-content {
|
||||
padding-left: var(--space-4);
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
|
||||
.node-detail-panel {
|
||||
border-left-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.evidence-thread-view *,
|
||||
.thread-header *,
|
||||
.node-detail-panel * {
|
||||
transition: none !important;
|
||||
.attestations-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,28 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
import { Component, OnInit, OnDestroy, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
import { EvidenceThreadService, EvidenceThreadGraph, EvidenceVerdict } from '../../services/evidence-thread.service';
|
||||
import { EvidenceGraphPanelComponent } from '../evidence-graph-panel/evidence-graph-panel.component';
|
||||
import { EvidenceTimelinePanelComponent } from '../evidence-timeline-panel/evidence-timeline-panel.component';
|
||||
import { EvidenceTranscriptPanelComponent } from '../evidence-transcript-panel/evidence-transcript-panel.component';
|
||||
import { EvidenceNodeCardComponent } from '../evidence-node-card/evidence-node-card.component';
|
||||
import { EvidenceExportDialogComponent } from '../evidence-export-dialog/evidence-export-dialog.component';
|
||||
import { TranslatePipe } from '../../../../core/i18n/translate.pipe';
|
||||
import {
|
||||
EvidenceThreadAttestation,
|
||||
EvidenceThreadService,
|
||||
} from '../../services/evidence-thread.service';
|
||||
import { DigestChipComponent } from '../../../../shared/domain/digest-chip/digest-chip.component';
|
||||
|
||||
@Component({
|
||||
@@ -31,75 +32,45 @@ import { DigestChipComponent } from '../../../../shared/domain/digest-chip/diges
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatTabsModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
MatMenuModule,
|
||||
MatSnackBarModule,
|
||||
MatDialogModule,
|
||||
EvidenceGraphPanelComponent,
|
||||
EvidenceTimelinePanelComponent,
|
||||
EvidenceTranscriptPanelComponent,
|
||||
EvidenceNodeCardComponent,
|
||||
TranslatePipe,
|
||||
DigestChipComponent
|
||||
DigestChipComponent,
|
||||
],
|
||||
templateUrl: './evidence-thread-view.component.html',
|
||||
styleUrls: ['./evidence-thread-view.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidenceThreadViewComponent implements OnInit, OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
readonly evidenceService = inject(EvidenceThreadService);
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
|
||||
private readonly verdictIconSvgMap: Record<string, string> = {
|
||||
check_circle: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
|
||||
warning: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
||||
block: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>',
|
||||
schedule: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||
help_outline: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
||||
};
|
||||
|
||||
getVerdictIconSvg(verdict?: EvidenceVerdict): SafeHtml {
|
||||
const icon = this.getVerdictIcon(verdict);
|
||||
return this.sanitizer.bypassSecurityTrustHtml(this.verdictIconSvgMap[icon] || this.verdictIconSvgMap['help_outline']);
|
||||
}
|
||||
|
||||
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
readonly artifactDigest = signal<string>('');
|
||||
readonly selectedTabIndex = signal<number>(0);
|
||||
readonly selectedNodeId = signal<string | null>(null);
|
||||
readonly canonicalId = signal('');
|
||||
readonly returnPurl = signal<string | null>(null);
|
||||
|
||||
readonly thread = this.evidenceService.currentThread;
|
||||
readonly loading = this.evidenceService.loading;
|
||||
readonly error = this.evidenceService.error;
|
||||
readonly nodes = this.evidenceService.currentNodes;
|
||||
readonly links = this.evidenceService.currentLinks;
|
||||
readonly nodesByKind = this.evidenceService.nodesByKind;
|
||||
|
||||
readonly verdictClass = computed(() => {
|
||||
const verdict = this.thread()?.thread?.verdict;
|
||||
return this.evidenceService.getVerdictColor(verdict);
|
||||
});
|
||||
|
||||
readonly nodeCount = computed(() => this.nodes().length);
|
||||
readonly linkCount = computed(() => this.links().length);
|
||||
readonly attestationCount = computed(() => this.thread()?.attestations.length ?? 0);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.params.pipe(takeUntil(this.destroy$)).subscribe(params => {
|
||||
const digest = params['artifactDigest'];
|
||||
if (digest) {
|
||||
this.artifactDigest.set(decodeURIComponent(digest));
|
||||
this.loadThread();
|
||||
this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe((params) => {
|
||||
const canonicalId = params.get('canonicalId');
|
||||
if (!canonicalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.canonicalId.set(decodeURIComponent(canonicalId));
|
||||
this.loadThread();
|
||||
});
|
||||
|
||||
this.route.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe((params) => {
|
||||
this.returnPurl.set(params.get('purl'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,72 +80,57 @@ export class EvidenceThreadViewComponent implements OnInit, OnDestroy {
|
||||
this.evidenceService.clearCurrentThread();
|
||||
}
|
||||
|
||||
private loadThread(): void {
|
||||
const digest = this.artifactDigest();
|
||||
if (!digest) return;
|
||||
|
||||
this.evidenceService.getThreadByDigest(digest)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
error: () => {
|
||||
this.snackBar.open('Failed to load evidence thread', 'Dismiss', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onRefresh(): void {
|
||||
this.loadThread();
|
||||
}
|
||||
|
||||
onTabChange(index: number): void {
|
||||
this.selectedTabIndex.set(index);
|
||||
}
|
||||
|
||||
onNodeSelect(nodeId: string): void {
|
||||
this.selectedNodeId.set(nodeId);
|
||||
}
|
||||
|
||||
onExport(): void {
|
||||
const thread = this.thread();
|
||||
if (!thread) return;
|
||||
|
||||
const dialogRef = this.dialog.open(EvidenceExportDialogComponent, {
|
||||
width: '500px',
|
||||
data: {
|
||||
artifactDigest: this.artifactDigest(),
|
||||
thread: thread.thread
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result?.success) {
|
||||
this.snackBar.open('Export started successfully', 'Dismiss', {
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onBack(): void {
|
||||
this.router.navigate(['/evidence/threads']);
|
||||
const purl = this.returnPurl();
|
||||
void this.router.navigate(['/evidence/threads'], {
|
||||
queryParams: purl ? { purl } : {},
|
||||
});
|
||||
}
|
||||
|
||||
getVerdictLabel(verdict?: EvidenceVerdict): string {
|
||||
if (!verdict) return 'Unknown';
|
||||
return verdict.charAt(0).toUpperCase() + verdict.slice(1);
|
||||
formatDate(value: string): string {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
getVerdictIcon(verdict?: EvidenceVerdict): string {
|
||||
const icons: Record<EvidenceVerdict, string> = {
|
||||
allow: 'check_circle',
|
||||
warn: 'warning',
|
||||
block: 'block',
|
||||
pending: 'schedule',
|
||||
unknown: 'help_outline'
|
||||
};
|
||||
return icons[verdict ?? 'unknown'] ?? 'help_outline';
|
||||
formatTransparencyMode(mode?: string): string {
|
||||
if (!mode) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return mode.charAt(0).toUpperCase() + mode.slice(1);
|
||||
}
|
||||
|
||||
trackAttestation(_index: number, attestation: EvidenceThreadAttestation): string {
|
||||
return `${attestation.dsseDigest}:${attestation.predicateType}`;
|
||||
}
|
||||
|
||||
private loadThread(): void {
|
||||
const canonicalId = this.canonicalId();
|
||||
if (!canonicalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.evidenceService
|
||||
.getThreadByCanonicalId(canonicalId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,32 +2,74 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, catchError, tap, of, BehaviorSubject, map } from 'rxjs';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
|
||||
import { Observable, catchError, map, of, tap } from 'rxjs';
|
||||
|
||||
// Evidence Thread Models
|
||||
export type EvidenceThreadStatus = 'active' | 'archived' | 'exported';
|
||||
export type EvidenceVerdict = 'allow' | 'warn' | 'block' | 'pending' | 'unknown';
|
||||
export type ReachabilityMode = 'exploitable' | 'likely_exploitable' | 'possibly_exploitable' | 'unreachable' | 'unknown';
|
||||
export type EvidenceNodeKind = 'sbom_diff' | 'reachability' | 'vex' | 'attestation' | 'policy_eval' | 'runtime_observation' | 'patch_verification' | 'approval' | 'ai_rationale';
|
||||
export type EvidenceLinkRelation = 'supports' | 'contradicts' | 'precedes' | 'triggers' | 'derived_from' | 'references';
|
||||
export type ReachabilityMode =
|
||||
| 'exploitable'
|
||||
| 'likely_exploitable'
|
||||
| 'possibly_exploitable'
|
||||
| 'unreachable'
|
||||
| 'unknown';
|
||||
export type EvidenceNodeKind =
|
||||
| 'sbom_diff'
|
||||
| 'reachability'
|
||||
| 'vex'
|
||||
| 'attestation'
|
||||
| 'policy_eval'
|
||||
| 'runtime_observation'
|
||||
| 'patch_verification'
|
||||
| 'approval'
|
||||
| 'ai_rationale';
|
||||
export type EvidenceLinkRelation =
|
||||
| 'supports'
|
||||
| 'contradicts'
|
||||
| 'precedes'
|
||||
| 'triggers'
|
||||
| 'derived_from'
|
||||
| 'references';
|
||||
export type TranscriptType = 'summary' | 'detailed' | 'audit';
|
||||
export type ExportFormat = 'dsse' | 'json' | 'pdf' | 'markdown';
|
||||
|
||||
export interface EvidenceThreadAttestation {
|
||||
predicateType: string;
|
||||
dsseDigest: string;
|
||||
signerKeyId?: string;
|
||||
rekorEntryId?: string;
|
||||
rekorTile?: string;
|
||||
signedAt: string;
|
||||
}
|
||||
|
||||
export interface EvidenceThreadTransparencyStatus {
|
||||
mode: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface EvidenceThread {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
artifactDigest: string;
|
||||
artifactName?: string;
|
||||
status: EvidenceThreadStatus;
|
||||
verdict?: EvidenceVerdict;
|
||||
riskScore?: number;
|
||||
reachabilityMode?: ReachabilityMode;
|
||||
knowledgeSnapshotHash?: string;
|
||||
engineVersion?: string;
|
||||
canonicalId: string;
|
||||
format: string;
|
||||
artifactDigest?: string;
|
||||
purl?: string;
|
||||
attestations: EvidenceThreadAttestation[];
|
||||
transparencyStatus?: EvidenceThreadTransparencyStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EvidenceThreadSummary {
|
||||
canonicalId: string;
|
||||
format: string;
|
||||
purl?: string;
|
||||
attestationCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface EvidencePaginationInfo {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface EvidenceAnchor {
|
||||
@@ -100,10 +142,8 @@ export interface EvidenceThreadGraph {
|
||||
}
|
||||
|
||||
export interface EvidenceThreadListResponse {
|
||||
items: EvidenceThread[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
threads: EvidenceThreadSummary[];
|
||||
pagination: EvidencePaginationInfo;
|
||||
}
|
||||
|
||||
export interface TranscriptRequest {
|
||||
@@ -118,194 +158,217 @@ export interface ExportRequest {
|
||||
}
|
||||
|
||||
export interface EvidenceThreadFilter {
|
||||
status?: EvidenceThreadStatus;
|
||||
verdict?: EvidenceVerdict;
|
||||
artifactName?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
purl?: string;
|
||||
}
|
||||
|
||||
interface EvidenceThreadListApiResponse {
|
||||
threads?: EvidenceThreadSummaryApiModel[];
|
||||
pagination?: EvidencePaginationApiModel;
|
||||
}
|
||||
|
||||
interface EvidenceThreadSummaryApiModel {
|
||||
canonical_id?: string;
|
||||
format?: string;
|
||||
purl?: string;
|
||||
attestation_count?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface EvidencePaginationApiModel {
|
||||
total?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
interface EvidenceThreadApiModel {
|
||||
canonical_id?: string;
|
||||
format?: string;
|
||||
artifact_digest?: string;
|
||||
purl?: string;
|
||||
attestations?: EvidenceThreadAttestationApiModel[];
|
||||
transparency_status?: EvidenceThreadTransparencyStatusApiModel;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface EvidenceThreadAttestationApiModel {
|
||||
predicate_type?: string;
|
||||
dsse_digest?: string;
|
||||
signer_keyid?: string;
|
||||
rekor_entry_id?: string;
|
||||
rekor_tile?: string;
|
||||
signed_at?: string;
|
||||
}
|
||||
|
||||
interface EvidenceThreadTransparencyStatusApiModel {
|
||||
mode?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function emptyListResponse(): EvidenceThreadListResponse {
|
||||
return {
|
||||
threads: [],
|
||||
pagination: {
|
||||
total: 0,
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing Evidence Threads.
|
||||
* Provides API integration and local state management for evidence thread operations.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EvidenceThreadService {
|
||||
private readonly httpClient = inject(HttpClient);
|
||||
private readonly apiBase = '/api/v1/evidence';
|
||||
private readonly apiBase = '/api/v1/evidence/thread';
|
||||
|
||||
// Local state signals
|
||||
private readonly _currentThread = signal<EvidenceThreadGraph | null>(null);
|
||||
private readonly _threads = signal<EvidenceThread[]>([]);
|
||||
private readonly _loading = signal<boolean>(false);
|
||||
private readonly _currentThread = signal<EvidenceThread | null>(null);
|
||||
private readonly _threads = signal<EvidenceThreadSummary[]>([]);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
private readonly _currentNodes = signal<EvidenceNode[]>([]);
|
||||
private readonly _currentLinks = signal<EvidenceLink[]>([]);
|
||||
|
||||
// Public computed signals
|
||||
readonly currentThread = this._currentThread.asReadonly();
|
||||
readonly threads = this._threads.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
|
||||
readonly currentNodes = computed(() => this._currentThread()?.nodes ?? []);
|
||||
readonly currentLinks = computed(() => this._currentThread()?.links ?? []);
|
||||
readonly currentNodes = this._currentNodes.asReadonly();
|
||||
readonly currentLinks = this._currentLinks.asReadonly();
|
||||
|
||||
readonly nodesByKind = computed(() => {
|
||||
const nodes = this.currentNodes();
|
||||
const nodes = this._currentNodes();
|
||||
return nodes.reduce((acc, node) => {
|
||||
if (!acc[node.kind]) {
|
||||
acc[node.kind] = [];
|
||||
}
|
||||
|
||||
acc[node.kind].push(node);
|
||||
return acc;
|
||||
}, {} as Record<EvidenceNodeKind, EvidenceNode[]>);
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches a list of evidence threads with optional filtering.
|
||||
*/
|
||||
getThreads(filter?: EvidenceThreadFilter): Observable<EvidenceThreadListResponse> {
|
||||
const purl = filter?.purl?.trim() ?? '';
|
||||
this._error.set(null);
|
||||
|
||||
if (!purl) {
|
||||
const empty = emptyListResponse();
|
||||
this._threads.set(empty.threads);
|
||||
this._loading.set(false);
|
||||
return of(empty);
|
||||
}
|
||||
|
||||
this._loading.set(true);
|
||||
const params = new HttpParams().set('purl', purl);
|
||||
|
||||
return this.httpClient
|
||||
.get<EvidenceThreadListApiResponse>(`${this.apiBase}/`, { params })
|
||||
.pipe(
|
||||
map((response) => this.normalizeListResponse(response)),
|
||||
tap((response) => {
|
||||
this._threads.set(response.threads);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError((error) => {
|
||||
this._threads.set([]);
|
||||
this._error.set(
|
||||
this.buildErrorMessage(error, `Failed to load evidence threads for ${purl}.`)
|
||||
);
|
||||
this._loading.set(false);
|
||||
return of(emptyListResponse());
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getThreadByCanonicalId(canonicalId: string): Observable<EvidenceThread | null> {
|
||||
const normalizedCanonicalId = canonicalId.trim();
|
||||
if (!normalizedCanonicalId) {
|
||||
this._currentThread.set(null);
|
||||
this._currentNodes.set([]);
|
||||
this._currentLinks.set([]);
|
||||
return of(null);
|
||||
}
|
||||
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
let params = new HttpParams();
|
||||
if (filter?.status) {
|
||||
params = params.set('status', filter.status);
|
||||
}
|
||||
if (filter?.verdict) {
|
||||
params = params.set('verdict', filter.verdict);
|
||||
}
|
||||
if (filter?.artifactName) {
|
||||
params = params.set('artifactName', filter.artifactName);
|
||||
}
|
||||
if (filter?.page !== undefined) {
|
||||
params = params.set('page', filter.page.toString());
|
||||
}
|
||||
if (filter?.pageSize !== undefined) {
|
||||
params = params.set('pageSize', filter.pageSize.toString());
|
||||
}
|
||||
return this.httpClient
|
||||
.get<EvidenceThreadApiModel>(`${this.apiBase}/${encodeURIComponent(normalizedCanonicalId)}`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeThreadResponse(response)),
|
||||
tap((thread) => {
|
||||
this._currentThread.set(thread);
|
||||
this._currentNodes.set([]);
|
||||
this._currentLinks.set([]);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError((error) => {
|
||||
this._currentThread.set(null);
|
||||
this._currentNodes.set([]);
|
||||
this._currentLinks.set([]);
|
||||
this._error.set(
|
||||
this.buildErrorMessage(
|
||||
error,
|
||||
`Failed to load evidence thread ${normalizedCanonicalId}.`
|
||||
)
|
||||
);
|
||||
this._loading.set(false);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.httpClient.get<EvidenceThreadListResponse>(this.apiBase, { params }).pipe(
|
||||
tap(response => {
|
||||
this._threads.set(response.items);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message ?? 'Failed to fetch evidence threads');
|
||||
this._loading.set(false);
|
||||
return of({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
})
|
||||
// Compatibility shim for older revived components/tests.
|
||||
getThreadByDigest(canonicalId: string): Observable<EvidenceThread | null> {
|
||||
return this.getThreadByCanonicalId(canonicalId);
|
||||
}
|
||||
|
||||
getNodes(_canonicalId: string): Observable<EvidenceNode[]> {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
getLinks(_canonicalId: string): Observable<EvidenceLink[]> {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
generateTranscript(
|
||||
_canonicalId: string,
|
||||
_request: TranscriptRequest
|
||||
): Observable<EvidenceTranscript | null> {
|
||||
this._error.set(
|
||||
'Evidence transcripts are not supported by the current EvidenceLocker thread API.'
|
||||
);
|
||||
return of(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single evidence thread by artifact digest, including its full graph.
|
||||
*/
|
||||
getThreadByDigest(artifactDigest: string): Observable<EvidenceThreadGraph | null> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
const encodedDigest = encodeURIComponent(artifactDigest);
|
||||
return this.httpClient.get<EvidenceThreadGraph>(`${this.apiBase}/${encodedDigest}`).pipe(
|
||||
tap(graph => {
|
||||
this._currentThread.set(graph);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message ?? 'Failed to fetch evidence thread');
|
||||
this._loading.set(false);
|
||||
return of(null);
|
||||
})
|
||||
exportThread(
|
||||
_canonicalId: string,
|
||||
_request: ExportRequest
|
||||
): Observable<EvidenceExport | null> {
|
||||
this._error.set(
|
||||
'Evidence exports are not supported by the current EvidenceLocker thread API.'
|
||||
);
|
||||
return of(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches nodes for a specific thread.
|
||||
*/
|
||||
getNodes(artifactDigest: string): Observable<EvidenceNode[]> {
|
||||
const encodedDigest = encodeURIComponent(artifactDigest);
|
||||
return this.httpClient.get<EvidenceNode[]>(`${this.apiBase}/${encodedDigest}/nodes`).pipe(
|
||||
catchError(err => {
|
||||
this._error.set(err.message ?? 'Failed to fetch evidence nodes');
|
||||
return of([]);
|
||||
})
|
||||
downloadExport(_exportId: string): Observable<Blob> {
|
||||
this._error.set(
|
||||
'Evidence exports are not supported by the current EvidenceLocker thread API.'
|
||||
);
|
||||
return of(new Blob());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches links for a specific thread.
|
||||
*/
|
||||
getLinks(artifactDigest: string): Observable<EvidenceLink[]> {
|
||||
const encodedDigest = encodeURIComponent(artifactDigest);
|
||||
return this.httpClient.get<EvidenceLink[]>(`${this.apiBase}/${encodedDigest}/links`).pipe(
|
||||
catchError(err => {
|
||||
this._error.set(err.message ?? 'Failed to fetch evidence links');
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a transcript for the evidence thread.
|
||||
*/
|
||||
generateTranscript(artifactDigest: string, request: TranscriptRequest): Observable<EvidenceTranscript | null> {
|
||||
const encodedDigest = encodeURIComponent(artifactDigest);
|
||||
|
||||
return this.httpClient.post<EvidenceTranscript>(
|
||||
`${this.apiBase}/${encodedDigest}/transcript`,
|
||||
request
|
||||
).pipe(
|
||||
catchError(err => {
|
||||
this._error.set(err.message ?? 'Failed to generate transcript');
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the evidence thread in the specified format.
|
||||
*/
|
||||
exportThread(artifactDigest: string, request: ExportRequest): Observable<EvidenceExport | null> {
|
||||
const encodedDigest = encodeURIComponent(artifactDigest);
|
||||
|
||||
return this.httpClient.post<EvidenceExport>(
|
||||
`${this.apiBase}/${encodedDigest}/export`,
|
||||
request
|
||||
).pipe(
|
||||
catchError(err => {
|
||||
this._error.set(err.message ?? 'Failed to export evidence thread');
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an exported evidence bundle.
|
||||
*/
|
||||
downloadExport(exportId: string): Observable<Blob> {
|
||||
return this.httpClient.get(`${this.apiBase}/exports/${exportId}/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current thread from local state.
|
||||
*/
|
||||
clearCurrentThread(): void {
|
||||
this._currentThread.set(null);
|
||||
this._currentNodes.set([]);
|
||||
this._currentLinks.set([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any error state.
|
||||
*/
|
||||
clearError(): void {
|
||||
this._error.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the display label for a node kind.
|
||||
*/
|
||||
getNodeKindLabel(kind: EvidenceNodeKind): string {
|
||||
const labels: Record<EvidenceNodeKind, string> = {
|
||||
sbom_diff: 'SBOM Diff',
|
||||
@@ -316,14 +379,11 @@ export class EvidenceThreadService {
|
||||
runtime_observation: 'Runtime Observation',
|
||||
patch_verification: 'Patch Verification',
|
||||
approval: 'Approval',
|
||||
ai_rationale: 'AI Rationale'
|
||||
ai_rationale: 'AI Rationale',
|
||||
};
|
||||
return labels[kind] ?? kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the icon name for a node kind.
|
||||
*/
|
||||
getNodeKindIcon(kind: EvidenceNodeKind): string {
|
||||
const icons: Record<EvidenceNodeKind, string> = {
|
||||
sbom_diff: 'compare_arrows',
|
||||
@@ -334,29 +394,26 @@ export class EvidenceThreadService {
|
||||
runtime_observation: 'visibility',
|
||||
patch_verification: 'check_circle',
|
||||
approval: 'thumb_up',
|
||||
ai_rationale: 'psychology'
|
||||
ai_rationale: 'psychology',
|
||||
};
|
||||
return icons[kind] ?? 'help_outline';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the color class for a verdict.
|
||||
*/
|
||||
getVerdictColor(verdict?: EvidenceVerdict): string {
|
||||
if (!verdict) return 'neutral';
|
||||
if (!verdict) {
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
const colors: Record<EvidenceVerdict, string> = {
|
||||
allow: 'success',
|
||||
warn: 'warning',
|
||||
block: 'error',
|
||||
pending: 'info',
|
||||
unknown: 'neutral'
|
||||
unknown: 'neutral',
|
||||
};
|
||||
return colors[verdict] ?? 'neutral';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the display label for a link relation.
|
||||
*/
|
||||
getLinkRelationLabel(relation: EvidenceLinkRelation): string {
|
||||
const labels: Record<EvidenceLinkRelation, string> = {
|
||||
supports: 'Supports',
|
||||
@@ -364,8 +421,80 @@ export class EvidenceThreadService {
|
||||
precedes: 'Precedes',
|
||||
triggers: 'Triggers',
|
||||
derived_from: 'Derived From',
|
||||
references: 'References'
|
||||
references: 'References',
|
||||
};
|
||||
return labels[relation] ?? relation;
|
||||
}
|
||||
|
||||
private normalizeListResponse(
|
||||
response: EvidenceThreadListApiResponse | null | undefined
|
||||
): EvidenceThreadListResponse {
|
||||
const threads = (response?.threads ?? []).map((thread) => ({
|
||||
canonicalId: thread.canonical_id ?? '',
|
||||
format: thread.format ?? 'unknown',
|
||||
purl: thread.purl ?? undefined,
|
||||
attestationCount: thread.attestation_count ?? 0,
|
||||
createdAt: thread.created_at ?? '',
|
||||
}));
|
||||
|
||||
return {
|
||||
threads,
|
||||
pagination: {
|
||||
total: response?.pagination?.total ?? threads.length,
|
||||
limit: response?.pagination?.limit ?? threads.length,
|
||||
offset: response?.pagination?.offset ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeThreadResponse(response: EvidenceThreadApiModel): EvidenceThread {
|
||||
return {
|
||||
canonicalId: response.canonical_id ?? '',
|
||||
format: response.format ?? 'unknown',
|
||||
artifactDigest: response.artifact_digest ?? undefined,
|
||||
purl: response.purl ?? undefined,
|
||||
attestations: (response.attestations ?? []).map((attestation) => ({
|
||||
predicateType: attestation.predicate_type ?? 'unknown',
|
||||
dsseDigest: attestation.dsse_digest ?? '',
|
||||
signerKeyId: attestation.signer_keyid ?? undefined,
|
||||
rekorEntryId: attestation.rekor_entry_id ?? undefined,
|
||||
rekorTile: attestation.rekor_tile ?? undefined,
|
||||
signedAt: attestation.signed_at ?? '',
|
||||
})),
|
||||
transparencyStatus: response.transparency_status?.mode
|
||||
? {
|
||||
mode: response.transparency_status.mode,
|
||||
reason: response.transparency_status.reason ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: response.created_at ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
private buildErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const apiError =
|
||||
typeof error.error === 'object' && error.error && 'error' in error.error
|
||||
? String(error.error.error)
|
||||
: null;
|
||||
|
||||
if (apiError) {
|
||||
return apiError;
|
||||
}
|
||||
|
||||
if (typeof error.error === 'string' && error.error.trim()) {
|
||||
return error.error.trim();
|
||||
}
|
||||
|
||||
if (error.status === 404) {
|
||||
return 'Evidence thread not found.';
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { TimelineFilterComponent } from './timeline-filter.component';
|
||||
|
||||
describe('TimelineFilterComponent', () => {
|
||||
let fixture: ComponentFixture<TimelineFilterComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TimelineFilterComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParams: of({}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TimelineFilterComponent);
|
||||
});
|
||||
|
||||
it('renders the filter form without Angular Material control errors', () => {
|
||||
expect(() => fixture.detectChanges()).not.toThrow();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('input[formControlName="fromHlc"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { Component, Output, EventEmitter, inject, OnInit, OnDestroy } from '@ang
|
||||
|
||||
import { FormBuilder, ReactiveFormsModule, FormGroup } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatChipsModule
|
||||
|
||||
@@ -1,51 +1,52 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
|
||||
import { EvidenceThreadListComponent } from '../../app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component';
|
||||
import { EvidenceThreadViewComponent } from '../../app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component';
|
||||
import {
|
||||
EvidenceThread,
|
||||
EvidenceThreadGraph,
|
||||
EvidenceThreadService,
|
||||
} from '../../app/features/evidence-thread/services/evidence-thread.service';
|
||||
import { EvidenceThreadService } from '../../app/features/evidence-thread/services/evidence-thread.service';
|
||||
|
||||
describe('Evidence thread browser', () => {
|
||||
describe('EvidenceThreadListComponent', () => {
|
||||
let fixture: ComponentFixture<EvidenceThreadListComponent>;
|
||||
let component: EvidenceThreadListComponent;
|
||||
let router: Router;
|
||||
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
|
||||
const thread: EvidenceThread = {
|
||||
id: 'thread-1',
|
||||
tenantId: 'tenant-1',
|
||||
artifactDigest: 'sha256:artifact-1',
|
||||
artifactName: 'artifact-a',
|
||||
status: 'active',
|
||||
verdict: 'allow',
|
||||
riskScore: 2.2,
|
||||
createdAt: '2026-02-10T00:00:00Z',
|
||||
updatedAt: '2026-02-10T00:00:00Z',
|
||||
const thread = {
|
||||
canonicalId: 'canon-1',
|
||||
format: 'dsse-envelope',
|
||||
purl: 'pkg:oci/acme/api@sha256:abc123',
|
||||
attestationCount: 2,
|
||||
createdAt: '2026-03-08T09:00:00Z',
|
||||
};
|
||||
|
||||
const listServiceStub = {
|
||||
threads: signal<EvidenceThread[]>([thread]),
|
||||
threads: signal([thread]),
|
||||
loading: signal(false),
|
||||
error: signal<string | null>(null),
|
||||
getThreads: jasmine
|
||||
.createSpy('getThreads')
|
||||
.and.returnValue(of({ items: [thread], total: 1, page: 1, pageSize: 20 })),
|
||||
getVerdictColor: jasmine.createSpy('getVerdictColor').and.returnValue('success'),
|
||||
.and.returnValue(of({ threads: [thread], pagination: { total: 1, limit: 25, offset: 0 } })),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
queryParamMap$ = new BehaviorSubject(
|
||||
convertToParamMap({ purl: 'pkg:oci/acme/api@sha256:abc123' })
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidenceThreadListComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: EvidenceThreadService, useValue: listServiceStub },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParamMap: queryParamMap$.asObservable(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -57,18 +58,23 @@ describe('Evidence thread browser', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('loads thread list on init and tracks total rows', () => {
|
||||
expect(listServiceStub.getThreads).toHaveBeenCalled();
|
||||
expect(component.totalItems()).toBe(1);
|
||||
it('loads thread list from the current PURL query parameter', () => {
|
||||
expect(component.searchQuery).toBe('pkg:oci/acme/api@sha256:abc123');
|
||||
expect(component.threads().length).toBe(1);
|
||||
expect(listServiceStub.getThreads).toHaveBeenCalledWith({
|
||||
purl: 'pkg:oci/acme/api@sha256:abc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to encoded thread digest when a row is opened', () => {
|
||||
it('navigates to the canonical-id detail route and preserves the lookup PURL', () => {
|
||||
component.onRowClick(thread);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/evidence/threads',
|
||||
encodeURIComponent('sha256:artifact-1'),
|
||||
]);
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
['/evidence/threads', encodeURIComponent('canon-1')],
|
||||
{
|
||||
queryParams: { purl: 'pkg:oci/acme/api@sha256:abc123' },
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,79 +82,45 @@ describe('Evidence thread browser', () => {
|
||||
let fixture: ComponentFixture<EvidenceThreadViewComponent>;
|
||||
let component: EvidenceThreadViewComponent;
|
||||
let router: Router;
|
||||
let routeParams$: BehaviorSubject<Record<string, string>>;
|
||||
|
||||
const graph: EvidenceThreadGraph = {
|
||||
thread: {
|
||||
id: 'thread-1',
|
||||
tenantId: 'tenant-1',
|
||||
artifactDigest: 'sha256:artifact-1',
|
||||
artifactName: 'artifact-a',
|
||||
status: 'active',
|
||||
verdict: 'allow',
|
||||
riskScore: 2.2,
|
||||
createdAt: '2026-02-10T00:00:00Z',
|
||||
updatedAt: '2026-02-10T00:00:00Z',
|
||||
},
|
||||
nodes: [
|
||||
const thread = {
|
||||
canonicalId: 'canon-1',
|
||||
format: 'dsse-envelope',
|
||||
artifactDigest: 'sha256:artifact-1',
|
||||
purl: 'pkg:oci/acme/api@sha256:abc123',
|
||||
createdAt: '2026-03-08T09:00:00Z',
|
||||
attestations: [
|
||||
{
|
||||
id: 'node-1',
|
||||
tenantId: 'tenant-1',
|
||||
threadId: 'thread-1',
|
||||
kind: 'sbom_diff',
|
||||
refId: 'ref-1',
|
||||
title: 'SBOM',
|
||||
anchors: [],
|
||||
content: {},
|
||||
createdAt: '2026-02-10T00:00:00Z',
|
||||
predicateType: 'https://slsa.dev/provenance/v1',
|
||||
dsseDigest: 'sha256:dsse-1',
|
||||
signerKeyId: 'signer-1',
|
||||
rekorEntryId: 'entry-1',
|
||||
signedAt: '2026-03-08T09:05:00Z',
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
};
|
||||
|
||||
const viewServiceStub = {
|
||||
currentThread: signal(thread),
|
||||
loading: signal(false),
|
||||
error: signal<string | null>(null),
|
||||
getThreadByCanonicalId: jasmine
|
||||
.createSpy('getThreadByCanonicalId')
|
||||
.and.returnValue(of(thread)),
|
||||
clearCurrentThread: jasmine.createSpy('clearCurrentThread'),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
Object.defineProperty(globalThis, 'ResizeObserver', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: class {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
},
|
||||
});
|
||||
|
||||
routeParams$ = new BehaviorSubject<Record<string, string>>({
|
||||
artifactDigest: encodeURIComponent('sha256:artifact-1'),
|
||||
});
|
||||
|
||||
const dialogStub = {
|
||||
open: jasmine.createSpy('open').and.returnValue({
|
||||
afterClosed: () => of(null),
|
||||
}),
|
||||
};
|
||||
|
||||
const viewServiceStub = {
|
||||
currentThread: signal<EvidenceThreadGraph | null>(graph),
|
||||
loading: signal(false),
|
||||
error: signal<string | null>(null),
|
||||
currentNodes: signal(graph.nodes),
|
||||
currentLinks: signal(graph.links),
|
||||
nodesByKind: signal({ sbom_diff: graph.nodes }),
|
||||
getThreadByDigest: jasmine.createSpy('getThreadByDigest').and.returnValue(of(graph)),
|
||||
clearCurrentThread: jasmine.createSpy('clearCurrentThread'),
|
||||
getVerdictColor: jasmine.createSpy('getVerdictColor').and.returnValue('success'),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidenceThreadViewComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: EvidenceThreadService, useValue: viewServiceStub },
|
||||
{ provide: MatDialog, useValue: dialogStub },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: routeParams$.asObservable(),
|
||||
paramMap: of(convertToParamMap({ canonicalId: 'canon-1' })),
|
||||
queryParamMap: of(convertToParamMap({ purl: 'pkg:oci/acme/api@sha256:abc123' })),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -162,15 +134,18 @@ describe('Evidence thread browser', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('decodes route digest and loads thread details', () => {
|
||||
expect(component.artifactDigest()).toBe('sha256:artifact-1');
|
||||
expect(component.thread()?.thread.id).toBe('thread-1');
|
||||
it('loads thread details from the canonical-id route parameter', () => {
|
||||
expect(component.canonicalId()).toBe('canon-1');
|
||||
expect(component.thread()?.canonicalId).toBe('canon-1');
|
||||
expect(viewServiceStub.getThreadByCanonicalId).toHaveBeenCalledWith('canon-1');
|
||||
});
|
||||
|
||||
it('navigates back to the canonical evidence threads list', () => {
|
||||
it('navigates back to the PURL-filtered evidence threads list', () => {
|
||||
component.onBack();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/evidence/threads']);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/evidence/threads'], {
|
||||
queryParams: { purl: 'pkg:oci/acme/api@sha256:abc123' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { EvidenceThreadService } from '../../app/features/evidence-thread/services/evidence-thread.service';
|
||||
|
||||
describe('EvidenceThreadService loading behavior', () => {
|
||||
describe('EvidenceThreadService compatibility actions', () => {
|
||||
let service: EvidenceThreadService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
@@ -21,56 +21,29 @@ describe('EvidenceThreadService loading behavior', () => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('does not toggle global loading when generating transcript', () => {
|
||||
expect(service.loading()).toBeFalse();
|
||||
it('fails closed without network traffic when transcript generation is requested', () => {
|
||||
let actual: unknown = 'pending';
|
||||
|
||||
let actual: unknown = null;
|
||||
service.generateTranscript('sha256:artifact-dev', { transcriptType: 'summary' }).subscribe((value) => {
|
||||
service.generateTranscript('canon-1', { transcriptType: 'summary' }).subscribe((value) => {
|
||||
actual = value;
|
||||
});
|
||||
|
||||
const request = httpMock.expectOne('/api/v1/evidence/sha256%3Aartifact-dev/transcript');
|
||||
expect(request.request.method).toBe('POST');
|
||||
expect(actual).toBeNull();
|
||||
expect(service.loading()).toBeFalse();
|
||||
|
||||
request.flush({
|
||||
id: 'transcript-1',
|
||||
tenantId: 'tenant-a',
|
||||
threadId: 'thread-1',
|
||||
transcriptType: 'summary',
|
||||
templateVersion: 'v1',
|
||||
content: 'summary text',
|
||||
anchors: [],
|
||||
generatedAt: '2026-02-11T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(service.loading()).toBeFalse();
|
||||
expect(actual).not.toBeNull();
|
||||
expect(service.error()).toContain('not supported');
|
||||
expect(httpMock.match(() => true).length).toBe(0);
|
||||
});
|
||||
|
||||
it('does not toggle global loading when exporting thread content', () => {
|
||||
expect(service.loading()).toBeFalse();
|
||||
it('fails closed without network traffic when evidence export is requested', () => {
|
||||
let actual: unknown = 'pending';
|
||||
|
||||
let actual: unknown = null;
|
||||
service.exportThread('sha256:artifact-dev', { format: 'json', sign: false }).subscribe((value) => {
|
||||
service.exportThread('canon-1', { format: 'json', sign: false }).subscribe((value) => {
|
||||
actual = value;
|
||||
});
|
||||
|
||||
const request = httpMock.expectOne('/api/v1/evidence/sha256%3Aartifact-dev/export');
|
||||
expect(request.request.method).toBe('POST');
|
||||
expect(actual).toBeNull();
|
||||
expect(service.loading()).toBeFalse();
|
||||
|
||||
request.flush({
|
||||
id: 'export-1',
|
||||
tenantId: 'tenant-a',
|
||||
threadId: 'thread-1',
|
||||
exportFormat: 'json',
|
||||
contentHash: 'sha256:export-1',
|
||||
storagePath: '/tmp/export-1.json',
|
||||
createdAt: '2026-02-11T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(service.loading()).toBeFalse();
|
||||
expect(actual).not.toBeNull();
|
||||
expect(service.error()).toContain('not supported');
|
||||
expect(httpMock.match(() => true).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user