fix tests. new product advisories enhancements

This commit is contained in:
master
2026-01-25 19:11:36 +02:00
parent c70e83719e
commit 6e687b523a
504 changed files with 40610 additions and 3785 deletions

View File

@@ -0,0 +1,270 @@
/**
* @file component-diff-row.component.spec.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel (DD-004)
* @description Unit tests for component diff row component.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentDiffRowComponent } from './component-diff-row.component';
import { ComponentDiff } from '../../models/deploy-diff.models';
describe('ComponentDiffRowComponent', () => {
let fixture: ComponentFixture<ComponentDiffRowComponent>;
let component: ComponentDiffRowComponent;
const mockAddedComponent: ComponentDiff = {
id: 'comp-added',
changeType: 'added',
name: 'new-package',
group: '@company',
fromVersion: null,
toVersion: '1.0.0',
licenseChanged: false,
toLicense: 'MIT',
purl: 'pkg:npm/@company/new-package@1.0.0',
};
const mockRemovedComponent: ComponentDiff = {
id: 'comp-removed',
changeType: 'removed',
name: 'old-package',
fromVersion: '2.0.0',
toVersion: null,
licenseChanged: false,
fromLicense: 'Apache-2.0',
};
const mockChangedComponent: ComponentDiff = {
id: 'comp-changed',
changeType: 'changed',
name: 'updated-package',
fromVersion: '1.0.0',
toVersion: '2.0.0',
fromLicense: 'MIT',
toLicense: 'Apache-2.0',
licenseChanged: true,
versionChange: {
type: 'major',
description: 'Major version upgrade',
breaking: true,
},
dependencies: [
{ name: 'dep-a', version: '1.0.0', direct: true },
{ name: 'dep-b', version: '2.0.0', direct: false },
],
vulnerabilities: [
{
id: 'CVE-2026-1234',
severity: 'high',
cvssScore: 8.5,
fixed: true,
url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-1234',
},
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ComponentDiffRowComponent],
}).compileComponents();
fixture = TestBed.createComponent(ComponentDiffRowComponent);
component = fixture.componentInstance;
});
afterEach(() => {
fixture.destroy();
});
describe('DD-004: Row display', () => {
it('shows package name and version', () => {
fixture.componentRef.setInput('component', mockAddedComponent);
fixture.detectChanges();
const name = fixture.nativeElement.querySelector('.package-name');
expect(name.textContent).toContain('new-package');
const version = fixture.nativeElement.querySelector('.version-to');
expect(version.textContent).toContain('1.0.0');
});
it('shows group prefix when present', () => {
fixture.componentRef.setInput('component', mockAddedComponent);
fixture.detectChanges();
const group = fixture.nativeElement.querySelector('.package-group');
expect(group.textContent).toContain('@company/');
});
});
describe('Change type badge', () => {
it('shows Added badge for added components', () => {
fixture.componentRef.setInput('component', mockAddedComponent);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.change-badge');
expect(badge.textContent).toContain('Added');
expect(badge.classList.contains('badge-added')).toBeTrue();
});
it('shows Removed badge for removed components', () => {
fixture.componentRef.setInput('component', mockRemovedComponent);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.change-badge');
expect(badge.textContent).toContain('Removed');
expect(badge.classList.contains('badge-removed')).toBeTrue();
});
it('shows Changed badge for changed components', () => {
fixture.componentRef.setInput('component', mockChangedComponent);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.change-badge');
expect(badge.textContent).toContain('Changed');
expect(badge.classList.contains('badge-modified')).toBeTrue();
});
});
describe('Version diff formatting', () => {
it('shows version change with arrow', () => {
fixture.componentRef.setInput('component', mockChangedComponent);
fixture.detectChanges();
const from = fixture.nativeElement.querySelector('.version-from');
const arrow = fixture.nativeElement.querySelector('.version-arrow');
const to = fixture.nativeElement.querySelector('.version-to');
expect(from.textContent).toContain('1.0.0');
expect(arrow.textContent).toContain('→');
expect(to.textContent).toContain('2.0.0');
});
it('shows version badge for semantic changes', () => {
fixture.componentRef.setInput('component', mockChangedComponent);
fixture.detectChanges();
const versionBadge = fixture.nativeElement.querySelector('.version-badge');
expect(versionBadge.textContent).toContain('major');
expect(versionBadge.classList.contains('version-badge--major')).toBeTrue();
});
it('applies strikethrough to removed version', () => {
fixture.componentRef.setInput('component', mockRemovedComponent);
fixture.detectChanges();
const from = fixture.nativeElement.querySelector('.version-from');
expect(from.classList.contains('version-removed')).toBeTrue();
});
});
describe('License display', () => {
it('shows license change warning when license changed', () => {
fixture.componentRef.setInput('component', mockChangedComponent);
fixture.detectChanges();
const licenseChanged = fixture.nativeElement.querySelector('.license-changed');
expect(licenseChanged).toBeTruthy();
expect(licenseChanged.textContent).toContain('MIT');
expect(licenseChanged.textContent).toContain('Apache-2.0');
});
it('shows simple license when unchanged', () => {
fixture.componentRef.setInput('component', mockAddedComponent);
fixture.detectChanges();
const license = fixture.nativeElement.querySelector('.license');
expect(license.textContent).toContain('MIT');
});
});
describe('Expandable details', () => {
it('shows expand button', () => {
fixture.componentRef.setInput('component', mockChangedComponent);
fixture.detectChanges();
const expandBtn = fixture.nativeElement.querySelector('.expand-btn');
expect(expandBtn).toBeTruthy();
});
it('emits toggleExpand on click', () => {
fixture.componentRef.setInput('component', mockChangedComponent);
let emittedId: string | undefined;
component.toggleExpand.subscribe(id => (emittedId = id));
fixture.detectChanges();
const row = fixture.nativeElement.querySelector('.diff-row__main');
row.click();
expect(emittedId).toBe('comp-changed');
});
it('shows details section when expanded', () => {
fixture.componentRef.setInput('component', mockChangedComponent);
fixture.componentRef.setInput('expanded', true);
fixture.detectChanges();
const details = fixture.nativeElement.querySelector('.diff-row__details');
expect(details).toBeTruthy();
});
it('shows dependencies in expanded view', () => {
fixture.componentRef.setInput('component', mockChangedComponent);
fixture.componentRef.setInput('expanded', true);
fixture.detectChanges();
const depList = fixture.nativeElement.querySelector('.dependency-list');
expect(depList).toBeTruthy();
const deps = depList.querySelectorAll('.dependency-item');
expect(deps.length).toBe(2);
});
it('shows vulnerabilities in expanded view', () => {
fixture.componentRef.setInput('component', mockChangedComponent);
fixture.componentRef.setInput('expanded', true);
fixture.detectChanges();
const vulnList = fixture.nativeElement.querySelector('.vuln-list');
expect(vulnList).toBeTruthy();
const vulnItem = vulnList.querySelector('.vuln-item');
expect(vulnItem.textContent).toContain('CVE-2026-1234');
expect(vulnItem.textContent).toContain('high');
});
it('shows PURL in expanded view', () => {
fixture.componentRef.setInput('component', mockAddedComponent);
fixture.componentRef.setInput('expanded', true);
fixture.detectChanges();
const purl = fixture.nativeElement.querySelector('.purl-display');
expect(purl.textContent).toContain('pkg:npm/@company/new-package@1.0.0');
});
});
describe('Version comparison logic', () => {
it('hasVersionChange returns true when versions differ', () => {
fixture.componentRef.setInput('component', mockChangedComponent);
fixture.detectChanges();
expect(component.hasVersionChange()).toBeTrue();
});
it('isAdded returns true for added components', () => {
fixture.componentRef.setInput('component', mockAddedComponent);
fixture.detectChanges();
expect(component.isAdded()).toBeTrue();
expect(component.isRemoved()).toBeFalse();
});
it('isRemoved returns true for removed components', () => {
fixture.componentRef.setInput('component', mockRemovedComponent);
fixture.detectChanges();
expect(component.isRemoved()).toBeTrue();
expect(component.isAdded()).toBeFalse();
});
});
});

View File

@@ -0,0 +1,633 @@
/**
* @file component-diff-row.component.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel (DD-004)
* @description Individual component comparison row in the deploy diff view.
*/
import {
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ComponentDiff,
formatVersionChange,
getChangeTypeClass,
getChangeTypeIcon,
} from '../../models/deploy-diff.models';
/**
* Row component displaying a single component diff.
*
* Features:
* - Shows package name, version change, license
* - Change type badge (Added/Removed/Changed)
* - Version delta with semantic diff coloring
* - Expandable details section
* - License change highlight
*
* @example
* <app-component-diff-row
* [component]="diff"
* [expanded]="isExpanded"
* (toggleExpand)="onToggleExpand($event)"
* />
*/
@Component({
selector: 'app-component-diff-row',
standalone: true,
imports: [CommonModule],
template: `
<div
class="diff-row"
[class]="rowClasses()"
[class.expanded]="expanded()"
role="row"
[attr.aria-expanded]="expanded()"
>
<!-- Main row content -->
<div class="diff-row__main" (click)="onToggle()">
<!-- Change type badge -->
<div class="diff-row__badge">
<span
class="change-badge"
[class]="badgeClass()"
[attr.aria-label]="badgeAriaLabel()"
>
<svg class="badge-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@switch (component().changeType) {
@case ('added') {
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
}
@case ('removed') {
<circle cx="12" cy="12" r="10"/>
<line x1="8" y1="12" x2="16" y2="12"/>
}
@case ('changed') {
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4M12 16h.01"/>
}
}
</svg>
{{ changeTypeLabel() }}
</span>
</div>
<!-- Package name -->
<div class="diff-row__name">
@if (component().group) {
<span class="package-group">{{ component().group }}/</span>
}
<span class="package-name">{{ component().name }}</span>
</div>
<!-- Version change -->
<div class="diff-row__version">
@if (hasVersionChange()) {
<span class="version-from" [class.version-removed]="isRemoved()">
{{ component().fromVersion || '--' }}
</span>
@if (!isRemoved() && !isAdded()) {
<span class="version-arrow">→</span>
}
<span class="version-to" [class.version-added]="isAdded()">
{{ component().toVersion || '--' }}
</span>
@if (versionChangeType(); as changeType) {
<span class="version-badge" [class]="'version-badge--' + changeType">
{{ changeType }}
</span>
}
} @else {
<span class="version-unchanged">{{ component().fromVersion || component().toVersion }}</span>
}
</div>
<!-- License -->
<div class="diff-row__license">
@if (component().licenseChanged) {
<span class="license-changed" title="License changed">
{{ component().fromLicense || '?' }} → {{ component().toLicense || '?' }}
<svg class="warning-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</span>
} @else {
<span class="license">{{ component().toLicense || component().fromLicense || '--' }}</span>
}
</div>
<!-- Expand indicator -->
<div class="diff-row__expand">
<button
type="button"
class="expand-btn"
[attr.aria-label]="expanded() ? 'Collapse details' : 'Expand details'"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
[class.rotated]="expanded()"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</div>
<!-- Expanded details -->
@if (expanded()) {
<div class="diff-row__details" role="region" aria-label="Component details">
<!-- Dependencies section -->
@if (component().dependencies?.length) {
<div class="details-section">
<h4 class="details-section__title">Dependencies ({{ component().dependencies!.length }})</h4>
<ul class="dependency-list">
@for (dep of component().dependencies!.slice(0, 10); track dep.name) {
<li class="dependency-item" [class.direct]="dep.direct">
<span class="dep-name">{{ dep.name }}</span>
@if (dep.version) {
<span class="dep-version">{{ dep.version }}</span>
}
@if (dep.direct) {
<span class="dep-badge">direct</span>
}
</li>
}
@if (component().dependencies!.length > 10) {
<li class="dependency-more">
+{{ component().dependencies!.length - 10 }} more...
</li>
}
</ul>
</div>
}
<!-- Vulnerabilities section -->
@if (component().vulnerabilities?.length) {
<div class="details-section">
<h4 class="details-section__title">Vulnerabilities ({{ component().vulnerabilities!.length }})</h4>
<ul class="vuln-list">
@for (vuln of component().vulnerabilities; track vuln.id) {
<li class="vuln-item" [class]="'severity-' + vuln.severity">
<a
[href]="vuln.url || '#'"
target="_blank"
rel="noopener noreferrer"
class="vuln-link"
>
{{ vuln.id }}
</a>
<span class="vuln-severity">{{ vuln.severity }}</span>
@if (vuln.cvssScore !== undefined) {
<span class="vuln-score">{{ vuln.cvssScore.toFixed(1) }}</span>
}
@if (vuln.fixed) {
<span class="vuln-fixed">Fixed</span>
}
</li>
}
</ul>
</div>
}
<!-- PURL -->
@if (component().purl) {
<div class="details-section">
<h4 class="details-section__title">Package URL</h4>
<code class="purl-display">{{ component().purl }}</code>
</div>
}
</div>
}
</div>
`,
styles: [`
.diff-row {
border-bottom: 1px solid var(--border-light, #e5e7eb);
transition: background-color 0.15s;
&:hover {
background-color: var(--surface-hover, #f9fafb);
}
&.expanded {
background-color: var(--surface-secondary, #f3f4f6);
}
&.change-added {
border-left: 3px solid var(--color-success, #16a34a);
}
&.change-removed {
border-left: 3px solid var(--color-error, #dc2626);
}
&.change-modified {
border-left: 3px solid var(--color-warning, #ca8a04);
}
}
.diff-row__main {
display: grid;
grid-template-columns: 100px 1fr 200px 150px 40px;
gap: 0.75rem;
padding: 0.75rem 1rem;
align-items: center;
cursor: pointer;
}
/* Change badge */
.diff-row__badge {
display: flex;
justify-content: flex-start;
}
.change-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
border-radius: 4px;
&.badge-added {
background-color: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&.badge-removed {
background-color: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
}
&.badge-modified {
background-color: var(--color-warning-bg, #fef9c3);
color: var(--color-warning, #ca8a04);
}
}
.badge-icon {
width: 14px;
height: 14px;
}
/* Package name */
.diff-row__name {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 0.8125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.package-group {
color: var(--text-muted, #9ca3af);
}
.package-name {
color: var(--text-primary, #111827);
font-weight: 500;
}
/* Version */
.diff-row__version {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 0.75rem;
}
.version-from {
color: var(--text-secondary, #6b7280);
&.version-removed {
color: var(--color-error, #dc2626);
text-decoration: line-through;
}
}
.version-arrow {
color: var(--text-muted, #9ca3af);
}
.version-to {
color: var(--text-primary, #111827);
&.version-added {
color: var(--color-success, #16a34a);
font-weight: 500;
}
}
.version-unchanged {
color: var(--text-muted, #9ca3af);
}
.version-badge {
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
border-radius: 3px;
&--major {
background-color: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
}
&--minor {
background-color: var(--color-warning-bg, #fef9c3);
color: var(--color-warning, #ca8a04);
}
&--patch {
background-color: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
}
}
/* License */
.diff-row__license {
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
}
.license-changed {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--color-warning, #ca8a04);
}
.warning-icon {
color: var(--color-warning, #ca8a04);
}
/* Expand button */
.diff-row__expand {
display: flex;
justify-content: center;
}
.expand-btn {
padding: 0.25rem;
background: transparent;
border: none;
color: var(--text-muted, #9ca3af);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
&:hover {
background-color: var(--surface-hover, #e5e7eb);
color: var(--text-primary, #111827);
}
svg {
transition: transform 0.2s;
&.rotated {
transform: rotate(180deg);
}
}
}
/* Details section */
.diff-row__details {
padding: 1rem 1rem 1rem 2rem;
border-top: 1px solid var(--border-light, #e5e7eb);
background-color: var(--surface-primary, #ffffff);
}
.details-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.details-section__title {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.025em;
}
/* Dependencies */
.dependency-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.dependency-item {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-family: 'SF Mono', 'Consolas', monospace;
background-color: var(--surface-secondary, #f3f4f6);
border-radius: 4px;
&.direct {
background-color: var(--color-info-bg, #dbeafe);
}
}
.dep-name {
color: var(--text-primary, #111827);
}
.dep-version {
color: var(--text-muted, #9ca3af);
}
.dep-badge {
font-size: 0.625rem;
color: var(--color-info, #2563eb);
font-weight: 500;
}
.dependency-more {
color: var(--text-muted, #9ca3af);
font-size: 0.75rem;
font-style: italic;
}
/* Vulnerabilities */
.vuln-list {
margin: 0;
padding: 0;
list-style: none;
}
.vuln-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 0;
font-size: 0.75rem;
border-bottom: 1px solid var(--border-light, #e5e7eb);
&:last-child {
border-bottom: none;
}
&.severity-critical {
.vuln-severity { color: var(--color-error, #dc2626); }
}
&.severity-high {
.vuln-severity { color: var(--color-error, #dc2626); }
}
&.severity-medium {
.vuln-severity { color: var(--color-warning, #ca8a04); }
}
&.severity-low {
.vuln-severity { color: var(--color-info, #2563eb); }
}
}
.vuln-link {
font-family: 'SF Mono', 'Consolas', monospace;
color: var(--text-link, #2563eb);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.vuln-severity {
font-weight: 600;
text-transform: capitalize;
}
.vuln-score {
color: var(--text-muted, #9ca3af);
}
.vuln-fixed {
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
font-weight: 600;
background-color: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
border-radius: 3px;
}
/* PURL */
.purl-display {
display: block;
padding: 0.5rem;
font-size: 0.75rem;
font-family: 'SF Mono', 'Consolas', monospace;
background-color: var(--surface-secondary, #f3f4f6);
border-radius: 4px;
word-break: break-all;
}
`],
})
export class ComponentDiffRowComponent {
/** Component diff data */
readonly component = input.required<ComponentDiff>();
/** Whether this row is expanded */
readonly expanded = input<boolean>(false);
/** Emits when row expansion should toggle */
readonly toggleExpand = output<string>();
/** Computed: row CSS classes */
readonly rowClasses = computed(() => {
const type = this.component().changeType;
return getChangeTypeClass(type);
});
/** Computed: badge CSS class */
readonly badgeClass = computed(() => {
const type = this.component().changeType;
switch (type) {
case 'added':
return 'badge-added';
case 'removed':
return 'badge-removed';
case 'changed':
return 'badge-modified';
default:
return '';
}
});
/** Computed: change type label */
readonly changeTypeLabel = computed(() => {
const type = this.component().changeType;
switch (type) {
case 'added':
return 'Added';
case 'removed':
return 'Removed';
case 'changed':
return 'Changed';
default:
return '';
}
});
/** Computed: badge aria label */
readonly badgeAriaLabel = computed(() => {
const comp = this.component();
return `${this.changeTypeLabel()}: ${comp.name}`;
});
/** Computed: version change type */
readonly versionChangeType = computed(() => {
return this.component().versionChange?.type;
});
/** Computed: has version change */
readonly hasVersionChange = computed(() => {
const comp = this.component();
return comp.fromVersion !== comp.toVersion;
});
/** Check if component is added */
isAdded(): boolean {
return this.component().changeType === 'added';
}
/** Check if component is removed */
isRemoved(): boolean {
return this.component().changeType === 'removed';
}
/** Toggle expansion */
onToggle(): void {
this.toggleExpand.emit(this.component().id);
}
}

View File

@@ -0,0 +1,379 @@
/**
* @file deploy-action-bar.component.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel (DD-006)
* @description One-click policy action bar for deploy decisions.
*/
import {
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
PolicyResult,
DeployActionType,
} from '../../models/deploy-diff.models';
/**
* Deploy action bar component.
*
* Features:
* - Block (red) - Reject deployment
* - Allow (override) (yellow) - Approve with justification
* - Schedule canary (blue) - Progressive rollout
* - Sticky footer position
* - Keyboard accessible
*
* @example
* <app-deploy-action-bar
* [policyResult]="policyResult"
* [loading]="isLoading"
* (actionClick)="onActionClick($event)"
* />
*/
@Component({
selector: 'app-deploy-action-bar',
standalone: true,
imports: [CommonModule],
template: `
<footer
class="action-bar"
role="toolbar"
aria-label="Deployment actions"
>
<!-- Policy summary -->
<div class="action-bar__summary">
@if (policyResult(); as result) {
<div class="policy-summary">
@if (result.allowed) {
<span class="summary-badge summary-badge--pass">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
Policy passed
</span>
} @else {
<span class="summary-badge summary-badge--fail">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{{ result.failCount }} policy failure{{ result.failCount !== 1 ? 's' : '' }}
</span>
}
@if (result.warnCount > 0) {
<span class="summary-badge summary-badge--warn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
{{ result.warnCount }} warning{{ result.warnCount !== 1 ? 's' : ''}}
</span>
}
</div>
}
</div>
<!-- Action buttons -->
<div class="action-bar__actions">
<!-- Block button -->
<button
type="button"
class="action-btn action-btn--block"
[disabled]="loading() || disabled()"
(click)="onAction('block')"
[attr.aria-busy]="loading() && currentAction() === 'block'"
>
@if (loading() && currentAction() === 'block') {
<span class="btn-spinner"></span>
Blocking...
} @else {
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
Block
}
</button>
<!-- Allow (override) button -->
<button
type="button"
class="action-btn action-btn--override"
[disabled]="loading() || disabled() || !canOverride()"
(click)="onAction('allow_override')"
[attr.aria-busy]="loading() && currentAction() === 'allow_override'"
[title]="!canOverride() ? 'Override not available for this deployment' : 'Allow deployment with justification'"
>
@if (loading() && currentAction() === 'allow_override') {
<span class="btn-spinner"></span>
Processing...
} @else {
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<polyline points="9 12 12 15 16 10"/>
</svg>
Allow (override)
}
</button>
<!-- Schedule canary button -->
<button
type="button"
class="action-btn action-btn--canary"
[disabled]="loading() || disabled()"
(click)="onAction('schedule_canary')"
[attr.aria-busy]="loading() && currentAction() === 'schedule_canary'"
>
@if (loading() && currentAction() === 'schedule_canary') {
<span class="btn-spinner"></span>
Scheduling...
} @else {
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
Schedule canary
}
</button>
</div>
<!-- Keyboard hints -->
<div class="action-bar__hints" aria-hidden="true">
<span class="hint">
<kbd>B</kbd> Block
</span>
<span class="hint">
<kbd>O</kbd> Override
</span>
<span class="hint">
<kbd>C</kbd> Canary
</span>
</div>
</footer>
`,
styles: [`
.action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--surface-secondary, #f9fafb);
border-top: 1px solid var(--border-default, #e5e7eb);
position: sticky;
bottom: 0;
}
/* Summary */
.action-bar__summary {
flex: 1;
}
.policy-summary {
display: flex;
align-items: center;
gap: 0.75rem;
}
.summary-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 4px;
&--pass {
background-color: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&--fail {
background-color: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
}
&--warn {
background-color: var(--color-warning-bg, #fef9c3);
color: var(--color-warning, #ca8a04);
}
}
/* Actions */
.action-bar__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: 2px;
}
&--block {
background-color: var(--color-error, #dc2626);
color: white;
&:hover:not(:disabled) {
background-color: var(--color-error-dark, #b91c1c);
}
}
&--override {
background-color: var(--color-warning, #ca8a04);
color: white;
&:hover:not(:disabled) {
background-color: var(--color-warning-dark, #a16207);
}
}
&--canary {
background-color: var(--color-info, #2563eb);
color: white;
&:hover:not(:disabled) {
background-color: var(--color-info-dark, #1d4ed8);
}
}
}
.btn-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Hints */
.action-bar__hints {
display: flex;
align-items: center;
gap: 0.75rem;
}
.hint {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--text-muted, #9ca3af);
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.25rem;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 0.625rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 3px;
box-shadow: 0 1px 0 var(--border-default, #e5e7eb);
}
}
/* Responsive */
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
gap: 0.75rem;
}
.action-bar__actions {
width: 100%;
flex-direction: column;
}
.action-btn {
width: 100%;
justify-content: center;
}
.action-bar__hints {
display: none;
}
}
`],
host: {
'(document:keydown.b)': 'onKeyboardShortcut($event, "block")',
'(document:keydown.o)': 'onKeyboardShortcut($event, "allow_override")',
'(document:keydown.c)': 'onKeyboardShortcut($event, "schedule_canary")',
},
})
export class DeployActionBarComponent {
/** Policy evaluation result */
readonly policyResult = input<PolicyResult | undefined>();
/** Loading state */
readonly loading = input<boolean>(false);
/** Disabled state */
readonly disabled = input<boolean>(false);
/** Emits when an action is clicked */
readonly actionClick = output<DeployActionType>();
/** Current action being processed */
readonly currentAction = signal<DeployActionType | null>(null);
/** Computed: whether override is available */
readonly canOverride = computed(() => {
const result = this.policyResult();
return result?.overrideAvailable ?? false;
});
/** Handle action button click */
onAction(action: DeployActionType): void {
if (this.loading() || this.disabled()) return;
if (action === 'allow_override' && !this.canOverride()) return;
this.currentAction.set(action);
this.actionClick.emit(action);
}
/** Handle keyboard shortcuts */
onKeyboardShortcut(event: KeyboardEvent, action: DeployActionType): void {
// Don't trigger if in input/textarea
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
event.preventDefault();
this.onAction(action);
}
}

View File

@@ -0,0 +1,323 @@
/**
* @file deploy-diff-panel.component.spec.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel (DD-010)
* @description Unit tests for deploy diff panel component.
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { DeployDiffPanelComponent } from './deploy-diff-panel.component';
import { DeployDiffService } from '../../services/deploy-diff.service';
import {
SbomDiffResult,
ComponentDiff,
PolicyHit,
PolicyResult,
} from '../../models/deploy-diff.models';
describe('DeployDiffPanelComponent', () => {
let fixture: ComponentFixture<DeployDiffPanelComponent>;
let component: DeployDiffPanelComponent;
let httpMock: HttpTestingController;
const mockComponentDiff: ComponentDiff = {
id: 'comp-1',
changeType: 'added',
name: 'lodash',
fromVersion: null,
toVersion: '4.17.21',
licenseChanged: false,
toLicense: 'MIT',
};
const mockPolicyHit: PolicyHit = {
id: 'hit-1',
gate: 'version-check',
severity: 'high',
result: 'fail',
message: 'Major version upgrade detected',
componentIds: ['comp-1'],
};
const mockPolicyResult: PolicyResult = {
allowed: false,
overrideAvailable: true,
failCount: 1,
warnCount: 0,
passCount: 5,
};
const mockDiffResult: SbomDiffResult = {
added: [mockComponentDiff],
removed: [],
changed: [
{
id: 'comp-2',
changeType: 'changed',
name: 'axios',
fromVersion: '0.21.0',
toVersion: '1.0.0',
licenseChanged: false,
versionChange: {
type: 'major',
description: 'Major version upgrade',
breaking: true,
},
},
],
unchanged: 50,
policyHits: [mockPolicyHit],
policyResult: mockPolicyResult,
metadata: {
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
fromLabel: 'v1.0.0',
toLabel: 'v2.0.0',
computedAt: '2026-01-25T10:00:00Z',
fromTotalComponents: 52,
toTotalComponents: 53,
},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DeployDiffPanelComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
DeployDiffService,
],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(DeployDiffPanelComponent);
component = fixture.componentInstance;
// Set required inputs
fixture.componentRef.setInput('fromDigest', 'sha256:abc123');
fixture.componentRef.setInput('toDigest', 'sha256:def456');
});
afterEach(() => {
httpMock.verify();
fixture.destroy();
});
describe('DD-008: Container assembly', () => {
it('renders header with version info', fakeAsync(() => {
fixture.detectChanges();
// Respond to diff request
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.diff-panel__header');
expect(header).toBeTruthy();
const title = header.querySelector('.header-title');
expect(title.textContent).toContain('Deployment Diff');
}));
it('shows summary strip with counts', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
const summary = fixture.nativeElement.querySelector('.diff-panel__summary');
expect(summary).toBeTruthy();
expect(summary.textContent).toContain('1');
expect(summary.textContent).toContain('added');
expect(summary.textContent).toContain('policy failure');
}));
it('integrates side-by-side viewer', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
const viewer = fixture.nativeElement.querySelector('app-sbom-side-by-side');
expect(viewer).toBeTruthy();
}));
it('shows action bar at bottom', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
const actionBar = fixture.nativeElement.querySelector('app-deploy-action-bar');
expect(actionBar).toBeTruthy();
}));
});
describe('Loading state', () => {
it('shows loading skeleton initially', () => {
fixture.detectChanges();
const skeleton = fixture.nativeElement.querySelector('.loading-skeleton');
expect(skeleton).toBeTruthy();
// Don't flush HTTP yet - check loading state
httpMock.expectOne('/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456');
});
it('hides loading skeleton after data loads', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
const skeleton = fixture.nativeElement.querySelector('.loading-skeleton');
expect(skeleton).toBeFalsy();
}));
});
describe('Error state', () => {
it('shows error state on API failure', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush({ message: 'Not found' }, { status: 404, statusText: 'Not Found' });
tick();
fixture.detectChanges();
const errorState = fixture.nativeElement.querySelector('.error-state');
expect(errorState).toBeTruthy();
expect(errorState.textContent).toContain('Failed to load diff');
}));
it('shows retry button on error', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush({ message: 'Error' }, { status: 500, statusText: 'Server Error' });
tick();
fixture.detectChanges();
const retryBtn = fixture.nativeElement.querySelector('.retry-btn');
expect(retryBtn).toBeTruthy();
expect(retryBtn.textContent).toContain('Retry');
}));
});
describe('Action handling', () => {
it('opens override dialog on allow_override click', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
// Trigger action
component.onActionClick('allow_override');
fixture.detectChanges();
expect(component.showOverrideDialog()).toBeTrue();
}));
it('emits actionTaken on block', fakeAsync(() => {
let emittedAction: any = null;
component.actionTaken.subscribe(a => (emittedAction = a));
fixture.detectChanges();
const diffReq = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
diffReq.flush(mockDiffResult);
tick();
fixture.detectChanges();
// Trigger block action
component.onActionClick('block');
tick();
const blockReq = httpMock.expectOne('/api/v1/deploy/block');
blockReq.flush({
type: 'block',
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
timestamp: '2026-01-25T10:00:00Z',
});
tick();
expect(emittedAction).toBeTruthy();
expect(emittedAction.type).toBe('block');
}));
});
describe('Expand/collapse', () => {
it('toggles expanded component', fakeAsync(() => {
fixture.detectChanges();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
fixture.detectChanges();
expect(component.expandedComponentId()).toBeUndefined();
component.onExpandToggle('comp-1');
expect(component.expandedComponentId()).toBe('comp-1');
component.onExpandToggle('comp-1');
expect(component.expandedComponentId()).toBeUndefined();
}));
});
describe('Refresh', () => {
it('clears cache and refetches on refresh', fakeAsync(() => {
fixture.detectChanges();
const req1 = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req1.flush(mockDiffResult);
tick();
fixture.detectChanges();
// Refresh
component.refresh();
tick();
const req2 = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
expect(req2).toBeTruthy();
req2.flush(mockDiffResult);
}));
});
});

View File

@@ -0,0 +1,633 @@
/**
* @file deploy-diff-panel.component.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel (DD-008)
* @description Main container for deploy diff panel with all sub-components.
*/
import {
Component,
computed,
input,
output,
signal,
inject,
OnInit,
effect,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
SbomDiffResult,
DeployAction,
DeployActionType,
OverrideDetails,
SignerIdentity,
computeDiffSummary,
DiffSummary,
} from '../../models/deploy-diff.models';
import { DeployDiffService } from '../../services/deploy-diff.service';
import { SbomSideBySideComponent } from '../sbom-side-by-side/sbom-side-by-side.component';
import { DeployActionBarComponent } from '../deploy-action-bar/deploy-action-bar.component';
import { OverrideDialogComponent } from '../override-dialog/override-dialog.component';
/**
* Deploy diff panel container component.
*
* Features:
* - Header with version labels
* - Summary strip with counts
* - Side-by-side SBOM viewer with policy annotations
* - Action bar (sticky footer)
* - Loading/error states
*
* @example
* <app-deploy-diff-panel
* [fromDigest]="currentDigest"
* [toDigest]="newDigest"
* (actionTaken)="onActionTaken($event)"
* />
*/
@Component({
selector: 'app-deploy-diff-panel',
standalone: true,
imports: [
CommonModule,
SbomSideBySideComponent,
DeployActionBarComponent,
OverrideDialogComponent,
],
template: `
<div class="diff-panel">
<!-- Header -->
<header class="diff-panel__header">
<div class="header-content">
<h2 class="header-title">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 3h5v5M8 3H3v5M3 16v5h5M21 16v5h-5"/>
<path d="M21 3l-9.5 9.5M3 21l9.5-9.5"/>
</svg>
Deployment Diff: A vs B
</h2>
<div class="header-labels">
<span class="version-label version-label--from">
{{ fromLabel() || fromDigest().slice(0, 12) }}
</span>
<span class="arrow">→</span>
<span class="version-label version-label--to">
{{ toLabel() || toDigest().slice(0, 12) }}
</span>
</div>
</div>
<!-- Actions -->
<div class="header-actions">
<button
type="button"
class="refresh-btn"
(click)="refresh()"
[disabled]="loading()"
title="Refresh diff"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
[class.spinning]="loading()"
>
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
</svg>
</button>
</div>
</header>
<!-- Summary strip -->
@if (summary(); as s) {
<div class="diff-panel__summary">
<span class="summary-item summary-item--added">
<strong>{{ s.addedCount }}</strong> added
</span>
<span class="summary-item summary-item--removed">
<strong>{{ s.removedCount }}</strong> removed
</span>
<span class="summary-item summary-item--changed">
<strong>{{ s.changedCount }}</strong> changed
</span>
@if (s.policyFailures > 0) {
<span class="summary-item summary-item--failures">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
<strong>{{ s.policyFailures }}</strong> policy failure{{ s.policyFailures !== 1 ? 's' : '' }}
</span>
}
@if (s.policyWarnings > 0) {
<span class="summary-item summary-item--warnings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<strong>{{ s.policyWarnings }}</strong> warning{{ s.policyWarnings !== 1 ? 's' : '' }}
</span>
}
</div>
}
<!-- Content area -->
<div class="diff-panel__content">
@if (loading()) {
<!-- Loading skeleton -->
<div class="loading-skeleton">
<div class="skeleton-header">
<div class="skeleton-bar skeleton-bar--wide"></div>
<div class="skeleton-bar skeleton-bar--narrow"></div>
</div>
@for (i of [1,2,3,4,5]; track i) {
<div class="skeleton-row">
<div class="skeleton-bar skeleton-bar--medium"></div>
<div class="skeleton-bar skeleton-bar--short"></div>
<div class="skeleton-bar skeleton-bar--medium"></div>
</div>
}
</div>
} @else if (error(); as errorMsg) {
<!-- Error state -->
<div class="error-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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>
<h3>Failed to load diff</h3>
<p>{{ errorMsg }}</p>
<button type="button" class="retry-btn" (click)="refresh()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
</svg>
Retry
</button>
</div>
} @else if (diffResult()) {
<!-- Side-by-side viewer -->
<app-sbom-side-by-side
[diffResult]="diffResult()!"
[expandedId]="expandedComponentId()"
(expandToggle)="onExpandToggle($event)"
/>
}
</div>
<!-- Action bar -->
@if (diffResult()) {
<app-deploy-action-bar
[policyResult]="diffResult()!.policyResult"
[loading]="actionLoading()"
[disabled]="loading()"
(actionClick)="onActionClick($event)"
/>
}
<!-- Override dialog -->
<app-override-dialog
[open]="showOverrideDialog()"
[signer]="currentSigner()"
[policyHits]="failingHits()"
[loading]="actionLoading()"
(confirm)="onOverrideConfirm($event)"
(cancel)="onOverrideCancel()"
/>
</div>
`,
styles: [`
.diff-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
/* Header */
.diff-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: var(--surface-primary, #ffffff);
border-bottom: 1px solid var(--border-default, #e5e7eb);
}
.header-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.header-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary, #111827);
svg {
color: var(--color-primary, #2563eb);
}
}
.header-labels {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.version-label {
padding: 0.25rem 0.5rem;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 0.75rem;
border-radius: 4px;
&--from {
background: var(--surface-secondary, #f3f4f6);
color: var(--text-secondary, #6b7280);
}
&--to {
background: var(--color-primary-bg, #dbeafe);
color: var(--color-primary, #2563eb);
}
}
.arrow {
color: var(--text-muted, #9ca3af);
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.refresh-btn {
padding: 0.5rem;
background: transparent;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
color: var(--text-secondary, #6b7280);
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
background: var(--surface-hover, #f3f4f6);
color: var(--text-primary, #111827);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg.spinning {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Summary strip */
.diff-panel__summary {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.625rem 1.25rem;
background: var(--surface-secondary, #f9fafb);
border-bottom: 1px solid var(--border-default, #e5e7eb);
}
.summary-item {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
strong {
font-weight: 600;
}
&--added {
color: var(--color-success, #16a34a);
}
&--removed {
color: var(--color-error, #dc2626);
}
&--changed {
color: var(--color-warning, #ca8a04);
}
&--failures {
color: var(--color-error, #dc2626);
}
&--warnings {
color: var(--color-warning, #ca8a04);
}
}
/* Content */
.diff-panel__content {
flex: 1;
overflow: auto;
}
/* Loading skeleton */
.loading-skeleton {
padding: 1rem;
}
.skeleton-header {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.skeleton-row {
display: flex;
gap: 1rem;
margin-bottom: 0.75rem;
}
.skeleton-bar {
height: 1rem;
background: linear-gradient(90deg, var(--surface-secondary) 25%, var(--surface-tertiary) 50%, var(--surface-secondary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
&--wide { width: 200px; }
&--medium { width: 120px; }
&--narrow { width: 80px; }
&--short { width: 60px; }
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Error state */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem 1rem;
text-align: center;
svg {
color: var(--color-error, #dc2626);
opacity: 0.5;
}
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
p {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary, #6b7280);
max-width: 400px;
}
}
.retry-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-link, #2563eb);
background: transparent;
border: 1px solid var(--color-primary, #2563eb);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--color-primary-bg, #dbeafe);
}
}
`],
})
export class DeployDiffPanelComponent implements OnInit {
private readonly diffService = inject(DeployDiffService);
/** From version digest */
readonly fromDigest = input.required<string>();
/** To version digest */
readonly toDigest = input.required<string>();
/** Optional from version label */
readonly fromLabel = input<string | undefined>();
/** Optional to version label */
readonly toLabel = input<string | undefined>();
/** Current signer identity (for override) */
readonly currentSigner = input<SignerIdentity | undefined>();
/** Emits when an action is taken */
readonly actionTaken = output<DeployAction>();
/** Loading state */
readonly loading = signal<boolean>(false);
/** Error state */
readonly error = signal<string | null>(null);
/** Diff result */
readonly diffResult = signal<SbomDiffResult | null>(null);
/** Currently expanded component ID */
readonly expandedComponentId = signal<string | undefined>(undefined);
/** Action loading state */
readonly actionLoading = signal<boolean>(false);
/** Show override dialog */
readonly showOverrideDialog = signal<boolean>(false);
/** Computed: diff summary */
readonly summary = computed<DiffSummary | null>(() => {
const result = this.diffResult();
return result ? computeDiffSummary(result) : null;
});
/** Computed: failing policy hits */
readonly failingHits = computed(() => {
const result = this.diffResult();
return result?.policyHits.filter(h => h.result === 'fail') ?? [];
});
constructor() {
// Fetch diff when digests change
effect(() => {
const from = this.fromDigest();
const to = this.toDigest();
if (from && to) {
this.fetchDiff(from, to);
}
});
}
ngOnInit(): void {
// Initial fetch handled by effect
}
/** Fetch diff data */
async fetchDiff(from: string, to: string): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const result = await this.diffService.fetchDiff({ fromDigest: from, toDigest: to });
this.diffResult.set(result);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to fetch diff');
this.diffResult.set(null);
} finally {
this.loading.set(false);
}
}
/** Refresh diff */
refresh(): void {
this.diffService.clearCache();
this.fetchDiff(this.fromDigest(), this.toDigest());
}
/** Handle expand toggle */
onExpandToggle(id: string): void {
this.expandedComponentId.update(current =>
current === id ? undefined : id
);
}
/** Handle action click */
async onActionClick(action: DeployActionType): Promise<void> {
if (action === 'allow_override') {
this.showOverrideDialog.set(true);
return;
}
if (action === 'block') {
await this.executeBlock();
} else if (action === 'schedule_canary') {
await this.executeCanary();
}
}
/** Execute block action */
private async executeBlock(): Promise<void> {
this.actionLoading.set(true);
try {
const result = await this.diffService.blockDeployment(
this.fromDigest(),
this.toDigest()
);
this.actionTaken.emit(result);
} catch (err) {
console.error('Failed to block deployment:', err);
} finally {
this.actionLoading.set(false);
}
}
/** Execute canary action */
private async executeCanary(): Promise<void> {
this.actionLoading.set(true);
try {
const result = await this.diffService.scheduleCanary(
this.fromDigest(),
this.toDigest(),
{
initialPercent: 5,
stepPercent: 10,
stepIntervalMinutes: 15,
successCriteria: {
maxErrorRate: 1,
maxLatencyP99Ms: 500,
},
}
);
this.actionTaken.emit(result);
} catch (err) {
console.error('Failed to schedule canary:', err);
} finally {
this.actionLoading.set(false);
}
}
/** Handle override confirm */
async onOverrideConfirm(details: OverrideDetails): Promise<void> {
this.actionLoading.set(true);
try {
const result = await this.diffService.submitOverride(
this.fromDigest(),
this.toDigest(),
details
);
this.showOverrideDialog.set(false);
this.actionTaken.emit(result);
// Emit telemetry
this.emitTelemetry('policy.override.saved', {
fromDigest: this.fromDigest(),
toDigest: this.toDigest(),
overriddenCount: details.overriddenHits.length,
});
} catch (err) {
console.error('Failed to submit override:', err);
} finally {
this.actionLoading.set(false);
}
}
/** Handle override cancel */
onOverrideCancel(): void {
this.showOverrideDialog.set(false);
}
/** Emit telemetry event */
private emitTelemetry(event: string, properties: Record<string, unknown>): void {
// In production, this would call a telemetry service
console.log('[Telemetry]', event, properties);
}
}

View File

@@ -0,0 +1,12 @@
/**
* @file index.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel
* @description Public API for deploy-diff components.
*/
export * from './component-diff-row/component-diff-row.component';
export * from './policy-hit-annotation/policy-hit-annotation.component';
export * from './sbom-side-by-side/sbom-side-by-side.component';
export * from './deploy-action-bar/deploy-action-bar.component';
export * from './override-dialog/override-dialog.component';
export * from './deploy-diff-panel/deploy-diff-panel.component';

View File

@@ -0,0 +1,620 @@
/**
* @file override-dialog.component.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel (DD-007)
* @description Policy override dialog with justification requirement.
*/
import {
Component,
computed,
input,
output,
signal,
effect,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
OverrideDetails,
SignerIdentity,
PolicyHit,
} from '../../models/deploy-diff.models';
/**
* Override dialog component.
*
* Features:
* - Warning microcopy about audit trail
* - Required reason textarea (min 20 chars)
* - Optional JIRA/ticket link field
* - Signer identity preview
* - Confirm/Cancel buttons
*
* @example
* <app-override-dialog
* [open]="showDialog"
* [signer]="currentUser"
* [policyHits]="failedHits"
* (confirm)="onConfirm($event)"
* (cancel)="onCancel()"
* />
*/
@Component({
selector: 'app-override-dialog',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
@if (open()) {
<div
class="dialog-backdrop"
(click)="onBackdropClick($event)"
role="dialog"
aria-modal="true"
aria-labelledby="override-dialog-title"
>
<div class="dialog" (click)="$event.stopPropagation()">
<!-- Header -->
<header class="dialog__header">
<h2 id="override-dialog-title" class="dialog__title">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<path d="M12 8v4M12 16h.01"/>
</svg>
Policy Override
</h2>
<button
type="button"
class="close-btn"
(click)="onCancel()"
aria-label="Close dialog"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</header>
<!-- Warning -->
<div class="dialog__warning">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<p>
Override must include justification and will be recorded in audit log (signed).
</p>
</div>
<!-- Content -->
<div class="dialog__content">
<!-- Policy hits being overridden -->
@if (policyHits()?.length) {
<div class="form-group">
<label class="form-label">Overriding {{ policyHits()!.length }} policy failure(s):</label>
<ul class="policy-list">
@for (hit of policyHits(); track hit.id) {
<li class="policy-item">
<span class="policy-gate">{{ hit.gate }}</span>
<span class="policy-message">{{ hit.message }}</span>
</li>
}
</ul>
</div>
}
<!-- Reason field -->
<div class="form-group">
<label class="form-label" for="override-reason">
Justification <span class="required">*</span>
</label>
<textarea
id="override-reason"
class="form-textarea"
[class.invalid]="showReasonError()"
[(ngModel)]="reason"
(ngModelChange)="onReasonChange()"
placeholder="Explain why this override is necessary (minimum 20 characters)..."
rows="4"
required
minlength="20"
></textarea>
@if (showReasonError()) {
<span class="form-error">
Justification must be at least 20 characters ({{ reason().length }}/20)
</span>
}
<span class="form-hint">
{{ reason().length }} characters
</span>
</div>
<!-- Ticket link field -->
<div class="form-group">
<label class="form-label" for="override-ticket">
Ticket/Issue Link <span class="optional">(optional)</span>
</label>
<input
id="override-ticket"
type="url"
class="form-input"
[(ngModel)]="ticketUrl"
placeholder="https://jira.example.com/browse/PROJ-123"
/>
<span class="form-hint">
Link to related JIRA ticket, GitHub issue, or other tracking system
</span>
</div>
<!-- Signer info -->
@if (signer(); as user) {
<div class="signer-preview">
<h3 class="signer-title">Signed by:</h3>
<div class="signer-info">
<div class="signer-avatar">
{{ getInitials(user.displayName) }}
</div>
<div class="signer-details">
<span class="signer-name">{{ user.displayName }}</span>
<span class="signer-email">{{ user.email }}</span>
</div>
<div class="signer-timestamp">
{{ currentTimestamp() }}
</div>
</div>
</div>
}
</div>
<!-- Footer -->
<footer class="dialog__footer">
<button
type="button"
class="btn btn--secondary"
(click)="onCancel()"
[disabled]="loading()"
>
Cancel
</button>
<button
type="button"
class="btn btn--primary"
(click)="onConfirm()"
[disabled]="!isValid() || loading()"
>
@if (loading()) {
<span class="btn-spinner"></span>
Processing...
} @else {
Confirm Override
}
</button>
</footer>
</div>
</div>
}
`,
styles: [`
.dialog-backdrop {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.dialog {
width: 100%;
max-width: 560px;
max-height: 90vh;
overflow-y: auto;
background: var(--surface-primary, #ffffff);
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* Header */
.dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-default, #e5e7eb);
}
.dialog__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary, #111827);
svg {
color: var(--color-warning, #ca8a04);
}
}
.close-btn {
padding: 0.375rem;
background: transparent;
border: none;
color: var(--text-muted, #9ca3af);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--surface-hover, #f3f4f6);
color: var(--text-primary, #111827);
}
}
/* Warning */
.dialog__warning {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1.25rem;
background: var(--color-warning-bg, #fef9c3);
color: var(--color-warning-dark, #854d0e);
font-size: 0.875rem;
line-height: 1.5;
svg {
flex-shrink: 0;
margin-top: 0.125rem;
}
p {
margin: 0;
}
}
/* Content */
.dialog__content {
padding: 1.25rem;
}
.form-group {
margin-bottom: 1.25rem;
&:last-child {
margin-bottom: 0;
}
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #111827);
}
.required {
color: var(--color-error, #dc2626);
}
.optional {
font-weight: 400;
color: var(--text-muted, #9ca3af);
}
.form-textarea,
.form-input {
width: 100%;
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
color: var(--text-primary, #111827);
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #d1d5db);
border-radius: 6px;
transition: border-color 0.15s, box-shadow 0.15s;
&:focus {
outline: none;
border-color: var(--color-primary, #2563eb);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
&.invalid {
border-color: var(--color-error, #dc2626);
&:focus {
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
}
}
&::placeholder {
color: var(--text-muted, #9ca3af);
}
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-error {
display: block;
margin-top: 0.375rem;
font-size: 0.75rem;
color: var(--color-error, #dc2626);
}
.form-hint {
display: block;
margin-top: 0.375rem;
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
/* Policy list */
.policy-list {
margin: 0;
padding: 0;
list-style: none;
}
.policy-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
background: var(--color-error-bg, #fee2e2);
border-radius: 4px;
margin-bottom: 0.375rem;
&:last-child {
margin-bottom: 0;
}
}
.policy-gate {
font-weight: 600;
color: var(--color-error, #dc2626);
}
.policy-message {
color: var(--text-secondary, #4b5563);
}
/* Signer preview */
.signer-preview {
padding: 1rem;
background: var(--surface-secondary, #f9fafb);
border-radius: 8px;
}
.signer-title {
margin: 0 0 0.75rem;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #9ca3af);
}
.signer-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.signer-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
font-size: 0.875rem;
font-weight: 600;
color: white;
background: var(--color-primary, #2563eb);
border-radius: 50%;
}
.signer-details {
flex: 1;
}
.signer-name {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #111827);
}
.signer-email {
display: block;
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
}
.signer-timestamp {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
/* Footer */
.dialog__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: var(--surface-secondary, #f9fafb);
border-top: 1px solid var(--border-default, #e5e7eb);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--secondary {
background: var(--surface-primary, #ffffff);
color: var(--text-secondary, #6b7280);
border: 1px solid var(--border-default, #d1d5db);
&:hover:not(:disabled) {
background: var(--surface-hover, #f3f4f6);
}
}
&--primary {
background: var(--color-warning, #ca8a04);
color: white;
&:hover:not(:disabled) {
background: var(--color-warning-dark, #a16207);
}
}
}
.btn-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`],
host: {
'(document:keydown.escape)': 'onEscapeKey()',
},
})
export class OverrideDialogComponent {
/** Whether the dialog is open */
readonly open = input<boolean>(false);
/** Current signer identity */
readonly signer = input<SignerIdentity | undefined>();
/** Policy hits being overridden */
readonly policyHits = input<PolicyHit[] | undefined>();
/** Loading state */
readonly loading = input<boolean>(false);
/** Emits when override is confirmed */
readonly confirm = output<OverrideDetails>();
/** Emits when dialog is cancelled */
readonly cancel = output<void>();
/** Justification reason */
readonly reason = signal<string>('');
/** Optional ticket URL */
readonly ticketUrl = signal<string>('');
/** Whether reason has been touched */
readonly reasonTouched = signal<boolean>(false);
/** Minimum reason length */
private readonly minReasonLength = 20;
/** Computed: current timestamp */
readonly currentTimestamp = computed(() => {
return new Date().toISOString();
});
/** Computed: whether form is valid */
readonly isValid = computed(() => {
return this.reason().trim().length >= this.minReasonLength;
});
/** Computed: whether to show reason error */
readonly showReasonError = computed(() => {
return this.reasonTouched() && this.reason().trim().length < this.minReasonLength;
});
constructor() {
// Reset form when dialog opens
effect(() => {
if (this.open()) {
this.reason.set('');
this.ticketUrl.set('');
this.reasonTouched.set(false);
}
});
}
/** Handle reason change */
onReasonChange(): void {
this.reasonTouched.set(true);
}
/** Handle confirm */
onConfirm(): void {
if (!this.isValid() || this.loading()) return;
const signer = this.signer();
if (!signer) return;
const details: OverrideDetails = {
reason: this.reason().trim(),
ticketUrl: this.ticketUrl().trim() || undefined,
signer,
overriddenHits: this.policyHits()?.map(h => h.id) ?? [],
};
this.confirm.emit(details);
}
/** Handle cancel */
onCancel(): void {
if (this.loading()) return;
this.cancel.emit();
}
/** Handle escape key */
onEscapeKey(): void {
if (this.open() && !this.loading()) {
this.onCancel();
}
}
/** Handle backdrop click */
onBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget && !this.loading()) {
this.onCancel();
}
}
/** Get user initials */
getInitials(name: string): string {
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
}

View File

@@ -0,0 +1,370 @@
/**
* @file policy-hit-annotation.component.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel (DD-005)
* @description Inline policy hit annotation for component diff rows.
*/
import { Component, computed, input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import {
ComponentEvidenceStatus,
PolicyHit,
getPolicySeverityClass,
} from '../../models/deploy-diff.models';
/**
* Inline policy hit annotation component.
*
* Features:
* - Evidence pills: DSSE, Rekor, VEX status
* - Policy gate result badge (Pass/Fail/Warn)
* - Tooltip with gate details
* - Click links to policy details
*
* @example
* <app-policy-hit-annotation
* [evidence]="component.evidence"
* [policyHits]="policyHits"
* />
*/
@Component({
selector: 'app-policy-hit-annotation',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="policy-annotation" role="group" aria-label="Policy and evidence status">
<!-- Evidence pills -->
<div class="evidence-pills">
<!-- DSSE status -->
@if (evidence(); as ev) {
<span
class="evidence-pill"
[class.valid]="ev.dsse.valid"
[class.invalid]="!ev.dsse.valid"
[title]="ev.dsse.message || (ev.dsse.valid ? 'DSSE signature valid' : 'DSSE signature missing or invalid')"
>
DSSE
@if (ev.dsse.valid) {
<svg class="pill-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
} @else {
<svg class="pill-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
}
</span>
<!-- Rekor status -->
<span
class="evidence-pill"
[class.valid]="ev.rekor.valid"
[class.invalid]="!ev.rekor.valid"
[title]="ev.rekor.timestamp ? 'Rekor entry: ' + ev.rekor.timestamp : (ev.rekor.valid ? 'Rekor inclusion verified' : 'No Rekor entry')"
>
Rekor
@if (ev.rekor.valid) {
<svg class="pill-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
} @else {
<svg class="pill-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
}
</span>
<!-- VEX status -->
@if (ev.vex) {
<span
class="evidence-pill vex-pill"
[class]="'vex-' + ev.vex.status"
[title]="ev.vex.justification || vexStatusLabel(ev.vex.status)"
>
VEX: {{ vexStatusLabel(ev.vex.status) }}
</span>
}
}
</div>
<!-- Policy gate results -->
@if (policyHits()?.length) {
<div class="policy-hits">
@for (hit of visibleHits(); track hit.id) {
<a
class="policy-badge"
[class]="getPolicyClass(hit)"
[routerLink]="['/policy/gates', hit.gate]"
[title]="hit.message"
>
<span class="policy-result">{{ hit.result | uppercase }}</span>
<span class="policy-gate">{{ hit.gate }}</span>
</a>
}
@if (hiddenHitsCount() > 0) {
<button
type="button"
class="more-hits-btn"
(click)="showAllHits.set(true)"
>
+{{ hiddenHitsCount() }} more
</button>
}
</div>
}
<!-- Overall status indicator -->
@if (overallStatus(); as status) {
<div class="overall-status" [class]="'status-' + status">
@switch (status) {
@case ('pass') {
<svg class="status-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span class="status-text">All gates pass</span>
}
@case ('fail') {
<svg class="status-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
<span class="status-text">Policy failed</span>
}
@case ('warn') {
<svg class="status-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span class="status-text">Warnings</span>
}
}
</div>
}
</div>
`,
styles: [`
.policy-annotation {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
/* Evidence pills */
.evidence-pills {
display: flex;
align-items: center;
gap: 0.375rem;
}
.evidence-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
font-weight: 500;
border-radius: 20px;
white-space: nowrap;
&.valid {
background-color: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&.invalid {
background-color: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
}
}
.pill-icon {
width: 12px;
height: 12px;
}
/* VEX status variants */
.vex-pill {
&.vex-not_affected {
background-color: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&.vex-affected {
background-color: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
}
&.vex-fixed {
background-color: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
}
&.vex-under_investigation {
background-color: var(--color-warning-bg, #fef9c3);
color: var(--color-warning, #ca8a04);
}
&.vex-unknown {
background-color: var(--surface-secondary, #f3f4f6);
color: var(--text-secondary, #6b7280);
}
}
/* Policy hits */
.policy-hits {
display: flex;
align-items: center;
gap: 0.375rem;
}
.policy-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
font-weight: 500;
text-decoration: none;
border-radius: 4px;
transition: opacity 0.15s;
&:hover {
opacity: 0.8;
}
&.policy-pass {
background-color: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&.policy-fail {
background-color: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
}
&.policy-warn {
background-color: var(--color-warning-bg, #fef9c3);
color: var(--color-warning, #ca8a04);
}
}
.policy-result {
font-weight: 600;
}
.policy-gate {
opacity: 0.8;
}
.more-hits-btn {
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
color: var(--text-link, #2563eb);
background: transparent;
border: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
/* Overall status */
.overall-status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 4px;
&.status-pass {
background-color: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
&.status-fail {
background-color: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
}
&.status-warn {
background-color: var(--color-warning-bg, #fef9c3);
color: var(--color-warning, #ca8a04);
}
}
.status-icon {
width: 14px;
height: 14px;
}
`],
})
export class PolicyHitAnnotationComponent {
/** Evidence status for the component */
readonly evidence = input<ComponentEvidenceStatus | undefined>();
/** Policy hits for the component */
readonly policyHits = input<PolicyHit[] | undefined>();
/** Show all hits (vs truncated) */
readonly showAllHits = signal<boolean>(false);
/** Max visible hits before truncation */
private readonly maxVisibleHits = 3;
/** Computed: visible hits */
readonly visibleHits = computed(() => {
const hits = this.policyHits();
if (!hits) return [];
if (this.showAllHits()) return hits;
return hits.slice(0, this.maxVisibleHits);
});
/** Computed: hidden hits count */
readonly hiddenHitsCount = computed(() => {
const hits = this.policyHits();
if (!hits || this.showAllHits()) return 0;
return Math.max(0, hits.length - this.maxVisibleHits);
});
/** Computed: overall status */
readonly overallStatus = computed<'pass' | 'fail' | 'warn' | null>(() => {
const hits = this.policyHits();
if (!hits || hits.length === 0) return null;
if (hits.some(h => h.result === 'fail')) return 'fail';
if (hits.some(h => h.result === 'warn')) return 'warn';
return 'pass';
});
/** Get policy badge CSS class */
getPolicyClass(hit: PolicyHit): string {
return `policy-${hit.result}`;
}
/** Get VEX status label */
vexStatusLabel(status: string): string {
switch (status) {
case 'not_affected':
return 'Not Affected';
case 'affected':
return 'Affected';
case 'fixed':
return 'Fixed';
case 'under_investigation':
return 'Investigating';
default:
return 'Unknown';
}
}
}

View File

@@ -0,0 +1,629 @@
/**
* @file sbom-side-by-side.component.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel (DD-003)
* @description Side-by-side SBOM viewer with synchronized scrolling.
*/
import {
Component,
computed,
input,
output,
signal,
effect,
ElementRef,
viewChild,
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import {
SbomDiffResult,
ComponentDiff,
PolicyHit,
} from '../../models/deploy-diff.models';
import { ComponentDiffRowComponent } from '../component-diff-row/component-diff-row.component';
import { PolicyHitAnnotationComponent } from '../policy-hit-annotation/policy-hit-annotation.component';
/**
* Unified row for virtual scroll - represents either a component or a placeholder.
*/
interface UnifiedRow {
readonly id: string;
readonly type: 'component' | 'placeholder';
readonly changeType: 'added' | 'removed' | 'changed';
readonly component?: ComponentDiff;
readonly side: 'left' | 'right' | 'both';
}
/**
* Side-by-side SBOM viewer component.
*
* Features:
* - Two-column layout (Version A / Version B)
* - Synchronized scrolling between columns
* - Component rows aligned when matching
* - Visual indicators for added/removed/changed
* - Virtual scroll for large SBOMs (>500 components)
*
* @example
* <app-sbom-side-by-side
* [diffResult]="diff"
* [expandedId]="expandedComponentId"
* (expandToggle)="onExpandToggle($event)"
* />
*/
@Component({
selector: 'app-sbom-side-by-side',
standalone: true,
imports: [
CommonModule,
ScrollingModule,
ComponentDiffRowComponent,
PolicyHitAnnotationComponent,
],
template: `
<div class="sbom-side-by-side">
<!-- Headers -->
<div class="sbom-headers">
<div class="sbom-header sbom-header--left">
<h3 class="header-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 8 12 12 14 14"/>
</svg>
Version A (Current)
</h3>
@if (metadata()?.fromLabel) {
<span class="header-label">{{ metadata()!.fromLabel }}</span>
}
<span class="header-count">{{ metadata()?.fromTotalComponents ?? 0 }} components</span>
</div>
<div class="sbom-header sbom-header--right">
<h3 class="header-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 8 12 12 14 14"/>
</svg>
Version B (New)
</h3>
@if (metadata()?.toLabel) {
<span class="header-label">{{ metadata()!.toLabel }}</span>
}
<span class="header-count">{{ metadata()?.toTotalComponents ?? 0 }} components</span>
</div>
</div>
<!-- Content area -->
<div class="sbom-content" #contentArea>
@if (useVirtualScroll()) {
<!-- Virtual scroll for large SBOMs -->
<cdk-virtual-scroll-viewport
class="sbom-viewport"
[itemSize]="rowHeight"
#viewport
>
<div
*cdkVirtualFor="let row of unifiedRows(); trackBy: trackRow"
class="unified-row"
[class]="'row-' + row.changeType"
>
<!-- Left side (old version) -->
<div class="row-pane row-pane--left">
@if (row.side === 'left' || row.side === 'both') {
@if (row.component) {
<div class="component-cell" [class.removed]="row.changeType === 'removed'">
<span class="component-name">{{ row.component.name }}</span>
<span class="component-version">{{ row.component.fromVersion }}</span>
</div>
}
} @else {
<div class="placeholder-cell">
<span class="placeholder-line"></span>
</div>
}
</div>
<!-- Right side (new version) -->
<div class="row-pane row-pane--right">
@if (row.side === 'right' || row.side === 'both') {
@if (row.component) {
<div class="component-cell" [class.added]="row.changeType === 'added'">
<span class="component-name">{{ row.component.name }}</span>
<span class="component-version">{{ row.component.toVersion }}</span>
@if (row.component.evidence || row.component.policyHits?.length) {
<app-policy-hit-annotation
[evidence]="row.component.evidence"
[policyHits]="row.component.policyHits"
/>
}
</div>
}
} @else {
<div class="placeholder-cell">
<span class="placeholder-line"></span>
</div>
}
</div>
</div>
</cdk-virtual-scroll-viewport>
} @else {
<!-- Regular scroll for small SBOMs -->
<div class="sbom-rows">
@for (row of unifiedRows(); track row.id) {
<div
class="unified-row"
[class]="'row-' + row.changeType"
[class.expanded]="expandedId() === row.id"
>
<!-- Left side -->
<div class="row-pane row-pane--left">
@if ((row.side === 'left' || row.side === 'both') && row.component) {
<div
class="component-cell clickable"
[class.removed]="row.changeType === 'removed'"
(click)="onRowClick(row)"
>
<div class="component-main">
<span class="component-name">{{ row.component.name }}</span>
<span class="component-version">{{ row.component.fromVersion || '--' }}</span>
</div>
@if (row.component.fromLicense) {
<span class="component-license">{{ row.component.fromLicense }}</span>
}
</div>
} @else {
<div class="placeholder-cell">
<span class="placeholder-line"></span>
</div>
}
</div>
<!-- Diff indicator -->
<div class="diff-indicator">
@switch (row.changeType) {
@case ('added') {
<span class="diff-badge diff-badge--added" title="Added">+</span>
}
@case ('removed') {
<span class="diff-badge diff-badge--removed" title="Removed">-</span>
}
@case ('changed') {
<span class="diff-badge diff-badge--changed" title="Changed">~</span>
}
}
</div>
<!-- Right side -->
<div class="row-pane row-pane--right">
@if ((row.side === 'right' || row.side === 'both') && row.component) {
<div
class="component-cell clickable"
[class.added]="row.changeType === 'added'"
(click)="onRowClick(row)"
>
<div class="component-main">
<span class="component-name">{{ row.component.name }}</span>
<span class="component-version">{{ row.component.toVersion || '--' }}</span>
</div>
@if (row.component.toLicense) {
<span class="component-license">{{ row.component.toLicense }}</span>
}
@if (row.component.evidence || row.component.policyHits?.length) {
<app-policy-hit-annotation
[evidence]="row.component.evidence"
[policyHits]="row.component.policyHits"
/>
}
</div>
} @else {
<div class="placeholder-cell">
<span class="placeholder-line"></span>
</div>
}
</div>
</div>
<!-- Expanded details -->
@if (expandedId() === row.id && row.component) {
<div class="expanded-details">
<app-component-diff-row
[component]="row.component"
[expanded]="true"
(toggleExpand)="onToggleExpand($event)"
/>
</div>
}
}
</div>
}
</div>
<!-- Empty state -->
@if (!hasComponents()) {
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14.5 10c-.83 0-1.5-.67-1.5-1.5v-5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5z"/>
<path d="M20.5 10H19V8.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>
<path d="M9.5 14c.83 0 1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5S8 21.33 8 20.5v-5c0-.83.67-1.5 1.5-1.5z"/>
<path d="M3.5 14H5v1.5c0 .83-.67 1.5-1.5 1.5S2 16.33 2 15.5 2.67 14 3.5 14z"/>
<path d="M14 14.5c0-.83.67-1.5 1.5-1.5h5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-5c-.83 0-1.5-.67-1.5-1.5z"/>
<path d="M14 19.5c0-.83.67-1.5 1.5-1.5H17v1.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5z"/>
<path d="M10 9.5C10 10.33 9.33 11 8.5 11h-5C2.67 11 2 10.33 2 9.5S2.67 8 3.5 8h5c.83 0 1.5.67 1.5 1.5z"/>
<path d="M10 4.5C10 5.33 9.33 6 8.5 6H7V4.5C7 3.67 7.67 3 8.5 3S10 3.67 10 4.5z"/>
</svg>
<p>No component differences found</p>
<span class="empty-hint">The two SBOM versions appear to be identical</span>
</div>
}
</div>
`,
styles: [`
.sbom-side-by-side {
display: flex;
flex-direction: column;
height: 100%;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
/* Headers */
.sbom-headers {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px solid var(--border-default, #e5e7eb);
}
.sbom-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 1rem;
background: var(--surface-secondary, #f9fafb);
&--left {
border-right: 1px solid var(--border-default, #e5e7eb);
}
}
.header-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #111827);
svg {
color: var(--text-secondary, #6b7280);
}
}
.header-label {
font-size: 0.75rem;
font-family: 'SF Mono', 'Consolas', monospace;
color: var(--text-secondary, #6b7280);
}
.header-count {
font-size: 0.6875rem;
color: var(--text-muted, #9ca3af);
}
/* Content */
.sbom-content {
flex: 1;
overflow: auto;
}
.sbom-viewport {
height: 100%;
}
.sbom-rows {
min-height: 200px;
}
/* Unified rows */
.unified-row {
display: grid;
grid-template-columns: 1fr 40px 1fr;
border-bottom: 1px solid var(--border-light, #f3f4f6);
&:hover {
background-color: var(--surface-hover, #f9fafb);
}
&.row-added {
background-color: rgba(22, 163, 74, 0.05);
}
&.row-removed {
background-color: rgba(220, 38, 38, 0.05);
}
&.row-changed {
background-color: rgba(202, 138, 4, 0.05);
}
&.expanded {
background-color: var(--surface-secondary, #f3f4f6);
}
}
.row-pane {
padding: 0.5rem 0.75rem;
min-height: 48px;
display: flex;
align-items: center;
&--left {
border-right: 1px solid var(--border-light, #f3f4f6);
}
&--right {
border-left: 1px solid var(--border-light, #f3f4f6);
}
}
/* Component cell */
.component-cell {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
&.clickable {
cursor: pointer;
}
&.added {
.component-name, .component-version {
color: var(--color-success, #16a34a);
}
}
&.removed {
.component-name, .component-version {
color: var(--color-error, #dc2626);
text-decoration: line-through;
}
}
}
.component-main {
display: flex;
align-items: center;
gap: 0.5rem;
}
.component-name {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary, #111827);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.component-version {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
}
.component-license {
font-size: 0.6875rem;
color: var(--text-muted, #9ca3af);
}
/* Placeholder cell */
.placeholder-cell {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.placeholder-line {
width: 60%;
height: 2px;
background: var(--border-light, #e5e7eb);
border-radius: 1px;
}
/* Diff indicator */
.diff-indicator {
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-secondary, #f9fafb);
}
.diff-badge {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: 0.75rem;
font-weight: 600;
border-radius: 4px;
&--added {
background-color: var(--color-success, #16a34a);
color: white;
}
&--removed {
background-color: var(--color-error, #dc2626);
color: white;
}
&--changed {
background-color: var(--color-warning, #ca8a04);
color: white;
}
}
/* Expanded details */
.expanded-details {
grid-column: 1 / -1;
padding: 0;
border-top: 1px solid var(--border-light, #e5e7eb);
background: var(--surface-primary, #ffffff);
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem 1rem;
color: var(--text-muted, #9ca3af);
svg {
opacity: 0.5;
}
p {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
}
}
.empty-hint {
font-size: 0.75rem;
}
/* Row height for virtual scroll */
:host {
--row-height: 56px;
}
`],
})
export class SbomSideBySideComponent implements AfterViewInit, OnDestroy {
/** Diff result data */
readonly diffResult = input<SbomDiffResult | undefined>();
/** Currently expanded component ID */
readonly expandedId = input<string | undefined>();
/** Emits when a row should be expanded/collapsed */
readonly expandToggle = output<string>();
/** Row height for virtual scroll */
readonly rowHeight = 56;
/** Threshold for virtual scroll (components) */
private readonly virtualScrollThreshold = 500;
/** Viewport reference for virtual scroll */
private readonly viewport = viewChild<CdkVirtualScrollViewport>('viewport');
/** Content area reference */
private readonly contentArea = viewChild<ElementRef>('contentArea');
/** Computed: diff metadata */
readonly metadata = computed(() => this.diffResult()?.metadata);
/** Computed: whether to use virtual scroll */
readonly useVirtualScroll = computed(() => {
const result = this.diffResult();
if (!result) return false;
const totalComponents = result.added.length + result.removed.length + result.changed.length;
return totalComponents > this.virtualScrollThreshold;
});
/** Computed: has any components */
readonly hasComponents = computed(() => {
const result = this.diffResult();
if (!result) return false;
return result.added.length > 0 || result.removed.length > 0 || result.changed.length > 0;
});
/** Computed: unified rows for display */
readonly unifiedRows = computed<UnifiedRow[]>(() => {
const result = this.diffResult();
if (!result) return [];
const rows: UnifiedRow[] = [];
// Add removed components (left side only)
for (const comp of result.removed) {
rows.push({
id: comp.id,
type: 'component',
changeType: 'removed',
component: comp,
side: 'left',
});
}
// Add changed components (both sides)
for (const comp of result.changed) {
rows.push({
id: comp.id,
type: 'component',
changeType: 'changed',
component: comp,
side: 'both',
});
}
// Add new components (right side only)
for (const comp of result.added) {
rows.push({
id: comp.id,
type: 'component',
changeType: 'added',
component: comp,
side: 'right',
});
}
// Sort by name for consistent display
rows.sort((a, b) => {
const nameA = a.component?.name ?? '';
const nameB = b.component?.name ?? '';
return nameA.localeCompare(nameB);
});
return rows;
});
ngAfterViewInit(): void {
// Setup scroll sync if needed
}
ngOnDestroy(): void {
// Cleanup
}
/** Track function for virtual scroll */
trackRow(index: number, row: UnifiedRow): string {
return row.id;
}
/** Handle row click */
onRowClick(row: UnifiedRow): void {
if (row.component) {
this.expandToggle.emit(row.id);
}
}
/** Handle expand toggle from component row */
onToggleExpand(id: string): void {
this.expandToggle.emit(id);
}
}

View File

@@ -0,0 +1,24 @@
/**
* @file deploy-diff.routes.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel (DD-009)
* @description Routes for deploy diff feature module.
*/
import { Routes } from '@angular/router';
/**
* Deploy diff feature routes.
*
* Route: /deploy/diff?from={digest}&to={digest}
*/
export const DEPLOY_DIFF_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/deploy-diff.page').then(m => m.DeployDiffPage),
title: 'Deployment Diff',
data: {
breadcrumb: 'Compare Versions',
},
},
];

View File

@@ -0,0 +1,10 @@
/**
* @file index.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel
* @description Public API for deploy-diff feature module.
*/
export * from './components';
export * from './models';
export * from './services';
export * from './deploy-diff.routes';

View File

@@ -0,0 +1,427 @@
/**
* @file deploy-diff.models.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel
* @description TypeScript interfaces for A/B deploy diff functionality.
*/
// ============================================================================
// SBOM DIFF TYPES
// ============================================================================
/**
* Request to compute SBOM diff between two versions.
*/
export interface SbomDiffRequest {
/** Current version SBOM digest */
readonly fromDigest: string;
/** New version SBOM digest */
readonly toDigest: string;
}
/**
* Result of SBOM diff computation.
*/
export interface SbomDiffResult {
/** Components added in the new version */
readonly added: ComponentDiff[];
/** Components removed from the current version */
readonly removed: ComponentDiff[];
/** Components that changed between versions */
readonly changed: ComponentDiff[];
/** Count of unchanged components (not returned in detail) */
readonly unchanged: number;
/** Policy hits for the diff (violations, warnings) */
readonly policyHits: PolicyHit[];
/** Overall policy evaluation result */
readonly policyResult: PolicyResult;
/** Metadata about the comparison */
readonly metadata: DiffMetadata;
}
/**
* Metadata about the diff comparison.
*/
export interface DiffMetadata {
/** From version digest */
readonly fromDigest: string;
/** To version digest */
readonly toDigest: string;
/** From version label (e.g., "v1.2.3") */
readonly fromLabel?: string;
/** To version label (e.g., "v1.3.0") */
readonly toLabel?: string;
/** Timestamp when diff was computed */
readonly computedAt: string;
/** Total components in from version */
readonly fromTotalComponents: number;
/** Total components in to version */
readonly toTotalComponents: number;
}
// ============================================================================
// COMPONENT DIFF TYPES
// ============================================================================
/**
* Diff information for a single component.
*/
export interface ComponentDiff {
/** Unique identifier for this diff row */
readonly id: string;
/** Change type */
readonly changeType: ChangeType;
/** Package name */
readonly name: string;
/** Package group/namespace (optional) */
readonly group?: string;
/** Package URL (purl) */
readonly purl?: string;
/** Version in the old SBOM (null for added) */
readonly fromVersion: string | null;
/** Version in the new SBOM (null for removed) */
readonly toVersion: string | null;
/** License in old version */
readonly fromLicense?: string;
/** License in new version */
readonly toLicense?: string;
/** Whether license changed */
readonly licenseChanged: boolean;
/** Semantic version change classification */
readonly versionChange?: VersionChange;
/** Dependencies (for expansion) */
readonly dependencies?: ComponentDependency[];
/** Known vulnerabilities */
readonly vulnerabilities?: ComponentVulnerability[];
/** Evidence status */
readonly evidence?: ComponentEvidenceStatus;
/** Policy hits for this specific component */
readonly policyHits?: PolicyHit[];
}
/**
* Type of change for a component.
*/
export type ChangeType = 'added' | 'removed' | 'changed' | 'unchanged';
/**
* Semantic version change classification.
*/
export interface VersionChange {
/** Type of semantic version change */
readonly type: 'major' | 'minor' | 'patch' | 'unknown';
/** Human-readable description */
readonly description: string;
/** Whether this is considered breaking */
readonly breaking: boolean;
}
/**
* Dependency of a component.
*/
export interface ComponentDependency {
/** Dependency name */
readonly name: string;
/** Dependency version */
readonly version?: string;
/** Dependency purl */
readonly purl?: string;
/** Whether this is a direct dependency */
readonly direct: boolean;
}
/**
* Vulnerability associated with a component.
*/
export interface ComponentVulnerability {
/** CVE ID */
readonly id: string;
/** Severity */
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'unknown';
/** CVSS score */
readonly cvssScore?: number;
/** Whether this is fixed in the new version */
readonly fixed: boolean;
/** URL for more info */
readonly url?: string;
}
/**
* Evidence status for a component.
*/
export interface ComponentEvidenceStatus {
/** DSSE signature status */
readonly dsse: EvidenceItemStatus;
/** Rekor inclusion proof status */
readonly rekor: EvidenceItemStatus;
/** SBOM match status */
readonly sbom: EvidenceItemStatus;
/** VEX status */
readonly vex?: VexStatus;
}
/**
* Status of a single evidence item.
*/
export interface EvidenceItemStatus {
/** Whether the evidence is present and valid */
readonly valid: boolean;
/** Status message */
readonly message?: string;
/** Timestamp (e.g., Rekor tile date) */
readonly timestamp?: string;
}
/**
* VEX status for a component.
*/
export interface VexStatus {
/** VEX statement status */
readonly status: 'not_affected' | 'affected' | 'fixed' | 'under_investigation' | 'unknown';
/** Justification */
readonly justification?: string;
}
// ============================================================================
// POLICY TYPES
// ============================================================================
/**
* Policy evaluation hit (violation or warning).
*/
export interface PolicyHit {
/** Unique ID */
readonly id: string;
/** Policy gate name */
readonly gate: string;
/** Severity of the hit */
readonly severity: PolicySeverity;
/** Result type */
readonly result: 'pass' | 'fail' | 'warn';
/** Human-readable message */
readonly message: string;
/** Component IDs this hit applies to (empty = applies to all) */
readonly componentIds?: string[];
/** Link to policy details */
readonly policyUrl?: string;
}
/**
* Policy severity levels.
*/
export type PolicySeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
/**
* Overall policy evaluation result.
*/
export interface PolicyResult {
/** Whether deployment is allowed */
readonly allowed: boolean;
/** Whether override is available */
readonly overrideAvailable: boolean;
/** Count of failures */
readonly failCount: number;
/** Count of warnings */
readonly warnCount: number;
/** Count of passes */
readonly passCount: number;
}
// ============================================================================
// DEPLOY ACTION TYPES
// ============================================================================
/**
* Deploy action types.
*/
export type DeployActionType = 'block' | 'allow' | 'allow_override' | 'schedule_canary';
/**
* Deploy action event.
*/
export interface DeployAction {
/** Action type */
readonly type: DeployActionType;
/** From digest */
readonly fromDigest: string;
/** To digest */
readonly toDigest: string;
/** Timestamp */
readonly timestamp: string;
/** Override details (if applicable) */
readonly override?: OverrideDetails;
/** Canary details (if applicable) */
readonly canary?: CanaryDetails;
}
/**
* Override justification details.
*/
export interface OverrideDetails {
/** Justification text (required, min 20 chars) */
readonly reason: string;
/** Optional ticket/issue link */
readonly ticketUrl?: string;
/** Signer identity */
readonly signer: SignerIdentity;
/** Policy hits being overridden */
readonly overriddenHits: string[];
}
/**
* Signer identity for audit trail.
*/
export interface SignerIdentity {
/** User ID */
readonly userId: string;
/** Display name */
readonly displayName: string;
/** Email */
readonly email: string;
}
/**
* Canary deployment options.
*/
export interface CanaryDetails {
/** Initial percentage */
readonly initialPercent: number;
/** Step increment */
readonly stepPercent: number;
/** Step interval in minutes */
readonly stepIntervalMinutes: number;
/** Success criteria */
readonly successCriteria: CanarySuccessCriteria;
}
/**
* Canary success criteria.
*/
export interface CanarySuccessCriteria {
/** Max error rate percent */
readonly maxErrorRate: number;
/** Max latency P99 in ms */
readonly maxLatencyP99Ms: number;
}
// ============================================================================
// VIEW MODEL TYPES
// ============================================================================
/**
* View state for the deploy diff panel.
*/
export interface DeployDiffViewState {
/** Loading state */
readonly loading: boolean;
/** Error message */
readonly error?: string;
/** Diff result */
readonly diff?: SbomDiffResult;
/** Currently expanded component ID */
readonly expandedComponentId?: string;
/** Filter by change type */
readonly changeTypeFilter: ChangeType | 'all';
/** Filter by policy result */
readonly policyFilter: 'all' | 'failing' | 'passing';
/** Search query */
readonly searchQuery: string;
}
/**
* Summary statistics for the diff.
*/
export interface DiffSummary {
/** Number of added components */
readonly addedCount: number;
/** Number of removed components */
readonly removedCount: number;
/** Number of changed components */
readonly changedCount: number;
/** Number of unchanged components */
readonly unchangedCount: number;
/** Number of policy failures */
readonly policyFailures: number;
/** Number of policy warnings */
readonly policyWarnings: number;
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Computes diff summary from result.
*/
export function computeDiffSummary(result: SbomDiffResult): DiffSummary {
return {
addedCount: result.added.length,
removedCount: result.removed.length,
changedCount: result.changed.length,
unchangedCount: result.unchanged,
policyFailures: result.policyHits.filter(h => h.result === 'fail').length,
policyWarnings: result.policyHits.filter(h => h.result === 'warn').length,
};
}
/**
* Formats version change for display.
*/
export function formatVersionChange(from: string | null, to: string | null): string {
if (!from && to) return `+ ${to}`;
if (from && !to) return `- ${from}`;
if (from && to) return `${from}${to}`;
return '--';
}
/**
* Gets CSS class for change type.
*/
export function getChangeTypeClass(type: ChangeType): string {
switch (type) {
case 'added':
return 'change-added';
case 'removed':
return 'change-removed';
case 'changed':
return 'change-modified';
case 'unchanged':
default:
return 'change-unchanged';
}
}
/**
* Gets CSS class for policy severity.
*/
export function getPolicySeverityClass(severity: PolicySeverity): string {
switch (severity) {
case 'critical':
return 'severity-critical';
case 'high':
return 'severity-high';
case 'medium':
return 'severity-medium';
case 'low':
return 'severity-low';
case 'info':
default:
return 'severity-info';
}
}
/**
* Gets icon name for change type.
*/
export function getChangeTypeIcon(type: ChangeType): string {
switch (type) {
case 'added':
return 'plus-circle';
case 'removed':
return 'minus-circle';
case 'changed':
return 'edit-2';
case 'unchanged':
default:
return 'circle';
}
}

View File

@@ -0,0 +1,7 @@
/**
* @file index.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel
* @description Public API for deploy-diff models.
*/
export * from './deploy-diff.models';

View File

@@ -0,0 +1,261 @@
/**
* @file deploy-diff.page.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel (DD-009)
* @description Page component for deploy diff route.
*/
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { DeployDiffPanelComponent } from '../components/deploy-diff-panel/deploy-diff-panel.component';
import { DeployAction, SignerIdentity } from '../models/deploy-diff.models';
/**
* Deploy diff page component.
*
* Handles routing and query parameter parsing for the deploy diff panel.
*
* @example
* Route: /deploy/diff?from=sha256:abc123&to=sha256:def456
*/
@Component({
selector: 'app-deploy-diff-page',
standalone: true,
imports: [CommonModule, RouterLink, DeployDiffPanelComponent],
template: `
<div class="page-container">
<!-- Breadcrumb -->
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol class="breadcrumb-list">
<li class="breadcrumb-item">
<a routerLink="/deploy" class="breadcrumb-link">Deployments</a>
</li>
<li class="breadcrumb-separator" aria-hidden="true">/</li>
<li class="breadcrumb-item breadcrumb-item--current" aria-current="page">
Compare Versions
</li>
</ol>
</nav>
<!-- Main content -->
@if (hasValidParams()) {
<app-deploy-diff-panel
[fromDigest]="fromDigest()!"
[toDigest]="toDigest()!"
[fromLabel]="fromLabel()"
[toLabel]="toLabel()"
[currentSigner]="currentSigner()"
(actionTaken)="onActionTaken($event)"
/>
} @else {
<!-- Invalid params state -->
<div class="invalid-params">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<h2>Missing Parameters</h2>
<p>
To view a deployment diff, provide both <code>from</code> and <code>to</code> digest parameters.
</p>
<div class="example-url">
<strong>Example:</strong>
<code>/deploy/diff?from=sha256:abc...&amp;to=sha256:def...</code>
</div>
<a routerLink="/deploy" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back to Deployments
</a>
</div>
}
</div>
`,
styles: [`
.page-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
gap: 1rem;
}
/* Breadcrumb */
.breadcrumb {
margin-bottom: 0.5rem;
}
.breadcrumb-list {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.breadcrumb-item {
font-size: 0.875rem;
&--current {
color: var(--text-primary, #111827);
font-weight: 500;
}
}
.breadcrumb-link {
color: var(--text-secondary, #6b7280);
text-decoration: none;
&:hover {
color: var(--text-link, #2563eb);
text-decoration: underline;
}
}
.breadcrumb-separator {
color: var(--text-muted, #9ca3af);
}
/* Invalid params state */
.invalid-params {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 3rem 1rem;
text-align: center;
flex: 1;
svg {
color: var(--text-muted, #9ca3af);
opacity: 0.5;
}
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
p {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary, #6b7280);
max-width: 400px;
code {
padding: 0.125rem 0.375rem;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 0.8125rem;
background: var(--surface-secondary, #f3f4f6);
border-radius: 4px;
}
}
}
.example-url {
padding: 1rem;
background: var(--surface-secondary, #f3f4f6);
border-radius: 8px;
font-size: 0.8125rem;
strong {
color: var(--text-secondary, #6b7280);
}
code {
display: block;
margin-top: 0.5rem;
font-family: 'SF Mono', 'Consolas', monospace;
color: var(--text-primary, #111827);
word-break: break-all;
}
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-link, #2563eb);
text-decoration: none;
border: 1px solid var(--color-primary, #2563eb);
border-radius: 6px;
transition: all 0.15s;
&:hover {
background: var(--color-primary-bg, #dbeafe);
}
}
`],
})
export class DeployDiffPage implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
/** From digest query param */
readonly fromDigest = signal<string | null>(null);
/** To digest query param */
readonly toDigest = signal<string | null>(null);
/** From label query param (optional) */
readonly fromLabel = signal<string | undefined>(undefined);
/** To label query param (optional) */
readonly toLabel = signal<string | undefined>(undefined);
/** Current user/signer - in production would come from auth service */
readonly currentSigner = signal<SignerIdentity>({
userId: 'user-123',
displayName: 'Current User',
email: 'user@example.com',
});
/** Computed: whether we have valid params */
readonly hasValidParams = computed(() => {
return !!this.fromDigest() && !!this.toDigest();
});
ngOnInit(): void {
// Subscribe to query params
this.route.queryParamMap.subscribe(params => {
this.fromDigest.set(params.get('from'));
this.toDigest.set(params.get('to'));
this.fromLabel.set(params.get('fromLabel') ?? undefined);
this.toLabel.set(params.get('toLabel') ?? undefined);
});
}
/** Handle action taken */
onActionTaken(action: DeployAction): void {
console.log('[DeployDiffPage] Action taken:', action);
// Navigate based on action type
switch (action.type) {
case 'block':
// Could navigate to blocked deployments list
break;
case 'allow_override':
// Could navigate to deployment progress
this.router.navigate(['/deploy', 'progress'], {
queryParams: { digest: this.toDigest() },
});
break;
case 'schedule_canary':
// Navigate to canary monitoring
this.router.navigate(['/deploy', 'canary'], {
queryParams: { digest: this.toDigest() },
});
break;
}
}
}

View File

@@ -0,0 +1,359 @@
/**
* @file deploy-diff.service.spec.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel (DD-002)
* @description Unit tests for deploy diff service.
*/
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { DeployDiffService } from './deploy-diff.service';
import { SbomDiffResult, ComponentDiff, PolicyHit } from '../models/deploy-diff.models';
describe('DeployDiffService', () => {
let service: DeployDiffService;
let httpMock: HttpTestingController;
const mockDiffResult: SbomDiffResult = {
added: [
{
id: 'comp-1',
changeType: 'added',
name: 'new-package',
fromVersion: null,
toVersion: '1.0.0',
licenseChanged: false,
},
],
removed: [],
changed: [],
unchanged: 50,
policyHits: [
{
id: 'hit-1',
gate: 'version-check',
severity: 'high',
result: 'fail',
message: 'Version check failed',
componentIds: ['comp-1'],
},
],
policyResult: {
allowed: false,
overrideAvailable: true,
failCount: 1,
warnCount: 0,
passCount: 5,
},
metadata: {
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
computedAt: '2026-01-25T10:00:00Z',
fromTotalComponents: 50,
toTotalComponents: 51,
},
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
DeployDiffService,
],
});
service = TestBed.inject(DeployDiffService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
describe('DD-002: fetchDiff', () => {
it('calls diff API with correct params', fakeAsync(async () => {
const promise = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
expect(req.request.method).toBe('GET');
req.flush(mockDiffResult);
const result = await promise;
expect(result).toEqual(mockDiffResult);
}));
it('maps response to typed model', fakeAsync(async () => {
const promise = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
const result = await promise;
expect(result.added.length).toBe(1);
expect(result.added[0].name).toBe('new-package');
expect(result.policyHits[0].gate).toBe('version-check');
}));
it('caches result for repeated comparisons', fakeAsync(async () => {
// First call
const promise1 = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
const req1 = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req1.flush(mockDiffResult);
await promise1;
// Second call should use cache
const promise2 = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
// No HTTP request should be made
httpMock.expectNone('/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456');
const result2 = await promise2;
expect(result2).toEqual(mockDiffResult);
}));
it('handles invalid digests with error', fakeAsync(async () => {
const promise = service.fetchDiff({
fromDigest: 'invalid',
toDigest: 'also-invalid',
});
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=invalid&to=also-invalid'
);
req.flush({ message: 'Invalid digest format' }, { status: 400, statusText: 'Bad Request' });
try {
await promise;
fail('Should have thrown');
} catch (err: any) {
expect(err.message).toContain('Invalid');
}
}));
it('handles 404 with appropriate message', fakeAsync(async () => {
const promise = service.fetchDiff({
fromDigest: 'sha256:notfound',
toDigest: 'sha256:def456',
});
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:notfound&to=sha256:def456'
);
req.flush(null, { status: 404, statusText: 'Not Found' });
try {
await promise;
fail('Should have thrown');
} catch (err: any) {
expect(err.message).toContain('not found');
}
}));
});
describe('submitOverride', () => {
it('calls override API', fakeAsync(async () => {
const promise = service.submitOverride(
'sha256:abc123',
'sha256:def456',
{
reason: 'Emergency hotfix required for production issue',
ticketUrl: 'https://jira.example.com/PROJ-123',
signer: {
userId: 'user-1',
displayName: 'Test User',
email: 'test@example.com',
},
overriddenHits: ['hit-1'],
}
);
const req = httpMock.expectOne('/api/v1/policy/override');
expect(req.request.method).toBe('POST');
expect(req.request.body.reason).toBe('Emergency hotfix required for production issue');
expect(req.request.body.overriddenHits).toEqual(['hit-1']);
req.flush({
type: 'allow_override',
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
timestamp: '2026-01-25T10:00:00Z',
});
const result = await promise;
expect(result.type).toBe('allow_override');
}));
});
describe('blockDeployment', () => {
it('calls block API', fakeAsync(async () => {
const promise = service.blockDeployment('sha256:abc123', 'sha256:def456');
const req = httpMock.expectOne('/api/v1/deploy/block');
expect(req.request.method).toBe('POST');
req.flush({
type: 'block',
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
timestamp: '2026-01-25T10:00:00Z',
});
const result = await promise;
expect(result.type).toBe('block');
}));
});
describe('scheduleCanary', () => {
it('calls canary API', fakeAsync(async () => {
const promise = service.scheduleCanary(
'sha256:abc123',
'sha256:def456',
{
initialPercent: 5,
stepPercent: 10,
stepIntervalMinutes: 15,
successCriteria: {
maxErrorRate: 1,
maxLatencyP99Ms: 500,
},
}
);
const req = httpMock.expectOne('/api/v1/deploy/canary');
expect(req.request.method).toBe('POST');
expect(req.request.body.initialPercent).toBe(5);
req.flush({
type: 'schedule_canary',
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
timestamp: '2026-01-25T10:00:00Z',
});
const result = await promise;
expect(result.type).toBe('schedule_canary');
}));
});
describe('filterComponents', () => {
it('filters by change type', () => {
const result = service.filterComponents(mockDiffResult, 'added', 'all', '');
expect(result.length).toBe(1);
expect(result[0].changeType).toBe('added');
});
it('filters by policy result', () => {
const result = service.filterComponents(mockDiffResult, 'all', 'failing', '');
expect(result.every(c => mockDiffResult.policyHits.some(
h => h.result === 'fail' && h.componentIds?.includes(c.id)
))).toBeTrue();
});
it('filters by search query', () => {
const result = service.filterComponents(mockDiffResult, 'all', 'all', 'new');
expect(result.length).toBe(1);
expect(result[0].name).toContain('new');
});
});
describe('getPolicyHitsForComponent', () => {
it('returns hits for specific component', () => {
const hits = service.getPolicyHitsForComponent(mockDiffResult, 'comp-1');
expect(hits.length).toBe(1);
expect(hits[0].id).toBe('hit-1');
});
it('returns global hits (no componentIds)', () => {
const resultWithGlobalHit: SbomDiffResult = {
...mockDiffResult,
policyHits: [
{
id: 'global-hit',
gate: 'sbom-completeness',
severity: 'medium',
result: 'warn',
message: 'SBOM completeness below threshold',
},
],
};
const hits = service.getPolicyHitsForComponent(resultWithGlobalHit, 'any-component');
expect(hits.length).toBe(1);
expect(hits[0].id).toBe('global-hit');
});
});
describe('clearCache', () => {
it('clears cached results', fakeAsync(async () => {
// First call - caches result
const promise1 = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
const req1 = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req1.flush(mockDiffResult);
await promise1;
// Clear cache
service.clearCache();
// Second call should make new request
const promise2 = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
const req2 = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req2.flush(mockDiffResult);
await promise2;
}));
});
describe('loading state', () => {
it('sets loading true during fetch', fakeAsync(() => {
expect(service.loading()).toBeFalse();
const promise = service.fetchDiff({
fromDigest: 'sha256:abc123',
toDigest: 'sha256:def456',
});
expect(service.loading()).toBeTrue();
const req = httpMock.expectOne(
'/api/v1/sbom/diff?from=sha256:abc123&to=sha256:def456'
);
req.flush(mockDiffResult);
tick();
expect(service.loading()).toBeFalse();
}));
});
});

View File

@@ -0,0 +1,322 @@
/**
* @file deploy-diff.service.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel
* @description Service for fetching and computing SBOM diffs between deployment versions.
*/
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
SbomDiffRequest,
SbomDiffResult,
DeployAction,
OverrideDetails,
CanaryDetails,
ComponentDiff,
PolicyHit,
} from '../models/deploy-diff.models';
/**
* Cache entry for diff results.
*/
interface DiffCacheEntry {
readonly key: string;
readonly result: SbomDiffResult;
readonly timestamp: number;
}
/**
* Service for managing SBOM diff operations.
*
* Features:
* - Fetches diff from API
* - Caches results for repeated comparisons
* - Handles policy override submissions
* - Handles canary scheduling
*/
@Injectable({
providedIn: 'root',
})
export class DeployDiffService {
private readonly http = inject(HttpClient);
/** API base URL */
private readonly apiBase = '/api/v1';
/** Cache TTL in milliseconds (5 minutes) */
private readonly cacheTtlMs = 5 * 60 * 1000;
/** Diff cache */
private readonly cache = signal<Map<string, DiffCacheEntry>>(new Map());
/** Current loading state */
readonly loading = signal<boolean>(false);
/** Current error state */
readonly error = signal<string | null>(null);
/** Current diff result */
readonly currentDiff = signal<SbomDiffResult | null>(null);
/** Computed: has cached diff */
readonly hasCachedDiff = computed(() => this.currentDiff() !== null);
/**
* Fetches SBOM diff between two versions.
*
* @param request - The diff request with from/to digests
* @returns Promise resolving to diff result
*/
async fetchDiff(request: SbomDiffRequest): Promise<SbomDiffResult> {
const cacheKey = this.getCacheKey(request.fromDigest, request.toDigest);
// Check cache first
const cached = this.getFromCache(cacheKey);
if (cached) {
this.currentDiff.set(cached);
return cached;
}
this.loading.set(true);
this.error.set(null);
try {
const result = await firstValueFrom(
this.http.get<SbomDiffResult>(
`${this.apiBase}/sbom/diff`,
{
params: {
from: request.fromDigest,
to: request.toDigest,
},
}
)
);
// Cache the result
this.addToCache(cacheKey, result);
this.currentDiff.set(result);
return result;
} catch (err) {
const errorMessage = this.extractErrorMessage(err);
this.error.set(errorMessage);
throw new Error(errorMessage);
} finally {
this.loading.set(false);
}
}
/**
* Submits a policy override for the deployment.
*
* @param fromDigest - Current version digest
* @param toDigest - New version digest
* @param override - Override details
* @returns Promise resolving to deploy action
*/
async submitOverride(
fromDigest: string,
toDigest: string,
override: OverrideDetails
): Promise<DeployAction> {
const response = await firstValueFrom(
this.http.post<DeployAction>(
`${this.apiBase}/policy/override`,
{
fromDigest,
toDigest,
reason: override.reason,
ticketUrl: override.ticketUrl,
overriddenHits: override.overriddenHits,
}
)
);
return response;
}
/**
* Blocks the deployment.
*
* @param fromDigest - Current version digest
* @param toDigest - New version digest
* @returns Promise resolving to deploy action
*/
async blockDeployment(fromDigest: string, toDigest: string): Promise<DeployAction> {
const response = await firstValueFrom(
this.http.post<DeployAction>(
`${this.apiBase}/deploy/block`,
{ fromDigest, toDigest }
)
);
return response;
}
/**
* Schedules a canary deployment.
*
* @param fromDigest - Current version digest
* @param toDigest - New version digest
* @param canary - Canary configuration
* @returns Promise resolving to deploy action
*/
async scheduleCanary(
fromDigest: string,
toDigest: string,
canary: CanaryDetails
): Promise<DeployAction> {
const response = await firstValueFrom(
this.http.post<DeployAction>(
`${this.apiBase}/deploy/canary`,
{
fromDigest,
toDigest,
...canary,
}
)
);
return response;
}
/**
* Gets filtered components based on criteria.
*
* @param result - The diff result
* @param changeType - Filter by change type
* @param policyFilter - Filter by policy result
* @param searchQuery - Search query
*/
filterComponents(
result: SbomDiffResult,
changeType: 'all' | 'added' | 'removed' | 'changed',
policyFilter: 'all' | 'failing' | 'passing',
searchQuery: string
): ComponentDiff[] {
let components: ComponentDiff[] = [];
// Gather components based on change type filter
if (changeType === 'all' || changeType === 'added') {
components = [...components, ...result.added];
}
if (changeType === 'all' || changeType === 'removed') {
components = [...components, ...result.removed];
}
if (changeType === 'all' || changeType === 'changed') {
components = [...components, ...result.changed];
}
// Filter by policy result
if (policyFilter !== 'all') {
const failingIds = new Set(
result.policyHits
.filter(h => h.result === 'fail')
.flatMap(h => h.componentIds ?? [])
);
components = components.filter(c => {
const isFailing = failingIds.has(c.id);
return policyFilter === 'failing' ? isFailing : !isFailing;
});
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
components = components.filter(c =>
c.name.toLowerCase().includes(query) ||
(c.group?.toLowerCase().includes(query)) ||
(c.purl?.toLowerCase().includes(query))
);
}
return components;
}
/**
* Gets policy hits for a specific component.
*/
getPolicyHitsForComponent(
result: SbomDiffResult,
componentId: string
): PolicyHit[] {
return result.policyHits.filter(
h => !h.componentIds || h.componentIds.includes(componentId)
);
}
/**
* Clears the diff cache.
*/
clearCache(): void {
this.cache.set(new Map());
this.currentDiff.set(null);
}
/**
* Generates cache key from digests.
*/
private getCacheKey(from: string, to: string): string {
return `${from}:${to}`;
}
/**
* Gets result from cache if valid.
*/
private getFromCache(key: string): SbomDiffResult | null {
const entry = this.cache().get(key);
if (!entry) return null;
// Check if cache entry is expired
if (Date.now() - entry.timestamp > this.cacheTtlMs) {
this.cache.update(map => {
const newMap = new Map(map);
newMap.delete(key);
return newMap;
});
return null;
}
return entry.result;
}
/**
* Adds result to cache.
*/
private addToCache(key: string, result: SbomDiffResult): void {
this.cache.update(map => {
const newMap = new Map(map);
newMap.set(key, {
key,
result,
timestamp: Date.now(),
});
return newMap;
});
}
/**
* Extracts error message from HTTP error.
*/
private extractErrorMessage(err: unknown): string {
if (err instanceof HttpErrorResponse) {
if (err.error?.message) {
return err.error.message;
}
if (err.status === 404) {
return 'SBOM diff endpoint not found. Please verify the digests are valid.';
}
if (err.status === 400) {
return 'Invalid request. Please check the digest format.';
}
return `Server error: ${err.statusText || 'Unknown error'}`;
}
if (err instanceof Error) {
return err.message;
}
return 'An unexpected error occurred';
}
}

View File

@@ -0,0 +1,7 @@
/**
* @file index.ts
* @sprint SPRINT_20260125_006_FE_ab_deploy_diff_panel
* @description Public API for deploy-diff services.
*/
export * from './deploy-diff.service';

View File

@@ -49,6 +49,12 @@ export interface ExportIncludeOptions {
policyEvaluations: boolean;
evidence: boolean;
rawLogs: boolean;
/** Include replay_log.json for deterministic proof replay (SB-002) */
replayLog?: boolean;
/** Include DSSE envelope (SB-002) */
dsseEnvelope?: boolean;
/** Include Rekor tile receipt (SB-002) */
rekorReceipt?: boolean;
}
export interface ExportSchedule {
@@ -119,3 +125,82 @@ export interface VerificationResult {
warnings: string[];
verifiedAt: string;
}
// -----------------------------------------------------------------------------
// StellaBundle Export Types (Sprint: SPRINT_20260125_005_FE_stella_bundle_export)
// -----------------------------------------------------------------------------
/**
* Export format options including OCI referrer for StellaBundle
*/
export type ExportFormat = 'tar.gz' | 'zip' | 'json' | 'ndjson' | 'oci';
/**
* StellaBundle export request containing all options for a signed audit pack
*/
export interface StellaBundleExportRequest {
artifactId: string;
format: ExportFormat;
includeOptions: StellaBundleIncludeOptions;
destination?: ExportDestination;
}
/**
* Include options specific to StellaBundle (DSSE+Rekor signed audit pack)
*/
export interface StellaBundleIncludeOptions {
/** Canonicalized SBOM (JCS format) */
canonicalizedSbom: boolean;
/** DSSE envelope with signature */
dsseEnvelope: boolean;
/** Rekor transparency log tile receipt */
rekorTileReceipt: boolean;
/** Replay log for deterministic proof replay */
replayLog: boolean;
/** VEX decisions */
vexDecisions: boolean;
/** Policy evaluations */
policyEvaluations: boolean;
}
/**
* Result of a StellaBundle export operation
*/
export interface StellaBundleExportResult {
success: boolean;
exportId: string;
artifactId: string;
format: ExportFormat;
/** OCI reference (for OCI format): oci://registry.example.com/repo@sha256:... */
ociReference?: string;
/** Download URL (for tar.gz/zip formats) */
downloadUrl?: string;
/** SHA-256 checksum of the bundle */
checksumSha256: string;
/** Size in bytes */
sizeBytes: number;
/** Files included in bundle */
includedFiles: string[];
/** Duration of export operation in milliseconds */
durationMs: number;
/** Timestamp when export completed */
completedAt: string;
/** Error message if failed */
errorMessage?: string;
}
/**
* Telemetry event for StellaBundle export (SB-006)
*/
export interface StellaBundleExportTelemetry {
event: 'stella.bundle.exported';
properties: {
artifact_id: string;
format: ExportFormat;
includes_replay_log: boolean;
includes_dsse: boolean;
includes_rekor: boolean;
duration_ms: number;
success: boolean;
};
}

View File

@@ -15,7 +15,9 @@ import {
ExportProfile,
ExportRun,
ExportRunStatus,
StellaBundleExportResult,
} from './evidence-export.models';
import { StellaBundleExportButtonComponent } from './stella-bundle-export-button/stella-bundle-export-button.component';
/**
* Export Center Component (Sprint: SPRINT_20251229_016)
@@ -24,12 +26,25 @@ import {
@Component({
selector: 'app-export-center',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, StellaBundleExportButtonComponent],
template: `
<div class="export-center">
<header class="page-header">
<h1>Export Center</h1>
<p>Configure export profiles and monitor export runs.</p>
<div class="header-content">
<div class="header-text">
<h1>Export Center</h1>
<p>Configure export profiles and monitor export runs.</p>
</div>
<!-- Quick Actions (SB-003) -->
<div class="quick-actions">
<app-stella-bundle-export-button
[artifactId]="selectedArtifactId()"
[disabled]="!selectedArtifactId()"
(exportComplete)="onStellaBundleExported($event)"
(viewDetails)="onViewBundleDetails($event)"
/>
</div>
</div>
</header>
<!-- Tabs -->
@@ -95,6 +110,15 @@ import {
@if (profile.includeOptions.rawLogs) {
<span class="badge">Logs</span>
}
@if (profile.includeOptions.replayLog) {
<span class="badge badge-stella">Replay Log</span>
}
@if (profile.includeOptions.dsseEnvelope) {
<span class="badge badge-stella">DSSE</span>
}
@if (profile.includeOptions.rekorReceipt) {
<span class="badge badge-stella">Rekor</span>
}
</div>
</div>
@@ -340,14 +364,33 @@ import {
.page-header {
margin-bottom: 2rem;
h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
@media (max-width: 640px) {
flex-direction: column;
}
}
p {
margin: 0;
color: var(--text-secondary);
.header-text {
h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
p {
margin: 0;
color: var(--text-secondary);
}
}
.quick-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
}
@@ -447,6 +490,11 @@ import {
border-radius: 0.125rem;
font-size: 0.6875rem;
font-weight: 500;
&.badge-stella {
background: var(--success-surface);
color: var(--success);
}
}
.schedule-info {
@@ -767,11 +815,36 @@ export class ExportCenterComponent implements OnInit, OnDestroy {
readonly showProfileModal = signal(false);
readonly editingProfile = signal<ExportProfile | null>(null);
/** Selected artifact ID for quick actions (SB-003) */
readonly selectedArtifactId = signal<string>('artifact-demo-123');
runStatusFilter = '';
profileForm = this.getEmptyProfileForm();
readonly profiles = signal<ExportProfile[]>([
// StellaBundle Preset (SB-003) - Signed audit pack for OCI referrer
{
id: 'stella-bundle',
name: 'StellaBundle (OCI referrer)',
description: 'Signed audit pack with DSSE envelope, Rekor tile receipt, and replay log. Suitable for auditor delivery via OCI referrer.',
format: 'tar.gz', // Note: actual export uses 'oci' but profile system uses tar.gz
includeOptions: {
sbom: true,
vulnerabilities: true,
attestations: true,
provenance: true,
vexDecisions: true,
policyEvaluations: true,
evidence: true,
rawLogs: false,
replayLog: true,
dsseEnvelope: true,
rekorReceipt: true,
},
schedule: { type: 'manual' },
destinations: [],
},
// Mock data
{
id: 'ep-001',
@@ -989,6 +1062,36 @@ export class ExportCenterComponent implements OnInit, OnDestroy {
console.log('Downloading run output:', run.outputPath);
}
/**
* Handle StellaBundle export completion (SB-003)
*/
onStellaBundleExported(result: StellaBundleExportResult): void {
console.log('StellaBundle exported:', result);
// Add to runs list
const newRun: ExportRun = {
id: result.exportId,
profileId: 'stella-bundle',
profileName: 'StellaBundle Export',
status: 'completed',
startedAt: new Date(Date.now() - result.durationMs).toISOString(),
completedAt: result.completedAt,
progress: 100,
itemsProcessed: result.includedFiles.length,
itemsTotal: result.includedFiles.length,
outputPath: result.ociReference || result.downloadUrl,
};
this.runs.update(runs => [newRun, ...runs]);
}
/**
* Handle view bundle details (SB-003)
*/
onViewBundleDetails(result: StellaBundleExportResult): void {
console.log('Viewing bundle details:', result);
// Navigate to bundle details or show in modal
// TODO: Implement navigation to bundle details view
}
onRunFilterChange(): void {
// Computed signal handles filtering
}

View File

@@ -0,0 +1,9 @@
// -----------------------------------------------------------------------------
// StellaBundle Export Button - Public API
// Sprint: SPRINT_20260125_005_FE_stella_bundle_export
// -----------------------------------------------------------------------------
export {
StellaBundleExportButtonComponent,
ExportState,
} from './stella-bundle-export-button.component';

View File

@@ -0,0 +1,370 @@
// -----------------------------------------------------------------------------
// stella-bundle-export-button.component.spec.ts
// Sprint: SPRINT_20260125_005_FE_stella_bundle_export
// Unit tests for StellaBundle export button
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import {
StellaBundleExportButtonComponent,
ExportState,
} from './stella-bundle-export-button.component';
import { StellaBundleExportResult } from '../evidence-export.models';
describe('StellaBundleExportButtonComponent', () => {
let fixture: ComponentFixture<StellaBundleExportButtonComponent>;
let component: StellaBundleExportButtonComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [StellaBundleExportButtonComponent],
}).compileComponents();
fixture = TestBed.createComponent(StellaBundleExportButtonComponent);
component = fixture.componentInstance;
component.artifactId = 'test-artifact-123';
});
afterEach(() => {
fixture.destroy();
});
describe('SB-001: Button rendering', () => {
it('renders button with correct text', () => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
expect(button).toBeTruthy();
expect(button.textContent).toContain('Export StellaBundle');
});
it('has correct tooltip per advisory spec', () => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
expect(button.getAttribute('title')).toBe(
'Export StellaBundle — creates signed audit pack (DSSE+Rekor) suitable for auditor delivery (OCI referrer).'
);
});
it('shows OCI badge by default', () => {
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.oci-badge');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('OCI');
});
it('hides OCI badge when showOciBadge is false', () => {
component.showOciBadge = false;
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.oci-badge');
expect(badge).toBeFalsy();
});
it('renders in compact mode (icon only)', () => {
component.compact = true;
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
expect(button.classList.contains('compact')).toBeTrue();
const label = fixture.nativeElement.querySelector('.btn-label');
const style = window.getComputedStyle(label);
// In compact mode, label should be hidden via CSS
expect(button.classList.contains('compact')).toBeTrue();
});
});
describe('SB-001: Export flow', () => {
it('emits exportStarted on click', () => {
fixture.detectChanges();
let emittedId = '';
component.exportStarted.subscribe((id) => (emittedId = id));
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
expect(emittedId).toBe('test-artifact-123');
});
it('shows loading state during export', () => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
fixture.detectChanges();
expect(component.state()).toBe('exporting');
expect(button.textContent).toContain('Exporting...');
expect(button.classList.contains('exporting')).toBeTrue();
});
it('disables button during export', () => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
fixture.detectChanges();
expect(button.disabled).toBeTrue();
});
it('emits exportComplete on success', fakeAsync(() => {
fixture.detectChanges();
let result: StellaBundleExportResult | null = null;
component.exportComplete.subscribe((r) => (result = r));
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500); // Wait for mock export
fixture.detectChanges();
expect(result).toBeTruthy();
expect(result!.success).toBeTrue();
expect(result!.artifactId).toBe('test-artifact-123');
}));
it('prevents multiple concurrent exports', () => {
fixture.detectChanges();
let emitCount = 0;
component.exportStarted.subscribe(() => emitCount++);
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
button.click();
button.click();
expect(emitCount).toBe(1);
});
it('respects disabled input', () => {
component.disabled = true;
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
expect(button.disabled).toBeTrue();
let emitCount = 0;
component.exportStarted.subscribe(() => emitCount++);
button.click();
expect(emitCount).toBe(0);
});
});
describe('SB-004: Post-export toast', () => {
it('shows toast after successful export', fakeAsync(() => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
const toast = fixture.nativeElement.querySelector('.export-toast');
expect(toast).toBeTruthy();
expect(toast.classList.contains('success')).toBeTrue();
}));
it('displays OCI reference in monospace', fakeAsync(() => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
const ociRef = fixture.nativeElement.querySelector('.oci-ref');
expect(ociRef).toBeTruthy();
expect(ociRef.textContent).toContain('oci://');
}));
it('has Copy reference button', fakeAsync(() => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
const copyBtn = fixture.nativeElement.querySelector(
'.toast-actions .toast-btn'
);
expect(copyBtn).toBeTruthy();
expect(copyBtn.textContent).toContain('Copy reference');
}));
it('toast persists until dismissed', fakeAsync(() => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
// Wait additional time - toast should still be visible
tick(5000);
fixture.detectChanges();
const toast = fixture.nativeElement.querySelector('.export-toast');
expect(toast).toBeTruthy();
}));
it('dismisses toast on close button click', fakeAsync(() => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
const closeBtn = fixture.nativeElement.querySelector('.toast-close');
closeBtn.click();
fixture.detectChanges();
const toast = fixture.nativeElement.querySelector('.export-toast');
expect(toast).toBeFalsy();
}));
it('has View details link', fakeAsync(() => {
fixture.detectChanges();
let emittedResult: StellaBundleExportResult | null = null;
component.viewDetails.subscribe((r) => (emittedResult = r));
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
const detailsBtn = fixture.nativeElement.querySelector('.toast-btn-link');
expect(detailsBtn).toBeTruthy();
expect(detailsBtn.textContent).toContain('View details');
detailsBtn.click();
expect(emittedResult).toBeTruthy();
}));
});
describe('SB-002: Include options', () => {
it('uses StellaBundle preset by default', fakeAsync(() => {
fixture.detectChanges();
let result: StellaBundleExportResult | null = null;
component.exportComplete.subscribe((r) => (result = r));
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
expect(result!.includedFiles).toContain('sbom.cdx.json');
expect(result!.includedFiles).toContain('dsse-envelope.json');
expect(result!.includedFiles).toContain('rekor-receipt.json');
expect(result!.includedFiles).toContain('replay_log.json');
}));
it('accepts custom include options', () => {
component.includeOptions = {
replayLog: false,
vexDecisions: false,
};
fixture.detectChanges();
// Custom options should be merged with defaults
expect(component.includeOptions.replayLog).toBeFalse();
expect(component.includeOptions.vexDecisions).toBeFalse();
});
});
describe('SB-006: Telemetry', () => {
it('logs telemetry event on successful export', fakeAsync(() => {
fixture.detectChanges();
const consoleSpy = spyOn(console, 'log');
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
expect(consoleSpy).toHaveBeenCalledWith(
'[Telemetry]',
jasmine.objectContaining({
event: 'stella.bundle.exported',
properties: jasmine.objectContaining({
artifact_id: 'test-artifact-123',
format: 'oci',
includes_replay_log: true,
includes_dsse: true,
includes_rekor: true,
success: true,
}),
})
);
}));
it('includes duration in telemetry', fakeAsync(() => {
fixture.detectChanges();
const consoleSpy = spyOn(console, 'log');
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
const telemetryCall = consoleSpy.calls.mostRecent();
const telemetry = telemetryCall.args[1];
expect(telemetry.properties.duration_ms).toBeGreaterThan(0);
}));
});
describe('Accessibility', () => {
it('has correct aria-label in idle state', () => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
expect(button.getAttribute('aria-label')).toContain('Export StellaBundle');
});
it('sets aria-busy during export', () => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
fixture.detectChanges();
expect(button.getAttribute('aria-busy')).toBe('true');
});
it('toast has role=alert', fakeAsync(() => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
const toast = fixture.nativeElement.querySelector('.export-toast');
expect(toast.getAttribute('role')).toBe('alert');
expect(toast.getAttribute('aria-live')).toBe('polite');
}));
});
describe('Format options', () => {
it('defaults to OCI format', () => {
expect(component.format).toBe('oci');
});
it('supports tar.gz format', fakeAsync(() => {
component.format = 'tar.gz';
fixture.detectChanges();
let result: StellaBundleExportResult | null = null;
component.exportComplete.subscribe((r) => (result = r));
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
expect(result!.format).toBe('tar.gz');
expect(result!.downloadUrl).toBeTruthy();
expect(result!.ociReference).toBeFalsy();
}));
});
});

View File

@@ -0,0 +1,639 @@
// -----------------------------------------------------------------------------
// stella-bundle-export-button.component.ts
// Sprint: SPRINT_20260125_005_FE_stella_bundle_export
// Task SB-001: Create StellaBundle export button component
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
EventEmitter,
inject,
Input,
Output,
signal,
} from '@angular/core';
import {
ExportFormat,
StellaBundleExportRequest,
StellaBundleExportResult,
StellaBundleExportTelemetry,
StellaBundleIncludeOptions,
} from '../evidence-export.models';
/**
* Export state for the button
*/
export type ExportState = 'idle' | 'exporting' | 'success' | 'error';
/**
* StellaBundle Export Button Component (SB-001)
*
* Quick-action button for one-click StellaBundle export with OCI referrer support.
* Creates signed audit pack (DSSE+Rekor) suitable for auditor delivery.
*
* Usage:
* ```html
* <app-stella-bundle-export-button
* [artifactId]="artifact.id"
* (exportComplete)="onExportComplete($event)"
* />
* ```
*/
@Component({
selector: 'app-stella-bundle-export-button',
standalone: true,
imports: [CommonModule],
template: `
<button
class="stella-bundle-btn"
[class.exporting]="state() === 'exporting'"
[class.success]="state() === 'success'"
[class.error]="state() === 'error'"
[class.compact]="compact"
[disabled]="disabled || state() === 'exporting'"
[attr.aria-busy]="state() === 'exporting'"
[attr.aria-label]="ariaLabel()"
[title]="tooltipText"
(click)="onExport()"
>
<!-- Icon -->
<span class="btn-icon" aria-hidden="true">
@switch (state()) {
@case ('idle') {
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 1v10M4 7l4 4 4-4M2 13h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
}
@case ('exporting') {
<svg class="spinner" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-dashoffset="8"/>
</svg>
}
@case ('success') {
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3 8l3 3 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
}
@case ('error') {
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
}
}
</span>
<!-- Label -->
@if (!compact) {
<span class="btn-label">
@switch (state()) {
@case ('idle') { Export StellaBundle }
@case ('exporting') { Exporting... }
@case ('success') { Exported }
@case ('error') { Export Failed }
}
</span>
}
<!-- OCI badge -->
@if (!compact && showOciBadge) {
<span class="oci-badge" aria-label="OCI referrer format">OCI</span>
}
</button>
<!-- Post-export toast (SB-004) -->
@if (showToast() && lastResult()) {
<div
class="export-toast"
[class.success]="lastResult()!.success"
[class.error]="!lastResult()!.success"
role="alert"
aria-live="polite"
>
<div class="toast-content">
@if (lastResult()!.success) {
<span class="toast-icon">✓</span>
<div class="toast-message">
<span class="toast-title">Bundle exported</span>
@if (lastResult()!.ociReference) {
<code class="oci-ref">{{ lastResult()!.ociReference }}</code>
}
</div>
<div class="toast-actions">
@if (lastResult()!.ociReference) {
<button
class="toast-btn"
(click)="copyOciReference()"
[attr.aria-label]="'Copy OCI reference'"
>
{{ copied() ? 'Copied!' : 'Copy reference' }}
</button>
}
@if (lastResult()!.downloadUrl) {
<a
class="toast-btn"
[href]="lastResult()!.downloadUrl"
download
aria-label="Download bundle"
>
Download
</a>
}
<button
class="toast-btn toast-btn-link"
(click)="viewBundleDetails()"
aria-label="View bundle details"
>
View details
</button>
</div>
} @else {
<span class="toast-icon">✗</span>
<div class="toast-message">
<span class="toast-title">Export failed</span>
<span class="toast-error">{{ lastResult()!.errorMessage }}</span>
</div>
<button class="toast-btn" (click)="onExport()">Retry</button>
}
</div>
<button
class="toast-close"
(click)="dismissToast()"
aria-label="Dismiss notification"
>
×
</button>
</div>
}
`,
styles: [`
:host {
display: inline-block;
position: relative;
}
.stella-bundle-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--primary);
color: var(--on-primary);
border: none;
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: var(--primary-hover);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.compact {
padding: 0.375rem;
border-radius: 0.25rem;
.btn-label, .oci-badge { display: none; }
}
&.exporting {
background: var(--info);
}
&.success {
background: var(--success);
}
&.error {
background: var(--error);
}
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.btn-label {
white-space: nowrap;
}
.oci-badge {
padding: 0.125rem 0.375rem;
background: rgba(255, 255, 255, 0.2);
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
/* Toast styles (SB-004) */
.export-toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
max-width: 420px;
background: var(--surface-primary);
border-radius: 0.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
border: 1px solid var(--border);
z-index: 1000;
animation: slideIn 0.2s ease;
&.success {
border-left: 4px solid var(--success);
}
&.error {
border-left: 4px solid var(--error);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.toast-content {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
padding-right: 2.5rem;
}
.toast-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
.success & {
background: var(--success-surface);
color: var(--success);
}
.error & {
background: var(--error-surface);
color: var(--error);
}
}
.toast-message {
flex: 1;
min-width: 0;
}
.toast-title {
display: block;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
.oci-ref {
display: block;
font-family: monospace;
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--surface-secondary);
padding: 0.375rem 0.5rem;
border-radius: 0.25rem;
word-break: break-all;
margin: 0.5rem 0;
}
.toast-error {
display: block;
font-size: 0.8125rem;
color: var(--error);
}
.toast-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.toast-btn {
padding: 0.375rem 0.75rem;
background: var(--surface-secondary);
border: 1px solid var(--border);
border-radius: 0.25rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
color: var(--text-primary);
text-decoration: none;
&:hover {
background: var(--surface-hover);
}
&.toast-btn-link {
background: none;
border: none;
color: var(--primary);
padding: 0.375rem;
&:hover {
text-decoration: underline;
}
}
}
.toast-close {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
font-size: 1.25rem;
color: var(--text-secondary);
cursor: pointer;
border-radius: 0.25rem;
&:hover {
background: var(--surface-secondary);
color: var(--text-primary);
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StellaBundleExportButtonComponent {
/** Artifact ID to export */
@Input({ required: true }) artifactId!: string;
/** Disable the button */
@Input() disabled = false;
/** Compact mode (icon only) */
@Input() compact = false;
/** Show OCI badge on button */
@Input() showOciBadge = true;
/** Export format (default: OCI for StellaBundle) */
@Input() format: ExportFormat = 'oci';
/** Custom include options (defaults to StellaBundle preset) */
@Input() includeOptions?: Partial<StellaBundleIncludeOptions>;
/** Emitted when export starts */
@Output() exportStarted = new EventEmitter<string>();
/** Emitted when export completes successfully */
@Output() exportComplete = new EventEmitter<StellaBundleExportResult>();
/** Emitted when export fails */
@Output() exportError = new EventEmitter<Error>();
/** Emitted when user clicks "View details" in toast */
@Output() viewDetails = new EventEmitter<StellaBundleExportResult>();
// Internal state
readonly state = signal<ExportState>('idle');
readonly lastResult = signal<StellaBundleExportResult | null>(null);
readonly showToast = signal(false);
readonly copied = signal(false);
private exportStartTime = 0;
/** Tooltip text per advisory spec */
readonly tooltipText =
'Export StellaBundle — creates signed audit pack (DSSE+Rekor) suitable for auditor delivery (OCI referrer).';
/** Computed aria-label based on state */
readonly ariaLabel = computed(() => {
switch (this.state()) {
case 'idle':
return this.tooltipText;
case 'exporting':
return 'Exporting StellaBundle...';
case 'success':
return 'StellaBundle exported successfully';
case 'error':
return 'StellaBundle export failed';
}
});
/**
* Trigger the export
*/
async onExport(): Promise<void> {
if (this.state() === 'exporting' || this.disabled) {
return;
}
this.state.set('exporting');
this.showToast.set(false);
this.exportStartTime = Date.now();
this.exportStarted.emit(this.artifactId);
try {
const request = this.buildExportRequest();
const result = await this.executeExport(request);
this.state.set(result.success ? 'success' : 'error');
this.lastResult.set(result);
this.showToast.set(true);
if (result.success) {
this.exportComplete.emit(result);
this.emitTelemetry(result);
} else {
this.exportError.emit(new Error(result.errorMessage || 'Export failed'));
}
// Reset button state after delay (keep toast visible)
setTimeout(() => {
this.state.set('idle');
}, 3000);
} catch (error) {
this.state.set('error');
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.lastResult.set({
success: false,
exportId: '',
artifactId: this.artifactId,
format: this.format,
checksumSha256: '',
sizeBytes: 0,
includedFiles: [],
durationMs: Date.now() - this.exportStartTime,
completedAt: new Date().toISOString(),
errorMessage,
});
this.showToast.set(true);
this.exportError.emit(error instanceof Error ? error : new Error(errorMessage));
}
}
/**
* Build export request with StellaBundle preset options
*/
private buildExportRequest(): StellaBundleExportRequest {
const defaultOptions: StellaBundleIncludeOptions = {
canonicalizedSbom: true,
dsseEnvelope: true,
rekorTileReceipt: true,
replayLog: true,
vexDecisions: true,
policyEvaluations: true,
};
return {
artifactId: this.artifactId,
format: this.format,
includeOptions: { ...defaultOptions, ...this.includeOptions },
};
}
/**
* Execute the export (mock implementation - integrate with actual service)
*/
private async executeExport(
request: StellaBundleExportRequest
): Promise<StellaBundleExportResult> {
// TODO: Replace with actual ExportService call
// return this.exportService.exportStellaBundle(request);
// Mock implementation for UI development
await new Promise((resolve) => setTimeout(resolve, 2000));
const mockOciRef = `oci://registry.example.com/artifacts/${request.artifactId}@sha256:${this.generateMockSha()}`;
return {
success: true,
exportId: `exp-${Date.now()}`,
artifactId: request.artifactId,
format: request.format,
ociReference: request.format === 'oci' ? mockOciRef : undefined,
downloadUrl:
request.format !== 'oci'
? `/api/v1/exports/download/${request.artifactId}.${request.format}`
: undefined,
checksumSha256: `sha256:${this.generateMockSha()}`,
sizeBytes: 2567890,
includedFiles: [
'sbom.cdx.json',
'dsse-envelope.json',
'rekor-receipt.json',
'replay_log.json',
'vex-decisions.json',
'policy-evaluations.json',
],
durationMs: Date.now() - this.exportStartTime,
completedAt: new Date().toISOString(),
};
}
/**
* Emit telemetry event (SB-006)
*/
private emitTelemetry(result: StellaBundleExportResult): void {
const telemetry: StellaBundleExportTelemetry = {
event: 'stella.bundle.exported',
properties: {
artifact_id: result.artifactId,
format: result.format,
includes_replay_log: result.includedFiles.includes('replay_log.json'),
includes_dsse: result.includedFiles.includes('dsse-envelope.json'),
includes_rekor: result.includedFiles.includes('rekor-receipt.json'),
duration_ms: result.durationMs,
success: result.success,
},
};
// TODO: Replace with actual telemetry service
// this.telemetryService.track(telemetry);
console.log('[Telemetry]', telemetry);
}
/**
* Copy OCI reference to clipboard (SB-004)
*/
async copyOciReference(): Promise<void> {
const ref = this.lastResult()?.ociReference;
if (!ref) return;
try {
await navigator.clipboard.writeText(ref);
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
} catch (error) {
console.error('Failed to copy OCI reference:', error);
}
}
/**
* Dismiss the toast notification
*/
dismissToast(): void {
this.showToast.set(false);
}
/**
* View bundle details
*/
viewBundleDetails(): void {
const result = this.lastResult();
if (result) {
this.viewDetails.emit(result);
}
}
/**
* Generate mock SHA256 for demo
*/
private generateMockSha(): string {
const chars = 'abcdef0123456789';
return Array.from({ length: 64 }, () =>
chars[Math.floor(Math.random() * chars.length)]
).join('');
}
}

View File

@@ -1,10 +1,17 @@
// -----------------------------------------------------------------------------
// evidence-pills.component.spec.ts
// Sprint: SPRINT_20260125_001_FE_evidence_ribbon_enhancement
// Unit tests for enhanced evidence ribbon with DSSE/Rekor/SBOM pills
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import type { EvidenceBundle } from '../../models/evidence.model';
import { EvidencePillsComponent } from './evidence-pills.component';
import { EvidencePillsComponent, EvidencePillType } from './evidence-pills.component';
describe('EvidencePillsComponent', () => {
let fixture: ComponentFixture<EvidencePillsComponent>;
let component: EvidencePillsComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -12,62 +19,394 @@ describe('EvidencePillsComponent', () => {
}).compileComponents();
fixture = TestBed.createComponent(EvidencePillsComponent);
component = fixture.componentInstance;
});
afterEach(() => {
fixture.destroy();
});
it('renders 4 pills and completeness badge', () => {
fixture.detectChanges();
describe('Classic pills (backward compatibility)', () => {
it('renders 7 pills (4 classic + 3 new) and completeness badge by default', () => {
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelectorAll('button.pill').length).toBe(4);
expect(element.querySelector('.completeness-badge')?.textContent?.trim()).toBe('0/4');
const element = fixture.nativeElement as HTMLElement;
const pills = element.querySelectorAll('button.pill');
expect(pills.length).toBe(7); // 4 classic + 3 new (DSSE, Rekor, SBOM)
expect(element.querySelector('.completeness-badge')?.textContent?.trim()).toBe('0/7');
});
it('computes classic pill classes from evidence', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date(0).toISOString(),
reachability: { status: 'available', hash: 'sha256:reach' },
callstack: { status: 'loading', hash: 'sha256:call' },
provenance: { status: 'pending_enrichment', hash: 'sha256:prov' },
vex: { status: 'error' },
hashes: { combinedHash: 'sha256:all', hashes: ['sha256:reach', 'sha256:call'] },
};
component.evidence = evidence;
fixture.detectChanges();
const pills = Array.from(fixture.nativeElement.querySelectorAll('button.pill')) as HTMLButtonElement[];
// First 4 are classic pills
expect(pills[0].classList.contains('available')).toBeTrue();
expect(pills[1].classList.contains('loading')).toBeTrue();
expect(pills[2].classList.contains('pending')).toBeTrue();
expect(pills[3].classList.contains('unavailable')).toBeTrue();
expect(pills[0].getAttribute('aria-label')).toBe('Reachability: available');
expect(pills[1].getAttribute('aria-label')).toBe('Call-stack: loading');
expect(pills[2].getAttribute('aria-label')).toBe('Provenance: pending_enrichment');
expect(pills[3].getAttribute('aria-label')).toBe('VEX: error');
});
it('can hide classic pills with showClassicPills=false', () => {
component.showClassicPills = false;
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const pills = element.querySelectorAll('button.pill');
expect(pills.length).toBe(3); // Only new pills (DSSE, Rekor, SBOM)
expect(element.querySelector('.completeness-badge')?.textContent?.trim()).toBe('0/3');
});
});
it('computes pill classes and badge from evidence', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date(0).toISOString(),
reachability: { status: 'available', hash: 'sha256:reach' },
callstack: { status: 'loading', hash: 'sha256:call' },
provenance: { status: 'pending_enrichment', hash: 'sha256:prov' },
vex: { status: 'error' },
hashes: { combinedHash: 'sha256:all', hashes: ['sha256:reach', 'sha256:call'] },
};
describe('DSSE pill (ER-001)', () => {
it('shows verified state when DSSE status is valid', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
dsse: { status: 'valid', keyId: 'key-abc123456789' },
};
fixture.componentInstance.evidence = evidence;
fixture.detectChanges();
component.evidence = evidence;
component.showClassicPills = false;
fixture.detectChanges();
const pills = Array.from(fixture.nativeElement.querySelectorAll('button.pill')) as HTMLButtonElement[];
expect(pills.length).toBe(4);
const dssePill = fixture.nativeElement.querySelector('button.pill:first-of-type') as HTMLButtonElement;
expect(dssePill.classList.contains('verified')).toBeTrue();
expect(dssePill.getAttribute('aria-label')).toBe('DSSE: signature verified');
expect(dssePill.getAttribute('title')).toContain('DSSE signature verified');
expect(dssePill.getAttribute('title')).toContain('key: key-abc1');
});
expect(pills[0].classList.contains('available')).toBeTrue();
expect(pills[1].classList.contains('loading')).toBeTrue();
expect(pills[2].classList.contains('pending')).toBeTrue();
expect(pills[3].classList.contains('unavailable')).toBeTrue();
it('shows failed state when DSSE status is invalid', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
dsse: { status: 'invalid', details: 'signature mismatch' },
};
expect(pills[0].getAttribute('aria-label')).toBe('Reachability: available');
expect(pills[1].getAttribute('aria-label')).toBe('Call-stack: loading');
expect(pills[2].getAttribute('aria-label')).toBe('Provenance: pending_enrichment');
expect(pills[3].getAttribute('aria-label')).toBe('VEX: error');
component.evidence = evidence;
component.showClassicPills = false;
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.completeness-badge')?.textContent?.trim()).toBe('1/4');
const dssePill = fixture.nativeElement.querySelector('button.pill:first-of-type') as HTMLButtonElement;
expect(dssePill.classList.contains('failed')).toBeTrue();
expect(dssePill.getAttribute('aria-label')).toBe('DSSE: signature verification failed');
expect(dssePill.getAttribute('title')).toContain('signature mismatch');
});
it('shows missing state when DSSE is not present', () => {
component.showClassicPills = false;
fixture.detectChanges();
const dssePill = fixture.nativeElement.querySelector('button.pill:first-of-type') as HTMLButtonElement;
expect(dssePill.classList.contains('missing')).toBeTrue();
expect(dssePill.getAttribute('aria-label')).toBe('DSSE: no signature found');
});
});
it('emits pillClick when a pill is clicked', () => {
const component = fixture.componentInstance;
const clicks: Array<'reachability' | 'callstack' | 'provenance' | 'vex'> = [];
component.pillClick.subscribe((value) => clicks.push(value));
describe('Rekor pill (ER-002)', () => {
it('shows verified state with tile date when Rekor is ok', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
rekor: { ok: true, tileDate: '2026-01-12', logIndex: 12345 },
};
fixture.detectChanges();
component.evidence = evidence;
component.showClassicPills = false;
fixture.detectChanges();
const pills = (fixture.nativeElement as HTMLElement).querySelectorAll('button.pill');
(pills[1] as HTMLButtonElement).click();
(pills[3] as HTMLButtonElement).click();
const pills = fixture.nativeElement.querySelectorAll('button.pill') as NodeListOf<HTMLButtonElement>;
const rekorPill = pills[1]; // Second pill is Rekor
expect(clicks).toEqual(['callstack', 'vex']);
expect(rekorPill.classList.contains('verified')).toBeTrue();
expect(rekorPill.getAttribute('aria-label')).toContain('Rekor: verified');
expect(rekorPill.textContent).toContain('tile: 2026-01-12');
expect(rekorPill.getAttribute('title')).toContain('Rekor inclusion verified');
expect(rekorPill.getAttribute('title')).toContain('index: 12345');
});
it('shows failed state when Rekor inclusion failed', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
rekor: { ok: false },
};
component.evidence = evidence;
component.showClassicPills = false;
fixture.detectChanges();
const pills = fixture.nativeElement.querySelectorAll('button.pill') as NodeListOf<HTMLButtonElement>;
const rekorPill = pills[1];
expect(rekorPill.classList.contains('failed')).toBeTrue();
expect(rekorPill.getAttribute('aria-label')).toBe('Rekor: inclusion verification failed');
});
it('shows missing state when Rekor is not present', () => {
component.showClassicPills = false;
fixture.detectChanges();
const pills = fixture.nativeElement.querySelectorAll('button.pill') as NodeListOf<HTMLButtonElement>;
const rekorPill = pills[1];
expect(rekorPill.classList.contains('missing')).toBeTrue();
expect(rekorPill.getAttribute('aria-label')).toBe('Rekor: no inclusion found');
});
});
describe('SBOM pill (ER-003)', () => {
it('shows format and match percentage when SBOM is available', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
sbom: { format: 'CycloneDX', matchPct: 98, componentCount: 150 },
};
component.evidence = evidence;
component.showClassicPills = false;
fixture.detectChanges();
const pills = fixture.nativeElement.querySelectorAll('button.pill') as NodeListOf<HTMLButtonElement>;
const sbomPill = pills[2]; // Third pill is SBOM
expect(sbomPill.classList.contains('verified')).toBeTrue();
expect(sbomPill.textContent).toContain('CycloneDX');
expect(sbomPill.textContent).toContain('98% match');
expect(sbomPill.getAttribute('aria-label')).toBe('SBOM: CycloneDX, 98% component match');
});
it('shows download links when SBOM URLs are available', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
sbom: {
format: 'SPDX',
matchPct: 85,
downloadUrl: '/api/sbom/download',
vexDownloadUrl: '/api/vex/download',
},
rekor: { ok: true, receiptUrl: '/api/receipt' },
};
component.evidence = evidence;
component.showClassicPills = false;
fixture.detectChanges();
const downloadLinks = fixture.nativeElement.querySelectorAll('.download-link') as NodeListOf<HTMLAnchorElement>;
expect(downloadLinks.length).toBe(3); // SBOM, VEX, Receipt
expect(downloadLinks[0].getAttribute('href')).toBe('/api/sbom/download');
expect(downloadLinks[0].getAttribute('title')).toBe('Download SBOM');
expect(downloadLinks[1].getAttribute('href')).toBe('/api/vex/download');
expect(downloadLinks[2].getAttribute('href')).toBe('/api/receipt');
});
it('shows missing state when SBOM is not attached', () => {
component.showClassicPills = false;
fixture.detectChanges();
const pills = fixture.nativeElement.querySelectorAll('button.pill') as NodeListOf<HTMLButtonElement>;
const sbomPill = pills[2];
expect(sbomPill.classList.contains('missing')).toBeTrue();
expect(sbomPill.getAttribute('aria-label')).toBe('SBOM: not attached');
});
});
describe('Quick-Verify button (ER-004)', () => {
it('is enabled when DSSE is valid', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
dsse: { status: 'valid' },
};
component.evidence = evidence;
fixture.detectChanges();
const quickVerifyBtn = fixture.nativeElement.querySelector('.quick-verify-btn') as HTMLButtonElement;
expect(quickVerifyBtn.disabled).toBeFalse();
expect(quickVerifyBtn.classList.contains('disabled')).toBeFalse();
});
it('is enabled when Rekor is ok', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
rekor: { ok: true },
};
component.evidence = evidence;
fixture.detectChanges();
const quickVerifyBtn = fixture.nativeElement.querySelector('.quick-verify-btn') as HTMLButtonElement;
expect(quickVerifyBtn.disabled).toBeFalse();
});
it('is disabled when no verification evidence exists', () => {
fixture.detectChanges();
const quickVerifyBtn = fixture.nativeElement.querySelector('.quick-verify-btn') as HTMLButtonElement;
expect(quickVerifyBtn.disabled).toBeTrue();
expect(quickVerifyBtn.classList.contains('disabled')).toBeTrue();
});
it('shows "Why?" link when disabled', () => {
fixture.detectChanges();
const whyLink = fixture.nativeElement.querySelector('.why-link') as HTMLButtonElement;
expect(whyLink).toBeTruthy();
expect(whyLink.textContent?.trim()).toBe('Why?');
});
it('hides "Why?" link when enabled', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
dsse: { status: 'valid' },
};
component.evidence = evidence;
fixture.detectChanges();
const whyLink = fixture.nativeElement.querySelector('.why-link');
expect(whyLink).toBeFalsy();
});
it('emits quickVerifyClick when clicked', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
dsse: { status: 'valid' },
};
component.evidence = evidence;
let clicked = false;
component.quickVerifyClick.subscribe(() => (clicked = true));
fixture.detectChanges();
const quickVerifyBtn = fixture.nativeElement.querySelector('.quick-verify-btn') as HTMLButtonElement;
quickVerifyBtn.click();
expect(clicked).toBeTrue();
});
it('has correct tooltip per advisory spec', () => {
fixture.detectChanges();
const quickVerifyBtn = fixture.nativeElement.querySelector('.quick-verify-btn') as HTMLButtonElement;
expect(quickVerifyBtn.getAttribute('title')).toBe(
'Quick-Verify: deterministically replays signed proof; shows inclusion receipt and failure reason.'
);
});
});
describe('pillClick events', () => {
it('emits correct pill type for new pills', () => {
component.showClassicPills = false;
const clicks: EvidencePillType[] = [];
component.pillClick.subscribe((value) => clicks.push(value));
fixture.detectChanges();
const pills = fixture.nativeElement.querySelectorAll('button.pill') as NodeListOf<HTMLButtonElement>;
pills[0].click(); // DSSE
pills[1].click(); // Rekor
pills[2].click(); // SBOM
expect(clicks).toEqual(['dsse', 'rekor', 'sbom']);
});
it('emits correct pill type for classic pills', () => {
const clicks: EvidencePillType[] = [];
component.pillClick.subscribe((value) => clicks.push(value));
fixture.detectChanges();
const pills = fixture.nativeElement.querySelectorAll('button.pill') as NodeListOf<HTMLButtonElement>;
pills[0].click(); // Reachability
pills[1].click(); // Call-stack
pills[2].click(); // Provenance
pills[3].click(); // VEX
expect(clicks).toEqual(['reachability', 'callstack', 'provenance', 'vex']);
});
});
describe('Completeness calculation', () => {
it('counts only new pills when classic pills are hidden', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
dsse: { status: 'valid' },
rekor: { ok: true },
sbom: { format: 'CycloneDX', matchPct: 100 },
reachability: { status: 'available' },
};
component.evidence = evidence;
component.showClassicPills = false;
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.completeness-badge') as HTMLElement;
expect(badge.textContent?.trim()).toBe('3/3');
});
it('counts all pills when classic pills are shown', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date().toISOString(),
dsse: { status: 'valid' },
rekor: { ok: true },
sbom: { format: 'CycloneDX', matchPct: 100 },
reachability: { status: 'available' },
callstack: { status: 'available' },
provenance: { status: 'available' },
vex: { status: 'available' },
};
component.evidence = evidence;
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.completeness-badge') as HTMLElement;
expect(badge.textContent?.trim()).toBe('7/7');
});
});
describe('Accessibility', () => {
it('has proper ARIA region role', () => {
fixture.detectChanges();
const ribbon = fixture.nativeElement.querySelector('.evidence-ribbon') as HTMLElement;
expect(ribbon.getAttribute('role')).toBe('region');
expect(ribbon.getAttribute('aria-label')).toBe('Evidence verification status');
});
it('has screen reader description for Quick-Verify', () => {
fixture.detectChanges();
const srOnly = fixture.nativeElement.querySelector('#quick-verify-desc') as HTMLElement;
expect(srOnly).toBeTruthy();
expect(srOnly.textContent).toContain('Deterministically replays signed proof');
});
});
});

View File

@@ -1,157 +1,550 @@
// -----------------------------------------------------------------------------
// evidence-pills.component.ts
// Sprint: SPRINT_20260125_001_FE_evidence_ribbon_enhancement
// Enhanced evidence ribbon with DSSE/Rekor/SBOM pills and Quick-Verify button
// -----------------------------------------------------------------------------
import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { EvidenceBundle, EvidenceStatus, EvidenceBitset } from '../../models/evidence.model';
import {
EvidenceBundle,
EvidenceStatus,
EvidenceBitset,
DsseEvidenceSection,
RekorEvidenceSection,
SbomEvidenceSection,
} from '../../models/evidence.model';
/** Pill types that can be clicked */
export type EvidencePillType =
| 'reachability'
| 'callstack'
| 'provenance'
| 'vex'
| 'dsse'
| 'rekor'
| 'sbom';
@Component({
selector: 'app-evidence-pills',
standalone: true,
imports: [CommonModule],
template: `
<div class="evidence-pills">
<div class="evidence-ribbon" role="region" aria-label="Evidence verification status">
<!-- Classic pills (configurable) -->
@if (showClassicPills()) {
<button class="pill"
[class.available]="reachabilityStatus() === 'available'"
[class.loading]="reachabilityStatus() === 'loading'"
[class.unavailable]="reachabilityStatus() === 'unavailable' || reachabilityStatus() === 'error'"
[class.pending]="reachabilityStatus() === 'pending_enrichment'"
(click)="pillClick.emit('reachability')"
[attr.aria-label]="'Reachability: ' + reachabilityStatus()">
<span class="icon" aria-hidden="true">{{ getIcon(reachabilityStatus()) }}</span>
<span class="label">Reachability</span>
</button>
<button class="pill"
[class.available]="callstackStatus() === 'available'"
[class.loading]="callstackStatus() === 'loading'"
[class.unavailable]="callstackStatus() === 'unavailable' || callstackStatus() === 'error'"
[class.pending]="callstackStatus() === 'pending_enrichment'"
(click)="pillClick.emit('callstack')"
[attr.aria-label]="'Call-stack: ' + callstackStatus()">
<span class="icon" aria-hidden="true">{{ getIcon(callstackStatus()) }}</span>
<span class="label">Call-stack</span>
</button>
<button class="pill"
[class.available]="provenanceStatus() === 'available'"
[class.loading]="provenanceStatus() === 'loading'"
[class.unavailable]="provenanceStatus() === 'unavailable' || provenanceStatus() === 'error'"
[class.pending]="provenanceStatus() === 'pending_enrichment'"
(click)="pillClick.emit('provenance')"
[attr.aria-label]="'Provenance: ' + provenanceStatus()">
<span class="icon" aria-hidden="true">{{ getIcon(provenanceStatus()) }}</span>
<span class="label">Provenance</span>
</button>
<button class="pill"
[class.available]="vexStatus() === 'available'"
[class.loading]="vexStatus() === 'loading'"
[class.unavailable]="vexStatus() === 'unavailable' || vexStatus() === 'error'"
[class.pending]="vexStatus() === 'pending_enrichment'"
(click)="pillClick.emit('vex')"
[attr.aria-label]="'VEX: ' + vexStatus()">
<span class="icon" aria-hidden="true">{{ getIcon(vexStatus()) }}</span>
<span class="label">VEX</span>
</button>
}
<!-- DSSE Pill (ER-001) -->
<button class="pill"
[class.available]="reachabilityStatus() === 'available'"
[class.loading]="reachabilityStatus() === 'loading'"
[class.unavailable]="reachabilityStatus() === 'unavailable' || reachabilityStatus() === 'error'"
[class.pending]="reachabilityStatus() === 'pending_enrichment'"
(click)="pillClick.emit('reachability')"
[attr.aria-label]="'Reachability: ' + reachabilityStatus()">
<span class="icon">{{ getIcon(reachabilityStatus()) }}</span>
<span class="label">Reachability</span>
[class.verified]="dsseVerified()"
[class.failed]="dsseFailed()"
[class.missing]="dsseMissing()"
(click)="pillClick.emit('dsse')"
[attr.aria-label]="dsseAriaLabel()"
[title]="dsseTooltip()">
<span class="icon" aria-hidden="true">{{ dsseVerified() ? '\u2713' : '\u2717' }}</span>
<span class="label">DSSE</span>
</button>
<!-- Rekor Pill (ER-002) -->
<button class="pill"
[class.available]="callstackStatus() === 'available'"
[class.loading]="callstackStatus() === 'loading'"
[class.unavailable]="callstackStatus() === 'unavailable' || callstackStatus() === 'error'"
[class.pending]="callstackStatus() === 'pending_enrichment'"
(click)="pillClick.emit('callstack')"
[attr.aria-label]="'Call-stack: ' + callstackStatus()">
<span class="icon">{{ getIcon(callstackStatus()) }}</span>
<span class="label">Call-stack</span>
[class.verified]="rekorVerified()"
[class.failed]="!rekorVerified() && !rekorMissing()"
[class.missing]="rekorMissing()"
(click)="pillClick.emit('rekor')"
[attr.aria-label]="rekorAriaLabel()"
[title]="rekorTooltip()">
<span class="icon" aria-hidden="true">{{ rekorVerified() ? '\u2713' : '\u2717' }}</span>
<span class="label">Rekor</span>
@if (rekorVerified() && rekorTileDate()) {
<span class="pill-detail">(tile: {{ rekorTileDate() }})</span>
}
</button>
<!-- SBOM Pill (ER-003) -->
<button class="pill"
[class.available]="provenanceStatus() === 'available'"
[class.loading]="provenanceStatus() === 'loading'"
[class.unavailable]="provenanceStatus() === 'unavailable' || provenanceStatus() === 'error'"
[class.pending]="provenanceStatus() === 'pending_enrichment'"
(click)="pillClick.emit('provenance')"
[attr.aria-label]="'Provenance: ' + provenanceStatus()">
<span class="icon">{{ getIcon(provenanceStatus()) }}</span>
<span class="label">Provenance</span>
[class.verified]="sbomAvailable()"
[class.missing]="!sbomAvailable()"
(click)="pillClick.emit('sbom')"
[attr.aria-label]="sbomAriaLabel()"
[title]="sbomTooltip()">
<span class="icon" aria-hidden="true">{{ sbomAvailable() ? '\u2713' : '\u2717' }}</span>
<span class="label">SBOM</span>
@if (sbomAvailable()) {
<span class="pill-detail">{{ sbomFormat() }} · {{ sbomMatchPct() }}% match</span>
}
</button>
<button class="pill"
[class.available]="vexStatus() === 'available'"
[class.loading]="vexStatus() === 'loading'"
[class.unavailable]="vexStatus() === 'unavailable' || vexStatus() === 'error'"
[class.pending]="vexStatus() === 'pending_enrichment'"
(click)="pillClick.emit('vex')"
[attr.aria-label]="'VEX: ' + vexStatus()">
<span class="icon">{{ getIcon(vexStatus()) }}</span>
<span class="label">VEX</span>
<!-- Download links (ER-003) -->
@if (sbomAvailable()) {
<div class="download-links">
@if (sbomDownloadUrl()) {
<a class="download-link"
[href]="sbomDownloadUrl()"
target="_blank"
rel="noopener"
title="Download SBOM"
aria-label="Download SBOM file">
<span aria-hidden="true">\u2B07</span>
</a>
}
@if (vexDownloadUrl()) {
<a class="download-link"
[href]="vexDownloadUrl()"
target="_blank"
rel="noopener"
title="Download VEX"
aria-label="Download VEX file">
<span aria-hidden="true">\u2B07</span>
</a>
}
@if (receiptUrl()) {
<a class="download-link"
[href]="receiptUrl()"
target="_blank"
rel="noopener"
title="Signed receipt (JSON)"
aria-label="View signed receipt">
<span aria-hidden="true">\u{1F517}</span>
</a>
}
</div>
}
<!-- Completeness badge -->
@if (showCompletenessBadge()) {
<div class="completeness-badge" [attr.aria-label]="'Evidence completeness: ' + completenessScore() + ' of ' + totalPillCount()">
{{ completenessScore() }}/{{ totalPillCount() }}
</div>
}
<!-- Quick-Verify Button (ER-004) -->
<button class="quick-verify-btn"
[class.disabled]="!canQuickVerify()"
[disabled]="!canQuickVerify()"
(click)="onQuickVerifyClick()"
[title]="quickVerifyTooltip"
aria-describedby="quick-verify-desc">
<span class="btn-icon" aria-hidden="true">\u25B6</span>
<span class="btn-label">Quick-Verify</span>
</button>
<div class="completeness-badge" [attr.aria-label]="'Evidence completeness: ' + completenessScore() + ' of 4'">
{{ completenessScore() }}/4
</div>
<!-- Why link when verification unavailable -->
@if (!canQuickVerify()) {
<button class="why-link"
(click)="whyClick.emit()"
aria-label="Why is verification unavailable?">
Why?
</button>
}
<!-- Hidden description for screen readers -->
<span id="quick-verify-desc" class="sr-only">
Deterministically replays signed proof; shows inclusion receipt and failure reason.
</span>
</div>
`,
styles: [`
.evidence-pills {
.evidence-ribbon {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Base pill styles - 20-22px height per advisory spec */
.pill {
display: flex;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 16px;
font-size: 13px;
height: 22px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
transition: all 0.15s ease;
background: var(--surface-variant, #f5f5f5);
border: 1px solid transparent;
color: var(--text-primary, #333);
font-weight: 500;
}
.pill .icon {
font-size: 12px;
line-height: 1;
}
.pill .pill-detail {
font-size: 11px;
opacity: 0.8;
margin-left: 2px;
}
/* Verified state (success) */
.pill.verified,
.pill.available {
background: var(--success-bg, #e8f5e9);
color: var(--success-text, #2e7d32);
border-color: var(--success-border, #a5d6a7);
}
/* Failed state */
.pill.failed {
background: var(--error-bg, #ffebee);
color: var(--error-text, #c62828);
border-color: var(--error-border, #ef9a9a);
}
/* Missing state (muted) */
.pill.missing,
.pill.unavailable {
background: var(--surface-muted, #e0e0e0);
color: var(--text-muted, #9e9e9e);
border-color: var(--border-muted, #bdbdbd);
opacity: 0.7;
}
/* Loading state */
.pill.loading {
background: var(--warning-bg, #fff3e0);
color: var(--warning-text, #ef6c00);
border-color: var(--warning-border, #ffcc80);
}
.pill.unavailable {
background: var(--error-bg, #ffebee);
color: var(--error-text, #c62828);
border-color: var(--error-border, #ef9a9a);
opacity: 0.7;
}
/* Pending state */
.pill.pending {
background: var(--info-bg, #e3f2fd);
color: var(--info-text, #1565c0);
border-color: var(--info-border, #90caf9);
}
.pill:hover {
.pill:hover:not(.missing):not(.unavailable) {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.pill:focus {
outline: 2px solid var(--primary-color, #1976d2);
.pill:focus-visible {
outline: 2px solid var(--focus-ring, #1976d2);
outline-offset: 2px;
}
.icon {
font-size: 14px;
/* Download links */
.download-links {
display: flex;
gap: 4px;
margin-left: 4px;
}
.label {
font-weight: 500;
.download-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #666);
text-decoration: none;
font-size: 12px;
transition: background 0.15s;
}
.download-link:hover {
background: var(--surface-hover, #e0e0e0);
color: var(--primary-color, #1976d2);
}
.download-link:focus-visible {
outline: 2px solid var(--focus-ring, #1976d2);
outline-offset: 1px;
}
/* Completeness badge */
.completeness-badge {
margin-left: auto;
font-weight: 600;
color: var(--text-secondary, #666);
padding: 4px 8px;
padding: 2px 8px;
background: var(--surface-variant, #f5f5f5);
border-radius: 12px;
font-size: 11px;
}
/* Quick-Verify button (primary action) */
.quick-verify-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
background: var(--primary-color, #1976d2);
color: white;
border: none;
transition: all 0.15s ease;
margin-left: 8px;
}
.quick-verify-btn:hover:not(.disabled) {
background: var(--primary-hover, #1565c0);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(25, 118, 210, 0.3);
}
.quick-verify-btn:focus-visible {
outline: 2px solid var(--focus-ring, #1976d2);
outline-offset: 2px;
}
.quick-verify-btn.disabled {
background: var(--surface-disabled, #bdbdbd);
color: var(--text-disabled, #9e9e9e);
cursor: not-allowed;
}
.quick-verify-btn .btn-icon {
font-size: 10px;
}
/* Why link */
.why-link {
background: none;
border: none;
color: var(--text-link, #1976d2);
font-size: 12px;
cursor: pointer;
text-decoration: underline;
padding: 4px;
}
.why-link:hover {
color: var(--text-link-hover, #1565c0);
}
.why-link:focus-visible {
outline: 2px solid var(--focus-ring, #1976d2);
outline-offset: 2px;
}
/* High contrast mode */
@media (prefers-contrast: high) {
.pill.verified,
.pill.available {
border-width: 2px;
}
.pill.failed {
border-width: 2px;
}
}
/* Responsive: wrap on narrow screens */
@media (max-width: 600px) {
.evidence-ribbon {
gap: 6px;
}
.pill .label {
display: none;
}
.pill .pill-detail {
display: none;
}
.quick-verify-btn .btn-label {
display: none;
}
}
`]
})
export class EvidencePillsComponent {
private _evidence = signal<EvidenceBundle | undefined>(undefined);
private _showClassicPills = signal(true);
private _showCompletenessBadge = signal(true);
/** Advisory-specified tooltip for Quick-Verify button */
readonly quickVerifyTooltip = 'Quick-Verify: deterministically replays signed proof; shows inclusion receipt and failure reason.';
@Input()
set evidence(value: EvidenceBundle | undefined) {
this._evidence.set(value);
}
@Output() pillClick = new EventEmitter<'reachability' | 'callstack' | 'provenance' | 'vex'>();
/** Whether to show classic pills (Reachability, Call-stack, Provenance, VEX) */
@Input()
set showClassicPills(value: boolean) {
this._showClassicPills.set(value);
}
reachabilityStatus = computed(() => this._evidence()?.reachability?.status ?? 'unavailable');
callstackStatus = computed(() => this._evidence()?.callstack?.status ?? 'unavailable');
provenanceStatus = computed(() => this._evidence()?.provenance?.status ?? 'unavailable');
vexStatus = computed(() => this._evidence()?.vex?.status ?? 'unavailable');
/** Whether to show completeness badge */
@Input()
set showCompletenessBadge(value: boolean) {
this._showCompletenessBadge.set(value);
}
completenessScore = computed(() => {
@Output() pillClick = new EventEmitter<EvidencePillType>();
@Output() quickVerifyClick = new EventEmitter<void>();
@Output() whyClick = new EventEmitter<void>();
// Classic pill status signals
readonly showClassicPills = computed(() => this._showClassicPills());
readonly showCompletenessBadge = computed(() => this._showCompletenessBadge());
readonly reachabilityStatus = computed(() => this._evidence()?.reachability?.status ?? 'unavailable');
readonly callstackStatus = computed(() => this._evidence()?.callstack?.status ?? 'unavailable');
readonly provenanceStatus = computed(() => this._evidence()?.provenance?.status ?? 'unavailable');
readonly vexStatus = computed(() => this._evidence()?.vex?.status ?? 'unavailable');
// DSSE pill signals (ER-001)
readonly dsseVerified = computed(() => this._evidence()?.dsse?.status === 'valid');
readonly dsseFailed = computed(() => this._evidence()?.dsse?.status === 'invalid');
readonly dsseMissing = computed(() => !this._evidence()?.dsse || this._evidence()?.dsse?.status === 'missing');
readonly dsseAriaLabel = computed(() => {
const dsse = this._evidence()?.dsse;
if (!dsse || dsse.status === 'missing') return 'DSSE: no signature found';
if (dsse.status === 'valid') return 'DSSE: signature verified';
return 'DSSE: signature verification failed';
});
readonly dsseTooltip = computed(() => {
const dsse = this._evidence()?.dsse;
if (!dsse || dsse.status === 'missing') return 'DSSE signature: not found';
if (dsse.status === 'valid') {
return `DSSE signature verified${dsse.keyId ? ` (key: ${dsse.keyId.slice(0, 8)}...)` : ''}`;
}
return `DSSE signature verification failed: ${dsse.details ?? 'unknown error'}`;
});
// Rekor pill signals (ER-002)
readonly rekorVerified = computed(() => this._evidence()?.rekor?.ok === true);
readonly rekorMissing = computed(() => !this._evidence()?.rekor);
readonly rekorTileDate = computed(() => this._evidence()?.rekor?.tileDate ?? null);
readonly rekorAriaLabel = computed(() => {
const rekor = this._evidence()?.rekor;
if (!rekor) return 'Rekor: no inclusion found';
if (rekor.ok) return `Rekor: verified, tile ${rekor.tileDate ?? 'unknown'}`;
return 'Rekor: inclusion verification failed';
});
readonly rekorTooltip = computed(() => {
const rekor = this._evidence()?.rekor;
if (!rekor) return 'Rekor transparency log: no inclusion found';
if (rekor.ok) {
return `Rekor inclusion verified on ${rekor.tileDate ?? 'unknown date'}${rekor.logIndex ? ` (index: ${rekor.logIndex})` : ''}`;
}
return 'Rekor inclusion proof verification failed';
});
// SBOM pill signals (ER-003)
readonly sbomAvailable = computed(() => !!this._evidence()?.sbom);
readonly sbomFormat = computed(() => this._evidence()?.sbom?.format ?? 'unknown');
readonly sbomMatchPct = computed(() => this._evidence()?.sbom?.matchPct ?? 0);
readonly sbomDownloadUrl = computed(() => this._evidence()?.sbom?.downloadUrl ?? null);
readonly vexDownloadUrl = computed(() => this._evidence()?.sbom?.vexDownloadUrl ?? null);
readonly receiptUrl = computed(() => this._evidence()?.rekor?.receiptUrl ?? null);
readonly sbomAriaLabel = computed(() => {
const sbom = this._evidence()?.sbom;
if (!sbom) return 'SBOM: not attached';
return `SBOM: ${sbom.format}, ${sbom.matchPct}% component match`;
});
readonly sbomTooltip = computed(() => {
const sbom = this._evidence()?.sbom;
if (!sbom) return 'SBOM: not attached to this artifact';
return `SBOM format: ${sbom.format}${sbom.version ? ` v${sbom.version}` : ''}, ${sbom.matchPct}% component match${sbom.componentCount ? ` (${sbom.componentCount} components)` : ''}`;
});
// Completeness calculation
readonly completenessScore = computed(() => {
const bundle = this._evidence();
return EvidenceBitset.fromBundle(bundle).completenessScore;
let score = 0;
// New evidence pills
if (bundle?.dsse?.status === 'valid') score++;
if (bundle?.rekor?.ok) score++;
if (bundle?.sbom) score++;
// Classic pills (if showing)
if (this._showClassicPills()) {
if (bundle?.reachability?.status === 'available') score++;
if (bundle?.callstack?.status === 'available') score++;
if (bundle?.provenance?.status === 'available') score++;
if (bundle?.vex?.status === 'available') score++;
}
return score;
});
readonly totalPillCount = computed(() => {
return this._showClassicPills() ? 7 : 3;
});
// Quick-Verify availability (ER-004)
readonly canQuickVerify = computed(() => {
const bundle = this._evidence();
// Can verify if we have at least DSSE or Rekor evidence
return bundle?.dsse?.status === 'valid' || bundle?.rekor?.ok === true;
});
getIcon(status: EvidenceStatus): string {
@@ -170,4 +563,10 @@ export class EvidencePillsComponent {
return '?';
}
}
onQuickVerifyClick(): void {
if (this.canQuickVerify()) {
this.quickVerifyClick.emit();
}
}
}

View File

@@ -0,0 +1,15 @@
// -----------------------------------------------------------------------------
// Quiet Triage Lane Components - Public API
// Sprint: SPRINT_20260125_003_FE_quiet_triage_lane
// -----------------------------------------------------------------------------
export { TtlCountdownChipComponent } from './ttl-countdown-chip.component';
export {
ParkedItemCardComponent,
ParkedFinding,
ParkedReason,
} from './parked-item-card.component';
export {
QuietLaneContainerComponent,
TriageLaneType,
} from './quiet-lane-container.component';

View File

@@ -0,0 +1,443 @@
// -----------------------------------------------------------------------------
// parked-item-card.component.ts
// Sprint: SPRINT_20260125_003_FE_quiet_triage_lane
// Task: QT-004, QT-005 - Parked item card with inline actions
// Description: Collapsed card for quiet triage lane items with muted styling
// -----------------------------------------------------------------------------
import {
Component,
Input,
Output,
EventEmitter,
signal,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { TtlCountdownChipComponent } from './ttl-countdown-chip.component';
/** Reason badges for why item is parked */
export type ParkedReason =
| 'low_evidence'
| 'vendor_only'
| 'unverified'
| 'low_confidence'
| 'no_fix_available'
| 'disputed';
/** Parked finding data */
export interface ParkedFinding {
id: string;
title: string;
component: string;
version: string;
severity: 'critical' | 'high' | 'medium' | 'low' | 'unknown';
reasons: ParkedReason[];
parkedAt: string;
expiresAt: string;
parkedBy?: string;
}
const REASON_LABELS: Record<ParkedReason, string> = {
low_evidence: 'low evidence',
vendor_only: 'vendor-only',
unverified: 'unverified',
low_confidence: 'low confidence',
no_fix_available: 'no fix available',
disputed: 'disputed',
};
@Component({
selector: 'app-parked-item-card',
standalone: true,
imports: [CommonModule, TtlCountdownChipComponent],
template: `
<article
class="parked-card"
[class.expanded]="expanded()"
[attr.aria-expanded]="expanded()"
>
<!-- Collapsed view (always visible) -->
<div class="card-header" (click)="toggleExpanded()">
<div class="card-main">
<h4 class="card-title">{{ finding.title }}</h4>
<div class="card-meta">
<span class="component-version">
{{ finding.component }}@{{ finding.version }}
</span>
<span class="severity-badge" [class]="finding.severity">
{{ finding.severity }}
</span>
</div>
</div>
<div class="card-badges">
@for (reason of finding.reasons; track reason) {
<span class="reason-badge">{{ getReasonLabel(reason) }}</span>
}
</div>
<div class="card-right">
<app-ttl-countdown-chip [expiresAt]="finding.expiresAt" />
<button
class="expand-toggle"
[attr.aria-label]="expanded() ? 'Collapse details' : 'Expand details'"
type="button"
>
<span aria-hidden="true">{{ expanded() ? '\u25B2' : '\u25BC' }}</span>
</button>
</div>
</div>
<!-- Expanded view (details) -->
@if (expanded()) {
<div class="card-details">
<div class="detail-row">
<span class="detail-label">Parked on:</span>
<span class="detail-value">{{ formatDate(finding.parkedAt) }}</span>
</div>
@if (finding.parkedBy) {
<div class="detail-row">
<span class="detail-label">Parked by:</span>
<span class="detail-value">{{ finding.parkedBy }}</span>
</div>
}
<div class="detail-row">
<span class="detail-label">Auto-prune:</span>
<span class="detail-value">{{ formatDate(finding.expiresAt) }}</span>
</div>
</div>
}
<!-- Inline actions (QT-005) -->
<div class="card-actions">
<button
class="action-btn"
(click)="onRecheck($event)"
[disabled]="actionLoading()"
type="button"
>
@if (actionLoading() && currentAction() === 'recheck') {
<span class="spinner" aria-hidden="true"></span>
}
Recheck now
</button>
<button
class="action-btn primary"
(click)="onPromote($event)"
[disabled]="actionLoading()"
type="button"
>
@if (actionLoading() && currentAction() === 'promote') {
<span class="spinner" aria-hidden="true"></span>
}
Promote to Active
</button>
<button
class="action-btn secondary"
(click)="onExtendTtl($event)"
[disabled]="actionLoading()"
type="button"
>
Extend 30d
</button>
</div>
</article>
`,
styles: [`
.parked-card {
background: var(--surface-muted, #f5f5f5);
border: 1px solid var(--border-muted, #e0e0e0);
border-radius: 8px;
padding: 12px 16px;
opacity: 0.85;
transition: all 0.2s ease;
}
.parked-card:hover {
opacity: 1;
border-color: var(--border-color, #bdbdbd);
}
.parked-card.expanded {
opacity: 1;
background: var(--surface-primary, #fff);
}
/* Header */
.card-header {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.card-main {
flex: 1;
min-width: 0;
}
.card-title {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #333);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.component-version {
color: var(--text-secondary, #666);
font-family: monospace;
}
.severity-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.severity-badge.critical {
background: var(--severity-critical-bg, #d32f2f);
color: white;
}
.severity-badge.high {
background: var(--severity-high-bg, #f57c00);
color: white;
}
.severity-badge.medium {
background: var(--severity-medium-bg, #fbc02d);
color: #333;
}
.severity-badge.low {
background: var(--severity-low-bg, #388e3c);
color: white;
}
.severity-badge.unknown {
background: var(--surface-muted, #9e9e9e);
color: white;
}
/* Badges */
.card-badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.reason-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
background: var(--surface-secondary, #e0e0e0);
color: var(--text-muted, #666);
white-space: nowrap;
}
/* Right section */
.card-right {
display: flex;
align-items: center;
gap: 8px;
}
.expand-toggle {
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted, #9e9e9e);
font-size: 10px;
}
.expand-toggle:hover {
background: var(--surface-hover, #e0e0e0);
}
/* Details (expanded) */
.card-details {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color, #e0e0e0);
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.detail-row {
display: flex;
gap: 8px;
font-size: 12px;
}
.detail-label {
color: var(--text-muted, #9e9e9e);
}
.detail-value {
color: var(--text-primary, #333);
}
/* Actions */
.card-actions {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.action-btn {
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
background: transparent;
border: 1px solid var(--border-color, #e0e0e0);
color: var(--text-secondary, #666);
display: inline-flex;
align-items: center;
gap: 6px;
}
.action-btn:hover:not(:disabled) {
background: var(--surface-hover, #f5f5f5);
border-color: var(--border-hover, #bdbdbd);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn.primary {
background: var(--primary-color, #1976d2);
border-color: var(--primary-color, #1976d2);
color: white;
}
.action-btn.primary:hover:not(:disabled) {
background: var(--primary-hover, #1565c0);
}
.action-btn.secondary {
color: var(--text-link, #1976d2);
border-color: var(--text-link, #1976d2);
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 600px) {
.card-header {
flex-wrap: wrap;
}
.card-badges {
order: 3;
width: 100%;
margin-top: 8px;
}
.card-actions {
flex-wrap: wrap;
}
.action-btn {
flex: 1;
justify-content: center;
}
}
`]
})
export class ParkedItemCardComponent {
@Input({ required: true }) finding!: ParkedFinding;
@Output() recheckRequested = new EventEmitter<string>();
@Output() promoteRequested = new EventEmitter<string>();
@Output() extendTtlRequested = new EventEmitter<string>();
private _expanded = signal(false);
private _actionLoading = signal(false);
private _currentAction = signal<'recheck' | 'promote' | 'extend' | null>(null);
readonly expanded = computed(() => this._expanded());
readonly actionLoading = computed(() => this._actionLoading());
readonly currentAction = computed(() => this._currentAction());
toggleExpanded(): void {
this._expanded.update(v => !v);
}
getReasonLabel(reason: ParkedReason): string {
return REASON_LABELS[reason] ?? reason;
}
formatDate(dateStr: string): string {
try {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch {
return dateStr;
}
}
onRecheck(event: Event): void {
event.stopPropagation();
this._currentAction.set('recheck');
this._actionLoading.set(true);
this.recheckRequested.emit(this.finding.id);
}
onPromote(event: Event): void {
event.stopPropagation();
this._currentAction.set('promote');
this._actionLoading.set(true);
this.promoteRequested.emit(this.finding.id);
}
onExtendTtl(event: Event): void {
event.stopPropagation();
this._currentAction.set('extend');
this._actionLoading.set(true);
this.extendTtlRequested.emit(this.finding.id);
}
/** Reset loading state after action completes */
resetActionState(): void {
this._actionLoading.set(false);
this._currentAction.set(null);
}
}

View File

@@ -0,0 +1,400 @@
// -----------------------------------------------------------------------------
// quiet-lane-container.component.ts
// Sprint: SPRINT_20260125_003_FE_quiet_triage_lane
// Task: QT-007 - Parked lane container with auto-prune indicator
// Description: Container view for the Quiet Triage (Parked) lane
// -----------------------------------------------------------------------------
import {
Component,
Input,
Output,
EventEmitter,
computed,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ParkedItemCardComponent, ParkedFinding } from './parked-item-card.component';
/** Lane selection state */
export type TriageLaneType = 'active' | 'parked' | 'review';
@Component({
selector: 'app-quiet-lane-container',
standalone: true,
imports: [CommonModule, ParkedItemCardComponent],
template: `
<div class="quiet-lane">
<!-- Header -->
<header class="lane-header">
<div class="header-main">
<h2 class="lane-title">
Parked
<span class="lane-count">({{ findings().length }})</span>
</h2>
<span class="auto-prune-badge">auto-prune</span>
</div>
<!-- Bulk actions -->
@if (findings().length > 0) {
<div class="bulk-actions">
<button
class="bulk-btn"
(click)="onPromoteAll()"
[disabled]="bulkLoading()"
type="button"
>
Promote All
</button>
<button
class="bulk-btn danger"
(click)="onClearExpired()"
[disabled]="bulkLoading() || expiredCount() === 0"
type="button"
>
Clear Expired ({{ expiredCount() }})
</button>
</div>
}
</header>
<!-- Info banner -->
<div class="info-banner" role="status">
<span class="info-icon" aria-hidden="true">\u24D8</span>
<p class="info-text">
Items here are automatically removed after <strong>{{ defaultTtlDays }} days</strong>.
Use "Promote to Active" to move items back to your active triage queue.
</p>
</div>
<!-- Findings list -->
<div class="findings-list" role="list">
@if (loading()) {
<div class="loading-state">
<div class="spinner"></div>
<span>Loading parked items...</span>
</div>
} @else if (error()) {
<div class="error-state">
<span class="error-icon">!</span>
<span>{{ error() }}</span>
<button class="retry-btn" (click)="retryRequested.emit()" type="button">
Retry
</button>
</div>
} @else if (findings().length === 0) {
<div class="empty-state">
<span class="empty-icon" aria-hidden="true">\u2713</span>
<h3>No parked items</h3>
<p>Items you send to Quiet Triage will appear here.</p>
</div>
} @else {
@for (finding of findings(); track finding.id) {
<app-parked-item-card
[finding]="finding"
(recheckRequested)="onRecheckItem($event)"
(promoteRequested)="onPromoteItem($event)"
(extendTtlRequested)="onExtendTtlItem($event)"
/>
}
}
</div>
<!-- Footer with pagination/stats -->
@if (findings().length > 0) {
<footer class="lane-footer">
<span class="stats">
{{ findings().length }} parked
@if (expiredCount() > 0) {
· <span class="expired-count">{{ expiredCount() }} expired</span>
}
</span>
</footer>
}
</div>
`,
styles: [`
.quiet-lane {
display: flex;
flex-direction: column;
height: 100%;
background: var(--surface-secondary, #fafafa);
}
/* Header */
.lane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: var(--surface-primary, #fff);
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.header-main {
display: flex;
align-items: center;
gap: 12px;
}
.lane-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.lane-count {
font-weight: 400;
color: var(--text-muted, #9e9e9e);
}
.auto-prune-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
background: var(--info-bg, #e3f2fd);
color: var(--info-text, #1565c0);
}
.bulk-actions {
display: flex;
gap: 8px;
}
.bulk-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
background: var(--surface-secondary, #f5f5f5);
border: 1px solid var(--border-color, #e0e0e0);
color: var(--text-primary, #333);
}
.bulk-btn:hover:not(:disabled) {
background: var(--surface-hover, #e0e0e0);
}
.bulk-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bulk-btn.danger {
color: var(--error-text, #c62828);
border-color: var(--error-border, #ef9a9a);
}
.bulk-btn.danger:hover:not(:disabled) {
background: var(--error-bg, #ffebee);
}
/* Info banner */
.info-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 20px;
background: var(--info-bg-subtle, #f5f9ff);
border-bottom: 1px solid var(--info-border, #90caf9);
}
.info-icon {
font-size: 16px;
color: var(--info-text, #1565c0);
flex-shrink: 0;
}
.info-text {
margin: 0;
font-size: 13px;
color: var(--text-secondary, #666);
line-height: 1.5;
}
.info-text strong {
color: var(--text-primary, #333);
}
/* Findings list */
.findings-list {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* States */
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-muted, #9e9e9e);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color, #e0e0e0);
border-top-color: var(--primary-color, #1976d2);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-state {
color: var(--error-text, #c62828);
}
.error-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--error-bg, #ffebee);
color: var(--error-text, #c62828);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 20px;
margin-bottom: 16px;
}
.retry-btn {
margin-top: 16px;
padding: 8px 16px;
background: var(--primary-color, #1976d2);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.empty-state {
color: var(--text-secondary, #666);
}
.empty-icon {
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--success-bg, #e8f5e9);
color: var(--success-text, #2e7d32);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
margin-bottom: 16px;
}
.empty-state h3 {
margin: 0 0 8px 0;
font-size: 16px;
color: var(--text-primary, #333);
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* Footer */
.lane-footer {
padding: 12px 20px;
background: var(--surface-primary, #fff);
border-top: 1px solid var(--border-color, #e0e0e0);
}
.stats {
font-size: 12px;
color: var(--text-muted, #9e9e9e);
}
.expired-count {
color: var(--error-text, #c62828);
}
`]
})
export class QuietLaneContainerComponent {
private _findings = signal<ParkedFinding[]>([]);
private _loading = signal(false);
private _error = signal<string | null>(null);
private _bulkLoading = signal(false);
@Input()
set findings(value: ParkedFinding[]) {
this._findings.set(value);
}
@Input() defaultTtlDays = 30;
@Input()
set loading(value: boolean) {
this._loading.set(value);
}
@Input()
set error(value: string | null) {
this._error.set(value);
}
@Output() recheckRequested = new EventEmitter<string[]>();
@Output() promoteRequested = new EventEmitter<string[]>();
@Output() extendTtlRequested = new EventEmitter<string[]>();
@Output() clearExpiredRequested = new EventEmitter<void>();
@Output() retryRequested = new EventEmitter<void>();
readonly findings = computed(() => this._findings());
readonly loading = computed(() => this._loading());
readonly error = computed(() => this._error());
readonly bulkLoading = computed(() => this._bulkLoading());
readonly expiredCount = computed(() => {
const now = new Date();
return this._findings().filter(f => new Date(f.expiresAt) <= now).length;
});
onRecheckItem(findingId: string): void {
this.recheckRequested.emit([findingId]);
}
onPromoteItem(findingId: string): void {
this.promoteRequested.emit([findingId]);
}
onExtendTtlItem(findingId: string): void {
this.extendTtlRequested.emit([findingId]);
}
onPromoteAll(): void {
this._bulkLoading.set(true);
const ids = this._findings().map(f => f.id);
this.promoteRequested.emit(ids);
}
onClearExpired(): void {
this._bulkLoading.set(true);
this.clearExpiredRequested.emit();
}
/** Reset bulk loading state */
resetBulkLoading(): void {
this._bulkLoading.set(false);
}
}

View File

@@ -0,0 +1,197 @@
// -----------------------------------------------------------------------------
// ttl-countdown-chip.component.ts
// Sprint: SPRINT_20260125_003_FE_quiet_triage_lane
// Task: QT-003 - TTL countdown chip component
// Description: Shows time until auto-prune with color-coded urgency
// -----------------------------------------------------------------------------
import {
Component,
Input,
computed,
signal,
effect,
OnDestroy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
/** Color threshold configuration */
interface TtlThreshold {
daysRemaining: number;
color: 'green' | 'yellow' | 'red';
label: string;
}
const TTL_THRESHOLDS: TtlThreshold[] = [
{ daysRemaining: 14, color: 'green', label: 'Plenty of time' },
{ daysRemaining: 7, color: 'yellow', label: 'Expiring soon' },
{ daysRemaining: 0, color: 'red', label: 'Expiring very soon' },
];
@Component({
selector: 'app-ttl-countdown-chip',
standalone: true,
imports: [CommonModule],
template: `
<span
class="ttl-chip"
[class.green]="chipColor() === 'green'"
[class.yellow]="chipColor() === 'yellow'"
[class.red]="chipColor() === 'red'"
[class.expired]="isExpired()"
[attr.aria-label]="ariaLabel()"
[title]="tooltip()"
>
<span class="ttl-icon" aria-hidden="true">{{ icon() }}</span>
<span class="ttl-text">{{ displayText() }}</span>
</span>
`,
styles: [`
.ttl-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.ttl-icon {
font-size: 10px;
}
.ttl-chip.green {
background: var(--success-bg, #e8f5e9);
color: var(--success-text, #2e7d32);
}
.ttl-chip.yellow {
background: var(--warning-bg, #fff3e0);
color: var(--warning-text, #ef6c00);
}
.ttl-chip.red {
background: var(--error-bg, #ffebee);
color: var(--error-text, #c62828);
}
.ttl-chip.expired {
background: var(--surface-muted, #e0e0e0);
color: var(--text-muted, #9e9e9e);
text-decoration: line-through;
}
`]
})
export class TtlCountdownChipComponent implements OnDestroy {
private _expiresAt = signal<Date | null>(null);
private _showExact = signal(false);
private _now = signal(new Date());
private _updateInterval: ReturnType<typeof setInterval> | null = null;
@Input()
set expiresAt(value: Date | string) {
const date = typeof value === 'string' ? new Date(value) : value;
this._expiresAt.set(date);
}
/** Show exact date (e.g., "Jan 25") instead of relative (e.g., "29d") */
@Input()
set showExact(value: boolean) {
this._showExact.set(value);
}
constructor() {
// Update every minute for real-time countdown
this._updateInterval = setInterval(() => {
this._now.set(new Date());
}, 60000);
}
ngOnDestroy(): void {
if (this._updateInterval) {
clearInterval(this._updateInterval);
}
}
readonly isExpired = computed(() => {
const expires = this._expiresAt();
if (!expires) return false;
return this._now() >= expires;
});
readonly daysRemaining = computed(() => {
const expires = this._expiresAt();
if (!expires) return 0;
const diff = expires.getTime() - this._now().getTime();
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
});
readonly hoursRemaining = computed(() => {
const expires = this._expiresAt();
if (!expires) return 0;
const diff = expires.getTime() - this._now().getTime();
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60)));
});
readonly chipColor = computed<'green' | 'yellow' | 'red'>(() => {
if (this.isExpired()) return 'red';
const days = this.daysRemaining();
if (days > 14) return 'green';
if (days > 7) return 'yellow';
return 'red';
});
readonly icon = computed(() => {
if (this.isExpired()) return '\u23F1'; // stopwatch
const days = this.daysRemaining();
if (days > 14) return '\u23F0'; // clock
if (days > 7) return '\u26A0'; // warning
return '\u{1F525}'; // fire
});
readonly displayText = computed(() => {
if (this.isExpired()) return 'Expired';
const days = this.daysRemaining();
const hours = this.hoursRemaining();
if (this._showExact()) {
const expires = this._expiresAt();
if (!expires) return '--';
return expires.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
if (days === 0) {
return hours <= 1 ? '<1h left' : `${hours}h left`;
}
return `${days}d left`;
});
readonly tooltip = computed(() => {
const expires = this._expiresAt();
if (!expires) return 'No expiration date set';
if (this.isExpired()) {
return `Expired on ${expires.toLocaleString()}`;
}
return `Auto-prune on ${expires.toLocaleString()}`;
});
readonly ariaLabel = computed(() => {
if (this.isExpired()) return 'Item has expired and will be pruned';
const days = this.daysRemaining();
const expires = this._expiresAt();
if (!expires) return 'No expiration date';
return `${days} days until auto-prune on ${expires.toLocaleDateString()}`;
});
}

View File

@@ -1,8 +1,9 @@
// -----------------------------------------------------------------------------
// triage-lane-toggle.component.ts
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
// Task: T002 - Create TriageLaneToggle component
// Description: Toggle between Quiet (actionable) and Review (hidden/gated) lanes
// Sprint: SPRINT_20260125_003_FE_quiet_triage_lane
// Task: QT-001, QT-002 - Three-lane triage toggle
// Description: Toggle between Active, Parked (auto-prune), and Review lanes
// Note: Renamed 'quiet' to 'active' per advisory terminology alignment
// -----------------------------------------------------------------------------
import {
@@ -16,7 +17,11 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
export type TriageLane = 'quiet' | 'review';
/** @deprecated Use TriageLaneType for new code */
export type TriageLane = 'active' | 'parked' | 'review';
/** Preferred type name for three-lane system */
export type TriageLaneType = TriageLane;
@Component({
selector: 'app-triage-lane-toggle',
@@ -24,23 +29,48 @@ export type TriageLane = 'quiet' | 'review';
imports: [CommonModule],
template: `
<div class="lane-toggle" role="tablist" aria-label="Triage lane selection">
<!-- Active lane (formerly 'quiet') -->
<button
class="lane-toggle__btn"
[class.lane-toggle__btn--active]="currentLane() === 'quiet'"
[class.lane-toggle__btn--active]="currentLane() === 'active'"
role="tab"
[attr.aria-selected]="currentLane() === 'quiet'"
[attr.aria-selected]="currentLane() === 'active'"
[attr.aria-controls]="'findings-panel'"
tabindex="0"
(click)="selectLane('quiet')"
(keydown.ArrowRight)="selectLane('review')"
(click)="selectLane('active')"
(keydown.ArrowRight)="selectLane('parked')"
>
<span class="lane-toggle__icon" aria-hidden="true"></span>
<span class="lane-toggle__label">Actionable</span>
<span class="lane-toggle__count" [attr.aria-label]="visibleCount + ' actionable findings'">
({{ visibleCount }})
<span class="lane-toggle__icon" aria-hidden="true">\u2713</span>
<span class="lane-toggle__label">Active</span>
<span class="lane-toggle__count" [attr.aria-label]="activeCount + ' active findings'">
({{ activeCount }})
</span>
</button>
<!-- Parked lane (QT-002 - new for quiet triage) -->
<button
class="lane-toggle__btn"
[class.lane-toggle__btn--active]="currentLane() === 'parked'"
[class.lane-toggle__btn--parked]="true"
role="tab"
[attr.aria-selected]="currentLane() === 'parked'"
[attr.aria-controls]="'findings-panel'"
tabindex="0"
(click)="selectLane('parked')"
(keydown.ArrowLeft)="selectLane('active')"
(keydown.ArrowRight)="selectLane('review')"
>
<span class="lane-toggle__icon" aria-hidden="true">\u23F8</span>
<span class="lane-toggle__label">Parked</span>
<span class="lane-toggle__count" [attr.aria-label]="parkedCount + ' parked findings'">
({{ parkedCount }})
</span>
@if (parkedCount > 0) {
<span class="lane-toggle__badge" title="Auto-prune enabled">30d</span>
}
</button>
<!-- Review lane -->
<button
class="lane-toggle__btn"
[class.lane-toggle__btn--active]="currentLane() === 'review'"
@@ -49,18 +79,18 @@ export type TriageLane = 'quiet' | 'review';
[attr.aria-controls]="'findings-panel'"
tabindex="0"
(click)="selectLane('review')"
(keydown.ArrowLeft)="selectLane('quiet')"
(keydown.ArrowLeft)="selectLane('parked')"
>
<span class="lane-toggle__icon" aria-hidden="true">👁</span>
<span class="lane-toggle__icon" aria-hidden="true">\u{1F441}</span>
<span class="lane-toggle__label">Review</span>
<span class="lane-toggle__count" [attr.aria-label]="hiddenCount + ' hidden findings'">
({{ hiddenCount }})
<span class="lane-toggle__count" [attr.aria-label]="reviewCount + ' review findings'">
({{ reviewCount }})
</span>
</button>
<!-- Keyboard hint -->
<span class="lane-toggle__hint" aria-hidden="true">
Press <kbd>Q</kbd> for Quiet, <kbd>R</kbd> for Review
Press <kbd>A</kbd> Active, <kbd>P</kbd> Parked, <kbd>R</kbd> Review
</span>
</div>
`,
@@ -137,6 +167,31 @@ export type TriageLane = 'quiet' | 'review';
font-size: 0.75rem;
}
/* Parked lane badge (30d indicator) */
.lane-toggle__badge {
padding: 0.125rem 0.375rem;
background: var(--info-bg, #e3f2fd);
color: var(--info-text, #1565c0);
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
margin-left: 0.25rem;
}
.lane-toggle__btn--active .lane-toggle__badge {
background: rgba(255, 255, 255, 0.25);
color: inherit;
}
/* Parked lane specific styling */
.lane-toggle__btn--parked:not(.lane-toggle__btn--active) {
opacity: 0.8;
}
.lane-toggle__btn--parked.lane-toggle__btn--active {
background: var(--info-color, #1976d2);
}
/* High contrast mode */
@media (prefers-contrast: high) {
.lane-toggle__btn--active {
@@ -146,17 +201,31 @@ export type TriageLane = 'quiet' | 'review';
`]
})
export class TriageLaneToggleComponent {
/** @deprecated Use activeCount instead */
@Input() visibleCount = 0;
/** @deprecated Use reviewCount instead */
@Input() hiddenCount = 0;
/** Count for Active lane */
@Input() activeCount = 0;
/** Count for Parked lane (auto-prune items) */
@Input() parkedCount = 0;
/** Count for Review lane */
@Input() reviewCount = 0;
@Input() set lane(value: TriageLane) {
this.currentLane.set(value);
// Handle legacy 'quiet' value
const normalizedValue = value === ('quiet' as TriageLane) ? 'active' : value;
this.currentLane.set(normalizedValue);
}
@Output() laneChange = new EventEmitter<TriageLane>();
readonly currentLane = signal<TriageLane>('quiet');
readonly currentLane = signal<TriageLane>('active');
readonly totalCount = computed(() => this.visibleCount + this.hiddenCount);
readonly totalCount = computed(() =>
this.activeCount + this.parkedCount + this.reviewCount
);
selectLane(lane: TriageLane): void {
if (this.currentLane() !== lane) {
@@ -165,18 +234,35 @@ export class TriageLaneToggleComponent {
}
}
@HostListener('document:keydown.q', ['$event'])
onQuietShortcut(event: KeyboardEvent): void {
// Only if not in an input field
/** Navigate to next lane (for arrow key navigation) */
private getNextLane(current: TriageLane, direction: 'left' | 'right'): TriageLane {
const order: TriageLane[] = ['active', 'parked', 'review'];
const currentIndex = order.indexOf(current);
if (direction === 'right') {
return order[(currentIndex + 1) % order.length];
} else {
return order[(currentIndex - 1 + order.length) % order.length];
}
}
@HostListener('document:keydown.a', ['$event'])
onActiveShortcut(event: KeyboardEvent): void {
if (!this.isInputFocused()) {
event.preventDefault();
this.selectLane('quiet');
this.selectLane('active');
}
}
@HostListener('document:keydown.p', ['$event'])
onParkedShortcut(event: KeyboardEvent): void {
if (!this.isInputFocused()) {
event.preventDefault();
this.selectLane('parked');
}
}
@HostListener('document:keydown.r', ['$event'])
onReviewShortcut(event: KeyboardEvent): void {
// Only if not in an input field
if (!this.isInputFocused()) {
event.preventDefault();
this.selectLane('review');

View File

@@ -16,6 +16,12 @@ export interface EvidenceBundle {
aiCodeGuard?: AiCodeGuardEvidenceSection;
hashes?: EvidenceHashes;
computedAt: string;
/** DSSE signature evidence. Sprint: SPRINT_20260125_001_FE */
dsse?: DsseEvidenceSection;
/** Rekor transparency log evidence. Sprint: SPRINT_20260125_001_FE */
rekor?: RekorEvidenceSection;
/** SBOM evidence. Sprint: SPRINT_20260125_001_FE */
sbom?: SbomEvidenceSection;
}
/**
@@ -92,6 +98,61 @@ export interface EvidenceHashes {
hashes?: string[];
}
/**
* DSSE (Dead Simple Signing Envelope) evidence section.
* Sprint: SPRINT_20260125_001_FE_evidence_ribbon_enhancement
* Task: ER-001
*/
export interface DsseEvidenceSection {
status: 'valid' | 'invalid' | 'missing';
keyId?: string;
algorithm?: string;
signedAt?: string;
details?: string;
envelopeUrl?: string;
}
/**
* Rekor transparency log evidence section.
* Sprint: SPRINT_20260125_001_FE_evidence_ribbon_enhancement
* Task: ER-002
*/
export interface RekorEvidenceSection {
ok: boolean;
tileDate?: string;
logIndex?: number;
receiptUrl?: string;
inclusionProof?: string;
}
/**
* SBOM (Software Bill of Materials) evidence section.
* Sprint: SPRINT_20260125_001_FE_evidence_ribbon_enhancement
* Task: ER-003
*/
export interface SbomEvidenceSection {
format: 'CycloneDX' | 'SPDX' | 'SWID' | 'unknown';
version?: string;
matchPct: number;
componentCount?: number;
downloadUrl?: string;
vexDownloadUrl?: string;
}
/**
* Evidence ribbon data for artifact verification display.
* Sprint: SPRINT_20260125_001_FE_evidence_ribbon_enhancement
* Task: ER-006
*/
export interface EvidenceRibbonData {
artifactId: string;
dsse: DsseEvidenceSection;
rekor: RekorEvidenceSection;
sbom: SbomEvidenceSection | null;
canVerify: boolean;
lastVerifiedAt?: string;
}
/**
* Evidence bitset for completeness tracking.
*/

View File

@@ -0,0 +1,14 @@
// -----------------------------------------------------------------------------
// VEX Merge Panel - Public API
// Sprint: SPRINT_20260125_004_FE_vex_merge_panel_enhancement
// -----------------------------------------------------------------------------
export {
VexMergePanelComponent,
VexSourceType,
ConfidenceLevel,
DiffBadgeType,
VexProvenance,
VexMergeRow,
VexMergeConflict,
} from './vex-merge-panel.component';

View File

@@ -0,0 +1,373 @@
// -----------------------------------------------------------------------------
// vex-merge-panel.component.spec.ts
// Sprint: SPRINT_20260125_004_FE_vex_merge_panel_enhancement
// Unit tests for VEX merge panel
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import {
VexMergePanelComponent,
VexMergeConflict,
VexMergeRow,
} from './vex-merge-panel.component';
describe('VexMergePanelComponent', () => {
let fixture: ComponentFixture<VexMergePanelComponent>;
let component: VexMergePanelComponent;
const mockRow: VexMergeRow = {
id: 'row-1',
source: 'vendor',
sourceName: 'Vendor A',
confidence: 'high',
status: 'not_affected',
diffType: 'added',
addedCount: 2,
provenance: {
url: 'https://vendor.com/vex/123',
ingestedAt: '2026-01-20T10:00:00Z',
rawSnippet: '{"status": "not_affected"}',
previousStatus: 'affected',
currentStatus: 'not_affected',
},
isWinning: true,
trustScore: 0.95,
ruleId: 'rule-vendor-priority',
};
const mockConflict: VexMergeConflict = {
id: 'conflict-1',
vulnId: 'CVE-2026-1234',
productId: 'product-abc',
rows: [
mockRow,
{
...mockRow,
id: 'row-2',
source: 'distro',
sourceName: 'Distro B',
confidence: 'medium',
diffType: 'unchanged',
isWinning: false,
trustScore: 0.7,
},
],
finalStatus: 'not_affected',
mergeStrategy: 'priority',
conflictResolved: true,
resolutionRule: 'vendor-priority-rule',
mergedAt: '2026-01-20T12:00:00Z',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VexMergePanelComponent, RouterTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(VexMergePanelComponent);
component = fixture.componentInstance;
});
afterEach(() => {
fixture.destroy();
});
describe('3-column layout (VM-001)', () => {
it('renders table with Source, Confidence, Diff columns', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const headers = fixture.nativeElement.querySelectorAll('.table-header [role="columnheader"]');
expect(headers.length).toBe(3);
expect(headers[0].textContent).toContain('Source');
expect(headers[1].textContent).toContain('Confidence');
expect(headers[2].textContent).toContain('Merge Diff');
});
it('renders rows for each source', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.table-row');
expect(rows.length).toBe(2);
});
it('highlights winning row', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.table-row');
expect(rows[0].classList.contains('winning')).toBeTrue();
expect(rows[1].classList.contains('winning')).toBeFalse();
});
});
describe('Diff badges (VM-002)', () => {
it('shows added badge with count', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const addedBadge = fixture.nativeElement.querySelector('.diff-badge.added');
expect(addedBadge).toBeTruthy();
expect(addedBadge.textContent).toContain('+2');
});
it('shows unchanged badge for unchanged rows', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.table-row');
const unchangedBadge = rows[1].querySelector('.diff-badge.unchanged');
expect(unchangedBadge).toBeTruthy();
});
});
describe('Confidence column', () => {
it('shows confidence badge with correct class', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const badges = fixture.nativeElement.querySelectorAll('.confidence-badge');
expect(badges[0].classList.contains('high')).toBeTrue();
expect(badges[1].classList.contains('medium')).toBeTrue();
});
it('shows trust score percentage', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const scores = fixture.nativeElement.querySelectorAll('.trust-score');
expect(scores[0].textContent).toContain('95%');
expect(scores[1].textContent).toContain('70%');
});
});
describe('Provenance popover (VM-003)', () => {
it('shows popover on source click', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const trigger = fixture.nativeElement.querySelector('.source-trigger');
trigger.click();
fixture.detectChanges();
const popover = fixture.nativeElement.querySelector('.provenance-popover');
expect(popover).toBeTruthy();
});
it('displays origin URL in popover', () => {
component.conflict = mockConflict;
fixture.detectChanges();
// Click to open
const trigger = fixture.nativeElement.querySelector('.source-trigger');
trigger.click();
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.prov-link');
expect(link.href).toContain('vendor.com/vex/123');
});
it('shows previous vs current diff', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const trigger = fixture.nativeElement.querySelector('.source-trigger');
trigger.click();
fixture.detectChanges();
const diffOld = fixture.nativeElement.querySelector('.diff-old');
const diffNew = fixture.nativeElement.querySelector('.diff-new');
expect(diffOld.textContent).toContain('affected');
expect(diffNew.textContent).toContain('not_affected');
});
it('shows raw VEX snippet', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const trigger = fixture.nativeElement.querySelector('.source-trigger');
trigger.click();
fixture.detectChanges();
const snippet = fixture.nativeElement.querySelector('.snippet-content');
expect(snippet.textContent).toContain('not_affected');
});
});
describe('Trust Algebra link (VM-004)', () => {
it('links to trust algebra with rule ID', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.trust-algebra-link');
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toContain('/policy/trust-algebra');
});
});
describe('Conflict resolution (VM-005)', () => {
it('shows conflict resolution when resolved', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const resolution = fixture.nativeElement.querySelector('.conflict-resolution');
expect(resolution).toBeTruthy();
});
it('shows resolution rule', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const rule = fixture.nativeElement.querySelector('.resolution-rule code');
expect(rule.textContent).toContain('vendor-priority-rule');
});
it('hides resolution when not resolved', () => {
component.conflict = { ...mockConflict, conflictResolved: false };
fixture.detectChanges();
const resolution = fixture.nativeElement.querySelector('.conflict-resolution');
expect(resolution).toBeFalsy();
});
});
describe('Download actions (VM-006)', () => {
it('emits downloadMerged on button click', () => {
component.conflict = mockConflict;
let emitted = false;
component.downloadMerged.subscribe(() => (emitted = true));
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll('.action-btn');
buttons[0].click();
expect(emitted).toBeTrue();
});
it('emits downloadAllSources on button click', () => {
component.conflict = mockConflict;
let emitted = false;
component.downloadAllSources.subscribe(() => (emitted = true));
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll('.action-btn');
buttons[1].click();
expect(emitted).toBeTrue();
});
});
describe('Source icons', () => {
it('returns correct icons for each source type', () => {
expect(component.getSourceIcon('vendor')).toBeTruthy();
expect(component.getSourceIcon('distro')).toBeTruthy();
expect(component.getSourceIcon('internal')).toBeTruthy();
expect(component.getSourceIcon('community')).toBeTruthy();
});
});
describe('Date formatting', () => {
it('formats dates correctly', () => {
const formatted = component.formatDate('2026-01-20T10:00:00Z');
expect(formatted).toContain('2026');
expect(formatted).toContain('Jan');
});
it('handles invalid dates', () => {
expect(component.formatDate('')).toBe('--');
});
});
describe('Empty state (VM-005)', () => {
it('shows empty state when no rows', () => {
component.conflict = { ...mockConflict, rows: [] };
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState).toBeTruthy();
expect(emptyState.textContent).toContain('No VEX statements available');
});
it('hides empty state when rows exist', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState).toBeFalsy();
});
});
describe('Popover outside click (VM-003)', () => {
it('closes popover on outside click', () => {
component.conflict = mockConflict;
fixture.detectChanges();
// Open popover
const trigger = fixture.nativeElement.querySelector('.source-trigger');
trigger.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.provenance-popover')).toBeTruthy();
// Simulate outside click
document.body.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.provenance-popover')).toBeFalsy();
});
it('closes popover on Escape key', () => {
component.conflict = mockConflict;
fixture.detectChanges();
// Open popover
const trigger = fixture.nativeElement.querySelector('.source-trigger');
trigger.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.provenance-popover')).toBeTruthy();
// Press Escape
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' });
document.dispatchEvent(escapeEvent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.provenance-popover')).toBeFalsy();
});
});
describe('Download loading state (VM-006)', () => {
it('shows loading state during merged VEX download', async () => {
component.conflict = mockConflict;
fixture.detectChanges();
const downloadBtn = fixture.nativeElement.querySelector('.download-actions .action-btn');
downloadBtn.click();
fixture.detectChanges();
expect(downloadBtn.classList.contains('loading')).toBeTrue();
expect(downloadBtn.textContent).toContain('Downloading...');
});
it('disables download button when no rows', () => {
component.conflict = { ...mockConflict, rows: [] };
fixture.detectChanges();
const downloadBtn = fixture.nativeElement.querySelector('.download-actions .action-btn');
expect(downloadBtn.disabled).toBeTrue();
});
});
describe('Trust Algebra link (VM-004)', () => {
it('has tooltip explaining Trust Algebra', () => {
component.conflict = mockConflict;
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.trust-algebra-link');
expect(link.getAttribute('title')).toContain('policy rule');
expect(link.getAttribute('title')).toContain('merge decision');
});
});
});

View File

@@ -0,0 +1,14 @@
// -----------------------------------------------------------------------------
// Quick-Verify Drawer - Public API
// Sprint: SPRINT_20260125_002_FE_quick_verify_drawer
// -----------------------------------------------------------------------------
export {
QuickVerifyDrawerComponent,
VerifyStep,
VerifyStepStatus,
VerifyResult,
DsseReceipt,
VerifyFailureCategory,
DrawerStatus,
} from './quick-verify-drawer.component';

View File

@@ -0,0 +1,341 @@
// -----------------------------------------------------------------------------
// quick-verify-drawer.component.spec.ts
// Sprint: SPRINT_20260125_002_FE_quick_verify_drawer
// Unit tests for Quick-Verify drawer component
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { QuickVerifyDrawerComponent, VerifyStep } from './quick-verify-drawer.component';
describe('QuickVerifyDrawerComponent', () => {
let fixture: ComponentFixture<QuickVerifyDrawerComponent>;
let component: QuickVerifyDrawerComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [QuickVerifyDrawerComponent],
}).compileComponents();
fixture = TestBed.createComponent(QuickVerifyDrawerComponent);
component = fixture.componentInstance;
});
afterEach(() => {
fixture.destroy();
});
describe('Drawer visibility', () => {
it('is hidden by default', () => {
fixture.detectChanges();
const drawer = fixture.nativeElement.querySelector('.quick-verify-drawer') as HTMLElement;
expect(drawer.classList.contains('open')).toBeFalse();
});
it('shows when isOpen is true', () => {
component.isOpen = true;
component.artifactId = 'test-artifact';
fixture.detectChanges();
const drawer = fixture.nativeElement.querySelector('.quick-verify-drawer') as HTMLElement;
expect(drawer.classList.contains('open')).toBeTrue();
});
it('shows backdrop when open', () => {
component.isOpen = true;
component.artifactId = 'test-artifact';
fixture.detectChanges();
const backdrop = fixture.nativeElement.querySelector('.drawer-backdrop');
expect(backdrop).toBeTruthy();
});
it('emits close when backdrop is clicked', () => {
component.isOpen = true;
component.artifactId = 'test-artifact';
let closed = false;
component.close.subscribe(() => (closed = true));
fixture.detectChanges();
const backdrop = fixture.nativeElement.querySelector('.drawer-backdrop') as HTMLElement;
backdrop.click();
expect(closed).toBeTrue();
});
it('emits close when close button is clicked', () => {
component.isOpen = true;
component.artifactId = 'test-artifact';
let closed = false;
component.close.subscribe(() => (closed = true));
fixture.detectChanges();
const closeBtn = fixture.nativeElement.querySelector('.close-btn') as HTMLButtonElement;
closeBtn.click();
expect(closed).toBeTrue();
});
});
describe('Header status (QV-001)', () => {
it('shows Ready status initially', () => {
component.isOpen = true;
component.artifactId = 'test-artifact';
// Prevent auto-start
spyOn(component as any, 'simulateVerification');
fixture.detectChanges();
const statusBadge = fixture.nativeElement.querySelector('.status-badge') as HTMLElement;
expect(statusBadge.textContent?.trim()).toBe('Ready');
expect(statusBadge.classList.contains('idle')).toBeTrue();
});
it('is 480px wide on desktop', () => {
fixture.detectChanges();
const drawer = fixture.nativeElement.querySelector('.quick-verify-drawer') as HTMLElement;
const styles = getComputedStyle(drawer);
expect(styles.width).toBe('480px');
});
it('has sticky header', () => {
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.drawer-header') as HTMLElement;
const styles = getComputedStyle(header);
expect(styles.position).toBe('sticky');
});
});
describe('Step list (QV-002)', () => {
it('shows empty state when no steps', () => {
component.isOpen = true;
component.artifactId = 'test-artifact';
// Prevent auto-start
spyOn(component as any, 'simulateVerification');
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState).toBeTruthy();
expect(emptyState.textContent).toContain('Click Quick-Verify to start');
});
it('displays step icons based on status', () => {
// Manually set steps
(component as any)._steps.set([
{ id: '1', name: 'Step 1', status: 'success' },
{ id: '2', name: 'Step 2', status: 'running' },
{ id: '3', name: 'Step 3', status: 'pending' },
{ id: '4', name: 'Step 4', status: 'failed' },
] as VerifyStep[]);
fixture.detectChanges();
const steps = fixture.nativeElement.querySelectorAll('.step-item') as NodeListOf<HTMLElement>;
expect(steps.length).toBe(4);
expect(steps[0].classList.contains('success')).toBeTrue();
expect(steps[1].classList.contains('running')).toBeTrue();
expect(steps[2].classList.contains('pending')).toBeTrue();
expect(steps[3].classList.contains('failed')).toBeTrue();
});
it('shows spinner for running step', () => {
(component as any)._steps.set([
{ id: '1', name: 'Running step', status: 'running' },
] as VerifyStep[]);
fixture.detectChanges();
const spinner = fixture.nativeElement.querySelector('.spinner');
expect(spinner).toBeTruthy();
});
it('shows duration for completed steps', () => {
(component as any)._steps.set([
{ id: '1', name: 'Step 1', status: 'success', durationMs: 1500 },
] as VerifyStep[]);
fixture.detectChanges();
const duration = fixture.nativeElement.querySelector('.step-duration') as HTMLElement;
expect(duration).toBeTruthy();
expect(duration.textContent).toContain('1.5s');
});
it('shows error message for failed steps', () => {
(component as any)._steps.set([
{ id: '1', name: 'Step 1', status: 'failed', error: 'Signature mismatch' },
] as VerifyStep[]);
fixture.detectChanges();
const error = fixture.nativeElement.querySelector('.step-error') as HTMLElement;
expect(error).toBeTruthy();
expect(error.textContent).toContain('Signature mismatch');
});
});
describe('Receipt viewer (QV-003)', () => {
it('is collapsed by default', () => {
(component as any)._receipt.set({
payloadType: 'application/json',
payload: 'test',
signatures: [{ keyid: 'key1', sig: 'sig1' }],
});
fixture.detectChanges();
const receiptContent = fixture.nativeElement.querySelector('.receipt-content');
expect(receiptContent).toBeFalsy();
});
it('expands when toggle is clicked', () => {
(component as any)._receipt.set({
payloadType: 'application/json',
payload: 'test',
signatures: [{ keyid: 'key1', sig: 'sig1' }],
});
fixture.detectChanges();
const toggle = fixture.nativeElement.querySelector('.collapse-toggle') as HTMLButtonElement;
toggle.click();
fixture.detectChanges();
const receiptContent = fixture.nativeElement.querySelector('.receipt-content');
expect(receiptContent).toBeTruthy();
});
it('shows formatted JSON', () => {
(component as any)._receipt.set({
payloadType: 'application/json',
payload: 'test',
signatures: [{ keyid: 'key1', sig: 'sig1' }],
});
(component as any)._receiptExpanded.set(true);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('.receipt-content') as HTMLElement;
expect(content.textContent).toContain('payloadType');
expect(content.textContent).toContain('application/json');
});
it('has copy button', () => {
(component as any)._receipt.set({
payloadType: 'application/json',
payload: 'test',
signatures: [],
});
(component as any)._receiptExpanded.set(true);
fixture.detectChanges();
const copyBtn = fixture.nativeElement.querySelector('.receipt-header .copy-btn');
expect(copyBtn).toBeTruthy();
});
});
describe('Failure display (QV-004)', () => {
it('shows failure section when there is a failure', () => {
(component as any)._failureReason.set('DSSE signature verification failed');
(component as any)._failureCategory.set('SignatureInvalid');
fixture.detectChanges();
const failureSection = fixture.nativeElement.querySelector('.failure-section');
expect(failureSection).toBeTruthy();
});
it('displays failure category badge', () => {
(component as any)._failureReason.set('Error');
(component as any)._failureCategory.set('SignatureInvalid');
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.failure-badge') as HTMLElement;
expect(badge.textContent?.trim()).toBe('Signature Invalid');
});
it('shows log excerpt when available', () => {
(component as any)._failureReason.set('Error');
(component as any)._logExcerpt.set('Error: signature mismatch\nat verify()');
fixture.detectChanges();
const logContent = fixture.nativeElement.querySelector('.log-content') as HTMLElement;
expect(logContent).toBeTruthy();
expect(logContent.textContent).toContain('signature mismatch');
});
it('has link to learn more', () => {
(component as any)._failureReason.set('Error');
(component as any)._failureCategory.set('RekorInclusionFailed');
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.why-link') as HTMLAnchorElement;
expect(link).toBeTruthy();
expect(link.href).toContain('rekorinclusionfailed');
});
});
describe('Verification flow (QV-005)', () => {
it('emits verifyStarted when verification begins', fakeAsync(() => {
component.artifactId = 'test-artifact-123';
let startedId: string | null = null;
component.verifyStarted.subscribe(id => (startedId = id));
component.onStartVerify();
expect(startedId).toBe('test-artifact-123');
// Clean up
component.onCancel();
tick(5000);
}));
it('emits verifyCancelled when cancelled', fakeAsync(() => {
component.artifactId = 'test-artifact';
let cancelled = false;
component.verifyCancelled.subscribe(() => (cancelled = true));
component.onStartVerify();
tick(100);
component.onCancel();
expect(cancelled).toBeTrue();
tick(5000);
}));
it('shows cancel button during verification', fakeAsync(() => {
component.artifactId = 'test-artifact';
fixture.detectChanges();
component.onStartVerify();
fixture.detectChanges();
const cancelBtn = fixture.nativeElement.querySelector('.cancel-btn');
expect(cancelBtn).toBeTruthy();
component.onCancel();
tick(5000);
}));
});
describe('Accessibility', () => {
it('has correct ARIA attributes', () => {
fixture.detectChanges();
const drawer = fixture.nativeElement.querySelector('.quick-verify-drawer') as HTMLElement;
expect(drawer.getAttribute('role')).toBe('dialog');
expect(drawer.getAttribute('aria-modal')).toBe('true');
expect(drawer.getAttribute('aria-labelledby')).toBe('drawer-title');
});
it('has labelled sections', () => {
(component as any)._steps.set([{ id: '1', name: 'Test', status: 'success' }] as VerifyStep[]);
fixture.detectChanges();
const stepsSection = fixture.nativeElement.querySelector('.steps-section');
expect(stepsSection.getAttribute('aria-labelledby')).toBe('steps-heading');
});
});
describe('Duration formatting', () => {
it('formats milliseconds correctly', () => {
expect(component.formatDuration(500)).toBe('500ms');
expect(component.formatDuration(1500)).toBe('1.5s');
expect(component.formatDuration(2000)).toBe('2.0s');
});
});
});

View File

@@ -0,0 +1,924 @@
// -----------------------------------------------------------------------------
// quick-verify-drawer.component.ts
// Sprint: SPRINT_20260125_002_FE_quick_verify_drawer
// Task: QV-001, QV-002, QV-003, QV-004, QV-005
// Description: Right-side drawer for Quick-Verify proof replay visualization
// -----------------------------------------------------------------------------
import {
Component,
Input,
Output,
EventEmitter,
signal,
computed,
effect,
OnDestroy,
HostListener,
} from '@angular/core';
import { CommonModule } from '@angular/common';
/** Verification step status */
export type VerifyStepStatus = 'pending' | 'running' | 'success' | 'failed' | 'skipped';
/** Individual verification step */
export interface VerifyStep {
id: string;
name: string;
status: VerifyStepStatus;
startedAt?: string;
completedAt?: string;
durationMs?: number;
error?: string;
details?: string;
}
/** Verification result */
export interface VerifyResult {
verified: boolean;
steps: VerifyStep[];
receipt?: DsseReceipt;
failureReason?: string;
failureCategory?: VerifyFailureCategory;
totalDurationMs: number;
}
/** DSSE receipt for display */
export interface DsseReceipt {
payloadType: string;
payload: string;
signatures: Array<{
keyid: string;
sig: string;
}>;
}
/** Failure categories per advisory */
export type VerifyFailureCategory =
| 'SignatureInvalid'
| 'RekorInclusionFailed'
| 'PayloadTampered'
| 'KeyNotTrusted'
| 'Expired'
| 'Unknown';
/** Drawer status for header display */
export type DrawerStatus = 'idle' | 'replaying' | 'verified' | 'failed';
@Component({
selector: 'app-quick-verify-drawer',
standalone: true,
imports: [CommonModule],
template: `
<!-- Backdrop -->
@if (isOpen()) {
<div
class="drawer-backdrop"
(click)="close.emit()"
[attr.aria-hidden]="true"
></div>
}
<!-- Drawer -->
<aside
class="quick-verify-drawer"
[class.open]="isOpen()"
role="dialog"
aria-modal="true"
aria-labelledby="drawer-title"
[attr.aria-hidden]="!isOpen()"
>
<!-- Sticky Header -->
<header class="drawer-header">
<div class="header-content">
<h2 id="drawer-title" class="drawer-title">Quick-Verify</h2>
<div class="header-status">
<span class="status-badge" [class]="drawerStatus()">
{{ statusLabel() }}
</span>
@if (elapsedTime()) {
<span class="elapsed-time">{{ elapsedTime() }}</span>
}
</div>
</div>
<button
class="close-btn"
(click)="close.emit()"
aria-label="Close drawer"
type="button"
>
<span aria-hidden="true">&times;</span>
</button>
</header>
<!-- Content -->
<div class="drawer-content">
<!-- Step List (QV-002) -->
<section class="steps-section" aria-labelledby="steps-heading">
<h3 id="steps-heading" class="section-heading">Verification Steps</h3>
<ol class="step-list" role="list">
@for (step of steps(); track step.id; let i = $index) {
<li
class="step-item"
[class.running]="step.status === 'running'"
[class.success]="step.status === 'success'"
[class.failed]="step.status === 'failed'"
[class.pending]="step.status === 'pending'"
[class.skipped]="step.status === 'skipped'"
>
<span class="step-icon" aria-hidden="true">
@switch (step.status) {
@case ('running') { <span class="spinner"></span> }
@case ('success') { \u2713 }
@case ('failed') { \u2717 }
@case ('skipped') { \u2014 }
@default { \u25CB }
}
</span>
<div class="step-content">
<span class="step-name">{{ step.name }}</span>
@if (step.durationMs != null) {
<span class="step-duration">{{ formatDuration(step.durationMs) }}</span>
}
@if (step.error) {
<div class="step-error">{{ step.error }}</div>
}
</div>
</li>
}
</ol>
@if (steps().length === 0 && drawerStatus() === 'idle') {
<div class="empty-state">
<p>Click Quick-Verify to start proof replay</p>
</div>
}
</section>
<!-- Failure Reason (QV-004) -->
@if (failureReason()) {
<section class="failure-section" aria-labelledby="failure-heading">
<h3 id="failure-heading" class="section-heading">Failure Details</h3>
<div class="failure-card">
<span class="failure-badge" [attr.data-category]="failureCategory()">
{{ failureCategoryLabel() }}
</span>
<p class="failure-reason">{{ failureReason() }}</p>
@if (logExcerpt()) {
<div class="log-excerpt">
<div class="log-header">
<span>Log excerpt (first 10 lines)</span>
<button
class="copy-btn"
(click)="copyFullReceipt()"
type="button"
>
Copy full receipt
</button>
</div>
<pre class="log-content"><code>{{ logExcerpt() }}</code></pre>
</div>
}
<a
class="why-link"
[href]="failureHelpUrl()"
target="_blank"
rel="noopener"
>
Learn more about this error
</a>
</div>
</section>
}
<!-- Receipt Viewer (QV-003) -->
@if (receipt()) {
<section class="receipt-section" aria-labelledby="receipt-heading">
<h3 id="receipt-heading" class="section-heading">
<button
class="collapse-toggle"
(click)="toggleReceiptExpanded()"
[attr.aria-expanded]="receiptExpanded()"
type="button"
>
<span class="toggle-icon" aria-hidden="true">
{{ receiptExpanded() ? '\u25BC' : '\u25B6' }}
</span>
Signed receipt (JSON)
</button>
</h3>
@if (receiptExpanded()) {
<div class="receipt-viewer">
<div class="receipt-header">
<button
class="copy-btn"
(click)="copyReceipt()"
type="button"
>
{{ copyButtonLabel() }}
</button>
</div>
<pre class="receipt-content"><code>{{ formattedReceipt() }}</code></pre>
</div>
}
</section>
}
</div>
<!-- Footer -->
<footer class="drawer-footer">
@if (drawerStatus() === 'replaying') {
<button
class="cancel-btn"
(click)="onCancel()"
type="button"
>
Cancel
</button>
} @else {
<button
class="verify-btn"
(click)="onStartVerify()"
[disabled]="drawerStatus() === 'replaying'"
type="button"
>
{{ drawerStatus() === 'idle' ? 'Start Verification' : 'Re-verify' }}
</button>
}
</footer>
</aside>
`,
styles: [`
/* Backdrop */
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 999;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Drawer container */
.quick-verify-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 480px;
max-width: 100%;
background: var(--surface-primary, #fff);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
z-index: 1000;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.quick-verify-drawer.open {
transform: translateX(0);
}
/* Header */
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--surface-secondary, #fafafa);
position: sticky;
top: 0;
z-index: 1;
}
.header-content {
display: flex;
align-items: center;
gap: 12px;
}
.drawer-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.header-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.idle {
background: var(--surface-muted, #e0e0e0);
color: var(--text-muted, #666);
}
.status-badge.replaying {
background: var(--info-bg, #e3f2fd);
color: var(--info-text, #1565c0);
}
.status-badge.verified {
background: var(--success-bg, #e8f5e9);
color: var(--success-text, #2e7d32);
}
.status-badge.failed {
background: var(--error-bg, #ffebee);
color: var(--error-text, #c62828);
}
.elapsed-time {
font-size: 12px;
color: var(--text-secondary, #666);
font-family: monospace;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 24px;
color: var(--text-secondary, #666);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.close-btn:hover {
background: var(--surface-hover, #e0e0e0);
}
.close-btn:focus-visible {
outline: 2px solid var(--focus-ring, #1976d2);
outline-offset: 2px;
}
/* Content */
.drawer-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.section-heading {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
margin: 0 0 12px 0;
}
/* Step list */
.steps-section {
margin-bottom: 24px;
}
.step-list {
list-style: none;
margin: 0;
padding: 0;
}
.step-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.step-item:last-child {
border-bottom: none;
}
.step-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 14px;
flex-shrink: 0;
}
.step-item.pending .step-icon {
background: var(--surface-muted, #e0e0e0);
color: var(--text-muted, #9e9e9e);
}
.step-item.running .step-icon {
background: var(--info-bg, #e3f2fd);
color: var(--info-text, #1565c0);
}
.step-item.success .step-icon {
background: var(--success-bg, #e8f5e9);
color: var(--success-text, #2e7d32);
}
.step-item.failed .step-icon {
background: var(--error-bg, #ffebee);
color: var(--error-text, #c62828);
}
.step-item.skipped .step-icon {
background: var(--surface-muted, #e0e0e0);
color: var(--text-muted, #9e9e9e);
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid var(--info-border, #90caf9);
border-top-color: var(--info-text, #1565c0);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.step-content {
flex: 1;
min-width: 0;
}
.step-name {
display: block;
font-size: 14px;
color: var(--text-primary, #1a1a1a);
}
.step-duration {
font-size: 12px;
color: var(--text-secondary, #666);
font-family: monospace;
margin-left: 8px;
}
.step-error {
margin-top: 4px;
padding: 8px;
background: var(--error-bg, #ffebee);
border-radius: 4px;
font-size: 12px;
color: var(--error-text, #c62828);
}
.step-item.running {
background: var(--info-bg-subtle, #f5f9ff);
margin: 0 -20px;
padding: 12px 20px;
}
/* Failure section */
.failure-section {
margin-bottom: 24px;
}
.failure-card {
padding: 16px;
background: var(--error-bg, #ffebee);
border: 1px solid var(--error-border, #ef9a9a);
border-radius: 8px;
}
.failure-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
background: var(--error-text, #c62828);
color: white;
margin-bottom: 8px;
}
.failure-reason {
margin: 0 0 12px 0;
font-size: 14px;
color: var(--text-primary, #1a1a1a);
}
.log-excerpt {
margin-top: 12px;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
color: var(--text-secondary, #666);
}
.log-content {
margin: 0;
padding: 12px;
background: var(--surface-code, #1e1e1e);
border-radius: 4px;
font-size: 12px;
font-family: monospace;
color: var(--text-code, #d4d4d4);
overflow-x: auto;
max-height: 200px;
}
.why-link {
display: inline-block;
margin-top: 12px;
font-size: 13px;
color: var(--text-link, #1976d2);
}
/* Receipt viewer */
.receipt-section {
margin-bottom: 24px;
}
.collapse-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
color: var(--text-primary, #1a1a1a);
}
.collapse-toggle:hover {
color: var(--text-link, #1976d2);
}
.toggle-icon {
font-size: 10px;
}
.receipt-viewer {
margin-top: 12px;
}
.receipt-header {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.copy-btn {
padding: 6px 12px;
background: var(--surface-secondary, #f5f5f5);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
}
.copy-btn:hover {
background: var(--surface-hover, #e0e0e0);
}
.receipt-content {
margin: 0;
padding: 16px;
background: var(--surface-code, #1e1e1e);
border-radius: 6px;
font-size: 12px;
font-family: monospace;
color: var(--text-code, #d4d4d4);
overflow-x: auto;
max-height: 400px;
}
/* Footer */
.drawer-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color, #e0e0e0);
background: var(--surface-secondary, #fafafa);
}
.verify-btn,
.cancel-btn {
width: 100%;
padding: 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.verify-btn {
background: var(--primary-color, #1976d2);
color: white;
border: none;
}
.verify-btn:hover:not(:disabled) {
background: var(--primary-hover, #1565c0);
}
.verify-btn:disabled {
background: var(--surface-disabled, #bdbdbd);
cursor: not-allowed;
}
.cancel-btn {
background: transparent;
color: var(--text-secondary, #666);
border: 1px solid var(--border-color, #e0e0e0);
}
.cancel-btn:hover {
background: var(--surface-hover, #f5f5f5);
}
/* Empty state */
.empty-state {
padding: 40px 20px;
text-align: center;
color: var(--text-muted, #9e9e9e);
}
/* Mobile responsive */
@media (max-width: 600px) {
.quick-verify-drawer {
width: 100%;
}
}
`]
})
export class QuickVerifyDrawerComponent implements OnDestroy {
private _isOpen = signal(false);
private _artifactId = signal<string | null>(null);
private _steps = signal<VerifyStep[]>([]);
private _receipt = signal<DsseReceipt | null>(null);
private _failureReason = signal<string | null>(null);
private _failureCategory = signal<VerifyFailureCategory | null>(null);
private _logExcerpt = signal<string | null>(null);
private _drawerStatus = signal<DrawerStatus>('idle');
private _startTime = signal<number | null>(null);
private _elapsedMs = signal<number>(0);
private _receiptExpanded = signal(false);
private _copyLabel = signal('Copy');
private _timerInterval: ReturnType<typeof setInterval> | null = null;
@Input()
set isOpen(value: boolean) {
this._isOpen.set(value);
if (value && this._artifactId()) {
// Auto-start verification when drawer opens
this.onStartVerify();
}
}
@Input()
set artifactId(value: string) {
this._artifactId.set(value);
}
@Output() close = new EventEmitter<void>();
@Output() verifyComplete = new EventEmitter<VerifyResult>();
@Output() verifyStarted = new EventEmitter<string>();
@Output() verifyCancelled = new EventEmitter<void>();
// Public signals for template
readonly isOpen = computed(() => this._isOpen());
readonly steps = computed(() => this._steps());
readonly receipt = computed(() => this._receipt());
readonly failureReason = computed(() => this._failureReason());
readonly failureCategory = computed(() => this._failureCategory());
readonly logExcerpt = computed(() => this._logExcerpt());
readonly drawerStatus = computed(() => this._drawerStatus());
readonly receiptExpanded = computed(() => this._receiptExpanded());
readonly copyButtonLabel = computed(() => this._copyLabel());
readonly statusLabel = computed(() => {
switch (this._drawerStatus()) {
case 'replaying': return 'Replaying...';
case 'verified': return 'Verified';
case 'failed': return 'Failed';
default: return 'Ready';
}
});
readonly elapsedTime = computed(() => {
const ms = this._elapsedMs();
if (ms === 0) return null;
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
});
readonly formattedReceipt = computed(() => {
const r = this._receipt();
if (!r) return '';
return JSON.stringify(r, null, 2);
});
readonly failureCategoryLabel = computed(() => {
const category = this._failureCategory();
if (!category) return 'Error';
const labels: Record<VerifyFailureCategory, string> = {
SignatureInvalid: 'Signature Invalid',
RekorInclusionFailed: 'Rekor Inclusion Failed',
PayloadTampered: 'Payload Tampered',
KeyNotTrusted: 'Key Not Trusted',
Expired: 'Expired',
Unknown: 'Unknown Error',
};
return labels[category];
});
readonly failureHelpUrl = computed(() => {
const category = this._failureCategory() ?? 'Unknown';
return `/docs/verification-errors#${category.toLowerCase()}`;
});
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this._isOpen()) {
this.close.emit();
}
}
ngOnDestroy(): void {
this.stopTimer();
}
toggleReceiptExpanded(): void {
this._receiptExpanded.update(v => !v);
}
async copyReceipt(): Promise<void> {
const text = this.formattedReceipt();
if (!text) return;
try {
await navigator.clipboard.writeText(text);
this._copyLabel.set('Copied!');
setTimeout(() => this._copyLabel.set('Copy'), 2000);
} catch {
this._copyLabel.set('Failed');
setTimeout(() => this._copyLabel.set('Copy'), 2000);
}
}
async copyFullReceipt(): Promise<void> {
// In real implementation, this would copy the full log
await this.copyReceipt();
}
onStartVerify(): void {
const artifactId = this._artifactId();
if (!artifactId) return;
this.resetState();
this._drawerStatus.set('replaying');
this.startTimer();
this.verifyStarted.emit(artifactId);
// Simulate verification steps (in real impl, this would come from ReplayService)
this.simulateVerification();
}
onCancel(): void {
this.stopTimer();
this._drawerStatus.set('idle');
this.verifyCancelled.emit();
}
formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
private resetState(): void {
this._steps.set([]);
this._receipt.set(null);
this._failureReason.set(null);
this._failureCategory.set(null);
this._logExcerpt.set(null);
this._elapsedMs.set(0);
this._receiptExpanded.set(false);
}
private startTimer(): void {
this._startTime.set(Date.now());
this._timerInterval = setInterval(() => {
const start = this._startTime();
if (start) {
this._elapsedMs.set(Date.now() - start);
}
}, 100);
}
private stopTimer(): void {
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
}
/**
* Simulates the verification process for demo purposes.
* In real implementation, this would be replaced with ReplayService integration.
*/
private async simulateVerification(): Promise<void> {
const stepDefinitions = [
{ id: 'fetch', name: 'Fetching artifact metadata...' },
{ id: 'dsse', name: 'Verifying DSSE signature...' },
{ id: 'rekor', name: 'Checking Rekor inclusion...' },
{ id: 'payload', name: 'Validating payload integrity...' },
{ id: 'complete', name: 'Finalizing verification...' },
];
for (let i = 0; i < stepDefinitions.length; i++) {
const def = stepDefinitions[i];
// Add step as running
const step: VerifyStep = {
id: def.id,
name: def.name,
status: 'running',
startedAt: new Date().toISOString(),
};
this._steps.update(steps => [...steps, step]);
// Simulate processing time
await this.delay(500 + Math.random() * 500);
// Update step to success
this._steps.update(steps =>
steps.map(s =>
s.id === def.id
? {
...s,
status: 'success' as VerifyStepStatus,
completedAt: new Date().toISOString(),
durationMs: 500 + Math.floor(Math.random() * 500),
}
: s
)
);
}
// Complete verification
this.stopTimer();
this._drawerStatus.set('verified');
// Set mock receipt
this._receipt.set({
payloadType: 'application/vnd.in-toto+json',
payload: btoa(JSON.stringify({ _type: 'https://in-toto.io/Statement/v0.1' })),
signatures: [
{
keyid: 'SHA256:abc123def456',
sig: 'MEUCIQDtest...',
},
],
});
this.verifyComplete.emit({
verified: true,
steps: this._steps(),
receipt: this._receipt() ?? undefined,
totalDurationMs: this._elapsedMs(),
});
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}