feat(ui): ship workflow visualization replay workspace
This commit is contained in:
@@ -31,7 +31,7 @@
|
|||||||
## Delivery Tracker
|
## Delivery Tracker
|
||||||
|
|
||||||
### FE-WV-001 - Wire run-detail tabs into Releases and Evidence
|
### FE-WV-001 - Wire run-detail tabs into Releases and Evidence
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: none
|
Dependency: none
|
||||||
Owners: Product Manager, FE Architect
|
Owners: Product Manager, FE Architect
|
||||||
Task description:
|
Task description:
|
||||||
@@ -39,12 +39,12 @@ Task description:
|
|||||||
- Ensure operators can reach the graph and replay experience from the active shells.
|
- Ensure operators can reach the graph and replay experience from the active shells.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Canonical run-detail tabs are active in the router.
|
- [x] Canonical run-detail tabs are active in the router.
|
||||||
- [ ] Runtime graph and replay routes are mounted and reachable.
|
- [x] Runtime graph and replay routes are mounted and reachable.
|
||||||
- [ ] Evidence-side entry points open the working runtime context.
|
- [x] Evidence-side entry points open the working runtime context.
|
||||||
|
|
||||||
### FE-WV-002 - Ship the Graph, Timeline, and Critical Path tabs
|
### FE-WV-002 - Ship the Graph, Timeline, and Critical Path tabs
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-WV-001
|
Dependency: FE-WV-001
|
||||||
Owners: Developer, FE Architect
|
Owners: Developer, FE Architect
|
||||||
Task description:
|
Task description:
|
||||||
@@ -52,12 +52,12 @@ Task description:
|
|||||||
- Ensure these tabs are usable for real run diagnosis, not just visual placeholders.
|
- Ensure these tabs are usable for real run diagnosis, not just visual placeholders.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Graph, timeline, and critical-path tabs render with working filters and node metadata.
|
- [x] Graph, timeline, and critical-path tabs render with working filters and node metadata.
|
||||||
- [ ] Operators can diagnose run state from the shipped tabs.
|
- [x] Operators can diagnose run state from the shipped tabs.
|
||||||
- [ ] Step selection opens the working detail drawer.
|
- [x] Step selection opens the working detail drawer.
|
||||||
|
|
||||||
### FE-WV-003 - Ship Replay and Evidence integration
|
### FE-WV-003 - Ship Replay and Evidence integration
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-WV-001
|
Dependency: FE-WV-001
|
||||||
Owners: Developer, Product Manager
|
Owners: Developer, Product Manager
|
||||||
Task description:
|
Task description:
|
||||||
@@ -65,12 +65,12 @@ Task description:
|
|||||||
- Integrate the existing evidence replay controls and proof replay surfaces into the active run-detail shell.
|
- Integrate the existing evidence replay controls and proof replay surfaces into the active run-detail shell.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Replay tab works from the active run-detail shell.
|
- [x] Replay tab works from the active run-detail shell.
|
||||||
- [ ] Existing evidence replay controls are usable from the new tab model.
|
- [x] Existing evidence replay controls are usable from the new tab model.
|
||||||
- [ ] Evidence and Replay tabs both expose their intended workflows in the shipped UI.
|
- [x] Evidence and Replay tabs both expose their intended workflows in the shipped UI.
|
||||||
|
|
||||||
### FE-WV-004 - Ship step-detail drill-in and deep-link behavior
|
### FE-WV-004 - Ship step-detail drill-in and deep-link behavior
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-WV-002
|
Dependency: FE-WV-002
|
||||||
Owners: Developer, FE Architect
|
Owners: Developer, FE Architect
|
||||||
Task description:
|
Task description:
|
||||||
@@ -78,12 +78,12 @@ Task description:
|
|||||||
- Ensure failed-step drill-ins work from graph and timeline views without losing run context.
|
- Ensure failed-step drill-ins work from graph and timeline views without losing run context.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Step-detail drawer works from graph and timeline tabs.
|
- [x] Step-detail drawer works from graph and timeline tabs.
|
||||||
- [ ] `step=<id>` deep-link behavior is usable and shareable.
|
- [x] `step=<id>` deep-link behavior is usable and shareable.
|
||||||
- [ ] Any required full-route escalation is implemented only where necessary.
|
- [x] Any required full-route escalation is implemented only where necessary.
|
||||||
|
|
||||||
### FE-WV-005 - Implement bounded workflow-editor preview reuse
|
### FE-WV-005 - Implement bounded workflow-editor preview reuse
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-WV-001
|
Dependency: FE-WV-001
|
||||||
Owners: FE Architect, Developer
|
Owners: FE Architect, Developer
|
||||||
Task description:
|
Task description:
|
||||||
@@ -91,12 +91,12 @@ Task description:
|
|||||||
- Keep authoring preview clearly separate from live run telemetry and troubleshooting.
|
- Keep authoring preview clearly separate from live run telemetry and troubleshooting.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Workflow editor can render the bounded preview mode.
|
- [x] Workflow editor can render the bounded preview mode.
|
||||||
- [ ] Runtime-only behaviors are excluded from preview.
|
- [x] Runtime-only behaviors are excluded from preview.
|
||||||
- [ ] Shared and runtime-only graph components are separated in the shipped implementation.
|
- [x] Shared and runtime-only graph components are separated in the shipped implementation.
|
||||||
|
|
||||||
### FE-WV-006 - Verify, redirect, and document the shipped capability
|
### FE-WV-006 - Verify, redirect, and document the shipped capability
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-WV-003
|
Dependency: FE-WV-003
|
||||||
Owners: QA, Documentation author
|
Owners: QA, Documentation author
|
||||||
Task description:
|
Task description:
|
||||||
@@ -104,24 +104,37 @@ Task description:
|
|||||||
- Update release and evidence docs so workflow visualization and replay ship as a usable capability, not just a merge target.
|
- Update release and evidence docs so workflow visualization and replay ship as a usable capability, not just a merge target.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Verification covers graph, timeline, replay, and evidence tabs.
|
- [x] Verification covers graph, timeline, replay, and evidence tabs.
|
||||||
- [ ] Step deep links and alias redirects are included in testing.
|
- [x] Step deep links and alias redirects are included in testing.
|
||||||
- [ ] Docs reflect the shipped run-detail and replay capability.
|
- [x] Docs reflect the shipped run-detail and replay capability.
|
||||||
|
|
||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 2026-03-07 | Sprint created to ship workflow graphing and replay as a run-detail capability under Releases, with evidence-side entry points and a bounded authoring preview reuse model. | Project Manager |
|
| 2026-03-07 | Sprint created to ship workflow graphing and replay as a run-detail capability under Releases, with evidence-side entry points and a bounded authoring preview reuse model. | Project Manager |
|
||||||
|
| 2026-03-07 | Implementation resumed; shared workflow components, route wiring, and verification were moved into active delivery. | Developer |
|
||||||
|
| 2026-03-07 | Shipped the canonical `/releases/runs/:runId/{summary|graph|timeline|critical-path|replay|evidence}` workspace, bounded workflow-editor preview reuse, evidence replay handoff, and legacy `workflow-visualization/*` alias redirects into the mounted run shell. | Developer |
|
||||||
|
| 2026-03-07 | Verified the feature with targeted Angular tests (`npx ng test --watch=false --include src/tests/release-control/release-control-routes.spec.ts --include src/tests/releases/release-detail.live-refresh.spec.ts --include src/tests/evidence/replay-controls-reachability-handoff.spec.ts --include src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts --include src/tests/workflow_visualization/run-graph-replay-page.behavior.spec.ts`) and Playwright browser scenarios (`npx playwright test tests/e2e/workflow-visualization-replay.spec.ts --workers=1`). | QA |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
- Decision: runtime workflow visualization is owned by `Releases > Runs`, not by a standalone product.
|
- Decision: runtime workflow visualization is owned by `Releases > Runs`, not by a standalone product.
|
||||||
- Decision: `Evidence > Verify & Replay` becomes a secondary entry point into the same replay model.
|
- Decision: `Evidence > Verify & Replay` becomes a secondary entry point into the same replay model.
|
||||||
|
- Decision: the canonical mounted routes are `/releases/runs/:runId/{summary|graph|timeline|critical-path|replay|evidence}` and legacy `workflow-visualization/*` URLs now redirect into that route family.
|
||||||
|
- Decision: workflow editor preview reuse is graph-only and cannot trigger runtime graph or replay loading.
|
||||||
- Risk: runtime and authoring semantics may get mixed in one component tree and confuse operators.
|
- Risk: runtime and authoring semantics may get mixed in one component tree and confuse operators.
|
||||||
- Mitigation: freeze a strict preview versus runtime boundary before implementation begins.
|
- Mitigation: freeze a strict preview versus runtime boundary before implementation begins.
|
||||||
- Risk: replay controls already living in evidence routes may diverge from the run-detail experience.
|
- Risk: replay controls already living in evidence routes may diverge from the run-detail experience.
|
||||||
- Mitigation: require one shared route and tab model for replay semantics.
|
- Mitigation: require one shared route and tab model for replay semantics.
|
||||||
- Delivery rule: this sprint is only complete when release operators can use the mounted graph, timeline, replay, and evidence flows without depending on the dead workflow-visualization branch.
|
- Delivery rule: this sprint is only complete when release operators can use the mounted graph, timeline, replay, and evidence flows without depending on the dead workflow-visualization branch.
|
||||||
- Reference design note: `docs/modules/ui/workflow-visualization-replay/README.md`.
|
- Reference design note: `docs/modules/ui/workflow-visualization-replay/README.md`.
|
||||||
|
- Docs synced:
|
||||||
|
- `docs/modules/ui/workflow-visualization-replay/README.md`
|
||||||
|
- `docs/features/checked/web/workflow-visualization-replay-ui.md`
|
||||||
|
- `docs/modules/ui/restoration-topics/workflow-visualization-and-replay.md`
|
||||||
|
- `docs/modules/ui/restoration-topics/README.md`
|
||||||
|
- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md`
|
||||||
|
- `docs/modules/ui/TASKS.md`
|
||||||
|
- `docs/modules/ui/implementation_plan.md`
|
||||||
|
|
||||||
## Next Checkpoints
|
## Next Checkpoints
|
||||||
- 2026-03-08: confirm runtime ownership, tab model, and evidence entry-point rules.
|
- 2026-03-08: confirm runtime ownership, tab model, and evidence entry-point rules.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Workflow Visualization Replay UI
|
||||||
|
|
||||||
|
## Module
|
||||||
|
Web
|
||||||
|
|
||||||
|
## Status
|
||||||
|
VERIFIED
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Shipped the canonical `Releases > Runs` graph and replay workspace with stable summary, graph, timeline, critical-path, replay, and evidence tabs. Operators can drill into failed steps, deep-link a step drawer with `step=<id>`, move into `Evidence > Verify & Replay`, and return to the same run context without depending on the dead `workflow-visualization` branch.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/workflow-visualization/`
|
||||||
|
- **Primary components**:
|
||||||
|
- `run-graph-replay-page` (`src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts`)
|
||||||
|
- `workflow-visualizer` (`src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/workflow-visualizer/workflow-visualizer.component.ts`)
|
||||||
|
- `time-travel-controls` (`src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/time-travel-controls/time-travel-controls.component.ts`)
|
||||||
|
- `step-detail-panel` (`src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/step-detail-panel/step-detail-panel.component.ts`)
|
||||||
|
- **Canonical routes**:
|
||||||
|
- `/releases/runs/:runId/summary`
|
||||||
|
- `/releases/runs/:runId/graph`
|
||||||
|
- `/releases/runs/:runId/timeline`
|
||||||
|
- `/releases/runs/:runId/critical-path`
|
||||||
|
- `/releases/runs/:runId/replay`
|
||||||
|
- `/releases/runs/:runId/evidence`
|
||||||
|
- **Legacy aliases**:
|
||||||
|
- `/workflow-visualization/:runId`
|
||||||
|
- `/workflow-visualization/:runId/:tab`
|
||||||
|
- **Query state**:
|
||||||
|
- `step=<stepId>`
|
||||||
|
- `returnTo=<encoded route>`
|
||||||
|
- **Secondary entry points**:
|
||||||
|
- `Releases > Activity`
|
||||||
|
- release detail replay actions
|
||||||
|
- `Evidence > Verify & Replay`
|
||||||
|
- workflow editor `Preview DAG` mode
|
||||||
|
|
||||||
|
## E2E Test Plan
|
||||||
|
- **Setup**:
|
||||||
|
- [ ] Log in with a user that can access `Releases`, `Evidence`, and workflow administration.
|
||||||
|
- [ ] Navigate to `/releases/runs/<runId>/graph`.
|
||||||
|
- [ ] Ensure run detail, timeline, replay, and workflow graph fixtures or seeded data exist.
|
||||||
|
- **Core verification**:
|
||||||
|
- [ ] Verify `Summary`, `Graph`, `Timeline`, `Critical Path`, `Replay`, and `Evidence` tabs render in one mounted run shell.
|
||||||
|
- [ ] Verify graph and timeline step selection open the step drawer and preserve `step=<id>` in the URL.
|
||||||
|
- [ ] Verify replay and evidence actions preserve run context and hand off correctly to `Evidence > Verify & Replay`.
|
||||||
|
- **Legacy verification**:
|
||||||
|
- [ ] Verify `workflow-visualization/*` aliases redirect into `/releases/runs/:runId/*`.
|
||||||
|
- [ ] Verify authoring preview renders the graph-only workflow editor mode without runtime replay loading.
|
||||||
|
- [ ] Verify redirect and deep-link behavior tolerate additional context query params.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Run:
|
||||||
|
- `npx ng test --watch=false --include src/tests/release-control/release-control-routes.spec.ts --include src/tests/releases/release-detail.live-refresh.spec.ts --include src/tests/evidence/replay-controls-reachability-handoff.spec.ts --include src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts --include src/tests/workflow_visualization/run-graph-replay-page.behavior.spec.ts`
|
||||||
|
- `npx playwright test tests/e2e/workflow-visualization-replay.spec.ts --workers=1`
|
||||||
|
- Tier 0 (source): pass
|
||||||
|
- Tier 1 (build/tests): pass
|
||||||
|
- Tier 2 (behavior): pass
|
||||||
|
- Verified on (UTC): 2026-03-07T21:24:11.4848681Z
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
- `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md`
|
- `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md`
|
||||||
- `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md`
|
- `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md`
|
||||||
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md`
|
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md`
|
||||||
- `docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
|
|
||||||
- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`
|
- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`
|
||||||
|
|
||||||
## Delivery Tasks
|
## Delivery Tasks
|
||||||
@@ -92,12 +91,12 @@
|
|||||||
- [DONE] FE-TX-004 Audit bundles page and create-flow 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-005 Supporting component merge matrix for Triage explainability
|
||||||
- [DONE] FE-TX-006 QA, rollout, and docs sync for Triage explainability
|
- [DONE] FE-TX-006 QA, rollout, and docs sync for Triage explainability
|
||||||
- [TODO] FE-WV-001 Freeze run-detail tab and route contract for workflow visualization
|
- [DONE] FE-WV-001 Freeze run-detail tab and route contract for workflow visualization
|
||||||
- [TODO] FE-WV-002 Graph, timeline, and critical-path slice
|
- [DONE] FE-WV-002 Graph, timeline, and critical-path slice
|
||||||
- [TODO] FE-WV-003 Replay and evidence integration slice
|
- [DONE] FE-WV-003 Replay and evidence integration slice
|
||||||
- [TODO] FE-WV-004 Step-detail drawer and deep-link behavior
|
- [DONE] FE-WV-004 Step-detail drawer and deep-link behavior
|
||||||
- [TODO] FE-WV-005 Workflow-editor preview reuse boundary
|
- [DONE] FE-WV-005 Workflow-editor preview reuse boundary
|
||||||
- [TODO] FE-WV-006 QA, rollout, alias migration, and docs sync for workflow visualization
|
- [DONE] FE-WV-006 QA, rollout, alias migration, and docs sync for workflow visualization
|
||||||
- [TODO] FE-CA-001 Freeze contextual placement decision matrix and route-state contract
|
- [TODO] FE-CA-001 Freeze contextual placement decision matrix and route-state contract
|
||||||
- [TODO] FE-CA-002 Shared contextual drawer host
|
- [TODO] FE-CA-002 Shared contextual drawer host
|
||||||
- [TODO] FE-CA-003 Split list-detail and right-rail primitives
|
- [TODO] FE-CA-003 Split list-detail and right-rail primitives
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ The order is by confidence that the capability should exist in the final Stella
|
|||||||
- `/releases/runs`, `/evidence`, and release-context views
|
- `/releases/runs`, `/evidence`, and release-context views
|
||||||
- Notes:
|
- Notes:
|
||||||
- Detailed UX dossier: `docs/modules/ui/workflow-visualization-replay/README.md`
|
- Detailed UX dossier: `docs/modules/ui/workflow-visualization-replay/README.md`
|
||||||
- Implementation sprint: `docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
|
- Implementation sprint: `docs-archived/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
|
||||||
|
- Shipped verification note: `docs/features/checked/web/workflow-visualization-replay-ui.md`
|
||||||
|
|
||||||
## Tier 2 - Surface Existing Capability Instead Of Rebuilding
|
## Tier 2 - Surface Existing Capability Instead Of Rebuilding
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
|||||||
- `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components.
|
- `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components.
|
||||||
- `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation.
|
- `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation.
|
||||||
- `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself.
|
- `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself.
|
||||||
- `SPRINT_20260307_028_FE_workflow_visualization_replay.md` - ship run-detail graph, timeline, replay, and evidence tabs plus bounded workflow-editor preview reuse.
|
|
||||||
- `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - ship the shared tabs, drawers, right rails, split views, and contextual detail primitives adopted by the restoration features.
|
- `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - ship the shared tabs, drawers, right rails, split views, and contextual detail primitives adopted by the restoration features.
|
||||||
|
|
||||||
## Latest evidence
|
## Latest evidence
|
||||||
@@ -27,6 +26,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
|||||||
- `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs.
|
- `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs.
|
||||||
- `docs/features/checked/web/operations-consolidation-ui.md` - shipped verification note for the canonical Operations shell, overview grouping, and legacy alias cutover.
|
- `docs/features/checked/web/operations-consolidation-ui.md` - shipped verification note for the canonical Operations shell, overview grouping, and legacy alias cutover.
|
||||||
- `docs/features/checked/web/triage-explainability-workspace-ui.md` - shipped verification note for the canonical triage artifact workspace, explainability rail, audit bundles, and security alias cutover.
|
- `docs/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/features/checked/web/workflow-visualization-replay-ui.md` - shipped verification note for the canonical run-detail graph, timeline, replay, evidence tabs, and workflow-editor preview reuse boundary.
|
||||||
- `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract.
|
- `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract.
|
||||||
- `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan.
|
- `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan.
|
||||||
- `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier.
|
- `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ It answers four questions for each topic:
|
|||||||
- `docs/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md`
|
- `docs/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md`
|
||||||
- `docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md`
|
- `docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md`
|
||||||
- `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
|
- `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
|
||||||
- `docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
|
- `docs-archived/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md` - shipped workflow visualization and replay restoration
|
||||||
- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`
|
- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`
|
||||||
|
|
||||||
## Placement Matrix
|
## Placement Matrix
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ The graph/timeline/critical-path experiences should be facets of the same run de
|
|||||||
## Detailed UX And Sprint
|
## Detailed UX And Sprint
|
||||||
|
|
||||||
- Detailed UX dossier: `../workflow-visualization-replay/README.md`
|
- Detailed UX dossier: `../workflow-visualization-replay/README.md`
|
||||||
- Implementation sprint: `../../../implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
|
- Implementation sprint: `../../../docs-archived/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
|
||||||
|
- Shipped verification note: `../../../features/checked/web/workflow-visualization-replay-ui.md`
|
||||||
|
|
||||||
## Corroborating Inputs
|
## Corroborating Inputs
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ Restore workflow graphing, time-travel, and replay as a run-detail capability un
|
|||||||
|
|
||||||
This capability should help operators understand what happened in a release run, why a run failed or slowed, and how evidence or replay confirms the outcome.
|
This capability should help operators understand what happened in a release run, why a run failed or slowed, and how evidence or replay confirms the outcome.
|
||||||
|
|
||||||
|
## Shipped Outcome
|
||||||
|
|
||||||
|
- The canonical runtime workspace now ships under `/releases/runs/:runId/{summary|graph|timeline|critical-path|replay|evidence}`.
|
||||||
|
- `Releases > Activity` and release detail flows deep-link into the mounted run workspace instead of the dead prototype route family.
|
||||||
|
- `Evidence > Verify & Replay` can open the same run context, and replay controls can return operators into the canonical run workspace.
|
||||||
|
- Legacy `workflow-visualization/:runId/*` URLs now redirect into the canonical run workspace.
|
||||||
|
- Workflow editor preview reuse is bounded to graph preview mode and does not load runtime replay semantics.
|
||||||
|
- Verification evidence is captured in `docs/features/checked/web/workflow-visualization-replay-ui.md`.
|
||||||
|
|
||||||
## Why This Is The Right Shape
|
## Why This Is The Right Shape
|
||||||
|
|
||||||
- The abandoned visualizer models runtime concepts such as graph state, auto-refresh, step detail, critical path, and time travel.
|
- The abandoned visualizer models runtime concepts such as graph state, auto-refresh, step detail, critical path, and time travel.
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ import {
|
|||||||
/
|
/
|
||||||
<code>{{ runId() || 'run-unset' }}</code>
|
<code>{{ runId() || 'run-unset' }}</code>
|
||||||
</p>
|
</p>
|
||||||
|
@if (runId()) {
|
||||||
|
<div class="replay-runtime-link">
|
||||||
|
<button class="btn btn-secondary" type="button" (click)="openRunReplayWorkspace()">
|
||||||
|
Open run replay workspace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -281,6 +288,10 @@ import {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.replay-runtime-link {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2.5rem;
|
||||||
}
|
}
|
||||||
@@ -843,6 +854,19 @@ export class ReplayControlsComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openRunReplayWorkspace(): void {
|
||||||
|
if (!this.runId()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.router.navigate(['/releases/runs', this.runId(), 'replay'], {
|
||||||
|
queryParams: {
|
||||||
|
releaseId: this.releaseId(),
|
||||||
|
returnTo: this.buildReplayReturnTo(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
requestReplay(): void {
|
requestReplay(): void {
|
||||||
const newRequest: ReplayRequest = {
|
const newRequest: ReplayRequest = {
|
||||||
id: `rr-${Date.now()}`,
|
id: `rr-${Date.now()}`,
|
||||||
|
|||||||
@@ -645,14 +645,22 @@ export class ReleaseDetailComponent {
|
|||||||
|
|
||||||
void this.router.navigate(['/security/reachability/witnesses'], { queryParams });
|
void this.router.navigate(['/security/reachability/witnesses'], { queryParams });
|
||||||
}
|
}
|
||||||
replayRun(): void { void this.router.navigate(['/evidence/verify-replay'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
|
replayRun(): void {
|
||||||
|
void this.router.navigate(['/releases/runs', this.releaseId(), 'replay'], {
|
||||||
|
queryParams: { releaseId: this.releaseContextId() },
|
||||||
|
});
|
||||||
|
}
|
||||||
exportRunEvidence(): void { void this.router.navigate(['/evidence/capsules'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
|
exportRunEvidence(): void { void this.router.navigate(['/evidence/capsules'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
|
||||||
openAgentLogs(target: string): void { void this.router.navigate(['/ops/operations/jobs-queues'], { queryParams: { releaseId: this.releaseContextId(), target } }); }
|
openAgentLogs(target: string): void { void this.router.navigate(['/ops/operations/jobs-queues'], { queryParams: { releaseId: this.releaseContextId(), target } }); }
|
||||||
openTopology(target: string): void { void this.router.navigate(['/setup/topology/targets'], { queryParams: { releaseId: this.releaseContextId(), target } }); }
|
openTopology(target: string): void { void this.router.navigate(['/setup/topology/targets'], { queryParams: { releaseId: this.releaseContextId(), target } }); }
|
||||||
openGlobalFindings(): void { void this.router.navigate(['/security/triage'], { queryParams: { releaseId: this.releaseContextId() } }); }
|
openGlobalFindings(): void { void this.router.navigate(['/security/triage'], { queryParams: { releaseId: this.releaseContextId() } }); }
|
||||||
exportSecurityEvidence(): void { void this.router.navigate(['/evidence/capsules'], { queryParams: { releaseId: this.releaseContextId(), scope: 'security' } }); }
|
exportSecurityEvidence(): void { void this.router.navigate(['/evidence/capsules'], { queryParams: { releaseId: this.releaseContextId(), scope: 'security' } }); }
|
||||||
openProofChain(): void { void this.router.navigate(['/evidence/proofs'], { queryParams: { releaseId: this.releaseContextId() } }); }
|
openProofChain(): void { void this.router.navigate(['/evidence/proofs'], { queryParams: { releaseId: this.releaseContextId() } }); }
|
||||||
openReplay(): void { void this.router.navigate(['/evidence/verify-replay'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
|
openReplay(): void {
|
||||||
|
void this.router.navigate(['/releases/runs', this.releaseId(), 'replay'], {
|
||||||
|
queryParams: { releaseId: this.releaseContextId() },
|
||||||
|
});
|
||||||
|
}
|
||||||
exportReleaseEvidence(): void { void this.router.navigate(['/evidence/exports'], { queryParams: { releaseId: this.releaseContextId(), scope: 'release' } }); }
|
exportReleaseEvidence(): void { void this.router.navigate(['/evidence/exports'], { queryParams: { releaseId: this.releaseContextId(), scope: 'release' } }); }
|
||||||
openUnifiedAudit(): void { void this.router.navigate(['/evidence/audit-log'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
|
openUnifiedAudit(): void { void this.router.navigate(['/evidence/audit-log'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
type WorkflowStepType,
|
type WorkflowStepType,
|
||||||
type StepTypeDefinition,
|
type StepTypeDefinition,
|
||||||
} from '../../../../core/api/workflow.models';
|
} from '../../../../core/api/workflow.models';
|
||||||
|
import { WorkflowVisualizerComponent } from '../../../workflow-visualization/components/workflow-visualizer/workflow-visualizer.component';
|
||||||
|
import type { WorkflowGraph } from '../../../workflow-visualization/services/workflow-visualization.service';
|
||||||
|
|
||||||
interface CanvasState {
|
interface CanvasState {
|
||||||
offsetX: number;
|
offsetX: number;
|
||||||
@@ -50,7 +52,7 @@ interface ConnectionState {
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-workflow-editor',
|
selector: 'app-workflow-editor',
|
||||||
imports: [RouterLink, FormsModule],
|
imports: [RouterLink, FormsModule, WorkflowVisualizerComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -106,6 +108,32 @@ interface ConnectionState {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (previewMode()) {
|
||||||
|
<section class="preview-mode-panel" data-testid="workflow-preview-mode">
|
||||||
|
<header class="preview-mode-panel__header">
|
||||||
|
<div>
|
||||||
|
<h2>Preview Graph</h2>
|
||||||
|
<p>Read-only graph preview that reuses the runtime visualizer without replay or live telemetry controls.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" type="button" (click)="previewMode.set(false)">
|
||||||
|
Return to editor
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (previewGraph(); as graph) {
|
||||||
|
<app-workflow-visualizer
|
||||||
|
[runId]="store.selectedWorkflow()?.id || 'workflow-preview'"
|
||||||
|
[graph]="graph"
|
||||||
|
[previewMode]="true"
|
||||||
|
[autoRefresh]="false"
|
||||||
|
[showMinimap]="false"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<div class="preview-mode-panel__loading">Preparing graph preview...</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="editor-body">
|
<div class="editor-body">
|
||||||
<!-- Step Palette -->
|
<!-- Step Palette -->
|
||||||
<aside class="step-palette">
|
<aside class="step-palette">
|
||||||
@@ -857,6 +885,42 @@ interface ConnectionState {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-mode-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0 1.25rem 1rem;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-mode-panel__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-mode-panel__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-mode-panel__header p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-mode-panel__loading {
|
||||||
|
border: 1px dashed var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
`]
|
`]
|
||||||
})
|
})
|
||||||
export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit {
|
export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||||
@@ -871,6 +935,7 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit
|
|||||||
|
|
||||||
// View state
|
// View state
|
||||||
readonly showYamlView = signal(false);
|
readonly showYamlView = signal(false);
|
||||||
|
readonly previewMode = signal(this.route.snapshot.queryParamMap?.get('view') === 'preview');
|
||||||
|
|
||||||
// Canvas state
|
// Canvas state
|
||||||
readonly canvasState = signal<CanvasState>({
|
readonly canvasState = signal<CanvasState>({
|
||||||
@@ -916,6 +981,47 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit
|
|||||||
return workflow ? workflowToYaml(workflow) : '';
|
return workflow ? workflowToYaml(workflow) : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly previewGraph = computed<WorkflowGraph | null>(() => {
|
||||||
|
const workflow = this.store.selectedWorkflow();
|
||||||
|
const steps = this.store.steps();
|
||||||
|
if (!workflow || steps.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: workflow.id,
|
||||||
|
name: workflow.name,
|
||||||
|
layoutAlgorithm: 'dagre',
|
||||||
|
nodes: steps.map((step) => ({
|
||||||
|
id: step.id,
|
||||||
|
label: step.name,
|
||||||
|
type: step.type === 'gate' ? 'gate' : step.type === 'approval' ? 'approval' : 'task',
|
||||||
|
status: 'pending',
|
||||||
|
metadata: {
|
||||||
|
description: step.description ?? '',
|
||||||
|
triggerEnvironments: workflow.triggerEnvironments,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
edges: steps.flatMap((step) =>
|
||||||
|
step.dependencies.map((dependency) => ({
|
||||||
|
id: `${dependency}-${step.id}`,
|
||||||
|
source: dependency,
|
||||||
|
target: step.id,
|
||||||
|
type: 'dependency' as const,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
positions: steps.map((step) => ({
|
||||||
|
nodeId: step.id,
|
||||||
|
x: step.position.x + 160,
|
||||||
|
y: step.position.y + 120,
|
||||||
|
})),
|
||||||
|
metadata: {
|
||||||
|
workflowStatus: workflow.status,
|
||||||
|
preview: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
readonly connections = computed(() => {
|
readonly connections = computed(() => {
|
||||||
const steps = this.store.steps();
|
const steps = this.store.steps();
|
||||||
const connections: { id: string; path: string; from: string; to: string }[] = [];
|
const connections: { id: string; path: string; from: string; to: string }[] = [];
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ interface PlatformListResponse<T> {
|
|||||||
<tbody>
|
<tbody>
|
||||||
@for (row of filteredRows(); track row.activityId) {
|
@for (row of filteredRows(); track row.activityId) {
|
||||||
<tr>
|
<tr>
|
||||||
<td><a [routerLink]="['/releases/runs', row.releaseId, 'timeline']">{{ row.activityId }}</a></td>
|
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
|
||||||
<td>{{ row.releaseName }}</td>
|
<td>{{ row.releaseName }}</td>
|
||||||
<td>{{ deriveLane(row) }}</td>
|
<td>{{ deriveLane(row) }}</td>
|
||||||
<td>{{ deriveOutcome(row) }}</td>
|
<td>{{ deriveOutcome(row) }}</td>
|
||||||
|
|||||||
@@ -382,6 +382,8 @@ import { WorkflowVisualizationService, StepDetails as ServiceStepDetails } from
|
|||||||
export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
@Input() runId!: string;
|
@Input() runId!: string;
|
||||||
@Input() stepId?: string;
|
@Input() stepId?: string;
|
||||||
|
@Input() stepData?: StepDetails | null;
|
||||||
|
@Input() logsData?: readonly LogEntry[] | null;
|
||||||
|
|
||||||
@Output() stepSelected = new EventEmitter<string>();
|
@Output() stepSelected = new EventEmitter<string>();
|
||||||
@Output() retryRequested = new EventEmitter<string>();
|
@Output() retryRequested = new EventEmitter<string>();
|
||||||
@@ -394,6 +396,7 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
|
|
||||||
// Logs state
|
// Logs state
|
||||||
logs: LogEntry[] = [];
|
logs: LogEntry[] = [];
|
||||||
|
private allLogs: LogEntry[] = [];
|
||||||
loadingLogs = false;
|
loadingLogs = false;
|
||||||
hasMoreLogs = false;
|
hasMoreLogs = false;
|
||||||
autoScroll = true;
|
autoScroll = true;
|
||||||
@@ -402,6 +405,7 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
|
|
||||||
private readonly destroy$ = new Subject<void>();
|
private readonly destroy$ = new Subject<void>();
|
||||||
private readonly searchSubject = new Subject<string>();
|
private readonly searchSubject = new Subject<string>();
|
||||||
|
protected readonly Object = Object;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private visualizationService: WorkflowVisualizationService,
|
private visualizationService: WorkflowVisualizationService,
|
||||||
@@ -424,12 +428,27 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes['stepData'] && this.stepData) {
|
||||||
|
this.stepDetails = this.stepData;
|
||||||
|
this.loading = false;
|
||||||
|
this.applyLocalLogs();
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (changes['stepId'] && this.stepId) {
|
if (changes['stepId'] && this.stepId) {
|
||||||
this.loadStepDetails();
|
this.loadStepDetails();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadStepDetails(): void {
|
loadStepDetails(): void {
|
||||||
|
if (this.stepData) {
|
||||||
|
this.stepDetails = this.stepData;
|
||||||
|
this.loading = false;
|
||||||
|
this.applyLocalLogs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.stepId) return;
|
if (!this.stepId) return;
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
@@ -437,11 +456,12 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (details) => {
|
next: (details) => {
|
||||||
this.stepDetails = details;
|
const normalized = this.normalizeStepDetails(details);
|
||||||
|
this.stepDetails = normalized;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
||||||
// Auto-switch to logs tab if there are errors
|
// Auto-switch to logs tab if there are errors
|
||||||
if (details.logSummary.errorCount > 0 && this.activeTab === 'overview') {
|
if (normalized.logSummary.errorCount > 0 && this.activeTab === 'overview') {
|
||||||
this.activeTab = 'logs';
|
this.activeTab = 'logs';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +477,11 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadLogs(reset = false): void {
|
loadLogs(reset = false): void {
|
||||||
|
if (this.logsData) {
|
||||||
|
this.applyLocalLogs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.stepId) return;
|
if (!this.stepId) return;
|
||||||
|
|
||||||
if (reset) {
|
if (reset) {
|
||||||
@@ -474,7 +499,8 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.logs = reset ? result.logs as any : [...this.logs, ...result.logs as any];
|
const normalizedLogs = this.normalizeServiceLogs(result.logs as readonly unknown[]);
|
||||||
|
this.logs = reset ? normalizedLogs : [...this.logs, ...normalizedLogs];
|
||||||
this.logPageToken = (result as any).nextPageToken;
|
this.logPageToken = (result as any).nextPageToken;
|
||||||
this.hasMoreLogs = !!(result as any).nextPageToken;
|
this.hasMoreLogs = !!(result as any).nextPageToken;
|
||||||
this.loadingLogs = false;
|
this.loadingLogs = false;
|
||||||
@@ -498,10 +524,19 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onLogFilterChange(): void {
|
onLogFilterChange(): void {
|
||||||
|
if (this.logsData) {
|
||||||
|
this.applyLocalLogs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.loadLogs(true);
|
this.loadLogs(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSearchChange(search: string): void {
|
onSearchChange(search: string): void {
|
||||||
|
if (this.logsData) {
|
||||||
|
this.logFilter.search = search;
|
||||||
|
this.applyLocalLogs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.searchSubject.next(search);
|
this.searchSubject.next(search);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,6 +610,87 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
return new Date(timestamp).toLocaleTimeString();
|
return new Date(timestamp).toLocaleTimeString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyLocalLogs(): void {
|
||||||
|
this.allLogs = [...(this.logsData ?? [])];
|
||||||
|
const level = this.logFilter.level.trim().toLowerCase();
|
||||||
|
const search = this.logFilter.search.trim().toLowerCase();
|
||||||
|
|
||||||
|
this.logs = this.allLogs.filter((entry) => {
|
||||||
|
if (level && entry.level.toLowerCase() !== level) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (search && !entry.message.toLowerCase().includes(search)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
this.hasMoreLogs = false;
|
||||||
|
this.loadingLogs = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeStepDetails(details: ServiceStepDetails): StepDetails {
|
||||||
|
const normalizedLogs = this.normalizeServiceLogs(details.logs as readonly unknown[] | undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runId: this.runId,
|
||||||
|
stepId: details.nodeId,
|
||||||
|
stepName: details.name,
|
||||||
|
stepType: details.description ?? details.name,
|
||||||
|
status: this.normalizeStatus(details.status),
|
||||||
|
inputs: (details.inputs as Record<string, any> | null | undefined) ?? null,
|
||||||
|
outputs: (details.outputs as Record<string, any> | null | undefined) ?? null,
|
||||||
|
inputSources: [],
|
||||||
|
outputConsumers: [],
|
||||||
|
timing: {
|
||||||
|
queuedAt: details.startTime ?? null,
|
||||||
|
startedAt: details.startTime ?? null,
|
||||||
|
completedAt: details.endTime ?? null,
|
||||||
|
queueTime: '00:00:00.000',
|
||||||
|
executionTime: this.formatExecutionDuration(details.duration),
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
dependsOn: [],
|
||||||
|
blocks: [],
|
||||||
|
blockedBy: [],
|
||||||
|
},
|
||||||
|
logSummary: {
|
||||||
|
totalLines: normalizedLogs.length,
|
||||||
|
errorCount: details.error ? 1 : 0,
|
||||||
|
warningCount: normalizedLogs.filter((entry) => entry.level.toLowerCase() === 'warning').length,
|
||||||
|
},
|
||||||
|
error: details.error
|
||||||
|
? {
|
||||||
|
message: details.error.message,
|
||||||
|
type: 'StepFailure',
|
||||||
|
isRetryable: true,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
retryCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeStatus(status: string): string {
|
||||||
|
const normalized = status.toLowerCase();
|
||||||
|
if (normalized.includes('fail') || normalized.includes('error')) return 'Failed';
|
||||||
|
if (normalized.includes('run') || normalized.includes('progress')) return 'Running';
|
||||||
|
if (normalized.includes('skip')) return 'Skipped';
|
||||||
|
if (normalized.includes('cancel')) return 'Cancelled';
|
||||||
|
if (normalized.includes('success') || normalized.includes('complete')) return 'Succeeded';
|
||||||
|
return 'Pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatExecutionDuration(duration?: number): string | null {
|
||||||
|
if (typeof duration !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(duration / 3600000);
|
||||||
|
const minutes = Math.floor((duration % 3600000) / 60000);
|
||||||
|
const seconds = Math.floor((duration % 60000) / 1000);
|
||||||
|
const millis = duration % 1000;
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${millis.toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
getTimeSegmentFlex(segment: 'queue' | 'execution'): number {
|
getTimeSegmentFlex(segment: 'queue' | 'execution'): number {
|
||||||
if (!this.stepDetails?.timing) return 0;
|
if (!this.stepDetails?.timing) return 0;
|
||||||
|
|
||||||
@@ -598,6 +714,47 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeServiceLogs(logs: readonly unknown[] | undefined): LogEntry[] {
|
||||||
|
return [...(logs ?? [])].map((entry, index) => {
|
||||||
|
if (typeof entry === 'string') {
|
||||||
|
return {
|
||||||
|
timestamp: new Date(Date.now() + index).toISOString(),
|
||||||
|
level: this.inferLogLevel(entry),
|
||||||
|
message: entry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry && typeof entry === 'object') {
|
||||||
|
const record = entry as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
timestamp: typeof record['timestamp'] === 'string' ? record['timestamp'] : new Date(Date.now() + index).toISOString(),
|
||||||
|
level: typeof record['level'] === 'string' ? record['level'] : this.inferLogLevel(String(record['message'] ?? '')),
|
||||||
|
message: typeof record['message'] === 'string' ? record['message'] : JSON.stringify(record),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: new Date(Date.now() + index).toISOString(),
|
||||||
|
level: 'info',
|
||||||
|
message: String(entry),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferLogLevel(message: string): string {
|
||||||
|
const normalized = message.toLowerCase();
|
||||||
|
if (normalized.includes('error') || normalized.includes('failed') || normalized.includes('exception')) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
if (normalized.includes('warn') || normalized.includes('degrad')) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
if (normalized.includes('debug') || normalized.includes('trace')) {
|
||||||
|
return 'debug';
|
||||||
|
}
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// Description: Controls for time-travel debugging with playback and timeline
|
// Description: Controls for time-travel debugging with playback and timeline
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, SimpleChanges, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Subject, takeUntil, interval, timer } from 'rxjs';
|
import { Subject, takeUntil, interval, timer } from 'rxjs';
|
||||||
@@ -182,9 +182,12 @@ import { TimeTravelService, DebugSession, SnapshotSummary, SnapshotState } from
|
|||||||
styles: [``],
|
styles: [``],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
export class TimeTravelControlsComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
@Input() runId!: string;
|
@Input() runId!: string;
|
||||||
@Input() sessionId?: string;
|
@Input() sessionId?: string;
|
||||||
|
@Input() sessionData?: DebugSession | null;
|
||||||
|
@Input() snapshotsData?: readonly SnapshotSummary[] | null;
|
||||||
|
@Input() statesByIndex?: Readonly<Record<number, SnapshotState>> | null;
|
||||||
|
|
||||||
@Output() snapshotChanged = new EventEmitter<SnapshotState>();
|
@Output() snapshotChanged = new EventEmitter<SnapshotState>();
|
||||||
@Output() sessionCreated = new EventEmitter<DebugSession>();
|
@Output() sessionCreated = new EventEmitter<DebugSession>();
|
||||||
@@ -213,6 +216,7 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private readonly destroy$ = new Subject<void>();
|
private readonly destroy$ = new Subject<void>();
|
||||||
private playbackInterval$ = new Subject<void>();
|
private playbackInterval$ = new Subject<void>();
|
||||||
|
private readonly keydownHandler = (event: KeyboardEvent) => this.handleKeydown(event);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private timeTravelService: TimeTravelService,
|
private timeTravelService: TimeTravelService,
|
||||||
@@ -220,21 +224,29 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.sessionId) {
|
if (this.hasPreloadedState()) {
|
||||||
|
this.hydrateFromInputs();
|
||||||
|
} else if (this.sessionId) {
|
||||||
this.loadSession(this.sessionId);
|
this.loadSession(this.sessionId);
|
||||||
} else {
|
} else {
|
||||||
this.createSession();
|
this.createSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', this.handleKeydown.bind(this));
|
document.addEventListener('keydown', this.keydownHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if ((changes['sessionData'] || changes['snapshotsData'] || changes['statesByIndex']) && this.hasPreloadedState()) {
|
||||||
|
this.hydrateFromInputs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
this.playbackInterval$.complete();
|
this.playbackInterval$.complete();
|
||||||
document.removeEventListener('keydown', this.handleKeydown.bind(this));
|
document.removeEventListener('keydown', this.keydownHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
get progressPercentage(): number {
|
get progressPercentage(): number {
|
||||||
@@ -283,6 +295,11 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadSnapshots(): void {
|
loadSnapshots(): void {
|
||||||
|
if (this.hasPreloadedState()) {
|
||||||
|
this.hydrateFromInputs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.session) return;
|
if (!this.session) return;
|
||||||
|
|
||||||
this.timeTravelService.getSnapshots(this.session.sessionId)
|
this.timeTravelService.getSnapshots(this.session.sessionId)
|
||||||
@@ -331,6 +348,17 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private navigateTo(index: number): void {
|
private navigateTo(index: number): void {
|
||||||
|
if (this.hasPreloadedState()) {
|
||||||
|
this.currentIndex = Math.max(0, Math.min(index, this.totalSnapshots - 1));
|
||||||
|
this.currentSnapshot = this.snapshots[this.currentIndex] ?? null;
|
||||||
|
this.currentState = this.statesByIndex?.[this.currentIndex] ?? null;
|
||||||
|
if (this.currentState) {
|
||||||
|
this.snapshotChanged.emit(this.currentState);
|
||||||
|
}
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.session || this.loading) return;
|
if (!this.session || this.loading) return;
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
@@ -338,7 +366,7 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (state) => {
|
next: (state) => {
|
||||||
this.currentIndex = state.snapshotIndex;
|
this.currentIndex = state.snapshotIndex ?? index;
|
||||||
this.currentSnapshot = this.snapshots[this.currentIndex];
|
this.currentSnapshot = this.snapshots[this.currentIndex];
|
||||||
this.currentState = state;
|
this.currentState = state;
|
||||||
this.snapshotChanged.emit(state);
|
this.snapshotChanged.emit(state);
|
||||||
@@ -520,4 +548,22 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
|||||||
onShowDiffChange(): void {
|
onShowDiffChange(): void {
|
||||||
// Could load diff data if not already loaded
|
// Could load diff data if not already loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasPreloadedState(): boolean {
|
||||||
|
return !!this.sessionData || !!this.snapshotsData?.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hydrateFromInputs(): void {
|
||||||
|
this.session = this.sessionData ?? null;
|
||||||
|
this.snapshots = [...(this.snapshotsData ?? [])];
|
||||||
|
this.totalSnapshots = this.snapshots.length;
|
||||||
|
const requestedIndex = this.session?.currentSnapshotIndex ?? Math.max(0, this.totalSnapshots - 1);
|
||||||
|
this.currentIndex = Math.max(0, Math.min(requestedIndex, Math.max(0, this.totalSnapshots - 1)));
|
||||||
|
this.currentSnapshot = this.snapshots[this.currentIndex] ?? this.snapshots.at(-1) ?? null;
|
||||||
|
this.currentState = this.statesByIndex?.[this.currentIndex] ?? null;
|
||||||
|
this.startTime = this.snapshots[0] ? new Date(this.snapshots[0].timestamp) : null;
|
||||||
|
this.endTime = this.snapshots.at(-1) ? new Date(this.snapshots.at(-1)!.timestamp) : null;
|
||||||
|
this.loading = false;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// Description: React Flow based workflow DAG visualization component
|
// Description: React Flow based workflow DAG visualization component
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, SimpleChanges, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Subject, takeUntil, interval, switchMap, filter } from 'rxjs';
|
import { Subject, takeUntil, interval, switchMap, filter } from 'rxjs';
|
||||||
@@ -134,18 +134,18 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
|||||||
@for (edge of edges; track edge.id) {
|
@for (edge of edges; track edge.id) {
|
||||||
<g class="edge"
|
<g class="edge"
|
||||||
[class.critical]="showCriticalPath && criticalPathEdges.has(edge.id)"
|
[class.critical]="showCriticalPath && criticalPathEdges.has(edge.id)"
|
||||||
[class.animated]="edge.isAnimated">
|
[class.animated]="edge.animated">
|
||||||
<path
|
<path
|
||||||
[attr.d]="getEdgePath(edge)"
|
[attr.d]="getEdgePath(edge)"
|
||||||
[attr.stroke]="getEdgeColor(edge)"
|
[attr.stroke]="getEdgeColor(edge)"
|
||||||
[attr.stroke-width]="getEdgeWidth(edge)"
|
[attr.stroke-width]="getEdgeWidth(edge)"
|
||||||
[attr.marker-end]="edge.isAnimated ? 'url(#arrowhead-animated)' : 'url(#arrowhead)'"
|
[attr.marker-end]="edge.animated ? 'url(#arrowhead-animated)' : 'url(#arrowhead)'"
|
||||||
fill="none"
|
fill="none"
|
||||||
class="edge-path">
|
class="edge-path">
|
||||||
</path>
|
</path>
|
||||||
|
|
||||||
<!-- Animated dots for running edges -->
|
<!-- Animated dots for running edges -->
|
||||||
@if (edge.isAnimated) {
|
@if (edge.animated) {
|
||||||
<circle r="4" [attr.fill]="'var(--color-running)'">
|
<circle r="4" [attr.fill]="'var(--color-running)'">
|
||||||
<animateMotion
|
<animateMotion
|
||||||
[attr.path]="getEdgePath(edge)"
|
[attr.path]="getEdgePath(edge)"
|
||||||
@@ -162,7 +162,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
|||||||
@for (node of nodes; track node.id) {
|
@for (node of nodes; track node.id) {
|
||||||
<g
|
<g
|
||||||
class="node"
|
class="node"
|
||||||
[class]="'node-' + node.status.toLowerCase()"
|
[class]="'node-' + nodeStatus(node)"
|
||||||
[class.selected]="selectedNodeId === node.id"
|
[class.selected]="selectedNodeId === node.id"
|
||||||
[class.critical]="showCriticalPath && criticalPathNodes.has(node.id)"
|
[class.critical]="showCriticalPath && criticalPathNodes.has(node.id)"
|
||||||
[attr.transform]="getNodeTransform(node)"
|
[attr.transform]="getNodeTransform(node)"
|
||||||
@@ -183,7 +183,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
|||||||
|
|
||||||
<!-- Status icon -->
|
<!-- Status icon -->
|
||||||
<g [attr.transform]="'translate(12, ' + (nodeHeight / 2) + ')'">
|
<g [attr.transform]="'translate(12, ' + (nodeHeight / 2) + ')'">
|
||||||
@switch (node.status) {
|
@switch (displayStatus(node)) {
|
||||||
@case ('Running') {
|
@case ('Running') {
|
||||||
<circle r="6" fill="var(--color-running)" class="pulse" />
|
<circle r="6" fill="var(--color-running)" class="pulse" />
|
||||||
}
|
}
|
||||||
@@ -220,7 +220,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
|||||||
</text>
|
</text>
|
||||||
|
|
||||||
<!-- Duration badge (if completed) -->
|
<!-- Duration badge (if completed) -->
|
||||||
@if (node.data?.['duration']) {
|
@if (node.duration) {
|
||||||
<g [attr.transform]="'translate(' + (nodeWidth - 8) + ', 8)'">
|
<g [attr.transform]="'translate(' + (nodeWidth - 8) + ', 8)'">
|
||||||
<rect
|
<rect
|
||||||
x="-24" y="-8"
|
x="-24" y="-8"
|
||||||
@@ -229,7 +229,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
|||||||
fill="var(--color-badge-bg)"
|
fill="var(--color-badge-bg)"
|
||||||
class="duration-badge" />
|
class="duration-badge" />
|
||||||
<text x="-8" y="4" text-anchor="middle" font-size="9" fill="var(--color-badge-text)">
|
<text x="-8" y="4" text-anchor="middle" font-size="9" fill="var(--color-badge-text)">
|
||||||
{{ formatDuration(node.data?.['duration']) }}
|
{{ formatDuration(node.duration) }}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
}
|
}
|
||||||
@@ -310,14 +310,16 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
|||||||
styleUrls: ['./workflow-visualizer.component.scss'],
|
styleUrls: ['./workflow-visualizer.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
export class WorkflowVisualizerComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
@ViewChild('canvasContainer') canvasContainer!: ElementRef<HTMLDivElement>;
|
@ViewChild('canvasContainer') canvasContainer!: ElementRef<HTMLDivElement>;
|
||||||
@ViewChild('svgCanvas') svgCanvas!: ElementRef<SVGElement>;
|
@ViewChild('svgCanvas') svgCanvas!: ElementRef<SVGElement>;
|
||||||
|
|
||||||
@Input() runId!: string;
|
@Input() runId!: string;
|
||||||
|
@Input() graph: WorkflowGraph | null = null;
|
||||||
@Input() showMinimap = true;
|
@Input() showMinimap = true;
|
||||||
@Input() autoRefresh = true;
|
@Input() autoRefresh = true;
|
||||||
@Input() refreshInterval = 2000;
|
@Input() refreshInterval = 2000;
|
||||||
|
@Input() previewMode = false;
|
||||||
|
|
||||||
@Output() nodeSelected = new EventEmitter<GraphNode>();
|
@Output() nodeSelected = new EventEmitter<GraphNode>();
|
||||||
@Output() nodeDoubleClicked = new EventEmitter<GraphNode>();
|
@Output() nodeDoubleClicked = new EventEmitter<GraphNode>();
|
||||||
@@ -369,9 +371,14 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
|||||||
constructor(private visualizationService: WorkflowVisualizationService) {}
|
constructor(private visualizationService: WorkflowVisualizationService) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
if (this.graph) {
|
||||||
|
this.setGraph(this.graph);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.loadGraph();
|
this.loadGraph();
|
||||||
|
|
||||||
if (this.autoRefresh) {
|
if (this.autoRefresh && !this.previewMode && !this.graph) {
|
||||||
interval(this.refreshInterval)
|
interval(this.refreshInterval)
|
||||||
.pipe(
|
.pipe(
|
||||||
takeUntil(this.destroy$),
|
takeUntil(this.destroy$),
|
||||||
@@ -385,6 +392,12 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes['graph'] && this.graph) {
|
||||||
|
this.setGraph(this.graph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
@@ -397,6 +410,11 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadGraph(): void {
|
loadGraph(): void {
|
||||||
|
if (this.graph) {
|
||||||
|
this.setGraph(this.graph);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
@@ -404,12 +422,7 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (graph) => {
|
next: (graph) => {
|
||||||
this.nodes = graph.nodes;
|
this.setGraph(graph);
|
||||||
this.edges = graph.edges;
|
|
||||||
this.positions.clear();
|
|
||||||
graph.positions.forEach(p => this.positions.set(p.nodeId, p));
|
|
||||||
this.loading = false;
|
|
||||||
this.graphLoaded.emit(graph);
|
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.error = err.message || 'Failed to load workflow graph';
|
this.error = err.message || 'Failed to load workflow graph';
|
||||||
@@ -487,6 +500,9 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
onLayoutChange(): void {
|
onLayoutChange(): void {
|
||||||
|
if (this.graph) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.loadGraph();
|
this.loadGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,9 +536,44 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
retry(): void {
|
retry(): void {
|
||||||
|
if (this.graph) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.loadGraph();
|
this.loadGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setGraph(graph: WorkflowGraph): void {
|
||||||
|
this.nodes = [...graph.nodes];
|
||||||
|
this.edges = [...graph.edges];
|
||||||
|
this.positions.clear();
|
||||||
|
graph.positions.forEach((position) => this.positions.set(position.nodeId, position));
|
||||||
|
this.loading = false;
|
||||||
|
this.error = null;
|
||||||
|
this.graphLoaded.emit(graph);
|
||||||
|
}
|
||||||
|
|
||||||
|
displayStatus(node: GraphNode): 'Pending' | 'Running' | 'Succeeded' | 'Failed' | 'Skipped' | 'Cancelled' {
|
||||||
|
switch (this.nodeStatus(node)) {
|
||||||
|
case 'running':
|
||||||
|
return 'Running';
|
||||||
|
case 'succeeded':
|
||||||
|
return 'Succeeded';
|
||||||
|
case 'failed':
|
||||||
|
return 'Failed';
|
||||||
|
case 'skipped':
|
||||||
|
return 'Skipped';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'Cancelled';
|
||||||
|
case 'pending':
|
||||||
|
default:
|
||||||
|
return 'Pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeStatus(node: GraphNode): GraphNode['status'] {
|
||||||
|
return (node.status ?? 'pending').toLowerCase() as GraphNode['status'];
|
||||||
|
}
|
||||||
|
|
||||||
// Rendering helpers
|
// Rendering helpers
|
||||||
getNodeTransform(node: GraphNode): string {
|
getNodeTransform(node: GraphNode): string {
|
||||||
const pos = this.positions.get(node.id);
|
const pos = this.positions.get(node.id);
|
||||||
@@ -563,32 +614,33 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
getNodeFill(node: GraphNode): string {
|
getNodeFill(node: GraphNode): string {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
'Pending': 'var(--color-pending-bg)',
|
pending: 'var(--color-pending-bg)',
|
||||||
'Queued': 'var(--color-queued-bg)',
|
queued: 'var(--color-queued-bg)',
|
||||||
'Running': 'var(--color-running-bg)',
|
running: 'var(--color-running-bg)',
|
||||||
'Succeeded': 'var(--color-success-bg)',
|
succeeded: 'var(--color-success-bg)',
|
||||||
'Failed': 'var(--color-error-bg)',
|
failed: 'var(--color-error-bg)',
|
||||||
'Skipped': 'var(--color-skipped-bg)',
|
skipped: 'var(--color-skipped-bg)',
|
||||||
'Cancelled': 'var(--color-cancelled-bg)'
|
cancelled: 'var(--color-cancelled-bg)'
|
||||||
};
|
};
|
||||||
return colors[node.status] || 'var(--color-default-bg)';
|
return colors[this.nodeStatus(node)] || 'var(--color-default-bg)';
|
||||||
}
|
}
|
||||||
|
|
||||||
getNodeStroke(node: GraphNode): string {
|
getNodeStroke(node: GraphNode): string {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
'Pending': 'var(--color-pending-stroke)',
|
pending: 'var(--color-pending-stroke)',
|
||||||
'Queued': 'var(--color-queued-stroke)',
|
queued: 'var(--color-queued-stroke)',
|
||||||
'Running': 'var(--color-running)',
|
running: 'var(--color-running)',
|
||||||
'Succeeded': 'var(--color-success)',
|
succeeded: 'var(--color-success)',
|
||||||
'Failed': 'var(--color-error)',
|
failed: 'var(--color-error)',
|
||||||
'Skipped': 'var(--color-skipped-stroke)',
|
skipped: 'var(--color-skipped-stroke)',
|
||||||
'Cancelled': 'var(--color-cancelled-stroke)'
|
cancelled: 'var(--color-cancelled-stroke)'
|
||||||
};
|
};
|
||||||
return colors[node.status] || 'var(--color-default-stroke)';
|
return colors[this.nodeStatus(node)] || 'var(--color-default-stroke)';
|
||||||
}
|
}
|
||||||
|
|
||||||
getNodeTextColor(node: GraphNode): string {
|
getNodeTextColor(node: GraphNode): string {
|
||||||
return node.status === 'failed' || node.status === 'running'
|
const status = this.nodeStatus(node);
|
||||||
|
return status === 'failed' || status === 'running'
|
||||||
? 'var(--color-text-light)'
|
? 'var(--color-text-light)'
|
||||||
: 'var(--color-text)';
|
: 'var(--color-text)';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<section class="run-workspace" data-testid="run-graph-replay-page">
|
||||||
|
<header class="run-workspace__header">
|
||||||
|
<div>
|
||||||
|
<a routerLink="/releases/runs" class="run-workspace__back-link">Back to release runs</a>
|
||||||
|
<h1>{{ context()?.detail?.releaseName || 'Release Run' }}</h1>
|
||||||
|
<p class="run-workspace__subtitle">
|
||||||
|
Runtime graphing, replay, and evidence for
|
||||||
|
<code>{{ runId() }}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (context(); as context) {
|
||||||
|
<div class="run-workspace__meta">
|
||||||
|
<span class="chip">{{ context.detail.releaseType }}</span>
|
||||||
|
<span class="chip">{{ context.detail.status }}</span>
|
||||||
|
<span class="chip">{{ context.detail.outcome }}</span>
|
||||||
|
<span class="chip">{{ context.detail.targetRegion || 'global' }}/{{ context.detail.targetEnvironment || 'all' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (returnTo()) {
|
||||||
|
<div class="return-banner">
|
||||||
|
<span>Opened from another operator flow.</span>
|
||||||
|
<a [routerLink]="returnTo()">Return to previous context</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<nav class="run-tabs" aria-label="Run graph workspace tabs">
|
||||||
|
@for (tab of tabs; track tab.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="run-tab"
|
||||||
|
[class.run-tab--active]="activeTab() === tab.id"
|
||||||
|
[attr.data-testid]="'run-workspace-tab-' + tab.id"
|
||||||
|
(click)="setTab(tab.id)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="state-banner">Loading run graph and replay context...</div>
|
||||||
|
} @else if (error()) {
|
||||||
|
<div class="state-banner state-banner--error">{{ error() }}</div>
|
||||||
|
} @else if (context(); as context) {
|
||||||
|
<div class="run-workspace__layout" [class.run-workspace__layout--with-step]="!!selectedStep()">
|
||||||
|
<main class="run-workspace__main">
|
||||||
|
@switch (activeTab()) {
|
||||||
|
@case ('summary') {
|
||||||
|
<section class="summary-grid">
|
||||||
|
<article class="summary-card">
|
||||||
|
<h2>Gate posture</h2>
|
||||||
|
<p class="summary-value">{{ context.gateDecision?.verdict || context.detail.statusRow.gateStatus }}</p>
|
||||||
|
<p class="summary-help">{{ context.gateDecision?.blockers?.join(', ') || 'No blocking gate reasons reported.' }}</p>
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="openLegacyRunTab('gate-decision')">Open gate details</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="summary-card">
|
||||||
|
<h2>Approvals</h2>
|
||||||
|
<p class="summary-value">{{ context.approvals?.checkpoints?.length || 0 }}</p>
|
||||||
|
<p class="summary-help">
|
||||||
|
{{ context.detail.needsApproval ? 'This run still depends on approval checkpoints.' : 'No approval checkpoints are required for this run.' }}
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="openLegacyRunTab('approvals')">Open approvals</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="summary-card">
|
||||||
|
<h2>Deployment targets</h2>
|
||||||
|
<p class="summary-value">{{ context.deployments?.targets?.length || 0 }}</p>
|
||||||
|
<p class="summary-help">Track deployment-state detail in the legacy deployment workbench when needed.</p>
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="openLegacyRunTab('deployments')">Open deployments</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="summary-card">
|
||||||
|
<h2>Replay determinism</h2>
|
||||||
|
<p class="summary-value">{{ context.replay?.verdict || context.evidence?.replayDeterminismVerdict || 'unknown' }}</p>
|
||||||
|
<p class="summary-help">Replay and evidence tabs use the same run-scoped context.</p>
|
||||||
|
<button type="button" class="btn btn--primary" (click)="openReplayWorkspace()">Open replay</button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-strip">
|
||||||
|
<article>
|
||||||
|
<h3>Security inputs</h3>
|
||||||
|
<p>Coverage: {{ context.securityInputs?.reachabilityCoveragePercent ?? 'n/a' }}%</p>
|
||||||
|
<p>Feed freshness: {{ context.securityInputs?.feedFreshnessStatus ?? 'unknown' }}</p>
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="openLegacyRunTab('security-inputs')">Open security inputs</button>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Evidence posture</h3>
|
||||||
|
<p>Signature: {{ context.evidence?.signatureStatus ?? 'unknown' }}</p>
|
||||||
|
<p>Mismatch: {{ context.evidence?.replayMismatch ? 'yes' : 'no' }}</p>
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="setTab('evidence')">Open evidence tab</button>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Critical path</h3>
|
||||||
|
<p>{{ context.criticalPath.path.join(' -> ') }}</p>
|
||||||
|
<p>Total runtime: {{ formatDuration(context.criticalPath.totalDuration) }}</p>
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="setTab('critical-path')">Inspect critical path</button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('graph') {
|
||||||
|
<section class="graph-toolbar">
|
||||||
|
<div class="filter-group">
|
||||||
|
<button type="button" class="btn btn--secondary" [class.is-active]="graphFilter() === 'all'" (click)="graphFilter.set('all')">All steps</button>
|
||||||
|
<button type="button" class="btn btn--secondary" [class.is-active]="graphFilter() === 'failed'" (click)="graphFilter.set('failed')">Failed only</button>
|
||||||
|
<button type="button" class="btn btn--secondary" [class.is-active]="graphFilter() === 'active'" (click)="graphFilter.set('active')">Active only</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn--secondary" [class.is-active]="criticalPathOnly()" (click)="toggleCriticalPath()">Critical path only</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-workflow-visualizer
|
||||||
|
[runId]="runId()"
|
||||||
|
[graph]="filteredGraph()"
|
||||||
|
[autoRefresh]="false"
|
||||||
|
(nodeSelected)="openStep($event.id)"
|
||||||
|
(nodeDoubleClicked)="openStep($event.id)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('timeline') {
|
||||||
|
<section class="timeline-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Phase</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of context.timeline; track row.eventId) {
|
||||||
|
<tr (click)="onTimelineRowSelected(row)" [attr.data-testid]="'run-timeline-row-' + row.eventId">
|
||||||
|
<td>{{ formatWhen(row.occurredAt) }}</td>
|
||||||
|
<td>{{ row.phaseLabel }}</td>
|
||||||
|
<td>{{ row.status }}</td>
|
||||||
|
<td>{{ row.message }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="4">No timeline events were returned for this run.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('critical-path') {
|
||||||
|
<section class="critical-path-panel">
|
||||||
|
<header>
|
||||||
|
<h2>Critical path</h2>
|
||||||
|
<p>{{ context.criticalPath.path.join(' -> ') }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="critical-path-cards">
|
||||||
|
@for (node of criticalPathNodes(); track node.id) {
|
||||||
|
<article class="critical-card" (click)="openStep(node.id)" [attr.data-testid]="'critical-path-node-' + node.id">
|
||||||
|
<h3>{{ node.label }}</h3>
|
||||||
|
<p>Status: {{ node.status }}</p>
|
||||||
|
<p>Duration: {{ formatDuration(node.duration) }}</p>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="setTab('graph')">Open graph view</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('replay') {
|
||||||
|
<section class="replay-grid">
|
||||||
|
<article class="summary-card">
|
||||||
|
<h2>Replay verdict</h2>
|
||||||
|
<p class="summary-value">{{ context.replay?.verdict || 'unknown' }}</p>
|
||||||
|
<p class="summary-help">Run-scoped replay and debug snapshots stay attached to this release run.</p>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card">
|
||||||
|
<h2>Signature status</h2>
|
||||||
|
<p class="summary-value">{{ context.evidence?.signatureStatus || 'unknown' }}</p>
|
||||||
|
<p class="summary-help">Mismatch: {{ context.evidence?.replayMismatch ? 'yes' : 'no' }}</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-time-travel-controls
|
||||||
|
[runId]="runId()"
|
||||||
|
[sessionData]="context.debugSession"
|
||||||
|
[snapshotsData]="context.snapshots"
|
||||||
|
[statesByIndex]="context.snapshotStates"
|
||||||
|
(snapshotChanged)="onSnapshotChanged($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="replay-actions">
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="openEvidenceEntry()">Open evidence entry</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('evidence') {
|
||||||
|
<section class="evidence-grid">
|
||||||
|
<article class="summary-card">
|
||||||
|
<h2>Determinism</h2>
|
||||||
|
<p class="summary-value">{{ context.evidence?.replayDeterminismVerdict || context.replay?.verdict || 'unknown' }}</p>
|
||||||
|
<p class="summary-help">Review signed replay posture and drift inside the run shell, then use the evidence page for exports.</p>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card">
|
||||||
|
<h2>Audit entries</h2>
|
||||||
|
<p class="summary-value">{{ context.audit?.entries?.length || 0 }}</p>
|
||||||
|
<p class="summary-help">Run audit events stay linked to the same correlation key.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="audit-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Actor</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Correlation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (entry of context.audit?.entries || []; track entry.auditId) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ formatWhen(entry.occurredAt) }}</td>
|
||||||
|
<td>{{ entry.actorId }}</td>
|
||||||
|
<td>{{ entry.action }}</td>
|
||||||
|
<td>{{ entry.correlationKey }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="4">No audit entries are available for this run.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-replay-controls />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
@if (selectedStep()) {
|
||||||
|
<aside class="step-drawer" data-testid="run-step-drawer">
|
||||||
|
<div class="step-drawer__header">
|
||||||
|
<h2>Step detail</h2>
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="closeStep()">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-step-detail-panel
|
||||||
|
[runId]="runId()"
|
||||||
|
[stepId]="selectedStepId() ?? undefined"
|
||||||
|
[stepData]="selectedStep()"
|
||||||
|
[logsData]="selectedStepLogs()"
|
||||||
|
(stepSelected)="openStep($event)"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-workspace {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-workspace__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-workspace__header h1 {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-workspace__subtitle {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-workspace__back-link {
|
||||||
|
color: var(--color-status-info-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-workspace__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return-banner,
|
||||||
|
.state-banner {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return-banner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-banner--error {
|
||||||
|
border-color: var(--color-status-error-border);
|
||||||
|
background: var(--color-status-error-bg);
|
||||||
|
color: var(--color-status-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-tab {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-tab--active {
|
||||||
|
border-color: var(--color-brand-primary);
|
||||||
|
color: var(--color-brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-workspace__layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-workspace__layout--with-step {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid,
|
||||||
|
.replay-grid,
|
||||||
|
.evidence-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card,
|
||||||
|
.detail-strip article,
|
||||||
|
.critical-card,
|
||||||
|
.timeline-table,
|
||||||
|
.critical-path-panel,
|
||||||
|
.audit-table,
|
||||||
|
.step-drawer,
|
||||||
|
.graph-toolbar,
|
||||||
|
.replay-actions {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card,
|
||||||
|
.detail-strip article,
|
||||||
|
.critical-card,
|
||||||
|
.critical-path-panel,
|
||||||
|
.replay-actions,
|
||||||
|
.step-drawer {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-help {
|
||||||
|
margin: 0 0 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-table table,
|
||||||
|
.audit-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-table th,
|
||||||
|
.timeline-table td,
|
||||||
|
.audit-table th,
|
||||||
|
.audit-table td {
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-table tbody tr,
|
||||||
|
.critical-card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-table tbody tr:hover,
|
||||||
|
.critical-card:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-brand-primary) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-path-panel header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-path-cards {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-drawer {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-drawer__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-drawer__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
border-color: var(--color-brand-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-brand-primary) 14%, var(--color-surface-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary.is-active {
|
||||||
|
border-color: var(--color-brand-primary);
|
||||||
|
color: var(--color-brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.run-workspace__layout--with-step {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-drawer {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { ReplayControlsComponent } from '../evidence-export/replay-controls.component';
|
||||||
|
import { StepDetailPanelComponent } from './components/step-detail-panel/step-detail-panel.component';
|
||||||
|
import { TimeTravelControlsComponent } from './components/time-travel-controls/time-travel-controls.component';
|
||||||
|
import { WorkflowVisualizerComponent } from './components/workflow-visualizer/workflow-visualizer.component';
|
||||||
|
import {
|
||||||
|
RunVisualizationShellService,
|
||||||
|
type RunTimelineRow,
|
||||||
|
type RunVisualizationContext,
|
||||||
|
type RunWorkspaceTab,
|
||||||
|
} from './services/run-visualization-shell.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-run-graph-replay-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterLink,
|
||||||
|
WorkflowVisualizerComponent,
|
||||||
|
StepDetailPanelComponent,
|
||||||
|
TimeTravelControlsComponent,
|
||||||
|
ReplayControlsComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './run-graph-replay-page.component.html',
|
||||||
|
styleUrl: './run-graph-replay-page.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class RunGraphReplayPageComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly shellService = inject(RunVisualizationShellService);
|
||||||
|
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
readonly runId = signal('');
|
||||||
|
readonly activeTab = signal<RunWorkspaceTab>('summary');
|
||||||
|
readonly selectedStepId = signal<string | null>(null);
|
||||||
|
readonly context = signal<RunVisualizationContext | null>(null);
|
||||||
|
|
||||||
|
readonly graphFilter = signal<'all' | 'failed' | 'active'>('all');
|
||||||
|
readonly criticalPathOnly = signal(false);
|
||||||
|
readonly returnTo = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly tabs: readonly { id: RunWorkspaceTab; label: string }[] = [
|
||||||
|
{ id: 'summary', label: 'Summary' },
|
||||||
|
{ id: 'graph', label: 'Graph' },
|
||||||
|
{ id: 'timeline', label: 'Timeline' },
|
||||||
|
{ id: 'critical-path', label: 'Critical Path' },
|
||||||
|
{ id: 'replay', label: 'Replay' },
|
||||||
|
{ id: 'evidence', label: 'Evidence' },
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly filteredGraph = computed(() => {
|
||||||
|
const context = this.context();
|
||||||
|
if (!context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedIds = new Set(context.graph.nodes.map((node) => node.id));
|
||||||
|
|
||||||
|
if (this.criticalPathOnly()) {
|
||||||
|
allowedIds.clear();
|
||||||
|
context.criticalPath.path.forEach((id) => allowedIds.add(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = this.graphFilter();
|
||||||
|
if (filter !== 'all') {
|
||||||
|
for (const node of context.graph.nodes) {
|
||||||
|
const isActive = node.status === 'running' || node.status === 'pending';
|
||||||
|
const keep = filter === 'failed' ? node.status === 'failed' : isActive;
|
||||||
|
if (!keep) {
|
||||||
|
allowedIds.delete(node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...context.graph,
|
||||||
|
nodes: context.graph.nodes.filter((node) => allowedIds.has(node.id)),
|
||||||
|
edges: context.graph.edges.filter((edge) => allowedIds.has(edge.source) && allowedIds.has(edge.target)),
|
||||||
|
positions: context.graph.positions.filter((position) => allowedIds.has(position.nodeId)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly selectedStep = computed(() => {
|
||||||
|
const context = this.context();
|
||||||
|
const stepId = this.selectedStepId();
|
||||||
|
if (!context || !stepId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.stepDetails[stepId] ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly selectedStepLogs = computed(() => {
|
||||||
|
const context = this.context();
|
||||||
|
const stepId = this.selectedStepId();
|
||||||
|
if (!context || !stepId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.stepLogs[stepId] ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly criticalPathNodes = computed(() => {
|
||||||
|
const context = this.context();
|
||||||
|
if (!context) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map(context.graph.nodes.map((node) => [node.id, node]));
|
||||||
|
return context.criticalPath.path
|
||||||
|
.map((id) => byId.get(id))
|
||||||
|
.filter((node): node is NonNullable<typeof node> => !!node);
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||||
|
const nextRunId = params.get('runId') ?? '';
|
||||||
|
if (nextRunId && nextRunId !== this.runId()) {
|
||||||
|
this.runId.set(nextRunId);
|
||||||
|
this.load(nextRunId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.route.data.pipe(takeUntilDestroyed()).subscribe((data) => {
|
||||||
|
const tabFromData = typeof data['tab'] === 'string' ? data['tab'] : this.route.snapshot.paramMap.get('tab');
|
||||||
|
this.activeTab.set(this.normalizeTab(tabFromData));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||||
|
this.returnTo.set(params.get('returnTo'));
|
||||||
|
this.selectedStepId.set(params.get('step'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTab(tab: RunWorkspaceTab): void {
|
||||||
|
void this.router.navigate(['/releases/runs', this.runId(), tab], {
|
||||||
|
queryParams: {
|
||||||
|
step: this.selectedStepId(),
|
||||||
|
returnTo: this.returnTo(),
|
||||||
|
},
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openStep(stepId: string): void {
|
||||||
|
this.selectedStepId.set(stepId);
|
||||||
|
void this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { step: stepId },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeStep(): void {
|
||||||
|
this.selectedStepId.set(null);
|
||||||
|
void this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { step: null },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCriticalPath(): void {
|
||||||
|
this.criticalPathOnly.update((value) => !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
openLegacyRunTab(tab: 'gate-decision' | 'deployments' | 'security-inputs' | 'approvals'): void {
|
||||||
|
void this.router.navigate(['/releases/runs', this.runId(), tab]);
|
||||||
|
}
|
||||||
|
|
||||||
|
openEvidenceEntry(): void {
|
||||||
|
const context = this.context();
|
||||||
|
void this.router.navigate(['/evidence/verify-replay'], {
|
||||||
|
queryParams: {
|
||||||
|
releaseId: context?.detail.releaseId,
|
||||||
|
runId: this.runId(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openReplayWorkspace(): void {
|
||||||
|
this.setTab('replay');
|
||||||
|
}
|
||||||
|
|
||||||
|
onTimelineRowSelected(row: RunTimelineRow): void {
|
||||||
|
this.openStep(row.phaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSnapshotChanged(state: { stepId?: string | null }): void {
|
||||||
|
if (typeof state.stepId === 'string' && state.stepId.length > 0) {
|
||||||
|
this.openStep(state.stepId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatWhen(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return 'n/a';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDuration(durationMs: number | null | undefined): string {
|
||||||
|
if (!durationMs || durationMs <= 0) {
|
||||||
|
return '0s';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationMs < 1000) {
|
||||||
|
return `${durationMs}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationMs < 60000) {
|
||||||
|
return `${(durationMs / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.floor(durationMs / 60000)}m ${Math.floor((durationMs % 60000) / 1000)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(runId: string): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
|
||||||
|
this.shellService.loadContext(runId).pipe(takeUntilDestroyed()).subscribe({
|
||||||
|
next: (context) => {
|
||||||
|
this.context.set(context);
|
||||||
|
if (!this.selectedStepId() && context.criticalPath.path.length > 0 && this.activeTab() === 'critical-path') {
|
||||||
|
this.selectedStepId.set(context.criticalPath.path[0]);
|
||||||
|
}
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (err: unknown) => {
|
||||||
|
this.error.set(err instanceof Error ? err.message : 'Failed to load run graph and replay context.');
|
||||||
|
this.context.set(null);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeTab(value: string | null): RunWorkspaceTab {
|
||||||
|
switch ((value ?? '').toLowerCase()) {
|
||||||
|
case 'graph':
|
||||||
|
case 'timeline':
|
||||||
|
case 'critical-path':
|
||||||
|
case 'replay':
|
||||||
|
case 'evidence':
|
||||||
|
return value!.toLowerCase() as RunWorkspaceTab;
|
||||||
|
case 'summary':
|
||||||
|
default:
|
||||||
|
return 'summary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { catchError, forkJoin, map, of, type Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import type { DebugSession, SnapshotState, SnapshotSummary } from './time-travel.service';
|
||||||
|
import {
|
||||||
|
WorkflowVisualizationService,
|
||||||
|
type CriticalPathResult,
|
||||||
|
type GraphNode,
|
||||||
|
type NodePosition,
|
||||||
|
type WorkflowGraph,
|
||||||
|
} from './workflow-visualization.service';
|
||||||
|
|
||||||
|
export type RunWorkspaceTab = 'summary' | 'graph' | 'timeline' | 'critical-path' | 'replay' | 'evidence';
|
||||||
|
|
||||||
|
interface ReleaseRunDetailProjectionDto {
|
||||||
|
runId: string;
|
||||||
|
releaseId: string;
|
||||||
|
releaseName: string;
|
||||||
|
releaseSlug: string;
|
||||||
|
releaseType: string;
|
||||||
|
releaseVersionId: string;
|
||||||
|
releaseVersionNumber: number;
|
||||||
|
releaseVersionDigest: string;
|
||||||
|
lane: string;
|
||||||
|
status: string;
|
||||||
|
outcome: string;
|
||||||
|
targetEnvironment?: string | null;
|
||||||
|
targetRegion?: string | null;
|
||||||
|
scopeSummary: string;
|
||||||
|
requestedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
needsApproval: boolean;
|
||||||
|
blockedByDataIntegrity: boolean;
|
||||||
|
correlationKey: string;
|
||||||
|
statusRow?: {
|
||||||
|
runStatus: string;
|
||||||
|
gateStatus: string;
|
||||||
|
approvalStatus: string;
|
||||||
|
dataTrustStatus: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseRunTimelineProjectionDto {
|
||||||
|
runId: string;
|
||||||
|
events: Array<{
|
||||||
|
eventId: string;
|
||||||
|
eventClass: string;
|
||||||
|
phase: string;
|
||||||
|
status: string;
|
||||||
|
occurredAt: string;
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseRunGateDecisionProjectionDto {
|
||||||
|
runId: string;
|
||||||
|
verdict: string;
|
||||||
|
blockers: string[];
|
||||||
|
riskBudgetDelta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseRunApprovalsProjectionDto {
|
||||||
|
runId: string;
|
||||||
|
checkpoints: Array<{
|
||||||
|
checkpointId: string;
|
||||||
|
status: string;
|
||||||
|
approvedAt?: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseRunDeploymentsProjectionDto {
|
||||||
|
runId: string;
|
||||||
|
targets: Array<{
|
||||||
|
targetId: string;
|
||||||
|
targetName: string;
|
||||||
|
environment: string;
|
||||||
|
region: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseRunSecurityInputsProjectionDto {
|
||||||
|
runId: string;
|
||||||
|
reachabilityCoveragePercent: number;
|
||||||
|
feedFreshnessStatus: string;
|
||||||
|
vexStatementsApplied: number;
|
||||||
|
exceptionsApplied: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseRunEvidenceProjectionDto {
|
||||||
|
runId: string;
|
||||||
|
replayDeterminismVerdict: string;
|
||||||
|
replayMismatch: boolean;
|
||||||
|
signatureStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseRunReplayProjectionDto {
|
||||||
|
runId: string;
|
||||||
|
verdict: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseRunAuditProjectionDto {
|
||||||
|
runId: string;
|
||||||
|
entries: Array<{
|
||||||
|
auditId: string;
|
||||||
|
action: string;
|
||||||
|
actorId: string;
|
||||||
|
occurredAt: string;
|
||||||
|
correlationKey: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunTimelineRow {
|
||||||
|
readonly eventId: string;
|
||||||
|
readonly phaseId: string;
|
||||||
|
readonly phaseLabel: string;
|
||||||
|
readonly eventClass: string;
|
||||||
|
readonly status: string;
|
||||||
|
readonly occurredAt: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunStepLogEntry {
|
||||||
|
readonly timestamp: string;
|
||||||
|
readonly level: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunStepPanelData {
|
||||||
|
readonly runId: string;
|
||||||
|
readonly stepId: string;
|
||||||
|
readonly stepName: string;
|
||||||
|
readonly stepType: string;
|
||||||
|
readonly status: string;
|
||||||
|
readonly inputs: Record<string, unknown> | null;
|
||||||
|
readonly outputs: Record<string, unknown> | null;
|
||||||
|
readonly inputSources: Array<{ inputKey: string; sourceStepId: string; sourceOutputKey?: string }>;
|
||||||
|
readonly outputConsumers: Array<{ outputKey: string; consumerStepId: string }>;
|
||||||
|
readonly timing: {
|
||||||
|
queuedAt: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
queueTime: string | null;
|
||||||
|
executionTime: string | null;
|
||||||
|
};
|
||||||
|
readonly dependencies: {
|
||||||
|
dependsOn: string[];
|
||||||
|
blocks: string[];
|
||||||
|
blockedBy: string[];
|
||||||
|
};
|
||||||
|
readonly logSummary: {
|
||||||
|
totalLines: number;
|
||||||
|
errorCount: number;
|
||||||
|
warningCount: number;
|
||||||
|
};
|
||||||
|
readonly error: {
|
||||||
|
message: string;
|
||||||
|
type: string;
|
||||||
|
isRetryable: boolean;
|
||||||
|
} | null;
|
||||||
|
readonly retryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunVisualizationContext {
|
||||||
|
readonly detail: ReleaseRunDetailProjectionDto;
|
||||||
|
readonly timeline: readonly RunTimelineRow[];
|
||||||
|
readonly graph: WorkflowGraph;
|
||||||
|
readonly criticalPath: CriticalPathResult;
|
||||||
|
readonly securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
|
||||||
|
readonly gateDecision: ReleaseRunGateDecisionProjectionDto | null;
|
||||||
|
readonly approvals: ReleaseRunApprovalsProjectionDto | null;
|
||||||
|
readonly deployments: ReleaseRunDeploymentsProjectionDto | null;
|
||||||
|
readonly evidence: ReleaseRunEvidenceProjectionDto | null;
|
||||||
|
readonly replay: ReleaseRunReplayProjectionDto | null;
|
||||||
|
readonly audit: ReleaseRunAuditProjectionDto | null;
|
||||||
|
readonly stepDetails: Readonly<Record<string, RunStepPanelData>>;
|
||||||
|
readonly stepLogs: Readonly<Record<string, readonly RunStepLogEntry[]>>;
|
||||||
|
readonly debugSession: DebugSession;
|
||||||
|
readonly snapshots: readonly SnapshotSummary[];
|
||||||
|
readonly snapshotStates: Readonly<Record<number, SnapshotState>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PhaseId = 'ingest' | 'gate' | 'approval' | 'evidence' | 'deployment';
|
||||||
|
|
||||||
|
interface PhaseAggregate {
|
||||||
|
readonly phaseId: PhaseId;
|
||||||
|
readonly label: string;
|
||||||
|
readonly type: GraphNode['type'];
|
||||||
|
readonly dependsOn: readonly PhaseId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASES: readonly PhaseAggregate[] = [
|
||||||
|
{ phaseId: 'ingest', label: 'Ingest & Scan', type: 'task', dependsOn: [] },
|
||||||
|
{ phaseId: 'gate', label: 'Policy Gate', type: 'gate', dependsOn: ['ingest'] },
|
||||||
|
{ phaseId: 'approval', label: 'Approval', type: 'approval', dependsOn: ['gate'] },
|
||||||
|
{ phaseId: 'evidence', label: 'Evidence', type: 'task', dependsOn: ['approval'] },
|
||||||
|
{ phaseId: 'deployment', label: 'Deployment', type: 'task', dependsOn: ['evidence'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RunVisualizationShellService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly workflowVisualization = inject(WorkflowVisualizationService);
|
||||||
|
|
||||||
|
loadContext(runId: string): Observable<RunVisualizationContext> {
|
||||||
|
const runBase = `/api/v2/releases/runs/${encodeURIComponent(runId)}`;
|
||||||
|
|
||||||
|
return forkJoin({
|
||||||
|
detail: this.http.get<ReleaseRunDetailProjectionDto>(runBase),
|
||||||
|
timeline: this.http.get<ReleaseRunTimelineProjectionDto>(`${runBase}/timeline`).pipe(catchError(() => of(null))),
|
||||||
|
gateDecision: this.http.get<ReleaseRunGateDecisionProjectionDto>(`${runBase}/gate-decision`).pipe(catchError(() => of(null))),
|
||||||
|
approvals: this.http.get<ReleaseRunApprovalsProjectionDto>(`${runBase}/approvals`).pipe(catchError(() => of(null))),
|
||||||
|
deployments: this.http.get<ReleaseRunDeploymentsProjectionDto>(`${runBase}/deployments`).pipe(catchError(() => of(null))),
|
||||||
|
securityInputs: this.http.get<ReleaseRunSecurityInputsProjectionDto>(`${runBase}/security-inputs`).pipe(catchError(() => of(null))),
|
||||||
|
evidence: this.http.get<ReleaseRunEvidenceProjectionDto>(`${runBase}/evidence`).pipe(catchError(() => of(null))),
|
||||||
|
replay: this.http.get<ReleaseRunReplayProjectionDto>(`${runBase}/replay`).pipe(catchError(() => of(null))),
|
||||||
|
audit: this.http.get<ReleaseRunAuditProjectionDto>(`${runBase}/audit`).pipe(catchError(() => of(null))),
|
||||||
|
liveGraph: this.workflowVisualization.getGraph(runId).pipe(catchError(() => of(null))),
|
||||||
|
liveCriticalPath: this.workflowVisualization.getCriticalPath(runId).pipe(catchError(() => of(null))),
|
||||||
|
}).pipe(map((bundle) => this.toContext(runId, bundle)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toContext(
|
||||||
|
runId: string,
|
||||||
|
bundle: {
|
||||||
|
detail: ReleaseRunDetailProjectionDto;
|
||||||
|
timeline: ReleaseRunTimelineProjectionDto | null;
|
||||||
|
gateDecision: ReleaseRunGateDecisionProjectionDto | null;
|
||||||
|
approvals: ReleaseRunApprovalsProjectionDto | null;
|
||||||
|
deployments: ReleaseRunDeploymentsProjectionDto | null;
|
||||||
|
securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
|
||||||
|
evidence: ReleaseRunEvidenceProjectionDto | null;
|
||||||
|
replay: ReleaseRunReplayProjectionDto | null;
|
||||||
|
audit: ReleaseRunAuditProjectionDto | null;
|
||||||
|
liveGraph: WorkflowGraph | null;
|
||||||
|
liveCriticalPath: CriticalPathResult | null;
|
||||||
|
}
|
||||||
|
): RunVisualizationContext {
|
||||||
|
const timeline = this.toTimelineRows(bundle.timeline);
|
||||||
|
const graph = bundle.liveGraph ?? this.buildSyntheticGraph(bundle.detail, timeline, bundle);
|
||||||
|
const criticalPath = bundle.liveCriticalPath ?? this.buildSyntheticCriticalPath(graph);
|
||||||
|
const stepLogs = this.buildStepLogs(timeline);
|
||||||
|
const stepDetails = this.buildStepDetails(bundle.detail, graph, stepLogs, bundle);
|
||||||
|
const snapshots = this.buildSnapshots(timeline, graph);
|
||||||
|
const snapshotStates = this.buildSnapshotStates(bundle.detail, graph, snapshots, stepDetails, stepLogs, bundle);
|
||||||
|
const debugSession = this.buildDebugSession(runId, bundle.detail, snapshots);
|
||||||
|
|
||||||
|
return {
|
||||||
|
detail: bundle.detail,
|
||||||
|
timeline,
|
||||||
|
graph,
|
||||||
|
criticalPath,
|
||||||
|
securityInputs: bundle.securityInputs,
|
||||||
|
gateDecision: bundle.gateDecision,
|
||||||
|
approvals: bundle.approvals,
|
||||||
|
deployments: bundle.deployments,
|
||||||
|
evidence: bundle.evidence,
|
||||||
|
replay: bundle.replay,
|
||||||
|
audit: bundle.audit,
|
||||||
|
stepDetails,
|
||||||
|
stepLogs,
|
||||||
|
debugSession,
|
||||||
|
snapshots,
|
||||||
|
snapshotStates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toTimelineRows(source: ReleaseRunTimelineProjectionDto | null): readonly RunTimelineRow[] {
|
||||||
|
return [...(source?.events ?? [])]
|
||||||
|
.sort((left, right) => left.occurredAt.localeCompare(right.occurredAt))
|
||||||
|
.map((event) => {
|
||||||
|
const phaseId = this.normalizePhase(event.phase, event.eventClass);
|
||||||
|
return {
|
||||||
|
eventId: event.eventId,
|
||||||
|
phaseId,
|
||||||
|
phaseLabel: this.phaseLabel(phaseId),
|
||||||
|
eventClass: event.eventClass,
|
||||||
|
status: event.status,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
message: event.message,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSyntheticGraph(
|
||||||
|
detail: ReleaseRunDetailProjectionDto,
|
||||||
|
timeline: readonly RunTimelineRow[],
|
||||||
|
bundle: {
|
||||||
|
gateDecision: ReleaseRunGateDecisionProjectionDto | null;
|
||||||
|
approvals: ReleaseRunApprovalsProjectionDto | null;
|
||||||
|
deployments: ReleaseRunDeploymentsProjectionDto | null;
|
||||||
|
evidence: ReleaseRunEvidenceProjectionDto | null;
|
||||||
|
securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
|
||||||
|
}
|
||||||
|
): WorkflowGraph {
|
||||||
|
const phaseSummaries = new Map<PhaseId, readonly RunTimelineRow[]>();
|
||||||
|
for (const phase of PHASES) {
|
||||||
|
phaseSummaries.set(phase.phaseId, timeline.filter((event) => event.phaseId === phase.phaseId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: GraphNode[] = PHASES.map((phase) => {
|
||||||
|
const events = phaseSummaries.get(phase.phaseId) ?? [];
|
||||||
|
const startTime = events[0]?.occurredAt ?? detail.requestedAt;
|
||||||
|
const endTime = events.at(-1)?.occurredAt ?? detail.updatedAt;
|
||||||
|
const duration = this.durationMs(startTime, endTime);
|
||||||
|
const status = this.resolvePhaseStatus(phase.phaseId, detail, bundle, events);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: phase.phaseId,
|
||||||
|
label: phase.label,
|
||||||
|
type: phase.type,
|
||||||
|
status,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
duration,
|
||||||
|
metadata: {
|
||||||
|
blockers: bundle.gateDecision?.blockers ?? [],
|
||||||
|
approvalCount: bundle.approvals?.checkpoints.length ?? 0,
|
||||||
|
deploymentTargets: bundle.deployments?.targets.length ?? 0,
|
||||||
|
feedFreshnessStatus: bundle.securityInputs?.feedFreshnessStatus ?? 'unknown',
|
||||||
|
message: events.at(-1)?.message ?? detail.scopeSummary,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges = PHASES.flatMap((phase) =>
|
||||||
|
phase.dependsOn.map((dependency) => ({
|
||||||
|
id: `${dependency}-${phase.phaseId}`,
|
||||||
|
source: dependency,
|
||||||
|
target: phase.phaseId,
|
||||||
|
type: 'dependency' as const,
|
||||||
|
animated: nodes.find((node) => node.id === phase.phaseId)?.status === 'running',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: detail.runId,
|
||||||
|
name: `${detail.releaseName} Run`,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
positions: this.syntheticPositions(nodes),
|
||||||
|
layoutAlgorithm: 'dagre',
|
||||||
|
metadata: {
|
||||||
|
runId: detail.runId,
|
||||||
|
releaseId: detail.releaseId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSyntheticCriticalPath(graph: WorkflowGraph): CriticalPathResult {
|
||||||
|
const path = graph.nodes
|
||||||
|
.filter((node) => node.status !== 'skipped')
|
||||||
|
.sort((left, right) => this.phaseOrder(left.id) - this.phaseOrder(right.id))
|
||||||
|
.map((node) => node.id);
|
||||||
|
|
||||||
|
const totalDuration = graph.nodes.reduce((total, node) => total + (node.duration ?? 0), 0);
|
||||||
|
return { path, totalDuration };
|
||||||
|
}
|
||||||
|
|
||||||
|
private syntheticPositions(nodes: readonly GraphNode[]): NodePosition[] {
|
||||||
|
return nodes.map((node, index) => ({
|
||||||
|
nodeId: node.id,
|
||||||
|
x: 180 + index * 230,
|
||||||
|
y: 220 + (index % 2 === 0 ? 0 : 60),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStepLogs(timeline: readonly RunTimelineRow[]): Readonly<Record<string, readonly RunStepLogEntry[]>> {
|
||||||
|
const grouped: Record<string, RunStepLogEntry[]> = {};
|
||||||
|
for (const row of timeline) {
|
||||||
|
grouped[row.phaseId] ??= [];
|
||||||
|
grouped[row.phaseId].push({
|
||||||
|
timestamp: row.occurredAt,
|
||||||
|
level: this.statusToLogLevel(row.status),
|
||||||
|
message: row.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStepDetails(
|
||||||
|
detail: ReleaseRunDetailProjectionDto,
|
||||||
|
graph: WorkflowGraph,
|
||||||
|
stepLogs: Readonly<Record<string, readonly RunStepLogEntry[]>>,
|
||||||
|
bundle: {
|
||||||
|
gateDecision: ReleaseRunGateDecisionProjectionDto | null;
|
||||||
|
approvals: ReleaseRunApprovalsProjectionDto | null;
|
||||||
|
deployments: ReleaseRunDeploymentsProjectionDto | null;
|
||||||
|
evidence: ReleaseRunEvidenceProjectionDto | null;
|
||||||
|
replay: ReleaseRunReplayProjectionDto | null;
|
||||||
|
securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
|
||||||
|
}
|
||||||
|
): Readonly<Record<string, RunStepPanelData>> {
|
||||||
|
const result: Record<string, RunStepPanelData> = {};
|
||||||
|
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
const logs = [...(stepLogs[node.id] ?? [])];
|
||||||
|
const dependsOn = graph.edges.filter((edge) => edge.target === node.id).map((edge) => edge.source);
|
||||||
|
const blocks = graph.edges.filter((edge) => edge.source === node.id).map((edge) => edge.target);
|
||||||
|
|
||||||
|
result[node.id] = {
|
||||||
|
runId: detail.runId,
|
||||||
|
stepId: node.id,
|
||||||
|
stepName: node.label,
|
||||||
|
stepType: node.type,
|
||||||
|
status: this.titleCaseStatus(node.status),
|
||||||
|
inputs: this.inputsForNode(node.id, detail, bundle),
|
||||||
|
outputs: this.outputsForNode(node.id, detail, bundle),
|
||||||
|
inputSources: dependsOn.map((sourceId) => ({ inputKey: `${sourceId}-result`, sourceStepId: sourceId })),
|
||||||
|
outputConsumers: blocks.map((targetId) => ({ outputKey: `${node.id}-result`, consumerStepId: targetId })),
|
||||||
|
timing: {
|
||||||
|
queuedAt: node.startTime ?? detail.requestedAt,
|
||||||
|
startedAt: node.startTime ?? null,
|
||||||
|
completedAt: node.endTime ?? null,
|
||||||
|
queueTime: node.startTime && detail.requestedAt ? this.toDurationString(this.durationMs(detail.requestedAt, node.startTime)) : '00:00:00.000',
|
||||||
|
executionTime: this.toDurationString(node.duration ?? 0),
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
dependsOn,
|
||||||
|
blocks,
|
||||||
|
blockedBy: dependsOn,
|
||||||
|
},
|
||||||
|
logSummary: {
|
||||||
|
totalLines: logs.length,
|
||||||
|
errorCount: logs.filter((entry) => entry.level === 'error').length,
|
||||||
|
warningCount: logs.filter((entry) => entry.level === 'warning').length,
|
||||||
|
},
|
||||||
|
error: node.status === 'failed'
|
||||||
|
? {
|
||||||
|
message: logs.at(-1)?.message ?? `${node.label} failed.`,
|
||||||
|
type: 'RunPhaseFailure',
|
||||||
|
isRetryable: node.id !== 'approval',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
retryCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSnapshots(timeline: readonly RunTimelineRow[], graph: WorkflowGraph): readonly SnapshotSummary[] {
|
||||||
|
if (timeline.length > 0) {
|
||||||
|
return timeline.map((row, index) => ({
|
||||||
|
id: row.eventId,
|
||||||
|
index,
|
||||||
|
timestamp: row.occurredAt,
|
||||||
|
stepId: row.phaseId,
|
||||||
|
stepName: row.phaseLabel,
|
||||||
|
type: row.status.toLowerCase().includes('failed') ? 'error' : 'state-change',
|
||||||
|
eventType: `${row.phaseId}.${row.status}`.toLowerCase(),
|
||||||
|
description: row.message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph.nodes.map((node, index) => ({
|
||||||
|
id: `snapshot-${node.id}`,
|
||||||
|
index,
|
||||||
|
timestamp: node.endTime ?? node.startTime ?? new Date().toISOString(),
|
||||||
|
stepId: node.id,
|
||||||
|
stepName: node.label,
|
||||||
|
type: node.status === 'failed' ? 'error' : 'state-change',
|
||||||
|
eventType: `${node.id}.${node.status}`,
|
||||||
|
description: `${node.label} is ${node.status}.`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSnapshotStates(
|
||||||
|
detail: ReleaseRunDetailProjectionDto,
|
||||||
|
graph: WorkflowGraph,
|
||||||
|
snapshots: readonly SnapshotSummary[],
|
||||||
|
stepDetails: Readonly<Record<string, RunStepPanelData>>,
|
||||||
|
stepLogs: Readonly<Record<string, readonly RunStepLogEntry[]>>,
|
||||||
|
bundle: {
|
||||||
|
evidence: ReleaseRunEvidenceProjectionDto | null;
|
||||||
|
replay: ReleaseRunReplayProjectionDto | null;
|
||||||
|
gateDecision: ReleaseRunGateDecisionProjectionDto | null;
|
||||||
|
securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
|
||||||
|
}
|
||||||
|
): Readonly<Record<number, SnapshotState>> {
|
||||||
|
const result: Record<number, SnapshotState> = {};
|
||||||
|
|
||||||
|
snapshots.forEach((snapshot, index) => {
|
||||||
|
const previous = snapshots[index - 1];
|
||||||
|
const node = graph.nodes.find((item) => item.id === snapshot.stepId);
|
||||||
|
|
||||||
|
result[index] = {
|
||||||
|
id: snapshot.id,
|
||||||
|
snapshotIndex: index,
|
||||||
|
timestamp: snapshot.timestamp,
|
||||||
|
stepId: snapshot.stepId,
|
||||||
|
stepName: snapshot.stepName,
|
||||||
|
status: node?.status ?? snapshot.type,
|
||||||
|
variables: {
|
||||||
|
releaseId: detail.releaseId,
|
||||||
|
correlationKey: detail.correlationKey,
|
||||||
|
feedFreshnessStatus: bundle.securityInputs?.feedFreshnessStatus ?? 'unknown',
|
||||||
|
},
|
||||||
|
inputs: stepDetails[snapshot.stepId]?.inputs ?? {},
|
||||||
|
outputs: stepDetails[snapshot.stepId]?.outputs ?? {},
|
||||||
|
logs: (stepLogs[snapshot.stepId] ?? []).map((entry) => `[${entry.level}] ${entry.message}`),
|
||||||
|
metadata: {
|
||||||
|
replayVerdict: bundle.replay?.verdict ?? 'unknown',
|
||||||
|
signatureStatus: bundle.evidence?.signatureStatus ?? 'unknown',
|
||||||
|
gateVerdict: bundle.gateDecision?.verdict ?? 'unknown',
|
||||||
|
},
|
||||||
|
diff: previous
|
||||||
|
? {
|
||||||
|
previousSnapshot: previous.id,
|
||||||
|
previousStep: previous.stepId,
|
||||||
|
currentStep: snapshot.stepId,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDebugSession(runId: string, detail: ReleaseRunDetailProjectionDto, snapshots: readonly SnapshotSummary[]): DebugSession {
|
||||||
|
const createdAt = snapshots[0]?.timestamp ?? detail.requestedAt;
|
||||||
|
return {
|
||||||
|
id: `debug-${runId}`,
|
||||||
|
sessionId: `debug-${runId}`,
|
||||||
|
workflowId: runId,
|
||||||
|
createdAt,
|
||||||
|
expiresAt: new Date(new Date(createdAt).getTime() + 2 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: 'active',
|
||||||
|
currentSnapshotIndex: Math.max(0, snapshots.length - 1),
|
||||||
|
totalSnapshots: snapshots.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePhaseStatus(
|
||||||
|
phaseId: PhaseId,
|
||||||
|
detail: ReleaseRunDetailProjectionDto,
|
||||||
|
bundle: {
|
||||||
|
gateDecision: ReleaseRunGateDecisionProjectionDto | null;
|
||||||
|
approvals: ReleaseRunApprovalsProjectionDto | null;
|
||||||
|
deployments: ReleaseRunDeploymentsProjectionDto | null;
|
||||||
|
evidence: ReleaseRunEvidenceProjectionDto | null;
|
||||||
|
},
|
||||||
|
events: readonly RunTimelineRow[],
|
||||||
|
): GraphNode['status'] {
|
||||||
|
const eventStatus = this.normalizeStatus(events.at(-1)?.status ?? null);
|
||||||
|
if (eventStatus) {
|
||||||
|
return eventStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (phaseId) {
|
||||||
|
case 'ingest':
|
||||||
|
if (detail.status.toLowerCase() === 'queued' || detail.status.toLowerCase() === 'pending') return 'pending';
|
||||||
|
if (detail.outcome.toLowerCase() === 'failed' && detail.status.toLowerCase().includes('scan')) return 'failed';
|
||||||
|
return 'succeeded';
|
||||||
|
case 'gate':
|
||||||
|
return this.mapGateStatus(bundle.gateDecision?.verdict ?? detail.statusRow?.gateStatus ?? detail.status);
|
||||||
|
case 'approval':
|
||||||
|
if (!detail.needsApproval) return 'skipped';
|
||||||
|
if ((bundle.approvals?.checkpoints ?? []).some((item) => item.status.toLowerCase() === 'pending')) return 'pending';
|
||||||
|
if ((bundle.approvals?.checkpoints ?? []).some((item) => item.status.toLowerCase() === 'rejected')) return 'failed';
|
||||||
|
return 'succeeded';
|
||||||
|
case 'evidence':
|
||||||
|
return this.mapEvidenceStatus(bundle.evidence?.signatureStatus, bundle.evidence?.replayMismatch ?? false);
|
||||||
|
case 'deployment':
|
||||||
|
if ((bundle.deployments?.targets ?? []).some((target) => target.status.toLowerCase().includes('running'))) return 'running';
|
||||||
|
if ((bundle.deployments?.targets ?? []).some((target) => target.status.toLowerCase().includes('failed'))) return 'failed';
|
||||||
|
if (detail.outcome.toLowerCase() === 'deployed') return 'succeeded';
|
||||||
|
if (detail.outcome.toLowerCase() === 'failed') return 'failed';
|
||||||
|
return 'pending';
|
||||||
|
default:
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inputsForNode(
|
||||||
|
nodeId: string,
|
||||||
|
detail: ReleaseRunDetailProjectionDto,
|
||||||
|
bundle: {
|
||||||
|
gateDecision: ReleaseRunGateDecisionProjectionDto | null;
|
||||||
|
approvals: ReleaseRunApprovalsProjectionDto | null;
|
||||||
|
deployments: ReleaseRunDeploymentsProjectionDto | null;
|
||||||
|
securityInputs: ReleaseRunSecurityInputsProjectionDto | null;
|
||||||
|
}
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
switch (nodeId) {
|
||||||
|
case 'ingest':
|
||||||
|
return { releaseId: detail.releaseId, scopeSummary: detail.scopeSummary };
|
||||||
|
case 'gate':
|
||||||
|
return {
|
||||||
|
verdict: bundle.gateDecision?.verdict ?? detail.statusRow?.gateStatus ?? detail.status,
|
||||||
|
blockers: bundle.gateDecision?.blockers ?? [],
|
||||||
|
riskBudgetDelta: bundle.gateDecision?.riskBudgetDelta ?? 0,
|
||||||
|
};
|
||||||
|
case 'approval':
|
||||||
|
return { needsApproval: detail.needsApproval, checkpoints: bundle.approvals?.checkpoints ?? [] };
|
||||||
|
case 'evidence':
|
||||||
|
return {
|
||||||
|
reachabilityCoveragePercent: bundle.securityInputs?.reachabilityCoveragePercent ?? null,
|
||||||
|
feedFreshnessStatus: bundle.securityInputs?.feedFreshnessStatus ?? 'unknown',
|
||||||
|
vexStatementsApplied: bundle.securityInputs?.vexStatementsApplied ?? 0,
|
||||||
|
};
|
||||||
|
case 'deployment':
|
||||||
|
return { targets: bundle.deployments?.targets ?? [] };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private outputsForNode(
|
||||||
|
nodeId: string,
|
||||||
|
detail: ReleaseRunDetailProjectionDto,
|
||||||
|
bundle: {
|
||||||
|
evidence: ReleaseRunEvidenceProjectionDto | null;
|
||||||
|
replay: ReleaseRunReplayProjectionDto | null;
|
||||||
|
}
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
switch (nodeId) {
|
||||||
|
case 'gate':
|
||||||
|
return {
|
||||||
|
runStatus: detail.statusRow?.runStatus ?? detail.status,
|
||||||
|
approvalStatus: detail.statusRow?.approvalStatus ?? (detail.needsApproval ? 'pending' : 'approved'),
|
||||||
|
};
|
||||||
|
case 'evidence':
|
||||||
|
return {
|
||||||
|
signatureStatus: bundle.evidence?.signatureStatus ?? 'unknown',
|
||||||
|
determinismVerdict: bundle.evidence?.replayDeterminismVerdict ?? 'unknown',
|
||||||
|
replayMismatch: bundle.evidence?.replayMismatch ?? false,
|
||||||
|
};
|
||||||
|
case 'deployment':
|
||||||
|
return { outcome: detail.outcome, updatedAt: detail.updatedAt };
|
||||||
|
default:
|
||||||
|
return { replayVerdict: bundle.replay?.verdict ?? 'unknown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapGateStatus(value: string): GraphNode['status'] {
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
if (normalized.includes('block') || normalized.includes('fail') || normalized.includes('deny')) return 'failed';
|
||||||
|
if (normalized.includes('pending') || normalized.includes('review')) return 'pending';
|
||||||
|
if (normalized.includes('warn')) return 'running';
|
||||||
|
return 'succeeded';
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapEvidenceStatus(signatureStatus: string | undefined, mismatch: boolean): GraphNode['status'] {
|
||||||
|
const normalized = (signatureStatus ?? '').toLowerCase();
|
||||||
|
if (mismatch || normalized === 'invalid' || normalized === 'failed') return 'failed';
|
||||||
|
if (normalized === 'verified') return 'succeeded';
|
||||||
|
if (normalized === 'collecting' || normalized === 'pending') return 'running';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePhase(rawPhase: string, rawEventClass: string): PhaseId {
|
||||||
|
const normalized = `${rawPhase} ${rawEventClass}`.toLowerCase();
|
||||||
|
if (normalized.includes('deploy')) return 'deployment';
|
||||||
|
if (normalized.includes('evidence') || normalized.includes('replay') || normalized.includes('verify')) return 'evidence';
|
||||||
|
if (normalized.includes('approval')) return 'approval';
|
||||||
|
if (normalized.includes('gate') || normalized.includes('policy')) return 'gate';
|
||||||
|
return 'ingest';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeStatus(value: string | null): GraphNode['status'] | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
if (normalized.includes('fail') || normalized.includes('block') || normalized.includes('error')) return 'failed';
|
||||||
|
if (normalized.includes('run') || normalized.includes('progress') || normalized.includes('warn')) return 'running';
|
||||||
|
if (normalized.includes('skip')) return 'skipped';
|
||||||
|
if (normalized.includes('cancel')) return 'cancelled';
|
||||||
|
if (normalized.includes('pass') || normalized.includes('success') || normalized.includes('complete') || normalized.includes('approve')) return 'succeeded';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
private phaseLabel(phaseId: PhaseId): string {
|
||||||
|
return PHASES.find((phase) => phase.phaseId === phaseId)?.label ?? phaseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private phaseOrder(nodeId: string): number {
|
||||||
|
const index = PHASES.findIndex((phase) => phase.phaseId === nodeId);
|
||||||
|
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private durationMs(startAt: string | null | undefined, endAt: string | null | undefined): number {
|
||||||
|
if (!startAt || !endAt) return 0;
|
||||||
|
const start = new Date(startAt).getTime();
|
||||||
|
const end = new Date(endAt).getTime();
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end)) return 0;
|
||||||
|
return Math.max(0, end - start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDurationString(durationMs: number): string {
|
||||||
|
const totalSeconds = Math.floor(durationMs / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
const millis = durationMs % 1000;
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${millis.toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private titleCaseStatus(status: GraphNode['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return 'Running';
|
||||||
|
case 'succeeded':
|
||||||
|
return 'Succeeded';
|
||||||
|
case 'failed':
|
||||||
|
return 'Failed';
|
||||||
|
case 'skipped':
|
||||||
|
return 'Skipped';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'Cancelled';
|
||||||
|
case 'pending':
|
||||||
|
default:
|
||||||
|
return 'Pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private statusToLogLevel(status: string): string {
|
||||||
|
const normalized = status.toLowerCase();
|
||||||
|
if (normalized.includes('fail') || normalized.includes('block') || normalized.includes('error')) return 'error';
|
||||||
|
if (normalized.includes('warn') || normalized.includes('pending')) return 'warning';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ export interface SnapshotSummary {
|
|||||||
|
|
||||||
export interface SnapshotState {
|
export interface SnapshotState {
|
||||||
id: string;
|
id: string;
|
||||||
|
snapshotIndex?: number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
stepId: string;
|
stepId: string;
|
||||||
stepName: string;
|
stepName: string;
|
||||||
@@ -37,6 +38,7 @@ export interface SnapshotState {
|
|||||||
outputs: Record<string, unknown>;
|
outputs: Record<string, unknown>;
|
||||||
logs: string[];
|
logs: string[];
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
|
diff?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTempla
|
|||||||
redirectTo: '/security/findings/:findingId',
|
redirectTo: '/security/findings/:findingId',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'workflow-visualization/:runId',
|
||||||
|
redirectTo: '/releases/runs/:runId/graph',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'workflow-visualization/:runId/:tab',
|
||||||
|
redirectTo: '/releases/runs/:runId/:tab',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LEGACY_REDIRECT_ROUTES: Routes = LEGACY_REDIRECT_ROUTE_TEMPLATES.map((template) => ({
|
export const LEGACY_REDIRECT_ROUTES: Routes = LEGACY_REDIRECT_ROUTE_TEMPLATES.map((template) => ({
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { inject } from '@angular/core';
|
||||||
|
import { Router, Routes } from '@angular/router';
|
||||||
|
|
||||||
|
const RUN_WORKSPACE_TABS = ['summary', 'graph', 'timeline', 'critical-path', 'replay', 'evidence'] as const;
|
||||||
|
const LEGACY_RUN_TABS = ['overview', 'gate-decision', 'deployments', 'security-inputs', 'approvals', 'rollback'] as const;
|
||||||
|
|
||||||
|
function redirectRunTab(runId: string, tab: string, queryParams: Record<string, string>, fragment?: string | null) {
|
||||||
|
const router = inject(Router);
|
||||||
|
const target = router.parseUrl(`/releases/runs/${encodeURIComponent(runId)}/${tab}`);
|
||||||
|
target.queryParams = { ...queryParams };
|
||||||
|
target.fragment = fragment ?? null;
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
export const RELEASES_ROUTES: Routes = [
|
export const RELEASES_ROUTES: Routes = [
|
||||||
{
|
{
|
||||||
@@ -60,19 +72,35 @@ export const RELEASES_ROUTES: Routes = [
|
|||||||
path: 'runs/:runId',
|
path: 'runs/:runId',
|
||||||
title: 'Release Run Detail',
|
title: 'Release Run Detail',
|
||||||
data: { breadcrumb: 'Release Run', semanticObject: 'run' },
|
data: { breadcrumb: 'Release Run', semanticObject: 'run' },
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: ({ params, queryParams, fragment }) =>
|
||||||
|
redirectRunTab(params['runId'] ?? '', 'summary', queryParams as Record<string, string>, fragment),
|
||||||
|
},
|
||||||
|
...RUN_WORKSPACE_TABS.map((tab) => ({
|
||||||
|
path: `runs/:runId/${tab}`,
|
||||||
|
title: 'Release Run Detail',
|
||||||
|
data: { breadcrumb: 'Release Run', semanticObject: 'run', tab },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../features/workflow-visualization/run-graph-replay-page.component').then(
|
||||||
|
(m) => m.RunGraphReplayPageComponent,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
...LEGACY_RUN_TABS.map((tab) => ({
|
||||||
|
path: `runs/:runId/${tab}`,
|
||||||
|
title: 'Release Run Detail',
|
||||||
|
data: { breadcrumb: 'Release Run', semanticObject: 'run', legacyTab: tab },
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('../features/release-orchestrator/releases/release-detail/release-detail.component').then(
|
import('../features/release-orchestrator/releases/release-detail/release-detail.component').then(
|
||||||
(m) => m.ReleaseDetailComponent,
|
(m) => m.ReleaseDetailComponent,
|
||||||
),
|
),
|
||||||
},
|
})),
|
||||||
{
|
{
|
||||||
path: 'runs/:runId/:tab',
|
path: 'runs/:runId/:tab',
|
||||||
title: 'Release Run Detail',
|
title: 'Release Run Detail',
|
||||||
data: { breadcrumb: 'Release Run', semanticObject: 'run' },
|
data: { breadcrumb: 'Release Run', semanticObject: 'run' },
|
||||||
loadComponent: () =>
|
pathMatch: 'full',
|
||||||
import('../features/release-orchestrator/releases/release-detail/release-detail.component').then(
|
redirectTo: ({ params, queryParams, fragment }) =>
|
||||||
(m) => m.ReleaseDetailComponent,
|
redirectRunTab(params['runId'] ?? '', 'summary', queryParams as Record<string, string>, fragment),
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'approvals',
|
path: 'approvals',
|
||||||
|
|||||||
@@ -20,7 +20,17 @@ describe('RELEASES_ROUTES (pre-alpha)', () => {
|
|||||||
'versions/:versionId/:tab',
|
'versions/:versionId/:tab',
|
||||||
'runs',
|
'runs',
|
||||||
'runs/:runId',
|
'runs/:runId',
|
||||||
|
'runs/:runId/summary',
|
||||||
|
'runs/:runId/graph',
|
||||||
|
'runs/:runId/timeline',
|
||||||
|
'runs/:runId/critical-path',
|
||||||
|
'runs/:runId/replay',
|
||||||
|
'runs/:runId/evidence',
|
||||||
'runs/:runId/:tab',
|
'runs/:runId/:tab',
|
||||||
|
'runs/:runId/gate-decision',
|
||||||
|
'runs/:runId/deployments',
|
||||||
|
'runs/:runId/security-inputs',
|
||||||
|
'runs/:runId/approvals',
|
||||||
'approvals',
|
'approvals',
|
||||||
'promotion-queue',
|
'promotion-queue',
|
||||||
'hotfixes',
|
'hotfixes',
|
||||||
@@ -36,10 +46,9 @@ describe('RELEASES_ROUTES (pre-alpha)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has no redirects', () => {
|
it('uses redirects only for canonical run-shell entry points', () => {
|
||||||
for (const route of RELEASES_ROUTES) {
|
const redirectPaths = RELEASES_ROUTES.filter((route) => route.redirectTo).map((route) => route.path);
|
||||||
expect(route.redirectTo).toBeUndefined();
|
expect(redirectPaths).toEqual(['', 'runs/:runId', 'runs/:runId/:tab']);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import { STEP_TYPES } from '../../app/core/api/workflow.models';
|
|||||||
import { WorkflowEditorComponent } from '../../app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component';
|
import { WorkflowEditorComponent } from '../../app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component';
|
||||||
|
|
||||||
describe('visual-workflow-editor behavior', () => {
|
describe('visual-workflow-editor behavior', () => {
|
||||||
|
const routeState = {
|
||||||
|
workflowId: 'wf-001',
|
||||||
|
query: {} as Record<string, string>,
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
routeState.workflowId = 'wf-001';
|
||||||
|
routeState.query = {};
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [WorkflowEditorComponent],
|
imports: [WorkflowEditorComponent],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -15,7 +23,12 @@ describe('visual-workflow-editor behavior', () => {
|
|||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: {
|
useValue: {
|
||||||
snapshot: {
|
snapshot: {
|
||||||
paramMap: convertToParamMap({ id: 'wf-001' }),
|
get paramMap() {
|
||||||
|
return convertToParamMap({ id: routeState.workflowId });
|
||||||
|
},
|
||||||
|
get queryParamMap() {
|
||||||
|
return convertToParamMap(routeState.query);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -27,7 +40,8 @@ describe('visual-workflow-editor behavior', () => {
|
|||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createEditor(): Promise<ComponentFixture<WorkflowEditorComponent>> {
|
async function createEditor(query: Record<string, string> = {}): Promise<ComponentFixture<WorkflowEditorComponent>> {
|
||||||
|
routeState.query = query;
|
||||||
const fixture: ComponentFixture<WorkflowEditorComponent> =
|
const fixture: ComponentFixture<WorkflowEditorComponent> =
|
||||||
TestBed.createComponent(WorkflowEditorComponent);
|
TestBed.createComponent(WorkflowEditorComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -64,6 +78,16 @@ describe('visual-workflow-editor behavior', () => {
|
|||||||
expect(yamlEditor.value).toContain('steps:');
|
expect(yamlEditor.value).toContain('steps:');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the bounded preview visualizer when opened in preview mode', async () => {
|
||||||
|
const fixture = await createEditor({ view: 'preview' });
|
||||||
|
const text = fixture.nativeElement.textContent as string;
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="workflow-preview-mode"]')).toBeTruthy();
|
||||||
|
expect(text).toContain('Preview Graph');
|
||||||
|
expect(text).toContain('Return to editor');
|
||||||
|
expect(text).not.toContain('Debug Session');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid dependency edits for self-links and cycle creation', async () => {
|
it('rejects invalid dependency edits for self-links and cycle creation', async () => {
|
||||||
const fixture = await createEditor();
|
const fixture = await createEditor();
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { BehaviorSubject, of } from 'rxjs';
|
||||||
|
|
||||||
|
import { RunGraphReplayPageComponent } from '../../app/features/workflow-visualization/run-graph-replay-page.component';
|
||||||
|
import {
|
||||||
|
RunVisualizationShellService,
|
||||||
|
type RunVisualizationContext,
|
||||||
|
} from '../../app/features/workflow-visualization/services/run-visualization-shell.service';
|
||||||
|
|
||||||
|
describe('RunGraphReplayPageComponent', () => {
|
||||||
|
let fixture: ComponentFixture<RunGraphReplayPageComponent>;
|
||||||
|
let component: RunGraphReplayPageComponent;
|
||||||
|
let paramMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||||
|
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||||
|
let routeData$: BehaviorSubject<Record<string, unknown>>;
|
||||||
|
let loadContextSpy: jasmine.Spy;
|
||||||
|
let activatedRouteStub: {
|
||||||
|
paramMap: ReturnType<BehaviorSubject<ReturnType<typeof convertToParamMap>>['asObservable']>;
|
||||||
|
queryParamMap: ReturnType<BehaviorSubject<ReturnType<typeof convertToParamMap>>['asObservable']>;
|
||||||
|
data: ReturnType<BehaviorSubject<Record<string, unknown>>['asObservable']>;
|
||||||
|
snapshot: {
|
||||||
|
readonly paramMap: ReturnType<typeof convertToParamMap>;
|
||||||
|
readonly queryParamMap: ReturnType<typeof convertToParamMap>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockContext: RunVisualizationContext = {
|
||||||
|
detail: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
releaseId: 'rel-demo',
|
||||||
|
releaseName: 'Billing API',
|
||||||
|
releaseSlug: 'billing-api',
|
||||||
|
releaseType: 'standard',
|
||||||
|
releaseVersionId: 'ver-demo',
|
||||||
|
releaseVersionNumber: 7,
|
||||||
|
releaseVersionDigest: 'sha256:release-demo',
|
||||||
|
lane: 'standard',
|
||||||
|
status: 'running',
|
||||||
|
outcome: 'in_progress',
|
||||||
|
targetEnvironment: 'prod',
|
||||||
|
targetRegion: 'eu-west',
|
||||||
|
scopeSummary: 'stage->prod',
|
||||||
|
requestedAt: '2026-03-07T10:00:00Z',
|
||||||
|
updatedAt: '2026-03-07T10:22:00Z',
|
||||||
|
needsApproval: true,
|
||||||
|
blockedByDataIntegrity: false,
|
||||||
|
correlationKey: 'corr-demo',
|
||||||
|
statusRow: {
|
||||||
|
runStatus: 'running',
|
||||||
|
gateStatus: 'warn',
|
||||||
|
approvalStatus: 'pending',
|
||||||
|
dataTrustStatus: 'healthy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timeline: [
|
||||||
|
{
|
||||||
|
eventId: 'evt-ingest',
|
||||||
|
phaseId: 'ingest',
|
||||||
|
phaseLabel: 'Ingest & Scan',
|
||||||
|
eventClass: 'scan',
|
||||||
|
status: 'completed',
|
||||||
|
occurredAt: '2026-03-07T10:02:00Z',
|
||||||
|
message: 'Ingest completed successfully.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'evt-gate',
|
||||||
|
phaseId: 'gate',
|
||||||
|
phaseLabel: 'Policy Gate',
|
||||||
|
eventClass: 'gate',
|
||||||
|
status: 'warn',
|
||||||
|
occurredAt: '2026-03-07T10:07:00Z',
|
||||||
|
message: 'Gate requires operator review.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'evt-approval',
|
||||||
|
phaseId: 'approval',
|
||||||
|
phaseLabel: 'Approval',
|
||||||
|
eventClass: 'approval',
|
||||||
|
status: 'pending',
|
||||||
|
occurredAt: '2026-03-07T10:11:00Z',
|
||||||
|
message: 'Awaiting manual approval.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graph: {
|
||||||
|
id: 'run-demo',
|
||||||
|
name: 'Billing API Run',
|
||||||
|
layoutAlgorithm: 'dagre',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'ingest', label: 'Ingest & Scan', type: 'task', status: 'succeeded', duration: 180000 },
|
||||||
|
{ id: 'gate', label: 'Policy Gate', type: 'gate', status: 'running', duration: 120000 },
|
||||||
|
{ id: 'approval', label: 'Approval', type: 'approval', status: 'pending', duration: 0 },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'ingest-gate', source: 'ingest', target: 'gate', type: 'dependency' },
|
||||||
|
{ id: 'gate-approval', source: 'gate', target: 'approval', type: 'dependency' },
|
||||||
|
],
|
||||||
|
positions: [
|
||||||
|
{ nodeId: 'ingest', x: 100, y: 100 },
|
||||||
|
{ nodeId: 'gate', x: 300, y: 100 },
|
||||||
|
{ nodeId: 'approval', x: 500, y: 100 },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
releaseId: 'rel-demo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
criticalPath: {
|
||||||
|
path: ['ingest', 'gate', 'approval'],
|
||||||
|
totalDuration: 300000,
|
||||||
|
},
|
||||||
|
securityInputs: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
reachabilityCoveragePercent: 92,
|
||||||
|
feedFreshnessStatus: 'healthy',
|
||||||
|
vexStatementsApplied: 3,
|
||||||
|
exceptionsApplied: 1,
|
||||||
|
},
|
||||||
|
gateDecision: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
verdict: 'warn',
|
||||||
|
blockers: ['manual-review'],
|
||||||
|
riskBudgetDelta: 21,
|
||||||
|
},
|
||||||
|
approvals: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
checkpoints: [{ checkpointId: 'ap-001', status: 'pending', approvedAt: null }],
|
||||||
|
},
|
||||||
|
deployments: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
targetId: 'target-1',
|
||||||
|
targetName: 'billing-api-prod',
|
||||||
|
environment: 'prod',
|
||||||
|
region: 'eu-west',
|
||||||
|
status: 'queued',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
evidence: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
replayDeterminismVerdict: 'match',
|
||||||
|
replayMismatch: false,
|
||||||
|
signatureStatus: 'verified',
|
||||||
|
},
|
||||||
|
replay: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
verdict: 'match',
|
||||||
|
},
|
||||||
|
audit: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
auditId: 'audit-001',
|
||||||
|
action: 'release.run.updated',
|
||||||
|
actorId: 'ops@example.com',
|
||||||
|
occurredAt: '2026-03-07T10:12:00Z',
|
||||||
|
correlationKey: 'corr-demo',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
stepDetails: {
|
||||||
|
ingest: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
stepId: 'ingest',
|
||||||
|
stepName: 'Ingest & Scan',
|
||||||
|
stepType: 'task',
|
||||||
|
status: 'Succeeded',
|
||||||
|
inputs: { releaseId: 'rel-demo' },
|
||||||
|
outputs: { sbomDigest: 'sha256:sbom-demo' },
|
||||||
|
inputSources: [],
|
||||||
|
outputConsumers: [{ outputKey: 'ingest-result', consumerStepId: 'gate' }],
|
||||||
|
timing: {
|
||||||
|
queuedAt: '2026-03-07T10:00:00Z',
|
||||||
|
startedAt: '2026-03-07T10:00:00Z',
|
||||||
|
completedAt: '2026-03-07T10:02:00Z',
|
||||||
|
queueTime: '00:00:00.000',
|
||||||
|
executionTime: '00:02:00.000',
|
||||||
|
},
|
||||||
|
dependencies: { dependsOn: [], blocks: ['gate'], blockedBy: [] },
|
||||||
|
logSummary: { totalLines: 1, errorCount: 0, warningCount: 0 },
|
||||||
|
error: null,
|
||||||
|
retryCount: 0,
|
||||||
|
},
|
||||||
|
gate: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
stepId: 'gate',
|
||||||
|
stepName: 'Policy Gate',
|
||||||
|
stepType: 'gate',
|
||||||
|
status: 'Running',
|
||||||
|
inputs: { blockers: ['manual-review'] },
|
||||||
|
outputs: { verdict: 'warn' },
|
||||||
|
inputSources: [{ inputKey: 'ingest-result', sourceStepId: 'ingest' }],
|
||||||
|
outputConsumers: [{ outputKey: 'gate-result', consumerStepId: 'approval' }],
|
||||||
|
timing: {
|
||||||
|
queuedAt: '2026-03-07T10:02:00Z',
|
||||||
|
startedAt: '2026-03-07T10:03:00Z',
|
||||||
|
completedAt: null,
|
||||||
|
queueTime: '00:01:00.000',
|
||||||
|
executionTime: '00:04:00.000',
|
||||||
|
},
|
||||||
|
dependencies: { dependsOn: ['ingest'], blocks: ['approval'], blockedBy: ['ingest'] },
|
||||||
|
logSummary: { totalLines: 2, errorCount: 0, warningCount: 1 },
|
||||||
|
error: null,
|
||||||
|
retryCount: 0,
|
||||||
|
},
|
||||||
|
approval: {
|
||||||
|
runId: 'run-demo',
|
||||||
|
stepId: 'approval',
|
||||||
|
stepName: 'Approval',
|
||||||
|
stepType: 'approval',
|
||||||
|
status: 'Pending',
|
||||||
|
inputs: { needsApproval: true },
|
||||||
|
outputs: null,
|
||||||
|
inputSources: [{ inputKey: 'gate-result', sourceStepId: 'gate' }],
|
||||||
|
outputConsumers: [],
|
||||||
|
timing: {
|
||||||
|
queuedAt: '2026-03-07T10:07:00Z',
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
queueTime: '00:04:00.000',
|
||||||
|
executionTime: '00:00:00.000',
|
||||||
|
},
|
||||||
|
dependencies: { dependsOn: ['gate'], blocks: [], blockedBy: ['gate'] },
|
||||||
|
logSummary: { totalLines: 1, errorCount: 0, warningCount: 0 },
|
||||||
|
error: null,
|
||||||
|
retryCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stepLogs: {
|
||||||
|
ingest: [{ timestamp: '2026-03-07T10:02:00Z', level: 'info', message: 'Ingest completed successfully.' }],
|
||||||
|
gate: [
|
||||||
|
{ timestamp: '2026-03-07T10:06:00Z', level: 'warning', message: 'Risk budget threshold requires review.' },
|
||||||
|
{ timestamp: '2026-03-07T10:07:00Z', level: 'info', message: 'Operator review requested.' },
|
||||||
|
],
|
||||||
|
approval: [{ timestamp: '2026-03-07T10:11:00Z', level: 'info', message: 'Awaiting manual approval.' }],
|
||||||
|
},
|
||||||
|
debugSession: {
|
||||||
|
id: 'debug-run-demo',
|
||||||
|
sessionId: 'debug-run-demo',
|
||||||
|
workflowId: 'run-demo',
|
||||||
|
createdAt: '2026-03-07T10:02:00Z',
|
||||||
|
expiresAt: '2026-03-07T12:02:00Z',
|
||||||
|
status: 'active',
|
||||||
|
currentSnapshotIndex: 2,
|
||||||
|
totalSnapshots: 3,
|
||||||
|
},
|
||||||
|
snapshots: [
|
||||||
|
{
|
||||||
|
id: 'snapshot-1',
|
||||||
|
index: 0,
|
||||||
|
timestamp: '2026-03-07T10:02:00Z',
|
||||||
|
stepId: 'ingest',
|
||||||
|
stepName: 'Ingest & Scan',
|
||||||
|
type: 'state-change',
|
||||||
|
eventType: 'ingest.completed',
|
||||||
|
description: 'Ingest completed successfully.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snapshot-2',
|
||||||
|
index: 1,
|
||||||
|
timestamp: '2026-03-07T10:07:00Z',
|
||||||
|
stepId: 'gate',
|
||||||
|
stepName: 'Policy Gate',
|
||||||
|
type: 'state-change',
|
||||||
|
eventType: 'gate.warn',
|
||||||
|
description: 'Gate requires operator review.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snapshot-3',
|
||||||
|
index: 2,
|
||||||
|
timestamp: '2026-03-07T10:11:00Z',
|
||||||
|
stepId: 'approval',
|
||||||
|
stepName: 'Approval',
|
||||||
|
type: 'state-change',
|
||||||
|
eventType: 'approval.pending',
|
||||||
|
description: 'Awaiting manual approval.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
snapshotStates: {
|
||||||
|
0: {
|
||||||
|
id: 'snapshot-1',
|
||||||
|
snapshotIndex: 0,
|
||||||
|
timestamp: '2026-03-07T10:02:00Z',
|
||||||
|
stepId: 'ingest',
|
||||||
|
stepName: 'Ingest & Scan',
|
||||||
|
status: 'succeeded',
|
||||||
|
variables: {},
|
||||||
|
inputs: { releaseId: 'rel-demo' },
|
||||||
|
outputs: { sbomDigest: 'sha256:sbom-demo' },
|
||||||
|
logs: ['[info] Ingest completed successfully.'],
|
||||||
|
metadata: {},
|
||||||
|
diff: null,
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
id: 'snapshot-2',
|
||||||
|
snapshotIndex: 1,
|
||||||
|
timestamp: '2026-03-07T10:07:00Z',
|
||||||
|
stepId: 'gate',
|
||||||
|
stepName: 'Policy Gate',
|
||||||
|
status: 'running',
|
||||||
|
variables: {},
|
||||||
|
inputs: { blockers: ['manual-review'] },
|
||||||
|
outputs: { verdict: 'warn' },
|
||||||
|
logs: ['[warning] Risk budget threshold requires review.'],
|
||||||
|
metadata: {},
|
||||||
|
diff: { previousStep: 'ingest', currentStep: 'gate' },
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
id: 'snapshot-3',
|
||||||
|
snapshotIndex: 2,
|
||||||
|
timestamp: '2026-03-07T10:11:00Z',
|
||||||
|
stepId: 'approval',
|
||||||
|
stepName: 'Approval',
|
||||||
|
status: 'pending',
|
||||||
|
variables: {},
|
||||||
|
inputs: { needsApproval: true },
|
||||||
|
outputs: {},
|
||||||
|
logs: ['[info] Awaiting manual approval.'],
|
||||||
|
metadata: {},
|
||||||
|
diff: { previousStep: 'gate', currentStep: 'approval' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
paramMap$ = new BehaviorSubject(convertToParamMap({ runId: 'run-demo', tab: 'graph' }));
|
||||||
|
queryParamMap$ = new BehaviorSubject(
|
||||||
|
convertToParamMap({ step: 'gate', returnTo: '/evidence/verify-replay?runId=run-demo' }),
|
||||||
|
);
|
||||||
|
routeData$ = new BehaviorSubject<Record<string, unknown>>({ tab: 'graph' });
|
||||||
|
|
||||||
|
loadContextSpy = jasmine.createSpy('loadContext').and.returnValue(of(mockContext));
|
||||||
|
|
||||||
|
activatedRouteStub = {
|
||||||
|
paramMap: paramMap$.asObservable(),
|
||||||
|
queryParamMap: queryParamMap$.asObservable(),
|
||||||
|
data: routeData$.asObservable(),
|
||||||
|
snapshot: {
|
||||||
|
get paramMap() {
|
||||||
|
return paramMap$.value;
|
||||||
|
},
|
||||||
|
get queryParamMap() {
|
||||||
|
return queryParamMap$.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [RunGraphReplayPageComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: ActivatedRoute, useValue: activatedRouteStub },
|
||||||
|
{ provide: RunVisualizationShellService, useValue: { loadContext: loadContextSpy } },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createComponent(): void {
|
||||||
|
fixture = TestBed.createComponent(RunGraphReplayPageComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('loads the run context and restores the step drawer from deep-link state', () => {
|
||||||
|
createComponent();
|
||||||
|
|
||||||
|
expect(loadContextSpy).toHaveBeenCalledWith('run-demo');
|
||||||
|
expect(component.activeTab()).toBe('graph');
|
||||||
|
expect(component.selectedStepId()).toBe('gate');
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="run-step-drawer"]')).toBeTruthy();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Policy Gate');
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[data-testid="run-workspace-tab-graph"].run-tab--active'),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the evidence replay entry from the replay tab', () => {
|
||||||
|
paramMap$.next(convertToParamMap({ runId: 'run-demo', tab: 'replay' }));
|
||||||
|
queryParamMap$.next(convertToParamMap({ returnTo: '/releases/runs' }));
|
||||||
|
routeData$.next({ tab: 'replay' });
|
||||||
|
createComponent();
|
||||||
|
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
component.openEvidenceEntry();
|
||||||
|
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(['/evidence/verify-replay'], {
|
||||||
|
queryParams: {
|
||||||
|
releaseId: 'rel-demo',
|
||||||
|
runId: 'run-demo',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens timeline rows as step deep links while preserving query-state merges', () => {
|
||||||
|
paramMap$.next(convertToParamMap({ runId: 'run-demo', tab: 'timeline' }));
|
||||||
|
queryParamMap$.next(convertToParamMap({ returnTo: '/ops/operations' }));
|
||||||
|
routeData$.next({ tab: 'timeline' });
|
||||||
|
createComponent();
|
||||||
|
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
const approvalRow = fixture.nativeElement.querySelector('[data-testid="run-timeline-row-evt-approval"]');
|
||||||
|
approvalRow.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.selectedStepId()).toBe('approval');
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith([], {
|
||||||
|
relativeTo: activatedRouteStub as unknown as ActivatedRoute,
|
||||||
|
queryParams: { step: 'approval' },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
|
||||||
|
|
||||||
|
const releaseOperatorSession: StubAuthSession = {
|
||||||
|
subjectId: 'release-e2e-user',
|
||||||
|
tenant: 'tenant-default',
|
||||||
|
scopes: ['admin', 'ui.read', 'release:read', 'policy:read', 'signer:read', 'vex:export'],
|
||||||
|
};
|
||||||
|
|
||||||
|
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 runDetail = {
|
||||||
|
runId: 'run-e2e',
|
||||||
|
releaseId: 'rel-e2e',
|
||||||
|
releaseName: 'Billing API',
|
||||||
|
releaseSlug: 'billing-api',
|
||||||
|
releaseType: 'standard',
|
||||||
|
releaseVersionId: 'ver-e2e',
|
||||||
|
releaseVersionNumber: 42,
|
||||||
|
releaseVersionDigest: 'sha256:release-e2e',
|
||||||
|
lane: 'standard',
|
||||||
|
status: 'running',
|
||||||
|
outcome: 'in_progress',
|
||||||
|
targetEnvironment: 'prod',
|
||||||
|
targetRegion: 'eu-west',
|
||||||
|
scopeSummary: 'stage->prod',
|
||||||
|
requestedAt: '2026-03-07T10:00:00Z',
|
||||||
|
updatedAt: '2026-03-07T10:24:00Z',
|
||||||
|
needsApproval: true,
|
||||||
|
blockedByDataIntegrity: false,
|
||||||
|
correlationKey: 'corr-e2e',
|
||||||
|
statusRow: {
|
||||||
|
runStatus: 'running',
|
||||||
|
gateStatus: 'warn',
|
||||||
|
approvalStatus: 'pending',
|
||||||
|
dataTrustStatus: 'healthy',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTimeline = {
|
||||||
|
runId: 'run-e2e',
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
eventId: 'evt-ingest',
|
||||||
|
eventClass: 'scan',
|
||||||
|
phase: 'ingest',
|
||||||
|
status: 'completed',
|
||||||
|
occurredAt: '2026-03-07T10:02:00Z',
|
||||||
|
message: 'Ingest completed successfully.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'evt-gate',
|
||||||
|
eventClass: 'gate',
|
||||||
|
phase: 'gate',
|
||||||
|
status: 'warn',
|
||||||
|
occurredAt: '2026-03-07T10:07:00Z',
|
||||||
|
message: 'Gate requires operator review.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'evt-approval',
|
||||||
|
eventClass: 'approval',
|
||||||
|
phase: 'approval',
|
||||||
|
status: 'pending',
|
||||||
|
occurredAt: '2026-03-07T10:11:00Z',
|
||||||
|
message: 'Awaiting manual approval.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'evt-evidence',
|
||||||
|
eventClass: 'evidence',
|
||||||
|
phase: 'evidence',
|
||||||
|
status: 'completed',
|
||||||
|
occurredAt: '2026-03-07T10:18:00Z',
|
||||||
|
message: 'Evidence bundle prepared.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const liveGraph = {
|
||||||
|
id: 'run-e2e',
|
||||||
|
name: 'Billing API Run',
|
||||||
|
layoutAlgorithm: 'dagre',
|
||||||
|
nodes: [
|
||||||
|
{ id: 'ingest', label: 'Ingest & Scan', type: 'task', status: 'succeeded', duration: 120000 },
|
||||||
|
{ id: 'gate', label: 'Policy Gate', type: 'gate', status: 'running', duration: 240000 },
|
||||||
|
{ id: 'approval', label: 'Approval', type: 'approval', status: 'pending', duration: 0 },
|
||||||
|
{ id: 'evidence', label: 'Evidence', type: 'task', status: 'succeeded', duration: 60000 },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'ingest-gate', source: 'ingest', target: 'gate', type: 'dependency' },
|
||||||
|
{ id: 'gate-approval', source: 'gate', target: 'approval', type: 'dependency' },
|
||||||
|
{ id: 'approval-evidence', source: 'approval', target: 'evidence', type: 'dependency' },
|
||||||
|
],
|
||||||
|
positions: [
|
||||||
|
{ nodeId: 'ingest', x: 180, y: 180 },
|
||||||
|
{ nodeId: 'gate', x: 420, y: 180 },
|
||||||
|
{ nodeId: 'approval', x: 660, y: 180 },
|
||||||
|
{ nodeId: 'evidence', x: 900, y: 180 },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
runId: 'run-e2e',
|
||||||
|
releaseId: 'rel-e2e',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown, status = 200): Promise<void> {
|
||||||
|
await route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupHarness(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript((session) => {
|
||||||
|
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
|
||||||
|
}, releaseOperatorSession);
|
||||||
|
|
||||||
|
await page.route('**/api/**', (route) => fulfillJson(route, {}));
|
||||||
|
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
|
||||||
|
await page.route('**/platform/i18n/*.json', (route) => fulfillJson(route, {}));
|
||||||
|
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('**/authority/console/tenants**', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
tenants: [
|
||||||
|
{
|
||||||
|
tenantId: releaseOperatorSession.tenant,
|
||||||
|
displayName: 'Default Tenant',
|
||||||
|
isDefault: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/console/branding**', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
tenantId: releaseOperatorSession.tenant,
|
||||||
|
appName: 'Stella Ops',
|
||||||
|
logoUrl: null,
|
||||||
|
cssVariables: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/console/profile**', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
subjectId: releaseOperatorSession.subjectId,
|
||||||
|
username: 'release-e2e',
|
||||||
|
displayName: 'Release E2E',
|
||||||
|
tenant: releaseOperatorSession.tenant,
|
||||||
|
roles: ['release-operator'],
|
||||||
|
scopes: releaseOperatorSession.scopes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/console/token/introspect**', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
active: true,
|
||||||
|
tenant: releaseOperatorSession.tenant,
|
||||||
|
subject: releaseOperatorSession.subjectId,
|
||||||
|
scopes: releaseOperatorSession.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: releaseOperatorSession.tenant,
|
||||||
|
actorId: releaseOperatorSession.subjectId,
|
||||||
|
regions: ['eu-west'],
|
||||||
|
environments: ['prod'],
|
||||||
|
timeWindow: '24h',
|
||||||
|
stage: 'all',
|
||||||
|
updatedAt: '2026-03-07T12:00:00Z',
|
||||||
|
updatedBy: releaseOperatorSession.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(/\/api\/v2\/releases\/runs\/run-e2e(?:\?.*)?$/, (route) => fulfillJson(route, runDetail));
|
||||||
|
await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/timeline(?:\?.*)?$/, (route) => fulfillJson(route, runTimeline));
|
||||||
|
await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/gate-decision(?:\?.*)?$/, (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
runId: 'run-e2e',
|
||||||
|
verdict: 'warn',
|
||||||
|
blockers: ['manual-review'],
|
||||||
|
riskBudgetDelta: 14,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/approvals(?:\?.*)?$/, (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
runId: 'run-e2e',
|
||||||
|
checkpoints: [{ checkpointId: 'approve-prod', status: 'pending', approvedAt: null }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/deployments(?:\?.*)?$/, (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
runId: 'run-e2e',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
targetId: 'prod-1',
|
||||||
|
targetName: 'billing-api-prod',
|
||||||
|
environment: 'prod',
|
||||||
|
region: 'eu-west',
|
||||||
|
status: 'queued',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/security-inputs(?:\?.*)?$/, (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
runId: 'run-e2e',
|
||||||
|
reachabilityCoveragePercent: 94,
|
||||||
|
feedFreshnessStatus: 'healthy',
|
||||||
|
vexStatementsApplied: 3,
|
||||||
|
exceptionsApplied: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/evidence(?:\?.*)?$/, (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
runId: 'run-e2e',
|
||||||
|
replayDeterminismVerdict: 'match',
|
||||||
|
replayMismatch: false,
|
||||||
|
signatureStatus: 'verified',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/replay(?:\?.*)?$/, (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
runId: 'run-e2e',
|
||||||
|
verdict: 'match',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route(/\/api\/v2\/releases\/runs\/run-e2e\/audit(?:\?.*)?$/, (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
runId: 'run-e2e',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
auditId: 'audit-001',
|
||||||
|
action: 'release.run.updated',
|
||||||
|
actorId: 'ops@example.com',
|
||||||
|
occurredAt: '2026-03-07T10:19:00Z',
|
||||||
|
correlationKey: 'corr-e2e',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route(/\/api\/v1\/workflows\/run-e2e\/graph(?:\?.*)?$/, (route) => fulfillJson(route, liveGraph));
|
||||||
|
await page.route(/\/api\/v1\/workflows\/run-e2e\/critical-path(?:\?.*)?$/, (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
path: ['ingest', 'gate', 'approval', 'evidence'],
|
||||||
|
totalDuration: 420000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupHarness(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run workspace supports timeline drill-in and evidence replay handoff', async ({ page }) => {
|
||||||
|
await page.goto('/releases/runs/run-e2e/graph?step=gate', { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
await expect(page.getByTestId('run-graph-replay-page')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('run-workspace-tab-graph')).toHaveClass(/run-tab--active/);
|
||||||
|
await expect(page.getByRole('button', { name: 'Critical path only' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('run-workspace-tab-timeline').click();
|
||||||
|
await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/timeline\?.*step=gate/);
|
||||||
|
await page.getByTestId('run-timeline-row-evt-approval').click();
|
||||||
|
await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/timeline\?.*step=approval/);
|
||||||
|
await expect(page.getByTestId('run-step-drawer')).toContainText('Approval');
|
||||||
|
|
||||||
|
await page.getByTestId('run-workspace-tab-replay').click();
|
||||||
|
await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/replay\?.*step=approval/);
|
||||||
|
await expect(page.getByText('Replay verdict')).toBeVisible();
|
||||||
|
await expect(page.getByText('Debug Session')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Open evidence entry' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/evidence\/verify-replay\?.*releaseId=rel-e2e.*runId=run-e2e/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Verdict Replay' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Open run replay workspace' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/replay\?.*releaseId=rel-e2e.*returnTo=/);
|
||||||
|
await expect(page.getByText('Opened from another operator flow.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy workflow visualization aliases redirect into the canonical run shell', async ({ page }) => {
|
||||||
|
await page.goto('/workflow-visualization/run-e2e/graph?step=evidence', { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/graph\?.*step=evidence/);
|
||||||
|
await expect(page.getByTestId('run-workspace-tab-graph')).toHaveClass(/run-tab--active/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user