diff --git a/dev-docs/tasks/phase-11-cloud-functions/PROGRESS-dishant.md b/dev-docs/tasks/phase-11-cloud-functions/PROGRESS-dishant.md index 6115208..fdae224 100644 --- a/dev-docs/tasks/phase-11-cloud-functions/PROGRESS-dishant.md +++ b/dev-docs/tasks/phase-11-cloud-functions/PROGRESS-dishant.md @@ -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) diff --git a/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html b/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html index 8d09f58..854501c 100644 --- a/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html +++ b/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html @@ -20,6 +20,11 @@
+ +
+
+
+
diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.module.scss b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.module.scss new file mode 100644 index 0000000..b76f4aa --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.module.scss @@ -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); +} diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.tsx b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.tsx new file mode 100644 index 0000000..d63040c --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionDataPopup.tsx @@ -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 ( +
+
+ + {title} + + + {step.status} + + +
+ +
+ {step.inputData && Object.keys(step.inputData).length > 0 && ( +
+ Input +
{JSON.stringify(step.inputData, null, 2)}
+
+ )} + + {step.outputData && Object.keys(step.outputData).length > 0 && ( +
+ Output +
{JSON.stringify(step.outputData, null, 2)}
+
+ )} + + {step.errorMessage && ( +
+ Error +
{step.errorMessage}
+
+ )} + +
+ Duration: {formatDuration(step.durationMs)} + Started: {formatTime(step.startedAt)} + Step: {step.stepIndex + 1} +
+
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.module.scss b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.module.scss new file mode 100644 index 0000000..9d82e64 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.module.scss @@ -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; +} diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.tsx b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.tsx new file mode 100644 index 0000000..e5c7ea0 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionNodeBadge.tsx @@ -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 ( +
+ {statusIcon(step.status)} + {duration && {duration}} +
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.module.scss b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.module.scss new file mode 100644 index 0000000..11e9ad4 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.module.scss @@ -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); + } +} diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.tsx b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.tsx new file mode 100644 index 0000000..f9bd872 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionOverlay.tsx @@ -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(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [currentStepIndex, setCurrentStepIndex] = useState(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>(() => { + if (!pinnedExecution) return new Map(); + + const map = new Map(); + 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 ( +
+ {/* ── Fixed header ── */} +
+ {pinnedExecution.workflowName} + + {pinnedExecution.status} + + +
+ + {/* ── Canvas-space transform container ── */} +
+ {Array.from(nodeStepMap.entries()).map(([nodeId, step]) => { + const bounds = getNodeBounds(nodeId); + if (!bounds) return null; + + return ( + handleBadgeClick(nodeId)} + /> + ); + })} + + {/* Data popup — rendered in canvas-space so it follows the node */} + {selectedStep && selectedBounds && ( + setSelectedNodeId(null)} /> + )} +
+ + {/* ── Fixed timeline scrubber ── */} + +
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.module.scss b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.module.scss new file mode 100644 index 0000000..4435d13 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.module.scss @@ -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); + } +} diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.tsx b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.tsx new file mode 100644 index 0000000..e6cafd2 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/ExecutionTimeline.tsx @@ -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 ( +
+ + + onIndexChange(Number(e.target.value))} + aria-label="Step scrubber" + /> + + {/* Step dots — only show up to 30 to avoid overflow */} + {steps.length <= 30 && ( +
+ {steps.map((step, i) => ( +
onIndexChange(i)} + title={`Step ${i + 1}: ${step.nodeName || step.nodeType} (${step.status})`} + /> + ))} +
+ )} + + + Step {currentIndex + 1} / {steps.length} + + + +
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/index.ts b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/index.ts new file mode 100644 index 0000000..c2bf78c --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/CanvasOverlays/ExecutionOverlay/index.ts @@ -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'; diff --git a/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts b/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts index f9bcafb..0fa9c14 100644 --- a/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts +++ b/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts @@ -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) { diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.tsx b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.tsx index e90596f..b83c601 100644 --- a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.tsx +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.tsx @@ -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(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 (
@@ -41,7 +59,7 @@ export function ExecutionHistoryPanel() { setSelectedExecutionId(null)} - // onPinToCanvas will be wired in CF11-007 + onPinToCanvas={handlePinToCanvas} /> ) : ( diff --git a/packages/noodl-editor/tests/cloud/ExecutionOverlay.test.ts b/packages/noodl-editor/tests/cloud/ExecutionOverlay.test.ts new file mode 100644 index 0000000..02766b7 --- /dev/null +++ b/packages/noodl-editor/tests/cloud/ExecutionOverlay.test.ts @@ -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; + outputData?: Record; + 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 { + const map = new Map(); + 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 { + 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); + }); +}); diff --git a/packages/noodl-editor/tests/cloud/index.ts b/packages/noodl-editor/tests/cloud/index.ts index a43104b..8ec5850 100644 --- a/packages/noodl-editor/tests/cloud/index.ts +++ b/packages/noodl-editor/tests/cloud/index.ts @@ -1,2 +1,3 @@ export * from './cloudformation'; import './ExecutionHistoryPanel.test'; +import './ExecutionOverlay.test';