fix tests. new product advisories enhancements
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
10
src/Web/StellaOps.Web/src/app/features/deploy-diff/index.ts
Normal file
10
src/Web/StellaOps.Web/src/app/features/deploy-diff/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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...&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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()}`;
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">×</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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user