mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 10:03:31 +01:00
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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user