save progress
This commit is contained in:
@@ -455,6 +455,15 @@ export const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./features/unknowns-tracking/unknowns.routes').then((m) => m.unknownsRoutes),
|
||||
},
|
||||
// Analyze - Patch Map Explorer (SPRINT_20260103_003_FE_patch_map_explorer)
|
||||
{
|
||||
path: 'analyze/patch-map',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/binary-index/patch-map.component').then(
|
||||
(m) => m.PatchMapComponent
|
||||
),
|
||||
},
|
||||
// Fallback for unknown routes
|
||||
{
|
||||
path: '**',
|
||||
|
||||
129
src/Web/StellaOps.Web/src/app/core/api/patch-coverage.client.ts
Normal file
129
src/Web/StellaOps.Web/src/app/core/api/patch-coverage.client.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @file patch-coverage.client.ts
|
||||
* @sprint SPRINT_20260103_003_FE_patch_map_explorer
|
||||
* @description HTTP client for Patch Coverage API.
|
||||
*/
|
||||
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, map, catchError, throwError } from 'rxjs';
|
||||
|
||||
import {
|
||||
PatchCoverageResult,
|
||||
PatchCoverageDetails,
|
||||
PatchMatchPage,
|
||||
PatchCoverageQuery,
|
||||
PatchMatchQuery,
|
||||
} from './patch-coverage.models';
|
||||
|
||||
/**
|
||||
* Patch Coverage API interface.
|
||||
*/
|
||||
export interface PatchCoverageApi {
|
||||
/**
|
||||
* Get aggregated patch coverage by CVE.
|
||||
*/
|
||||
getCoverage(query?: PatchCoverageQuery): Observable<PatchCoverageResult>;
|
||||
|
||||
/**
|
||||
* Get detailed function-level coverage for a CVE.
|
||||
*/
|
||||
getCoverageDetails(cveId: string): Observable<PatchCoverageDetails>;
|
||||
|
||||
/**
|
||||
* Get paginated list of matching images.
|
||||
*/
|
||||
getMatchingImages(query: PatchMatchQuery): Observable<PatchMatchPage>;
|
||||
}
|
||||
|
||||
export const PATCH_COVERAGE_API = new InjectionToken<PatchCoverageApi>('PATCH_COVERAGE_API');
|
||||
|
||||
/**
|
||||
* HTTP implementation of Patch Coverage API.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PatchCoverageHttpClient implements PatchCoverageApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/stats/patch-coverage';
|
||||
|
||||
/**
|
||||
* Get aggregated patch coverage by CVE.
|
||||
*/
|
||||
getCoverage(query?: PatchCoverageQuery): Observable<PatchCoverageResult> {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (query?.cve) {
|
||||
params = params.set('cve', query.cve);
|
||||
}
|
||||
if (query?.package) {
|
||||
params = params.set('package', query.package);
|
||||
}
|
||||
if (query?.limit !== undefined) {
|
||||
params = params.set('limit', query.limit.toString());
|
||||
}
|
||||
if (query?.offset !== undefined) {
|
||||
params = params.set('offset', query.offset.toString());
|
||||
}
|
||||
|
||||
return this.http.get<PatchCoverageResult>(this.baseUrl, { params }).pipe(
|
||||
catchError(err => {
|
||||
console.error('Failed to fetch patch coverage', err);
|
||||
return throwError(() => new Error('Failed to fetch patch coverage'));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed function-level coverage for a CVE.
|
||||
*/
|
||||
getCoverageDetails(cveId: string): Observable<PatchCoverageDetails> {
|
||||
return this.http.get<PatchCoverageDetails>(
|
||||
`${this.baseUrl}/${encodeURIComponent(cveId)}/details`
|
||||
).pipe(
|
||||
catchError(err => {
|
||||
console.error(`Failed to fetch coverage details for ${cveId}`, err);
|
||||
return throwError(() => new Error(`Failed to fetch coverage details for ${cveId}`));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated list of matching images.
|
||||
*/
|
||||
getMatchingImages(query: PatchMatchQuery): Observable<PatchMatchPage> {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (query.symbol) {
|
||||
params = params.set('symbol', query.symbol);
|
||||
}
|
||||
if (query.state) {
|
||||
params = params.set('state', query.state);
|
||||
}
|
||||
if (query.limit !== undefined) {
|
||||
params = params.set('limit', query.limit.toString());
|
||||
}
|
||||
if (query.offset !== undefined) {
|
||||
params = params.set('offset', query.offset.toString());
|
||||
}
|
||||
|
||||
return this.http.get<PatchMatchPage>(
|
||||
`${this.baseUrl}/${encodeURIComponent(query.cveId)}/matches`,
|
||||
{ params }
|
||||
).pipe(
|
||||
catchError(err => {
|
||||
console.error(`Failed to fetch matching images for ${query.cveId}`, err);
|
||||
return throwError(() => new Error(`Failed to fetch matching images for ${query.cveId}`));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider configuration for Patch Coverage API.
|
||||
*/
|
||||
export function providePatchCoverageApi() {
|
||||
return {
|
||||
provide: PATCH_COVERAGE_API,
|
||||
useClass: PatchCoverageHttpClient,
|
||||
};
|
||||
}
|
||||
212
src/Web/StellaOps.Web/src/app/core/api/patch-coverage.models.ts
Normal file
212
src/Web/StellaOps.Web/src/app/core/api/patch-coverage.models.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @file patch-coverage.models.ts
|
||||
* @sprint SPRINT_20260103_003_FE_patch_map_explorer
|
||||
* @description TypeScript models for Patch Map Explorer.
|
||||
*/
|
||||
|
||||
/**
|
||||
* State of a binary match.
|
||||
*/
|
||||
export type MatchState = 'vulnerable' | 'patched' | 'unknown';
|
||||
|
||||
/**
|
||||
* Paginated result of patch coverage by CVE.
|
||||
*/
|
||||
export interface PatchCoverageResult {
|
||||
/** Coverage entries by CVE. */
|
||||
entries: PatchCoverageEntry[];
|
||||
/** Total CVEs matching filter. */
|
||||
totalCount: number;
|
||||
/** Pagination offset. */
|
||||
offset: number;
|
||||
/** Pagination limit. */
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch coverage summary for a single CVE.
|
||||
*/
|
||||
export interface PatchCoverageEntry {
|
||||
/** CVE identifier. */
|
||||
cveId: string;
|
||||
/** Primary package name. */
|
||||
packageName: string;
|
||||
/** Number of vulnerable matches. */
|
||||
vulnerableCount: number;
|
||||
/** Number of patched matches. */
|
||||
patchedCount: number;
|
||||
/** Number of unknown matches. */
|
||||
unknownCount: number;
|
||||
/** Number of distinct symbols. */
|
||||
symbolCount: number;
|
||||
/** Patch coverage percentage (0-100). */
|
||||
coveragePercent: number;
|
||||
/** When signatures were last updated. */
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed patch coverage with function-level breakdown.
|
||||
*/
|
||||
export interface PatchCoverageDetails {
|
||||
/** CVE identifier. */
|
||||
cveId: string;
|
||||
/** Primary package name. */
|
||||
packageName: string;
|
||||
/** Function-level breakdown. */
|
||||
functions: FunctionCoverageEntry[];
|
||||
/** Summary statistics. */
|
||||
summary: PatchCoverageSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coverage for a single function/symbol.
|
||||
*/
|
||||
export interface FunctionCoverageEntry {
|
||||
/** Symbol/function name. */
|
||||
symbolName: string;
|
||||
/** Shared object name. */
|
||||
soname?: string;
|
||||
/** Vulnerable match count. */
|
||||
vulnerableCount: number;
|
||||
/** Patched match count. */
|
||||
patchedCount: number;
|
||||
/** Unknown match count. */
|
||||
unknownCount: number;
|
||||
/** Whether vulnerable and patched signatures exist. */
|
||||
hasDelta: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary statistics for patch coverage.
|
||||
*/
|
||||
export interface PatchCoverageSummary {
|
||||
/** Total images analyzed. */
|
||||
totalImages: number;
|
||||
/** Vulnerable images. */
|
||||
vulnerableImages: number;
|
||||
/** Patched images. */
|
||||
patchedImages: number;
|
||||
/** Unknown images. */
|
||||
unknownImages: number;
|
||||
/** Overall coverage percentage (0-100). */
|
||||
overallCoverage: number;
|
||||
/** Number of distinct symbols. */
|
||||
symbolCount: number;
|
||||
/** Number of symbols with delta pairs. */
|
||||
deltaPairCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated list of matching images.
|
||||
*/
|
||||
export interface PatchMatchPage {
|
||||
/** Match entries. */
|
||||
matches: PatchMatchEntry[];
|
||||
/** Total matches. */
|
||||
totalCount: number;
|
||||
/** Offset used. */
|
||||
offset: number;
|
||||
/** Limit used. */
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single image match entry.
|
||||
*/
|
||||
export interface PatchMatchEntry {
|
||||
/** Match ID. */
|
||||
matchId: string;
|
||||
/** Binary key (image digest or path). */
|
||||
binaryKey: string;
|
||||
/** Binary SHA-256 hash. */
|
||||
binarySha256?: string;
|
||||
/** Matched symbol name. */
|
||||
symbolName: string;
|
||||
/** Match state. */
|
||||
matchState: MatchState;
|
||||
/** Confidence (0-1). */
|
||||
confidence: number;
|
||||
/** Scan ID. */
|
||||
scanId?: string;
|
||||
/** Scan timestamp. */
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query options for patch coverage list.
|
||||
*/
|
||||
export interface PatchCoverageQuery {
|
||||
/** CVE IDs to filter (comma-separated). */
|
||||
cve?: string;
|
||||
/** Package name filter. */
|
||||
package?: string;
|
||||
/** Maximum entries. */
|
||||
limit?: number;
|
||||
/** Pagination offset. */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query options for matching images.
|
||||
*/
|
||||
export interface PatchMatchQuery {
|
||||
/** CVE ID (required). */
|
||||
cveId: string;
|
||||
/** Symbol name filter. */
|
||||
symbol?: string;
|
||||
/** State filter. */
|
||||
state?: MatchState;
|
||||
/** Maximum entries. */
|
||||
limit?: number;
|
||||
/** Pagination offset. */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heatmap cell data for visualization.
|
||||
*/
|
||||
export interface HeatmapCell {
|
||||
/** CVE ID. */
|
||||
cveId: string;
|
||||
/** Package name. */
|
||||
packageName: string;
|
||||
/** Coverage percentage (0-100). */
|
||||
coverage: number;
|
||||
/** Color intensity based on coverage. */
|
||||
colorClass: 'critical' | 'high' | 'medium' | 'low' | 'safe';
|
||||
/** Vulnerable count. */
|
||||
vulnerable: number;
|
||||
/** Patched count. */
|
||||
patched: number;
|
||||
/** Unknown count. */
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute color class from coverage percentage.
|
||||
*/
|
||||
export function getCoverageColorClass(
|
||||
coverage: number
|
||||
): HeatmapCell['colorClass'] {
|
||||
if (coverage >= 90) return 'safe';
|
||||
if (coverage >= 70) return 'low';
|
||||
if (coverage >= 50) return 'medium';
|
||||
if (coverage >= 25) return 'high';
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform coverage entry to heatmap cell.
|
||||
*/
|
||||
export function toHeatmapCell(entry: PatchCoverageEntry): HeatmapCell {
|
||||
return {
|
||||
cveId: entry.cveId,
|
||||
packageName: entry.packageName,
|
||||
coverage: entry.coveragePercent,
|
||||
colorClass: getCoverageColorClass(entry.coveragePercent),
|
||||
vulnerable: entry.vulnerableCount,
|
||||
patched: entry.patchedCount,
|
||||
unknown: entry.unknownCount,
|
||||
};
|
||||
}
|
||||
@@ -80,6 +80,13 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
icon: 'help-circle',
|
||||
tooltip: 'Track and identify unknown components',
|
||||
},
|
||||
{
|
||||
id: 'patch-map',
|
||||
label: 'Patch Map',
|
||||
route: '/analyze/patch-map',
|
||||
icon: 'grid',
|
||||
tooltip: 'Fleet-wide binary patch coverage heatmap',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import {
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import {
|
||||
BinaryEvidence,
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
@Component({
|
||||
selector: 'app-binary-evidence-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, RouterModule],
|
||||
template: `
|
||||
<div class="binary-evidence-panel" [class.has-findings]="hasBinaries()">
|
||||
<!-- Summary Header -->
|
||||
@@ -62,6 +63,15 @@ import {
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Link to Patch Map Explorer -->
|
||||
<a
|
||||
routerLink="/analyze/patch-map"
|
||||
class="patch-map-link"
|
||||
title="View fleet-wide patch coverage heatmap"
|
||||
>
|
||||
<span class="link-icon">□</span>
|
||||
Patch Map
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Distribution Info -->
|
||||
@@ -294,6 +304,35 @@ import {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.patch-map-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.distro-info {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #f9fafb;
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* @file filter-preset-pills.component.ts
|
||||
* @sprint SPRINT_20260103_001_FE_filter_preset_pills
|
||||
* @description Always-visible preset pills bar for quick filter selection.
|
||||
*
|
||||
* Features:
|
||||
* - Horizontal scrollable preset pills
|
||||
* - Active preset highlighting
|
||||
* - Custom filter indicator with count
|
||||
* - Copy shareable URL button
|
||||
* - Responsive with horizontal scroll on mobile
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { FilterUrlSyncService } from '../../services/filter-url-sync.service';
|
||||
import {
|
||||
FilterPreset,
|
||||
getPresetsOrdered,
|
||||
} from './filter-preset.models';
|
||||
|
||||
/**
|
||||
* Icon mapping for preset icons (using simple text/unicode for accessibility).
|
||||
* Can be replaced with icon component references if needed.
|
||||
*/
|
||||
const ICON_MAP: Record<string, string> = {
|
||||
target: 'O',
|
||||
'alert-circle': '!',
|
||||
eye: '*',
|
||||
'check-circle': 'v',
|
||||
list: '=',
|
||||
activity: '~',
|
||||
'shield-check': '#',
|
||||
};
|
||||
|
||||
/**
|
||||
* Always-visible filter preset pills component.
|
||||
*
|
||||
* Displays a horizontal row of clickable pills that apply common filter
|
||||
* configurations with one click. Supports URL sync for shareable links.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-filter-preset-pills',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="preset-pills-container" role="toolbar" aria-label="Filter presets">
|
||||
<!-- Pills scroll container -->
|
||||
<div class="pills-scroll" role="group">
|
||||
@for (preset of presets(); track preset.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="preset-pill"
|
||||
[class.active]="activePresetId() === preset.id"
|
||||
[class.noise-gating]="preset.category === 'noise-gating'"
|
||||
[attr.aria-pressed]="activePresetId() === preset.id"
|
||||
[title]="preset.description"
|
||||
(click)="onPresetClick(preset)"
|
||||
>
|
||||
<span class="pill-icon" aria-hidden="true">{{ getIcon(preset.icon) }}</span>
|
||||
<span class="pill-label">{{ preset.name }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Custom filter indicator (when no preset matches) -->
|
||||
@if (!hasActivePreset() && filterCount() > 0) {
|
||||
<span class="custom-filter-badge" aria-label="Custom filters applied">
|
||||
<span class="badge-icon" aria-hidden="true">*</span>
|
||||
<span class="badge-label">Custom</span>
|
||||
<span class="badge-count">{{ filterCount() }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pills-actions">
|
||||
<!-- Copy URL button -->
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn copy-btn"
|
||||
[class.copied]="justCopied()"
|
||||
[attr.aria-label]="justCopied() ? 'Link copied!' : 'Copy shareable link'"
|
||||
[title]="justCopied() ? 'Copied!' : 'Copy shareable link'"
|
||||
(click)="onCopyUrl()"
|
||||
>
|
||||
<span class="action-icon" aria-hidden="true">{{ justCopied() ? 'v' : '@' }}</span>
|
||||
@if (justCopied()) {
|
||||
<span class="copy-feedback">Copied!</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Reset button (when not on default) -->
|
||||
@if (filterCount() > 0 || activePresetId() !== 'actionable') {
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn reset-btn"
|
||||
aria-label="Reset to defaults"
|
||||
title="Reset to defaults"
|
||||
(click)="onReset()"
|
||||
>
|
||||
<span class="action-icon" aria-hidden="true">x</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.preset-pills-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: var(--pills-bg, #f8f9fa);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--pills-border, #e9ecef);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
:host-context(.dark-mode) .preset-pills-container {
|
||||
--pills-bg: #2d2d2d;
|
||||
--pills-border: #404040;
|
||||
}
|
||||
|
||||
.pills-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #ccc transparent;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.pills-scroll::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.pills-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.pills-scroll::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Preset pill button */
|
||||
.preset-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--pill-bg, white);
|
||||
border: 1px solid var(--pill-border, #dee2e6);
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--pill-color, #495057);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:host-context(.dark-mode) .preset-pill {
|
||||
--pill-bg: #1e1e1e;
|
||||
--pill-border: #404040;
|
||||
--pill-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.preset-pill:hover {
|
||||
border-color: var(--pill-hover-border, #007bff);
|
||||
color: var(--pill-hover-color, #007bff);
|
||||
}
|
||||
|
||||
.preset-pill:focus-visible {
|
||||
outline: 2px solid var(--focus-ring, #007bff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Active preset */
|
||||
.preset-pill.active {
|
||||
background: var(--pill-active-bg, #e7f3ff);
|
||||
border-color: var(--pill-active-border, #007bff);
|
||||
color: var(--pill-active-color, #0056b3);
|
||||
}
|
||||
|
||||
:host-context(.dark-mode) .preset-pill.active {
|
||||
--pill-active-bg: #1a3a5c;
|
||||
--pill-active-border: #4dabf7;
|
||||
--pill-active-color: #74c0fc;
|
||||
}
|
||||
|
||||
/* Noise-gating presets have distinct styling */
|
||||
.preset-pill.noise-gating {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.preset-pill.noise-gating.active {
|
||||
background: var(--pill-ng-active-bg, #e8f5e9);
|
||||
border-color: var(--pill-ng-active-border, #2e7d32);
|
||||
border-style: solid;
|
||||
color: var(--pill-ng-active-color, #1b5e20);
|
||||
}
|
||||
|
||||
:host-context(.dark-mode) .preset-pill.noise-gating.active {
|
||||
--pill-ng-active-bg: #1b3d1e;
|
||||
--pill-ng-active-border: #4caf50;
|
||||
--pill-ng-active-color: #81c784;
|
||||
}
|
||||
|
||||
.pill-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pill-label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Custom filter badge */
|
||||
.custom-filter-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: var(--badge-bg, #fff3cd);
|
||||
border: 1px solid var(--badge-border, #ffc107);
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--badge-color, #856404);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:host-context(.dark-mode) .custom-filter-badge {
|
||||
--badge-bg: #3d3200;
|
||||
--badge-border: #ffc107;
|
||||
--badge-color: #ffd54f;
|
||||
}
|
||||
|
||||
.badge-count {
|
||||
background: var(--badge-count-bg, #ffc107);
|
||||
color: var(--badge-count-color, #1a1a1a);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.pills-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--divider, #dee2e6);
|
||||
}
|
||||
|
||||
:host-context(.dark-mode) .pills-actions {
|
||||
--divider: #404040;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--action-color, #6c757d);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--action-hover-bg, #e9ecef);
|
||||
color: var(--action-hover-color, #495057);
|
||||
}
|
||||
|
||||
:host-context(.dark-mode) .action-btn:hover {
|
||||
--action-hover-bg: #383838;
|
||||
--action-hover-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.action-btn:focus-visible {
|
||||
outline: 2px solid var(--focus-ring, #007bff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Copy button states */
|
||||
.copy-btn.copied {
|
||||
width: auto;
|
||||
padding: 0 8px;
|
||||
background: var(--success-bg, #d4edda);
|
||||
border-color: var(--success-border, #28a745);
|
||||
color: var(--success-color, #155724);
|
||||
}
|
||||
|
||||
:host-context(.dark-mode) .copy-btn.copied {
|
||||
--success-bg: #1b3d1e;
|
||||
--success-border: #4caf50;
|
||||
--success-color: #81c784;
|
||||
}
|
||||
|
||||
.copy-feedback {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Reset button */
|
||||
.reset-btn:hover {
|
||||
background: var(--reset-hover-bg, #f8d7da);
|
||||
color: var(--reset-hover-color, #721c24);
|
||||
}
|
||||
|
||||
:host-context(.dark-mode) .reset-btn:hover {
|
||||
--reset-hover-bg: #3d1e1e;
|
||||
--reset-hover-color: #f8d7da;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 600px) {
|
||||
.preset-pills-container {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.preset-pill {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pill-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pills-actions {
|
||||
padding-left: 6px;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class FilterPresetPillsComponent {
|
||||
private readonly urlSyncService = inject(FilterUrlSyncService);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Signals from service
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
readonly presets = signal(getPresetsOrdered());
|
||||
readonly activePresetId = this.urlSyncService.activePresetId;
|
||||
readonly hasActivePreset = this.urlSyncService.hasActivePreset;
|
||||
readonly filterCount = this.urlSyncService.activeFilterCount;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Local state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
readonly justCopied = signal(false);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Outputs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Emitted when a preset is clicked */
|
||||
readonly presetSelected = output<FilterPreset>();
|
||||
|
||||
/** Emitted when filters are reset */
|
||||
readonly filtersReset = output<void>();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get icon character for preset.
|
||||
*/
|
||||
getIcon(iconName: string): string {
|
||||
return ICON_MAP[iconName] ?? '?';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle preset pill click.
|
||||
*/
|
||||
onPresetClick(preset: FilterPreset): void {
|
||||
this.urlSyncService.applyPreset(preset.id);
|
||||
this.presetSelected.emit(preset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle copy URL click.
|
||||
*/
|
||||
async onCopyUrl(): Promise<void> {
|
||||
const success = await this.urlSyncService.copyShareableUrl();
|
||||
|
||||
if (success) {
|
||||
this.justCopied.set(true);
|
||||
setTimeout(() => this.justCopied.set(false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reset click.
|
||||
*/
|
||||
onReset(): void {
|
||||
this.urlSyncService.resetToDefaults();
|
||||
this.filtersReset.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* @file filter-preset.models.ts
|
||||
* @sprint SPRINT_20260103_001_FE_filter_preset_pills
|
||||
* @description Filter preset interfaces and standard presets for triage workflows.
|
||||
*
|
||||
* Presets provide one-click filter configurations for common triage scenarios.
|
||||
* The pill bar surfaces these as always-visible clickable chips with URL sync.
|
||||
*/
|
||||
|
||||
import {
|
||||
TriageFilters,
|
||||
TriageEnvironment,
|
||||
DEFAULT_TRIAGE_FILTERS,
|
||||
} from '../../models/evidence-subgraph.models';
|
||||
|
||||
/**
|
||||
* Category grouping for presets in the UI.
|
||||
*/
|
||||
export type PresetCategory = 'standard' | 'noise-gating' | 'custom';
|
||||
|
||||
/**
|
||||
* Filter preset configuration.
|
||||
*/
|
||||
export interface FilterPreset {
|
||||
/** Unique preset identifier for URL sync. */
|
||||
id: string;
|
||||
/** Display name shown on the pill. */
|
||||
name: string;
|
||||
/** Tooltip description. */
|
||||
description: string;
|
||||
/** Visual icon (kept as text for accessibility). */
|
||||
icon: string;
|
||||
/** Category for grouping in expanded view. */
|
||||
category: PresetCategory;
|
||||
/** Partial filter overrides applied when preset is selected. */
|
||||
filters: Partial<TriageFilters>;
|
||||
/** Whether this is a system preset (cannot be deleted). */
|
||||
isSystem: boolean;
|
||||
/** Sort order within category. */
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL query parameter keys for filter serialization.
|
||||
*/
|
||||
export const FILTER_QUERY_PARAMS = {
|
||||
preset: 'preset',
|
||||
reachability: 'reach',
|
||||
patchStatus: 'patch',
|
||||
vexStatus: 'vex',
|
||||
severity: 'sev',
|
||||
showSuppressed: 'supp',
|
||||
runtimeExecuted: 'runtime',
|
||||
environment: 'env',
|
||||
backportProved: 'backport',
|
||||
semverMismatch: 'semver',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Standard presets for common triage workflows.
|
||||
*
|
||||
* Order:
|
||||
* 1. actionable (default)
|
||||
* 2. prod-runtime (new noise-gating)
|
||||
* 3. backport-verified (new noise-gating)
|
||||
* 4. critical-only
|
||||
* 5. needs-review
|
||||
* 6. vex-applied
|
||||
* 7. all-findings
|
||||
*/
|
||||
export const FILTER_PRESETS: FilterPreset[] = [
|
||||
// Standard presets
|
||||
{
|
||||
id: 'actionable',
|
||||
name: 'Actionable',
|
||||
description: 'Reachable, unpatched, critical/high severity - the default quiet view',
|
||||
icon: 'target',
|
||||
category: 'standard',
|
||||
filters: DEFAULT_TRIAGE_FILTERS,
|
||||
isSystem: true,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'critical-only',
|
||||
name: 'Critical Only',
|
||||
description: 'Focus on critical vulnerabilities only',
|
||||
icon: 'alert-circle',
|
||||
category: 'standard',
|
||||
filters: {
|
||||
...DEFAULT_TRIAGE_FILTERS,
|
||||
severity: ['critical'],
|
||||
},
|
||||
isSystem: true,
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
id: 'needs-review',
|
||||
name: 'Needs Review',
|
||||
description: 'Items awaiting triage decision',
|
||||
icon: 'eye',
|
||||
category: 'standard',
|
||||
filters: {
|
||||
reachability: 'Reachable',
|
||||
patchStatus: 'Unpatched',
|
||||
vexStatus: 'Unvexed',
|
||||
severity: ['critical', 'high', 'medium'],
|
||||
showSuppressed: false,
|
||||
},
|
||||
isSystem: true,
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
id: 'vex-applied',
|
||||
name: 'VEX Applied',
|
||||
description: 'Show findings with VEX statements applied',
|
||||
icon: 'check-circle',
|
||||
category: 'standard',
|
||||
filters: {
|
||||
reachability: 'All',
|
||||
patchStatus: 'All',
|
||||
vexStatus: 'Vexed',
|
||||
severity: ['critical', 'high', 'medium', 'low'],
|
||||
showSuppressed: false,
|
||||
},
|
||||
isSystem: true,
|
||||
order: 6,
|
||||
},
|
||||
{
|
||||
id: 'all-findings',
|
||||
name: 'All Findings',
|
||||
description: 'Show everything including suppressed items',
|
||||
icon: 'list',
|
||||
category: 'standard',
|
||||
filters: {
|
||||
reachability: 'All',
|
||||
patchStatus: 'All',
|
||||
vexStatus: 'All',
|
||||
severity: ['critical', 'high', 'medium', 'low'],
|
||||
showSuppressed: true,
|
||||
},
|
||||
isSystem: true,
|
||||
order: 7,
|
||||
},
|
||||
|
||||
// Noise-gating presets (new)
|
||||
{
|
||||
id: 'prod-runtime',
|
||||
name: 'Prod Runtime',
|
||||
description: 'Only vulnerabilities in code paths observed executing in production',
|
||||
icon: 'activity',
|
||||
category: 'noise-gating',
|
||||
filters: {
|
||||
...DEFAULT_TRIAGE_FILTERS,
|
||||
runtimeExecuted: true,
|
||||
environment: 'prod',
|
||||
},
|
||||
isSystem: true,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'backport-verified',
|
||||
name: 'Backport Verified',
|
||||
description: 'Packages where binary analysis proves patch is applied despite semver mismatch',
|
||||
icon: 'shield-check',
|
||||
category: 'noise-gating',
|
||||
filters: {
|
||||
reachability: 'All',
|
||||
patchStatus: 'All',
|
||||
vexStatus: 'All',
|
||||
severity: ['critical', 'high', 'medium', 'low'],
|
||||
showSuppressed: false,
|
||||
backportProved: true,
|
||||
semverMismatch: true,
|
||||
},
|
||||
isSystem: true,
|
||||
order: 3,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get preset by ID.
|
||||
*/
|
||||
export function getPresetById(id: string): FilterPreset | undefined {
|
||||
return FILTER_PRESETS.find(p => p.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get presets sorted by order within categories.
|
||||
*/
|
||||
export function getPresetsOrdered(): FilterPreset[] {
|
||||
return [...FILTER_PRESETS].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get presets grouped by category.
|
||||
*/
|
||||
export function getPresetsByCategory(): Map<PresetCategory, FilterPreset[]> {
|
||||
const grouped = new Map<PresetCategory, FilterPreset[]>();
|
||||
|
||||
for (const preset of FILTER_PRESETS) {
|
||||
const list = grouped.get(preset.category) ?? [];
|
||||
list.push(preset);
|
||||
grouped.set(preset.category, list);
|
||||
}
|
||||
|
||||
// Sort each category by order
|
||||
for (const [category, presets] of grouped) {
|
||||
grouped.set(category, presets.sort((a, b) => a.order - b.order));
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current filters match a preset.
|
||||
*/
|
||||
export function matchesPreset(
|
||||
filters: TriageFilters,
|
||||
preset: FilterPreset
|
||||
): boolean {
|
||||
const pf: TriageFilters = {
|
||||
...DEFAULT_TRIAGE_FILTERS,
|
||||
...preset.filters,
|
||||
};
|
||||
|
||||
// Compare each filter field
|
||||
if (filters.reachability !== pf.reachability) return false;
|
||||
if (filters.patchStatus !== pf.patchStatus) return false;
|
||||
if (filters.vexStatus !== pf.vexStatus) return false;
|
||||
if (filters.showSuppressed !== pf.showSuppressed) return false;
|
||||
|
||||
// Compare severity arrays (order-independent)
|
||||
const aSev = [...filters.severity].sort();
|
||||
const bSev = [...pf.severity].sort();
|
||||
if (aSev.length !== bSev.length || !aSev.every((v, i) => v === bSev[i])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare noise-gating fields (only if preset specifies them)
|
||||
if (preset.filters.runtimeExecuted !== undefined) {
|
||||
if (filters.runtimeExecuted !== pf.runtimeExecuted) return false;
|
||||
}
|
||||
if (preset.filters.environment !== undefined) {
|
||||
if (filters.environment !== pf.environment) return false;
|
||||
}
|
||||
if (preset.filters.backportProved !== undefined) {
|
||||
if (filters.backportProved !== pf.backportProved) return false;
|
||||
}
|
||||
if (preset.filters.semverMismatch !== undefined) {
|
||||
if (filters.semverMismatch !== pf.semverMismatch) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find matching preset for current filters.
|
||||
*/
|
||||
export function findMatchingPreset(filters: TriageFilters): FilterPreset | null {
|
||||
for (const preset of FILTER_PRESETS) {
|
||||
if (matchesPreset(filters, preset)) {
|
||||
return preset;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize filters to URL query string.
|
||||
*/
|
||||
export function serializeFiltersToQuery(filters: TriageFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Check if matches a preset first - use preset param for cleaner URLs
|
||||
const matchingPreset = findMatchingPreset(filters);
|
||||
if (matchingPreset) {
|
||||
params.set(FILTER_QUERY_PARAMS.preset, matchingPreset.id);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
// Otherwise serialize individual filters
|
||||
if (filters.reachability !== 'Reachable') {
|
||||
params.set(FILTER_QUERY_PARAMS.reachability, filters.reachability);
|
||||
}
|
||||
if (filters.patchStatus !== 'Unpatched') {
|
||||
params.set(FILTER_QUERY_PARAMS.patchStatus, filters.patchStatus);
|
||||
}
|
||||
if (filters.vexStatus !== 'Unvexed') {
|
||||
params.set(FILTER_QUERY_PARAMS.vexStatus, filters.vexStatus);
|
||||
}
|
||||
if (filters.severity.length > 0) {
|
||||
params.set(FILTER_QUERY_PARAMS.severity, filters.severity.join(','));
|
||||
}
|
||||
if (filters.showSuppressed) {
|
||||
params.set(FILTER_QUERY_PARAMS.showSuppressed, 'true');
|
||||
}
|
||||
|
||||
// Noise-gating params (only if non-default)
|
||||
if (filters.runtimeExecuted) {
|
||||
params.set(FILTER_QUERY_PARAMS.runtimeExecuted, 'true');
|
||||
}
|
||||
if (filters.environment && filters.environment !== 'all') {
|
||||
params.set(FILTER_QUERY_PARAMS.environment, filters.environment);
|
||||
}
|
||||
if (filters.backportProved) {
|
||||
params.set(FILTER_QUERY_PARAMS.backportProved, 'true');
|
||||
}
|
||||
if (filters.semverMismatch) {
|
||||
params.set(FILTER_QUERY_PARAMS.semverMismatch, 'true');
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse filters from URL query string.
|
||||
*/
|
||||
export function parseFiltersFromQuery(queryString: string): TriageFilters {
|
||||
const params = new URLSearchParams(queryString);
|
||||
|
||||
// Check for preset param first
|
||||
const presetId = params.get(FILTER_QUERY_PARAMS.preset);
|
||||
if (presetId) {
|
||||
const preset = getPresetById(presetId);
|
||||
if (preset) {
|
||||
return {
|
||||
...DEFAULT_TRIAGE_FILTERS,
|
||||
...preset.filters,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Parse individual filters
|
||||
const filters: TriageFilters = { ...DEFAULT_TRIAGE_FILTERS };
|
||||
|
||||
const reachability = params.get(FILTER_QUERY_PARAMS.reachability);
|
||||
if (reachability && isValidReachability(reachability)) {
|
||||
filters.reachability = reachability;
|
||||
}
|
||||
|
||||
const patchStatus = params.get(FILTER_QUERY_PARAMS.patchStatus);
|
||||
if (patchStatus && isValidPatchStatus(patchStatus)) {
|
||||
filters.patchStatus = patchStatus;
|
||||
}
|
||||
|
||||
const vexStatus = params.get(FILTER_QUERY_PARAMS.vexStatus);
|
||||
if (vexStatus && isValidVexStatus(vexStatus)) {
|
||||
filters.vexStatus = vexStatus;
|
||||
}
|
||||
|
||||
const severity = params.get(FILTER_QUERY_PARAMS.severity);
|
||||
if (severity) {
|
||||
filters.severity = severity.split(',').filter(s => isValidSeverity(s));
|
||||
}
|
||||
|
||||
const showSuppressed = params.get(FILTER_QUERY_PARAMS.showSuppressed);
|
||||
if (showSuppressed === 'true') {
|
||||
filters.showSuppressed = true;
|
||||
}
|
||||
|
||||
// Noise-gating params
|
||||
const runtimeExecuted = params.get(FILTER_QUERY_PARAMS.runtimeExecuted);
|
||||
if (runtimeExecuted === 'true') {
|
||||
filters.runtimeExecuted = true;
|
||||
}
|
||||
|
||||
const environment = params.get(FILTER_QUERY_PARAMS.environment);
|
||||
if (environment && isValidEnvironment(environment)) {
|
||||
filters.environment = environment;
|
||||
}
|
||||
|
||||
const backportProved = params.get(FILTER_QUERY_PARAMS.backportProved);
|
||||
if (backportProved === 'true') {
|
||||
filters.backportProved = true;
|
||||
}
|
||||
|
||||
const semverMismatch = params.get(FILTER_QUERY_PARAMS.semverMismatch);
|
||||
if (semverMismatch === 'true') {
|
||||
filters.semverMismatch = true;
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
// Type guards for parsing validation
|
||||
function isValidReachability(value: string): value is TriageFilters['reachability'] {
|
||||
return ['All', 'Reachable', 'Unreachable', 'Unknown'].includes(value);
|
||||
}
|
||||
|
||||
function isValidPatchStatus(value: string): value is TriageFilters['patchStatus'] {
|
||||
return ['All', 'Patched', 'Unpatched'].includes(value);
|
||||
}
|
||||
|
||||
function isValidVexStatus(value: string): value is TriageFilters['vexStatus'] {
|
||||
return ['All', 'Vexed', 'Unvexed', 'Conflicting'].includes(value);
|
||||
}
|
||||
|
||||
function isValidSeverity(value: string): boolean {
|
||||
return ['critical', 'high', 'medium', 'low'].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
function isValidEnvironment(value: string): value is TriageEnvironment {
|
||||
return ['all', 'prod', 'staging', 'dev'].includes(value);
|
||||
}
|
||||
@@ -148,14 +148,60 @@ export interface ExecuteTriageActionResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* Triage filters.
|
||||
* Triage severity level.
|
||||
*/
|
||||
export type TriageSeverity = 'Critical' | 'High' | 'Medium' | 'Low';
|
||||
|
||||
/**
|
||||
* Environment filter for runtime-executed filtering.
|
||||
*/
|
||||
export type TriageEnvironment = 'all' | 'prod' | 'staging' | 'dev';
|
||||
|
||||
/**
|
||||
* Triage filters with noise-gating capabilities.
|
||||
*
|
||||
* @sprint SPRINT_20260103_001_FE_filter_preset_pills
|
||||
*/
|
||||
export interface TriageFilters {
|
||||
/** Reachability status filter. */
|
||||
reachability: 'All' | 'Reachable' | 'Unreachable' | 'Unknown';
|
||||
/** Patch status filter. */
|
||||
patchStatus: 'All' | 'Patched' | 'Unpatched';
|
||||
/** VEX status filter. */
|
||||
vexStatus: 'All' | 'Vexed' | 'Unvexed' | 'Conflicting';
|
||||
/** Severity levels to include. */
|
||||
severity: string[];
|
||||
/** Whether to show suppressed findings. */
|
||||
showSuppressed: boolean;
|
||||
|
||||
// Noise-gating fields for advanced filtering
|
||||
|
||||
/**
|
||||
* Filter to runtime-executed code paths only.
|
||||
* When true, shows only vulnerabilities in code paths observed
|
||||
* executing in the specified environment.
|
||||
*/
|
||||
runtimeExecuted?: boolean;
|
||||
|
||||
/**
|
||||
* Environment filter for runtime execution.
|
||||
* Used with runtimeExecuted to filter by deployment environment.
|
||||
*/
|
||||
environment?: TriageEnvironment;
|
||||
|
||||
/**
|
||||
* Filter to backport-verified findings only.
|
||||
* When true, shows only packages where binary patch signature
|
||||
* proves the fix is applied.
|
||||
*/
|
||||
backportProved?: boolean;
|
||||
|
||||
/**
|
||||
* Filter to semver-mismatch packages.
|
||||
* When true, shows packages where version string looks vulnerable
|
||||
* but binary analysis proves it's patched (backport detection).
|
||||
*/
|
||||
semverMismatch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* @file filter-url-sync.service.ts
|
||||
* @sprint SPRINT_20260103_001_FE_filter_preset_pills
|
||||
* @description Service for synchronizing triage filters with URL query parameters.
|
||||
*
|
||||
* Enables shareable filter states via URLs like:
|
||||
* - /triage?preset=actionable
|
||||
* - /triage?reach=Reachable&sev=critical,high&runtime=true&env=prod
|
||||
*/
|
||||
|
||||
import { Injectable, inject, signal, computed, effect, DestroyRef } from '@angular/core';
|
||||
import { Router, ActivatedRoute, NavigationEnd, Params } from '@angular/router';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { filter, map, distinctUntilChanged, skip } from 'rxjs/operators';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import {
|
||||
TriageFilters,
|
||||
DEFAULT_TRIAGE_FILTERS,
|
||||
} from '../models/evidence-subgraph.models';
|
||||
import {
|
||||
FilterPreset,
|
||||
FILTER_PRESETS,
|
||||
serializeFiltersToQuery,
|
||||
parseFiltersFromQuery,
|
||||
findMatchingPreset,
|
||||
getPresetById,
|
||||
FILTER_QUERY_PARAMS,
|
||||
} from '../components/filter-preset-pills/filter-preset.models';
|
||||
|
||||
/**
|
||||
* Service for bidirectional sync between filter state and URL query params.
|
||||
*
|
||||
* Features:
|
||||
* - Parses filters from URL on initial load
|
||||
* - Updates URL when filters change (debounced)
|
||||
* - Provides reactive signals for components
|
||||
* - Supports preset shortcuts for cleaner URLs
|
||||
* - Copy-to-clipboard for shareable links
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FilterUrlSyncService {
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State Signals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Current filter state (source of truth) */
|
||||
private readonly _filters = signal<TriageFilters>({ ...DEFAULT_TRIAGE_FILTERS });
|
||||
|
||||
/** Whether URL sync is currently updating (prevents infinite loops) */
|
||||
private readonly _isUpdatingUrl = signal(false);
|
||||
|
||||
/** Whether service has been initialized from URL */
|
||||
private readonly _initialized = signal(false);
|
||||
|
||||
/** Last URL that was synced (prevents duplicate updates) */
|
||||
private readonly _lastSyncedQuery = signal<string>('');
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public Signals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Current filters (read-only) */
|
||||
readonly filters = this._filters.asReadonly();
|
||||
|
||||
/** Whether service has initialized from URL */
|
||||
readonly initialized = this._initialized.asReadonly();
|
||||
|
||||
/** Currently active preset (computed from filters) */
|
||||
readonly activePreset = computed<FilterPreset | null>(() => {
|
||||
return findMatchingPreset(this._filters());
|
||||
});
|
||||
|
||||
/** Whether current filters match any preset */
|
||||
readonly hasActivePreset = computed(() => this.activePreset() !== null);
|
||||
|
||||
/** Active preset ID (for simple comparisons) */
|
||||
readonly activePresetId = computed(() => this.activePreset()?.id ?? null);
|
||||
|
||||
/** Count of active filter modifications from default */
|
||||
readonly activeFilterCount = computed(() => {
|
||||
const filters = this._filters();
|
||||
let count = 0;
|
||||
|
||||
if (filters.reachability !== DEFAULT_TRIAGE_FILTERS.reachability) count++;
|
||||
if (filters.patchStatus !== DEFAULT_TRIAGE_FILTERS.patchStatus) count++;
|
||||
if (filters.vexStatus !== DEFAULT_TRIAGE_FILTERS.vexStatus) count++;
|
||||
if (filters.showSuppressed !== DEFAULT_TRIAGE_FILTERS.showSuppressed) count++;
|
||||
|
||||
// Severity comparison
|
||||
const defaultSev = [...DEFAULT_TRIAGE_FILTERS.severity].sort();
|
||||
const currentSev = [...filters.severity].sort();
|
||||
if (defaultSev.length !== currentSev.length ||
|
||||
!defaultSev.every((v, i) => v === currentSev[i])) {
|
||||
count++;
|
||||
}
|
||||
|
||||
// Noise-gating fields
|
||||
if (filters.runtimeExecuted) count++;
|
||||
if (filters.environment && filters.environment !== 'all') count++;
|
||||
if (filters.backportProved) count++;
|
||||
if (filters.semverMismatch) count++;
|
||||
|
||||
return count;
|
||||
});
|
||||
|
||||
/** Human-readable summary of active filters */
|
||||
readonly filterSummary = computed(() => {
|
||||
const filters = this._filters();
|
||||
const parts: string[] = [];
|
||||
|
||||
if (filters.reachability !== 'All') {
|
||||
parts.push(filters.reachability);
|
||||
}
|
||||
if (filters.patchStatus !== 'All') {
|
||||
parts.push(filters.patchStatus);
|
||||
}
|
||||
if (filters.vexStatus !== 'All') {
|
||||
parts.push(filters.vexStatus);
|
||||
}
|
||||
|
||||
// Severity (if not all)
|
||||
if (filters.severity.length < 4 && filters.severity.length > 0) {
|
||||
parts.push(filters.severity.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('/'));
|
||||
}
|
||||
|
||||
// Noise-gating indicators
|
||||
if (filters.runtimeExecuted) {
|
||||
parts.push(`Runtime${filters.environment && filters.environment !== 'all' ? ':' + filters.environment : ''}`);
|
||||
}
|
||||
if (filters.backportProved) {
|
||||
parts.push('Backport-proven');
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' + ') : 'No filters';
|
||||
});
|
||||
|
||||
/** Current shareable URL */
|
||||
readonly shareableUrl = computed(() => {
|
||||
const query = serializeFiltersToQuery(this._filters());
|
||||
const base = window.location.origin + window.location.pathname;
|
||||
return query ? `${base}?${query}` : base;
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Event Subjects
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Emits when filters change */
|
||||
private readonly filtersChanged$ = new Subject<TriageFilters>();
|
||||
|
||||
constructor() {
|
||||
this.initializeFromUrl();
|
||||
this.setupUrlSync();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update filters and sync to URL.
|
||||
*/
|
||||
setFilters(filters: TriageFilters): void {
|
||||
this._filters.set(filters);
|
||||
this.syncToUrl(filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a partial filter update.
|
||||
*/
|
||||
updateFilters(partial: Partial<TriageFilters>): void {
|
||||
const newFilters = {
|
||||
...this._filters(),
|
||||
...partial,
|
||||
};
|
||||
this.setFilters(newFilters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a preset by ID.
|
||||
*/
|
||||
applyPreset(presetId: string): void {
|
||||
const preset = getPresetById(presetId);
|
||||
if (preset) {
|
||||
const newFilters: TriageFilters = {
|
||||
...DEFAULT_TRIAGE_FILTERS,
|
||||
...preset.filters,
|
||||
};
|
||||
this.setFilters(newFilters);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to default filters.
|
||||
*/
|
||||
resetToDefaults(): void {
|
||||
this.setFilters({ ...DEFAULT_TRIAGE_FILTERS });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all filters (show everything).
|
||||
*/
|
||||
clearAllFilters(): void {
|
||||
this.applyPreset('all-findings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy shareable URL to clipboard.
|
||||
* Returns true if successful.
|
||||
*/
|
||||
async copyShareableUrl(): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.shareableUrl());
|
||||
return true;
|
||||
} catch {
|
||||
// Fallback for older browsers or permission denied
|
||||
return this.fallbackCopyToClipboard(this.shareableUrl());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get observable of filter changes.
|
||||
*/
|
||||
get filtersChanged() {
|
||||
return this.filtersChanged$.asObservable();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initialize filters from current URL on service creation.
|
||||
*/
|
||||
private initializeFromUrl(): void {
|
||||
// Get initial query params
|
||||
const queryString = window.location.search.slice(1);
|
||||
|
||||
if (queryString) {
|
||||
const filters = parseFiltersFromQuery(queryString);
|
||||
this._filters.set(filters);
|
||||
this._lastSyncedQuery.set(queryString);
|
||||
}
|
||||
|
||||
this._initialized.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup subscription to sync URL changes back to filter state.
|
||||
*/
|
||||
private setupUrlSync(): void {
|
||||
// Watch for external navigation changes
|
||||
this.router.events.pipe(
|
||||
filter(event => event instanceof NavigationEnd),
|
||||
// Skip the first emission (we already initialized)
|
||||
skip(1),
|
||||
map(() => window.location.search.slice(1)),
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(queryString => {
|
||||
// Only update if not caused by our own sync
|
||||
if (!this._isUpdatingUrl() && queryString !== this._lastSyncedQuery()) {
|
||||
const filters = parseFiltersFromQuery(queryString);
|
||||
this._filters.set(filters);
|
||||
this._lastSyncedQuery.set(queryString);
|
||||
this.filtersChanged$.next(filters);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync current filters to URL query params.
|
||||
*/
|
||||
private syncToUrl(filters: TriageFilters): void {
|
||||
const queryString = serializeFiltersToQuery(filters);
|
||||
|
||||
// Skip if already synced
|
||||
if (queryString === this._lastSyncedQuery()) {
|
||||
this.filtersChanged$.next(filters);
|
||||
return;
|
||||
}
|
||||
|
||||
this._isUpdatingUrl.set(true);
|
||||
this._lastSyncedQuery.set(queryString);
|
||||
|
||||
// Update URL without triggering navigation
|
||||
const queryParams: Params = {};
|
||||
|
||||
if (queryString) {
|
||||
const params = new URLSearchParams(queryString);
|
||||
params.forEach((value, key) => {
|
||||
queryParams[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams,
|
||||
queryParamsHandling: '',
|
||||
replaceUrl: true, // Don't add to browser history for filter changes
|
||||
}).then(() => {
|
||||
this._isUpdatingUrl.set(false);
|
||||
this.filtersChanged$.next(filters);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback clipboard copy for older browsers.
|
||||
*/
|
||||
private fallbackCopyToClipboard(text: string): boolean {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
textArea.style.top = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
return success;
|
||||
} catch {
|
||||
document.body.removeChild(textArea);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user