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:
master
2026-03-16 16:36:07 +02:00
parent efa33efdbc
commit ef7797e6c2
4 changed files with 1197 additions and 603 deletions

View File

@@ -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();
});
});

View File

@@ -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;
}
}

View File

@@ -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);
}