feat(ui): ship workflow visualization replay workspace
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-WV-001 - Wire run-detail tabs into Releases and Evidence
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Product Manager, FE Architect
|
||||
Task description:
|
||||
@@ -39,12 +39,12 @@ Task description:
|
||||
- Ensure operators can reach the graph and replay experience from the active shells.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Canonical run-detail tabs are active in the router.
|
||||
- [ ] Runtime graph and replay routes are mounted and reachable.
|
||||
- [ ] Evidence-side entry points open the working runtime context.
|
||||
- [x] Canonical run-detail tabs are active in the router.
|
||||
- [x] Runtime graph and replay routes are mounted and reachable.
|
||||
- [x] Evidence-side entry points open the working runtime context.
|
||||
|
||||
### FE-WV-002 - Ship the Graph, Timeline, and Critical Path tabs
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-WV-001
|
||||
Owners: Developer, FE Architect
|
||||
Task description:
|
||||
@@ -52,12 +52,12 @@ Task description:
|
||||
- Ensure these tabs are usable for real run diagnosis, not just visual placeholders.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Graph, timeline, and critical-path tabs render with working filters and node metadata.
|
||||
- [ ] Operators can diagnose run state from the shipped tabs.
|
||||
- [ ] Step selection opens the working detail drawer.
|
||||
- [x] Graph, timeline, and critical-path tabs render with working filters and node metadata.
|
||||
- [x] Operators can diagnose run state from the shipped tabs.
|
||||
- [x] Step selection opens the working detail drawer.
|
||||
|
||||
### FE-WV-003 - Ship Replay and Evidence integration
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-WV-001
|
||||
Owners: Developer, Product Manager
|
||||
Task description:
|
||||
@@ -65,12 +65,12 @@ Task description:
|
||||
- Integrate the existing evidence replay controls and proof replay surfaces into the active run-detail shell.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Replay tab works from the active run-detail shell.
|
||||
- [ ] 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] Replay tab works from the active run-detail shell.
|
||||
- [x] Existing evidence replay controls are usable from the new tab model.
|
||||
- [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
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-WV-002
|
||||
Owners: Developer, FE Architect
|
||||
Task description:
|
||||
@@ -78,12 +78,12 @@ Task description:
|
||||
- Ensure failed-step drill-ins work from graph and timeline views without losing run context.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Step-detail drawer works from graph and timeline tabs.
|
||||
- [ ] `step=<id>` deep-link behavior is usable and shareable.
|
||||
- [ ] Any required full-route escalation is implemented only where necessary.
|
||||
- [x] Step-detail drawer works from graph and timeline tabs.
|
||||
- [x] `step=<id>` deep-link behavior is usable and shareable.
|
||||
- [x] Any required full-route escalation is implemented only where necessary.
|
||||
|
||||
### FE-WV-005 - Implement bounded workflow-editor preview reuse
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-WV-001
|
||||
Owners: FE Architect, Developer
|
||||
Task description:
|
||||
@@ -91,12 +91,12 @@ Task description:
|
||||
- Keep authoring preview clearly separate from live run telemetry and troubleshooting.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Workflow editor can render the bounded preview mode.
|
||||
- [ ] Runtime-only behaviors are excluded from preview.
|
||||
- [ ] Shared and runtime-only graph components are separated in the shipped implementation.
|
||||
- [x] Workflow editor can render the bounded preview mode.
|
||||
- [x] Runtime-only behaviors are excluded from preview.
|
||||
- [x] Shared and runtime-only graph components are separated in the shipped implementation.
|
||||
|
||||
### FE-WV-006 - Verify, redirect, and document the shipped capability
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-WV-003
|
||||
Owners: QA, Documentation author
|
||||
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.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Verification covers graph, timeline, replay, and evidence tabs.
|
||||
- [ ] Step deep links and alias redirects are included in testing.
|
||||
- [ ] Docs reflect the shipped run-detail and replay capability.
|
||||
- [x] Verification covers graph, timeline, replay, and evidence tabs.
|
||||
- [x] Step deep links and alias redirects are included in testing.
|
||||
- [x] Docs reflect the shipped run-detail and replay capability.
|
||||
|
||||
## Execution Log
|
||||
| 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 | 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
|
||||
- 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: 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
- 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_022_FE_policy_vex_release_decisioning_studio.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`
|
||||
|
||||
## Delivery Tasks
|
||||
@@ -92,12 +91,12 @@
|
||||
- [DONE] FE-TX-004 Audit bundles page and create-flow slice
|
||||
- [DONE] FE-TX-005 Supporting component merge matrix for Triage explainability
|
||||
- [DONE] FE-TX-006 QA, rollout, and docs sync for Triage explainability
|
||||
- [TODO] FE-WV-001 Freeze run-detail tab and route contract for workflow visualization
|
||||
- [TODO] FE-WV-002 Graph, timeline, and critical-path slice
|
||||
- [TODO] FE-WV-003 Replay and evidence integration slice
|
||||
- [TODO] FE-WV-004 Step-detail drawer and deep-link behavior
|
||||
- [TODO] 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-001 Freeze run-detail tab and route contract for workflow visualization
|
||||
- [DONE] FE-WV-002 Graph, timeline, and critical-path slice
|
||||
- [DONE] FE-WV-003 Replay and evidence integration slice
|
||||
- [DONE] FE-WV-004 Step-detail drawer and deep-link behavior
|
||||
- [DONE] FE-WV-005 Workflow-editor preview reuse boundary
|
||||
- [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-002 Shared contextual drawer host
|
||||
- [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
|
||||
- Notes:
|
||||
- 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
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
||||
- `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components.
|
||||
- `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation.
|
||||
- `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself.
|
||||
- `SPRINT_20260307_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.
|
||||
|
||||
## 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/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/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/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.
|
||||
|
||||
@@ -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_026_FE_platform_ops_consolidation.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`
|
||||
|
||||
## 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 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- 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>
|
||||
</p>
|
||||
@if (runId()) {
|
||||
<div class="replay-runtime-link">
|
||||
<button class="btn btn-secondary" type="button" (click)="openRunReplayWorkspace()">
|
||||
Open run replay workspace
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</header>
|
||||
|
||||
@@ -281,6 +288,10 @@ import {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.replay-runtime-link {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
section {
|
||||
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 {
|
||||
const newRequest: ReplayRequest = {
|
||||
id: `rr-${Date.now()}`,
|
||||
|
||||
@@ -645,14 +645,22 @@ export class ReleaseDetailComponent {
|
||||
|
||||
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() } }); }
|
||||
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 } }); }
|
||||
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' } }); }
|
||||
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' } }); }
|
||||
openUnifiedAudit(): void { void this.router.navigate(['/evidence/audit-log'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
type WorkflowStepType,
|
||||
type StepTypeDefinition,
|
||||
} 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 {
|
||||
offsetX: number;
|
||||
@@ -50,7 +52,7 @@ interface ConnectionState {
|
||||
|
||||
@Component({
|
||||
selector: 'app-workflow-editor',
|
||||
imports: [RouterLink, FormsModule],
|
||||
imports: [RouterLink, FormsModule, WorkflowVisualizerComponent],
|
||||
template: `
|
||||
<div class="editor-container">
|
||||
<!-- Header -->
|
||||
@@ -106,6 +108,32 @@ interface ConnectionState {
|
||||
</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">
|
||||
<!-- Step Palette -->
|
||||
<aside class="step-palette">
|
||||
@@ -857,6 +885,42 @@ interface ConnectionState {
|
||||
opacity: 0.5;
|
||||
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 {
|
||||
@@ -871,6 +935,7 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit
|
||||
|
||||
// View state
|
||||
readonly showYamlView = signal(false);
|
||||
readonly previewMode = signal(this.route.snapshot.queryParamMap?.get('view') === 'preview');
|
||||
|
||||
// Canvas state
|
||||
readonly canvasState = signal<CanvasState>({
|
||||
@@ -916,6 +981,47 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit
|
||||
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(() => {
|
||||
const steps = this.store.steps();
|
||||
const connections: { id: string; path: string; from: string; to: string }[] = [];
|
||||
|
||||
@@ -126,7 +126,7 @@ interface PlatformListResponse<T> {
|
||||
<tbody>
|
||||
@for (row of filteredRows(); track row.activityId) {
|
||||
<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>{{ deriveLane(row) }}</td>
|
||||
<td>{{ deriveOutcome(row) }}</td>
|
||||
|
||||
@@ -382,6 +382,8 @@ import { WorkflowVisualizationService, StepDetails as ServiceStepDetails } from
|
||||
export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
@Input() runId!: string;
|
||||
@Input() stepId?: string;
|
||||
@Input() stepData?: StepDetails | null;
|
||||
@Input() logsData?: readonly LogEntry[] | null;
|
||||
|
||||
@Output() stepSelected = new EventEmitter<string>();
|
||||
@Output() retryRequested = new EventEmitter<string>();
|
||||
@@ -394,6 +396,7 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
|
||||
// Logs state
|
||||
logs: LogEntry[] = [];
|
||||
private allLogs: LogEntry[] = [];
|
||||
loadingLogs = false;
|
||||
hasMoreLogs = false;
|
||||
autoScroll = true;
|
||||
@@ -402,6 +405,7 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
private readonly searchSubject = new Subject<string>();
|
||||
protected readonly Object = Object;
|
||||
|
||||
constructor(
|
||||
private visualizationService: WorkflowVisualizationService,
|
||||
@@ -424,12 +428,27 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
}
|
||||
|
||||
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) {
|
||||
this.loadStepDetails();
|
||||
}
|
||||
}
|
||||
|
||||
loadStepDetails(): void {
|
||||
if (this.stepData) {
|
||||
this.stepDetails = this.stepData;
|
||||
this.loading = false;
|
||||
this.applyLocalLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.stepId) return;
|
||||
|
||||
this.loading = true;
|
||||
@@ -437,11 +456,12 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (details) => {
|
||||
this.stepDetails = details;
|
||||
const normalized = this.normalizeStepDetails(details);
|
||||
this.stepDetails = normalized;
|
||||
this.loading = false;
|
||||
|
||||
// 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';
|
||||
}
|
||||
|
||||
@@ -457,6 +477,11 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
}
|
||||
|
||||
loadLogs(reset = false): void {
|
||||
if (this.logsData) {
|
||||
this.applyLocalLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.stepId) return;
|
||||
|
||||
if (reset) {
|
||||
@@ -474,7 +499,8 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
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.hasMoreLogs = !!(result as any).nextPageToken;
|
||||
this.loadingLogs = false;
|
||||
@@ -498,10 +524,19 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
}
|
||||
|
||||
onLogFilterChange(): void {
|
||||
if (this.logsData) {
|
||||
this.applyLocalLogs();
|
||||
return;
|
||||
}
|
||||
this.loadLogs(true);
|
||||
}
|
||||
|
||||
onSearchChange(search: string): void {
|
||||
if (this.logsData) {
|
||||
this.logFilter.search = search;
|
||||
this.applyLocalLogs();
|
||||
return;
|
||||
}
|
||||
this.searchSubject.next(search);
|
||||
}
|
||||
|
||||
@@ -575,6 +610,87 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
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 {
|
||||
if (!this.stepDetails?.timing) return 0;
|
||||
|
||||
@@ -598,6 +714,47 @@ export class StepDetailPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 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 { FormsModule } from '@angular/forms';
|
||||
import { Subject, takeUntil, interval, timer } from 'rxjs';
|
||||
@@ -182,9 +182,12 @@ import { TimeTravelService, DebugSession, SnapshotSummary, SnapshotState } from
|
||||
styles: [``],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
||||
export class TimeTravelControlsComponent implements OnInit, OnDestroy, OnChanges {
|
||||
@Input() runId!: 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() sessionCreated = new EventEmitter<DebugSession>();
|
||||
@@ -213,6 +216,7 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
private playbackInterval$ = new Subject<void>();
|
||||
private readonly keydownHandler = (event: KeyboardEvent) => this.handleKeydown(event);
|
||||
|
||||
constructor(
|
||||
private timeTravelService: TimeTravelService,
|
||||
@@ -220,21 +224,29 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.sessionId) {
|
||||
if (this.hasPreloadedState()) {
|
||||
this.hydrateFromInputs();
|
||||
} else if (this.sessionId) {
|
||||
this.loadSession(this.sessionId);
|
||||
} else {
|
||||
this.createSession();
|
||||
}
|
||||
|
||||
// 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 {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
this.playbackInterval$.complete();
|
||||
document.removeEventListener('keydown', this.handleKeydown.bind(this));
|
||||
document.removeEventListener('keydown', this.keydownHandler);
|
||||
}
|
||||
|
||||
get progressPercentage(): number {
|
||||
@@ -283,6 +295,11 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
loadSnapshots(): void {
|
||||
if (this.hasPreloadedState()) {
|
||||
this.hydrateFromInputs();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.session) return;
|
||||
|
||||
this.timeTravelService.getSnapshots(this.session.sessionId)
|
||||
@@ -331,6 +348,17 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
this.loading = true;
|
||||
@@ -338,7 +366,7 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (state) => {
|
||||
this.currentIndex = state.snapshotIndex;
|
||||
this.currentIndex = state.snapshotIndex ?? index;
|
||||
this.currentSnapshot = this.snapshots[this.currentIndex];
|
||||
this.currentState = state;
|
||||
this.snapshotChanged.emit(state);
|
||||
@@ -520,4 +548,22 @@ export class TimeTravelControlsComponent implements OnInit, OnDestroy {
|
||||
onShowDiffChange(): void {
|
||||
// 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
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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 { Subject, takeUntil, interval, switchMap, filter } from 'rxjs';
|
||||
@@ -134,18 +134,18 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
||||
@for (edge of edges; track edge.id) {
|
||||
<g class="edge"
|
||||
[class.critical]="showCriticalPath && criticalPathEdges.has(edge.id)"
|
||||
[class.animated]="edge.isAnimated">
|
||||
[class.animated]="edge.animated">
|
||||
<path
|
||||
[attr.d]="getEdgePath(edge)"
|
||||
[attr.stroke]="getEdgeColor(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"
|
||||
class="edge-path">
|
||||
</path>
|
||||
|
||||
<!-- Animated dots for running edges -->
|
||||
@if (edge.isAnimated) {
|
||||
@if (edge.animated) {
|
||||
<circle r="4" [attr.fill]="'var(--color-running)'">
|
||||
<animateMotion
|
||||
[attr.path]="getEdgePath(edge)"
|
||||
@@ -162,7 +162,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
||||
@for (node of nodes; track node.id) {
|
||||
<g
|
||||
class="node"
|
||||
[class]="'node-' + node.status.toLowerCase()"
|
||||
[class]="'node-' + nodeStatus(node)"
|
||||
[class.selected]="selectedNodeId === node.id"
|
||||
[class.critical]="showCriticalPath && criticalPathNodes.has(node.id)"
|
||||
[attr.transform]="getNodeTransform(node)"
|
||||
@@ -183,7 +183,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
||||
|
||||
<!-- Status icon -->
|
||||
<g [attr.transform]="'translate(12, ' + (nodeHeight / 2) + ')'">
|
||||
@switch (node.status) {
|
||||
@switch (displayStatus(node)) {
|
||||
@case ('Running') {
|
||||
<circle r="6" fill="var(--color-running)" class="pulse" />
|
||||
}
|
||||
@@ -220,7 +220,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
||||
</text>
|
||||
|
||||
<!-- Duration badge (if completed) -->
|
||||
@if (node.data?.['duration']) {
|
||||
@if (node.duration) {
|
||||
<g [attr.transform]="'translate(' + (nodeWidth - 8) + ', 8)'">
|
||||
<rect
|
||||
x="-24" y="-8"
|
||||
@@ -229,7 +229,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
||||
fill="var(--color-badge-bg)"
|
||||
class="duration-badge" />
|
||||
<text x="-8" y="4" text-anchor="middle" font-size="9" fill="var(--color-badge-text)">
|
||||
{{ formatDuration(node.data?.['duration']) }}
|
||||
{{ formatDuration(node.duration) }}
|
||||
</text>
|
||||
</g>
|
||||
}
|
||||
@@ -310,14 +310,16 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node
|
||||
styleUrls: ['./workflow-visualizer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
||||
export class WorkflowVisualizerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
@ViewChild('canvasContainer') canvasContainer!: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('svgCanvas') svgCanvas!: ElementRef<SVGElement>;
|
||||
|
||||
@Input() runId!: string;
|
||||
@Input() graph: WorkflowGraph | null = null;
|
||||
@Input() showMinimap = true;
|
||||
@Input() autoRefresh = true;
|
||||
@Input() refreshInterval = 2000;
|
||||
@Input() previewMode = false;
|
||||
|
||||
@Output() nodeSelected = new EventEmitter<GraphNode>();
|
||||
@Output() nodeDoubleClicked = new EventEmitter<GraphNode>();
|
||||
@@ -369,9 +371,14 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
||||
constructor(private visualizationService: WorkflowVisualizationService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.graph) {
|
||||
this.setGraph(this.graph);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadGraph();
|
||||
|
||||
if (this.autoRefresh) {
|
||||
if (this.autoRefresh && !this.previewMode && !this.graph) {
|
||||
interval(this.refreshInterval)
|
||||
.pipe(
|
||||
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 {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
@@ -397,6 +410,11 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
loadGraph(): void {
|
||||
if (this.graph) {
|
||||
this.setGraph(this.graph);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
@@ -404,12 +422,7 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (graph) => {
|
||||
this.nodes = graph.nodes;
|
||||
this.edges = graph.edges;
|
||||
this.positions.clear();
|
||||
graph.positions.forEach(p => this.positions.set(p.nodeId, p));
|
||||
this.loading = false;
|
||||
this.graphLoaded.emit(graph);
|
||||
this.setGraph(graph);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = err.message || 'Failed to load workflow graph';
|
||||
@@ -487,6 +500,9 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Layout
|
||||
onLayoutChange(): void {
|
||||
if (this.graph) {
|
||||
return;
|
||||
}
|
||||
this.loadGraph();
|
||||
}
|
||||
|
||||
@@ -520,9 +536,44 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
if (this.graph) {
|
||||
return;
|
||||
}
|
||||
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
|
||||
getNodeTransform(node: GraphNode): string {
|
||||
const pos = this.positions.get(node.id);
|
||||
@@ -563,32 +614,33 @@ export class WorkflowVisualizerComponent implements OnInit, OnDestroy {
|
||||
|
||||
getNodeFill(node: GraphNode): string {
|
||||
const colors: Record<string, string> = {
|
||||
'Pending': 'var(--color-pending-bg)',
|
||||
'Queued': 'var(--color-queued-bg)',
|
||||
'Running': 'var(--color-running-bg)',
|
||||
'Succeeded': 'var(--color-success-bg)',
|
||||
'Failed': 'var(--color-error-bg)',
|
||||
'Skipped': 'var(--color-skipped-bg)',
|
||||
'Cancelled': 'var(--color-cancelled-bg)'
|
||||
pending: 'var(--color-pending-bg)',
|
||||
queued: 'var(--color-queued-bg)',
|
||||
running: 'var(--color-running-bg)',
|
||||
succeeded: 'var(--color-success-bg)',
|
||||
failed: 'var(--color-error-bg)',
|
||||
skipped: 'var(--color-skipped-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 {
|
||||
const colors: Record<string, string> = {
|
||||
'Pending': 'var(--color-pending-stroke)',
|
||||
'Queued': 'var(--color-queued-stroke)',
|
||||
'Running': 'var(--color-running)',
|
||||
'Succeeded': 'var(--color-success)',
|
||||
'Failed': 'var(--color-error)',
|
||||
'Skipped': 'var(--color-skipped-stroke)',
|
||||
'Cancelled': 'var(--color-cancelled-stroke)'
|
||||
pending: 'var(--color-pending-stroke)',
|
||||
queued: 'var(--color-queued-stroke)',
|
||||
running: 'var(--color-running)',
|
||||
succeeded: 'var(--color-success)',
|
||||
failed: 'var(--color-error)',
|
||||
skipped: 'var(--color-skipped-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 {
|
||||
return node.status === 'failed' || node.status === 'running'
|
||||
const status = this.nodeStatus(node);
|
||||
return status === 'failed' || status === 'running'
|
||||
? 'var(--color-text-light)'
|
||||
: '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 {
|
||||
id: string;
|
||||
snapshotIndex?: number;
|
||||
timestamp: string;
|
||||
stepId: string;
|
||||
stepName: string;
|
||||
@@ -37,6 +38,7 @@ export interface SnapshotState {
|
||||
outputs: Record<string, unknown>;
|
||||
logs: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
diff?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
||||
@@ -28,6 +28,16 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTempla
|
||||
redirectTo: '/security/findings/:findingId',
|
||||
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) => ({
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
@@ -60,19 +72,35 @@ export const RELEASES_ROUTES: Routes = [
|
||||
path: 'runs/:runId',
|
||||
title: 'Release Run Detail',
|
||||
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: () =>
|
||||
import('../features/release-orchestrator/releases/release-detail/release-detail.component').then(
|
||||
(m) => m.ReleaseDetailComponent,
|
||||
),
|
||||
},
|
||||
})),
|
||||
{
|
||||
path: 'runs/:runId/:tab',
|
||||
title: 'Release Run Detail',
|
||||
data: { breadcrumb: 'Release Run', semanticObject: 'run' },
|
||||
loadComponent: () =>
|
||||
import('../features/release-orchestrator/releases/release-detail/release-detail.component').then(
|
||||
(m) => m.ReleaseDetailComponent,
|
||||
),
|
||||
pathMatch: 'full',
|
||||
redirectTo: ({ params, queryParams, fragment }) =>
|
||||
redirectRunTab(params['runId'] ?? '', 'summary', queryParams as Record<string, string>, fragment),
|
||||
},
|
||||
{
|
||||
path: 'approvals',
|
||||
|
||||
@@ -20,7 +20,17 @@ describe('RELEASES_ROUTES (pre-alpha)', () => {
|
||||
'versions/:versionId/:tab',
|
||||
'runs',
|
||||
'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/gate-decision',
|
||||
'runs/:runId/deployments',
|
||||
'runs/:runId/security-inputs',
|
||||
'runs/:runId/approvals',
|
||||
'approvals',
|
||||
'promotion-queue',
|
||||
'hotfixes',
|
||||
@@ -36,10 +46,9 @@ describe('RELEASES_ROUTES (pre-alpha)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('has no redirects', () => {
|
||||
for (const route of RELEASES_ROUTES) {
|
||||
expect(route.redirectTo).toBeUndefined();
|
||||
}
|
||||
it('uses redirects only for canonical run-shell entry points', () => {
|
||||
const redirectPaths = RELEASES_ROUTES.filter((route) => route.redirectTo).map((route) => route.path);
|
||||
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';
|
||||
|
||||
describe('visual-workflow-editor behavior', () => {
|
||||
const routeState = {
|
||||
workflowId: 'wf-001',
|
||||
query: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
routeState.workflowId = 'wf-001';
|
||||
routeState.query = {};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkflowEditorComponent],
|
||||
providers: [
|
||||
@@ -15,7 +23,12 @@ describe('visual-workflow-editor behavior', () => {
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
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();
|
||||
});
|
||||
|
||||
async function createEditor(): Promise<ComponentFixture<WorkflowEditorComponent>> {
|
||||
async function createEditor(query: Record<string, string> = {}): Promise<ComponentFixture<WorkflowEditorComponent>> {
|
||||
routeState.query = query;
|
||||
const fixture: ComponentFixture<WorkflowEditorComponent> =
|
||||
TestBed.createComponent(WorkflowEditorComponent);
|
||||
fixture.detectChanges();
|
||||
@@ -64,6 +78,16 @@ describe('visual-workflow-editor behavior', () => {
|
||||
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 () => {
|
||||
const fixture = await createEditor();
|
||||
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