feat(ui): ship triage explainability workspace

This commit is contained in:
master
2026-03-07 21:43:55 +02:00
parent 437d26c47c
commit 8f43378317
32 changed files with 2296 additions and 259 deletions

View File

@@ -31,7 +31,7 @@
## Delivery Tracker ## Delivery Tracker
### FE-TX-001 - Wire the canonical artifact workspace and route state ### FE-TX-001 - Wire the canonical artifact workspace and route state
Status: TODO Status: DONE
Dependency: none Dependency: none
Owners: Product Manager, FE Architect Owners: Product Manager, FE Architect
Task description: Task description:
@@ -39,12 +39,12 @@ Task description:
- Ensure the active shell exposes the triage workspace operators should actually use. - Ensure the active shell exposes the triage workspace operators should actually use.
Completion criteria: Completion criteria:
- [ ] Canonical artifact and audit-bundle routes are active in the router. - [x] Canonical artifact and audit-bundle routes are active in the router.
- [ ] Lane and panel query params work in the shipped UI. - [x] Lane and panel query params work in the shipped UI.
- [ ] Separate workbench brands are no longer required for triage access. - [x] Separate workbench brands are no longer required for triage access.
### FE-TX-002 - Ship the list-lane workflows ### FE-TX-002 - Ship the list-lane workflows
Status: TODO Status: DONE
Dependency: FE-TX-001 Dependency: FE-TX-001
Owners: Developer, FE Architect Owners: Developer, FE Architect
Task description: Task description:
@@ -52,12 +52,12 @@ Task description:
- Ensure row actions, bulk actions, and lane transitions are usable from the active artifact list. - Ensure row actions, bulk actions, and lane transitions are usable from the active artifact list.
Completion criteria: Completion criteria:
- [ ] Lane tabs or segmented controls are working in the shipped UI. - [x] Lane tabs or segmented controls are working in the shipped UI.
- [ ] Row and bulk actions work from the artifact list. - [x] Row and bulk actions work from the artifact list.
- [ ] Quiet-lane behavior is usable as queue state, not a detached page. - [x] Quiet-lane behavior is usable as queue state, not a detached page.
### FE-TX-003 - Ship the detail-side explainability workspace ### FE-TX-003 - Ship the detail-side explainability workspace
Status: TODO Status: DONE
Dependency: FE-TX-001 Dependency: FE-TX-001
Owners: Developer, Product Manager Owners: Developer, Product Manager
Task description: Task description:
@@ -65,12 +65,12 @@ Task description:
- Make them usable beside the central artifact summary and evidence trail instead of leaving them as unmounted workbench ideas. - Make them usable beside the central artifact summary and evidence trail instead of leaving them as unmounted workbench ideas.
Completion criteria: Completion criteria:
- [ ] Detail-side panels render and open via the active workspace route state. - [x] Detail-side panels render and open via the active workspace route state.
- [ ] Panel actions and return-to-context behavior work in the shipped UI. - [x] Panel actions and return-to-context behavior work in the shipped UI.
- [ ] AI remains advisory and evidence-first in the shipped detail experience. - [x] AI remains advisory and evidence-first in the shipped detail experience.
### FE-TX-004 - Ship the Audit Bundles page and create flow ### FE-TX-004 - Ship the Audit Bundles page and create flow
Status: TODO Status: DONE
Dependency: FE-TX-001 Dependency: FE-TX-001
Owners: Developer, Documentation author Owners: Developer, Documentation author
Task description: Task description:
@@ -78,12 +78,12 @@ Task description:
- Ensure operators can build and retrieve audit bundles from the active triage and evidence flows. - Ensure operators can build and retrieve audit bundles from the active triage and evidence flows.
Completion criteria: Completion criteria:
- [ ] Bundle list and create flow are usable in the shipped UI. - [x] Bundle list and create flow are usable in the shipped UI.
- [ ] Cross-links from artifact detail and evidence open the working page. - [x] Cross-links from artifact detail and evidence open the working page.
- [ ] Audit bundles remain a visible sibling page, not a hidden modal flow. - [x] Audit bundles remain a visible sibling page, not a hidden modal flow.
### FE-TX-005 - Migrate supporting components and retire workbench wrappers ### FE-TX-005 - Migrate supporting components and retire workbench wrappers
Status: TODO Status: DONE
Dependency: FE-TX-003 Dependency: FE-TX-003
Owners: Developer, Documentation author Owners: Developer, Documentation author
Task description: Task description:
@@ -91,12 +91,12 @@ Task description:
- Retire wrapper shells only after their preserved behavior is working in the active artifact workspace. - Retire wrapper shells only after their preserved behavior is working in the active artifact workspace.
Completion criteria: Completion criteria:
- [ ] Supporting components are visible in the working list or detail surfaces. - [x] Supporting components are visible in the working list or detail surfaces.
- [ ] Wrapper shells slated for retirement are no longer needed for preserved behavior. - [x] Wrapper shells slated for retirement are no longer needed for preserved behavior.
- [ ] No preserved triage functionality depends on an orphan workbench route. - [x] No preserved triage functionality depends on an orphan workbench route.
### FE-TX-006 - Verify, document, and cut over the workspace ### FE-TX-006 - Verify, document, and cut over the workspace
Status: TODO Status: DONE
Dependency: FE-TX-004 Dependency: FE-TX-004
Owners: QA, Documentation author Owners: QA, Documentation author
Task description: Task description:
@@ -104,14 +104,17 @@ Task description:
- Update triage and UI docs so the artifact workspace ships as the usable owner of these workflows. - Update triage and UI docs so the artifact workspace ships as the usable owner of these workflows.
Completion criteria: Completion criteria:
- [ ] Verification covers lane changes, detail panels, and audit bundles. - [x] Verification covers lane changes, detail panels, and audit bundles.
- [ ] Cross-shell deep links are included in testing. - [x] Cross-shell deep links are included in testing.
- [ ] Docs reflect the shipped artifact workspace and audit-bundle flows. - [x] Docs reflect the shipped artifact workspace and audit-bundle flows.
## Execution Log ## Execution Log
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
| --- | --- | --- | | --- | --- | --- |
| 2026-03-07 | Sprint created to ship one artifact workspace with lane segmentation, detail-side explainability, and a sibling Audit Bundles page instead of keeping those capabilities in dropped workbench shells. | Project Manager | | 2026-03-07 | Sprint created to ship one artifact workspace with lane segmentation, detail-side explainability, and a sibling Audit Bundles page instead of keeping those capabilities in dropped workbench shells. | Project Manager |
| 2026-03-07 | Implementation started. The current workspace already contains usable artifact and evidence surfaces, but route ownership, lane segmentation, explainability panels, and audit-bundle routing are split between `/security/*`, `/triage/*`, and orphan workbench ideas. This sprint absorbs the audit-bundle route/client prerequisite while wiring the canonical triage owner shell. | Developer |
| 2026-03-07 | Shipped the canonical `/triage/artifacts` and `/triage/audit-bundles` route family, list-lane state, merged explainability rail, audit-bundle create flow, and sidebar cutover. Security artifact aliases now canonicalize into `/triage/*` without dropping `artifactId`. | Developer |
| 2026-03-07 | Verified delivery with `npx ng test --watch=false --include src/tests/triage/triage-artifacts.component.spec.ts --include src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts --include src/tests/audit_bundle/triage-routes.spec.ts --include src/tests/security-risk/security-risk-routes.spec.ts --include src/tests/navigation/legacy-redirects.spec.ts --include src/tests/routes/legacy-route-migration-framework.component.spec.ts --include src/tests/audit_bundle/audit-bundles.client.contract.spec.ts --include src/tests/audit_bundle/triage-audit-bundle-new.component.spec.ts --include src/tests/audit_bundle/triage-audit-bundles.component.spec.ts` (68 passing tests) and `npx playwright test tests/e2e/triage-explainability-workspace.spec.ts --workers=1` (2 passing scenarios). | QA |
## Decisions & Risks ## Decisions & Risks
- Decision: triage stays one workspace with contextual explainability, not multiple workbench brands. - Decision: triage stays one workspace with contextual explainability, not multiple workbench brands.
@@ -120,10 +123,11 @@ Completion criteria:
- Mitigation: require explicit advisory-only copy and evidence panels in the detail contract. - Mitigation: require explicit advisory-only copy and evidence panels in the detail contract.
- Risk: quiet-lane behavior may get over-specialized into another shell. - Risk: quiet-lane behavior may get over-specialized into another shell.
- Mitigation: freeze it as list segmentation plus row or bulk actions only. - Mitigation: freeze it as list segmentation plus row or bulk actions only.
- Decision: the in-flight audit-bundle route/client repair is absorbed into this sprint so the canonical `/triage/*` workspace can ship without depending on an uncommitted prerequisite slice.
- Delivery rule: this sprint is only complete when the active triage workspace provides the preserved explainability and audit workflows without depending on orphan workbench pages. - Delivery rule: this sprint is only complete when the active triage workspace provides the preserved explainability and audit workflows without depending on orphan workbench pages.
- Decision: `/security/artifacts*` now acts strictly as an alias into `/triage/artifacts*`; the alias must preserve `artifactId`, query state, and fragments.
- Decision: TTFS telemetry timers run outside Angular and are cleaned up on destroy so the shipped workspace remains stable under unit and browser test harnesses.
- Reference design note: `docs/modules/ui/triage-explainability-workspace/README.md`. - Reference design note: `docs/modules/ui/triage-explainability-workspace/README.md`.
## Next Checkpoints ## Next Checkpoints
- 2026-03-08: confirm lane model, detail-side panel set, and Audit Bundles ownership. - Archive sprint after commit and keep follow-on work in `SPRINT_20260307_028_FE_workflow_visualization_replay.md` and `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`.
- 2026-03-09: freeze supporting component merge matrix and route/query contract.
- 2026-03-10: finalize QA and rollout contract.

View File

@@ -0,0 +1,60 @@
# Triage Explainability Workspace UI
## Module
Web
## Status
VERIFIED
## Description
Shipped the canonical triage artifact workspace with lane-based queue segmentation, contextual explainability panels, canonical `/triage/*` ownership, and a sibling `Audit Bundles` page. The preserved workbench ideas now live inside one usable operator shell instead of orphan routes. Security artifact entry points deep-link into the same canonical workspace instead of owning a second surface.
## Implementation Details
- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/triage/`
- **Primary components**:
- `triage-artifacts` (`src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts`)
- `triage-workspace` (`src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts`)
- `triage-audit-bundles` (`src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.ts`)
- `triage-audit-bundle-new` (`src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.ts`)
- **Canonical routes**:
- `/triage/artifacts`
- `/triage/artifacts/:artifactId`
- `/triage/audit-bundles`
- `/triage/audit-bundles/new`
- **Route state**:
- `lane=active|quiet|review`
- `panel=ai|reason|provenance|history`
- `findingId=<vulnId>`
- `tab=evidence|overview|reachability|delta|policy|attestations`
- **Preserved functionality**:
- lane-based queue management with row and bulk transitions
- explainability rail for AI recommendations, reason capsule, provenance, and decision history
- audit-bundle list and create flow
- security alias cutover from `/security/artifacts*`
- **Secondary entry points**:
- `Security > Triage`
- `Security > Findings`
- `Evidence`
## E2E Test Plan
- **Setup**:
- [ ] Log in with a user that can access `Security`, `Triage`, and `Evidence`.
- [ ] Navigate to `/triage/artifacts?lane=review`.
- [ ] Ensure vulnerability and audit-bundle fixtures are available.
- **Core verification**:
- [ ] Verify `Active`, `Quiet Lane`, and `Needs Review` lanes load and keep query state.
- [ ] Verify bulk lane movement and `Build audit bundle` operate from the live list.
- [ ] Verify workspace detail preserves `tab` and `panel` state while showing evidence-first advisory UX.
- **Legacy verification**:
- [ ] Verify `/security/artifacts` and `/security/artifacts/:artifactId` canonicalize into `/triage/*`.
- [ ] Verify alias redirects preserve `artifactId`, query params, and fragments.
- [ ] Verify audit-bundle creation remains reachable from list and detail entry points.
## Verification
- Run:
- `npx ng test --watch=false --include src/tests/triage/triage-artifacts.component.spec.ts --include src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts --include src/tests/audit_bundle/triage-routes.spec.ts --include src/tests/security-risk/security-risk-routes.spec.ts --include src/tests/navigation/legacy-redirects.spec.ts --include src/tests/routes/legacy-route-migration-framework.component.spec.ts --include src/tests/audit_bundle/audit-bundles.client.contract.spec.ts --include src/tests/audit_bundle/triage-audit-bundle-new.component.spec.ts --include src/tests/audit_bundle/triage-audit-bundles.component.spec.ts`
- `npx playwright test tests/e2e/triage-explainability-workspace.spec.ts --workers=1`
- Tier 0 (source): pass
- Tier 1 (build/tests): pass
- Tier 2 (behavior): pass
- Verified on (UTC): 2026-03-07T19:40:00Z

View File

@@ -9,7 +9,6 @@
- `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md`
- `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md`
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md`
- `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
- `docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md` - `docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`
@@ -87,12 +86,12 @@
- [DONE] FE-PO-004 Route cleanup and alias migration contract for Operations - [DONE] FE-PO-004 Route cleanup and alias migration contract for Operations
- [DONE] FE-PO-005 Setup boundary and deep-link contract for Operations - [DONE] FE-PO-005 Setup boundary and deep-link contract for Operations
- [DONE] FE-PO-006 QA, rollout, and docs sync for Platform Ops consolidation - [DONE] FE-PO-006 QA, rollout, and docs sync for Platform Ops consolidation
- [TODO] FE-TX-001 Freeze artifact workspace route, lane, and panel contract - [DONE] FE-TX-001 Freeze artifact workspace route, lane, and panel contract
- [TODO] FE-TX-002 List-lane segmentation slice for Artifact Workspace - [DONE] FE-TX-002 List-lane segmentation slice for Artifact Workspace
- [TODO] FE-TX-003 Detail-side explainability rail slice - [DONE] FE-TX-003 Detail-side explainability rail slice
- [TODO] FE-TX-004 Audit bundles page and create-flow slice - [DONE] FE-TX-004 Audit bundles page and create-flow slice
- [TODO] FE-TX-005 Supporting component merge matrix for Triage explainability - [DONE] FE-TX-005 Supporting component merge matrix for Triage explainability
- [TODO] FE-TX-006 QA, rollout, and docs sync for Triage explainability - [DONE] FE-TX-006 QA, rollout, and docs sync for Triage explainability
- [TODO] FE-WV-001 Freeze run-detail tab and route contract for workflow visualization - [TODO] FE-WV-001 Freeze run-detail tab and route contract for workflow visualization
- [TODO] FE-WV-002 Graph, timeline, and critical-path slice - [TODO] FE-WV-002 Graph, timeline, and critical-path slice
- [TODO] FE-WV-003 Replay and evidence integration slice - [TODO] FE-WV-003 Replay and evidence integration slice

View File

@@ -13,7 +13,6 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components. - `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components.
- `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation. - `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation.
- `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself. - `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself.
- `SPRINT_20260307_027_FE_triage_explainability_workspace.md` - ship the artifact workspace lane model, explainability panels, and audit-bundle flows.
- `SPRINT_20260307_028_FE_workflow_visualization_replay.md` - ship run-detail graph, timeline, replay, and evidence tabs plus bounded workflow-editor preview reuse. - `SPRINT_20260307_028_FE_workflow_visualization_replay.md` - ship run-detail graph, timeline, replay, and evidence tabs plus bounded workflow-editor preview reuse.
- `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - ship the shared tabs, drawers, right rails, split views, and contextual detail primitives adopted by the restoration features. - `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - ship the shared tabs, drawers, right rails, split views, and contextual detail primitives adopted by the restoration features.
@@ -27,6 +26,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- `docs/features/checked/web/reachability-witnessing-ui.md` - shipped verification note for the canonical Reachability witness and PoE shell. - `docs/features/checked/web/reachability-witnessing-ui.md` - shipped verification note for the canonical Reachability witness and PoE shell.
- `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs. - `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs.
- `docs/features/checked/web/operations-consolidation-ui.md` - shipped verification note for the canonical Operations shell, overview grouping, and legacy alias cutover. - `docs/features/checked/web/operations-consolidation-ui.md` - shipped verification note for the canonical Operations shell, overview grouping, and legacy alias cutover.
- `docs/features/checked/web/triage-explainability-workspace-ui.md` - shipped verification note for the canonical triage artifact workspace, explainability rail, audit bundles, and security alias cutover.
- `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract. - `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract.
- `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan. - `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan.
- `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier. - `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier.

View File

@@ -1,5 +1,19 @@
# Triage Explainability Workspace # Triage Explainability Workspace
## Status
Shipped on 2026-03-07 as the canonical triage owner shell.
- Canonical list route: `/triage/artifacts`
- Canonical detail route: `/triage/artifacts/:artifactId`
- Sibling supporting routes:
- `/triage/audit-bundles`
- `/triage/audit-bundles/new`
- Security alias routes:
- `/security/artifacts`
- `/security/artifacts/:artifactId`
- Verification note: `docs/features/checked/web/triage-explainability-workspace-ui.md`
## Recommendation ## Recommendation
Restore the useful triage workbench ideas by folding them into one canonical artifact workspace plus a sibling `Audit Bundles` page. Restore the useful triage workbench ideas by folding them into one canonical artifact workspace plus a sibling `Audit Bundles` page.

View File

@@ -8,7 +8,9 @@ import { generateTraceId } from './trace.util';
import type { import type {
AuditBundleCreateRequest, AuditBundleCreateRequest,
AuditBundleJobResponse, AuditBundleJobResponse,
AuditBundleJobStatus,
AuditBundleListResponse, AuditBundleListResponse,
BundleSubjectRef,
} from './audit-bundles.models'; } from './audit-bundles.models';
export interface AuditBundlesApi { export interface AuditBundlesApi {
@@ -21,9 +23,62 @@ export interface AuditBundlesApi {
export const AUDIT_BUNDLES_API = new InjectionToken<AuditBundlesApi>('AUDIT_BUNDLES_API'); export const AUDIT_BUNDLES_API = new InjectionToken<AuditBundlesApi>('AUDIT_BUNDLES_API');
export const AUDIT_BUNDLES_API_BASE_URL = new InjectionToken<string>('AUDIT_BUNDLES_API_BASE_URL'); export const AUDIT_BUNDLES_API_BASE_URL = new InjectionToken<string>('AUDIT_BUNDLES_API_BASE_URL');
interface AuditBundleCreateRequestDto {
subject: BundleSubjectRef;
timeWindow?: { from?: string; to?: string };
includeContent: {
vulnReports: boolean;
sbom: boolean;
vexDecisions: boolean;
policyEvaluations: boolean;
attestations: boolean;
};
}
interface AuditBundleSummaryDto {
bundleId: string;
subject: BundleSubjectRef;
status: string;
createdAt: string;
completedAt?: string | null;
bundleHash?: string | null;
}
interface AuditBundleListResponseDto {
bundles: AuditBundleSummaryDto[];
continuationToken?: string | null;
hasMore: boolean;
}
interface CreateAuditBundleResponseDto {
bundleId: string;
status: string;
statusUrl: string;
estimatedCompletionSeconds?: number | null;
}
interface AuditBundleStatusDto {
bundleId: string;
status: string;
progress: number;
createdAt: string;
completedAt?: string | null;
bundleHash?: string | null;
downloadUrl?: string | null;
ociReference?: string | null;
errorCode?: string | null;
errorMessage?: string | null;
}
interface KnownBundleMetadata {
subject: BundleSubjectRef;
createdAt: string;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuditBundlesHttpClient implements AuditBundlesApi { export class AuditBundlesHttpClient implements AuditBundlesApi {
private readonly tenantService = inject(TenantActivationService); private readonly tenantService = inject(TenantActivationService);
private readonly knownBundles = new Map<string, KnownBundleMetadata>();
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
@@ -36,8 +91,11 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
const traceId = generateTraceId(); const traceId = generateTraceId();
const headers = this.buildHeaders(tenant, traceId); const headers = this.buildHeaders(tenant, traceId);
return this.http.get<AuditBundleListResponse>(`${this.baseUrl}/v1/audit-bundles`, { headers }).pipe( return this.http.get<AuditBundleListResponseDto>(`${this.baseUrl}/v1/audit-bundles`, { headers }).pipe(
map((resp) => ({ ...resp, traceId })), map((resp) => {
const items = resp.bundles.map((bundle) => this.mapListItem(bundle, traceId));
return { items, count: items.length, traceId };
}),
catchError((err) => throwError(() => err)) catchError((err) => throwError(() => err))
); );
} }
@@ -47,8 +105,25 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(tenant, traceId, options.projectId); const headers = this.buildHeaders(tenant, traceId, options.projectId);
return this.http.post<AuditBundleJobResponse>(`${this.baseUrl}/v1/audit-bundles`, request, { headers }).pipe( return this.http.post<CreateAuditBundleResponseDto>(
map((resp) => ({ ...resp, traceId })), `${this.baseUrl}/v1/audit-bundles`,
this.toCreateRequestDto(request),
{ headers }
).pipe(
map((resp) => {
const createdAt = new Date().toISOString();
const job: AuditBundleJobResponse = {
bundleId: resp.bundleId,
status: normalizeAuditBundleStatus(resp.status),
createdAt,
subject: request.subject,
statusUrl: resp.statusUrl,
estimatedCompletionSeconds: resp.estimatedCompletionSeconds ?? undefined,
traceId,
};
this.rememberBundle(job);
return job;
}),
catchError((err) => throwError(() => err)) catchError((err) => throwError(() => err))
); );
} }
@@ -58,8 +133,26 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(tenant, traceId, options.projectId); const headers = this.buildHeaders(tenant, traceId, options.projectId);
return this.http.get<AuditBundleJobResponse>(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, { headers }).pipe( return this.http.get<AuditBundleStatusDto>(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, { headers }).pipe(
map((resp) => ({ ...resp, traceId })), map((resp) => {
const metadata = this.knownBundles.get(resp.bundleId);
const job: AuditBundleJobResponse = {
bundleId: resp.bundleId,
status: normalizeAuditBundleStatus(resp.status),
createdAt: coerceIsoTimestamp(resp.createdAt) ?? metadata?.createdAt ?? new Date(0).toISOString(),
completedAt: coerceIsoTimestamp(resp.completedAt),
progress: resp.progress,
subject: metadata?.subject ?? createUnknownSubject(resp.bundleId),
sha256: resp.bundleHash ?? undefined,
downloadUrl: resp.downloadUrl ?? undefined,
ociReference: resp.ociReference ?? undefined,
errorCode: resp.errorCode ?? undefined,
error: resp.errorMessage ?? undefined,
traceId,
};
this.rememberBundle(job);
return job;
}),
catchError((err) => throwError(() => err)) catchError((err) => throwError(() => err))
); );
} }
@@ -68,13 +161,48 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
const tenant = this.resolveTenant(options.tenantId); const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId(); const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(tenant, traceId, options.projectId).set('Accept', 'application/octet-stream'); const headers = this.buildHeaders(tenant, traceId, options.projectId).set('Accept', 'application/zip');
return this.http.get(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, { return this.http.get(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}/download`, {
headers, headers,
responseType: 'blob', responseType: 'blob',
}); });
} }
private toCreateRequestDto(request: AuditBundleCreateRequest): AuditBundleCreateRequestDto {
return {
subject: request.subject,
timeWindow: request.timeWindow,
includeContent: {
vulnReports: request.contents.vulnReports,
sbom: request.contents.sbom,
vexDecisions: request.contents.vex,
policyEvaluations: request.contents.policyEvals,
attestations: request.contents.attestations,
},
};
}
private mapListItem(bundle: AuditBundleSummaryDto, traceId: string): AuditBundleJobResponse {
const job: AuditBundleJobResponse = {
bundleId: bundle.bundleId,
status: normalizeAuditBundleStatus(bundle.status),
createdAt: coerceIsoTimestamp(bundle.createdAt) ?? new Date(0).toISOString(),
completedAt: coerceIsoTimestamp(bundle.completedAt),
subject: bundle.subject,
sha256: bundle.bundleHash ?? undefined,
traceId,
};
this.rememberBundle(job);
return job;
}
private rememberBundle(job: Pick<AuditBundleJobResponse, 'bundleId' | 'subject' | 'createdAt'>): void {
this.knownBundles.set(job.bundleId, {
subject: job.subject,
createdAt: job.createdAt,
});
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders { private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {
let headers = new HttpHeaders({ let headers = new HttpHeaders({
'X-Stella-Tenant': tenantId, 'X-Stella-Tenant': tenantId,
@@ -153,7 +281,7 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
null, null,
2 2
); );
return of(new Blob([payload], { type: 'application/json' })).pipe(delay(150)); return of(new Blob([payload], { type: 'application/zip' })).pipe(delay(150));
} }
private materialize(job: StoredAuditJob): AuditBundleJobResponse { private materialize(job: StoredAuditJob): AuditBundleJobResponse {
@@ -174,7 +302,7 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
status: 'completed', status: 'completed',
sha256: 'sha256:mock-bundle-sha256', sha256: 'sha256:mock-bundle-sha256',
integrityRootHash: 'sha256:mock-root-hash', integrityRootHash: 'sha256:mock-root-hash',
downloadUrl: `/v1/audit-bundles/${encodeURIComponent(job.bundleId)}`, downloadUrl: `/v1/audit-bundles/${encodeURIComponent(job.bundleId)}/download`,
ociReference: `oci://stellaops/audit-bundles@${job.bundleId}`, ociReference: `oci://stellaops/audit-bundles@${job.bundleId}`,
}; };
} }
@@ -188,3 +316,41 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
return new Date(MockAuditBundlesClient.BaseMs + seq * 60000).toISOString(); return new Date(MockAuditBundlesClient.BaseMs + seq * 60000).toISOString();
} }
} }
function normalizeAuditBundleStatus(status: string | null | undefined): AuditBundleJobStatus {
switch ((status ?? '').trim().toLowerCase()) {
case 'queued':
case 'accepted':
return 'queued';
case 'processing':
case 'running':
case 'in_progress':
return 'processing';
case 'completed':
case 'complete':
case 'succeeded':
return 'completed';
case 'failed':
case 'error':
return 'failed';
default:
return 'queued';
}
}
function coerceIsoTimestamp(value: string | null | undefined): string | undefined {
if (!value) {
return undefined;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}
function createUnknownSubject(bundleId: string): BundleSubjectRef {
return {
type: 'OTHER',
name: bundleId,
digest: {},
};
}

View File

@@ -83,10 +83,15 @@ export interface AuditBundleJobResponse {
readonly status: AuditBundleJobStatus; readonly status: AuditBundleJobStatus;
readonly createdAt: string; readonly createdAt: string;
readonly subject: BundleSubjectRef; readonly subject: BundleSubjectRef;
readonly completedAt?: string;
readonly progress?: number;
readonly sha256?: string; readonly sha256?: string;
readonly integrityRootHash?: string; readonly integrityRootHash?: string;
readonly downloadUrl?: string; readonly downloadUrl?: string;
readonly ociReference?: string; readonly ociReference?: string;
readonly statusUrl?: string;
readonly estimatedCompletionSeconds?: number;
readonly errorCode?: string;
readonly error?: string; readonly error?: string;
readonly traceId?: string; readonly traceId?: string;
} }
@@ -96,4 +101,3 @@ export interface AuditBundleListResponse {
readonly count: number; readonly count: number;
readonly traceId?: string; readonly traceId?: string;
} }

View File

@@ -0,0 +1,98 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, inject, signal } from '@angular/core';
export type TriageArtifactLane = 'active' | 'quiet' | 'review';
interface PersistedLaneRecord {
readonly lane: TriageArtifactLane;
readonly updatedAt: string;
}
type PersistedLaneMap = Record<string, PersistedLaneRecord>;
const STORAGE_KEY = 'stellaops.triage.artifact.lanes.v1';
@Injectable({ providedIn: 'root' })
export class TriageLaneStateService {
private readonly document = inject(DOCUMENT);
readonly assignments = signal<PersistedLaneMap>(this.readAssignments());
laneFor(artifactId: string, fallback: TriageArtifactLane): TriageArtifactLane {
return this.assignments()[artifactId]?.lane ?? fallback;
}
setLane(artifactIds: readonly string[], lane: TriageArtifactLane): void {
if (artifactIds.length === 0) {
return;
}
const updatedAt = new Date().toISOString();
this.assignments.update((state) => {
const next = { ...state };
for (const artifactId of artifactIds) {
next[artifactId] = { lane, updatedAt };
}
this.persist(next);
return next;
});
}
clearLane(artifactIds: readonly string[]): void {
if (artifactIds.length === 0) {
return;
}
this.assignments.update((state) => {
const next = { ...state };
for (const artifactId of artifactIds) {
delete next[artifactId];
}
this.persist(next);
return next;
});
}
private readAssignments(): PersistedLaneMap {
try {
const raw = this.document.defaultView?.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as PersistedLaneMap;
return this.normalize(parsed);
} catch {
return {};
}
}
private normalize(value: PersistedLaneMap): PersistedLaneMap {
const next: PersistedLaneMap = {};
for (const [artifactId, record] of Object.entries(value ?? {})) {
if (!artifactId || !record) {
continue;
}
if (record.lane !== 'active' && record.lane !== 'quiet' && record.lane !== 'review') {
continue;
}
next[artifactId] = {
lane: record.lane,
updatedAt: record.updatedAt ?? new Date(0).toISOString(),
};
}
return next;
}
private persist(value: PersistedLaneMap): void {
try {
this.document.defaultView?.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
// Best-effort persistence only.
}
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, inject } from '@angular/core'; import { DestroyRef, Injectable, NgZone, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { EvidenceBitset } from '../models/evidence.model'; import { EvidenceBitset } from '../models/evidence.model';
@@ -51,10 +51,23 @@ const BUDGETS = {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class TtfsTelemetryService { export class TtfsTelemetryService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly zone = inject(NgZone);
private readonly destroyRef = inject(DestroyRef);
private readonly activeTimings = new Map<string, TtfsTimings>(); private readonly activeTimings = new Map<string, TtfsTimings>();
private readonly pendingEvents: TtfsEvent[] = []; private readonly pendingEvents: TtfsEvent[] = [];
private flushTimeout: ReturnType<typeof setTimeout> | null = null; private flushTimeout: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.destroyRef.onDestroy(() => {
if (this.flushTimeout) {
clearTimeout(this.flushTimeout);
this.flushTimeout = null;
}
this.pendingEvents.length = 0;
this.activeTimings.clear();
});
}
/** /**
* Starts TTFS tracking for an alert. * Starts TTFS tracking for an alert.
*/ */
@@ -240,7 +253,11 @@ export class TtfsTelemetryService {
// Schedule flush if not already scheduled // Schedule flush if not already scheduled
if (!this.flushTimeout) { if (!this.flushTimeout) {
this.flushTimeout = setTimeout(() => this.flushEvents(), 5000); this.zone.runOutsideAngular(() => {
this.flushTimeout = setTimeout(() => {
this.zone.run(() => this.flushEvents());
}, 5000);
});
} }
// Flush immediately if we have too many events // Flush immediately if we have too many events

View File

@@ -1,11 +1,21 @@
<section class="triage-artifacts"> <section class="triage-artifacts" data-testid="triage-artifacts-page">
<header class="triage-artifacts__header"> <header class="triage-artifacts__header">
<div> <div>
<h1>Vulnerability Triage</h1> <h1>Artifact workspace</h1>
<p class="triage-artifacts__subtitle">Artifact-first workflow with evidence and VEX-first decisioning.</p> <p class="triage-artifacts__subtitle">
Triage live artifacts by lane, then open a single evidence-first decision workspace.
</p>
</div> </div>
<div class="triage-artifacts__actions"> <div class="triage-artifacts__actions">
<app-ai-code-guard-badge
[verdict]="guardVerdict()"
[totalFindings]="filteredRows().length"
[criticalCount]="severityCounts().critical"
[highCount]="severityCounts().high"
[mediumCount]="severityCounts().medium"
[lowCount]="severityCounts().low"
/>
<button type="button" class="btn btn--secondary" (click)="load()" [disabled]="loading()"> <button type="button" class="btn btn--secondary" (click)="load()" [disabled]="loading()">
Refresh Refresh
</button> </button>
@@ -21,6 +31,56 @@
/> />
} }
<section class="lane-strip" aria-label="Artifact lanes">
<button
type="button"
class="lane-pill"
data-testid="triage-lane-active"
[class.lane-pill--active]="lane() === 'active'"
[attr.aria-pressed]="lane() === 'active'"
(click)="setLaneFilter('active')"
>
Active
<span>{{ laneCounts().active }}</span>
</button>
<button
type="button"
class="lane-pill"
data-testid="triage-lane-quiet"
[class.lane-pill--active]="lane() === 'quiet'"
[attr.aria-pressed]="lane() === 'quiet'"
(click)="setLaneFilter('quiet')"
>
Quiet Lane
<span>{{ laneCounts().quiet }}</span>
</button>
<button
type="button"
class="lane-pill"
data-testid="triage-lane-review"
[class.lane-pill--active]="lane() === 'review'"
[attr.aria-pressed]="lane() === 'review'"
(click)="setLaneFilter('review')"
>
Needs Review
<span>{{ laneCounts().review }}</span>
</button>
</section>
@if (selectedCount() > 0) {
<section class="bulk-bar" data-testid="triage-bulk-bar">
<div>
<strong>{{ selectedCount() }}</strong> artifact{{ selectedCount() === 1 ? '' : 's' }} selected
</div>
<div class="bulk-bar__actions">
<button type="button" class="btn btn--secondary" (click)="moveSelectionToLane('active')">Move to Active</button>
<button type="button" class="btn btn--secondary" (click)="moveSelectionToLane('quiet')">Move to Quiet</button>
<button type="button" class="btn btn--secondary" (click)="moveSelectionToLane('review')">Move to Review</button>
<button type="button" class="btn btn--primary" (click)="openBundleWizardFromSelection()">Build audit bundle</button>
</div>
</section>
}
<div class="triage-artifacts__toolbar"> <div class="triage-artifacts__toolbar">
<div class="search-box"> <div class="search-box">
<input <input
@@ -29,110 +89,142 @@
placeholder="Search artifacts or environments..." placeholder="Search artifacts or environments..."
[value]="search()" [value]="search()"
(input)="setSearch($any($event.target).value)" (input)="setSearch($any($event.target).value)"
/> />
@if (search()) { @if (search()) {
<button type="button" class="search-box__clear" (click)="setSearch('')">Clear</button> <button type="button" class="search-box__clear" (click)="setSearch('')">Clear</button>
} }
</div>
<div class="filters">
<div class="filter-group">
<label class="filter-group__label">Environment</label>
<select
class="filter-group__select"
[value]="environment()"
(change)="setEnvironment($any($event.target).value)"
>
<option value="all">All</option>
@for (env of environmentOptions; track env) {
<option [value]="env">{{ env }}</option>
}
</select>
</div>
</div>
</div> </div>
@if (loading()) { <div class="filters">
<div class="triage-artifacts__loading"> <div class="filter-group">
<span class="spinner"></span> <label class="filter-group__label">Environment</label>
<span>Loading artifacts...</span> <select
class="filter-group__select"
[value]="environment()"
(change)="setEnvironment($any($event.target).value)"
>
<option value="all">All</option>
@for (env of environmentOptions; track env) {
<option [value]="env">{{ env }}</option>
}
</select>
</div> </div>
} @else { </div>
<div class="triage-artifacts__table-wrap"> </div>
@if (filteredRows().length > 0) {
<table class="triage-table">
<thead>
<tr>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('artifact')">
Artifact {{ getSortIcon('artifact') }}
</th>
<th class="triage-table__th">Type</th>
<th class="triage-table__th">Environment(s)</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('open')">
Open {{ getSortIcon('open') }}
</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('total')">
Total {{ getSortIcon('total') }}
</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('maxSeverity')">
Max severity {{ getSortIcon('maxSeverity') }}
</th>
<th class="triage-table__th">Attestations</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('lastScan')">
Last scan {{ getSortIcon('lastScan') }}
</th>
<th class="triage-table__th">Action</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows(); track row.artifactId) {
<tr class="triage-table__row">
<td class="triage-table__td">
<code class="artifact-id">{{ row.artifactId }}</code>
@if (row.readyToDeploy) {
<span class="ready-pill" title="All gates passed and required attestations verified (stub)">Ready to deploy</span>
}
</td>
<td class="triage-table__td">
<span class="chip chip--small">{{ row.type }}</span>
</td>
<td class="triage-table__td">
<span class="env-list">{{ row.environments.join(', ') }}</span>
</td>
<td class="triage-table__td">
<span class="count" [class.count--hot]="row.openVulns > 0">{{ row.openVulns }}</span>
</td>
<td class="triage-table__td">
<span class="count">{{ row.totalVulns }}</span>
</td>
<td class="triage-table__td">
<span class="chip chip--small" [class.chip--critical]="row.maxSeverity === 'critical'" [class.chip--high]="row.maxSeverity === 'high'">
{{ severityLabels[row.maxSeverity] }}
</span>
</td>
<td class="triage-table__td">
<span class="badge" [class.badge--ok]="row.attestationCount > 0" [class.badge--muted]="row.attestationCount === 0">
{{ row.attestationCount }}
</span>
</td>
<td class="triage-table__td">
<span class="when">{{ formatWhen(row.lastScanAt) }}</span>
</td>
<td class="triage-table__td triage-table__td--actions">
<button type="button" class="btn btn--small btn--primary" (click)="viewVulnerabilities(row)">
View vulnerabilities
</button>
</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="empty-state">
<p>No artifacts match your filters.</p>
</div>
}
</div> @if (loading()) {
} <div class="triage-artifacts__loading">
</section> <span class="spinner"></span>
<span>Loading artifacts...</span>
</div>
} @else {
<div class="triage-artifacts__table-wrap">
@if (filteredRows().length > 0) {
<table class="triage-table">
<thead>
<tr>
<th class="triage-table__th triage-table__th--checkbox">
<input
type="checkbox"
[checked]="filteredRows().length > 0 && selectedCount() === filteredRows().length"
[attr.aria-label]="'Select all visible artifacts'"
(change)="toggleSelectVisible()"
/>
</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('artifact')">
Artifact {{ getSortIcon('artifact') }}
</th>
<th class="triage-table__th">Lane</th>
<th class="triage-table__th">Environment(s)</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('open')">
Open {{ getSortIcon('open') }}
</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('total')">
Total {{ getSortIcon('total') }}
</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('maxSeverity')">
Max severity {{ getSortIcon('maxSeverity') }}
</th>
<th class="triage-table__th">Attestations</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('lastScan')">
Last scan {{ getSortIcon('lastScan') }}
</th>
<th class="triage-table__th">Actions</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows(); track row.artifactId) {
<tr class="triage-table__row" [attr.data-testid]="'triage-artifact-row-' + row.artifactId">
<td class="triage-table__td triage-table__td--checkbox">
<input
type="checkbox"
[checked]="isSelected(row.artifactId)"
[attr.aria-label]="'Select ' + row.artifactId"
(change)="toggleSelection(row.artifactId)"
/>
</td>
<td class="triage-table__td">
<code class="artifact-id">{{ row.artifactId }}</code>
@if (row.readyToDeploy) {
<span class="ready-pill" title="Signed evidence is available and no open findings remain.">
Ready to deploy
</span>
}
</td>
<td class="triage-table__td">
<span class="lane-badge" [class.lane-badge--quiet]="row.lane === 'quiet'" [class.lane-badge--review]="row.lane === 'review'">
{{ row.lane === 'quiet' ? 'Quiet lane' : row.lane === 'review' ? 'Needs review' : 'Active' }}
</span>
</td>
<td class="triage-table__td">
<span class="env-list">{{ row.environments.join(', ') }}</span>
</td>
<td class="triage-table__td">
<span class="count" [class.count--hot]="row.openVulns > 0">{{ row.openVulns }}</span>
</td>
<td class="triage-table__td">
<span class="count">{{ row.totalVulns }}</span>
</td>
<td class="triage-table__td">
<span class="chip chip--small" [class.chip--critical]="row.maxSeverity === 'critical'" [class.chip--high]="row.maxSeverity === 'high'">
{{ severityLabels[row.maxSeverity] }}
</span>
</td>
<td class="triage-table__td">
<span class="badge" [class.badge--ok]="row.attestationCount > 0" [class.badge--muted]="row.attestationCount === 0">
{{ row.attestationCount }}
</span>
</td>
<td class="triage-table__td">
<span class="when">{{ formatWhen(row.lastScanAt) }}</span>
</td>
<td class="triage-table__td triage-table__td--actions">
<div class="row-actions">
<button type="button" class="btn btn--small btn--primary" [attr.data-testid]="'triage-open-' + row.artifactId" (click)="viewArtifact(row)">
Open workspace
</button>
@if (row.lane === 'review') {
<button type="button" class="btn btn--small btn--secondary" (click)="clearArtifactLane(row)">
Use default lane
</button>
}
<button type="button" class="btn btn--small btn--secondary" (click)="moveArtifactToLane(row, nextLaneAction(row).lane)">
{{ nextLaneAction(row).label }}
</button>
<button type="button" class="btn btn--small btn--secondary" (click)="openBundleWizardForRow(row)">
Bundle
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="empty-state">
<p>No artifacts match the current lane and filters.</p>
</div>
}
</div>
}
</section>

View File

@@ -1,10 +1,5 @@
@use 'tokens/breakpoints' as *; @use 'tokens/breakpoints' as *;
/**
* Triage Artifacts Component Styles
* Migrated to design system tokens
*/
.triage-artifacts { .triage-artifacts {
padding: var(--space-6) var(--space-7); padding: var(--space-6) var(--space-7);
} }
@@ -20,22 +15,87 @@
.triage-artifacts__subtitle { .triage-artifacts__subtitle {
margin: var(--space-1) 0 0; margin: var(--space-1) 0 0;
color: var(--color-text-muted); color: var(--color-text-muted);
max-width: 60ch;
} }
.triage-artifacts__actions { .triage-artifacts__actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: var(--space-2); gap: var(--space-2);
align-items: center;
justify-content: flex-end;
} }
.triage-artifacts__error { .lane-strip {
border: 1px solid var(--color-status-error); display: flex;
background: var(--color-status-error-bg); flex-wrap: wrap;
color: var(--color-status-error); gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
.lane-pill {
display: inline-flex;
align-items: center;
gap: var(--space-2);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
padding: var(--space-2) var(--space-3);
background: var(--color-surface-primary);
color: var(--color-text-primary);
cursor: pointer;
font-weight: var(--font-weight-semibold);
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
background-color var(--motion-duration-fast) var(--motion-ease-default);
span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
border-radius: var(--radius-full);
background: var(--color-surface-tertiary);
padding: 0 var(--space-1-5);
font-size: var(--font-size-xs);
}
&:hover {
background: var(--color-surface-secondary);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
.lane-pill--active {
border-color: var(--color-brand-primary);
background: color-mix(in srgb, var(--color-brand-primary) 10%, var(--color-surface-primary));
span {
background: color-mix(in srgb, var(--color-brand-primary) 18%, var(--color-surface-primary));
}
}
.bulk-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl);
background: var(--color-surface-secondary);
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
}
.bulk-bar__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.triage-artifacts__toolbar { .triage-artifacts__toolbar {
display: flex; display: flex;
gap: var(--space-4); gap: var(--space-4);
@@ -122,7 +182,7 @@
.triage-artifacts__table-wrap { .triage-artifacts__table-wrap {
overflow: auto; overflow: auto;
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-xl);
background: var(--color-surface-primary); background: var(--color-surface-primary);
} }
@@ -134,11 +194,12 @@
.triage-table__th { .triage-table__th {
text-align: left; text-align: left;
padding: var(--space-3) var(--space-3); padding: var(--space-3);
border-bottom: 1px solid var(--color-border-primary); border-bottom: 1px solid var(--color-border-primary);
color: var(--color-text-primary); color: var(--color-text-primary);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
user-select: none; user-select: none;
white-space: nowrap;
} }
.triage-table__th--sortable { .triage-table__th--sortable {
@@ -149,8 +210,14 @@
} }
} }
.triage-table__th--checkbox,
.triage-table__td--checkbox {
width: 2.5rem;
text-align: center;
}
.triage-table__td { .triage-table__td {
padding: var(--space-3) var(--space-3); padding: var(--space-3);
border-bottom: 1px solid var(--color-border-secondary); border-bottom: 1px solid var(--color-border-secondary);
vertical-align: middle; vertical-align: middle;
} }
@@ -160,7 +227,7 @@
} }
.triage-table__td--actions { .triage-table__td--actions {
white-space: nowrap; min-width: 18rem;
} }
.artifact-id { .artifact-id {
@@ -168,9 +235,15 @@
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.ready-pill { .ready-pill,
.lane-badge,
.chip,
.badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
}
.ready-pill {
margin-left: var(--space-2); margin-left: var(--space-2);
padding: var(--space-0-5) var(--space-2); padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-full); border-radius: var(--radius-full);
@@ -181,9 +254,26 @@
border: 1px solid var(--color-status-success); border: 1px solid var(--color-status-success);
} }
.lane-badge {
border-radius: var(--radius-full);
padding: var(--space-1) var(--space-2);
background: color-mix(in srgb, var(--color-brand-primary) 10%, var(--color-surface-primary));
color: var(--color-text-primary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.lane-badge--quiet {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
.lane-badge--review {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.chip { .chip {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: var(--color-surface-tertiary); background: var(--color-surface-tertiary);
@@ -206,8 +296,6 @@
} }
.badge { .badge {
display: inline-flex;
align-items: center;
justify-content: center; justify-content: center;
min-width: 2rem; min-width: 2rem;
padding: var(--space-0-5) var(--space-2); padding: var(--space-0-5) var(--space-2);
@@ -233,9 +321,15 @@
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
} }
.when { .when,
.env-list {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-sm); }
.row-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
} }
.btn { .btn {
@@ -298,4 +392,8 @@
.search-box { .search-box {
min-width: 100%; min-width: 100%;
} }
.bulk-bar {
align-items: stretch;
}
} }

View File

@@ -1,4 +1,4 @@
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -7,12 +7,20 @@ import {
inject, inject,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client'; import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
import type { Vulnerability, VulnerabilitySeverity } from '../../core/api/vulnerability.models'; import type { Vulnerability, VulnerabilitySeverity } from '../../core/api/vulnerability.models';
import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component'; import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component';
import {
AiCodeGuardBadgeComponent,
type AiCodeGuardVerdict,
} from './components/ai-code-guard-badge/ai-code-guard-badge.component';
import {
TriageLaneStateService,
type TriageArtifactLane,
} from './services/triage-lane-state.service';
type SortField = 'artifact' | 'open' | 'total' | 'maxSeverity' | 'lastScan'; type SortField = 'artifact' | 'open' | 'total' | 'maxSeverity' | 'lastScan';
type SortOrder = 'asc' | 'desc'; type SortOrder = 'asc' | 'desc';
@@ -46,18 +54,21 @@ export interface TriageArtifactRow {
readonly attestationCount: number; readonly attestationCount: number;
readonly lastScanAt: string | null; readonly lastScanAt: string | null;
readonly readyToDeploy: boolean; readonly readyToDeploy: boolean;
readonly lane: TriageArtifactLane;
} }
@Component({ @Component({
selector: 'app-triage-artifacts', selector: 'app-triage-artifacts',
imports: [ErrorStateComponent], imports: [AiCodeGuardBadgeComponent, ErrorStateComponent],
templateUrl: './triage-artifacts.component.html', templateUrl: './triage-artifacts.component.html',
styleUrls: ['./triage-artifacts.component.scss'], styleUrls: ['./triage-artifacts.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class TriageArtifactsComponent implements OnInit { export class TriageArtifactsComponent implements OnInit {
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API); private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly laneState = inject(TriageLaneStateService);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
@@ -65,6 +76,8 @@ export class TriageArtifactsComponent implements OnInit {
readonly search = signal(''); readonly search = signal('');
readonly environment = signal<EnvironmentHint | 'all'>('all'); readonly environment = signal<EnvironmentHint | 'all'>('all');
readonly lane = signal<TriageArtifactLane>('active');
readonly selectedArtifactIds = signal<readonly string[]>([]);
readonly sortField = signal<SortField>('maxSeverity'); readonly sortField = signal<SortField>('maxSeverity');
readonly sortOrder = signal<SortOrder>('asc'); readonly sortOrder = signal<SortOrder>('asc');
@@ -73,6 +86,7 @@ export class TriageArtifactsComponent implements OnInit {
readonly severityLabels = SEVERITY_LABELS; readonly severityLabels = SEVERITY_LABELS;
readonly rows = computed<readonly TriageArtifactRow[]>(() => { readonly rows = computed<readonly TriageArtifactRow[]>(() => {
const assignments = this.laneState.assignments();
const byArtifact = new Map<string, Vulnerability[]>(); const byArtifact = new Map<string, Vulnerability[]>();
for (const vuln of this.vulnerabilities()) { for (const vuln of this.vulnerabilities()) {
@@ -95,6 +109,8 @@ export class TriageArtifactsComponent implements OnInit {
const totalVulns = vulns.length; const totalVulns = vulns.length;
const maxSeverity = this.computeMaxSeverity(vulns); const maxSeverity = this.computeMaxSeverity(vulns);
const lastScanAt = this.computeLastScanAt(vulns); const lastScanAt = this.computeLastScanAt(vulns);
const attestationCount = this.deriveAttestationCount(vulns);
const readyToDeploy = openVulns === 0 && attestationCount > 0;
result.push({ result.push({
artifactId, artifactId,
@@ -103,27 +119,81 @@ export class TriageArtifactsComponent implements OnInit {
openVulns, openVulns,
totalVulns, totalVulns,
maxSeverity, maxSeverity,
attestationCount: this.deriveAttestationCount(vulns), attestationCount,
lastScanAt, lastScanAt,
readyToDeploy: openVulns === 0 && this.deriveAttestationCount(vulns) > 0, readyToDeploy,
lane: assignments[artifactId]?.lane ?? this.deriveDefaultLane({ artifactId, envs, openVulns, maxSeverity, readyToDeploy }),
}); });
} }
return this.applySorting(result); return this.applySorting(result);
}); });
readonly laneCounts = computed(() => {
const counts = { active: 0, quiet: 0, review: 0 } as Record<TriageArtifactLane, number>;
for (const row of this.rows()) {
counts[row.lane] += 1;
}
return counts;
});
readonly filteredRows = computed<readonly TriageArtifactRow[]>(() => { readonly filteredRows = computed<readonly TriageArtifactRow[]>(() => {
const q = this.search().trim().toLowerCase(); const q = this.search().trim().toLowerCase();
const env = this.environment(); const env = this.environment();
const lane = this.lane();
return this.rows().filter((row) => { return this.rows().filter((row) => {
if (row.lane !== lane) return false;
if (env !== 'all' && !row.environments.includes(env)) return false; if (env !== 'all' && !row.environments.includes(env)) return false;
if (!q) return true; if (!q) return true;
return row.artifactId.toLowerCase().includes(q) || row.environments.some((e) => e.includes(q)); return row.artifactId.toLowerCase().includes(q) || row.environments.some((value) => value.includes(q));
}); });
}); });
readonly selectedCount = computed(() => this.selectedArtifactIds().length);
readonly guardVerdict = computed<AiCodeGuardVerdict>(() => {
const rows = this.filteredRows();
if (rows.length === 0) {
return 'pass';
}
if (rows.some((row) => row.maxSeverity === 'critical' && row.openVulns > 0)) {
return 'fail';
}
if (rows.some((row) => row.openVulns > 0)) {
return 'pass_with_warnings';
}
return 'pass';
});
readonly severityCounts = computed(() => {
const counts = { critical: 0, high: 0, medium: 0, low: 0 } as const;
const mutable = { ...counts };
for (const row of this.filteredRows()) {
if (row.maxSeverity === 'unknown') {
continue;
}
mutable[row.maxSeverity] += 1;
}
return mutable;
});
constructor() {
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const requestedLane = this.parseLane(params.get('lane'));
if (requestedLane !== this.lane()) {
this.lane.set(requestedLane);
this.selectedArtifactIds.set([]);
}
});
}
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
const requestedLane = this.parseLane(this.route.snapshot.queryParamMap.get('lane'));
this.lane.set(requestedLane);
await this.load(); await this.load();
} }
@@ -133,6 +203,7 @@ export class TriageArtifactsComponent implements OnInit {
try { try {
const resp = await firstValueFrom(this.api.listVulnerabilities({ includeReachability: true })); const resp = await firstValueFrom(this.api.listVulnerabilities({ includeReachability: true }));
this.vulnerabilities.set(resp.items); this.vulnerabilities.set(resp.items);
this.pruneSelection();
} catch (err) { } catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to load vulnerabilities'); this.error.set(err instanceof Error ? err.message : 'Failed to load vulnerabilities');
} finally { } finally {
@@ -146,6 +217,18 @@ export class TriageArtifactsComponent implements OnInit {
setEnvironment(value: EnvironmentHint | 'all'): void { setEnvironment(value: EnvironmentHint | 'all'): void {
this.environment.set(value); this.environment.set(value);
this.pruneSelection();
}
setLaneFilter(lane: TriageArtifactLane): void {
this.lane.set(lane);
this.selectedArtifactIds.set([]);
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { lane },
queryParamsHandling: 'merge',
replaceUrl: true,
});
} }
toggleSort(field: SortField): void { toggleSort(field: SortField): void {
@@ -162,8 +245,78 @@ export class TriageArtifactsComponent implements OnInit {
return this.sortOrder() === 'asc' ? '\u25B2' : '\u25BC'; return this.sortOrder() === 'asc' ? '\u25B2' : '\u25BC';
} }
viewVulnerabilities(row: TriageArtifactRow): void { isSelected(artifactId: string): boolean {
void this.router.navigate(['/triage/artifacts', row.artifactId]); return this.selectedArtifactIds().includes(artifactId);
}
toggleSelection(artifactId: string): void {
const current = new Set(this.selectedArtifactIds());
if (current.has(artifactId)) {
current.delete(artifactId);
} else {
current.add(artifactId);
}
this.selectedArtifactIds.set([...current].sort((a, b) => a.localeCompare(b)));
}
toggleSelectVisible(): void {
const visibleIds = this.filteredRows().map((row) => row.artifactId);
const current = new Set(this.selectedArtifactIds());
const allSelected = visibleIds.length > 0 && visibleIds.every((artifactId) => current.has(artifactId));
if (allSelected) {
for (const artifactId of visibleIds) {
current.delete(artifactId);
}
} else {
for (const artifactId of visibleIds) {
current.add(artifactId);
}
}
this.selectedArtifactIds.set([...current].sort((a, b) => a.localeCompare(b)));
}
moveSelectionToLane(lane: TriageArtifactLane): void {
const selected = this.selectedArtifactIds();
if (selected.length === 0) {
return;
}
this.laneState.setLane(selected, lane);
this.selectedArtifactIds.set([]);
}
moveArtifactToLane(row: TriageArtifactRow, lane: TriageArtifactLane): void {
this.laneState.setLane([row.artifactId], lane);
this.pruneSelection();
}
clearArtifactLane(row: TriageArtifactRow): void {
this.laneState.clearLane([row.artifactId]);
this.pruneSelection();
}
viewArtifact(row: TriageArtifactRow): void {
void this.router.navigate(['/triage/artifacts', row.artifactId], {
queryParams: {
lane: this.lane(),
panel: row.lane === 'review' ? 'history' : row.lane === 'quiet' ? 'provenance' : 'reason',
},
});
}
openBundleWizardFromSelection(): void {
const selected = this.selectedArtifactIds();
void this.router.navigate(['/triage/audit-bundles/new'], {
queryParams: selected.length === 1 ? { artifactId: selected[0] } : undefined,
});
}
openBundleWizardForRow(row: TriageArtifactRow): void {
void this.router.navigate(['/triage/audit-bundles/new'], {
queryParams: { artifactId: row.artifactId },
});
} }
formatWhen(value: string | null): string { formatWhen(value: string | null): string {
@@ -175,6 +328,26 @@ export class TriageArtifactsComponent implements OnInit {
} }
} }
nextLaneAction(row: TriageArtifactRow): { readonly label: string; readonly lane: TriageArtifactLane } {
if (row.lane === 'active') {
return { label: 'Quiet lane', lane: 'quiet' };
}
if (row.lane === 'quiet') {
return { label: 'Needs review', lane: 'review' };
}
return { label: 'Re-activate', lane: 'active' };
}
private pruneSelection(): void {
const visibleIds = new Set(this.filteredRows().map((row) => row.artifactId));
const next = this.selectedArtifactIds().filter((artifactId) => visibleIds.has(artifactId));
if (next.length !== this.selectedArtifactIds().length) {
this.selectedArtifactIds.set(next);
}
}
private applySorting(rows: readonly TriageArtifactRow[]): readonly TriageArtifactRow[] { private applySorting(rows: readonly TriageArtifactRow[]): readonly TriageArtifactRow[] {
const field = this.sortField(); const field = this.sortField();
const order = this.sortOrder(); const order = this.sortOrder();
@@ -202,11 +375,9 @@ export class TriageArtifactsComponent implements OnInit {
} }
if (cmp !== 0) return order === 'asc' ? cmp : -cmp; if (cmp !== 0) return order === 'asc' ? cmp : -cmp;
// stable tie-breakers
return a.artifactId.localeCompare(b.artifactId); return a.artifactId.localeCompare(b.artifactId);
}); });
// default "maxSeverity" should show most severe first
if (field === 'maxSeverity' && order === 'asc') { if (field === 'maxSeverity' && order === 'asc') {
return sorted; return sorted;
} }
@@ -225,10 +396,10 @@ export class TriageArtifactsComponent implements OnInit {
private computeLastScanAt(vulns: readonly Vulnerability[]): string | null { private computeLastScanAt(vulns: readonly Vulnerability[]): string | null {
const dates = vulns const dates = vulns
.map((v) => v.modifiedAt ?? v.publishedAt ?? null) .map((v) => v.modifiedAt ?? v.publishedAt ?? null)
.filter((v): v is string => typeof v === 'string'); .filter((value): value is string => typeof value === 'string');
if (dates.length === 0) return null; if (dates.length === 0) return null;
return dates.reduce((max, cur) => (cur > max ? cur : max), dates[0]); return dates.reduce((max, current) => (current > max ? current : max), dates[0]);
} }
private deriveType(artifactId: string): TriageArtifactRow['type'] { private deriveType(artifactId: string): TriageArtifactRow['type'] {
@@ -243,7 +414,35 @@ export class TriageArtifactsComponent implements OnInit {
} }
private deriveAttestationCount(vulns: readonly Vulnerability[]): number { private deriveAttestationCount(vulns: readonly Vulnerability[]): number {
// Deterministic placeholder: treat "fixed" and "excepted" as having signed evidence.
return vulns.filter((v) => v.status === 'fixed' || v.status === 'excepted').length; return vulns.filter((v) => v.status === 'fixed' || v.status === 'excepted').length;
} }
private deriveDefaultLane(summary: {
readonly artifactId: string;
readonly envs: readonly EnvironmentHint[];
readonly openVulns: number;
readonly maxSeverity: VulnerabilitySeverity;
readonly readyToDeploy: boolean;
}): TriageArtifactLane {
if (summary.maxSeverity === 'critical' && summary.openVulns > 0) {
return 'review';
}
if (summary.envs.includes('legacy')) {
return 'review';
}
if (summary.readyToDeploy || summary.envs.includes('internal') || summary.envs.includes('builder')) {
return 'quiet';
}
return 'active';
}
private parseLane(value: string | null): TriageArtifactLane {
if (value === 'quiet' || value === 'review') {
return value;
}
return 'active';
}
} }

View File

@@ -52,7 +52,9 @@ export class TriageAuditBundleNewComponent implements OnInit, OnDestroy {
if (artifactId) { if (artifactId) {
this.subjectName.set(artifactId); this.subjectName.set(artifactId);
this.subjectDigest.set(artifactId); if (looksLikeSha256Digest(artifactId)) {
this.subjectDigest.set(artifactId);
}
return; return;
} }
@@ -135,12 +137,20 @@ export class TriageAuditBundleNewComponent implements OnInit, OnDestroy {
async download(): Promise<void> { async download(): Promise<void> {
const job = this.job(); const job = this.job();
if (!job) return; if (!job) return;
const blob = await firstValueFrom(this.api.downloadBundle(job.bundleId)); try {
const url = URL.createObjectURL(blob); const blob = await firstValueFrom(this.api.downloadBundle(job.bundleId));
const a = document.createElement('a'); const url = URL.createObjectURL(blob);
a.href = url; const a = document.createElement('a');
a.download = `${job.bundleId}.json`; a.href = url;
a.click(); a.download = `${job.bundleId}.zip`;
URL.revokeObjectURL(url); a.click();
URL.revokeObjectURL(url);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Download failed');
}
} }
} }
function looksLikeSha256Digest(value: string): boolean {
return /^sha256:[a-f0-9]{32,}$/i.test(value.trim());
}

View File

@@ -49,7 +49,7 @@ export class TriageAuditBundlesComponent implements OnInit {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `${bundle.bundleId}.json`; a.download = `${bundle.bundleId}.zip`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (err) { } catch (err) {
@@ -57,4 +57,3 @@ export class TriageAuditBundlesComponent implements OnInit {
} }
} }
} }

View File

@@ -256,7 +256,8 @@
</button> </button>
</header> </header>
<div class="panel"> <div class="workspace-shell">
<div class="panel">
@if (!selectedVuln()) { @if (!selectedVuln()) {
<div class="empty">Select a finding to view evidence.</div> <div class="empty">Select a finding to view evidence.</div>
} @else if (activeTab() === 'evidence') { } @else if (activeTab() === 'evidence') {
@@ -800,6 +801,161 @@
</section> </section>
} }
</div> </div>
@if (selectedVuln()) {
<aside class="explainability-rail" data-testid="triage-explainability-rail">
<section class="rail-card rail-card--summary">
<header class="rail-card__header">
<div>
<p class="rail-card__eyebrow">Decision aids</p>
<h3>Explainability workspace</h3>
</div>
<app-ai-code-guard-badge
[verdict]="aiGuardVerdict()"
[totalFindings]="findings().length"
[criticalCount]="selectedVuln()!.vuln.severity === 'critical' ? 1 : 0"
[highCount]="selectedVuln()!.vuln.severity === 'high' ? 1 : 0"
[mediumCount]="selectedVuln()!.vuln.severity === 'medium' ? 1 : 0"
[lowCount]="selectedVuln()!.vuln.severity === 'low' ? 1 : 0"
/>
</header>
<p class="hint">
Route state keeps the active helper pinned with <code>panel={{ activePanel() }}</code>.
</p>
</section>
<nav class="rail-tabs" aria-label="Explainability panels">
<button
type="button"
class="pill"
data-testid="triage-panel-reason"
[class.pill--active]="activePanel() === 'reason'"
[attr.aria-pressed]="activePanel() === 'reason'"
(click)="setPanel('reason')"
>
Reason
</button>
<button
type="button"
class="pill"
data-testid="triage-panel-ai"
[class.pill--active]="activePanel() === 'ai'"
[attr.aria-pressed]="activePanel() === 'ai'"
(click)="setPanel('ai')"
>
AI
</button>
<button
type="button"
class="pill"
data-testid="triage-panel-provenance"
[class.pill--active]="activePanel() === 'provenance'"
[attr.aria-pressed]="activePanel() === 'provenance'"
(click)="setPanel('provenance')"
>
Provenance
</button>
<button
type="button"
class="pill"
data-testid="triage-panel-history"
[class.pill--active]="activePanel() === 'history'"
[attr.aria-pressed]="activePanel() === 'history'"
(click)="setPanel('history')"
>
History
</button>
</nav>
@if (activePanel() === 'reason') {
<section class="rail-card">
<header class="rail-card__header">
<div>
<p class="rail-card__eyebrow">Reason capsule</p>
<h3>Why this finding is here</h3>
</div>
<button type="button" class="btn btn--secondary btn--small" (click)="setTab('policy')">
Open policy
</button>
</header>
<p class="hint">
Severity <strong>{{ selectedVuln()!.vuln.severity }}</strong> with status
<code>{{ selectedVuln()!.vuln.status }}</code>.
</p>
<app-reason-capsule
[verdictId]="selectedVuln()!.vuln.vulnId"
[findingId]="selectedVuln()!.vuln.vulnId"
/>
</section>
} @else if (activePanel() === 'ai') {
<section class="rail-card">
<header class="rail-card__header">
<div>
<p class="rail-card__eyebrow">Advisory AI</p>
<h3>Suggested next move</h3>
</div>
<button type="button" class="btn btn--secondary btn--small" (click)="openDecisionDrawer()">
Review decision
</button>
</header>
<app-ai-recommendation-panel
[vulnId]="selectedVuln()!.vuln.vulnId"
[showReachability]="selectedVuln()!.vuln.reachabilityStatus === 'reachable'"
[autoAnalyze]="false"
(suggestionApplied)="onAiSuggestionApplied($event)"
(vexSuggestionUsed)="onUseAiVexSuggestion($event)"
/>
</section>
} @else if (activePanel() === 'provenance') {
<section class="rail-card">
<header class="rail-card__header">
<div>
<p class="rail-card__eyebrow">Provenance</p>
<h3>Snapshot and export</h3>
</div>
<button type="button" class="btn btn--secondary btn--small" (click)="setTab('attestations')">
Open attestations
</button>
</header>
@if (knowledgeSnapshot(); as snapshot) {
<stella-snapshot-viewer
[snapshot]="snapshot"
(exportSnapshot)="exportKnowledgeSnapshot($event)"
(replay)="replayKnowledgeSnapshot($event)"
/>
} @else {
<p class="hint">No provenance snapshot is available until a finding is selected.</p>
}
</section>
} @else if (activePanel() === 'history') {
<section class="rail-card">
<header class="rail-card__header">
<div>
<p class="rail-card__eyebrow">Decision history</p>
<h3>Recent decision events</h3>
</div>
<button type="button" class="btn btn--secondary btn--small" (click)="openAuditBundleWizard()">
Package evidence
</button>
</header>
@if (decisionHistory().length > 0) {
<ol class="decision-history">
@for (event of decisionHistory(); track event.id) {
<li class="decision-history__item">
<p class="decision-history__when">{{ event.when }}</p>
<strong>{{ event.title }}</strong>
<p>{{ event.detail }}</p>
</li>
}
</ol>
} @else {
<p class="hint">No decision events are recorded for this finding yet.</p>
}
</section>
}
</aside>
}
</div>
</section> </section>
</div> </div>

View File

@@ -334,6 +334,95 @@
overflow: auto; overflow: auto;
} }
.workspace-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
gap: var(--space-4);
padding: var(--space-4);
@include screen-below-xl {
grid-template-columns: 1fr;
}
}
.panel {
padding: 0;
}
.explainability-rail {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.rail-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.rail-card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl);
background: var(--color-surface-secondary);
padding: var(--space-3);
}
.rail-card--summary {
background: linear-gradient(160deg, color-mix(in srgb, var(--color-brand-primary) 8%, var(--color-surface-primary)), var(--color-surface-secondary));
}
.rail-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-3);
margin-bottom: var(--space-2);
h3 {
margin: 0;
font-size: var(--font-size-lg);
}
}
.rail-card__eyebrow {
margin: 0 0 var(--space-1);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.decision-history {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-3);
}
.decision-history__item {
padding: var(--space-3);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
strong {
display: block;
margin-bottom: var(--space-1);
}
p {
margin: 0;
}
}
.decision-history__when {
margin: 0 0 var(--space-1);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
}
.reachability-header { .reachability-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -1,7 +1,8 @@
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
DestroyRef,
OnInit, OnInit,
OnDestroy, OnDestroy,
ElementRef, ElementRef,
@@ -26,6 +27,19 @@ import { GatedBucketsComponent, type BucketExpandEvent } from './components/gate
import { GatingExplainerComponent } from './components/gating-explainer/gating-explainer.component'; import { GatingExplainerComponent } from './components/gating-explainer/gating-explainer.component';
import { VexTrustDisplayComponent } from './components/vex-trust-display/vex-trust-display.component'; import { VexTrustDisplayComponent } from './components/vex-trust-display/vex-trust-display.component';
import { ReplayCommandComponent } from './components/replay-command/replay-command.component'; import { ReplayCommandComponent } from './components/replay-command/replay-command.component';
import {
AiRecommendationPanelComponent,
type ApplySuggestionEvent,
} from './components/ai-recommendation-panel/ai-recommendation-panel.component';
import {
AiCodeGuardBadgeComponent,
type AiCodeGuardVerdict,
} from './components/ai-code-guard-badge/ai-code-guard-badge.component';
import { ReasonCapsuleComponent } from './components/reason-capsule/reason-capsule.component';
import {
SnapshotViewerComponent,
type KnowledgeSnapshot,
} from './components/snapshot-viewer/snapshot-viewer.component';
import { type TriageQuickVexStatus, TriageShortcutsService } from './services/triage-shortcuts.service'; import { type TriageQuickVexStatus, TriageShortcutsService } from './services/triage-shortcuts.service';
import { TtfsTelemetryService } from './services/ttfs-telemetry.service'; import { TtfsTelemetryService } from './services/ttfs-telemetry.service';
import { GatingService } from './services/gating.service'; import { GatingService } from './services/gating.service';
@@ -45,8 +59,10 @@ import type {
} from './models/gating.model'; } from './models/gating.model';
type TabId = 'evidence' | 'overview' | 'reachability' | 'policy' | 'attestations' | 'delta'; type TabId = 'evidence' | 'overview' | 'reachability' | 'policy' | 'attestations' | 'delta';
type DetailPanelId = 'ai' | 'reason' | 'provenance' | 'history';
const TAB_ORDER: readonly TabId[] = ['evidence', 'overview', 'reachability', 'delta', 'policy', 'attestations']; const TAB_ORDER: readonly TabId[] = ['evidence', 'overview', 'reachability', 'delta', 'policy', 'attestations'];
const DETAIL_PANEL_ORDER: readonly DetailPanelId[] = ['reason', 'ai', 'provenance', 'history'];
const REACHABILITY_VIEW_ORDER: readonly ('path-list' | 'compact-graph' | 'textual-proof')[] = [ const REACHABILITY_VIEW_ORDER: readonly ('path-list' | 'compact-graph' | 'textual-proof')[] = [
'path-list', 'path-list',
'compact-graph', 'compact-graph',
@@ -87,6 +103,13 @@ interface QuickVerificationState {
readonly details: readonly string[]; readonly details: readonly string[];
} }
interface WorkspaceHistoryEvent {
readonly id: string;
readonly title: string;
readonly detail: string;
readonly when: string;
}
@Component({ @Component({
selector: 'app-triage-workspace', selector: 'app-triage-workspace',
imports: [ imports: [
@@ -102,6 +125,10 @@ interface QuickVerificationState {
VexTrustDisplayComponent, VexTrustDisplayComponent,
ReplayCommandComponent, ReplayCommandComponent,
ErrorStateComponent, ErrorStateComponent,
AiRecommendationPanelComponent,
AiCodeGuardBadgeComponent,
ReasonCapsuleComponent,
SnapshotViewerComponent,
], ],
providers: [TriageShortcutsService], providers: [TriageShortcutsService],
templateUrl: './triage-workspace.component.html', templateUrl: './triage-workspace.component.html',
@@ -111,6 +138,7 @@ interface QuickVerificationState {
export class TriageWorkspaceComponent implements OnInit, OnDestroy { export class TriageWorkspaceComponent implements OnInit, OnDestroy {
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef); private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API); private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
@@ -132,6 +160,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
readonly selectedVulnId = signal<string | null>(null); readonly selectedVulnId = signal<string | null>(null);
readonly selectedForBulk = signal<readonly string[]>([]); readonly selectedForBulk = signal<readonly string[]>([]);
readonly activeTab = signal<TabId>('evidence'); readonly activeTab = signal<TabId>('evidence');
readonly activePanel = signal<DetailPanelId>('reason');
// Decision drawer state // Decision drawer state
readonly showDecisionDrawer = signal(false); readonly showDecisionDrawer = signal(false);
@@ -256,6 +285,105 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
}; };
}); });
readonly aiGuardVerdict = computed<AiCodeGuardVerdict>(() => {
const selected = this.selectedVuln();
if (!selected) {
return 'pending';
}
if (selected.vuln.status === 'open' && selected.vuln.severity === 'critical') {
return 'fail';
}
if (selected.vuln.status === 'open' || selected.vuln.status === 'in_progress') {
return 'pass_with_warnings';
}
return 'pass';
});
readonly knowledgeSnapshot = computed<KnowledgeSnapshot | null>(() => {
const selected = this.selectedVuln();
if (!selected) {
return null;
}
const evidence = this.getUnifiedEvidenceForFinding(selected.vuln.vulnId);
const artifactId = this.artifactId();
const sources = [
{
type: 'security',
name: selected.vuln.cveId,
epoch: selected.vuln.modifiedAt ?? selected.vuln.publishedAt ?? new Date(0).toISOString(),
digest: evidence?.manifests?.manifestHash ?? `sha256:${selected.vuln.vulnId.padEnd(32, '0').slice(0, 32)}`,
},
{
type: 'description',
name: evidence?.policy?.policyVersion ? `Policy ${evidence.policy.policyVersion}` : 'Policy verdict',
epoch: evidence?.generatedAt ?? selected.vuln.modifiedAt ?? new Date(0).toISOString(),
digest: evidence?.policy?.policyDigest ?? `sha256:${artifactId.padEnd(32, '0').slice(0, 32)}`,
},
].sort((a, b) => a.name.localeCompare(b.name));
return {
snapshotId: evidence?.manifests?.manifestHash ?? `snapshot-${selected.vuln.vulnId}`,
sources,
environment: { platform: artifactId || 'unknown-artifact' },
engine: { version: evidence?.sbom?.version ?? 'triage-workspace-v1' },
};
});
readonly decisionHistory = computed<readonly WorkspaceHistoryEvent[]>(() => {
const selected = this.selectedVuln();
if (!selected) {
return [];
}
const evidence = this.getUnifiedEvidenceForFinding(selected.vuln.vulnId);
const events: WorkspaceHistoryEvent[] = [];
if (selected.vuln.publishedAt) {
events.push({
id: `${selected.vuln.vulnId}-published`,
title: 'Finding published',
detail: `${selected.vuln.cveId} entered the artifact queue.`,
when: selected.vuln.publishedAt,
});
}
if (selected.vuln.modifiedAt) {
events.push({
id: `${selected.vuln.vulnId}-modified`,
title: 'Scanner refreshed',
detail: 'Scanner metadata or severity changed for this finding.',
when: selected.vuln.modifiedAt,
});
}
if (evidence?.generatedAt) {
events.push({
id: `${selected.vuln.vulnId}-evidence`,
title: 'Unified evidence generated',
detail: 'Reachability, policy, SBOM, and attestation evidence was assembled.',
when: evidence.generatedAt,
});
}
const vexDecision = this.latestVexDecision(selected.vuln.cveId);
if (vexDecision) {
events.push({
id: vexDecision.id,
title: `VEX ${vexDecision.status}`,
detail: vexDecision.justificationText ?? 'Decision recorded without freeform justification.',
when: vexDecision.updatedAt ?? vexDecision.createdAt,
});
}
return events
.slice()
.sort((a, b) => b.when.localeCompare(a.when) || a.id.localeCompare(b.id));
});
readonly findings = computed<readonly FindingCardModel[]>(() => { readonly findings = computed<readonly FindingCardModel[]>(() => {
const id = this.artifactId(); const id = this.artifactId();
if (!id) return []; if (!id) return [];
@@ -402,17 +530,33 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
const artifactId = this.route.snapshot.paramMap.get('artifactId') ?? ''; const artifactId = this.route.snapshot.paramMap.get('artifactId') ?? '';
const requestedFindingId = this.route.snapshot.queryParamMap.get('findingId'); const requestedFindingId = this.route.snapshot.queryParamMap.get('findingId');
const requestedTab = this.parseRequestedTab(this.route.snapshot.queryParamMap.get('tab')); const requestedTab = this.parseRequestedTab(this.route.snapshot.queryParamMap.get('tab'));
const requestedPanel = this.parseRequestedPanel(this.route.snapshot.queryParamMap.get('panel'));
this.artifactId.set(artifactId); this.artifactId.set(artifactId);
await this.load(); await this.load();
await this.loadVexDecisions(); await this.loadVexDecisions();
const initialFindingId = this.resolveRequestedFindingId(requestedFindingId); const initialFindingId = this.resolveRequestedFindingId(requestedFindingId);
this.selectedVulnId.set(initialFindingId); this.activePanel.set(requestedPanel);
this.activeTab.set(initialFindingId ? requestedTab : 'evidence');
if (initialFindingId) { if (initialFindingId) {
void this.loadUnifiedEvidence(initialFindingId); this.selectFinding(initialFindingId, { resetTab: false, syncQuery: false });
this.setTab(requestedTab, { syncQuery: false });
} else {
this.activeTab.set('evidence');
} }
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((queryParamMap) => {
const nextFindingId = this.resolveRequestedFindingId(queryParamMap.get('findingId'));
const nextTab = this.parseRequestedTab(queryParamMap.get('tab'));
const nextPanel = this.parseRequestedPanel(queryParamMap.get('panel'));
if (nextFindingId && nextFindingId !== this.selectedVulnId()) {
this.selectFinding(nextFindingId, { resetTab: false, syncQuery: false });
}
this.activePanel.set(nextPanel);
this.setTab(nextFindingId ? nextTab : 'evidence', { syncQuery: false });
});
// Keep initialization responsive; gated buckets are non-blocking metadata. // Keep initialization responsive; gated buckets are non-blocking metadata.
void this.loadGatedBuckets(); void this.loadGatedBuckets();
} }
@@ -546,8 +690,9 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
} }
} }
selectFinding(vulnId: string, options?: { resetTab?: boolean }): void { selectFinding(vulnId: string, options?: { resetTab?: boolean; syncQuery?: boolean }): void {
const previousId = this.selectedVulnId(); const previousId = this.selectedVulnId();
const nextTab = options?.resetTab ?? true ? 'evidence' : this.activeTab();
// If changing selection, start new TTFS tracking // If changing selection, start new TTFS tracking
if (previousId !== vulnId) { if (previousId !== vulnId) {
@@ -573,6 +718,10 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
if (options?.resetTab ?? true) { if (options?.resetTab ?? true) {
this.activeTab.set('evidence'); this.activeTab.set('evidence');
} }
if (options?.syncQuery ?? true) {
this.syncWorkspaceQueryState({ findingId: vulnId, tab: nextTab });
}
} }
toggleBulkSelection(vulnId: string): void { toggleBulkSelection(vulnId: string): void {
@@ -672,10 +821,10 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
switch (evidenceType) { switch (evidenceType) {
case 'reachability': case 'reachability':
case 'callstack': case 'callstack':
this.activeTab.set('reachability'); this.setTab('reachability');
break; break;
case 'provenance': case 'provenance':
this.activeTab.set('attestations'); this.setTab('attestations');
break; break;
case 'vex': case 'vex':
this.openDecisionDrawer(); this.openDecisionDrawer();
@@ -683,7 +832,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
case 'dsse': case 'dsse':
case 'rekor': case 'rekor':
case 'sbom': case 'sbom':
this.activeTab.set('evidence'); this.setTab('evidence');
break; break;
} }
} }
@@ -708,7 +857,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
'Require at least one verified DSSE signature or Rekor inclusion proof.', 'Require at least one verified DSSE signature or Rekor inclusion proof.',
], ],
}); });
this.activeTab.set('evidence'); this.setTab('evidence');
return; return;
} }
@@ -729,7 +878,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
checkedAt, checkedAt,
details, details,
}); });
this.activeTab.set('evidence'); this.setTab('evidence');
return; return;
} }
@@ -740,7 +889,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
checkedAt, checkedAt,
details, details,
}); });
this.activeTab.set('evidence'); this.setTab('evidence');
await this.refreshReplayCommand(selected.vuln.vulnId); await this.refreshReplayCommand(selected.vuln.vulnId);
} }
@@ -755,7 +904,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
'Load evidence for this finding, then ensure DSSE or Rekor proof is present.', 'Load evidence for this finding, then ensure DSSE or Rekor proof is present.',
], ],
}); });
this.activeTab.set('evidence'); this.setTab('evidence');
} }
// Get evidence hash for audit trail // Get evidence hash for audit trail
@@ -852,6 +1001,46 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
void this.router.navigate(['/triage/audit-bundles/new'], { queryParams: { artifactId } }); void this.router.navigate(['/triage/audit-bundles/new'], { queryParams: { artifactId } });
} }
onAiSuggestionApplied(event: ApplySuggestionEvent): void {
const action = event.action;
if (action.vexStatus) {
this.openQuickVex(this.mapSuggestionStatus(action.vexStatus));
return;
}
if (action.type === 'apply_fix') {
const selected = this.selectedVuln();
if (selected) {
this.openFixWorkflow(selected);
}
return;
}
this.openDecisionDrawer();
}
onUseAiVexSuggestion(_suggestion: string): void {
this.openDecisionDrawer();
this.announceKeyboardStatus('Opened decision drawer with AI suggestion ready for operator review');
}
exportKnowledgeSnapshot(_snapshotId: string): void {
this.openAuditBundleWizard();
}
replayKnowledgeSnapshot(snapshotId: string): void {
const selected = this.selectedVuln();
void this.router.navigate(['/evidence/verify-replay'], {
queryParams: {
snapshotId,
artifactId: this.artifactId(),
findingId: selected?.vuln.vulnId,
returnTo: this.buildWorkspaceReturnTo(this.activeTab()),
},
});
}
openCanonicalReachabilityWorkspace(): void { openCanonicalReachabilityWorkspace(): void {
const selected = this.selectedVuln(); const selected = this.selectedVuln();
if (!selected) { if (!selected) {
@@ -874,8 +1063,18 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
}); });
} }
setTab(tab: TabId): void { setTab(tab: TabId, options?: { syncQuery?: boolean }): void {
this.activeTab.set(tab); this.activeTab.set(tab);
if (options?.syncQuery ?? true) {
this.syncWorkspaceQueryState({ tab });
}
}
setPanel(panel: DetailPanelId, options?: { syncQuery?: boolean }): void {
this.activePanel.set(panel);
if (options?.syncQuery ?? true) {
this.syncWorkspaceQueryState({ panel });
}
} }
selectPolicyCell(cell: PolicyGateCell): void { selectPolicyCell(cell: PolicyGateCell): void {
@@ -927,14 +1126,14 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
const reachability = selected.vuln.reachabilityStatus ?? 'unknown'; const reachability = selected.vuln.reachabilityStatus ?? 'unknown';
if (reachability === 'unknown') { if (reachability === 'unknown') {
this.activeTab.set('reachability'); this.setTab('reachability');
this.focusTab('reachability'); this.focusTab('reachability');
this.announceKeyboardStatus('Jumped to reachability evidence'); this.announceKeyboardStatus('Jumped to reachability evidence');
return; return;
} }
if (!this.hasSignedEvidence(selected)) { if (!this.hasSignedEvidence(selected)) {
this.activeTab.set('attestations'); this.setTab('attestations');
this.focusTab('attestations'); this.focusTab('attestations');
this.announceKeyboardStatus('Jumped to provenance evidence'); this.announceKeyboardStatus('Jumped to provenance evidence');
return; return;
@@ -944,7 +1143,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
} }
private focusReachabilitySearch(): void { private focusReachabilitySearch(): void {
this.activeTab.set('reachability'); this.setTab('reachability');
this.focusTab('reachability'); this.focusTab('reachability');
const view = this.document.defaultView; const view = this.document.defaultView;
@@ -1013,7 +1212,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
const current = this.activeTab(); const current = this.activeTab();
const idx = TAB_ORDER.indexOf(current); const idx = TAB_ORDER.indexOf(current);
const next = TAB_ORDER[(idx + delta + TAB_ORDER.length) % TAB_ORDER.length] ?? 'overview'; const next = TAB_ORDER[(idx + delta + TAB_ORDER.length) % TAB_ORDER.length] ?? 'overview';
this.activeTab.set(next); this.setTab(next);
this.focusTab(next); this.focusTab(next);
} }
@@ -1069,6 +1268,20 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
return matching ?? null; return matching ?? null;
} }
private mapSuggestionStatus(status: 'not_affected' | 'affected_mitigated' | 'affected_unmitigated' | 'fixed' | 'under_investigation'): TriageQuickVexStatus {
switch (status) {
case 'not_affected':
return 'NOT_AFFECTED';
case 'affected_mitigated':
case 'affected_unmitigated':
case 'fixed':
return 'AFFECTED';
case 'under_investigation':
default:
return 'UNDER_INVESTIGATION';
}
}
private announceKeyboardStatus(message: string, ttlMs = 2000): void { private announceKeyboardStatus(message: string, ttlMs = 2000): void {
this.keyboardStatus.set(message); this.keyboardStatus.set(message);
this.clearKeyboardStatusTimeout(); this.clearKeyboardStatusTimeout();
@@ -1330,6 +1543,13 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
return 'evidence'; return 'evidence';
} }
private parseRequestedPanel(value: string | null): DetailPanelId {
if (value && DETAIL_PANEL_ORDER.includes(value as DetailPanelId)) {
return value as DetailPanelId;
}
return 'reason';
}
private resolveRequestedFindingId(requestedFindingId: string | null): string | null { private resolveRequestedFindingId(requestedFindingId: string | null): string | null {
if (requestedFindingId) { if (requestedFindingId) {
const requested = this.findings().find( const requested = this.findings().find(
@@ -1345,13 +1565,31 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
return this.findings()[0]?.vuln.vulnId ?? null; return this.findings()[0]?.vuln.vulnId ?? null;
} }
private syncWorkspaceQueryState(next: {
readonly findingId?: string | null;
readonly tab?: TabId;
readonly panel?: DetailPanelId;
}): void {
void this.router.navigate([], {
relativeTo: this.route,
queryParams: {
findingId: next.findingId ?? this.selectedVulnId(),
tab: next.tab ?? this.activeTab(),
panel: next.panel ?? this.activePanel(),
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
}
private buildWorkspaceReturnTo(tab: TabId): string { private buildWorkspaceReturnTo(tab: TabId): string {
const selected = this.selectedVuln(); const selected = this.selectedVuln();
return this.router.serializeUrl( return this.router.serializeUrl(
this.router.createUrlTree(['/security', 'artifacts', this.artifactId()], { this.router.createUrlTree(['/triage', 'artifacts', this.artifactId()], {
queryParams: { queryParams: {
findingId: selected?.vuln.vulnId, findingId: selected?.vuln.vulnId,
tab, tab,
panel: this.activePanel(),
}, },
}) })
); );

View File

@@ -793,7 +793,8 @@ export class AppSidebarComponent implements AfterViewInit {
StellaOpsScopes.VULN_VIEW, StellaOpsScopes.VULN_VIEW,
], ],
children: [ children: [
{ id: 'sec-triage', label: 'Triage', route: '/security/triage', icon: 'list' }, { id: 'sec-triage', label: 'Triage', route: '/triage/artifacts', icon: 'list' },
{ id: 'sec-audit-bundles', label: 'Audit Bundles', route: '/triage/audit-bundles', icon: 'archive' },
{ id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' }, { id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' },
{ id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' }, { id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' },
{ id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' }, { id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' },

View File

@@ -4,3 +4,4 @@
*/ */
export * from './legacy-redirects.routes'; export * from './legacy-redirects.routes';
export * from './triage.routes';

View File

@@ -18,16 +18,6 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTempla
redirectTo: '/topology/regions', redirectTo: '/topology/regions',
pathMatch: 'full', pathMatch: 'full',
}, },
{
path: 'triage/artifacts',
redirectTo: '/security/artifacts',
pathMatch: 'full',
},
{
path: 'triage/artifacts/:artifactId',
redirectTo: '/security/artifacts/:artifactId',
pathMatch: 'full',
},
{ {
path: 'triage/findings', path: 'triage/findings',
redirectTo: '/security/findings', redirectTo: '/security/findings',

View File

@@ -2,7 +2,18 @@
* Security & Risk Domain Routes * Security & Risk Domain Routes
* Sprint: SPRINT_20260218_014_FE_ui_v2_rewire_security_risk_consolidation (S9-01 through S9-05) * Sprint: SPRINT_20260218_014_FE_ui_v2_rewire_security_risk_consolidation (S9-01 through S9-05)
*/ */
import { Routes } from '@angular/router'; import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
function redirectToTriageWorkspace(path: string) {
return ({ queryParams, fragment }: { queryParams: Record<string, string>; fragment?: string | null }) => {
const router = inject(Router);
const target = router.parseUrl(path);
target.queryParams = { ...queryParams };
target.fragment = fragment ?? null;
return target;
};
}
export const SECURITY_RISK_ROUTES: Routes = [ export const SECURITY_RISK_ROUTES: Routes = [
{ {
@@ -309,15 +320,21 @@ export const SECURITY_RISK_ROUTES: Routes = [
path: 'artifacts', path: 'artifacts',
title: 'Artifacts', title: 'Artifacts',
data: { breadcrumb: 'Artifacts' }, data: { breadcrumb: 'Artifacts' },
loadComponent: () => pathMatch: 'full',
import('../features/triage/triage-artifacts.component').then((m) => m.TriageArtifactsComponent), redirectTo: redirectToTriageWorkspace('/triage/artifacts'),
}, },
{ {
path: 'artifacts/:artifactId', path: 'artifacts/:artifactId',
title: 'Artifact Detail', title: 'Artifact Detail',
data: { breadcrumb: 'Artifact Detail' }, data: { breadcrumb: 'Artifact Detail' },
loadComponent: () => pathMatch: 'full',
import('../features/triage/triage-workspace.component').then((m) => m.TriageWorkspaceComponent), redirectTo: ({ params, queryParams, fragment }) => {
const router = inject(Router);
const target = router.parseUrl(`/triage/artifacts/${encodeURIComponent(params['artifactId'] ?? '')}`);
target.queryParams = { ...queryParams };
target.fragment = fragment ?? null;
return target;
},
}, },
{ {
path: 'symbol-sources', path: 'symbol-sources',

View File

@@ -0,0 +1,43 @@
import { Routes } from '@angular/router';
export const TRIAGE_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'artifacts',
},
{
path: 'artifacts',
title: 'Artifact Workspace',
data: { breadcrumb: 'Artifacts' },
loadComponent: () =>
import('../features/triage/triage-artifacts.component').then((m) => m.TriageArtifactsComponent),
},
{
path: 'artifacts/:artifactId',
title: 'Artifact Detail',
data: { breadcrumb: 'Artifact Detail' },
loadComponent: () =>
import('../features/triage/triage-workspace.component').then((m) => m.TriageWorkspaceComponent),
},
{
path: 'audit-bundles/new',
title: 'Create Audit Bundle',
data: { breadcrumb: 'Create Bundle' },
loadComponent: () =>
import('../features/triage/triage-audit-bundle-new.component').then(
(m) => m.TriageAuditBundleNewComponent
),
},
{
path: 'audit-bundles',
title: 'Audit Bundles',
data: { breadcrumb: 'Audit Bundles' },
loadComponent: () =>
import('../features/triage/triage-audit-bundles.component').then((m) => m.TriageAuditBundlesComponent),
},
{
path: '**',
redirectTo: 'artifacts',
},
];

View File

@@ -0,0 +1,189 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import {
AUDIT_BUNDLES_API_BASE_URL,
AuditBundlesHttpClient,
} from '../../app/core/api/audit-bundles.client';
import { AuthSessionStore } from '../../app/core/auth/auth-session.store';
import { TenantActivationService } from '../../app/core/auth/tenant-activation.service';
describe('AuditBundlesHttpClient (audit bundle contract)', () => {
let client: AuditBundlesHttpClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
AuditBundlesHttpClient,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUDIT_BUNDLES_API_BASE_URL, useValue: '/api/exportcenter' },
{
provide: AuthSessionStore,
useValue: {
session: () => ({
tokens: { accessToken: 'test-token' },
}),
},
},
{
provide: TenantActivationService,
useValue: {
activeTenantId: () => 'tenant-demo',
},
},
],
});
client = TestBed.inject(AuditBundlesHttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('maps the ExportCenter list response into the UI bundle list model', async () => {
const promise = firstValueFrom(client.listBundles());
const req = httpMock.expectOne('/api/exportcenter/v1/audit-bundles');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
expect(req.request.headers.get('X-Stella-Tenant')).toBe('tenant-demo');
req.flush({
bundles: [
{
bundleId: 'bundle-001',
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
status: 'Completed',
createdAt: '2026-03-07T10:00:00Z',
completedAt: '2026-03-07T10:01:00Z',
bundleHash: 'sha256:bundle-001',
artifactCount: 4,
vexDecisionCount: 2,
},
],
continuationToken: null,
hasMore: false,
});
const result = await promise;
expect(result.count).toBe(1);
expect(result.items[0].status).toBe('completed');
expect(result.items[0].completedAt).toBe('2026-03-07T10:01:00.000Z');
expect(result.items[0].sha256).toBe('sha256:bundle-001');
});
it('posts the real ExportCenter create payload and synthesizes the wizard job model', async () => {
const createPromise = firstValueFrom(
client.createBundle(
{
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
contents: {
vulnReports: true,
sbom: true,
vex: false,
policyEvals: true,
attestations: false,
},
},
{ traceId: 'trace-create' },
),
);
const req = httpMock.expectOne('/api/exportcenter/v1/audit-bundles');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
includeContent: {
vulnReports: true,
sbom: true,
vexDecisions: false,
policyEvaluations: true,
attestations: false,
},
});
req.flush({
bundleId: 'bundle-001',
status: 'Accepted',
statusUrl: '/v1/audit-bundles/bundle-001',
estimatedCompletionSeconds: 15,
});
const created = await createPromise;
expect(created.status).toBe('queued');
expect(created.subject.name).toBe('asset-web-prod');
expect(created.statusUrl).toBe('/v1/audit-bundles/bundle-001');
expect(created.estimatedCompletionSeconds).toBe(15);
expect(created.traceId).toBe('trace-create');
});
it('maps bundle status responses and preserves the known subject across polling', async () => {
const createPromise = firstValueFrom(
client.createBundle(
{
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
contents: {
vulnReports: true,
sbom: true,
vex: true,
policyEvals: true,
attestations: true,
},
},
{ traceId: 'trace-prime' },
),
);
const createReq = httpMock.expectOne('/api/exportcenter/v1/audit-bundles');
createReq.flush({
bundleId: 'bundle-002',
status: 'Queued',
statusUrl: '/v1/audit-bundles/bundle-002',
estimatedCompletionSeconds: null,
});
await createPromise;
const statusPromise = firstValueFrom(client.getBundle('bundle-002', { traceId: 'trace-status' }));
const statusReq = httpMock.expectOne('/api/exportcenter/v1/audit-bundles/bundle-002');
expect(statusReq.request.method).toBe('GET');
statusReq.flush({
bundleId: 'bundle-002',
status: 'Completed',
progress: 100,
createdAt: '2026-03-07T10:05:00Z',
completedAt: '2026-03-07T10:06:30Z',
bundleHash: 'sha256:bundle-002',
downloadUrl: '/v1/audit-bundles/bundle-002/download',
ociReference: 'oci://stellaops/audit-bundles@bundle-002',
errorCode: null,
errorMessage: null,
});
const status = await statusPromise;
expect(status.status).toBe('completed');
expect(status.progress).toBe(100);
expect(status.subject.name).toBe('asset-web-prod');
expect(status.downloadUrl).toBe('/v1/audit-bundles/bundle-002/download');
expect(status.ociReference).toBe('oci://stellaops/audit-bundles@bundle-002');
expect(status.traceId).toBe('trace-status');
});
it('downloads zip content from the dedicated download endpoint', async () => {
const promise = firstValueFrom(client.downloadBundle('bundle-003', { traceId: 'trace-download' }));
const req = httpMock.expectOne('/api/exportcenter/v1/audit-bundles/bundle-003/download');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('Accept')).toBe('application/zip');
req.flush(new Blob(['PK\x03\x04'], { type: 'application/zip' }));
const result = await promise;
expect(result.type).toBe('application/zip');
});
});

View File

@@ -50,7 +50,7 @@ describe('TriageAuditBundleNewComponent (audit_bundle)', () => {
}) })
); );
api.downloadBundle.and.returnValue( api.downloadBundle.and.returnValue(
of(new Blob(['{}'], { type: 'application/json' })) of(new Blob(['PK\x03\x04'], { type: 'application/zip' }))
); );
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@@ -73,11 +73,12 @@ describe('TriageAuditBundleNewComponent (audit_bundle)', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
}); });
it('prefills subject fields from artifact query parameter', () => { it('prefills only the subject name from artifact query parameter', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(component.subjectName()).toBe('asset-web-prod'); expect(component.subjectName()).toBe('asset-web-prod');
expect(component.subjectDigest()).toBe('asset-web-prod'); expect(component.subjectDigest()).toBe('');
expect(component.canCreate()).toBeFalse();
}); });
it('advances and rewinds wizard steps deterministically', () => { it('advances and rewinds wizard steps deterministically', () => {
@@ -104,4 +105,32 @@ describe('TriageAuditBundleNewComponent (audit_bundle)', () => {
expect(component.step()).toBe('progress'); expect(component.step()).toBe('progress');
expect(component.job()?.bundleId).toBe('bndl-0001'); expect(component.job()?.bundleId).toBe('bndl-0001');
}); });
it('downloads completed bundles with a zip filename', async () => {
fixture.detectChanges();
component.job.set({
bundleId: 'bndl-0001',
status: 'completed',
createdAt: '2026-02-10T22:20:00Z',
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
});
const anchor = document.createElement('a');
const originalCreateElement = document.createElement.bind(document);
spyOn(document, 'createElement').and.callFake(((tagName: string) => {
if (tagName.toLowerCase() === 'a') {
return anchor;
}
return originalCreateElement(tagName);
}) as typeof document.createElement);
spyOn(URL, 'createObjectURL').and.returnValue('blob:zip');
spyOn(URL, 'revokeObjectURL');
const clickSpy = spyOn(anchor, 'click').and.stub();
await component.download();
expect(api.downloadBundle).toHaveBeenCalledWith('bndl-0001');
expect(anchor.download).toBe('bndl-0001.zip');
expect(clickSpy).toHaveBeenCalled();
});
}); });

View File

@@ -39,7 +39,7 @@ describe('TriageAuditBundlesComponent (audit_bundle)', () => {
}; };
api.listBundles.and.returnValue(of({ items: [bundle], count: 1 })); api.listBundles.and.returnValue(of({ items: [bundle], count: 1 }));
api.downloadBundle.and.returnValue( api.downloadBundle.and.returnValue(
of(new Blob(['bundle-json'], { type: 'application/json' })) of(new Blob(['bundle-zip'], { type: 'application/zip' }))
); );
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@@ -63,14 +63,24 @@ describe('TriageAuditBundlesComponent (audit_bundle)', () => {
it('downloads selected bundle via API and browser object URL', async () => { it('downloads selected bundle via API and browser object URL', async () => {
fixture.detectChanges(); fixture.detectChanges();
const anchor = document.createElement('a');
const originalCreateElement = document.createElement.bind(document);
const createElementSpy = spyOn(document, 'createElement').and.callFake(((tagName: string) => {
if (tagName.toLowerCase() === 'a') {
return anchor;
}
return originalCreateElement(tagName);
}) as typeof document.createElement);
const createUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:mock'); const createUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:mock');
const revokeSpy = spyOn(URL, 'revokeObjectURL'); const revokeSpy = spyOn(URL, 'revokeObjectURL');
const clickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub(); const clickSpy = spyOn(anchor, 'click').and.stub();
await component.download(bundle); await component.download(bundle);
expect(api.downloadBundle).toHaveBeenCalledWith('bndl-1234'); expect(api.downloadBundle).toHaveBeenCalledWith('bndl-1234');
expect(createElementSpy).toHaveBeenCalledWith('a');
expect(createUrlSpy).toHaveBeenCalled(); expect(createUrlSpy).toHaveBeenCalled();
expect(anchor.download).toBe('bndl-1234.zip');
expect(clickSpy).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled();
expect(revokeSpy).toHaveBeenCalledWith('blob:mock'); expect(revokeSpy).toHaveBeenCalledWith('blob:mock');
}); });

View File

@@ -0,0 +1,26 @@
import { TRIAGE_ROUTES } from '../../app/routes/triage.routes';
describe('TRIAGE_ROUTES', () => {
it('exposes the canonical artifact workspace and audit-bundles namespace under /triage', () => {
const paths = TRIAGE_ROUTES.map((route) => route.path);
expect(paths).toContain('');
expect(paths).toContain('artifacts');
expect(paths).toContain('artifacts/:artifactId');
expect(paths).toContain('audit-bundles');
expect(paths).toContain('audit-bundles/new');
});
it('matches the new-bundle wizard before the list route to avoid prefix capture', () => {
const listIndex = TRIAGE_ROUTES.findIndex((route) => route.path === 'audit-bundles');
const wizardIndex = TRIAGE_ROUTES.findIndex((route) => route.path === 'audit-bundles/new');
expect(wizardIndex).toBeGreaterThanOrEqual(0);
expect(listIndex).toBeGreaterThanOrEqual(0);
expect(wizardIndex).toBeLessThan(listIndex);
});
it('redirects the triage root into the canonical artifacts list', () => {
const root = TRIAGE_ROUTES.find((route) => route.path === '');
expect(root?.redirectTo).toBe('artifacts');
});
});

View File

@@ -15,10 +15,6 @@ describe('Legacy redirect policy', () => {
path: 'release-orchestrator/environments', path: 'release-orchestrator/environments',
redirectTo: '/topology/regions', redirectTo: '/topology/regions',
}), }),
jasmine.objectContaining({
path: 'triage/artifacts/:artifactId',
redirectTo: '/security/artifacts/:artifactId',
}),
jasmine.objectContaining({ jasmine.objectContaining({
path: 'triage/findings/:findingId', path: 'triage/findings/:findingId',
redirectTo: '/security/findings/:findingId', redirectTo: '/security/findings/:findingId',
@@ -31,10 +27,6 @@ describe('Legacy redirect policy', () => {
expect(LEGACY_REDIRECT_ROUTES.length).toBe(LEGACY_REDIRECT_ROUTE_TEMPLATES.length); expect(LEGACY_REDIRECT_ROUTES.length).toBe(LEGACY_REDIRECT_ROUTE_TEMPLATES.length);
expect(LEGACY_REDIRECT_ROUTES).toEqual( expect(LEGACY_REDIRECT_ROUTES).toEqual(
jasmine.arrayContaining([ jasmine.arrayContaining([
jasmine.objectContaining({
path: 'triage/artifacts',
pathMatch: 'full',
}),
jasmine.objectContaining({ jasmine.objectContaining({
path: 'triage/findings', path: 'triage/findings',
pathMatch: 'full', pathMatch: 'full',

View File

@@ -22,6 +22,7 @@ describe('Legacy Route Migration Framework (routes)', () => {
it('maps every legacy redirect target to a defined top-level route segment', () => { it('maps every legacy redirect target to a defined top-level route segment', () => {
const topLevelSegments = new Set([ const topLevelSegments = new Set([
'dashboard', 'dashboard',
'ops',
'releases', 'releases',
'security', 'security',
'evidence', 'evidence',
@@ -60,7 +61,6 @@ describe('Legacy Route Migration Framework (routes)', () => {
const testRoutes: Routes = [ const testRoutes: Routes = [
...LEGACY_REDIRECT_ROUTES, ...LEGACY_REDIRECT_ROUTES,
{ path: 'platform/ops/health-slo', component: DummyRouteTargetComponent }, { path: 'platform/ops/health-slo', component: DummyRouteTargetComponent },
{ path: 'security/artifacts/:artifactId', component: DummyRouteTargetComponent },
{ path: 'topology/regions', component: DummyRouteTargetComponent }, { path: 'topology/regions', component: DummyRouteTargetComponent },
{ path: '**', component: DummyRouteTargetComponent }, { path: '**', component: DummyRouteTargetComponent },
]; ];
@@ -76,12 +76,7 @@ describe('Legacy Route Migration Framework (routes)', () => {
it('redirects legacy operations paths to platform ops canonical paths', async () => { it('redirects legacy operations paths to platform ops canonical paths', async () => {
await router.navigateByUrl('/ops/health'); await router.navigateByUrl('/ops/health');
expect(router.url).toBe('/platform/ops/health-slo'); expect(router.url).toBe('/ops/operations/health-slo');
});
it('preserves route params and query params when redirecting triage artifact detail', async () => {
await router.navigateByUrl('/triage/artifacts/artifact-123?tab=evidence');
expect(router.url).toBe('/security/artifacts/artifact-123?tab=evidence');
}); });
it('redirects release orchestrator environments to topology domain', async () => { it('redirects release orchestrator environments to topology domain', async () => {

View File

@@ -109,6 +109,14 @@ describe('SECURITY_RISK_ROUTES', () => {
expect(allPaths).toContain('artifacts/:artifactId'); expect(allPaths).toContain('artifacts/:artifactId');
}); });
it('keeps security artifact aliases pointed at the canonical triage workspace', () => {
const artifactsRoute = getRouteByPath('artifacts');
const detailRoute = getRouteByPath('artifacts/:artifactId');
expect(typeof artifactsRoute?.redirectTo).toBe('function');
expect(typeof detailRoute?.redirectTo).toBe('function');
});
it('contains the scan detail route', () => { it('contains the scan detail route', () => {
expect(allPaths).toContain('scans/:scanId'); expect(allPaths).toContain('scans/:scanId');
}); });

View File

@@ -0,0 +1,117 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { VULNERABILITY_API, type VulnerabilityApi } from '../../app/core/api/vulnerability.client';
import type { Vulnerability } from '../../app/core/api/vulnerability.models';
import { TriageArtifactsComponent } from '../../app/features/triage/triage-artifacts.component';
describe('TriageArtifactsComponent', () => {
let fixture: ComponentFixture<TriageArtifactsComponent>;
let component: TriageArtifactsComponent;
let api: jasmine.SpyObj<VulnerabilityApi>;
const vulnerabilities: Vulnerability[] = [
{
vulnId: 'finding-review',
cveId: 'CVE-2026-1001',
title: 'Critical review finding',
severity: 'critical',
status: 'open',
publishedAt: '2026-03-01T00:00:00Z',
modifiedAt: '2026-03-02T00:00:00Z',
affectedComponents: [{ purl: 'pkg:oci/review', name: 'review', version: '1.0.0', assetIds: ['asset-review-prod'] }],
},
{
vulnId: 'finding-active',
cveId: 'CVE-2026-1002',
title: 'Actionable finding',
severity: 'high',
status: 'open',
publishedAt: '2026-03-01T00:00:00Z',
modifiedAt: '2026-03-03T00:00:00Z',
affectedComponents: [{ purl: 'pkg:oci/active', name: 'active', version: '1.0.0', assetIds: ['asset-active-prod'] }],
},
{
vulnId: 'finding-quiet',
cveId: 'CVE-2026-1003',
title: 'Quiet finding',
severity: 'low',
status: 'fixed',
publishedAt: '2026-03-01T00:00:00Z',
modifiedAt: '2026-03-04T00:00:00Z',
affectedComponents: [{ purl: 'pkg:oci/quiet', name: 'quiet', version: '1.0.0', assetIds: ['asset-builder-quiet'] }],
},
];
beforeEach(async () => {
localStorage.clear();
api = jasmine.createSpyObj('VulnerabilityApi', ['listVulnerabilities']) as jasmine.SpyObj<VulnerabilityApi>;
api.listVulnerabilities.and.returnValue(of({ items: vulnerabilities, total: vulnerabilities.length, hasMore: false }));
await TestBed.configureTestingModule({
imports: [TriageArtifactsComponent],
providers: [
provideRouter([]),
{ provide: VULNERABILITY_API, useValue: api },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({ lane: 'review' }),
},
queryParamMap: of(convertToParamMap({ lane: 'review' })),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(TriageArtifactsComponent);
component = fixture.componentInstance;
});
afterEach(() => {
localStorage.clear();
fixture?.destroy();
});
it('hydrates the canonical lane from query params and shows review artifacts first', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(component.lane()).toBe('review');
expect(component.filteredRows().map((row) => row.artifactId)).toEqual(['asset-review-prod']);
expect(component.laneCounts()).toEqual({ active: 1, quiet: 1, review: 1 });
});
it('persists row lane actions and removes moved artifacts from the current lane view', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const reviewRow = component.filteredRows()[0]!;
component.moveArtifactToLane(reviewRow, 'quiet');
fixture.detectChanges();
expect(component.filteredRows()).toEqual([]);
expect(localStorage.getItem('stellaops.triage.artifact.lanes.v1')).toContain('"asset-review-prod"');
});
it('opens the bundle wizard with the selected artifact', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.toggleSelection('asset-review-prod');
component.openBundleWizardFromSelection();
expect(navigateSpy).toHaveBeenCalledWith(['/triage/audit-bundles/new'], {
queryParams: { artifactId: 'asset-review-prod' },
});
});
});

View File

@@ -72,8 +72,14 @@ describe('triage-workspace-with-proof-tree behavior', () => {
queryParamMap: convertToParamMap({ queryParamMap: convertToParamMap({
findingId: 'v-2', findingId: 'v-2',
tab: 'reachability', tab: 'reachability',
panel: 'provenance',
}), }),
}, },
queryParamMap: of(convertToParamMap({
findingId: 'v-2',
tab: 'reachability',
panel: 'provenance',
})),
}, },
}, },
], ],
@@ -95,6 +101,7 @@ describe('triage-workspace-with-proof-tree behavior', () => {
expect(component.findings().map((finding) => finding.vuln.vulnId)).toEqual(['v-1', 'v-2']); expect(component.findings().map((finding) => finding.vuln.vulnId)).toEqual(['v-1', 'v-2']);
expect(component.selectedVulnId()).toBe('v-2'); expect(component.selectedVulnId()).toBe('v-2');
expect(component.activeTab()).toBe('reachability'); expect(component.activeTab()).toBe('reachability');
expect(component.activePanel()).toBe('provenance');
}); });
it('supports reachability tab with textual proof mode toggle', async () => { it('supports reachability tab with textual proof mode toggle', async () => {
@@ -131,12 +138,50 @@ describe('triage-workspace-with-proof-tree behavior', () => {
queryParams: { queryParams: {
search: 'CVE-2026-3002', search: 'CVE-2026-3002',
findingId: 'v-2', findingId: 'v-2',
returnTo: '/security/artifacts/asset-web-prod?findingId=v-2&tab=reachability', returnTo: '/triage/artifacts/asset-web-prod?findingId=v-2&tab=reachability&panel=provenance',
}, },
} }
); );
}); });
it('syncs query params when operators switch tabs and panels in the active workspace', async () => {
workspaceFixture.detectChanges();
await workspaceFixture.whenStable();
workspaceFixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
const component = workspaceFixture.componentInstance;
component.setPanel('history');
component.setTab('policy');
expect(navigateSpy.calls.allArgs()).toEqual([
[
[],
jasmine.objectContaining({
queryParams: jasmine.objectContaining({
findingId: 'v-2',
tab: 'reachability',
panel: 'history',
}),
replaceUrl: true,
}),
],
[
[],
jasmine.objectContaining({
queryParams: jasmine.objectContaining({
findingId: 'v-2',
tab: 'policy',
panel: 'history',
}),
replaceUrl: true,
}),
],
]);
});
it('renders proof tree digest and emits verify action', () => { it('renders proof tree digest and emits verify action', () => {
const proofFixture = TestBed.createComponent(ProofTreeComponent); const proofFixture = TestBed.createComponent(ProofTreeComponent);
const proofComponent = proofFixture.componentInstance; const proofComponent = proofFixture.componentInstance;

View File

@@ -0,0 +1,331 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
const securitySession: StubAuthSession = {
subjectId: 'triage-e2e-user',
tenant: 'tenant-default',
scopes: [
'admin',
'ui.read',
'scanner:read',
'sbom:read',
'advisory:read',
'vex:read',
'findings:read',
'vuln:view',
'vuln:read',
'vex:write',
],
};
const mockConfig = {
authority: {
issuer: '/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: '/authority/connect/authorize',
tokenEndpoint: '/authority/connect/token',
logoutEndpoint: '/authority/connect/logout',
redirectUri: 'https://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
scope: 'openid profile email ui.read',
audience: '/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
const vulnerabilityList = {
items: [
{
vulnId: 'finding-review-001',
cveId: 'CVE-2026-4001',
title: 'Review-worthy reachable finding',
severity: 'critical',
status: 'open',
publishedAt: '2026-03-07T08:00:00Z',
modifiedAt: '2026-03-07T09:00:00Z',
reachabilityStatus: 'reachable',
reachabilityScore: 92,
affectedComponents: [
{
purl: 'pkg:oci/review@1.0.0',
name: 'review-service',
version: '1.0.0',
assetIds: ['asset-review-prod'],
},
],
},
{
vulnId: 'finding-quiet-001',
cveId: 'CVE-2026-4002',
title: 'Quiet lane finding',
severity: 'low',
status: 'fixed',
publishedAt: '2026-03-07T08:00:00Z',
modifiedAt: '2026-03-07T09:00:00Z',
reachabilityStatus: 'unreachable',
reachabilityScore: 10,
affectedComponents: [
{
purl: 'pkg:oci/quiet@1.0.0',
name: 'quiet-service',
version: '1.0.0',
assetIds: ['asset-builder-quiet'],
},
],
},
],
total: 2,
hasMore: false,
page: 1,
pageSize: 20,
};
const unifiedEvidence = {
findingId: 'finding-review-001',
cveId: 'CVE-2026-4001',
componentPurl: 'pkg:oci/review@1.0.0',
reachability: {
subgraphId: 'sg-review-001',
status: 'reachable',
confidence: 0.92,
method: 'graph',
entryPoints: [
{
id: 'ep-1',
type: 'http',
name: 'POST /deploy',
location: 'src/review.ts:40',
distance: 3,
},
],
callChain: {
pathLength: 3,
pathCount: 1,
keySymbols: ['handleDeploy', 'applyPolicy', 'vulnerableFunction'],
callGraphUri: '/graphs/review-001',
},
graphUri: '/graphs/review-001',
},
attestations: [
{
id: 'att-001',
predicateType: 'https://slsa.dev/provenance/v1',
subjectDigest: 'sha256:reviewdigest',
signer: 'key-review',
signedAt: '2026-03-07T09:05:00Z',
verificationStatus: 'verified',
transparencyLogEntry: 'rekor-001',
},
],
policy: {
policyVersion: '2026.03.07',
policyDigest: 'sha256:policydigest',
verdict: 'warn',
rulesFired: [
{
ruleId: 'RULE-201',
name: 'reachable-critical',
effect: 'warn',
reason: 'Reachable critical finding requires operator review.',
},
],
},
manifests: {
artifactDigest: 'sha256:reviewdigest',
manifestHash: 'sha256:manifestreview',
feedSnapshotHash: 'sha256:feedreview',
policyHash: 'sha256:policydigest',
},
verification: {
status: 'verified',
hashesVerified: true,
attestationsVerified: true,
evidenceComplete: true,
verifiedAt: '2026-03-07T09:06:00Z',
},
replayCommand: 'stella replay finding-review-001',
evidenceBundleUrl: '/bundles/review-001.zip',
generatedAt: '2026-03-07T09:06:00Z',
};
async function fulfillJson(route: Route, body: unknown, status = 200): Promise<void> {
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
}
async function navigateClientSide(page: Page, target: string): Promise<void> {
await page.evaluate((url) => {
window.history.pushState({}, '', url);
window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state }));
}, target);
}
async function setupHarness(page: Page): Promise<void> {
await page.addInitScript((session) => {
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, securitySession);
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/.well-known/openid-configuration', (route) =>
fulfillJson(route, {
issuer: 'https://127.0.0.1:4400/authority',
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
})
);
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
await page.route('**/console/profile**', (route) =>
fulfillJson(route, {
subjectId: securitySession.subjectId,
username: 'triage-e2e',
displayName: 'Triage E2E',
tenant: securitySession.tenant,
roles: ['security-operator'],
scopes: securitySession.scopes,
})
);
await page.route('**/console/token/introspect**', (route) =>
fulfillJson(route, {
active: true,
tenant: securitySession.tenant,
subject: securitySession.subjectId,
scopes: securitySession.scopes,
})
);
await page.route('**/api/v2/context/regions', (route) =>
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }])
);
await page.route('**/api/v2/context/environments**', (route) =>
fulfillJson(route, [
{
environmentId: 'prod',
regionId: 'eu-west',
environmentType: 'prod',
displayName: 'Prod',
sortOrder: 1,
enabled: true,
},
])
);
await page.route('**/api/v2/context/preferences', (route) =>
fulfillJson(route, {
tenantId: securitySession.tenant,
actorId: securitySession.subjectId,
regions: ['eu-west'],
environments: ['prod'],
timeWindow: '24h',
stage: 'all',
updatedAt: '2026-03-07T12:00:00Z',
updatedBy: securitySession.subjectId,
})
);
await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, []));
await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, []));
await page.route('**/api/v1/telemetry/ttfs', (route) => fulfillJson(route, { accepted: true }, 202));
await page.route('**/vuln/vuln**', (route) => fulfillJson(route, vulnerabilityList));
await page.route('**/v1/vex-decisions**', (route) =>
fulfillJson(route, { items: [], count: 0, continuationToken: null })
);
await page.route('**/api/v1/triage/scans/asset-review-prod/gated-buckets', (route) =>
fulfillJson(route, {
scanId: 'asset-review-prod',
unreachableCount: 0,
policyDismissedCount: 0,
backportedCount: 0,
vexNotAffectedCount: 0,
supersededCount: 0,
userMutedCount: 0,
totalHiddenCount: 0,
actionableCount: 1,
totalCount: 1,
computedAt: '2026-03-07T09:06:00Z',
})
);
await page.route('**/api/v1/triage/findings/finding-review-001/evidence**', (route) =>
fulfillJson(route, unifiedEvidence)
);
await page.route('**/v1/audit-bundles', async (route) => {
if (route.request().method() === 'GET') {
await fulfillJson(route, { bundles: [], continuationToken: null, hasMore: false });
return;
}
await fulfillJson(route, {
bundleId: 'bundle-review-001',
status: 'completed',
subject: {
type: 'IMAGE',
name: 'asset-review-prod',
digest: { sha256: 'sha256:reviewdigest' },
},
createdAt: '2026-03-07T09:10:00Z',
sha256: 'sha256:bundle-review-001',
integrityRootHash: 'sha256:root-review-001',
downloadUrl: '/v1/audit-bundles/bundle-review-001/download',
ociReference: 'oci://stellaops/audit-bundles@bundle-review-001',
statusUrl: '/v1/audit-bundles/bundle-review-001',
});
});
}
test.beforeEach(async ({ page }) => {
await setupHarness(page);
});
test('artifact workspace supports lane movement and bundle creation shortcuts', async ({ page }) => {
await page.goto('/triage/artifacts?lane=review', { waitUntil: 'networkidle' });
await expect(page.getByTestId('triage-lane-review')).toHaveClass(/lane-pill--active/);
await expect(page.getByTestId('triage-artifact-row-asset-review-prod')).toBeVisible();
await page.getByLabel('Select asset-review-prod').check();
await page.getByRole('button', { name: 'Move to Quiet' }).click();
await expect(page.getByText('No artifacts match the current lane and filters.')).toBeVisible();
await page.getByTestId('triage-lane-quiet').click();
await expect(page.getByTestId('triage-artifact-row-asset-review-prod')).toBeVisible();
await page.getByLabel('Select asset-review-prod').check();
await page.getByRole('button', { name: 'Build audit bundle' }).click();
await expect(page).toHaveURL(/\/triage\/audit-bundles\/new\?artifactId=asset-review-prod$/);
await expect(page.getByLabel('Name')).toHaveValue('asset-review-prod');
});
test('workspace preserves panel and tab state and security aliases resolve into canonical triage routes', async ({ page }) => {
await page.goto('/triage/artifacts?lane=review', { waitUntil: 'networkidle' });
await page.getByTestId('triage-open-asset-review-prod').click();
await expect(page).toHaveURL(/\/triage\/artifacts\/asset-review-prod\?lane=review&panel=history$/);
await expect(page.getByText('Recent decision events')).toBeVisible();
await page.getByRole('tab', { name: 'Reachability' }).click();
await page.getByTestId('triage-panel-ai').click();
await expect(page).toHaveURL(/\/triage\/artifacts\/asset-review-prod\?lane=review&panel=ai&findingId=finding-review-001&tab=reachability$/);
await expect(page.getByText('Suggested next move')).toBeVisible();
await navigateClientSide(page, '/security/artifacts/asset-review-prod?tab=reachability&panel=history');
await expect(page).toHaveURL(/\/triage\/artifacts\/asset-review-prod\?tab=reachability&panel=history$/);
await expect(page.getByText('Recent decision events')).toBeVisible();
});