working on workflows

This commit is contained in:
2026-03-04 22:02:34 -06:00
parent b54aa3ec26
commit 7438f92502
63 changed files with 10231 additions and 731 deletions

View File

@@ -0,0 +1,461 @@
/**
* TimelineModal — Full-screen modal for the Workflow Timeline DAG.
*
* Opens as a portal overlay with:
* - A much larger vertical layout (more lane height, bigger bars)
* - A timescale zoom slider that re-computes the layout at wider widths
* - Horizontal scroll for zoomed-in views
* - All the same interactions as the inline renderer (hover, click, double-click)
* - Escape key / close button to dismiss
*/
import { useState, useRef, useCallback, useMemo, useEffect } from "react";
import { createPortal } from "react-dom";
import { X, ZoomIn, ZoomOut, RotateCcw, GitBranch } from "lucide-react";
import type {
TimelineTask,
TimelineEdge,
TimelineMilestone,
LayoutConfig,
ComputedLayout,
} from "./types";
import { DEFAULT_LAYOUT } from "./types";
import { computeLayout } from "./layout";
import TimelineRenderer from "./TimelineRenderer";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface TimelineModalProps {
/** Whether the modal is open */
isOpen: boolean;
/** Callback to close the modal */
onClose: () => void;
/** Timeline tasks */
tasks: TimelineTask[];
/** Structural dependency edges between tasks */
taskEdges: TimelineEdge[];
/** Synthetic milestone nodes */
milestones: TimelineMilestone[];
/** Edges connecting milestones */
milestoneEdges: TimelineEdge[];
/** Direct task→task edge keys replaced by milestone-routed paths */
suppressedEdgeKeys?: Set<string>;
/** Callback when a task is double-clicked (navigate to execution) */
onTaskClick?: (task: TimelineTask) => void;
/** Summary stats for the header */
summary: {
total: number;
completed: number;
failed: number;
running: number;
other: number;
durationMs: number | null;
};
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** The modal layout uses more generous spacing */
const MODAL_LAYOUT: LayoutConfig = {
...DEFAULT_LAYOUT,
laneHeight: 44,
barHeight: 28,
lanePadding: 8,
milestoneSize: 12,
paddingTop: 44,
paddingBottom: 24,
paddingLeft: 24,
paddingRight: 24,
minBarWidth: 12,
};
const MIN_ZOOM = 1;
const MAX_ZOOM = 8;
const ZOOM_STEP = 0.25;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function TimelineModal({
isOpen,
onClose,
tasks,
taskEdges,
milestones,
milestoneEdges,
suppressedEdgeKeys,
onTaskClick,
summary,
}: TimelineModalProps) {
const [zoom, setZoom] = useState(1);
const scrollRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(1200);
// ---- Observe container width ----
useEffect(() => {
if (!isOpen) return;
const el = containerRef.current;
if (!el) return;
// Initial measurement
setContainerWidth(el.clientWidth);
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.width > 0) {
setContainerWidth(entry.contentRect.width);
}
}
});
observer.observe(el);
return () => observer.disconnect();
}, [isOpen]);
// ---- Keyboard handling (Escape to close) ----
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, onClose]);
// ---- Prevent body scroll when modal is open ----
useEffect(() => {
if (!isOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [isOpen]);
// ---- Adjust layout config based on task count ----
const layoutConfig: LayoutConfig = useMemo(() => {
const taskCount = tasks.length;
if (taskCount > 80) {
return {
...MODAL_LAYOUT,
laneHeight: 32,
barHeight: 20,
lanePadding: 6,
};
}
if (taskCount > 40) {
return {
...MODAL_LAYOUT,
laneHeight: 38,
barHeight: 24,
lanePadding: 7,
};
}
return MODAL_LAYOUT;
}, [tasks.length]);
// ---- Compute layout at the zoomed width ----
const layout: ComputedLayout | null = useMemo(() => {
if (tasks.length === 0) return null;
// Zoom stretches the timeline horizontally
const effectiveWidth = Math.max(containerWidth * zoom, 600);
return computeLayout(
tasks,
taskEdges,
milestones,
milestoneEdges,
effectiveWidth,
layoutConfig,
suppressedEdgeKeys,
);
}, [
tasks,
taskEdges,
milestones,
milestoneEdges,
containerWidth,
zoom,
layoutConfig,
suppressedEdgeKeys,
]);
// ---- Zoom handlers ----
const handleZoomIn = useCallback(() => {
setZoom((z) => Math.min(MAX_ZOOM, z + ZOOM_STEP));
}, []);
const handleZoomOut = useCallback(() => {
setZoom((z) => Math.max(MIN_ZOOM, z - ZOOM_STEP));
}, []);
const handleZoomReset = useCallback(() => {
setZoom(1);
if (scrollRef.current) {
scrollRef.current.scrollLeft = 0;
}
}, []);
const handleZoomSlider = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setZoom(parseFloat(e.target.value));
},
[],
);
// ---- Wheel zoom on the timeline area ----
const handleWheel = useCallback((e: React.WheelEvent) => {
// Only zoom on Ctrl+wheel or meta+wheel to avoid interfering with normal scroll
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setZoom((z) => {
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z + delta));
return newZoom;
});
}, []);
if (!isOpen) return null;
const content = (
<div
className="fixed inset-0 z-50 flex flex-col"
style={{ backgroundColor: "rgba(0, 0, 0, 0.6)" }}
onClick={(e) => {
// Close on backdrop click
if (e.target === e.currentTarget) onClose();
}}
>
{/* Modal container */}
<div className="flex flex-col m-4 md:m-6 lg:m-8 bg-white rounded-xl shadow-2xl overflow-hidden flex-1 min-h-0">
{/* ---- Header ---- */}
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 bg-gray-50/80 flex-shrink-0">
<div className="flex items-center gap-3">
<GitBranch className="h-4 w-4 text-indigo-500" />
<h2 className="text-sm font-semibold text-gray-800">
Workflow Timeline
</h2>
<span className="text-xs text-gray-400">
{summary.total} task{summary.total !== 1 ? "s" : ""}
{summary.durationMs != null && (
<> · {formatDurationShort(summary.durationMs)}</>
)}
</span>
{/* Summary badges */}
<div className="flex items-center gap-1.5 ml-2">
{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>
</div>
{/* Right: zoom controls + close */}
<div className="flex items-center gap-3">
{/* Zoom controls */}
<div className="flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-2.5 py-1.5 shadow-sm">
<button
onClick={handleZoomOut}
disabled={zoom <= MIN_ZOOM}
className="p-0.5 text-gray-500 hover:text-gray-800 disabled:text-gray-300 disabled:cursor-not-allowed"
title="Zoom out"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<input
type="range"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={ZOOM_STEP}
value={zoom}
onChange={handleZoomSlider}
className="w-24 h-1 accent-indigo-500 cursor-pointer"
title={`Timescale: ${Math.round(zoom * 100)}%`}
/>
<button
onClick={handleZoomIn}
disabled={zoom >= MAX_ZOOM}
className="p-0.5 text-gray-500 hover:text-gray-800 disabled:text-gray-300 disabled:cursor-not-allowed"
title="Zoom in"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<span className="text-xs text-gray-500 font-mono tabular-nums w-10 text-center">
{Math.round(zoom * 100)}%
</span>
{zoom !== 1 && (
<button
onClick={handleZoomReset}
className="p-0.5 text-gray-400 hover:text-gray-700"
title="Reset zoom"
>
<RotateCcw className="h-3 w-3" />
</button>
)}
</div>
{/* Close button */}
<button
onClick={onClose}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="Close (Esc)"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* ---- Legend ---- */}
<div className="flex items-center gap-3 px-5 py-2 text-[10px] text-gray-400 border-b border-gray-100 flex-shrink-0">
<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" />
<span className="ml-auto text-gray-300">
Ctrl+scroll to zoom · Click task to highlight path · Double-click to
view
</span>
</div>
{/* ---- Timeline body ---- */}
<div
ref={containerRef}
className="flex-1 min-h-0 overflow-auto"
onWheel={handleWheel}
>
{layout ? (
<div ref={scrollRef} className="min-h-full">
<TimelineRenderer
layout={layout}
tasks={tasks}
config={layoutConfig}
onTaskClick={onTaskClick}
idPrefix="modal-"
/>
</div>
) : (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-gray-400">No tasks to display</span>
</div>
)}
</div>
</div>
</div>
);
return createPortal(content, document.body);
}
// ---------------------------------------------------------------------------
// Legend sub-components (duplicated from WorkflowTimelineDAG to keep modal
// self-contained — these are tiny presentational helpers)
// ---------------------------------------------------------------------------
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`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
/**
* Workflow Timeline DAG — barrel exports.
*
* Usage:
* import WorkflowTimelineDAG from "@/components/executions/workflow-timeline";
*/
export { default } from "./WorkflowTimelineGraph";
export type { ParentExecutionInfo } from "./WorkflowTimelineGraph";
export { default as TimelineRenderer } from "./TimelineRenderer";
export { default as TimelineModal } from "./TimelineModal";
// Re-export types consumers might need
export type {
TimelineTask,
TimelineEdge,
TimelineMilestone,
TimelineNode,
ComputedLayout,
TaskState,
EdgeKind,
MilestoneKind,
TooltipData,
LayoutConfig,
WorkflowDefinition,
WithItemsGroupInfo,
} from "./types";
export { WITH_ITEMS_COLLAPSE_THRESHOLD } from "./types";
// Re-export data utilities for testing / advanced usage
export {
buildTimelineTasks,
buildEdges,
buildMilestones,
findConnectedPath,
edgeKey,
} from "./data";
// Re-export layout utilities
export { computeLayout, computeGridLines, computeEdgePath } from "./layout";

View File

@@ -0,0 +1,673 @@
/**
* Layout Engine for the Workflow Timeline DAG.
*
* Responsible for:
* 1. Computing the time→pixel x-scale from task time bounds.
* 2. Assigning tasks to non-overlapping y-lanes (greedy packing).
* 3. Positioning milestone nodes.
* 4. Producing the final ComputedLayout consumed by the SVG renderer.
*/
import type {
TimelineTask,
TimelineEdge,
TimelineMilestone,
TimelineNode,
ComputedLayout,
LayoutConfig,
} from "./types";
import { DEFAULT_LAYOUT } from "./types";
// ---------------------------------------------------------------------------
// Time scale helpers
// ---------------------------------------------------------------------------
interface TimeScale {
/** Minimum time (epoch ms) */
minMs: number;
/** Maximum time (epoch ms) */
maxMs: number;
/** Available pixel width for the time axis */
axisWidth: number;
/** Pixels per millisecond */
pxPerMs: number;
}
function buildTimeScale(
tasks: TimelineTask[],
milestones: TimelineMilestone[],
chartWidth: number,
config: LayoutConfig,
): TimeScale {
// Collect all time values
const times: number[] = [];
for (const t of tasks) {
if (t.startMs != null) times.push(t.startMs);
if (t.endMs != null) times.push(t.endMs);
}
for (const m of milestones) {
times.push(m.timeMs);
}
if (times.length === 0) {
// Fallback: a 10-second window around now
const now = Date.now();
times.push(now - 5000, now + 5000);
}
let minMs = Math.min(...times);
let maxMs = Math.max(...times);
// Add a small buffer so nodes at the edges aren't right on the border
const rangeMs = maxMs - minMs;
const bufferMs = Math.max(rangeMs * 0.04, 200); // at least 200ms buffer
minMs -= bufferMs;
maxMs += bufferMs;
const axisWidth = chartWidth - config.paddingLeft - config.paddingRight;
const pxPerMs = axisWidth / Math.max(maxMs - minMs, 1);
return { minMs, maxMs, axisWidth, pxPerMs };
}
/** Convert a timestamp (epoch ms) to an x pixel position */
function timeToPx(ms: number, scale: TimeScale, config: LayoutConfig): number {
return config.paddingLeft + (ms - scale.minMs) * scale.pxPerMs;
}
// ---------------------------------------------------------------------------
// Lane assignment (greedy packing)
// ---------------------------------------------------------------------------
interface LaneInterval {
/** Left x pixel (inclusive) */
left: number;
/** Right x pixel (inclusive) */
right: number;
}
/**
* Assign each task to the first lane where it doesn't overlap with
* any existing task bar in that lane.
*
* Tasks are sorted by startTime (earliest first), then by duration
* descending (longer bars first) to maximise packing efficiency.
*
* After initial packing we optionally reorder lanes so tasks with
* shared upstream dependencies are adjacent.
*/
function assignLanes(
tasks: TimelineTask[],
scale: TimeScale,
config: LayoutConfig,
): Map<string, number> {
// Build a sortable list with pixel extents
type Entry = {
task: TimelineTask;
left: number;
right: number;
};
const entries: Entry[] = tasks.map((t) => {
const left = t.startMs != null ? timeToPx(t.startMs, scale, config) : 0;
let right =
t.endMs != null
? timeToPx(t.endMs, scale, config)
: left + config.minBarWidth;
// Ensure minimum width
if (right - left < config.minBarWidth) {
right = left + config.minBarWidth;
}
return { task: t, left, right };
});
// Sort: by start position, then by width descending (longer bars first)
entries.sort((a, b) => {
if (a.left !== b.left) return a.left - b.left;
return b.right - b.left - (a.right - a.left);
});
// Greedy lane packing
const lanes: LaneInterval[][] = []; // lanes[laneIndex] = list of intervals
const assignment = new Map<string, number>();
for (const entry of entries) {
let placed = false;
const gap = 4; // minimum px gap between bars in the same lane
for (let lane = 0; lane < lanes.length; lane++) {
const intervals = lanes[lane];
const overlaps = intervals.some(
(iv) => entry.left < iv.right + gap && entry.right + gap > iv.left,
);
if (!overlaps) {
intervals.push({ left: entry.left, right: entry.right });
assignment.set(entry.task.id, lane);
placed = true;
break;
}
}
if (!placed) {
// Open a new lane
lanes.push([{ left: entry.left, right: entry.right }]);
assignment.set(entry.task.id, lanes.length - 1);
}
}
// --- Optional lane reordering to cluster related tasks ---
// Build a lane affinity score based on shared upstream dependencies.
// We do a simple bubble-pass: for each pair of adjacent lanes,
// if swapping them increases the total number of adjacent upstream-sharing
// task pairs, do the swap.
const laneCount = lanes.length;
if (laneCount > 2) {
const laneIds: number[] = Array.from({ length: laneCount }, (_, i) => i);
// Build lane→taskIds mapping
const tasksByLane = new Map<number, string[]>();
for (const [taskId, lane] of assignment) {
const list = tasksByLane.get(lane) ?? [];
list.push(taskId);
tasksByLane.set(lane, list);
}
// Build a task→upstreams lookup
const taskUpstreams = new Map<string, Set<string>>();
for (const t of tasks) {
taskUpstreams.set(t.id, new Set(t.upstreamIds));
}
// Affinity between two lanes: count of task pairs that share upstream deps
function laneAffinity(laneA: number, laneB: number): number {
const aTasks = tasksByLane.get(laneA) ?? [];
const bTasks = tasksByLane.get(laneB) ?? [];
let score = 0;
for (const a of aTasks) {
const aUp = taskUpstreams.get(a);
if (!aUp || aUp.size === 0) continue;
for (const b of bTasks) {
const bUp = taskUpstreams.get(b);
if (!bUp || bUp.size === 0) continue;
// Count shared upstreams
for (const u of aUp) {
if (bUp.has(u)) {
score++;
break; // one shared upstream is enough for this pair
}
}
}
}
return score;
}
// Simple bubble sort passes (max 3 passes for stability)
for (let pass = 0; pass < 3; pass++) {
let swapped = false;
for (let i = 0; i < laneIds.length - 1; i++) {
const curr = laneIds[i];
const next = laneIds[i + 1];
// Check if swapping improves adjacency with neighbours
const prev = i > 0 ? laneIds[i - 1] : -1;
const after = i + 2 < laneIds.length ? laneIds[i + 2] : -1;
let scoreBefore = 0;
let scoreAfter = 0;
if (prev >= 0) {
scoreBefore += laneAffinity(prev, curr);
scoreAfter += laneAffinity(prev, next);
}
if (after >= 0) {
scoreBefore += laneAffinity(next, after);
scoreAfter += laneAffinity(curr, after);
}
scoreBefore += laneAffinity(curr, next);
scoreAfter += laneAffinity(next, curr); // same, symmetric
if (scoreAfter > scoreBefore) {
laneIds[i] = next;
laneIds[i + 1] = curr;
swapped = true;
}
}
if (!swapped) break;
}
// Remap lane assignments to the reordered indices
const reorderMap = new Map<number, number>();
for (let newIdx = 0; newIdx < laneIds.length; newIdx++) {
reorderMap.set(laneIds[newIdx], newIdx);
}
for (const [taskId, oldLane] of assignment) {
assignment.set(taskId, reorderMap.get(oldLane) ?? oldLane);
}
}
return assignment;
}
// ---------------------------------------------------------------------------
// Milestone lane assignment
// ---------------------------------------------------------------------------
/**
* Position milestones in a lane that centres them vertically relative to
* the tasks they connect to. Start and end milestones go to a middle lane.
* Internal merge/fork milestones are placed at the median lane of their
* connected tasks.
*/
function assignMilestoneLanes(
milestones: TimelineMilestone[],
milestoneEdges: TimelineEdge[],
taskLanes: Map<string, number>,
laneCount: number,
): Map<string, number> {
const assignment = new Map<string, number>();
const midLane = Math.max(0, Math.floor((laneCount - 1) / 2));
for (const ms of milestones) {
if (ms.kind === "start" || ms.kind === "end") {
assignment.set(ms.id, midLane);
continue;
}
// Gather lanes of connected tasks
const connectedLanes: number[] = [];
for (const e of milestoneEdges) {
if (e.from === ms.id) {
const lane = taskLanes.get(e.to);
if (lane != null) connectedLanes.push(lane);
}
if (e.to === ms.id) {
const lane = taskLanes.get(e.from);
if (lane != null) connectedLanes.push(lane);
}
}
if (connectedLanes.length > 0) {
connectedLanes.sort((a, b) => a - b);
const median = connectedLanes[Math.floor(connectedLanes.length / 2)];
assignment.set(ms.id, median);
} else {
assignment.set(ms.id, midLane);
}
}
return assignment;
}
// ---------------------------------------------------------------------------
// Build TimelineNode array
// ---------------------------------------------------------------------------
function buildNodes(
tasks: TimelineTask[],
milestones: TimelineMilestone[],
taskLanes: Map<string, number>,
milestoneLanes: Map<string, number>,
scale: TimeScale,
config: LayoutConfig,
): TimelineNode[] {
const nodes: TimelineNode[] = [];
// Task nodes
for (const task of tasks) {
const lane = taskLanes.get(task.id) ?? 0;
const left =
task.startMs != null
? timeToPx(task.startMs, scale, config)
: timeToPx(
scale.maxMs - (scale.maxMs - scale.minMs) * 0.05,
scale,
config,
);
let right =
task.endMs != null
? timeToPx(task.endMs, scale, config)
: left + config.minBarWidth;
if (right - left < config.minBarWidth) {
right = left + config.minBarWidth;
}
const y =
config.paddingTop +
lane * config.laneHeight +
(config.laneHeight - config.barHeight) / 2;
nodes.push({
type: "task",
id: task.id,
lane,
x: left,
y,
width: right - left,
task,
});
}
// Milestone nodes
for (const ms of milestones) {
const lane = milestoneLanes.get(ms.id) ?? 0;
const x = timeToPx(ms.timeMs, scale, config);
const y =
config.paddingTop + lane * config.laneHeight + config.laneHeight / 2;
nodes.push({
type: "milestone",
id: ms.id,
lane,
x,
y,
width: config.milestoneSize,
milestone: ms,
});
}
return nodes;
}
// ---------------------------------------------------------------------------
// Grid line computation
// ---------------------------------------------------------------------------
export interface GridLine {
/** X pixel position */
x: number;
/** Human-readable label */
label: string;
/** Whether this is a major gridline (gets a label) */
major: boolean;
}
/**
* Compute vertical gridlines at "nice" time intervals.
*
* Picks an interval that gives roughly 612 major gridlines across
* the visible chart width.
*/
export function computeGridLines(
scale: TimeScale,
config: LayoutConfig,
): GridLine[] {
const rangeMs = scale.maxMs - scale.minMs;
if (rangeMs <= 0) return [];
// Target ~8 major gridlines
const targetCount = 8;
const rawInterval = rangeMs / targetCount;
// Snap to a "nice" interval
const niceIntervals = [
100,
200,
500, // sub-second
1000,
2000,
5000, // seconds
10_000,
15_000,
30_000, // tens of seconds
60_000,
120_000,
300_000, // minutes
600_000,
900_000,
1_800_000, // tens of minutes
3_600_000,
7_200_000, // hours
14_400_000,
28_800_000,
43_200_000, // multi-hour
86_400_000, // day
];
let interval = niceIntervals[0];
for (const ni of niceIntervals) {
interval = ni;
if (ni >= rawInterval) break;
}
const lines: GridLine[] = [];
// Start at the first "nice" multiple >= minMs
const firstTick = Math.ceil(scale.minMs / interval) * interval;
for (let ms = firstTick; ms <= scale.maxMs; ms += interval) {
const x = timeToPx(ms, scale, config);
lines.push({
x,
label: formatTimeLabel(ms, interval),
major: true,
});
// Add a minor gridline halfway if the interval is large enough
if (interval >= 2000) {
const midMs = ms + interval / 2;
if (midMs < scale.maxMs) {
lines.push({
x: timeToPx(midMs, scale, config),
label: "",
major: false,
});
}
}
}
return lines;
}
/** Format a timestamp as a short label relative to the chart start */
function formatTimeLabel(ms: number, intervalMs: number): string {
const date = new Date(ms);
if (intervalMs >= 86_400_000) {
// Days — show date
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
if (intervalMs >= 3_600_000) {
// Hours — show HH:MM
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
if (intervalMs >= 60_000) {
// Minutes — show HH:MM:SS
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
if (intervalMs >= 1000) {
// Seconds — show HH:MM:SS
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
// Sub-second — show with milliseconds
return (
date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}) +
"." +
String(date.getMilliseconds()).padStart(3, "0")
);
}
// ---------------------------------------------------------------------------
// Public API: computeLayout
// ---------------------------------------------------------------------------
export function computeLayout(
tasks: TimelineTask[],
taskEdges: TimelineEdge[],
milestones: TimelineMilestone[],
milestoneEdges: TimelineEdge[],
/** Desired chart width (pixels). The layout will use this for the x-scale. */
chartWidth: number,
configOverrides?: Partial<LayoutConfig>,
/** Direct task→task edge keys that are replaced by milestone-routed paths.
* These are filtered out of `taskEdges` to avoid duplicate rendering. */
suppressedEdgeKeys?: Set<string>,
): ComputedLayout {
const config: LayoutConfig = { ...DEFAULT_LAYOUT, ...configOverrides };
// Use a reasonable minimum width
const effectiveWidth = Math.max(chartWidth, 400);
// 1. Build time scale
const scale = buildTimeScale(tasks, milestones, effectiveWidth, config);
// 2. Assign task lanes
const taskLanes = assignLanes(tasks, scale, config);
// Count lanes
let laneCount = 0;
for (const lane of taskLanes.values()) {
laneCount = Math.max(laneCount, lane + 1);
}
// Ensure at least 1 lane even if there are no tasks
laneCount = Math.max(laneCount, 1);
// 3. Assign milestone lanes
const milestoneLanes = assignMilestoneLanes(
milestones,
milestoneEdges,
taskLanes,
laneCount,
);
// 4. Build node positions
const nodes = buildNodes(
tasks,
milestones,
taskLanes,
milestoneLanes,
scale,
config,
);
// 5. Merge all edges, filtering out any task edges that have been
// replaced by milestone-routed paths (e.g. A→C replaced by A→merge→C).
const filteredTaskEdges = suppressedEdgeKeys?.size
? taskEdges.filter((e) => !suppressedEdgeKeys.has(`${e.from}${e.to}`))
: taskEdges;
const allEdges = [...filteredTaskEdges, ...milestoneEdges];
// Deduplicate edges (same from→to)
const edgeSet = new Set<string>();
const dedupedEdges: TimelineEdge[] = [];
for (const e of allEdges) {
const key = `${e.from}${e.to}`;
if (!edgeSet.has(key)) {
edgeSet.add(key);
dedupedEdges.push(e);
}
}
// 6. Compute total dimensions
const totalWidth = effectiveWidth;
const totalHeight =
config.paddingTop + laneCount * config.laneHeight + config.paddingBottom;
return {
nodes,
edges: dedupedEdges,
totalWidth,
totalHeight,
laneCount,
minTimeMs: scale.minMs,
maxTimeMs: scale.maxMs,
pxPerMs: scale.pxPerMs,
};
}
// ---------------------------------------------------------------------------
// Bezier edge path generation
// ---------------------------------------------------------------------------
/**
* Generate an SVG cubic Bezier path string for an edge between two nodes.
*
* Edges flow left→right. The control points bend horizontally so curves
* are smooth and mostly follow the x-axis direction.
*
* Anchoring:
* - Task nodes: outgoing from right-center, incoming at left-center
* - Milestones: connect at center
*/
export function computeEdgePath(
fromNode: TimelineNode,
toNode: TimelineNode,
config: LayoutConfig = DEFAULT_LAYOUT,
): string {
let x1: number, y1: number, x2: number, y2: number;
// Source anchor
if (fromNode.type === "task") {
x1 = fromNode.x + fromNode.width; // right edge
y1 = fromNode.y + config.barHeight / 2; // vertical center
} else {
x1 = fromNode.x;
y1 = fromNode.y;
}
// Target anchor
if (toNode.type === "task") {
x2 = toNode.x; // left edge
y2 = toNode.y + config.barHeight / 2; // vertical center
} else {
x2 = toNode.x;
y2 = toNode.y;
}
// Handle edge case where target is to the left of source (e.g., timing quirks)
// In that case, draw a slight arc that loops
const dx = x2 - x1;
const dy = y2 - y1;
if (dx < 5) {
// Target is to the left or very close — use an S-curve that goes
// slightly below/above and loops back
const loopOffset = Math.max(30, Math.abs(dx) + 20);
const yMid = (y1 + y2) / 2 + (dy >= 0 ? 20 : -20);
return [
`M ${x1} ${y1}`,
`C ${x1 + loopOffset} ${y1}, ${x2 - loopOffset} ${yMid}, ${(x1 + x2) / 2} ${yMid}`,
`C ${(x1 + x2) / 2 + loopOffset} ${yMid}, ${x2 - loopOffset} ${y2}, ${x2} ${y2}`,
].join(" ");
}
// Normal left→right Bezier
// Control point offset: 40% of horizontal distance, clamped
const cpOffset = Math.min(Math.max(dx * 0.4, 20), 120);
const cx1 = x1 + cpOffset;
const cy1 = y1;
const cx2 = x2 - cpOffset;
const cy2 = y2;
return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
}
// ---------------------------------------------------------------------------
// Export timeToPx for use by the renderer (gridlines etc.)
// ---------------------------------------------------------------------------
export { timeToPx, type TimeScale };

View File

@@ -0,0 +1,285 @@
/**
* Workflow Timeline DAG Types
*
* Types for the Prefect-style workflow run timeline visualization.
* This component renders workflow task executions as horizontal duration bars
* on a time axis with curved dependency edges showing the DAG structure.
*/
import type { ExecutionSummary } from "@/api";
// ---------------------------------------------------------------------------
// Core data types
// ---------------------------------------------------------------------------
export type TaskState =
| "completed"
| "running"
| "failed"
| "pending"
| "timeout"
| "cancelled"
| "abandoned";
/**
* Metadata for a collapsed with_items group node.
* When a with_items task has ≥ WITH_ITEMS_COLLAPSE_THRESHOLD items, all
* individual item executions are merged into a single TimelineTask carrying
* this info so the renderer can display a compact "task ×N" bar.
*/
export interface WithItemsGroupInfo {
/** Total number of items in the group */
totalItems: number;
/** Per-state item counts */
completed: number;
failed: number;
running: number;
pending: number;
timedOut: number;
cancelled: number;
/** Concurrency limit declared on the task (0 = unlimited / unknown) */
concurrency: number;
/** IDs of all member executions (for upstream/downstream tracking) */
memberIds: string[];
}
/** Threshold at which with_items children are collapsed into a single node */
export const WITH_ITEMS_COLLAPSE_THRESHOLD = 10;
/** A single task run positioned on the timeline */
export interface TimelineTask {
/** Unique identifier (execution ID as string) */
id: string;
/** Display name (task_name from workflow_task metadata) */
name: string;
/** Action reference */
actionRef: string;
/** Visual state for coloring */
state: TaskState;
/** Start time as epoch ms (null if not yet started) */
startMs: number | null;
/** End time as epoch ms (null if still running or not started) */
endMs: number | null;
/** IDs of upstream tasks this depends on */
upstreamIds: string[];
/** IDs of downstream tasks that depend on this */
downstreamIds: string[];
/** with_items task index (null if not a with_items expansion) */
taskIndex: number | null;
/** Whether this task timed out */
timedOut: boolean;
/** Retry info */
retryCount: number;
maxRetries: number;
/** Duration in ms (from metadata or computed) */
durationMs: number | null;
/** Original execution summary for tooltip details */
execution: ExecutionSummary;
/**
* Present only on collapsed with_items group nodes.
* When set, this task represents multiple item executions merged into one.
*/
groupInfo?: WithItemsGroupInfo;
}
// ---------------------------------------------------------------------------
// Synthetic milestone / junction nodes
// ---------------------------------------------------------------------------
export type MilestoneKind = "start" | "end" | "merge" | "fork";
export interface TimelineMilestone {
id: string;
kind: MilestoneKind;
/** Position on the time axis (epoch ms) */
timeMs: number;
/** Human-readable label */
label: string;
}
// ---------------------------------------------------------------------------
// Unified node type (task bar OR milestone)
// ---------------------------------------------------------------------------
export type TimelineNodeType = "task" | "milestone";
export interface TimelineNode {
type: TimelineNodeType;
/** Unique ID */
id: string;
/** Assigned lane (y index) */
lane: number;
/** Pixel positions (computed by layout) */
x: number;
y: number;
width: number;
/** Original data */
task?: TimelineTask;
milestone?: TimelineMilestone;
}
// ---------------------------------------------------------------------------
// Edges
// ---------------------------------------------------------------------------
export type EdgeKind = "success" | "failure" | "always" | "timeout" | "custom";
export interface TimelineEdge {
/** Source node ID */
from: string;
/** Target node ID */
to: string;
/** Visual classification for coloring */
kind: EdgeKind;
/** Optional transition label (e.g. "succeeded", "failed") */
label?: string;
/** Optional custom color from workflow definition */
color?: string;
}
// ---------------------------------------------------------------------------
// Layout constants
// ---------------------------------------------------------------------------
export interface LayoutConfig {
/** Height of each lane in pixels */
laneHeight: number;
/** Height of a task bar in pixels */
barHeight: number;
/** Vertical padding within each lane */
lanePadding: number;
/** Size of milestone diamond/square in pixels */
milestoneSize: number;
/** Left padding for the chart area (px) */
paddingLeft: number;
/** Right padding for the chart area (px) */
paddingRight: number;
/** Top padding for the time axis area (px) */
paddingTop: number;
/** Bottom padding (px) */
paddingBottom: number;
/** Minimum bar width for very short tasks (px) */
minBarWidth: number;
/** Horizontal gap between milestone and adjacent bars (px) */
milestoneGap: number;
}
export const DEFAULT_LAYOUT: LayoutConfig = {
laneHeight: 32,
barHeight: 20,
lanePadding: 6,
milestoneSize: 10,
paddingLeft: 20,
paddingRight: 20,
paddingTop: 36,
paddingBottom: 16,
minBarWidth: 8,
milestoneGap: 12,
};
// ---------------------------------------------------------------------------
// Computed layout result
// ---------------------------------------------------------------------------
export interface ComputedLayout {
nodes: TimelineNode[];
edges: TimelineEdge[];
/** Total width needed (px) */
totalWidth: number;
/** Total height needed (px) */
totalHeight: number;
/** Number of lanes used */
laneCount: number;
/** Time bounds */
minTimeMs: number;
maxTimeMs: number;
/** The linear scale factor: px per ms */
pxPerMs: number;
}
// ---------------------------------------------------------------------------
// Interaction state
// ---------------------------------------------------------------------------
export interface TooltipData {
task: TimelineTask;
x: number;
y: number;
}
export interface ViewState {
/** Horizontal scroll offset (px) */
scrollX: number;
/** Zoom level (1.0 = default) */
zoom: number;
}
// ---------------------------------------------------------------------------
// Workflow definition transition types (for edge extraction)
// ---------------------------------------------------------------------------
export interface WorkflowDefinitionTransition {
when?: string;
publish?: Record<string, string>[];
do?: string[];
__chart_meta__?: {
label?: string;
color?: string;
line_style?: string;
};
}
export interface WorkflowDefinitionTask {
name: string;
action?: string;
next?: WorkflowDefinitionTransition[];
/** Number of inbound tasks that must complete before this task runs */
join?: number;
/** with_items expression (present when the task fans out over a list) */
with_items?: string;
/** Max concurrent items for with_items (default 1 = serial) */
concurrency?: number;
// Legacy fields (auto-converted to next)
on_success?: string | string[];
on_failure?: string | string[];
on_complete?: string | string[];
on_timeout?: string | string[];
}
export interface WorkflowDefinition {
ref?: string;
label?: string;
tasks?: WorkflowDefinitionTask[];
}
// ---------------------------------------------------------------------------
// Color constants
// ---------------------------------------------------------------------------
export const STATE_COLORS: Record<
TaskState,
{ bg: string; border: string; text: string }
> = {
completed: { bg: "#dcfce7", border: "#22c55e", text: "#15803d" },
running: { bg: "#dbeafe", border: "#3b82f6", text: "#1d4ed8" },
failed: { bg: "#fee2e2", border: "#ef4444", text: "#b91c1c" },
pending: { bg: "#f3f4f6", border: "#9ca3af", text: "#6b7280" },
timeout: { bg: "#ffedd5", border: "#f97316", text: "#c2410c" },
cancelled: { bg: "#f3f4f6", border: "#9ca3af", text: "#6b7280" },
abandoned: { bg: "#fee2e2", border: "#f87171", text: "#b91c1c" },
};
export const EDGE_KIND_COLORS: Record<EdgeKind, string> = {
success: "#22c55e",
failure: "#ef4444",
always: "#9ca3af",
timeout: "#f97316",
custom: "#8b5cf6",
};
export const MILESTONE_COLORS: Record<MilestoneKind, string> = {
start: "#6b7280",
end: "#6b7280",
merge: "#8b5cf6",
fork: "#8b5cf6",
};