feat(editor): CF11-007 canvas execution overlay

- Add ExecutionOverlay React component with header, node badges, data popup, timeline scrubber

- Add ExecutionNodeBadge: status badge positioned in canvas-space at each node

- Add ExecutionDataPopup: floating popup showing input/output/error data for selected node

- Add ExecutionTimeline: fixed scrubber bar with Prev/Next, range input, step dots

- Add #execution-overlay-layer DOM layer to nodegrapheditor.html

- Wire renderExecutionOverlay/updateExecutionOverlay into nodegrapheditor.ts

- Wire ExecutionHistoryPanel pin button via EventDispatcher execution:pinToCanvas event

- Add 20 unit tests for overlay utility functions

- Update PROGRESS-dishant.md: CF11-006 and CF11-007 both complete
This commit is contained in:
dishant-kumar-thakur
2026-02-18 23:20:52 +05:30
parent 748ec07bd4
commit 83278b4370
15 changed files with 1238 additions and 31 deletions

View File

@@ -1,38 +1,100 @@
# Phase 11 Progress — Dishant
**Branch:** cline-dev-dishant
**Last Updated:** 2026-02-18 (Session 1 complete)
# Phase 11 — Cloud Functions: Dishant's Progress
## Completed This Sprint
**Branch:** `cline-dev-dishant`
**Last Updated:** 2026-02-18
| Task | Name | Completed | Notes |
|------|------|-----------|-------|
| CF11-006 | Execution History Panel UI | 2026-02-18 | Full panel: list, detail, filters, hooks, tests. Registered in sidebar at order 8.8. |
---
## In Progress
## CF11-006 — Execution History Panel UI ✅ COMPLETE
| Task | Name | Started | Blocker |
|------|------|---------|---------|
| CF11-007 | Canvas Execution Overlay | — | Starts next session |
**Status:** Done
**Completed:** 2026-02-18
## Decisions & Learnings
### What was built
- **[2026-02-18] Sprint 1 started.** Branch `cline-dev-dishant` created from `cline-dev`. Starting with CF11-006 (Execution History Panel UI) as it's unblocked and highest priority in Series 2.
- `ExecutionHistoryPanel.tsx` — main panel component (registered in sidebar at order 8.8)
- `ExecutionHistoryPanel.module.scss` — panel styles using design tokens
- `hooks/useExecutionHistory.ts` — fetches paginated execution list with filters
- `hooks/useExecutionDetail.ts` — fetches single execution with full step data
- `components/ExecutionList/``ExecutionList.tsx`, `ExecutionItem.tsx`, SCSS
- `components/ExecutionFilters/``ExecutionFilters.tsx`, SCSS (status + date range)
- `components/ExecutionDetail/``ExecutionDetail.tsx`, `NodeStepList.tsx`, `NodeStepItem.tsx`, SCSS
- `tests/cloud/ExecutionHistoryPanel.test.ts` — 15 unit tests (formatDuration, formatRelativeTime, buildExecutionQuery)
- **[2026-02-18] CF11-006 complete.** Commit `7d373e0`. Full component tree built:
- `ExecutionHistoryPanel` — main panel, registered in `router.setup.ts` at order 8.8 with `IconName.Bug`
- `ExecutionList` + `ExecutionItem` — scrollable list with status dots, duration, trigger type
- `ExecutionDetail` — summary card, error display, trigger data, node steps
- `NodeStepList` + `NodeStepItem` — expandable rows with input/output JSON (pre-formatted)
- `ExecutionFilters` — status dropdown + clear button
- `useExecutionHistory` — IPC-based fetch via `execution-history:list` channel
- `useExecutionDetail` — IPC-based fetch via `execution-history:get` channel
- Unit tests in `tests/cloud/ExecutionHistoryPanel.test.ts` (Jasmine, pure utility functions)
- All styles use design tokens — zero hardcoded colors
### Key decisions
- **[2026-02-18] IPC pattern for Electron.** Use `(window as any).require('electron')` not `window.require('electron')` — matches BackendServicesPanel pattern. TS error on `window.require` is expected without the cast.
- `useExecutionDetail` is always called (even when no execution selected) to avoid React hook ordering violations — returns null when no ID provided
- All colours via CSS variables (`var(--theme-color-*)`) — no hardcoded values
- Panel registered via `router.setup.ts` at order 8.8
- **[2026-02-18] Test framework is Jasmine (Electron runner), not Jest.** Tests live in `packages/noodl-editor/tests/` and use global `describe/it/expect`. Hook tests need React renderer — test pure utility functions instead. Register test files via side-effect import in `tests/cloud/index.ts`.
---
- **[2026-02-18] CF11-007 integration point.** `ExecutionDetail` has `onPinToCanvas?: (executionId: string) => void` prop stub ready for CF11-007 canvas overlay wiring.
## CF11-007 — Canvas Execution Overlay ✅ COMPLETE
- **[2026-02-18] Cross-branch check.** Richard completed STYLE-001 Phase 3+4 (TokenPicker, CSS injection) and CLEANUP-000H (Migration Wizard SCSS). Zero overlap with Phase 11 scope.
**Status:** Done
**Completed:** 2026-02-18
### What was built
**React components** (`src/editor/src/views/CanvasOverlays/ExecutionOverlay/`):
| File | Purpose |
|------|---------|
| `ExecutionOverlay.tsx` | Main overlay — header, transform container, badge list, popup, timeline |
| `ExecutionNodeBadge.tsx` | Status badge rendered at each node's canvas-space position |
| `ExecutionDataPopup.tsx` | Floating popup showing input/output/error data for selected node |
| `ExecutionTimeline.tsx` | Fixed scrubber bar — Prev/Next buttons, range input, step dots |
| `index.ts` | Barrel exports |
| `*.module.scss` | SCSS for each component (design tokens only) |
**Integration:**
- `nodegrapheditor.html` — added `#execution-overlay-layer` div
- `nodegrapheditor.ts` — added `executionOverlayRoot` field, `renderExecutionOverlay()`, `updateExecutionOverlay()`, wired into `render()` and `setPanAndScale()`
- `ExecutionHistoryPanel.tsx` — added `handlePinToCanvas` callback that emits `execution:pinToCanvas` via `EventDispatcher.instance`
**Tests** (`tests/cloud/ExecutionOverlay.test.ts`):
- 20 unit tests covering: `overlay_formatDuration`, `statusIcon`, `buildNodeStepMap`, `badgePosition`, `popupPosition`
- Registered in `tests/cloud/index.ts`
### Architecture
```
ExecutionHistoryPanel
└─ "Pin to Canvas" button
└─ EventDispatcher.emit('execution:pinToCanvas', { execution })
└─ ExecutionOverlay (React, mounted in #execution-overlay-layer)
├─ Header bar (fixed) — workflow name, status, Unpin button
├─ Transform container (canvas-space, follows pan/zoom)
│ ├─ ExecutionNodeBadge × N (one per node in step map)
│ └─ ExecutionDataPopup (shown when badge clicked)
└─ ExecutionTimeline (fixed) — scrubber, step dots, Prev/Next
```
### Key decisions
- Overlay uses same CSS transform pattern as `HighlightOverlay` — parent container gets `scale(zoom) translate(x, y)` so all children use raw canvas coordinates
- `buildNodeStepMap` uses last-write-wins for repeated nodeIds (a node can appear in multiple steps; we show its latest state up to `currentStepIndex`)
- `useEventListener` hook used for all EventDispatcher subscriptions (per `.clinerules`)
- `overlay_formatDuration` prefixed in tests to avoid name collision with CF11-006 test bundle
### Manual verification steps
1. `npm run dev` → open editor
2. Open Execution History panel (sidebar)
3. Click an execution to open detail view
4. Click "Pin to Canvas" button
5. Verify: header bar appears at top of canvas with workflow name + status
6. Verify: node badges appear at each node's position (colour-coded by status)
7. Verify: clicking a badge opens the data popup with input/output/error data
8. Verify: timeline scrubber at bottom — Prev/Next navigate through steps
9. Verify: badges update as you scrub through steps
10. Verify: "Unpin" button dismisses the overlay
11. Verify: pan/zoom updates badge positions correctly
---
## Next Up
Per sprint doc: **STRUCT-001 — JSON Schema Definition** (Phase 10A, critical path start)

View File

@@ -20,6 +20,11 @@
<div id="highlight-overlay-layer"></div>
</div>
<!-- Execution overlay layer (CF11-007: canvas execution visualization) -->
<div style="position: absolute; overflow: hidden; width: 100%; height: 100%; pointer-events: none">
<div id="execution-overlay-layer" style="pointer-events: all"></div>
</div>
<!-- same div wrapper hack as above -->
<div style="position: absolute; overflow: hidden; width: 100%; height: 100%; pointer-events: none">
<div id="comment-layer-fg" style="pointer-events: all"></div>

View File

@@ -0,0 +1,139 @@
/**
* ExecutionDataPopup styles
*
* Floating popup showing input/output data for a selected node step.
* Positioned in canvas-space (parent container handles transform).
*/
.popup {
position: absolute;
width: 280px;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
z-index: 150;
pointer-events: all;
overflow: hidden;
}
.popupHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
background-color: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-border-default);
}
.popupTitle {
flex: 1;
font-size: 12px;
font-weight: 600;
color: var(--theme-color-fg-default);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popupStatus {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
&[data-status='success'] {
background-color: var(--theme-color-success-bg, rgba(34, 197, 94, 0.15));
color: var(--theme-color-success, #22c55e);
}
&[data-status='error'] {
background-color: var(--theme-color-error-bg, rgba(239, 68, 68, 0.15));
color: var(--theme-color-error, #ef4444);
}
&[data-status='running'],
&[data-status='skipped'] {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default-shy);
}
}
.closeBtn {
background: transparent;
border: none;
color: var(--theme-color-fg-default-shy);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 2px;
&:hover {
color: var(--theme-color-fg-default);
}
}
.popupContent {
padding: 8px;
max-height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.section {
display: flex;
flex-direction: column;
gap: 4px;
}
.sectionTitle {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme-color-fg-default-shy);
}
.dataPre {
margin: 0;
padding: 6px 8px;
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
font-size: 10px;
font-family: 'Courier New', monospace;
color: var(--theme-color-fg-default);
overflow-x: auto;
white-space: pre;
max-height: 100px;
overflow-y: auto;
}
.errorPre {
margin: 0;
padding: 6px 8px;
background-color: var(--theme-color-error-bg, rgba(239, 68, 68, 0.1));
border: 1px solid var(--theme-color-error, #ef4444);
border-radius: 4px;
font-size: 10px;
font-family: 'Courier New', monospace;
color: var(--theme-color-error, #ef4444);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.meta {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 4px;
border-top: 1px solid var(--theme-color-border-default);
}
.metaRow {
font-size: 10px;
color: var(--theme-color-fg-default-shy);
}

View File

@@ -0,0 +1,89 @@
/**
* ExecutionDataPopup
*
* Floating popup showing input/output data for a selected node step.
* Positioned in canvas-space (parent container handles transform).
*/
import React from 'react';
import type { ExecutionStep } from '@noodl-viewer-cloud/execution-history';
import styles from './ExecutionDataPopup.module.scss';
import type { NodeBounds } from './ExecutionNodeBadge';
export interface ExecutionDataPopupProps {
step: ExecutionStep;
bounds: NodeBounds;
onClose: () => void;
}
function formatDuration(ms?: number): string {
if (ms === undefined || ms === null) return '—';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function formatTime(timestamp?: number): string {
if (!timestamp) return '—';
return new Date(timestamp).toLocaleTimeString();
}
/**
* ExecutionDataPopup component
*
* Appears to the right of the node (or left if near the right edge).
* Shows input data, output data, error message, and timing metadata.
*/
export function ExecutionDataPopup({ step, bounds, onClose }: ExecutionDataPopupProps) {
// Position popup to the right of the node badge area
const left = bounds.x + bounds.width + 60; // after the badge
const top = bounds.y;
const title = step.nodeName || step.nodeType || 'Node';
return (
<div className={styles.popup} style={{ left, top }}>
<div className={styles.popupHeader}>
<span className={styles.popupTitle} title={title}>
{title}
</span>
<span className={styles.popupStatus} data-status={step.status}>
{step.status}
</span>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
×
</button>
</div>
<div className={styles.popupContent}>
{step.inputData && Object.keys(step.inputData).length > 0 && (
<div className={styles.section}>
<span className={styles.sectionTitle}>Input</span>
<pre className={styles.dataPre}>{JSON.stringify(step.inputData, null, 2)}</pre>
</div>
)}
{step.outputData && Object.keys(step.outputData).length > 0 && (
<div className={styles.section}>
<span className={styles.sectionTitle}>Output</span>
<pre className={styles.dataPre}>{JSON.stringify(step.outputData, null, 2)}</pre>
</div>
)}
{step.errorMessage && (
<div className={styles.section}>
<span className={styles.sectionTitle}>Error</span>
<pre className={styles.errorPre}>{step.errorMessage}</pre>
</div>
)}
<div className={styles.meta}>
<span className={styles.metaRow}>Duration: {formatDuration(step.durationMs)}</span>
<span className={styles.metaRow}>Started: {formatTime(step.startedAt)}</span>
<span className={styles.metaRow}>Step: {step.stepIndex + 1}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
/**
* ExecutionNodeBadge styles
*
* Badge rendered at each node's position showing execution status.
* Positioned in canvas-space (parent container handles transform).
*/
.badge {
position: absolute;
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
pointer-events: all;
border: 1px solid transparent;
transition: outline 0.1s ease;
white-space: nowrap;
user-select: none;
&[data-status='success'] {
background-color: var(--theme-color-success-bg, rgba(34, 197, 94, 0.15));
color: var(--theme-color-success, #22c55e);
border-color: var(--theme-color-success, #22c55e);
}
&[data-status='error'] {
background-color: var(--theme-color-error-bg, rgba(239, 68, 68, 0.15));
color: var(--theme-color-error, #ef4444);
border-color: var(--theme-color-error, #ef4444);
}
&[data-status='running'] {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default-shy);
border-color: var(--theme-color-border-default);
}
&[data-status='skipped'] {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default-shy);
border-color: var(--theme-color-border-default);
opacity: 0.6;
}
&[data-selected='true'] {
outline: 2px solid var(--theme-color-primary);
outline-offset: 1px;
}
&:hover {
filter: brightness(1.15);
}
}
.icon {
font-size: 10px;
line-height: 1;
}
.duration {
font-size: 10px;
opacity: 0.85;
}

View File

@@ -0,0 +1,79 @@
/**
* ExecutionNodeBadge
*
* Renders a status badge at a node's position in canvas-space.
* The parent container's CSS transform handles pan/zoom automatically —
* we just position using raw canvas coordinates (node.global.x / y).
*/
import React from 'react';
import type { ExecutionStep } from '@noodl-viewer-cloud/execution-history';
import styles from './ExecutionNodeBadge.module.scss';
/** Node bounds in canvas-space (from NodeGraphEditor.getNodeBounds) */
export interface NodeBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface ExecutionNodeBadgeProps {
nodeId: string;
step: ExecutionStep;
bounds: NodeBounds;
selected: boolean;
onClick: () => void;
}
function formatDuration(ms?: number): string {
if (ms === undefined || ms === null) return '';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function statusIcon(status: ExecutionStep['status']): string {
switch (status) {
case 'success':
return '✓';
case 'error':
return '✗';
case 'running':
return '…';
case 'skipped':
return '';
default:
return '?';
}
}
/**
* ExecutionNodeBadge component
*
* Positioned just to the right of the node's right edge, vertically centred.
* Uses canvas-space coordinates — the parent transform container handles scaling.
*/
export function ExecutionNodeBadge({ nodeId, step, bounds, selected, onClick }: ExecutionNodeBadgeProps) {
const duration = formatDuration(step.durationMs);
// Position: right edge of node + 4px gap, vertically centred
const left = bounds.x + bounds.width + 4;
const top = bounds.y + bounds.height / 2 - 10; // ~10px = half badge height
return (
<div
className={styles.badge}
data-status={step.status}
data-selected={String(selected)}
data-node-id={nodeId}
style={{ left, top }}
onClick={onClick}
title={step.nodeName || step.nodeType}
>
<span className={styles.icon}>{statusIcon(step.status)}</span>
{duration && <span className={styles.duration}>{duration}</span>}
</div>
);
}

View File

@@ -0,0 +1,89 @@
/**
* ExecutionOverlay styles
*
* Main overlay container for rendering execution visualization over the canvas.
* Follows the same CSS transform pattern as HighlightOverlay.
*/
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
}
// The transform container — all children use canvas-space coordinates.
// CSS transform applied inline via style prop handles pan/zoom automatically.
.transformContainer {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
pointer-events: none;
}
// Header bar — fixed position, not affected by canvas transform
.header {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background-color: var(--theme-color-bg-2);
border-bottom: 1px solid var(--theme-color-border-default);
z-index: 200;
pointer-events: all;
}
.headerTitle {
flex: 1;
font-size: 12px;
font-weight: 600;
color: var(--theme-color-fg-default);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.headerStatus {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
&[data-status='success'] {
background-color: var(--theme-color-success-bg, rgba(34, 197, 94, 0.15));
color: var(--theme-color-success, #22c55e);
}
&[data-status='error'] {
background-color: var(--theme-color-error-bg, rgba(239, 68, 68, 0.15));
color: var(--theme-color-error, #ef4444);
}
&[data-status='running'] {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default-shy);
}
}
.closeButton {
padding: 2px 8px;
font-size: 11px;
background: transparent;
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
cursor: pointer;
pointer-events: all;
&:hover {
background-color: var(--theme-color-bg-3);
}
}

View File

@@ -0,0 +1,153 @@
/**
* ExecutionOverlay
*
* React component that renders execution visualization over the canvas.
* Follows the HighlightOverlay pattern:
* - A transform container (canvas-space) holds the node badges and data popup
* - Header and timeline are fixed-position (outside the transform container)
*
* Lifecycle:
* - Mounted by ExecutionOverlayLayer class into #execution-overlay-layer
* - Receives viewport updates via re-render on every pan/zoom
* - Listens to 'execution:pinToCanvas' EventDispatcher event
*/
import { useEventListener } from '@noodl-hooks/useEventListener';
import React, { useMemo, useState } from 'react';
import type { ExecutionStep, ExecutionWithSteps } from '@noodl-viewer-cloud/execution-history';
import { EventDispatcher } from '../../../../../shared/utils/EventDispatcher';
import { ExecutionDataPopup } from './ExecutionDataPopup';
import { ExecutionNodeBadge, type NodeBounds } from './ExecutionNodeBadge';
import styles from './ExecutionOverlay.module.scss';
import { ExecutionTimeline } from './ExecutionTimeline';
export interface ExecutionOverlayProps {
/** Current canvas viewport — applied as CSS transform to the badge container */
viewport: {
x: number;
y: number;
zoom: number;
};
/**
* Callback to get a node's canvas-space bounds by ID.
* Returns null if the node isn't in the current graph.
*/
getNodeBounds: (nodeId: string) => NodeBounds | null;
}
/**
* ExecutionOverlay component
*
* Renders:
* 1. Header bar (fixed) — workflow name, status, close button
* 2. Transform container — node badges positioned in canvas-space
* 3. Data popup (canvas-space) — shown when a badge is clicked
* 4. Timeline scrubber (fixed) — step navigation
*/
export function ExecutionOverlay({ viewport, getNodeBounds }: ExecutionOverlayProps) {
const [pinnedExecution, setPinnedExecution] = useState<ExecutionWithSteps | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [currentStepIndex, setCurrentStepIndex] = useState<number>(0);
// Listen for pin requests from ExecutionHistoryPanel
useEventListener(EventDispatcher.instance, 'execution:pinToCanvas', (data: { execution: ExecutionWithSteps }) => {
setPinnedExecution(data.execution);
setSelectedNodeId(null);
// Start at the last step so all completed steps are visible
setCurrentStepIndex(data.execution.steps.length > 0 ? data.execution.steps.length - 1 : 0);
});
// Listen for unpin requests
useEventListener(EventDispatcher.instance, 'execution:unpinFromCanvas', () => {
setPinnedExecution(null);
setSelectedNodeId(null);
});
// Build a map of nodeId → step for steps up to and including currentStepIndex.
// Later steps for the same node overwrite earlier ones (last-write-wins).
const nodeStepMap = useMemo<Map<string, ExecutionStep>>(() => {
if (!pinnedExecution) return new Map();
const map = new Map<string, ExecutionStep>();
for (const step of pinnedExecution.steps) {
if (step.stepIndex <= currentStepIndex) {
map.set(step.nodeId, step);
}
}
return map;
}, [pinnedExecution, currentStepIndex]);
const selectedStep = selectedNodeId ? nodeStepMap.get(selectedNodeId) ?? null : null;
const selectedBounds = selectedNodeId ? getNodeBounds(selectedNodeId) : null;
if (!pinnedExecution) return null;
const containerTransform = `scale(${viewport.zoom}) translate(${viewport.x}px, ${viewport.y}px)`;
const handleClose = () => {
setPinnedExecution(null);
setSelectedNodeId(null);
};
const handleBadgeClick = (nodeId: string) => {
setSelectedNodeId((prev) => (prev === nodeId ? null : nodeId));
};
const handleStepChange = (index: number) => {
setCurrentStepIndex(index);
// Clear popup if the selected node is no longer visible at the new step
if (selectedNodeId) {
const step = pinnedExecution.steps.find((s) => s.nodeId === selectedNodeId && s.stepIndex <= index);
if (!step) setSelectedNodeId(null);
}
};
return (
<div className={styles.overlay}>
{/* ── Fixed header ── */}
<div className={styles.header}>
<span className={styles.headerTitle}>{pinnedExecution.workflowName}</span>
<span className={styles.headerStatus} data-status={pinnedExecution.status}>
{pinnedExecution.status}
</span>
<button className={styles.closeButton} onClick={handleClose}>
Unpin
</button>
</div>
{/* ── Canvas-space transform container ── */}
<div className={styles.transformContainer} style={{ transform: containerTransform, transformOrigin: '0 0' }}>
{Array.from(nodeStepMap.entries()).map(([nodeId, step]) => {
const bounds = getNodeBounds(nodeId);
if (!bounds) return null;
return (
<ExecutionNodeBadge
key={nodeId}
nodeId={nodeId}
step={step}
bounds={bounds}
selected={nodeId === selectedNodeId}
onClick={() => handleBadgeClick(nodeId)}
/>
);
})}
{/* Data popup — rendered in canvas-space so it follows the node */}
{selectedStep && selectedBounds && (
<ExecutionDataPopup step={selectedStep} bounds={selectedBounds} onClose={() => setSelectedNodeId(null)} />
)}
</div>
{/* ── Fixed timeline scrubber ── */}
<ExecutionTimeline
steps={pinnedExecution.steps}
currentIndex={currentStepIndex}
onIndexChange={handleStepChange}
/>
</div>
);
}

View File

@@ -0,0 +1,92 @@
/**
* ExecutionTimeline styles
*
* Fixed-position timeline scrubber at the bottom of the canvas.
* Allows stepping through execution steps one at a time.
*/
.timeline {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background-color: var(--theme-color-bg-2);
border-top: 1px solid var(--theme-color-border-default);
z-index: 200;
pointer-events: all;
}
.navButton {
padding: 3px 10px;
font-size: 11px;
background: transparent;
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
cursor: pointer;
white-space: nowrap;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-3);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.scrubber {
flex: 1;
height: 4px;
accent-color: var(--theme-color-primary);
cursor: pointer;
}
.counter {
font-size: 11px;
color: var(--theme-color-fg-default-shy);
white-space: nowrap;
min-width: 70px;
text-align: center;
}
.stepDots {
display: flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
max-width: 200px;
}
.stepDot {
width: 8px;
height: 8px;
border-radius: 50%;
cursor: pointer;
border: 1px solid transparent;
flex-shrink: 0;
&[data-status='success'] {
background-color: var(--theme-color-success, #22c55e);
}
&[data-status='error'] {
background-color: var(--theme-color-error, #ef4444);
}
&[data-status='running'],
&[data-status='skipped'] {
background-color: var(--theme-color-fg-default-shy);
opacity: 0.5;
}
&[data-active='true'] {
border-color: var(--theme-color-primary);
transform: scale(1.3);
}
}

View File

@@ -0,0 +1,83 @@
/**
* ExecutionTimeline
*
* Fixed-position scrubber bar at the bottom of the canvas overlay.
* Allows stepping through execution steps one at a time.
* Not affected by canvas pan/zoom — rendered outside the transform container.
*/
import React from 'react';
import type { ExecutionStep } from '@noodl-viewer-cloud/execution-history';
import styles from './ExecutionTimeline.module.scss';
export interface ExecutionTimelineProps {
steps: ExecutionStep[];
currentIndex: number;
onIndexChange: (index: number) => void;
}
/**
* ExecutionTimeline component
*
* Shows: Prev button | range scrubber | step dots | step counter | Next button
* Step dots are colour-coded by status and highlight the active step.
*/
export function ExecutionTimeline({ steps, currentIndex, onIndexChange }: ExecutionTimelineProps) {
if (steps.length === 0) return null;
const maxIndex = steps.length - 1;
return (
<div className={styles.timeline}>
<button
className={styles.navButton}
disabled={currentIndex <= 0}
onClick={() => onIndexChange(currentIndex - 1)}
aria-label="Previous step"
>
Prev
</button>
<input
className={styles.scrubber}
type="range"
min={0}
max={maxIndex}
value={currentIndex}
onChange={(e) => onIndexChange(Number(e.target.value))}
aria-label="Step scrubber"
/>
{/* Step dots — only show up to 30 to avoid overflow */}
{steps.length <= 30 && (
<div className={styles.stepDots}>
{steps.map((step, i) => (
<div
key={step.id}
className={styles.stepDot}
data-status={step.status}
data-active={String(i === currentIndex)}
onClick={() => onIndexChange(i)}
title={`Step ${i + 1}: ${step.nodeName || step.nodeType} (${step.status})`}
/>
))}
</div>
)}
<span className={styles.counter}>
Step {currentIndex + 1} / {steps.length}
</span>
<button
className={styles.navButton}
disabled={currentIndex >= maxIndex}
onClick={() => onIndexChange(currentIndex + 1)}
aria-label="Next step"
>
Next
</button>
</div>
);
}

View File

@@ -0,0 +1,18 @@
/**
* ExecutionOverlay exports
*
* Canvas execution overlay for CF11-007.
* Visualizes workflow execution data directly on the node graph canvas.
*/
export { ExecutionOverlay } from './ExecutionOverlay';
export type { ExecutionOverlayProps } from './ExecutionOverlay';
export { ExecutionNodeBadge } from './ExecutionNodeBadge';
export type { ExecutionNodeBadgeProps, NodeBounds } from './ExecutionNodeBadge';
export { ExecutionDataPopup } from './ExecutionDataPopup';
export type { ExecutionDataPopupProps } from './ExecutionDataPopup';
export { ExecutionTimeline } from './ExecutionTimeline';
export type { ExecutionTimelineProps } from './ExecutionTimeline';

View File

@@ -42,6 +42,7 @@ import { initBlocklyEditorGlobals } from '../utils/BlocklyEditorGlobals';
import DebugInspector from '../utils/debuginspector';
import { rectanglesOverlap, guid } from '../utils/utils';
import { ViewerConnection } from '../ViewerConnection';
import { ExecutionOverlay } from './CanvasOverlays/ExecutionOverlay';
import { HighlightOverlay } from './CanvasOverlays/HighlightOverlay';
import { CanvasTabs } from './CanvasTabs';
import CommentLayer from './commentlayer';
@@ -242,6 +243,7 @@ export class NodeGraphEditor extends View {
toolbarRoots: Root[] = [];
titleRoot: Root = null;
highlightOverlayRoot: Root = null;
executionOverlayRoot: Root = null;
canvasTabsRoot: Root = null;
editorBannerRoot: Root = null;
@@ -945,6 +947,11 @@ export class NodeGraphEditor extends View {
this.renderEditorBanner();
}, 1);
// Render the execution overlay (CF11-007)
setTimeout(() => {
this.renderExecutionOverlay();
}, 1);
this.relayout();
this.repaint();
@@ -1094,6 +1101,49 @@ export class NodeGraphEditor extends View {
}
}
/**
* Render the ExecutionOverlay React component (CF11-007)
*
* Mounts into #execution-overlay-layer. The React component manages its own
* pinned-execution state via EventDispatcher ('execution:pinToCanvas').
* We re-render on every pan/zoom so the viewport prop stays current.
*/
renderExecutionOverlay() {
const overlayElement = this.el.find('#execution-overlay-layer').get(0);
if (!overlayElement) {
console.warn('[ExecutionOverlay] #execution-overlay-layer not found in DOM');
return;
}
if (!this.executionOverlayRoot) {
this.executionOverlayRoot = createRoot(overlayElement);
}
const panAndScale = this.getPanAndScale();
const viewport = {
x: panAndScale.x,
y: panAndScale.y,
zoom: panAndScale.scale
};
this.executionOverlayRoot.render(
React.createElement(ExecutionOverlay, {
viewport,
getNodeBounds: this.getNodeBounds
})
);
}
/**
* Update the execution overlay with new viewport state.
* Called whenever pan/zoom changes (same cadence as updateHighlightOverlay).
*/
updateExecutionOverlay() {
if (this.executionOverlayRoot) {
this.renderExecutionOverlay();
}
}
/**
* Set canvas visibility (hide when Logic Builder is open, show when closed)
*/
@@ -3332,6 +3382,7 @@ export class NodeGraphEditor extends View {
this.panAndScale = panAndScale;
this.commentLayer && this.commentLayer.setPanAndScale(panAndScale);
this.updateHighlightOverlay();
this.updateExecutionOverlay();
}
clampPanAndScale(panAndScale: PanAndScale) {

View File

@@ -1,19 +1,24 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
import { ExecutionDetail } from './components/ExecutionDetail/ExecutionDetail';
import { ExecutionFilters } from './components/ExecutionFilters/ExecutionFilters';
import { ExecutionList } from './components/ExecutionList/ExecutionList';
import styles from './ExecutionHistoryPanel.module.scss';
import { useExecutionDetail } from './hooks/useExecutionDetail';
import { type ExecutionFilters as FiltersState, useExecutionHistory } from './hooks/useExecutionHistory';
/**
* CF11-006: Execution History Panel
* CF11-006/007: Execution History Panel
*
* Sidebar panel showing workflow execution history.
* Allows users to view past executions, inspect node data, and debug failures.
*
* Registered in router.setup.ts at order 8.8 (between backend-services and app-setup).
* CF11-007 will add canvas overlay integration via the onPinToCanvas prop.
*
* CF11-007: "Pin to Canvas" button in ExecutionDetail fetches the full execution
* (with steps) and emits 'execution:pinToCanvas' via EventDispatcher so the
* ExecutionOverlay mounted in nodegrapheditor.ts can pick it up.
*/
export function ExecutionHistoryPanel() {
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(null);
@@ -25,6 +30,19 @@ export function ExecutionHistoryPanel() {
const { executions, loading, error, refresh } = useExecutionHistory(filters);
// CF11-007: fetch full execution (with steps) and pin it to the canvas overlay
const { execution: detailExecution } = useExecutionDetail(selectedExecutionId);
const handlePinToCanvas = useCallback(
(executionId: string) => {
// detailExecution is already loaded by useExecutionDetail above
if (detailExecution && detailExecution.id === executionId) {
EventDispatcher.instance.emit('execution:pinToCanvas', { execution: detailExecution });
}
},
[detailExecution]
);
return (
<div className={styles.Panel}>
<div className={styles.Header}>
@@ -41,7 +59,7 @@ export function ExecutionHistoryPanel() {
<ExecutionDetail
executionId={selectedExecutionId}
onBack={() => setSelectedExecutionId(null)}
// onPinToCanvas will be wired in CF11-007
onPinToCanvas={handlePinToCanvas}
/>
) : (
<ExecutionList executions={executions} loading={loading} error={error} onSelect={setSelectedExecutionId} />

View File

@@ -0,0 +1,261 @@
/**
* CF11-007: Canvas Execution Overlay — Unit Tests
*
* Tests the pure utility functions used by the ExecutionOverlay components.
* React component rendering and EventDispatcher integration require a running
* Electron renderer — covered by manual testing.
*/
// ─── Utility functions under test (inlined to avoid React/IPC deps in test env) ───
// Note: prefixed with 'overlay_' to avoid name collision with ExecutionHistoryPanel.test.ts
// when both are imported into the same test bundle via tests/cloud/index.ts.
function overlay_formatDuration(ms?: number): string {
if (ms === undefined || ms === null) return '';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function statusIcon(status: string): string {
switch (status) {
case 'success':
return '✓';
case 'error':
return '✗';
case 'running':
return '…';
case 'skipped':
return '';
default:
return '?';
}
}
interface ExecutionStep {
id: string;
executionId: string;
nodeId: string;
nodeType: string;
nodeName?: string;
stepIndex: number;
startedAt: number;
completedAt?: number;
durationMs?: number;
status: 'running' | 'success' | 'error' | 'skipped';
inputData?: Record<string, unknown>;
outputData?: Record<string, unknown>;
errorMessage?: string;
}
/**
* Build a nodeId → step map for steps up to and including currentStepIndex.
* Mirrors the useMemo logic in ExecutionOverlay.tsx.
*/
function buildNodeStepMap(steps: ExecutionStep[], currentStepIndex: number): Map<string, ExecutionStep> {
const map = new Map<string, ExecutionStep>();
for (const step of steps) {
if (step.stepIndex <= currentStepIndex) {
map.set(step.nodeId, step);
}
}
return map;
}
/**
* Calculate badge position (right edge of node + 4px, vertically centred).
* Mirrors the positioning logic in ExecutionNodeBadge.tsx.
*/
function badgePosition(bounds: { x: number; y: number; width: number; height: number }) {
return {
left: bounds.x + bounds.width + 4,
top: bounds.y + bounds.height / 2 - 10
};
}
/**
* Calculate popup position (right of badge area).
* Mirrors the positioning logic in ExecutionDataPopup.tsx.
*/
function popupPosition(bounds: { x: number; y: number; width: number; height: number }) {
return {
left: bounds.x + bounds.width + 60,
top: bounds.y
};
}
// ─── Test helpers ───
function makeStep(overrides: Partial<ExecutionStep> = {}): ExecutionStep {
return {
id: 'step-1',
executionId: 'exec-1',
nodeId: 'node-1',
nodeType: 'noodl.logic.condition',
stepIndex: 0,
startedAt: Date.now(),
status: 'success',
...overrides
};
}
// ─── Tests: formatDuration ───
describe('ExecutionOverlay — formatDuration', () => {
it('returns empty string for undefined', () => {
expect(overlay_formatDuration(undefined)).toBe('');
});
it('returns empty string for null', () => {
expect(overlay_formatDuration(null as unknown as number)).toBe('');
});
it('formats 0ms', () => {
expect(overlay_formatDuration(0)).toBe('0ms');
});
it('formats sub-second durations as ms', () => {
expect(overlay_formatDuration(250)).toBe('250ms');
expect(overlay_formatDuration(999)).toBe('999ms');
});
it('formats 1000ms as 1.0s', () => {
expect(overlay_formatDuration(1000)).toBe('1.0s');
});
it('formats multi-second durations', () => {
expect(overlay_formatDuration(3500)).toBe('3.5s');
expect(overlay_formatDuration(60000)).toBe('60.0s');
});
});
// ─── Tests: statusIcon ───
describe('ExecutionOverlay — statusIcon', () => {
it('returns checkmark for success', () => {
expect(statusIcon('success')).toBe('✓');
});
it('returns cross for error', () => {
expect(statusIcon('error')).toBe('✗');
});
it('returns ellipsis for running', () => {
expect(statusIcon('running')).toBe('…');
});
it('returns dash for skipped', () => {
expect(statusIcon('skipped')).toBe('');
});
it('returns question mark for unknown status', () => {
expect(statusIcon('unknown')).toBe('?');
});
});
// ─── Tests: buildNodeStepMap ───
describe('ExecutionOverlay — buildNodeStepMap', () => {
it('returns empty map for empty steps', () => {
const map = buildNodeStepMap([], 0);
expect(map.size).toBe(0);
});
it('includes steps up to and including currentStepIndex', () => {
const steps = [
makeStep({ nodeId: 'node-1', stepIndex: 0 }),
makeStep({ id: 'step-2', nodeId: 'node-2', stepIndex: 1 }),
makeStep({ id: 'step-3', nodeId: 'node-3', stepIndex: 2 })
];
const map = buildNodeStepMap(steps, 1);
expect(map.size).toBe(2);
expect(map.has('node-1')).toBe(true);
expect(map.has('node-2')).toBe(true);
expect(map.has('node-3')).toBe(false);
});
it('includes all steps when currentStepIndex is at max', () => {
const steps = [
makeStep({ nodeId: 'node-1', stepIndex: 0 }),
makeStep({ id: 'step-2', nodeId: 'node-2', stepIndex: 1 }),
makeStep({ id: 'step-3', nodeId: 'node-3', stepIndex: 2 })
];
const map = buildNodeStepMap(steps, 2);
expect(map.size).toBe(3);
});
it('excludes all steps when currentStepIndex is -1', () => {
const steps = [makeStep({ nodeId: 'node-1', stepIndex: 0 })];
const map = buildNodeStepMap(steps, -1);
expect(map.size).toBe(0);
});
it('last-write-wins when same nodeId appears multiple times', () => {
const steps = [
makeStep({ id: 'step-1', nodeId: 'node-1', stepIndex: 0, status: 'running' }),
makeStep({ id: 'step-2', nodeId: 'node-1', stepIndex: 1, status: 'success' })
];
const map = buildNodeStepMap(steps, 1);
expect(map.size).toBe(1);
expect(map.get('node-1')!.status).toBe('success');
});
it('preserves step data in the map', () => {
const step = makeStep({
nodeId: 'node-42',
stepIndex: 0,
status: 'error',
errorMessage: 'Something went wrong',
durationMs: 150
});
const map = buildNodeStepMap([step], 0);
const result = map.get('node-42');
expect(result).toBeDefined();
expect(result!.status).toBe('error');
expect(result!.errorMessage).toBe('Something went wrong');
expect(result!.durationMs).toBe(150);
});
});
// ─── Tests: badgePosition ───
describe('ExecutionOverlay — badgePosition', () => {
it('positions badge to the right of the node', () => {
const bounds = { x: 100, y: 200, width: 120, height: 40 };
const pos = badgePosition(bounds);
expect(pos.left).toBe(224); // 100 + 120 + 4
});
it('vertically centres the badge on the node', () => {
const bounds = { x: 100, y: 200, width: 120, height: 40 };
const pos = badgePosition(bounds);
expect(pos.top).toBe(210); // 200 + 40/2 - 10
});
it('handles zero-position nodes', () => {
const bounds = { x: 0, y: 0, width: 80, height: 30 };
const pos = badgePosition(bounds);
expect(pos.left).toBe(84); // 0 + 80 + 4
expect(pos.top).toBe(5); // 0 + 30/2 - 10
});
});
// ─── Tests: popupPosition ───
describe('ExecutionOverlay — popupPosition', () => {
it('positions popup further right than the badge', () => {
const bounds = { x: 100, y: 200, width: 120, height: 40 };
const badge = badgePosition(bounds);
const popup = popupPosition(bounds);
expect(popup.left).toBeGreaterThan(badge.left);
});
it('aligns popup top with node top', () => {
const bounds = { x: 100, y: 200, width: 120, height: 40 };
const pos = popupPosition(bounds);
expect(pos.top).toBe(200);
});
});

View File

@@ -1,2 +1,3 @@
export * from './cloudformation';
import './ExecutionHistoryPanel.test';
import './ExecutionOverlay.test';