From 8f4337831723e97cc69a4a728efac33eac3c512b Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 7 Mar 2026 21:43:55 +0200 Subject: [PATCH] feat(ui): ship triage explainability workspace --- ..._027_FE_triage_explainability_workspace.md | 58 +-- .../web/triage-explainability-workspace-ui.md | 60 ++++ docs/modules/ui/TASKS.md | 13 +- docs/modules/ui/implementation_plan.md | 2 +- .../triage-explainability-workspace/README.md | 14 + .../src/app/core/api/audit-bundles.client.ts | 186 +++++++++- .../src/app/core/api/audit-bundles.models.ts | 6 +- .../services/triage-lane-state.service.ts | 98 ++++++ .../triage/services/ttfs-telemetry.service.ts | 21 +- .../triage/triage-artifacts.component.html | 304 ++++++++++------ .../triage/triage-artifacts.component.scss | 142 ++++++-- .../triage/triage-artifacts.component.ts | 233 +++++++++++- .../triage-audit-bundle-new.component.ts | 26 +- .../triage/triage-audit-bundles.component.ts | 3 +- .../triage/triage-workspace.component.html | 158 ++++++++- .../triage/triage-workspace.component.scss | 89 +++++ .../triage/triage-workspace.component.ts | 274 ++++++++++++++- .../app-sidebar/app-sidebar.component.ts | 3 +- src/Web/StellaOps.Web/src/app/routes/index.ts | 1 + .../src/app/routes/legacy-redirects.routes.ts | 10 - .../src/app/routes/security-risk.routes.ts | 27 +- .../src/app/routes/triage.routes.ts | 43 +++ .../audit-bundles.client.contract.spec.ts | 189 ++++++++++ .../triage-audit-bundle-new.component.spec.ts | 35 +- .../triage-audit-bundles.component.spec.ts | 14 +- .../tests/audit_bundle/triage-routes.spec.ts | 26 ++ .../tests/navigation/legacy-redirects.spec.ts | 8 - ...oute-migration-framework.component.spec.ts | 9 +- .../security-risk-routes.spec.ts | 8 + .../triage/triage-artifacts.component.spec.ts | 117 +++++++ ...workspace-with-proof-tree.behavior.spec.ts | 47 ++- .../triage-explainability-workspace.spec.ts | 331 ++++++++++++++++++ 32 files changed, 2296 insertions(+), 259 deletions(-) rename {docs => docs-archived}/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md (62%) create mode 100644 docs/features/checked/web/triage-explainability-workspace-ui.md create mode 100644 src/Web/StellaOps.Web/src/app/features/triage/services/triage-lane-state.service.ts create mode 100644 src/Web/StellaOps.Web/src/app/routes/triage.routes.ts create mode 100644 src/Web/StellaOps.Web/src/tests/audit_bundle/audit-bundles.client.contract.spec.ts create mode 100644 src/Web/StellaOps.Web/src/tests/audit_bundle/triage-routes.spec.ts create mode 100644 src/Web/StellaOps.Web/src/tests/triage/triage-artifacts.component.spec.ts create mode 100644 src/Web/StellaOps.Web/tests/e2e/triage-explainability-workspace.spec.ts diff --git a/docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md b/docs-archived/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md similarity index 62% rename from docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md rename to docs-archived/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md index d27ac9a9c..5353a14ed 100644 --- a/docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md +++ b/docs-archived/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md @@ -31,7 +31,7 @@ ## Delivery Tracker ### FE-TX-001 - Wire the canonical artifact workspace and route state -Status: TODO +Status: DONE Dependency: none Owners: Product Manager, FE Architect Task description: @@ -39,12 +39,12 @@ Task description: - Ensure the active shell exposes the triage workspace operators should actually use. Completion criteria: -- [ ] Canonical artifact and audit-bundle routes are active in the router. -- [ ] Lane and panel query params work in the shipped UI. -- [ ] Separate workbench brands are no longer required for triage access. +- [x] Canonical artifact and audit-bundle routes are active in the router. +- [x] Lane and panel query params work in the shipped UI. +- [x] Separate workbench brands are no longer required for triage access. ### FE-TX-002 - Ship the list-lane workflows -Status: TODO +Status: DONE Dependency: FE-TX-001 Owners: Developer, FE Architect Task description: @@ -52,12 +52,12 @@ Task description: - Ensure row actions, bulk actions, and lane transitions are usable from the active artifact list. Completion criteria: -- [ ] Lane tabs or segmented controls are working in the shipped UI. -- [ ] Row and bulk actions work from the artifact list. -- [ ] Quiet-lane behavior is usable as queue state, not a detached page. +- [x] Lane tabs or segmented controls are working in the shipped UI. +- [x] Row and bulk actions work from the artifact list. +- [x] Quiet-lane behavior is usable as queue state, not a detached page. ### FE-TX-003 - Ship the detail-side explainability workspace -Status: TODO +Status: DONE Dependency: FE-TX-001 Owners: Developer, Product Manager 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. Completion criteria: -- [ ] Detail-side panels render and open via the active workspace route state. -- [ ] Panel actions and return-to-context behavior work in the shipped UI. -- [ ] AI remains advisory and evidence-first in the shipped detail experience. +- [x] Detail-side panels render and open via the active workspace route state. +- [x] Panel actions and return-to-context behavior work in the shipped UI. +- [x] AI remains advisory and evidence-first in the shipped detail experience. ### FE-TX-004 - Ship the Audit Bundles page and create flow -Status: TODO +Status: DONE Dependency: FE-TX-001 Owners: Developer, Documentation author Task description: @@ -78,12 +78,12 @@ Task description: - Ensure operators can build and retrieve audit bundles from the active triage and evidence flows. Completion criteria: -- [ ] Bundle list and create flow are usable in the shipped UI. -- [ ] Cross-links from artifact detail and evidence open the working page. -- [ ] Audit bundles remain a visible sibling page, not a hidden modal flow. +- [x] Bundle list and create flow are usable in the shipped UI. +- [x] Cross-links from artifact detail and evidence open the working page. +- [x] Audit bundles remain a visible sibling page, not a hidden modal flow. ### FE-TX-005 - Migrate supporting components and retire workbench wrappers -Status: TODO +Status: DONE Dependency: FE-TX-003 Owners: Developer, Documentation author Task description: @@ -91,12 +91,12 @@ Task description: - Retire wrapper shells only after their preserved behavior is working in the active artifact workspace. Completion criteria: -- [ ] Supporting components are visible in the working list or detail surfaces. -- [ ] Wrapper shells slated for retirement are no longer needed for preserved behavior. -- [ ] No preserved triage functionality depends on an orphan workbench route. +- [x] Supporting components are visible in the working list or detail surfaces. +- [x] Wrapper shells slated for retirement are no longer needed for preserved behavior. +- [x] No preserved triage functionality depends on an orphan workbench route. ### FE-TX-006 - Verify, document, and cut over the workspace -Status: TODO +Status: DONE Dependency: FE-TX-004 Owners: QA, Documentation author 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. Completion criteria: -- [ ] Verification covers lane changes, detail panels, and audit bundles. -- [ ] Cross-shell deep links are included in testing. -- [ ] Docs reflect the shipped artifact workspace and audit-bundle flows. +- [x] Verification covers lane changes, detail panels, and audit bundles. +- [x] Cross-shell deep links are included in testing. +- [x] Docs reflect the shipped artifact workspace and audit-bundle flows. ## Execution Log | 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 | 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 - 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. - Risk: quiet-lane behavior may get over-specialized into another shell. - 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. +- 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`. ## Next Checkpoints -- 2026-03-08: confirm lane model, detail-side panel set, and Audit Bundles ownership. -- 2026-03-09: freeze supporting component merge matrix and route/query contract. -- 2026-03-10: finalize QA and rollout contract. +- 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`. diff --git a/docs/features/checked/web/triage-explainability-workspace-ui.md b/docs/features/checked/web/triage-explainability-workspace-ui.md new file mode 100644 index 000000000..d47708e4a --- /dev/null +++ b/docs/features/checked/web/triage-explainability-workspace-ui.md @@ -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=` + - `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 diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index 9e9372dde..f476a4047 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -9,7 +9,6 @@ - `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_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_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-005 Setup boundary and deep-link contract for Operations - [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 -- [TODO] FE-TX-002 List-lane segmentation slice for Artifact Workspace -- [TODO] FE-TX-003 Detail-side explainability rail slice -- [TODO] FE-TX-004 Audit bundles page and create-flow slice -- [TODO] 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-001 Freeze artifact workspace route, lane, and panel contract +- [DONE] FE-TX-002 List-lane segmentation slice for Artifact Workspace +- [DONE] FE-TX-003 Detail-side explainability rail slice +- [DONE] FE-TX-004 Audit bundles page and create-flow slice +- [DONE] FE-TX-005 Supporting component merge matrix 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-002 Graph, timeline, and critical-path slice - [TODO] FE-WV-003 Replay and evidence integration slice diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index ce7ec9733..497615ec1 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.md @@ -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_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_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_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/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/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/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. diff --git a/docs/modules/ui/triage-explainability-workspace/README.md b/docs/modules/ui/triage-explainability-workspace/README.md index 169147f06..7b5a541b5 100644 --- a/docs/modules/ui/triage-explainability-workspace/README.md +++ b/docs/modules/ui/triage-explainability-workspace/README.md @@ -1,5 +1,19 @@ # 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 Restore the useful triage workbench ideas by folding them into one canonical artifact workspace plus a sibling `Audit Bundles` page. diff --git a/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.client.ts b/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.client.ts index d1ca60b3c..e40d66cb1 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.client.ts @@ -8,7 +8,9 @@ import { generateTraceId } from './trace.util'; import type { AuditBundleCreateRequest, AuditBundleJobResponse, + AuditBundleJobStatus, AuditBundleListResponse, + BundleSubjectRef, } from './audit-bundles.models'; export interface AuditBundlesApi { @@ -21,9 +23,62 @@ export interface AuditBundlesApi { export const AUDIT_BUNDLES_API = new InjectionToken('AUDIT_BUNDLES_API'); export const AUDIT_BUNDLES_API_BASE_URL = new InjectionToken('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' }) export class AuditBundlesHttpClient implements AuditBundlesApi { private readonly tenantService = inject(TenantActivationService); + private readonly knownBundles = new Map(); constructor( private readonly http: HttpClient, @@ -36,8 +91,11 @@ export class AuditBundlesHttpClient implements AuditBundlesApi { const traceId = generateTraceId(); const headers = this.buildHeaders(tenant, traceId); - return this.http.get(`${this.baseUrl}/v1/audit-bundles`, { headers }).pipe( - map((resp) => ({ ...resp, traceId })), + return this.http.get(`${this.baseUrl}/v1/audit-bundles`, { headers }).pipe( + map((resp) => { + const items = resp.bundles.map((bundle) => this.mapListItem(bundle, traceId)); + return { items, count: items.length, traceId }; + }), catchError((err) => throwError(() => err)) ); } @@ -47,8 +105,25 @@ export class AuditBundlesHttpClient implements AuditBundlesApi { const traceId = options.traceId ?? generateTraceId(); const headers = this.buildHeaders(tenant, traceId, options.projectId); - return this.http.post(`${this.baseUrl}/v1/audit-bundles`, request, { headers }).pipe( - map((resp) => ({ ...resp, traceId })), + return this.http.post( + `${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)) ); } @@ -58,8 +133,26 @@ export class AuditBundlesHttpClient implements AuditBundlesApi { const traceId = options.traceId ?? generateTraceId(); const headers = this.buildHeaders(tenant, traceId, options.projectId); - return this.http.get(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, { headers }).pipe( - map((resp) => ({ ...resp, traceId })), + return this.http.get(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, { headers }).pipe( + 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)) ); } @@ -68,13 +161,48 @@ export class AuditBundlesHttpClient implements AuditBundlesApi { const tenant = this.resolveTenant(options.tenantId); const traceId = options.traceId ?? generateTraceId(); - const headers = this.buildHeaders(tenant, traceId, options.projectId).set('Accept', 'application/octet-stream'); - return this.http.get(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, { + const headers = this.buildHeaders(tenant, traceId, options.projectId).set('Accept', 'application/zip'); + return this.http.get(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}/download`, { headers, 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): void { + this.knownBundles.set(job.bundleId, { + subject: job.subject, + createdAt: job.createdAt, + }); + } + private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders { let headers = new HttpHeaders({ 'X-Stella-Tenant': tenantId, @@ -153,7 +281,7 @@ export class MockAuditBundlesClient implements AuditBundlesApi { null, 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 { @@ -174,7 +302,7 @@ export class MockAuditBundlesClient implements AuditBundlesApi { status: 'completed', sha256: 'sha256:mock-bundle-sha256', 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}`, }; } @@ -188,3 +316,41 @@ export class MockAuditBundlesClient implements AuditBundlesApi { 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: {}, + }; +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts b/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts index fd013c27d..0eaf37f72 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts @@ -83,10 +83,15 @@ export interface AuditBundleJobResponse { readonly status: AuditBundleJobStatus; readonly createdAt: string; readonly subject: BundleSubjectRef; + readonly completedAt?: string; + readonly progress?: number; readonly sha256?: string; readonly integrityRootHash?: string; readonly downloadUrl?: string; readonly ociReference?: string; + readonly statusUrl?: string; + readonly estimatedCompletionSeconds?: number; + readonly errorCode?: string; readonly error?: string; readonly traceId?: string; } @@ -96,4 +101,3 @@ export interface AuditBundleListResponse { readonly count: number; readonly traceId?: string; } - diff --git a/src/Web/StellaOps.Web/src/app/features/triage/services/triage-lane-state.service.ts b/src/Web/StellaOps.Web/src/app/features/triage/services/triage-lane-state.service.ts new file mode 100644 index 000000000..5f0f93728 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/triage/services/triage-lane-state.service.ts @@ -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; + +const STORAGE_KEY = 'stellaops.triage.artifact.lanes.v1'; + +@Injectable({ providedIn: 'root' }) +export class TriageLaneStateService { + private readonly document = inject(DOCUMENT); + + readonly assignments = signal(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. + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.ts b/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.ts index 073cbe25f..4175e6ff8 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/services/ttfs-telemetry.service.ts @@ -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 { EvidenceBitset } from '../models/evidence.model'; @@ -51,10 +51,23 @@ const BUDGETS = { @Injectable({ providedIn: 'root' }) export class TtfsTelemetryService { private readonly http = inject(HttpClient); + private readonly zone = inject(NgZone); + private readonly destroyRef = inject(DestroyRef); private readonly activeTimings = new Map(); private readonly pendingEvents: TtfsEvent[] = []; private flushTimeout: ReturnType | 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. */ @@ -240,7 +253,11 @@ export class TtfsTelemetryService { // Schedule flush if not already scheduled 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 diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html index 6bf610fe0..c2f88067b 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html @@ -1,11 +1,21 @@ -
+
-

Vulnerability Triage

-

Artifact-first workflow with evidence and VEX-first decisioning.

+

Artifact workspace

+

+ Triage live artifacts by lane, then open a single evidence-first decision workspace. +

+ @@ -21,6 +31,56 @@ /> } +
+ + + +
+ + @if (selectedCount() > 0) { +
+
+ {{ selectedCount() }} artifact{{ selectedCount() === 1 ? '' : 's' }} selected +
+
+ + + + +
+
+ } +
- -
-
- - -
-
+ /> + @if (search()) { + + }
- @if (loading()) { -
- - Loading artifacts... +
+
+ +
- } @else { -
- @if (filteredRows().length > 0) { - - - - - - - - - - - - - - - - @for (row of filteredRows(); track row.artifactId) { - - - - - - - - - - - - } - -
- Artifact {{ getSortIcon('artifact') }} - TypeEnvironment(s) - Open {{ getSortIcon('open') }} - - Total {{ getSortIcon('total') }} - - Max severity {{ getSortIcon('maxSeverity') }} - Attestations - Last scan {{ getSortIcon('lastScan') }} - Action
- {{ row.artifactId }} - @if (row.readyToDeploy) { - Ready to deploy - } - - {{ row.type }} - - {{ row.environments.join(', ') }} - - {{ row.openVulns }} - - {{ row.totalVulns }} - - - {{ severityLabels[row.maxSeverity] }} - - - - {{ row.attestationCount }} - - - {{ formatWhen(row.lastScanAt) }} - - -
- } @else { -
-

No artifacts match your filters.

-
- } +
+
-
- } -
+ @if (loading()) { +
+ + Loading artifacts... +
+ } @else { +
+ @if (filteredRows().length > 0) { + + + + + + + + + + + + + + + + + @for (row of filteredRows(); track row.artifactId) { + + + + + + + + + + + + + } + +
+ + + Artifact {{ getSortIcon('artifact') }} + LaneEnvironment(s) + Open {{ getSortIcon('open') }} + + Total {{ getSortIcon('total') }} + + Max severity {{ getSortIcon('maxSeverity') }} + Attestations + Last scan {{ getSortIcon('lastScan') }} + Actions
+ + + {{ row.artifactId }} + @if (row.readyToDeploy) { + + Ready to deploy + + } + + + {{ row.lane === 'quiet' ? 'Quiet lane' : row.lane === 'review' ? 'Needs review' : 'Active' }} + + + {{ row.environments.join(', ') }} + + {{ row.openVulns }} + + {{ row.totalVulns }} + + + {{ severityLabels[row.maxSeverity] }} + + + + {{ row.attestationCount }} + + + {{ formatWhen(row.lastScanAt) }} + +
+ + @if (row.lane === 'review') { + + } + + +
+
+ } @else { +
+

No artifacts match the current lane and filters.

+
+ } +
+ } +
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss index 6f2f94096..7599621a3 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss @@ -1,10 +1,5 @@ @use 'tokens/breakpoints' as *; -/** - * Triage Artifacts Component Styles - * Migrated to design system tokens - */ - .triage-artifacts { padding: var(--space-6) var(--space-7); } @@ -20,22 +15,87 @@ .triage-artifacts__subtitle { margin: var(--space-1) 0 0; color: var(--color-text-muted); + max-width: 60ch; } .triage-artifacts__actions { display: flex; + flex-wrap: wrap; gap: var(--space-2); + align-items: center; + justify-content: flex-end; } -.triage-artifacts__error { - border: 1px solid var(--color-status-error); - background: var(--color-status-error-bg); - color: var(--color-status-error); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-lg); +.lane-strip { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); 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 { display: flex; gap: var(--space-4); @@ -122,7 +182,7 @@ .triage-artifacts__table-wrap { overflow: auto; border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); + border-radius: var(--radius-xl); background: var(--color-surface-primary); } @@ -134,11 +194,12 @@ .triage-table__th { text-align: left; - padding: var(--space-3) var(--space-3); + padding: var(--space-3); border-bottom: 1px solid var(--color-border-primary); color: var(--color-text-primary); font-weight: var(--font-weight-semibold); user-select: none; + white-space: nowrap; } .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 { - padding: var(--space-3) var(--space-3); + padding: var(--space-3); border-bottom: 1px solid var(--color-border-secondary); vertical-align: middle; } @@ -160,7 +227,7 @@ } .triage-table__td--actions { - white-space: nowrap; + min-width: 18rem; } .artifact-id { @@ -168,9 +235,15 @@ font-size: var(--font-size-sm); } -.ready-pill { +.ready-pill, +.lane-badge, +.chip, +.badge { display: inline-flex; align-items: center; +} + +.ready-pill { margin-left: var(--space-2); padding: var(--space-0-5) var(--space-2); border-radius: var(--radius-full); @@ -181,9 +254,26 @@ 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 { - display: inline-flex; - align-items: center; padding: var(--space-1) var(--space-2); border-radius: var(--radius-full); background: var(--color-surface-tertiary); @@ -206,8 +296,6 @@ } .badge { - display: inline-flex; - align-items: center; justify-content: center; min-width: 2rem; padding: var(--space-0-5) var(--space-2); @@ -233,9 +321,15 @@ font-weight: var(--font-weight-bold); } -.when { +.when, +.env-list { color: var(--color-text-muted); - font-size: var(--font-size-sm); +} + +.row-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); } .btn { @@ -298,4 +392,8 @@ .search-box { min-width: 100%; } + + .bulk-bar { + align-items: stretch; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts index ab9e56a23..fc0079e65 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts @@ -1,4 +1,4 @@ - +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ChangeDetectionStrategy, Component, @@ -7,12 +7,20 @@ import { inject, signal, } from '@angular/core'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { firstValueFrom } from 'rxjs'; import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client'; import type { Vulnerability, VulnerabilitySeverity } from '../../core/api/vulnerability.models'; 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 SortOrder = 'asc' | 'desc'; @@ -46,18 +54,21 @@ export interface TriageArtifactRow { readonly attestationCount: number; readonly lastScanAt: string | null; readonly readyToDeploy: boolean; + readonly lane: TriageArtifactLane; } @Component({ - selector: 'app-triage-artifacts', - imports: [ErrorStateComponent], - templateUrl: './triage-artifacts.component.html', - styleUrls: ['./triage-artifacts.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-triage-artifacts', + imports: [AiCodeGuardBadgeComponent, ErrorStateComponent], + templateUrl: './triage-artifacts.component.html', + styleUrls: ['./triage-artifacts.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class TriageArtifactsComponent implements OnInit { private readonly api = inject(VULNERABILITY_API); private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly laneState = inject(TriageLaneStateService); readonly loading = signal(false); readonly error = signal(null); @@ -65,6 +76,8 @@ export class TriageArtifactsComponent implements OnInit { readonly search = signal(''); readonly environment = signal('all'); + readonly lane = signal('active'); + readonly selectedArtifactIds = signal([]); readonly sortField = signal('maxSeverity'); readonly sortOrder = signal('asc'); @@ -73,6 +86,7 @@ export class TriageArtifactsComponent implements OnInit { readonly severityLabels = SEVERITY_LABELS; readonly rows = computed(() => { + const assignments = this.laneState.assignments(); const byArtifact = new Map(); for (const vuln of this.vulnerabilities()) { @@ -95,6 +109,8 @@ export class TriageArtifactsComponent implements OnInit { const totalVulns = vulns.length; const maxSeverity = this.computeMaxSeverity(vulns); const lastScanAt = this.computeLastScanAt(vulns); + const attestationCount = this.deriveAttestationCount(vulns); + const readyToDeploy = openVulns === 0 && attestationCount > 0; result.push({ artifactId, @@ -103,27 +119,81 @@ export class TriageArtifactsComponent implements OnInit { openVulns, totalVulns, maxSeverity, - attestationCount: this.deriveAttestationCount(vulns), + attestationCount, lastScanAt, - readyToDeploy: openVulns === 0 && this.deriveAttestationCount(vulns) > 0, + readyToDeploy, + lane: assignments[artifactId]?.lane ?? this.deriveDefaultLane({ artifactId, envs, openVulns, maxSeverity, readyToDeploy }), }); } return this.applySorting(result); }); + readonly laneCounts = computed(() => { + const counts = { active: 0, quiet: 0, review: 0 } as Record; + for (const row of this.rows()) { + counts[row.lane] += 1; + } + return counts; + }); + readonly filteredRows = computed(() => { const q = this.search().trim().toLowerCase(); const env = this.environment(); + const lane = this.lane(); return this.rows().filter((row) => { + if (row.lane !== lane) return false; if (env !== 'all' && !row.environments.includes(env)) return false; 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(() => { + 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 { + const requestedLane = this.parseLane(this.route.snapshot.queryParamMap.get('lane')); + this.lane.set(requestedLane); await this.load(); } @@ -133,6 +203,7 @@ export class TriageArtifactsComponent implements OnInit { try { const resp = await firstValueFrom(this.api.listVulnerabilities({ includeReachability: true })); this.vulnerabilities.set(resp.items); + this.pruneSelection(); } catch (err) { this.error.set(err instanceof Error ? err.message : 'Failed to load vulnerabilities'); } finally { @@ -146,6 +217,18 @@ export class TriageArtifactsComponent implements OnInit { setEnvironment(value: EnvironmentHint | 'all'): void { 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 { @@ -162,8 +245,78 @@ export class TriageArtifactsComponent implements OnInit { return this.sortOrder() === 'asc' ? '\u25B2' : '\u25BC'; } - viewVulnerabilities(row: TriageArtifactRow): void { - void this.router.navigate(['/triage/artifacts', row.artifactId]); + isSelected(artifactId: string): boolean { + 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 { @@ -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[] { const field = this.sortField(); const order = this.sortOrder(); @@ -202,11 +375,9 @@ export class TriageArtifactsComponent implements OnInit { } if (cmp !== 0) return order === 'asc' ? cmp : -cmp; - // stable tie-breakers return a.artifactId.localeCompare(b.artifactId); }); - // default "maxSeverity" should show most severe first if (field === 'maxSeverity' && order === 'asc') { return sorted; } @@ -225,10 +396,10 @@ export class TriageArtifactsComponent implements OnInit { private computeLastScanAt(vulns: readonly Vulnerability[]): string | null { const dates = vulns .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; - 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'] { @@ -243,7 +414,35 @@ export class TriageArtifactsComponent implements OnInit { } 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; } + + 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'; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.ts index ba95b74eb..e621c2ca4 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.ts @@ -52,7 +52,9 @@ export class TriageAuditBundleNewComponent implements OnInit, OnDestroy { if (artifactId) { this.subjectName.set(artifactId); - this.subjectDigest.set(artifactId); + if (looksLikeSha256Digest(artifactId)) { + this.subjectDigest.set(artifactId); + } return; } @@ -135,12 +137,20 @@ export class TriageAuditBundleNewComponent implements OnInit, OnDestroy { async download(): Promise { const job = this.job(); if (!job) return; - const blob = await firstValueFrom(this.api.downloadBundle(job.bundleId)); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${job.bundleId}.json`; - a.click(); - URL.revokeObjectURL(url); + try { + const blob = await firstValueFrom(this.api.downloadBundle(job.bundleId)); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${job.bundleId}.zip`; + 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()); +} diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.ts index 11f25fc7e..3389bee6e 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.ts @@ -49,7 +49,7 @@ export class TriageAuditBundlesComponent implements OnInit { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${bundle.bundleId}.json`; + a.download = `${bundle.bundleId}.zip`; a.click(); URL.revokeObjectURL(url); } catch (err) { @@ -57,4 +57,3 @@ export class TriageAuditBundlesComponent implements OnInit { } } } - diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html index 5d58f5f82..488964548 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html @@ -256,7 +256,8 @@ -
+
+
@if (!selectedVuln()) {
Select a finding to view evidence.
} @else if (activeTab() === 'evidence') { @@ -800,6 +801,161 @@ }
+ + @if (selectedVuln()) { + + } +
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss index f06a2b0d3..7972a48b9 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss @@ -334,6 +334,95 @@ 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 { display: flex; justify-content: space-between; diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts index 039694c84..8bece2405 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts @@ -1,7 +1,8 @@ - +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ChangeDetectionStrategy, Component, + DestroyRef, OnInit, OnDestroy, ElementRef, @@ -26,6 +27,19 @@ import { GatedBucketsComponent, type BucketExpandEvent } from './components/gate import { GatingExplainerComponent } from './components/gating-explainer/gating-explainer.component'; import { VexTrustDisplayComponent } from './components/vex-trust-display/vex-trust-display.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 { TtfsTelemetryService } from './services/ttfs-telemetry.service'; import { GatingService } from './services/gating.service'; @@ -45,8 +59,10 @@ import type { } from './models/gating.model'; 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 DETAIL_PANEL_ORDER: readonly DetailPanelId[] = ['reason', 'ai', 'provenance', 'history']; const REACHABILITY_VIEW_ORDER: readonly ('path-list' | 'compact-graph' | 'textual-proof')[] = [ 'path-list', 'compact-graph', @@ -87,6 +103,13 @@ interface QuickVerificationState { readonly details: readonly string[]; } +interface WorkspaceHistoryEvent { + readonly id: string; + readonly title: string; + readonly detail: string; + readonly when: string; +} + @Component({ selector: 'app-triage-workspace', imports: [ @@ -102,6 +125,10 @@ interface QuickVerificationState { VexTrustDisplayComponent, ReplayCommandComponent, ErrorStateComponent, + AiRecommendationPanelComponent, + AiCodeGuardBadgeComponent, + ReasonCapsuleComponent, + SnapshotViewerComponent, ], providers: [TriageShortcutsService], templateUrl: './triage-workspace.component.html', @@ -111,6 +138,7 @@ interface QuickVerificationState { export class TriageWorkspaceComponent implements OnInit, OnDestroy { private readonly document = inject(DOCUMENT); private readonly host = inject>(ElementRef); + private readonly destroyRef = inject(DestroyRef); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly vulnApi = inject(VULNERABILITY_API); @@ -132,6 +160,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { readonly selectedVulnId = signal(null); readonly selectedForBulk = signal([]); readonly activeTab = signal('evidence'); + readonly activePanel = signal('reason'); // Decision drawer state readonly showDecisionDrawer = signal(false); @@ -256,6 +285,105 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { }; }); + readonly aiGuardVerdict = computed(() => { + 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(() => { + 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(() => { + 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(() => { const id = this.artifactId(); if (!id) return []; @@ -402,17 +530,33 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { const artifactId = this.route.snapshot.paramMap.get('artifactId') ?? ''; const requestedFindingId = this.route.snapshot.queryParamMap.get('findingId'); const requestedTab = this.parseRequestedTab(this.route.snapshot.queryParamMap.get('tab')); + const requestedPanel = this.parseRequestedPanel(this.route.snapshot.queryParamMap.get('panel')); this.artifactId.set(artifactId); await this.load(); await this.loadVexDecisions(); const initialFindingId = this.resolveRequestedFindingId(requestedFindingId); - this.selectedVulnId.set(initialFindingId); - this.activeTab.set(initialFindingId ? requestedTab : 'evidence'); + this.activePanel.set(requestedPanel); 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. 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 nextTab = options?.resetTab ?? true ? 'evidence' : this.activeTab(); // If changing selection, start new TTFS tracking if (previousId !== vulnId) { @@ -573,6 +718,10 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { if (options?.resetTab ?? true) { this.activeTab.set('evidence'); } + + if (options?.syncQuery ?? true) { + this.syncWorkspaceQueryState({ findingId: vulnId, tab: nextTab }); + } } toggleBulkSelection(vulnId: string): void { @@ -672,10 +821,10 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { switch (evidenceType) { case 'reachability': case 'callstack': - this.activeTab.set('reachability'); + this.setTab('reachability'); break; case 'provenance': - this.activeTab.set('attestations'); + this.setTab('attestations'); break; case 'vex': this.openDecisionDrawer(); @@ -683,7 +832,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { case 'dsse': case 'rekor': case 'sbom': - this.activeTab.set('evidence'); + this.setTab('evidence'); break; } } @@ -708,7 +857,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { 'Require at least one verified DSSE signature or Rekor inclusion proof.', ], }); - this.activeTab.set('evidence'); + this.setTab('evidence'); return; } @@ -729,7 +878,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { checkedAt, details, }); - this.activeTab.set('evidence'); + this.setTab('evidence'); return; } @@ -740,7 +889,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { checkedAt, details, }); - this.activeTab.set('evidence'); + this.setTab('evidence'); 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.', ], }); - this.activeTab.set('evidence'); + this.setTab('evidence'); } // 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 } }); } + 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 { const selected = this.selectedVuln(); 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); + 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 { @@ -927,14 +1126,14 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { const reachability = selected.vuln.reachabilityStatus ?? 'unknown'; if (reachability === 'unknown') { - this.activeTab.set('reachability'); + this.setTab('reachability'); this.focusTab('reachability'); this.announceKeyboardStatus('Jumped to reachability evidence'); return; } if (!this.hasSignedEvidence(selected)) { - this.activeTab.set('attestations'); + this.setTab('attestations'); this.focusTab('attestations'); this.announceKeyboardStatus('Jumped to provenance evidence'); return; @@ -944,7 +1143,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { } private focusReachabilitySearch(): void { - this.activeTab.set('reachability'); + this.setTab('reachability'); this.focusTab('reachability'); const view = this.document.defaultView; @@ -1013,7 +1212,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { const current = this.activeTab(); const idx = TAB_ORDER.indexOf(current); const next = TAB_ORDER[(idx + delta + TAB_ORDER.length) % TAB_ORDER.length] ?? 'overview'; - this.activeTab.set(next); + this.setTab(next); this.focusTab(next); } @@ -1069,6 +1268,20 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { 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 { this.keyboardStatus.set(message); this.clearKeyboardStatusTimeout(); @@ -1330,6 +1543,13 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { 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 { if (requestedFindingId) { const requested = this.findings().find( @@ -1345,13 +1565,31 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy { 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 { const selected = this.selectedVuln(); return this.router.serializeUrl( - this.router.createUrlTree(['/security', 'artifacts', this.artifactId()], { + this.router.createUrlTree(['/triage', 'artifacts', this.artifactId()], { queryParams: { findingId: selected?.vuln.vulnId, tab, + panel: this.activePanel(), }, }) ); diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index 45acdff2c..53fbb3b16 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -793,7 +793,8 @@ export class AppSidebarComponent implements AfterViewInit { StellaOpsScopes.VULN_VIEW, ], 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-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' }, { id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' }, diff --git a/src/Web/StellaOps.Web/src/app/routes/index.ts b/src/Web/StellaOps.Web/src/app/routes/index.ts index 62c3ba12a..ca2c51d1d 100644 --- a/src/Web/StellaOps.Web/src/app/routes/index.ts +++ b/src/Web/StellaOps.Web/src/app/routes/index.ts @@ -4,3 +4,4 @@ */ export * from './legacy-redirects.routes'; +export * from './triage.routes'; diff --git a/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts b/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts index 099a1cc31..3ab662657 100644 --- a/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts @@ -18,16 +18,6 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTempla redirectTo: '/topology/regions', pathMatch: 'full', }, - { - path: 'triage/artifacts', - redirectTo: '/security/artifacts', - pathMatch: 'full', - }, - { - path: 'triage/artifacts/:artifactId', - redirectTo: '/security/artifacts/:artifactId', - pathMatch: 'full', - }, { path: 'triage/findings', redirectTo: '/security/findings', diff --git a/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts b/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts index 525850efa..f696a4153 100644 --- a/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts @@ -2,7 +2,18 @@ * Security & Risk Domain Routes * 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; 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 = [ { @@ -309,15 +320,21 @@ export const SECURITY_RISK_ROUTES: Routes = [ path: 'artifacts', title: 'Artifacts', data: { breadcrumb: 'Artifacts' }, - loadComponent: () => - import('../features/triage/triage-artifacts.component').then((m) => m.TriageArtifactsComponent), + pathMatch: 'full', + redirectTo: redirectToTriageWorkspace('/triage/artifacts'), }, { path: 'artifacts/:artifactId', title: 'Artifact Detail', data: { breadcrumb: 'Artifact Detail' }, - loadComponent: () => - import('../features/triage/triage-workspace.component').then((m) => m.TriageWorkspaceComponent), + pathMatch: 'full', + 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', diff --git a/src/Web/StellaOps.Web/src/app/routes/triage.routes.ts b/src/Web/StellaOps.Web/src/app/routes/triage.routes.ts new file mode 100644 index 000000000..10d4b602f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/routes/triage.routes.ts @@ -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', + }, +]; diff --git a/src/Web/StellaOps.Web/src/tests/audit_bundle/audit-bundles.client.contract.spec.ts b/src/Web/StellaOps.Web/src/tests/audit_bundle/audit-bundles.client.contract.spec.ts new file mode 100644 index 000000000..280967e87 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/audit_bundle/audit-bundles.client.contract.spec.ts @@ -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'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/audit_bundle/triage-audit-bundle-new.component.spec.ts b/src/Web/StellaOps.Web/src/tests/audit_bundle/triage-audit-bundle-new.component.spec.ts index 99aec8b15..e7aac7316 100644 --- a/src/Web/StellaOps.Web/src/tests/audit_bundle/triage-audit-bundle-new.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/audit_bundle/triage-audit-bundle-new.component.spec.ts @@ -50,7 +50,7 @@ describe('TriageAuditBundleNewComponent (audit_bundle)', () => { }) ); api.downloadBundle.and.returnValue( - of(new Blob(['{}'], { type: 'application/json' })) + of(new Blob(['PK\x03\x04'], { type: 'application/zip' })) ); await TestBed.configureTestingModule({ @@ -73,11 +73,12 @@ describe('TriageAuditBundleNewComponent (audit_bundle)', () => { component = fixture.componentInstance; }); - it('prefills subject fields from artifact query parameter', () => { + it('prefills only the subject name from artifact query parameter', () => { fixture.detectChanges(); 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', () => { @@ -104,4 +105,32 @@ describe('TriageAuditBundleNewComponent (audit_bundle)', () => { expect(component.step()).toBe('progress'); 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(); + }); }); diff --git a/src/Web/StellaOps.Web/src/tests/audit_bundle/triage-audit-bundles.component.spec.ts b/src/Web/StellaOps.Web/src/tests/audit_bundle/triage-audit-bundles.component.spec.ts index bb6feda4b..987a1aed6 100644 --- a/src/Web/StellaOps.Web/src/tests/audit_bundle/triage-audit-bundles.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/audit_bundle/triage-audit-bundles.component.spec.ts @@ -39,7 +39,7 @@ describe('TriageAuditBundlesComponent (audit_bundle)', () => { }; api.listBundles.and.returnValue(of({ items: [bundle], count: 1 })); api.downloadBundle.and.returnValue( - of(new Blob(['bundle-json'], { type: 'application/json' })) + of(new Blob(['bundle-zip'], { type: 'application/zip' })) ); await TestBed.configureTestingModule({ @@ -63,14 +63,24 @@ describe('TriageAuditBundlesComponent (audit_bundle)', () => { it('downloads selected bundle via API and browser object URL', async () => { 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 revokeSpy = spyOn(URL, 'revokeObjectURL'); - const clickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub(); + const clickSpy = spyOn(anchor, 'click').and.stub(); await component.download(bundle); expect(api.downloadBundle).toHaveBeenCalledWith('bndl-1234'); + expect(createElementSpy).toHaveBeenCalledWith('a'); expect(createUrlSpy).toHaveBeenCalled(); + expect(anchor.download).toBe('bndl-1234.zip'); expect(clickSpy).toHaveBeenCalled(); expect(revokeSpy).toHaveBeenCalledWith('blob:mock'); }); diff --git a/src/Web/StellaOps.Web/src/tests/audit_bundle/triage-routes.spec.ts b/src/Web/StellaOps.Web/src/tests/audit_bundle/triage-routes.spec.ts new file mode 100644 index 000000000..3c187f110 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/audit_bundle/triage-routes.spec.ts @@ -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'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/navigation/legacy-redirects.spec.ts b/src/Web/StellaOps.Web/src/tests/navigation/legacy-redirects.spec.ts index b68fd590a..8617ee419 100644 --- a/src/Web/StellaOps.Web/src/tests/navigation/legacy-redirects.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/navigation/legacy-redirects.spec.ts @@ -15,10 +15,6 @@ describe('Legacy redirect policy', () => { path: 'release-orchestrator/environments', redirectTo: '/topology/regions', }), - jasmine.objectContaining({ - path: 'triage/artifacts/:artifactId', - redirectTo: '/security/artifacts/:artifactId', - }), jasmine.objectContaining({ path: 'triage/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).toEqual( jasmine.arrayContaining([ - jasmine.objectContaining({ - path: 'triage/artifacts', - pathMatch: 'full', - }), jasmine.objectContaining({ path: 'triage/findings', pathMatch: 'full', diff --git a/src/Web/StellaOps.Web/src/tests/routes/legacy-route-migration-framework.component.spec.ts b/src/Web/StellaOps.Web/src/tests/routes/legacy-route-migration-framework.component.spec.ts index 9c7d9cd5e..3fbaefc5a 100644 --- a/src/Web/StellaOps.Web/src/tests/routes/legacy-route-migration-framework.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/routes/legacy-route-migration-framework.component.spec.ts @@ -22,6 +22,7 @@ describe('Legacy Route Migration Framework (routes)', () => { it('maps every legacy redirect target to a defined top-level route segment', () => { const topLevelSegments = new Set([ 'dashboard', + 'ops', 'releases', 'security', 'evidence', @@ -60,7 +61,6 @@ describe('Legacy Route Migration Framework (routes)', () => { const testRoutes: Routes = [ ...LEGACY_REDIRECT_ROUTES, { path: 'platform/ops/health-slo', component: DummyRouteTargetComponent }, - { path: 'security/artifacts/:artifactId', component: DummyRouteTargetComponent }, { path: 'topology/regions', 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 () => { await router.navigateByUrl('/ops/health'); - expect(router.url).toBe('/platform/ops/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'); + expect(router.url).toBe('/ops/operations/health-slo'); }); it('redirects release orchestrator environments to topology domain', async () => { diff --git a/src/Web/StellaOps.Web/src/tests/security-risk/security-risk-routes.spec.ts b/src/Web/StellaOps.Web/src/tests/security-risk/security-risk-routes.spec.ts index 1006256a9..028a881cf 100644 --- a/src/Web/StellaOps.Web/src/tests/security-risk/security-risk-routes.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/security-risk/security-risk-routes.spec.ts @@ -109,6 +109,14 @@ describe('SECURITY_RISK_ROUTES', () => { 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', () => { expect(allPaths).toContain('scans/:scanId'); }); diff --git a/src/Web/StellaOps.Web/src/tests/triage/triage-artifacts.component.spec.ts b/src/Web/StellaOps.Web/src/tests/triage/triage-artifacts.component.spec.ts new file mode 100644 index 000000000..953693082 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/triage/triage-artifacts.component.spec.ts @@ -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; + let component: TriageArtifactsComponent; + let api: jasmine.SpyObj; + + 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; + 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' }, + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts b/src/Web/StellaOps.Web/src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts index b918c4975..b33335731 100644 --- a/src/Web/StellaOps.Web/src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts @@ -72,8 +72,14 @@ describe('triage-workspace-with-proof-tree behavior', () => { queryParamMap: convertToParamMap({ findingId: 'v-2', 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.selectedVulnId()).toBe('v-2'); expect(component.activeTab()).toBe('reachability'); + expect(component.activePanel()).toBe('provenance'); }); it('supports reachability tab with textual proof mode toggle', async () => { @@ -131,12 +138,50 @@ describe('triage-workspace-with-proof-tree behavior', () => { queryParams: { search: 'CVE-2026-3002', 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', () => { const proofFixture = TestBed.createComponent(ProofTreeComponent); const proofComponent = proofFixture.componentInstance; diff --git a/src/Web/StellaOps.Web/tests/e2e/triage-explainability-workspace.spec.ts b/src/Web/StellaOps.Web/tests/e2e/triage-explainability-workspace.spec.ts new file mode 100644 index 000000000..e85318d60 --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/triage-explainability-workspace.spec.ts @@ -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 { + await route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(body), + }); +} + +async function navigateClientSide(page: Page, target: string): Promise { + await page.evaluate((url) => { + window.history.pushState({}, '', url); + window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state })); + }, target); +} + +async function setupHarness(page: Page): Promise { + 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(); +});