/** * WorkflowTimelineDAG — Orchestrator component for the Prefect-style * workflow run timeline visualization. * * This component: * 1. Fetches the workflow definition (for transition metadata) * 2. Transforms child execution summaries into timeline structures * 3. Computes the DAG layout (lanes, positions, edges) * 4. Delegates rendering to TimelineRenderer * * It is designed to be embedded in the ExecutionDetailPage for workflow * executions, receiving child execution data from the parent. */ import { useMemo, useRef, useCallback, useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import type { ExecutionSummary } from "@/api"; import { useWorkflow } from "@/hooks/useWorkflows"; import { useChildExecutions } from "@/hooks/useExecutions"; import { useExecutionStream } from "@/hooks/useExecutionStream"; import { ChartGantt, ChevronDown, ChevronRight, Loader2, Maximize2, } from "lucide-react"; import type { TimelineTask, TimelineEdge, TimelineMilestone, WorkflowDefinition, LayoutConfig, } from "./types"; import { DEFAULT_LAYOUT } from "./types"; import { buildTimelineTasks, collapseWithItemsGroups, buildEdges, buildMilestones, } from "./data"; import { computeLayout } from "./layout"; import TimelineRenderer from "./TimelineRenderer"; import TimelineModal from "./TimelineModal"; // --------------------------------------------------------------------------- // Minimal parent execution shape accepted by this component. // Both ExecutionResponse and ExecutionSummary satisfy this interface, // so callers don't need an ugly cast. // --------------------------------------------------------------------------- export interface ParentExecutionInfo { id: number; action_ref: string; status: string; created: string; updated: string; started_at?: string | null; } // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- interface WorkflowTimelineDAGProps { /** The parent (workflow) execution — accepts ExecutionResponse or ExecutionSummary */ parentExecution: ParentExecutionInfo; /** The action_ref of the parent execution (used to fetch workflow def) */ actionRef: string; /** Whether the panel starts collapsed */ defaultCollapsed?: boolean; /** * When true, renders only the timeline content (legend, renderer, modal) * without the outer card wrapper, header button, or collapse toggle. * Used when the component is embedded inside another panel (e.g. WorkflowDetailsPanel). */ embedded?: boolean; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export default function WorkflowTimelineGraph({ parentExecution, actionRef, defaultCollapsed = false, embedded = false, }: WorkflowTimelineDAGProps) { const navigate = useNavigate(); const containerRef = useRef(null); const [isCollapsed, setIsCollapsed] = useState( embedded ? false : defaultCollapsed, ); const [isModalOpen, setIsModalOpen] = useState(false); const [containerWidth, setContainerWidth] = useState(900); const [nowMs, setNowMs] = useState(Date.now); // ---- Determine if the workflow is still in-flight ---- const isTerminal = [ "completed", "failed", "timeout", "cancelled", "abandoned", ].includes(parentExecution.status); // ---- Smooth animation via requestAnimationFrame ---- // While the workflow is running and the panel is visible, tick at display // refresh rate (~60fps) so running task bars and the time axis grow smoothly. useEffect(() => { if (isTerminal || (!embedded && isCollapsed)) return; let rafId: number; const tick = () => { setNowMs(Date.now()); rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); }, [isTerminal, isCollapsed, embedded]); // ---- Data fetching ---- // Fetch child executions const { data: childData, isLoading: childrenLoading } = useChildExecutions( parentExecution.id, ); // Subscribe to real-time execution updates so child tasks update live useExecutionStream({ enabled: true }); // Fetch workflow definition for transition metadata // The workflow ref matches the action ref for workflow actions const { data: workflowData } = useWorkflow(actionRef); const childExecutions: ExecutionSummary[] = useMemo(() => { return childData?.data ?? []; }, [childData]); const workflowDef: WorkflowDefinition | null = useMemo(() => { if (!workflowData?.data?.definition) return null; return workflowData.data.definition as WorkflowDefinition; }, [workflowData]); // ---- Observe container width for responsive layout ---- useEffect(() => { const el = containerRef.current; if (!el) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { const w = entry.contentRect.width; if (w > 0) setContainerWidth(w); } }); observer.observe(el); return () => observer.disconnect(); }, [isCollapsed]); // ---- Build timeline data structures ---- // Split into two phases: // 1. Structural memo — edges and upstream/downstream links. These depend // only on the set of child executions and the workflow definition, NOT // on the current time. Recomputes only when real data changes. // 2. Per-frame memo — task time positions, milestones, and layout. These // depend on `nowMs` so they update every animation frame (~60fps) while // the workflow is running, giving smooth bar growth. // Phase 1: Build tasks (without time-dependent endMs) and compute edges. // `buildEdges` mutates tasks' upstreamIds/downstreamIds, so we must call // it in the same memo that creates the task objects. const { structuralTasks, taskEdges } = useMemo(() => { if (childExecutions.length === 0) { return { structuralTasks: [] as TimelineTask[], taskEdges: [] as TimelineEdge[], }; } // Build individual tasks, then collapse large with_items groups into // single synthetic nodes before computing edges. const rawTasks = buildTimelineTasks(childExecutions, workflowDef); const { tasks: structuralTasks, memberToGroup } = collapseWithItemsGroups( rawTasks, childExecutions, workflowDef, ); // Derive dependency edges (purely structural — no time dependency). // Pass the collapse mapping so edges redirect to group nodes. const taskEdges = buildEdges( structuralTasks, childExecutions, workflowDef, memberToGroup, ); return { structuralTasks, taskEdges }; }, [childExecutions, workflowDef]); // Phase 2: Patch running-task time positions and build milestones. // This runs every animation frame while the workflow is active. const { tasks, milestones, milestoneEdges, suppressedEdgeKeys } = useMemo(() => { if (structuralTasks.length === 0) { return { tasks: [] as TimelineTask[], milestones: [] as TimelineMilestone[], milestoneEdges: [] as TimelineEdge[], suppressedEdgeKeys: new Set(), }; } // Patch endMs / durationMs for running tasks so bars grow in real time. // We shallow-clone each task that needs updating to keep React diffing // efficient (unchanged tasks keep the same object identity). const tasks = structuralTasks.map((t) => { if (t.state === "running" && t.startMs != null) { const endMs = nowMs; return { ...t, endMs, durationMs: endMs - t.startMs }; } return t; }); // Build milestones (start/end diamonds, merge/fork junctions) const parentAsSummary: ExecutionSummary = { id: parentExecution.id, action_ref: parentExecution.action_ref, status: parentExecution.status as ExecutionSummary["status"], created: parentExecution.created, updated: parentExecution.updated, started_at: parentExecution.started_at, }; const { milestones, milestoneEdges, suppressedEdgeKeys } = buildMilestones(tasks, parentAsSummary); return { tasks, milestones, milestoneEdges, suppressedEdgeKeys }; }, [structuralTasks, parentExecution, nowMs]); // ---- Compute layout ---- const layoutConfig: LayoutConfig = useMemo(() => { // Adjust layout based on task count for readability const taskCount = tasks.length; if (taskCount > 50) { return { ...DEFAULT_LAYOUT, laneHeight: 26, barHeight: 16, lanePadding: 5, }; } if (taskCount > 20) { return { ...DEFAULT_LAYOUT, laneHeight: 30, barHeight: 18, lanePadding: 6, }; } return DEFAULT_LAYOUT; }, [tasks.length]); const layout = useMemo(() => { if (tasks.length === 0) return null; return computeLayout( tasks, taskEdges, milestones, milestoneEdges, containerWidth, layoutConfig, suppressedEdgeKeys, ); }, [ tasks, taskEdges, milestones, milestoneEdges, containerWidth, layoutConfig, suppressedEdgeKeys, ]); // ---- Handlers ---- const handleTaskClick = useCallback( (task: TimelineTask) => { navigate(`/executions/${task.id}`); }, [navigate], ); // ---- Summary stats ---- const summary = useMemo(() => { const total = childExecutions.length; const completed = childExecutions.filter( (e) => e.status === "completed", ).length; const failed = childExecutions.filter((e) => e.status === "failed").length; const running = childExecutions.filter( (e) => e.status === "running" || e.status === "requested" || e.status === "scheduling" || e.status === "scheduled", ).length; const other = total - completed - failed - running; // Compute overall duration from the already-patched tasks array so we // get the live running-task endMs values for free. let durationMs: number | null = null; const taskStartTimes = tasks .filter((t) => t.startMs != null) .map((t) => t.startMs!); const taskEndTimes = tasks .filter((t) => t.endMs != null) .map((t) => t.endMs!); if (taskStartTimes.length > 0 && taskEndTimes.length > 0) { durationMs = Math.max(...taskEndTimes) - Math.min(...taskStartTimes); } return { total, completed, failed, running, other, durationMs }; }, [childExecutions, tasks]); // ---- Early returns ---- if (childrenLoading && childExecutions.length === 0) { return (
Loading workflow timeline…
); } if (childExecutions.length === 0) { if (embedded) { return (
No workflow tasks yet.
); } return null; // No child tasks to display } // ---- Shared content (legend + renderer + modal) ---- const timelineContent = ( <> {/* Expand to modal */}
{/* Legend */}
|
{/* Timeline renderer */} {layout ? (
) : (
Computing layout…
)} {/* ---- Expanded modal ---- */} {isModalOpen && ( setIsModalOpen(false)} tasks={tasks} taskEdges={taskEdges} milestones={milestones} milestoneEdges={milestoneEdges} suppressedEdgeKeys={suppressedEdgeKeys} onTaskClick={handleTaskClick} summary={summary} /> )} ); // ---- Embedded mode: no card, no header, just the content ---- if (embedded) { return (
{timelineContent}
); } // ---- Standalone mode: full card with header + collapse ---- return (
{/* ---- Header ---- */} {/* ---- Body ---- */} {!isCollapsed && (
{timelineContent}
)}
); } // --------------------------------------------------------------------------- // Legend sub-components // --------------------------------------------------------------------------- function LegendItem({ color, label, dashed, dotted, }: { color: string; label: string; dashed?: boolean; dotted?: boolean; }) { return ( {label} ); } function EdgeLegendItem({ color, label, dashed, }: { color: string; label: string; dashed?: boolean; }) { return ( {label} ); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function formatDurationShort(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; const secs = ms / 1000; if (secs < 60) return `${secs.toFixed(1)}s`; const mins = Math.floor(secs / 60); const remainSecs = Math.round(secs % 60); if (mins < 60) return `${mins}m ${remainSecs}s`; const hrs = Math.floor(mins / 60); const remainMins = mins % 60; return `${hrs}h ${remainMins}m`; }