Sprint 5: Dashboard 3-column redesign + security reports export
S5-T02: Dashboard 3-column layout with real APIs
- CSS Grid: security posture (1/3) | environments + actions (2/3)
- Left column: Vulnerability Summary (from VULNERABILITY_API getStats()),
SBOM Health (from computed sbomStats), Advisory Feed Status (from
SourceManagementApi getStatus())
- Right column: mission summary strip (4 cards), promotion pipeline
(env card grid), environments at risk table, quick links
- Footer: platform health bar with status dots
- Real API calls with independent loading states and catchError defaults
- Refresh button re-fetches all data
- Responsive: collapses to single column on mobile
- Welcome guide still spans full width when no environments
- Removed old: reachabilityStats signal, nightlyOpsSignals signal,
NightlyOpsSignal interface, TitleCasePipe/UpperCasePipe imports
S5-T03: Security reports as downloadable exports
- New shared utility: download-helpers.ts (downloadCsv, downloadJson)
- Risk Report tab: "Export CSV" + "Generate PDF" (print-friendly CSS)
- VEX Ledger tab: "Export VEX Decisions" as JSON download
- Evidence Export tab: explainer + "Open Export Center" link
- Tests updated: 6 new test cases for export functionality
All 6 sprints now complete. Angular build: 0 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Component } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { ExportCenterComponent } from '../evidence-export/export-center.component';
|
||||
import { SecurityRiskOverviewComponent } from '../security-risk/security-risk-overview.component';
|
||||
import { SecurityDispositionPageComponent } from './security-disposition-page.component';
|
||||
@@ -27,10 +31,21 @@ class StubSecurityDispositionPageComponent {}
|
||||
})
|
||||
class StubExportCenterComponent {}
|
||||
|
||||
const mockContextStore = {
|
||||
selectedRegions: () => [],
|
||||
selectedEnvironments: () => [],
|
||||
initialize: () => {},
|
||||
contextVersion: () => 0,
|
||||
};
|
||||
|
||||
describe('SecurityReportsPageComponent', () => {
|
||||
let fixture: ComponentFixture<SecurityReportsPageComponent>;
|
||||
let httpSpy: jasmine.SpyObj<HttpClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
httpSpy = jasmine.createSpyObj('HttpClient', ['get']);
|
||||
httpSpy.get.and.returnValue(of({ items: [] }));
|
||||
|
||||
TestBed.overrideComponent(SecurityReportsPageComponent, {
|
||||
remove: {
|
||||
imports: [SecurityRiskOverviewComponent, SecurityDispositionPageComponent, ExportCenterComponent],
|
||||
@@ -46,6 +61,11 @@ describe('SecurityReportsPageComponent', () => {
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SecurityReportsPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: HttpClient, useValue: httpSpy },
|
||||
{ provide: PlatformContextStore, useValue: mockContextStore },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SecurityReportsPageComponent);
|
||||
@@ -68,4 +88,106 @@ describe('SecurityReportsPageComponent', () => {
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Evidence export report surface');
|
||||
});
|
||||
|
||||
it('shows Export CSV and Generate PDF buttons on the risk tab', () => {
|
||||
const toolbar = fixture.nativeElement.querySelector('.export-toolbar') as HTMLElement;
|
||||
expect(toolbar).toBeTruthy();
|
||||
expect(toolbar.textContent).toContain('Export CSV');
|
||||
expect(toolbar.textContent).toContain('Generate PDF');
|
||||
});
|
||||
|
||||
it('shows Export VEX Decisions button on the vex tab', () => {
|
||||
const buttons = fixture.nativeElement.querySelectorAll('button[role="tab"]') as NodeListOf<HTMLButtonElement>;
|
||||
buttons[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const toolbar = fixture.nativeElement.querySelector('.export-toolbar') as HTMLElement;
|
||||
expect(toolbar).toBeTruthy();
|
||||
expect(toolbar.textContent).toContain('Export VEX Decisions');
|
||||
});
|
||||
|
||||
it('shows Export Center link on the evidence tab', () => {
|
||||
const buttons = fixture.nativeElement.querySelectorAll('button[role="tab"]') as NodeListOf<HTMLButtonElement>;
|
||||
buttons[2].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const link = fixture.nativeElement.querySelector('.export-center-link') as HTMLAnchorElement;
|
||||
expect(link).toBeTruthy();
|
||||
expect(link.textContent).toContain('Open Export Center');
|
||||
});
|
||||
|
||||
it('shows evidence explainer text on the evidence tab', () => {
|
||||
const buttons = fixture.nativeElement.querySelectorAll('button[role="tab"]') as NodeListOf<HTMLButtonElement>;
|
||||
buttons[2].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const explainer = fixture.nativeElement.querySelector('.evidence-explainer') as HTMLElement;
|
||||
expect(explainer).toBeTruthy();
|
||||
expect(explainer.textContent).toContain('Evidence Bundles');
|
||||
expect(explainer.textContent).toContain('StellaBundle OCI');
|
||||
});
|
||||
|
||||
it('calls HTTP API when exporting risk CSV', () => {
|
||||
httpSpy.get.and.returnValue(of({
|
||||
items: [
|
||||
{
|
||||
findingId: 'f-1',
|
||||
cveId: 'CVE-2025-0001',
|
||||
severity: 'critical',
|
||||
environment: 'production',
|
||||
region: 'eu-west',
|
||||
releaseName: 'app-v1.0',
|
||||
effectiveDisposition: 'action_required',
|
||||
reachable: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Spy on URL.createObjectURL to prevent actual download
|
||||
spyOn(URL, 'createObjectURL').and.returnValue('blob:mock');
|
||||
spyOn(URL, 'revokeObjectURL');
|
||||
|
||||
fixture.componentInstance.exportRiskCsv();
|
||||
|
||||
expect(httpSpy.get).toHaveBeenCalledWith(
|
||||
'/api/v2/security/findings',
|
||||
jasmine.objectContaining({ params: jasmine.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls HTTP API when exporting VEX ledger', () => {
|
||||
httpSpy.get.and.returnValue(of({
|
||||
items: [
|
||||
{
|
||||
findingId: 'f-1',
|
||||
cveId: 'CVE-2025-0001',
|
||||
releaseName: 'app-v1.0',
|
||||
packageName: 'lodash',
|
||||
environment: 'production',
|
||||
effectiveDisposition: 'not_affected',
|
||||
updatedAt: '2026-03-15T10:00:00Z',
|
||||
vex: { status: 'not_affected', justification: 'component_not_present' },
|
||||
exception: { status: 'none', reason: '' },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
spyOn(URL, 'createObjectURL').and.returnValue('blob:mock');
|
||||
spyOn(URL, 'revokeObjectURL');
|
||||
|
||||
fixture.componentInstance.exportVexLedger();
|
||||
|
||||
expect(httpSpy.get).toHaveBeenCalledWith(
|
||||
'/api/v2/security/disposition',
|
||||
jasmine.objectContaining({ params: jasmine.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls window.print for PDF generation', () => {
|
||||
spyOn(window, 'print');
|
||||
|
||||
fixture.componentInstance.generatePdf();
|
||||
|
||||
expect(window.print).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,55 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { catchError, map, of, take } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { downloadCsv, downloadJson } from '../../shared/utils/download-helpers';
|
||||
import { ExportCenterComponent } from '../evidence-export/export-center.component';
|
||||
import { SecurityRiskOverviewComponent } from '../security-risk/security-risk-overview.component';
|
||||
import { SecurityDispositionPageComponent } from './security-disposition-page.component';
|
||||
|
||||
type ReportTab = 'risk' | 'vex' | 'evidence';
|
||||
|
||||
/** Minimal shape for the risk CSV export. */
|
||||
interface RiskFindingRow {
|
||||
findingId: string;
|
||||
cveId: string;
|
||||
severity: string;
|
||||
environment: string;
|
||||
region?: string;
|
||||
releaseName: string;
|
||||
effectiveDisposition: string;
|
||||
reachable: boolean;
|
||||
}
|
||||
|
||||
/** Minimal shape for VEX decision export. */
|
||||
interface VexDecisionRow {
|
||||
findingId: string;
|
||||
cveId: string;
|
||||
releaseName: string;
|
||||
packageName: string;
|
||||
environment: string;
|
||||
vexStatus: string;
|
||||
vexJustification: string;
|
||||
exceptionStatus: string;
|
||||
exceptionReason: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface PlatformListResponse<T> {
|
||||
items: T[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-security-reports-page',
|
||||
standalone: true,
|
||||
imports: [SecurityRiskOverviewComponent, SecurityDispositionPageComponent, ExportCenterComponent],
|
||||
imports: [
|
||||
RouterLink,
|
||||
SecurityRiskOverviewComponent,
|
||||
SecurityDispositionPageComponent,
|
||||
ExportCenterComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="security-reports">
|
||||
@@ -46,16 +86,45 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
|
||||
@switch (activeTab()) {
|
||||
@case ('risk') {
|
||||
<section class="report-panel">
|
||||
<div class="export-toolbar">
|
||||
<button type="button" class="export-btn" (click)="exportRiskCsv()" [disabled]="riskExporting()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
||||
Export CSV
|
||||
</button>
|
||||
<button type="button" class="export-btn" (click)="generatePdf()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
||||
Generate PDF
|
||||
</button>
|
||||
</div>
|
||||
<app-security-risk-overview></app-security-risk-overview>
|
||||
</section>
|
||||
}
|
||||
@case ('vex') {
|
||||
<section class="report-panel">
|
||||
<div class="export-toolbar">
|
||||
<button type="button" class="export-btn" (click)="exportVexLedger()" [disabled]="vexExporting()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
||||
Export VEX Decisions
|
||||
</button>
|
||||
</div>
|
||||
<app-security-disposition-page></app-security-disposition-page>
|
||||
</section>
|
||||
}
|
||||
@case ('evidence') {
|
||||
<section class="report-panel">
|
||||
<section class="report-panel evidence-tab">
|
||||
<div class="evidence-explainer">
|
||||
<h2>Evidence Bundles</h2>
|
||||
<p>
|
||||
Evidence bundles (StellaBundle OCI) are managed from the Export Center.
|
||||
The Export Center lets you create export profiles, schedule automated runs,
|
||||
and download signed audit packs with DSSE envelopes, Rekor tile receipts,
|
||||
and replay logs suitable for auditor delivery via OCI referrer.
|
||||
</p>
|
||||
<a routerLink="/evidence/exports" class="export-center-link">
|
||||
Open Export Center
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<app-export-center></app-export-center>
|
||||
</section>
|
||||
}
|
||||
@@ -102,9 +171,203 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
|
||||
background: var(--color-surface-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.export-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-brand-primary);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.export-btn:hover:not(:disabled) {
|
||||
background: var(--color-surface-secondary);
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.export-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.evidence-tab .evidence-explainer {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.evidence-explainer h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.evidence-explainer p {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.export-center-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: var(--color-brand-primary);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.export-center-link:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.tabs, .export-toolbar, .evidence-explainer, .export-center-link {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class SecurityReportsPageComponent {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
|
||||
readonly activeTab = signal<ReportTab>('risk');
|
||||
readonly riskExporting = signal(false);
|
||||
readonly vexExporting = signal(false);
|
||||
|
||||
exportRiskCsv(): void {
|
||||
this.riskExporting.set(true);
|
||||
|
||||
const params = this.buildContextParams();
|
||||
this.http
|
||||
.get<PlatformListResponse<RiskFindingRow>>('/api/v2/security/findings', {
|
||||
params: params.set('pivot', 'cve'),
|
||||
})
|
||||
.pipe(
|
||||
map((res) => res.items ?? []),
|
||||
catchError(() => of([] as RiskFindingRow[])),
|
||||
take(1),
|
||||
)
|
||||
.subscribe({
|
||||
next: (findings) => {
|
||||
const headers = [
|
||||
'Finding ID',
|
||||
'CVE',
|
||||
'Severity',
|
||||
'Environment',
|
||||
'Region',
|
||||
'Release',
|
||||
'Disposition',
|
||||
'Reachable',
|
||||
];
|
||||
const rows = findings.map((f) => [
|
||||
f.findingId,
|
||||
f.cveId,
|
||||
f.severity,
|
||||
f.environment,
|
||||
f.region ?? '',
|
||||
f.releaseName,
|
||||
f.effectiveDisposition,
|
||||
f.reachable ? 'yes' : 'no',
|
||||
]);
|
||||
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
downloadCsv(`risk-report-${date}.csv`, headers, rows);
|
||||
this.riskExporting.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.riskExporting.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
generatePdf(): void {
|
||||
document.body.classList.add('print-report');
|
||||
window.print();
|
||||
document.body.classList.remove('print-report');
|
||||
}
|
||||
|
||||
exportVexLedger(): void {
|
||||
this.vexExporting.set(true);
|
||||
|
||||
const params = this.buildContextParams();
|
||||
|
||||
interface DispositionRow {
|
||||
findingId: string;
|
||||
cveId: string;
|
||||
releaseName: string;
|
||||
packageName: string;
|
||||
environment: string;
|
||||
effectiveDisposition: string;
|
||||
updatedAt: string;
|
||||
vex: { status: string; justification: string };
|
||||
exception: { status: string; reason: string };
|
||||
}
|
||||
|
||||
this.http
|
||||
.get<PlatformListResponse<DispositionRow>>('/api/v2/security/disposition', { params })
|
||||
.pipe(
|
||||
map((res) => res.items ?? []),
|
||||
catchError(() => of([] as DispositionRow[])),
|
||||
take(1),
|
||||
)
|
||||
.subscribe({
|
||||
next: (rows) => {
|
||||
const decisions: VexDecisionRow[] = rows.map((r) => ({
|
||||
findingId: r.findingId,
|
||||
cveId: r.cveId,
|
||||
releaseName: r.releaseName,
|
||||
packageName: r.packageName,
|
||||
environment: r.environment,
|
||||
vexStatus: r.vex.status,
|
||||
vexJustification: r.vex.justification,
|
||||
exceptionStatus: r.exception.status,
|
||||
exceptionReason: r.exception.reason,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
downloadJson(`vex-ledger-${date}.json`, {
|
||||
exportedAt: new Date().toISOString(),
|
||||
recordCount: decisions.length,
|
||||
decisions,
|
||||
});
|
||||
this.vexExporting.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.vexExporting.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private buildContextParams(): HttpParams {
|
||||
let params = new HttpParams().set('limit', '500').set('offset', '0');
|
||||
const region = this.context.selectedRegions()[0];
|
||||
const environment = this.context.selectedEnvironments()[0];
|
||||
if (region) params = params.set('region', region);
|
||||
if (environment) params = params.set('environment', environment);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Browser download helpers for CSV and JSON exports.
|
||||
*
|
||||
* Sprint: SPRINT_20260315_005 (T03 - Security Reports downloadable exports)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates a CSV string from headers and rows and triggers a browser download.
|
||||
*/
|
||||
export function downloadCsv(filename: string, headers: string[], rows: string[][]): void {
|
||||
const escapeCsvField = (field: string): string => {
|
||||
if (field.includes(',') || field.includes('"') || field.includes('\n')) {
|
||||
return `"${field.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return field;
|
||||
};
|
||||
|
||||
const csv = [
|
||||
headers.map(escapeCsvField).join(','),
|
||||
...rows.map((r) => r.map(escapeCsvField).join(',')),
|
||||
].join('\n');
|
||||
|
||||
triggerBlobDownload(new Blob([csv], { type: 'text/csv;charset=utf-8' }), filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes data as formatted JSON and triggers a browser download.
|
||||
*/
|
||||
export function downloadJson(filename: string, data: unknown): void {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
triggerBlobDownload(new Blob([json], { type: 'application/json' }), filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level helper: create a temporary object URL, click a hidden anchor, then revoke.
|
||||
*/
|
||||
function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user