working on workflows
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
446
web/src/components/executions/WorkflowDetailsPanel.tsx
Normal file
446
web/src/components/executions/WorkflowDetailsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
1053
web/src/components/executions/workflow-timeline/TimelineRenderer.tsx
Normal file
1053
web/src/components/executions/workflow-timeline/TimelineRenderer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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`;
|
||||
}
|
||||
1376
web/src/components/executions/workflow-timeline/data.ts
Normal file
1376
web/src/components/executions/workflow-timeline/data.ts
Normal file
File diff suppressed because it is too large
Load Diff
41
web/src/components/executions/workflow-timeline/index.ts
Normal file
41
web/src/components/executions/workflow-timeline/index.ts
Normal 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";
|
||||
673
web/src/components/executions/workflow-timeline/layout.ts
Normal file
673
web/src/components/executions/workflow-timeline/layout.ts
Normal 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 6–12 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 };
|
||||
285
web/src/components/executions/workflow-timeline/types.ts
Normal file
285
web/src/components/executions/workflow-timeline/types.ts
Normal 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",
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 previews — two 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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user