working on workflows
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* 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<HTMLDivElement>(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<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className={embedded ? "" : "bg-white shadow rounded-lg"}>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span className="text-sm text-gray-500">
|
||||
Loading workflow timeline…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (childExecutions.length === 0) {
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-gray-500">
|
||||
No workflow tasks yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null; // No child tasks to display
|
||||
}
|
||||
|
||||
// ---- Shared content (legend + renderer + modal) ----
|
||||
const timelineContent = (
|
||||
<>
|
||||
{/* Expand to modal */}
|
||||
<div className="flex justify-end px-3 py-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1 text-[10px] text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="Open expanded timeline with zoom"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
Expand
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-3 px-5 pb-2 text-[10px] text-gray-400">
|
||||
<LegendItem color="#22c55e" label="Completed" />
|
||||
<LegendItem color="#3b82f6" label="Running" />
|
||||
<LegendItem color="#ef4444" label="Failed" dashed />
|
||||
<LegendItem color="#f97316" label="Timeout" dotted />
|
||||
<LegendItem color="#9ca3af" label="Pending" />
|
||||
<span className="ml-2 text-gray-300">|</span>
|
||||
<EdgeLegendItem color="#22c55e" label="Succeeded" />
|
||||
<EdgeLegendItem color="#ef4444" label="Failed" dashed />
|
||||
<EdgeLegendItem color="#9ca3af" label="Always" />
|
||||
</div>
|
||||
|
||||
{/* Timeline renderer */}
|
||||
{layout ? (
|
||||
<div
|
||||
className={embedded ? "pb-3" : "px-2 pb-3"}
|
||||
style={{
|
||||
minHeight: layout.totalHeight + 8,
|
||||
}}
|
||||
>
|
||||
<TimelineRenderer
|
||||
layout={layout}
|
||||
tasks={tasks}
|
||||
config={layoutConfig}
|
||||
onTaskClick={handleTaskClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-300" />
|
||||
<span className="ml-2 text-xs text-gray-400">Computing layout…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Expanded modal ---- */}
|
||||
{isModalOpen && (
|
||||
<TimelineModal
|
||||
isOpen
|
||||
onClose={() => 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 (
|
||||
<div ref={containerRef} className="pt-1">
|
||||
{timelineContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Standalone mode: full card with header + collapse ----
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg" ref={containerRef}>
|
||||
{/* ---- Header ---- */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="w-full flex items-center justify-between px-5 py-3 text-left hover:bg-gray-50 rounded-t-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<ChartGantt className="h-4 w-4 text-indigo-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
Workflow Timeline
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">
|
||||
{summary.total} task{summary.total !== 1 ? "s" : ""}
|
||||
{summary.durationMs != null && (
|
||||
<> · {formatDurationShort(summary.durationMs)}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{summary.completed > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-green-100 text-green-700">
|
||||
{summary.completed} ✓
|
||||
</span>
|
||||
)}
|
||||
{summary.running > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-blue-100 text-blue-700">
|
||||
{summary.running} ⟳
|
||||
</span>
|
||||
)}
|
||||
{summary.failed > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-red-100 text-red-700">
|
||||
{summary.failed} ✗
|
||||
</span>
|
||||
)}
|
||||
{summary.other > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 text-gray-500">
|
||||
{summary.other}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* ---- Body ---- */}
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-gray-100">{timelineContent}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legend sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LegendItem({
|
||||
color,
|
||||
label,
|
||||
dashed,
|
||||
dotted,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
dashed?: boolean;
|
||||
dotted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-5 h-2.5 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
opacity: 0.7,
|
||||
border: dashed
|
||||
? `1px dashed ${color}`
|
||||
: dotted
|
||||
? `1px dotted ${color}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EdgeLegendItem({
|
||||
color,
|
||||
label,
|
||||
dashed,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
dashed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="16" height="8" viewBox="0 0 16 8">
|
||||
<line
|
||||
x1="0"
|
||||
y1="4"
|
||||
x2="16"
|
||||
y2="4"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={dashed ? "3 2" : undefined}
|
||||
opacity="0.7"
|
||||
/>
|
||||
<polygon points="12,1 16,4 12,7" fill={color} opacity="0.6" />
|
||||
</svg>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user