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

@@ -62,6 +62,7 @@ export type ExecutionResponse = {
workflow_task?: {
workflow_execution: number;
task_name: string;
triggered_by?: string | null;
task_index?: number | null;
task_batch?: number | null;
retry_count: number;

View File

@@ -54,6 +54,7 @@ export type ExecutionSummary = {
workflow_task?: {
workflow_execution: number;
task_name: string;
triggered_by?: string | null;
task_index?: number | null;
task_batch?: number | null;
retry_count: number;

View File

@@ -1,326 +0,0 @@
import { useState, useMemo } from "react";
import { Link } from "react-router-dom";
import { formatDistanceToNow } from "date-fns";
import {
ChevronDown,
ChevronRight,
Workflow,
CheckCircle2,
XCircle,
Clock,
Loader2,
AlertTriangle,
Ban,
CircleDot,
RotateCcw,
} from "lucide-react";
import { useChildExecutions } from "@/hooks/useExecutions";
import { useExecutionStream } from "@/hooks/useExecutionStream";
interface WorkflowTasksPanelProps {
/** The parent (workflow) execution ID */
parentExecutionId: number;
/** Whether the panel starts collapsed (default: false — open by default for workflows) */
defaultCollapsed?: boolean;
}
/** Format a duration in ms to a human-readable string. */
function formatDuration(ms: number): string {
if (ms < 1000) return `${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`;
}
function getStatusIcon(status: string) {
switch (status) {
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />;
case "running":
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />;
case "requested":
case "scheduling":
case "scheduled":
return <Clock className="h-4 w-4 text-yellow-500" />;
case "timeout":
return <AlertTriangle className="h-4 w-4 text-orange-500" />;
case "canceling":
case "cancelled":
return <Ban className="h-4 w-4 text-gray-400" />;
case "abandoned":
return <AlertTriangle className="h-4 w-4 text-red-400" />;
default:
return <CircleDot className="h-4 w-4 text-gray-400" />;
}
}
function getStatusBadgeClasses(status: string): string {
switch (status) {
case "completed":
return "bg-green-100 text-green-800";
case "failed":
return "bg-red-100 text-red-800";
case "running":
return "bg-blue-100 text-blue-800";
case "requested":
case "scheduling":
case "scheduled":
return "bg-yellow-100 text-yellow-800";
case "timeout":
return "bg-orange-100 text-orange-800";
case "canceling":
case "cancelled":
return "bg-gray-100 text-gray-800";
case "abandoned":
return "bg-red-100 text-red-600";
default:
return "bg-gray-100 text-gray-800";
}
}
/**
* Panel that displays workflow task (child) executions for a parent
* workflow execution. Shows each task's name, action, status, and timing.
*/
export default function WorkflowTasksPanel({
parentExecutionId,
defaultCollapsed = false,
}: WorkflowTasksPanelProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const { data, isLoading, error } = useChildExecutions(parentExecutionId);
// Subscribe to the unfiltered execution stream so that child execution
// WebSocket notifications update the ["executions", { parent }] query cache
// in real-time (the detail page only subscribes filtered by its own ID).
useExecutionStream({ enabled: true });
const tasks = useMemo(() => {
if (!data?.data) return [];
return data.data;
}, [data]);
const summary = useMemo(() => {
const total = tasks.length;
const completed = tasks.filter((t) => t.status === "completed").length;
const failed = tasks.filter((t) => t.status === "failed").length;
const running = tasks.filter(
(t) =>
t.status === "running" ||
t.status === "requested" ||
t.status === "scheduling" ||
t.status === "scheduled",
).length;
const other = total - completed - failed - running;
return { total, completed, failed, running, other };
}, [tasks]);
if (!isLoading && tasks.length === 0 && !error) {
// No child tasks — nothing to show
return null;
}
return (
<div className="bg-white shadow rounded-lg">
{/* Header */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="w-full flex items-center justify-between p-6 text-left hover:bg-gray-50 rounded-lg transition-colors"
>
<div className="flex items-center gap-3">
{isCollapsed ? (
<ChevronRight className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
<Workflow className="h-5 w-5 text-indigo-500" />
<h2 className="text-xl font-semibold">Workflow Tasks</h2>
{!isLoading && (
<span className="text-sm text-gray-500">
({summary.total} task{summary.total !== 1 ? "s" : ""})
</span>
)}
</div>
{/* Summary badges */}
{!isCollapsed || !isLoading ? (
<div className="flex items-center gap-2">
{summary.completed > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<CheckCircle2 className="h-3 w-3" />
{summary.completed}
</span>
)}
{summary.running > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<Loader2 className="h-3 w-3 animate-spin" />
{summary.running}
</span>
)}
{summary.failed > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<XCircle className="h-3 w-3" />
{summary.failed}
</span>
)}
{summary.other > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
{summary.other}
</span>
)}
</div>
) : null}
</button>
{/* Content */}
{!isCollapsed && (
<div className="px-6 pb-6">
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="ml-2 text-sm text-gray-500">
Loading workflow tasks
</span>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded text-sm">
Error loading workflow tasks:{" "}
{error instanceof Error ? error.message : "Unknown error"}
</div>
)}
{!isLoading && !error && tasks.length > 0 && (
<div className="space-y-2">
{/* Column headers */}
<div className="grid grid-cols-12 gap-3 px-3 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider border-b border-gray-100">
<div className="col-span-1">#</div>
<div className="col-span-3">Task</div>
<div className="col-span-3">Action</div>
<div className="col-span-2">Status</div>
<div className="col-span-2">Duration</div>
<div className="col-span-1">Retry</div>
</div>
{/* Task rows */}
{tasks.map((task, idx) => {
const wt = task.workflow_task;
const taskName = wt?.task_name ?? `Task ${idx + 1}`;
const retryCount = wt?.retry_count ?? 0;
const maxRetries = wt?.max_retries ?? 0;
const timedOut = wt?.timed_out ?? false;
// Compute duration from started_at → updated (actual run time)
const startedAt = task.started_at
? new Date(task.started_at)
: null;
const created = new Date(task.created);
const updated = new Date(task.updated);
const isTerminal =
task.status === "completed" ||
task.status === "failed" ||
task.status === "timeout";
const durationMs =
wt?.duration_ms ??
(isTerminal && startedAt
? updated.getTime() - startedAt.getTime()
: null);
return (
<Link
key={task.id}
to={`/executions/${task.id}`}
className="grid grid-cols-12 gap-3 px-3 py-3 rounded-lg hover:bg-gray-50 transition-colors items-center group"
>
{/* Index */}
<div className="col-span-1 text-sm text-gray-400 font-mono">
{idx + 1}
</div>
{/* Task name */}
<div className="col-span-3 flex items-center gap-2 min-w-0">
{getStatusIcon(task.status)}
<span
className="text-sm font-medium text-gray-900 truncate group-hover:text-blue-600"
title={taskName}
>
{taskName}
</span>
{wt?.task_index != null && (
<span className="text-xs text-gray-400 flex-shrink-0">
[{wt.task_index}]
</span>
)}
</div>
{/* Action ref */}
<div className="col-span-3 min-w-0">
<span
className="text-sm text-gray-600 truncate block"
title={task.action_ref}
>
{task.action_ref}
</span>
</div>
{/* Status badge */}
<div className="col-span-2 flex items-center gap-1.5">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClasses(task.status)}`}
>
{task.status}
</span>
{timedOut && (
<span title="Timed out">
<AlertTriangle className="h-3.5 w-3.5 text-orange-500" />
</span>
)}
</div>
{/* Duration */}
<div className="col-span-2 text-sm text-gray-500">
{task.status === "running" ? (
<span className="text-blue-600">
{formatDistanceToNow(startedAt ?? created, {
addSuffix: false,
})}
</span>
) : durationMs != null && durationMs > 0 ? (
formatDuration(durationMs)
) : (
<span className="text-gray-300"></span>
)}
</div>
{/* Retry info */}
<div className="col-span-1 text-sm text-gray-500">
{maxRetries > 0 ? (
<span
className="inline-flex items-center gap-0.5"
title={`Attempt ${retryCount + 1} of ${maxRetries + 1}`}
>
<RotateCcw className="h-3 w-3" />
{retryCount}/{maxRetries}
</span>
) : (
<span className="text-gray-300"></span>
)}
</div>
</Link>
);
})}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect, useCallback } from "react";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { formatDistanceToNow } from "date-fns";
import {
ChevronDown,
@@ -14,6 +14,7 @@ import {
Download,
Eye,
X,
Radio,
} from "lucide-react";
import {
useExecutionArtifacts,
@@ -136,7 +137,110 @@ function TextFileDetail({
const [content, setContent] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [isLoadingContent, setIsLoadingContent] = useState(true);
const [isStreaming, setIsStreaming] = useState(false);
const [isWaiting, setIsWaiting] = useState(false);
const [streamDone, setStreamDone] = useState(false);
const preRef = useRef<HTMLPreElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
// Track whether the user has scrolled away from the bottom so we can
// auto-scroll only when they're already at the end.
const userScrolledAwayRef = useRef(false);
// Auto-scroll the <pre> to the bottom when new content arrives,
// unless the user has deliberately scrolled up.
const scrollToBottom = useCallback(() => {
const el = preRef.current;
if (el && !userScrolledAwayRef.current) {
el.scrollTop = el.scrollHeight;
}
}, []);
// Detect whether the user has scrolled away from the bottom.
const handleScroll = useCallback(() => {
const el = preRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
userScrolledAwayRef.current = !atBottom;
}, []);
// ---- SSE streaming path (used when execution is running) ----
useEffect(() => {
if (!isRunning) return;
const token = localStorage.getItem("access_token");
if (!token) {
setLoadError("No authentication token available");
setIsLoadingContent(false);
return;
}
const url = `${OpenAPI.BASE}/api/v1/artifacts/${artifactId}/stream?token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
eventSourceRef.current = es;
setIsStreaming(true);
setStreamDone(false);
es.addEventListener("waiting", (e: MessageEvent) => {
setIsWaiting(true);
setIsLoadingContent(false);
// If the message says "File found", the next event will be content
if (e.data?.includes("File found")) {
setIsWaiting(false);
}
});
es.addEventListener("content", (e: MessageEvent) => {
setContent(e.data);
setLoadError(null);
setIsLoadingContent(false);
setIsWaiting(false);
// Scroll after React renders the new content
requestAnimationFrame(scrollToBottom);
});
es.addEventListener("append", (e: MessageEvent) => {
setContent((prev) => (prev ?? "") + e.data);
setLoadError(null);
requestAnimationFrame(scrollToBottom);
});
es.addEventListener("done", () => {
setStreamDone(true);
setIsStreaming(false);
es.close();
});
es.addEventListener("error", (e: MessageEvent) => {
// SSE spec fires generic error events on connection close.
// Only show user-facing errors if the server sent an explicit event.
if (e.data) {
setLoadError(e.data);
}
});
es.onerror = () => {
// Connection dropped — EventSource will auto-reconnect, but if it
// reaches CLOSED state we fall back to the download endpoint.
if (es.readyState === EventSource.CLOSED) {
setIsStreaming(false);
// If we never got any content via SSE, fall back to download
setContent((prev) => {
if (prev === null) {
// Will be handled by the fetch fallback below
}
return prev;
});
}
};
return () => {
es.close();
eventSourceRef.current = null;
setIsStreaming(false);
};
}, [artifactId, isRunning, scrollToBottom]);
// ---- Fetch fallback (used when not running, or SSE never connected) ----
const fetchContent = useCallback(async () => {
const token = localStorage.getItem("access_token");
const url = `${OpenAPI.BASE}/api/v1/artifacts/${artifactId}/download`;
@@ -159,16 +263,10 @@ function TextFileDetail({
}
}, [artifactId]);
// Initial load
// When NOT running (execution completed), use download endpoint once.
useEffect(() => {
if (isRunning) return;
fetchContent();
}, [fetchContent]);
// Poll while running to pick up new file versions
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(fetchContent, 3000);
return () => clearInterval(interval);
}, [isRunning, fetchContent]);
return (
@@ -179,10 +277,19 @@ function TextFileDetail({
{artifactName ?? "Text File"}
</h4>
<div className="flex items-center gap-2">
{isRunning && (
<div className="flex items-center gap-1 text-xs text-blue-600">
{isStreaming && !streamDone && (
<div className="flex items-center gap-1 text-xs text-green-600">
<Radio className="h-3 w-3 animate-pulse" />
<span>Streaming</span>
</div>
)}
{streamDone && (
<span className="text-xs text-gray-500">Stream complete</span>
)}
{isWaiting && (
<div className="flex items-center gap-1 text-xs text-amber-600">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Live</span>
<span>Waiting for file</span>
</div>
)}
<button
@@ -194,7 +301,7 @@ function TextFileDetail({
</div>
</div>
{isLoadingContent && (
{isLoadingContent && !isWaiting && (
<div className="flex items-center gap-2 py-2 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
Loading content
@@ -206,10 +313,20 @@ function TextFileDetail({
)}
{!isLoadingContent && !loadError && content !== null && (
<pre className="max-h-64 overflow-y-auto bg-gray-900 text-gray-100 rounded p-3 text-xs font-mono whitespace-pre-wrap break-all">
<pre
ref={preRef}
onScroll={handleScroll}
className="max-h-64 overflow-y-auto bg-gray-900 text-gray-100 rounded p-3 text-xs font-mono whitespace-pre-wrap break-all"
>
{content || <span className="text-gray-500 italic">(empty)</span>}
</pre>
)}
{isWaiting && content === null && !loadError && (
<div className="bg-gray-900 rounded p-3 text-xs text-gray-500 italic">
Waiting for the worker to write the file
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,446 @@
import { useState, useMemo } from "react";
import { Link } from "react-router-dom";
import { formatDistanceToNow } from "date-fns";
import {
ChevronDown,
ChevronRight,
Workflow,
ChartGantt,
List,
CheckCircle2,
XCircle,
Clock,
Loader2,
AlertTriangle,
Ban,
CircleDot,
RotateCcw,
} from "lucide-react";
import type { ExecutionSummary } from "@/api";
import { useChildExecutions } from "@/hooks/useExecutions";
import { useExecutionStream } from "@/hooks/useExecutionStream";
import WorkflowTimelineDAG, {
type ParentExecutionInfo,
} from "@/components/executions/workflow-timeline";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type TabId = "timeline" | "tasks";
interface WorkflowDetailsPanelProps {
/** The parent (workflow) execution */
parentExecution: ParentExecutionInfo;
/** The action_ref of the parent execution (used to fetch workflow def) */
actionRef: string;
/** Whether the panel starts collapsed (default: false) */
defaultCollapsed?: boolean;
/** Which tab to show initially (default: "timeline") */
defaultTab?: TabId;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatDuration(ms: number): string {
if (ms < 1000) return `${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`;
}
function getStatusIcon(status: string) {
switch (status) {
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />;
case "running":
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />;
case "requested":
case "scheduling":
case "scheduled":
return <Clock className="h-4 w-4 text-yellow-500" />;
case "timeout":
return <AlertTriangle className="h-4 w-4 text-orange-500" />;
case "canceling":
case "cancelled":
return <Ban className="h-4 w-4 text-gray-400" />;
case "abandoned":
return <AlertTriangle className="h-4 w-4 text-red-400" />;
default:
return <CircleDot className="h-4 w-4 text-gray-400" />;
}
}
function getStatusBadgeClasses(status: string): string {
switch (status) {
case "completed":
return "bg-green-100 text-green-800";
case "failed":
return "bg-red-100 text-red-800";
case "running":
return "bg-blue-100 text-blue-800";
case "requested":
case "scheduling":
case "scheduled":
return "bg-yellow-100 text-yellow-800";
case "timeout":
return "bg-orange-100 text-orange-800";
case "canceling":
case "cancelled":
return "bg-gray-100 text-gray-800";
case "abandoned":
return "bg-red-100 text-red-600";
default:
return "bg-gray-100 text-gray-800";
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Combined "Workflow Details" panel that sits at the top of the execution
* detail page for workflow executions. Contains two tabs:
* - **Timeline** — the Gantt-style WorkflowTimelineDAG
* - **Tasks** — the tabular list of child task executions
*/
export default function WorkflowDetailsPanel({
parentExecution,
actionRef,
defaultCollapsed = false,
defaultTab = "timeline",
}: WorkflowDetailsPanelProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [activeTab, setActiveTab] = useState<TabId>(defaultTab);
// Fetch child executions (shared between both tabs' summary badges)
const { data, isLoading, error } = useChildExecutions(parentExecution.id);
// Subscribe to unfiltered execution stream so child execution WebSocket
// notifications update the query cache in real-time.
useExecutionStream({ enabled: true });
const tasks = useMemo(() => data?.data ?? [], [data]);
const summary = useMemo(() => {
const total = tasks.length;
const completed = tasks.filter((t) => t.status === "completed").length;
const failed = tasks.filter((t) => t.status === "failed").length;
const running = tasks.filter(
(t) =>
t.status === "running" ||
t.status === "requested" ||
t.status === "scheduling" ||
t.status === "scheduled",
).length;
const other = total - completed - failed - running;
return { total, completed, failed, running, other };
}, [tasks]);
// Don't render at all if there are no children and we're done loading
if (!isLoading && tasks.length === 0 && !error) {
return null;
}
return (
<div className="bg-white shadow rounded-lg">
{/* ----------------------------------------------------------------- */}
{/* Header row: collapse toggle + title + summary badges */}
{/* ----------------------------------------------------------------- */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-gray-50 rounded-t-lg transition-colors"
>
<div className="flex items-center gap-3">
{isCollapsed ? (
<ChevronRight className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
<Workflow className="h-5 w-5 text-indigo-500" />
<h2 className="text-xl font-semibold">Workflow Details</h2>
{!isLoading && (
<span className="text-sm text-gray-500">
({summary.total} task{summary.total !== 1 ? "s" : ""})
</span>
)}
</div>
{/* Summary badges (always visible) */}
<div className="flex items-center gap-2">
{summary.completed > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<CheckCircle2 className="h-3 w-3" />
{summary.completed}
</span>
)}
{summary.running > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<Loader2 className="h-3 w-3 animate-spin" />
{summary.running}
</span>
)}
{summary.failed > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<XCircle className="h-3 w-3" />
{summary.failed}
</span>
)}
{summary.other > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
{summary.other}
</span>
)}
</div>
</button>
{/* ----------------------------------------------------------------- */}
{/* Body (collapsible) */}
{/* ----------------------------------------------------------------- */}
{!isCollapsed && (
<div className="border-t border-gray-100">
{/* Tab bar */}
<div className="flex items-center gap-1 px-6 pt-3 pb-0">
<TabButton
active={activeTab === "timeline"}
onClick={() => setActiveTab("timeline")}
icon={<ChartGantt className="h-4 w-4" />}
label="Timeline"
/>
<TabButton
active={activeTab === "tasks"}
onClick={() => setActiveTab("tasks")}
icon={<List className="h-4 w-4" />}
label="Tasks"
/>
</div>
{/* Tab content — both tabs stay mounted so the timeline's
ResizeObserver remains active and containerWidth never resets. */}
<div className={activeTab === "timeline" ? "" : "hidden"}>
<WorkflowTimelineDAG
parentExecution={parentExecution}
actionRef={actionRef}
embedded
/>
</div>
<div className={activeTab === "tasks" ? "" : "hidden"}>
<TasksTab tasks={tasks} isLoading={isLoading} error={error} />
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Tab Button
// ---------------------------------------------------------------------------
function TabButton({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={`
flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-t-md
transition-colors border-b-2
${
active
? "text-indigo-700 border-indigo-500 bg-indigo-50/50"
: "text-gray-500 border-transparent hover:text-gray-700 hover:bg-gray-50"
}
`}
>
{icon}
{label}
</button>
);
}
// ---------------------------------------------------------------------------
// Tasks Tab — table of child task executions
// ---------------------------------------------------------------------------
function TasksTab({
tasks,
isLoading,
error,
}: {
tasks: ExecutionSummary[];
isLoading: boolean;
error: unknown;
}) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="ml-2 text-sm text-gray-500">
Loading workflow tasks
</span>
</div>
);
}
if (error) {
return (
<div className="mx-6 my-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded text-sm">
Error loading workflow tasks:{" "}
{error instanceof Error ? error.message : "Unknown error"}
</div>
);
}
if (tasks.length === 0) {
return (
<div className="flex items-center justify-center py-8 text-sm text-gray-500">
No workflow tasks yet.
</div>
);
}
return (
<div className="px-6 pb-6 pt-2">
<div className="space-y-2">
{/* Column headers */}
<div className="grid grid-cols-12 gap-3 px-3 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider border-b border-gray-100">
<div className="col-span-1">#</div>
<div className="col-span-3">Task</div>
<div className="col-span-3">Action</div>
<div className="col-span-2">Status</div>
<div className="col-span-2">Duration</div>
<div className="col-span-1">Retry</div>
</div>
{/* Task rows */}
{tasks.map((task, idx) => {
const wt = task.workflow_task;
const taskName = wt?.task_name ?? `Task ${idx + 1}`;
const retryCount = wt?.retry_count ?? 0;
const maxRetries = wt?.max_retries ?? 0;
const timedOut = wt?.timed_out ?? false;
// Compute duration from started_at → updated (actual run time)
const startedAt = task.started_at ? new Date(task.started_at) : null;
const created = new Date(task.created);
const updated = new Date(task.updated);
const isTerminal =
task.status === "completed" ||
task.status === "failed" ||
task.status === "timeout";
const durationMs =
wt?.duration_ms ??
(isTerminal && startedAt
? updated.getTime() - startedAt.getTime()
: null);
return (
<Link
key={task.id}
to={`/executions/${task.id}`}
className="grid grid-cols-12 gap-3 px-3 py-3 rounded-lg hover:bg-gray-50 transition-colors items-center group"
>
{/* Index */}
<div className="col-span-1 text-sm text-gray-400 font-mono">
{idx + 1}
</div>
{/* Task name */}
<div className="col-span-3 flex items-center gap-2 min-w-0">
{getStatusIcon(task.status)}
<span
className="text-sm font-medium text-gray-900 truncate group-hover:text-blue-600"
title={taskName}
>
{taskName}
</span>
{wt?.task_index != null && (
<span className="text-xs text-gray-400 flex-shrink-0">
[{wt.task_index}]
</span>
)}
</div>
{/* Action ref */}
<div className="col-span-3 min-w-0">
<span
className="text-sm text-gray-600 truncate block"
title={task.action_ref}
>
{task.action_ref}
</span>
</div>
{/* Status badge */}
<div className="col-span-2 flex items-center gap-1.5">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClasses(task.status)}`}
>
{task.status}
</span>
{timedOut && (
<span title="Timed out">
<AlertTriangle className="h-3.5 w-3.5 text-orange-500" />
</span>
)}
</div>
{/* Duration */}
<div className="col-span-2 text-sm text-gray-500">
{task.status === "running" ? (
<span className="text-blue-600">
{formatDistanceToNow(startedAt ?? created, {
addSuffix: false,
})}
</span>
) : durationMs != null && durationMs > 0 ? (
formatDuration(durationMs)
) : (
<span className="text-gray-300"></span>
)}
</div>
{/* Retry info */}
<div className="col-span-1 text-sm text-gray-500">
{maxRetries > 0 ? (
<span
className="inline-flex items-center gap-0.5"
title={`Attempt ${retryCount + 1} of ${maxRetries + 1}`}
>
<RotateCcw className="h-3 w-3" />
{retryCount}/{maxRetries}
</span>
) : (
<span className="text-gray-300"></span>
)}
</div>
</Link>
);
})}
</div>
</div>
);
}

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",
};

View File

@@ -160,6 +160,10 @@ export function useExecutionStream(options: UseExecutionStreamOptions = {}) {
// Extract query params from the query key (format: ["executions", params])
const queryParams = queryKey[1];
// Child execution queries (keyed by { parent: id }) fetch all pages
// and must not be capped — the timeline DAG needs every child.
const isChildQuery = !!(queryParams as any)?.parent;
const old = oldData as any;
// Check if execution already exists in the list
@@ -224,7 +228,9 @@ export function useExecutionStream(options: UseExecutionStreamOptions = {}) {
if (hasUnsupportedFilters(queryParams)) {
return;
}
updatedData = [executionData, ...old.data].slice(0, 50);
updatedData = isChildQuery
? [...old.data, executionData]
: [executionData, ...old.data].slice(0, 50);
totalItemsDelta = 1;
} else {
// No boundary crossing: either both match (execution was
@@ -240,8 +246,11 @@ export function useExecutionStream(options: UseExecutionStreamOptions = {}) {
}
if (matchesQuery) {
// Add to beginning and cap at 50 items to prevent unbounded growth
updatedData = [executionData, ...old.data].slice(0, 50);
// Add to the list. Child queries keep all items (no cap);
// other lists cap at 50 to prevent unbounded growth.
updatedData = isChildQuery
? [...old.data, executionData]
: [executionData, ...old.data].slice(0, 50);
totalItemsDelta = 1;
} else {
return;

View File

@@ -116,11 +116,34 @@ export function useChildExecutions(parentId: number | undefined) {
return useQuery({
queryKey: ["executions", { parent: parentId }],
queryFn: async () => {
const response = await ExecutionsService.listExecutions({
// Fetch page 1 with max page size (API caps at 100)
const first = await ExecutionsService.listExecutions({
parent: parentId,
perPage: 100,
page: 1,
});
return response;
const { total_pages } = first.pagination;
if (total_pages <= 1) return first;
// Fetch remaining pages in parallel
const remaining = await Promise.all(
Array.from({ length: total_pages - 1 }, (_, i) =>
ExecutionsService.listExecutions({
parent: parentId,
perPage: 100,
page: i + 2,
}),
),
);
// Merge all pages into the first response
for (const page of remaining) {
first.data.push(...page.data);
}
first.pagination.total_pages = 1;
first.pagination.page_size = first.data.length;
return first;
},
enabled: !!parentId,
staleTime: 5000,

View File

@@ -45,6 +45,8 @@ import {
generateUniqueTaskName,
generateTaskId,
builderStateToDefinition,
builderStateToGraph,
builderStateToActionYaml,
definitionToBuilderState,
validateWorkflow,
addTransitionTarget,
@@ -585,12 +587,14 @@ export default function WorkflowBuilderPage() {
doSave();
}, [startNodeWarning, doSave]);
// YAML preview — generate proper YAML from builder state
const yamlPreview = useMemo(() => {
// YAML previewstwo separate panels for the two-file model:
// 1. Action YAML (ref, label, parameters, output, tags, workflow_file)
// 2. Workflow YAML (version, vars, tasks, output_map — graph only)
const actionYamlPreview = useMemo(() => {
if (!showYamlPreview) return "";
try {
const definition = builderStateToDefinition(state, actionSchemaMap);
return yaml.dump(definition, {
const actionDef = builderStateToActionYaml(state);
return yaml.dump(actionDef, {
indent: 2,
lineWidth: 120,
noRefs: true,
@@ -599,7 +603,24 @@ export default function WorkflowBuilderPage() {
forceQuotes: false,
});
} catch {
return "# Error generating YAML preview";
return "# Error generating action YAML preview";
}
}, [state, showYamlPreview]);
const workflowYamlPreview = useMemo(() => {
if (!showYamlPreview) return "";
try {
const graphDef = builderStateToGraph(state, actionSchemaMap);
return yaml.dump(graphDef, {
indent: 2,
lineWidth: 120,
noRefs: true,
sortKeys: false,
quotingType: '"',
forceQuotes: false,
});
} catch {
return "# Error generating workflow YAML preview";
}
}, [state, showYamlPreview, actionSchemaMap]);
@@ -854,26 +875,64 @@ export default function WorkflowBuilderPage() {
{/* Main content area */}
<div className="flex-1 flex overflow-hidden">
{showYamlPreview ? (
/* Raw YAML mode — full-width YAML view */
<div className="flex-1 flex flex-col overflow-hidden bg-gray-900">
<div className="flex items-center gap-2 px-4 py-2 bg-gray-800 border-b border-gray-700 flex-shrink-0">
<FileCode className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium text-gray-300">
Workflow Definition
</span>
<span className="text-[10px] text-gray-500 ml-1">
(read-only preview of the generated YAML)
</span>
<div className="ml-auto">
/* Raw YAML mode — two-panel view: Action YAML + Workflow YAML */
<div className="flex-1 flex overflow-hidden">
{/* Left panel: Action YAML */}
<div className="w-2/5 flex flex-col overflow-hidden bg-gray-900 border-r border-gray-700">
<div className="flex items-center gap-2 px-4 py-2 bg-gray-800 border-b border-gray-700 flex-shrink-0">
<FileCode className="w-4 h-4 text-blue-400" />
<span className="text-sm font-medium text-gray-300">
Action YAML
</span>
<span className="text-[10px] text-gray-500 ml-1">
actions/{state.name}.yaml
</span>
<div className="flex-1" />
<button
onClick={() => {
navigator.clipboard.writeText(yamlPreview).then(() => {
setYamlCopied(true);
setTimeout(() => setYamlCopied(false), 2000);
});
navigator.clipboard.writeText(actionYamlPreview);
}}
className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded transition-colors text-gray-400 hover:text-gray-200 hover:bg-gray-700"
title="Copy YAML to clipboard"
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 bg-gray-700 hover:bg-gray-600 rounded transition-colors"
title="Copy action YAML to clipboard"
>
<Copy className="w-3.5 h-3.5" />
<span>Copy</span>
</button>
</div>
<div className="px-4 py-2 bg-gray-800/50 border-b border-gray-700/50 flex-shrink-0">
<p className="text-[10px] text-gray-500 leading-relaxed">
Defines the action identity, parameters, and output schema.
References the workflow file via{" "}
<code className="text-gray-400">workflow_file</code>.
</p>
</div>
<pre className="flex-1 overflow-auto p-4 text-sm font-mono text-blue-300 whitespace-pre leading-relaxed">
{actionYamlPreview}
</pre>
</div>
{/* Right panel: Workflow YAML (graph only) */}
<div className="flex-1 flex flex-col overflow-hidden bg-gray-900">
<div className="flex items-center gap-2 px-4 py-2 bg-gray-800 border-b border-gray-700 flex-shrink-0">
<FileCode className="w-4 h-4 text-green-400" />
<span className="text-sm font-medium text-gray-300">
Workflow YAML
</span>
<span className="text-[10px] text-gray-500 ml-1">
actions/workflows/{state.name}.workflow.yaml
</span>
<div className="flex-1" />
<button
onClick={() => {
navigator.clipboard
.writeText(workflowYamlPreview)
.then(() => {
setYamlCopied(true);
setTimeout(() => setYamlCopied(false), 2000);
});
}}
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 bg-gray-700 hover:bg-gray-600 rounded transition-colors"
title="Copy workflow YAML to clipboard"
>
{yamlCopied ? (
<>
@@ -883,15 +942,21 @@ export default function WorkflowBuilderPage() {
) : (
<>
<Copy className="w-3.5 h-3.5" />
Copy
<span>Copy</span>
</>
)}
</button>
</div>
<div className="px-4 py-2 bg-gray-800/50 border-b border-gray-700/50 flex-shrink-0">
<p className="text-[10px] text-gray-500 leading-relaxed">
Execution graph only tasks, transitions, variables. No
action-level metadata (those are in the action YAML).
</p>
</div>
<pre className="flex-1 overflow-auto p-4 text-sm font-mono text-green-400 whitespace-pre leading-relaxed">
{workflowYamlPreview}
</pre>
</div>
<pre className="flex-1 overflow-auto p-6 text-sm font-mono text-green-400 whitespace-pre leading-relaxed">
{yamlPreview}
</pre>
</div>
) : (
<>

View File

@@ -22,9 +22,9 @@ import { useState, useMemo } from "react";
import { RotateCcw, Loader2 } from "lucide-react";
import ExecuteActionModal from "@/components/common/ExecuteActionModal";
import EntityHistoryPanel from "@/components/common/EntityHistoryPanel";
import WorkflowTasksPanel from "@/components/common/WorkflowTasksPanel";
import ExecutionArtifactsPanel from "@/components/executions/ExecutionArtifactsPanel";
import ExecutionProgressBar from "@/components/executions/ExecutionProgressBar";
import WorkflowDetailsPanel from "@/components/executions/WorkflowDetailsPanel";
const getStatusColor = (status: string) => {
switch (status) {
@@ -279,6 +279,16 @@ export default function ExecutionDetailPage() {
)}
</div>
{/* Workflow Details — combined timeline + tasks panel (top of page for workflows) */}
{isWorkflow && (
<div className="mb-6">
<WorkflowDetailsPanel
parentExecution={execution}
actionRef={execution.action_ref}
/>
</div>
)}
{/* Re-Run Modal */}
{showRerunModal && actionData?.data && (
<ExecuteActionModal
@@ -542,13 +552,6 @@ export default function ExecutionDetailPage() {
</div>
</div>
{/* Workflow Tasks (shown only for workflow executions) */}
{isWorkflow && (
<div className="mt-6">
<WorkflowTasksPanel parentExecutionId={execution.id} />
</div>
)}
{/* Artifacts */}
<div className="mt-6">
<ExecutionArtifactsPanel

View File

@@ -222,6 +222,10 @@ export interface ParamDefinition {
}
/** Workflow definition as stored in the YAML file / API */
/**
* Full workflow definition — used for DB storage and the save API payload.
* Contains both action-level metadata AND the execution graph.
*/
export interface WorkflowYamlDefinition {
ref: string;
label: string;
@@ -235,6 +239,37 @@ export interface WorkflowYamlDefinition {
tags?: string[];
}
/**
* Graph-only workflow definition — written to the `.workflow.yaml` file on disk.
*
* Action-linked workflow files contain only the execution graph. The companion
* action YAML (`actions/{name}.yaml`) is authoritative for `ref`, `label`,
* `description`, `parameters`, `output`, and `tags`.
*/
export interface WorkflowGraphDefinition {
version: string;
vars?: Record<string, unknown>;
tasks: WorkflowYamlTask[];
output_map?: Record<string, string>;
}
/**
* Action YAML definition — written to the companion `actions/{name}.yaml` file.
*
* Controls the action's identity and exposed interface. References the workflow
* file via `workflow_file`.
*/
export interface ActionYamlDefinition {
ref: string;
label: string;
description?: string;
enabled: boolean;
workflow_file: string;
parameters?: Record<string, unknown>;
output?: Record<string, unknown>;
tags?: string[];
}
/** Chart-only metadata for a transition edge (not consumed by the backend) */
export interface TransitionChartMeta {
/** Custom display label for the transition */
@@ -382,6 +417,52 @@ export function builderStateToDefinition(
state: WorkflowBuilderState,
actionSchemas?: Map<string, Record<string, unknown> | null>,
): WorkflowYamlDefinition {
const graph = builderStateToGraph(state, actionSchemas);
const definition: WorkflowYamlDefinition = {
ref: `${state.packRef}.${state.name}`,
label: state.label,
version: state.version,
tasks: graph.tasks,
};
if (state.description) {
definition.description = state.description;
}
if (Object.keys(state.parameters).length > 0) {
definition.parameters = state.parameters;
}
if (Object.keys(state.output).length > 0) {
definition.output = state.output;
}
if (graph.vars && Object.keys(graph.vars).length > 0) {
definition.vars = graph.vars;
}
if (graph.output_map) {
definition.output_map = graph.output_map;
}
if (state.tags.length > 0) {
definition.tags = state.tags;
}
return definition;
}
/**
* Extract the graph-only workflow definition from builder state.
*
* This produces the content that should be written to the `.workflow.yaml`
* file on disk — no `ref`, `label`, `description`, `parameters`, `output`,
* or `tags`. Those belong in the companion action YAML.
*/
export function builderStateToGraph(
state: WorkflowBuilderState,
actionSchemas?: Map<string, Record<string, unknown> | null>,
): WorkflowGraphDefinition {
const tasks: WorkflowYamlTask[] = state.tasks.map((task) => {
const yamlTask: WorkflowYamlTask = {
name: task.name,
@@ -446,34 +527,51 @@ export function builderStateToDefinition(
return yamlTask;
});
const definition: WorkflowYamlDefinition = {
ref: `${state.packRef}.${state.name}`,
label: state.label,
const graph: WorkflowGraphDefinition = {
version: state.version,
tasks,
};
if (Object.keys(state.vars).length > 0) {
graph.vars = state.vars;
}
return graph;
}
/**
* Extract the action YAML definition from builder state.
*
* This produces the content for the companion `actions/{name}.yaml` file
* that owns action-level metadata and references the workflow file.
*/
export function builderStateToActionYaml(
state: WorkflowBuilderState,
): ActionYamlDefinition {
const action: ActionYamlDefinition = {
ref: `${state.packRef}.${state.name}`,
label: state.label,
enabled: state.enabled,
workflow_file: `workflows/${state.name}.workflow.yaml`,
};
if (state.description) {
definition.description = state.description;
action.description = state.description;
}
if (Object.keys(state.parameters).length > 0) {
definition.parameters = state.parameters;
action.parameters = state.parameters;
}
if (Object.keys(state.output).length > 0) {
definition.output = state.output;
}
if (Object.keys(state.vars).length > 0) {
definition.vars = state.vars;
action.output = state.output;
}
if (state.tags.length > 0) {
definition.tags = state.tags;
action.tags = state.tags;
}
return definition;
return action;
}
// ---------------------------------------------------------------------------