up
This commit is contained in:
78
src/Web/StellaOps.Web/.lighthouserc.js
Normal file
78
src/Web/StellaOps.Web/.lighthouserc.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Lighthouse CI Configuration
|
||||
*
|
||||
* Quality gates aligned with StellaOps advisory:
|
||||
* - Performance: >= 90
|
||||
* - Accessibility: >= 95
|
||||
* - Best Practices: >= 90
|
||||
* - SEO: >= 90
|
||||
*/
|
||||
module.exports = {
|
||||
ci: {
|
||||
collect: {
|
||||
staticDistDir: './dist/stella-ops-web/browser',
|
||||
numberOfRuns: 3,
|
||||
settings: {
|
||||
// Mobile-first testing
|
||||
formFactor: 'mobile',
|
||||
throttling: {
|
||||
rttMs: 150,
|
||||
throughputKbps: 1638.4,
|
||||
cpuSlowdownMultiplier: 4,
|
||||
},
|
||||
screenEmulation: {
|
||||
mobile: true,
|
||||
width: 375,
|
||||
height: 667,
|
||||
deviceScaleFactor: 2,
|
||||
disabled: false,
|
||||
},
|
||||
// Skip audits that don't apply to SPAs
|
||||
skipAudits: [
|
||||
'uses-http2', // Often depends on server config
|
||||
'redirects-http', // Handled by deployment
|
||||
],
|
||||
},
|
||||
},
|
||||
assert: {
|
||||
preset: 'lighthouse:recommended',
|
||||
assertions: {
|
||||
// Performance budget
|
||||
'categories:performance': ['warn', { minScore: 0.9 }],
|
||||
'first-contentful-paint': ['warn', { maxNumericValue: 2000 }],
|
||||
'largest-contentful-paint': ['warn', { maxNumericValue: 2500 }],
|
||||
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
|
||||
'total-blocking-time': ['warn', { maxNumericValue: 300 }],
|
||||
|
||||
// Accessibility (strict - these are blocking)
|
||||
'categories:accessibility': ['error', { minScore: 0.95 }],
|
||||
'color-contrast': 'error',
|
||||
'document-title': 'error',
|
||||
'html-has-lang': 'error',
|
||||
'image-alt': 'error',
|
||||
'label': 'error',
|
||||
'link-name': 'error',
|
||||
'meta-viewport': 'error',
|
||||
'button-name': 'error',
|
||||
'aria-roles': 'error',
|
||||
'aria-valid-attr': 'error',
|
||||
'aria-valid-attr-value': 'error',
|
||||
|
||||
// Best Practices
|
||||
'categories:best-practices': ['warn', { minScore: 0.9 }],
|
||||
'is-on-https': 'off', // Handled by deployment
|
||||
'no-vulnerable-libraries': 'error',
|
||||
|
||||
// SEO
|
||||
'categories:seo': ['warn', { minScore: 0.9 }],
|
||||
'meta-description': 'warn',
|
||||
'robots-txt': 'off', // Handled by deployment
|
||||
},
|
||||
},
|
||||
upload: {
|
||||
target: 'filesystem',
|
||||
outputDir: './lighthouse-results',
|
||||
reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -47,3 +47,4 @@
|
||||
| WEB-TRIAGE-0215-001 | DONE (2025-12-12) | Added triage TS models + web SDK clients (VEX decisions, audit bundles, vuln-scan attestation predicate) and fixed `scripts/chrome-path.js` so `npm test` runs on Windows Playwright Chromium. |
|
||||
| UI-VEX-0215-A11Y | DONE (2025-12-12) | Added dialog semantics + focus trap for `VexDecisionModalComponent` and Playwright Axe coverage in `tests/e2e/a11y-smoke.spec.ts`. |
|
||||
| UI-TRIAGE-0215-FIXTURES | DONE (2025-12-12) | Made quickstart mock fixtures deterministic for triage surfaces (VEX decisions, audit bundles, vulnerabilities) to support offline-kit hashing and stable tests. |
|
||||
| UI-TRIAGE-4601-001 | DOING (2025-12-14) | Keyboard shortcuts for triage workspace (SPRINT_4601_0001_0001_keyboard_shortcuts.md). |
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { CommonModule, DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
inject,
|
||||
output,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
|
||||
import { KeyboardShortcutsService, type KeyboardShortcut, type KeyboardShortcutCategory } from '../../services/keyboard-shortcuts.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-keyboard-help',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="overlay" (click)="closed.emit()" role="presentation">
|
||||
<section
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Keyboard shortcuts"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<header class="header">
|
||||
<h2 class="title">Keyboard shortcuts</h2>
|
||||
<button #closeButton type="button" class="close" (click)="closed.emit()" aria-label="Close keyboard shortcuts">
|
||||
Close
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@for (category of categories; track category.key) {
|
||||
<section class="category">
|
||||
<h3 class="category__title">{{ category.title }}</h3>
|
||||
<div class="grid">
|
||||
@for (shortcut of shortcutsByCategory(category.key); track shortcut.description + ':' + shortcut.key) {
|
||||
<div class="row">
|
||||
<kbd>{{ formatShortcut(shortcut) }}</kbd>
|
||||
<span>{{ shortcut.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<footer class="footer">
|
||||
<span>Press <kbd>?</kbd> to toggle this help.</span>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 220;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(720px, 100%);
|
||||
max-height: min(80vh, 720px);
|
||||
overflow: auto;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
background: #0b1224;
|
||||
color: #e5e7eb;
|
||||
padding: 1.1rem 1.25rem;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.close {
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
color: #e5e7eb;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.category__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
min-width: 68px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 1.1rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class KeyboardHelpComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly keyboard = inject(KeyboardShortcutsService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly previouslyFocused =
|
||||
this.document.activeElement instanceof HTMLElement ? this.document.activeElement : null;
|
||||
|
||||
@ViewChild('closeButton', { static: true })
|
||||
private readonly closeButton?: ElementRef<HTMLButtonElement>;
|
||||
|
||||
readonly closed = output<void>();
|
||||
|
||||
readonly categories: readonly { key: KeyboardShortcutCategory; title: string }[] = [
|
||||
{ key: 'navigation', title: 'Navigation' },
|
||||
{ key: 'decision', title: 'Decision' },
|
||||
{ key: 'utility', title: 'Utility' },
|
||||
];
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
this.closeButton?.nativeElement.focus();
|
||||
} catch {
|
||||
// best-effort focus only
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
try {
|
||||
this.previouslyFocused?.focus();
|
||||
} catch {
|
||||
// best-effort restore focus only
|
||||
}
|
||||
}
|
||||
|
||||
shortcutsByCategory(category: KeyboardShortcutCategory): KeyboardShortcut[] {
|
||||
return this.keyboard.getByCategory(category);
|
||||
}
|
||||
|
||||
formatShortcut(shortcut: KeyboardShortcut): string {
|
||||
const combo = this.keyboard.toComboLabel(shortcut);
|
||||
return combo
|
||||
.split('+')
|
||||
.map((part) => this.formatKeyPart(part))
|
||||
.join('+');
|
||||
}
|
||||
|
||||
private formatKeyPart(value: string): string {
|
||||
const key = value.toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
ctrl: 'Ctrl',
|
||||
alt: 'Alt',
|
||||
meta: 'Meta',
|
||||
arrowdown: '↓',
|
||||
arrowup: '↑',
|
||||
arrowleft: '←',
|
||||
arrowright: '→',
|
||||
escape: 'Esc',
|
||||
enter: 'Enter',
|
||||
};
|
||||
|
||||
if (map[key]) return map[key];
|
||||
if (key.length === 1) return key.toUpperCase();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { DestroyRef, Injectable, inject } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Observable, Subject, fromEvent } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
export type KeyboardShortcutCategory = 'navigation' | 'decision' | 'utility';
|
||||
export type KeyboardShortcutModifier = 'ctrl' | 'alt' | 'meta';
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
readonly key: string;
|
||||
readonly description: string;
|
||||
readonly category: KeyboardShortcutCategory;
|
||||
readonly action: (event: KeyboardEvent) => void;
|
||||
readonly requiresModifier?: KeyboardShortcutModifier;
|
||||
readonly enabled?: () => boolean;
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: Record<KeyboardShortcutCategory, number> = {
|
||||
navigation: 0,
|
||||
decision: 1,
|
||||
utility: 2,
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class KeyboardShortcutsService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private readonly shortcuts = new Map<string, KeyboardShortcut>();
|
||||
private readonly keyPress$ = new Subject<KeyboardEvent>();
|
||||
private enabled = true;
|
||||
|
||||
constructor() {
|
||||
fromEvent<KeyboardEvent>(this.document, 'keydown')
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
filter(() => this.enabled),
|
||||
filter((event) => !event.repeat),
|
||||
filter((event) => !event.defaultPrevented),
|
||||
filter((event) => !this.isEditableTarget(event.target))
|
||||
)
|
||||
.subscribe((event) => this.handleKeyPress(event));
|
||||
}
|
||||
|
||||
register(shortcut: KeyboardShortcut): () => void {
|
||||
const storageKey = this.toStorageKey(shortcut);
|
||||
this.shortcuts.set(storageKey, shortcut);
|
||||
return () => this.shortcuts.delete(storageKey);
|
||||
}
|
||||
|
||||
unregister(combo: string): void {
|
||||
this.shortcuts.delete(combo.toLowerCase());
|
||||
}
|
||||
|
||||
getAll(): KeyboardShortcut[] {
|
||||
return this.sorted([...this.shortcuts.values()]);
|
||||
}
|
||||
|
||||
getByCategory(category: KeyboardShortcutCategory): KeyboardShortcut[] {
|
||||
return this.getAll().filter((s) => s.category === category);
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
onKeyPress(): Observable<KeyboardEvent> {
|
||||
return this.keyPress$.asObservable();
|
||||
}
|
||||
|
||||
toComboLabel(shortcut: KeyboardShortcut): string {
|
||||
const parts: string[] = [];
|
||||
if (shortcut.requiresModifier) parts.push(shortcut.requiresModifier);
|
||||
parts.push(shortcut.key.toLowerCase());
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
private handleKeyPress(event: KeyboardEvent): void {
|
||||
const key = this.normalizeKey(event);
|
||||
const shortcut = this.shortcuts.get(key);
|
||||
|
||||
if (!shortcut) return;
|
||||
if (shortcut.enabled && !shortcut.enabled()) return;
|
||||
|
||||
event.preventDefault();
|
||||
shortcut.action(event);
|
||||
this.keyPress$.next(event);
|
||||
}
|
||||
|
||||
private normalizeKey(event: KeyboardEvent): string {
|
||||
const parts: string[] = [];
|
||||
if (event.ctrlKey) parts.push('ctrl');
|
||||
if (event.altKey) parts.push('alt');
|
||||
if (event.metaKey) parts.push('meta');
|
||||
parts.push((event.key || '').toLowerCase());
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
private toStorageKey(shortcut: KeyboardShortcut): string {
|
||||
const parts: string[] = [];
|
||||
if (shortcut.requiresModifier) parts.push(shortcut.requiresModifier);
|
||||
parts.push(shortcut.key.toLowerCase());
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
private isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
if (['input', 'textarea', 'select'].includes(tagName)) return true;
|
||||
if (target.getAttribute('contenteditable') === 'true') return true;
|
||||
return target.closest('[contenteditable="true"]') !== null;
|
||||
}
|
||||
|
||||
private sorted(list: KeyboardShortcut[]): KeyboardShortcut[] {
|
||||
return [...list].sort((a, b) => {
|
||||
const cat = CATEGORY_ORDER[a.category] - CATEGORY_ORDER[b.category];
|
||||
if (cat !== 0) return cat;
|
||||
|
||||
const aKey = this.toComboLabel(a);
|
||||
const bKey = this.toComboLabel(b);
|
||||
const keyCmp = aKey.localeCompare(bKey);
|
||||
if (keyCmp !== 0) return keyCmp;
|
||||
|
||||
return a.description.localeCompare(b.description);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import { KeyboardShortcutsService } from './keyboard-shortcuts.service';
|
||||
|
||||
export type TriageQuickVexStatus = 'AFFECTED' | 'NOT_AFFECTED' | 'UNDER_INVESTIGATION';
|
||||
|
||||
export interface TriageShortcutsActions {
|
||||
readonly canRunWorkspaceShortcuts: () => boolean;
|
||||
readonly canRunGlobalShortcuts: () => boolean;
|
||||
readonly hasSelection: () => boolean;
|
||||
|
||||
readonly jumpToIncompleteEvidence: () => void;
|
||||
readonly focusGraphSearch: () => void;
|
||||
readonly toggleReachabilityView: () => void;
|
||||
readonly toggleDeterministicSort: () => void;
|
||||
|
||||
readonly openQuickVex: (status: TriageQuickVexStatus) => void;
|
||||
readonly copyDsseAttestation: () => void | Promise<void>;
|
||||
readonly toggleKeyboardHelp: () => void;
|
||||
|
||||
readonly selectNextFinding: () => void;
|
||||
readonly selectPreviousFinding: () => void;
|
||||
readonly selectNextTab: () => void;
|
||||
readonly selectPreviousTab: () => void;
|
||||
readonly openSelected: () => void;
|
||||
readonly closeOverlays: () => void;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TriageShortcutsService {
|
||||
private readonly keyboard = inject(KeyboardShortcutsService);
|
||||
|
||||
private cleanup: Array<() => void> = [];
|
||||
private initialized = false;
|
||||
|
||||
initialize(actions: TriageShortcutsActions): void {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
|
||||
const globalEnabled = () => actions.canRunGlobalShortcuts();
|
||||
const workspaceEnabled = () => actions.canRunWorkspaceShortcuts();
|
||||
const selectionEnabled = () => actions.canRunWorkspaceShortcuts() && actions.hasSelection();
|
||||
|
||||
this.cleanup.push(
|
||||
// Navigation
|
||||
this.keyboard.register({
|
||||
key: 'j',
|
||||
description: 'Jump to first incomplete evidence pane',
|
||||
category: 'navigation',
|
||||
action: () => actions.jumpToIncompleteEvidence(),
|
||||
enabled: selectionEnabled,
|
||||
}),
|
||||
this.keyboard.register({
|
||||
key: '/',
|
||||
description: 'Search within reachability graph',
|
||||
category: 'navigation',
|
||||
action: () => actions.focusGraphSearch(),
|
||||
enabled: selectionEnabled,
|
||||
}),
|
||||
this.keyboard.register({
|
||||
key: 'r',
|
||||
description: 'Toggle reachability view',
|
||||
category: 'navigation',
|
||||
action: () => actions.toggleReachabilityView(),
|
||||
enabled: selectionEnabled,
|
||||
}),
|
||||
this.keyboard.register({
|
||||
key: 's',
|
||||
description: 'Toggle deterministic sort',
|
||||
category: 'navigation',
|
||||
action: () => actions.toggleDeterministicSort(),
|
||||
enabled: workspaceEnabled,
|
||||
}),
|
||||
|
||||
// Decision
|
||||
this.keyboard.register({
|
||||
key: 'a',
|
||||
description: 'Quick VEX: Affected',
|
||||
category: 'decision',
|
||||
action: () => actions.openQuickVex('AFFECTED'),
|
||||
enabled: selectionEnabled,
|
||||
}),
|
||||
this.keyboard.register({
|
||||
key: 'n',
|
||||
description: 'Quick VEX: Not affected',
|
||||
category: 'decision',
|
||||
action: () => actions.openQuickVex('NOT_AFFECTED'),
|
||||
enabled: selectionEnabled,
|
||||
}),
|
||||
this.keyboard.register({
|
||||
key: 'u',
|
||||
description: 'Quick VEX: Under investigation',
|
||||
category: 'decision',
|
||||
action: () => actions.openQuickVex('UNDER_INVESTIGATION'),
|
||||
enabled: selectionEnabled,
|
||||
}),
|
||||
|
||||
// Utility
|
||||
this.keyboard.register({
|
||||
key: 'y',
|
||||
description: 'Copy DSSE attestation',
|
||||
category: 'utility',
|
||||
action: () => void actions.copyDsseAttestation(),
|
||||
enabled: globalEnabled,
|
||||
}),
|
||||
this.keyboard.register({
|
||||
key: '?',
|
||||
description: 'Toggle keyboard help',
|
||||
category: 'utility',
|
||||
action: () => actions.toggleKeyboardHelp(),
|
||||
enabled: globalEnabled,
|
||||
}),
|
||||
|
||||
// Arrow navigation
|
||||
this.keyboard.register({
|
||||
key: 'arrowdown',
|
||||
description: 'Select next finding',
|
||||
category: 'navigation',
|
||||
action: () => actions.selectNextFinding(),
|
||||
enabled: workspaceEnabled,
|
||||
}),
|
||||
this.keyboard.register({
|
||||
key: 'arrowup',
|
||||
description: 'Select previous finding',
|
||||
category: 'navigation',
|
||||
action: () => actions.selectPreviousFinding(),
|
||||
enabled: workspaceEnabled,
|
||||
}),
|
||||
this.keyboard.register({
|
||||
key: 'arrowright',
|
||||
description: 'Next evidence tab',
|
||||
category: 'navigation',
|
||||
action: () => actions.selectNextTab(),
|
||||
enabled: workspaceEnabled,
|
||||
}),
|
||||
this.keyboard.register({
|
||||
key: 'arrowleft',
|
||||
description: 'Previous evidence tab',
|
||||
category: 'navigation',
|
||||
action: () => actions.selectPreviousTab(),
|
||||
enabled: workspaceEnabled,
|
||||
}),
|
||||
|
||||
this.keyboard.register({
|
||||
key: 'enter',
|
||||
description: 'Open selected finding',
|
||||
category: 'navigation',
|
||||
action: () => actions.openSelected(),
|
||||
enabled: selectionEnabled,
|
||||
}),
|
||||
this.keyboard.register({
|
||||
key: 'escape',
|
||||
description: 'Close overlay',
|
||||
category: 'navigation',
|
||||
action: () => actions.closeOverlays(),
|
||||
enabled: globalEnabled,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
for (const unregister of this.cleanup) unregister();
|
||||
this.cleanup = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CommonModule, DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
@@ -13,9 +16,11 @@ import { firstValueFrom } from 'rxjs';
|
||||
import type { SeverityCounts, VulnScanAttestation } from '../../core/api/attestation-vuln-scan.models';
|
||||
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
|
||||
import type { AffectedComponent, Vulnerability, VulnerabilitySeverity } from '../../core/api/vulnerability.models';
|
||||
import type { VexDecision } from '../../core/api/evidence.models';
|
||||
import type { VexDecision, VexStatus } from '../../core/api/evidence.models';
|
||||
import { VEX_DECISIONS_API, type VexDecisionsApi } from '../../core/api/vex-decisions.client';
|
||||
import { ReachabilityWhyDrawerComponent } from '../reachability/reachability-why-drawer.component';
|
||||
import { KeyboardHelpComponent } from './components/keyboard-help/keyboard-help.component';
|
||||
import { type TriageQuickVexStatus, TriageShortcutsService } from './services/triage-shortcuts.service';
|
||||
import { VexDecisionModalComponent } from './vex-decision-modal.component';
|
||||
import {
|
||||
TriageAttestationDetailModalComponent,
|
||||
@@ -57,18 +62,25 @@ interface PolicyGateCell {
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
ReachabilityWhyDrawerComponent,
|
||||
KeyboardHelpComponent,
|
||||
VexDecisionModalComponent,
|
||||
TriageAttestationDetailModalComponent,
|
||||
],
|
||||
providers: [TriageShortcutsService],
|
||||
templateUrl: './triage-workspace.component.html',
|
||||
styleUrls: ['./triage-workspace.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TriageWorkspaceComponent implements OnInit {
|
||||
export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
|
||||
private readonly vexApi = inject<VexDecisionsApi>(VEX_DECISIONS_API);
|
||||
private readonly shortcuts = inject(TriageShortcutsService);
|
||||
|
||||
@ViewChild('reachabilitySearchInput')
|
||||
private readonly reachabilitySearchInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
readonly artifactId = signal<string>('');
|
||||
readonly loading = signal(false);
|
||||
@@ -83,11 +95,19 @@ export class TriageWorkspaceComponent implements OnInit {
|
||||
|
||||
readonly showVexModal = signal(false);
|
||||
readonly vexTargetVulnerabilityIds = signal<readonly string[]>([]);
|
||||
readonly vexModalInitialStatus = signal<VexStatus | null>(null);
|
||||
|
||||
readonly showReachabilityDrawer = signal(false);
|
||||
readonly reachabilityComponent = signal<string | null>(null);
|
||||
|
||||
readonly attestationModal = signal<TriageAttestationDetail | null>(null);
|
||||
readonly showKeyboardHelp = signal(false);
|
||||
|
||||
readonly reachabilityView = signal<'path-list' | 'compact-graph' | 'textual-proof'>('path-list');
|
||||
readonly reachabilitySearch = signal('');
|
||||
|
||||
readonly findingsSort = signal<'default' | 'deterministic'>('default');
|
||||
readonly keyboardStatus = signal<string | null>(null);
|
||||
|
||||
readonly selectedVuln = computed(() => {
|
||||
const id = this.selectedVulnId();
|
||||
@@ -105,10 +125,24 @@ export class TriageWorkspaceComponent implements OnInit {
|
||||
})
|
||||
.filter((v): v is FindingCardModel => v !== null);
|
||||
|
||||
// deterministic ordering: severity, status, cveId
|
||||
const sortMode = this.findingsSort();
|
||||
relevant.sort((a, b) => {
|
||||
if (sortMode === 'deterministic') {
|
||||
const reach = this.compareReachability(a.vuln, b.vuln);
|
||||
if (reach !== 0) return reach;
|
||||
}
|
||||
|
||||
const sev = SEVERITY_ORDER[a.vuln.severity] - SEVERITY_ORDER[b.vuln.severity];
|
||||
if (sev !== 0) return sev;
|
||||
|
||||
if (sortMode === 'deterministic') {
|
||||
const age = this.compareAge(a.vuln, b.vuln);
|
||||
if (age !== 0) return age;
|
||||
|
||||
const component = (a.component?.purl ?? '').localeCompare(b.component?.purl ?? '');
|
||||
if (component !== 0) return component;
|
||||
}
|
||||
|
||||
const cve = a.vuln.cveId.localeCompare(b.vuln.cveId);
|
||||
if (cve !== 0) return cve;
|
||||
return a.vuln.vulnId.localeCompare(b.vuln.vulnId);
|
||||
@@ -194,6 +228,28 @@ export class TriageWorkspaceComponent implements OnInit {
|
||||
);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.shortcuts.initialize({
|
||||
canRunWorkspaceShortcuts: () => !this.isShortcutOverlayOpen(),
|
||||
canRunGlobalShortcuts: () => !this.showVexModal(),
|
||||
hasSelection: () => typeof this.selectedVulnId() === 'string' && this.selectedVulnId()!.length > 0,
|
||||
|
||||
jumpToIncompleteEvidence: () => this.jumpToIncompleteEvidencePane(),
|
||||
focusGraphSearch: () => this.focusReachabilitySearch(),
|
||||
toggleReachabilityView: () => this.cycleReachabilityView(),
|
||||
toggleDeterministicSort: () => this.toggleFindingsSort(),
|
||||
|
||||
openQuickVex: (status) => this.openQuickVex(status),
|
||||
copyDsseAttestation: () => void this.copyDsseAttestation(),
|
||||
toggleKeyboardHelp: () => this.toggleKeyboardHelp(),
|
||||
|
||||
selectNextFinding: () => this.selectRelativeFinding(1),
|
||||
selectPreviousFinding: () => this.selectRelativeFinding(-1),
|
||||
selectNextTab: () => this.selectRelativeTab(1),
|
||||
selectPreviousTab: () => this.selectRelativeTab(-1),
|
||||
openSelected: () => this.openVexForSelected(),
|
||||
closeOverlays: () => this.closeOverlays(),
|
||||
});
|
||||
|
||||
const artifactId = this.route.snapshot.paramMap.get('artifactId') ?? '';
|
||||
this.artifactId.set(artifactId);
|
||||
await this.load();
|
||||
@@ -203,6 +259,10 @@ export class TriageWorkspaceComponent implements OnInit {
|
||||
this.selectedVulnId.set(first);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.shortcuts.destroy();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
@@ -249,6 +309,7 @@ export class TriageWorkspaceComponent implements OnInit {
|
||||
}
|
||||
|
||||
openVexForFinding(vulnId: string): void {
|
||||
this.vexModalInitialStatus.set(null);
|
||||
const selected = this.findings().find((f) => f.vuln.vulnId === vulnId);
|
||||
if (!selected) return;
|
||||
this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]);
|
||||
@@ -256,6 +317,7 @@ export class TriageWorkspaceComponent implements OnInit {
|
||||
}
|
||||
|
||||
openBulkVex(): void {
|
||||
this.vexModalInitialStatus.set(null);
|
||||
const selectedIds = this.selectedForBulk();
|
||||
if (selectedIds.length === 0) return;
|
||||
const cves = this.findings()
|
||||
@@ -270,6 +332,7 @@ export class TriageWorkspaceComponent implements OnInit {
|
||||
closeVexModal(): void {
|
||||
this.showVexModal.set(false);
|
||||
this.vexTargetVulnerabilityIds.set([]);
|
||||
this.vexModalInitialStatus.set(null);
|
||||
}
|
||||
|
||||
onVexSaved(decisions: readonly VexDecision[]): void {
|
||||
|
||||
Reference in New Issue
Block a user