[WIP] Workflows

This commit is contained in:
2026-02-27 16:34:17 -06:00
parent 570c52e623
commit daeff10f18
96 changed files with 5889 additions and 2098 deletions

View File

@@ -0,0 +1,312 @@
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";
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);
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 created → updated (best available)
const created = new Date(task.created);
const updated = new Date(task.updated);
const durationMs =
wt?.duration_ms ??
(task.status === "completed" ||
task.status === "failed" ||
task.status === "timeout"
? updated.getTime() - created.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(created, { addSuffix: false })}
</span>
) : durationMs != null && durationMs > 0 ? (
formatDuration(durationMs)
) : (
<span className="text-gray-300"></span>
)}
</div>
{/* Retry info */}
<div className="col-span-1 text-sm text-gray-500">
{maxRetries > 0 ? (
<span
className="inline-flex items-center gap-0.5"
title={`Attempt ${retryCount + 1} of ${maxRetries + 1}`}
>
<RotateCcw className="h-3 w-3" />
{retryCount}/{maxRetries}
</span>
) : (
<span className="text-gray-300"></span>
)}
</div>
</Link>
);
})}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,297 @@
import { memo, useEffect } from "react";
import { Link } from "react-router-dom";
import { X, ExternalLink, Loader2 } from "lucide-react";
import { useExecution } from "@/hooks/useExecutions";
import { useExecutionStream } from "@/hooks/useExecutionStream";
import { formatDistanceToNow } from "date-fns";
import type { ExecutionStatus } from "@/api";
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`;
}
const getStatusColor = (status: string) => {
switch (status) {
case "succeeded":
case "completed":
return "bg-green-100 text-green-800";
case "failed":
case "timeout":
return "bg-red-100 text-red-800";
case "running":
return "bg-blue-100 text-blue-800";
case "scheduled":
case "scheduling":
case "requested":
return "bg-yellow-100 text-yellow-800";
case "canceling":
case "cancelled":
return "bg-gray-100 text-gray-600";
default:
return "bg-gray-100 text-gray-800";
}
};
interface ExecutionPreviewPanelProps {
executionId: number;
onClose: () => void;
}
const ExecutionPreviewPanel = memo(function ExecutionPreviewPanel({
executionId,
onClose,
}: ExecutionPreviewPanelProps) {
const { data, isLoading, error } = useExecution(executionId);
const execution = data?.data;
// Subscribe to real-time updates for this execution
useExecutionStream({ executionId, enabled: true });
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const isRunning =
execution?.status === "running" ||
execution?.status === "scheduling" ||
execution?.status === "scheduled" ||
execution?.status === "requested";
const created = execution ? new Date(execution.created) : null;
const updated = execution ? new Date(execution.updated) : null;
const durationMs =
created && updated && !isRunning
? updated.getTime() - created.getTime()
: null;
return (
<div className="border-l border-gray-200 bg-white flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">
Execution #{executionId}
</h3>
{execution && (
<span
className={`px-2 py-0.5 text-xs rounded-full font-medium flex-shrink-0 ${getStatusColor(execution.status)}`}
>
{execution.status}
</span>
)}
{isRunning && (
<Loader2 className="h-3.5 w-3.5 text-blue-500 animate-spin flex-shrink-0" />
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Link
to={`/executions/${executionId}`}
className="p-1.5 text-gray-400 hover:text-blue-600 rounded hover:bg-gray-100 transition-colors"
title="Open full detail page"
>
<ExternalLink className="h-4 w-4" />
</Link>
<button
onClick={onClose}
className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100 transition-colors"
title="Close preview (Esc)"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{isLoading && (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
)}
{error && !execution && (
<div className="p-4">
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded text-sm">
Error: {(error as Error).message}
</div>
</div>
)}
{execution && (
<div className="divide-y divide-gray-100">
{/* Action */}
<div className="px-4 py-3">
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Action
</dt>
<dd className="mt-1">
<Link
to={`/actions/${execution.action_ref}`}
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
{execution.action_ref}
</Link>
</dd>
</div>
{/* Timing */}
<div className="px-4 py-3 space-y-2">
<div>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Created
</dt>
<dd className="mt-0.5 text-sm text-gray-900">
{created!.toLocaleString()}
<span className="text-gray-400 ml-1.5 text-xs">
{formatDistanceToNow(created!, { addSuffix: true })}
</span>
</dd>
</div>
{durationMs != null && durationMs > 0 && (
<div>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Duration
</dt>
<dd className="mt-0.5 text-sm text-gray-900">
{formatDuration(durationMs)}
</dd>
</div>
)}
{isRunning && (
<div>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Elapsed
</dt>
<dd className="mt-0.5 text-sm text-blue-600 flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
{formatDistanceToNow(created!)}
</dd>
</div>
)}
</div>
{/* References */}
<div className="px-4 py-3 space-y-2">
{execution.parent && (
<div>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Parent Execution
</dt>
<dd className="mt-0.5 text-sm">
<Link
to={`/executions/${execution.parent}`}
className="text-blue-600 hover:text-blue-800 font-mono"
>
#{execution.parent}
</Link>
</dd>
</div>
)}
{execution.enforcement && (
<div>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Enforcement
</dt>
<dd className="mt-0.5 text-sm text-gray-900 font-mono">
#{execution.enforcement}
</dd>
</div>
)}
{execution.executor && (
<div>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Executor
</dt>
<dd className="mt-0.5 text-sm text-gray-900 font-mono">
#{execution.executor}
</dd>
</div>
)}
{execution.workflow_task && (
<div>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Workflow Task
</dt>
<dd className="mt-0.5 text-sm text-gray-900">
<span className="font-medium">
{execution.workflow_task.task_name}
</span>
{execution.workflow_task.task_index != null && (
<span className="text-gray-400 ml-1">
[{execution.workflow_task.task_index}]
</span>
)}
</dd>
</div>
)}
</div>
{/* Config / Parameters */}
{execution.config &&
Object.keys(execution.config).length > 0 && (
<div className="px-4 py-3">
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1.5">
Parameters
</dt>
<dd>
<pre className="bg-gray-50 border border-gray-200 rounded p-3 text-xs overflow-x-auto max-h-48 overflow-y-auto">
{JSON.stringify(execution.config, null, 2)}
</pre>
</dd>
</div>
)}
{/* Result */}
{execution.result &&
Object.keys(execution.result).length > 0 && (
<div className="px-4 py-3">
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1.5">
Result
</dt>
<dd>
<pre
className={`border rounded p-3 text-xs overflow-x-auto max-h-64 overflow-y-auto ${
execution.status === ("failed" as ExecutionStatus) ||
execution.status === ("timeout" as ExecutionStatus)
? "bg-red-50 border-red-200"
: "bg-gray-50 border-gray-200"
}`}
>
{JSON.stringify(execution.result, null, 2)}
</pre>
</dd>
</div>
)}
</div>
)}
</div>
{/* Footer */}
{execution && (
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50 flex-shrink-0">
<Link
to={`/executions/${executionId}`}
className="block w-full text-center px-3 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
>
Open Full Details
</Link>
</div>
)}
</div>
);
});
export default ExecutionPreviewPanel;

View File

@@ -0,0 +1,78 @@
import { memo } from "react";
interface PaginationProps {
page: number;
setPage: (page: number) => void;
pageSize: number;
total: number;
}
function computeRange(page: number, pageSize: number, total: number) {
const start = (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, total);
return { start, end };
}
const Pagination = memo(function Pagination({
page,
setPage,
pageSize,
total,
}: PaginationProps) {
const totalPages = Math.ceil(total / pageSize);
if (totalPages <= 1) return null;
const { start, end } = computeRange(page, pageSize, total);
return (
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{start}</span> to{" "}
<span className="font-medium">{end}</span> of{" "}
<span className="font-medium">{total}</span> executions
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
</div>
</div>
);
});
Pagination.displayName = "Pagination";
export default Pagination;

View File

@@ -0,0 +1,618 @@
import { useState, useMemo, memo } from "react";
import { Link } from "react-router-dom";
import {
ChevronRight,
ChevronDown,
Workflow,
Loader2,
CheckCircle2,
XCircle,
Clock,
AlertTriangle,
Ban,
CircleDot,
RotateCcw,
} from "lucide-react";
import { useChildExecutions } from "@/hooks/useExecutions";
import type { ExecutionSummary } from "@/api";
import Pagination from "./Pagination";
// ─── Helpers ────────────────────────────────────────────────────────────────
function getStatusColor(status: string) {
switch (status) {
case "completed":
return "bg-green-100 text-green-800";
case "failed":
case "timeout":
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 "canceling":
case "cancelled":
return "bg-gray-100 text-gray-600";
default:
return "bg-gray-100 text-gray-800";
}
}
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 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`;
}
// ─── Child execution row (recursive) ────────────────────────────────────────
interface ChildExecutionRowProps {
execution: ExecutionSummary;
depth: number;
selectedExecutionId: number | null;
onSelectExecution: (id: number) => void;
workflowActionRefs: Set<string>;
}
/**
* A single child-execution row inside the accordion. If it has its own
* children (nested workflow), it can be expanded recursively.
*/
const ChildExecutionRow = memo(function ChildExecutionRow({
execution,
depth,
selectedExecutionId,
onSelectExecution,
workflowActionRefs,
}: ChildExecutionRowProps) {
const isWorkflow = workflowActionRefs.has(execution.action_ref);
const [expanded, setExpanded] = useState(false);
// Only fetch children when expanded and this is a workflow action
const { data, isLoading } = useChildExecutions(
expanded && isWorkflow ? execution.id : undefined,
);
const children = useMemo(() => data?.data ?? [], [data]);
const hasChildren = expanded && children.length > 0;
const wt = execution.workflow_task;
const taskName = wt?.task_name;
const retryCount = wt?.retry_count ?? 0;
const maxRetries = wt?.max_retries ?? 0;
const created = new Date(execution.created);
const updated = new Date(execution.updated);
const durationMs =
wt?.duration_ms ??
(execution.status === "completed" ||
execution.status === "failed" ||
execution.status === "timeout"
? updated.getTime() - created.getTime()
: null);
const indent = 16 + depth * 24;
return (
<>
<tr
className={`hover:bg-gray-50/80 group border-t border-gray-100 cursor-pointer ${
selectedExecutionId === execution.id
? "bg-blue-50 hover:bg-blue-50"
: ""
}`}
onClick={() => onSelectExecution(execution.id)}
>
{/* Task name / expand toggle */}
<td className="py-3 pr-2" style={{ paddingLeft: indent }}>
<div className="flex items-center gap-1.5 min-w-0">
{isWorkflow && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setExpanded((prev) => !prev);
}}
className={`flex-shrink-0 p-0.5 rounded hover:bg-gray-200 transition-colors ${
expanded || isLoading
? "visible"
: "invisible group-hover:visible"
}`}
title={expanded ? "Collapse" : "Expand"}
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 text-gray-400 animate-spin" />
) : expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-gray-400" />
)}
</button>
)}
{getStatusIcon(execution.status)}
{taskName && (
<span
className="text-sm font-medium text-gray-700 truncate"
title={taskName}
>
{taskName}
</span>
)}
{wt?.task_index != null && (
<span className="text-xs text-gray-400 flex-shrink-0">
[{wt.task_index}]
</span>
)}
</div>
</td>
{/* Exec ID */}
<td className="px-4 py-3 font-mono text-xs">
<Link
to={`/executions/${execution.id}`}
className="text-blue-600 hover:text-blue-800"
onClick={(e) => e.stopPropagation()}
>
#{execution.id}
</Link>
</td>
{/* Action */}
<td className="px-4 py-3">
<Link
to={`/executions/${execution.id}`}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline truncate block"
title={execution.action_ref}
onClick={(e) => e.stopPropagation()}
>
{execution.action_ref}
</Link>
</td>
{/* Status */}
<td className="px-4 py-3">
<span
className={`px-2 py-0.5 text-xs rounded-full font-medium ${getStatusColor(execution.status)}`}
>
{execution.status}
</span>
</td>
{/* Duration */}
<td className="px-4 py-3 text-sm text-gray-500">
{execution.status === "running" ? (
<span className="text-blue-600 flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
running
</span>
) : durationMs != null && durationMs > 0 ? (
formatDuration(durationMs)
) : (
<span className="text-gray-300">&mdash;</span>
)}
</td>
{/* Retry */}
<td className="px-4 py-3 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">&mdash;</span>
)}
</td>
</tr>
{/* Nested children */}
{expanded &&
!isLoading &&
hasChildren &&
children.map((child: ExecutionSummary) => (
<ChildExecutionRow
key={child.id}
execution={child}
depth={depth + 1}
selectedExecutionId={selectedExecutionId}
onSelectExecution={onSelectExecution}
workflowActionRefs={workflowActionRefs}
/>
))}
</>
);
});
// ─── Top-level workflow row (accordion) ─────────────────────────────────────
interface WorkflowExecutionRowProps {
execution: ExecutionSummary;
workflowActionRefs: Set<string>;
selectedExecutionId: number | null;
onSelectExecution: (id: number) => void;
}
/**
* A top-level execution row with an expandable accordion for child tasks.
*/
const WorkflowExecutionRow = memo(function WorkflowExecutionRow({
execution,
workflowActionRefs,
selectedExecutionId,
onSelectExecution,
}: WorkflowExecutionRowProps) {
const isWorkflow = workflowActionRefs.has(execution.action_ref);
const [expanded, setExpanded] = useState(false);
const { data, isLoading } = useChildExecutions(
expanded && isWorkflow ? execution.id : undefined,
);
const children = useMemo(() => data?.data ?? [], [data]);
const summary = useMemo(() => {
const total = children.length;
const completed = children.filter(
(t: ExecutionSummary) => t.status === "completed",
).length;
const failed = children.filter(
(t: ExecutionSummary) => t.status === "failed" || t.status === "timeout",
).length;
const running = children.filter(
(t: ExecutionSummary) =>
t.status === "running" ||
t.status === "requested" ||
t.status === "scheduling" ||
t.status === "scheduled",
).length;
return { total, completed, failed, running };
}, [children]);
const hasWorkflowChildren = expanded && children.length > 0;
return (
<>
{/* Main execution row */}
<tr
className={`hover:bg-gray-50 border-b border-gray-200 cursor-pointer ${
selectedExecutionId === execution.id
? "bg-blue-50 hover:bg-blue-50"
: ""
}`}
onClick={() => onSelectExecution(execution.id)}
>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
{isWorkflow && (
<button
onClick={(e) => {
e.stopPropagation();
setExpanded((prev) => !prev);
}}
className="flex-shrink-0 p-0.5 rounded hover:bg-gray-200 transition-colors"
title={
expanded ? "Collapse workflow tasks" : "Expand workflow tasks"
}
>
{isLoading ? (
<Loader2 className="h-4 w-4 text-gray-400 animate-spin" />
) : expanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
)}
<Link
to={`/executions/${execution.id}`}
className="text-blue-600 hover:text-blue-800 font-mono text-sm"
onClick={(e) => e.stopPropagation()}
>
#{execution.id}
</Link>
</div>
</td>
<td className="px-6 py-4">
<span className="text-sm text-gray-900">{execution.action_ref}</span>
</td>
<td className="px-6 py-4">
{execution.rule_ref ? (
<span className="text-sm text-gray-700">{execution.rule_ref}</span>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
<td className="px-6 py-4">
{execution.trigger_ref ? (
<span className="text-sm text-gray-700">
{execution.trigger_ref}
</span>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 text-xs rounded ${getStatusColor(execution.status)}`}
>
{execution.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(execution.created).toLocaleString()}
</td>
</tr>
{/* Expanded child-task section */}
{expanded && (
<tr>
<td colSpan={6} className="p-0">
<div className="bg-gray-50 border-b border-gray-200">
{/* Summary bar */}
{hasWorkflowChildren && (
<div className="flex items-center gap-3 px-8 py-2 border-b border-gray-200 bg-gray-100/60">
<Workflow className="h-4 w-4 text-indigo-500" />
<span className="text-xs font-medium text-gray-600">
{summary.total} task{summary.total !== 1 ? "s" : ""}
</span>
{summary.completed > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
<CheckCircle2 className="h-3 w-3" />
{summary.completed}
</span>
)}
{summary.running > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
<Loader2 className="h-3 w-3 animate-spin" />
{summary.running}
</span>
)}
{summary.failed > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
<XCircle className="h-3 w-3" />
{summary.failed}
</span>
)}
</div>
)}
{/* Loading state */}
{isLoading && (
<div className="flex items-center gap-2 px-8 py-4">
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
<span className="text-sm text-gray-500">
Loading workflow tasks...
</span>
</div>
)}
{/* No children yet (workflow still starting) */}
{!isLoading && children.length === 0 && (
<div className="px-8 py-3 text-sm text-gray-400 italic">
No child tasks yet.
</div>
)}
{/* Children table */}
{hasWorkflowChildren && (
<table className="w-full">
<thead>
<tr className="text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
className="py-2 pr-2 text-left"
style={{ paddingLeft: 40 }}
>
Task
</th>
<th className="px-4 py-2 text-left">ID</th>
<th className="px-4 py-2 text-left">Action</th>
<th className="px-4 py-2 text-left">Status</th>
<th className="px-4 py-2 text-left">Duration</th>
<th className="px-4 py-2 text-left">Retry</th>
</tr>
</thead>
<tbody>
{children.map((child: ExecutionSummary) => (
<ChildExecutionRow
key={child.id}
execution={child}
depth={0}
selectedExecutionId={selectedExecutionId}
onSelectExecution={onSelectExecution}
workflowActionRefs={workflowActionRefs}
/>
))}
</tbody>
</table>
)}
</div>
</td>
</tr>
)}
</>
);
});
// ─── Main tree table ────────────────────────────────────────────────────────
interface WorkflowExecutionTreeProps {
executions: ExecutionSummary[];
isLoading: boolean;
isFetching: boolean;
error: Error | null;
hasActiveFilters: boolean;
clearFilters: () => void;
page: number;
setPage: (page: number) => void;
pageSize: number;
total: number;
workflowActionRefs: Set<string>;
selectedExecutionId: number | null;
onSelectExecution: (id: number) => void;
}
/**
* Renders the executions list in "By Workflow" mode. Top-level executions
* are shown with the same columns as the "All" view, but each row is
* expandable to reveal the workflow's child task executions in an accordion.
* Nested workflows can be drilled into recursively.
*/
const WorkflowExecutionTree = memo(function WorkflowExecutionTree({
executions,
isLoading,
isFetching,
error,
hasActiveFilters,
clearFilters,
page,
setPage,
pageSize,
total,
workflowActionRefs,
selectedExecutionId,
onSelectExecution,
}: WorkflowExecutionTreeProps) {
// Initial load
if (isLoading && executions.length === 0) {
return (
<div className="bg-white shadow rounded-lg">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
</div>
);
}
// Error with no cached data
if (error && executions.length === 0) {
return (
<div className="bg-white shadow rounded-lg">
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
<p>Error: {error.message}</p>
</div>
</div>
);
}
// Empty
if (executions.length === 0) {
return (
<div className="bg-white p-12 text-center rounded-lg shadow">
<p>No executions found</p>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
>
Clear filters
</button>
)}
</div>
);
}
return (
<div className="relative">
{/* Loading overlay */}
{isFetching && (
<div className="absolute inset-0 bg-white/60 z-10 flex items-center justify-center rounded-lg">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
)}
{/* Non-fatal error banner */}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
<p>Error refreshing: {error.message}</p>
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Rule
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Trigger
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created
</th>
</tr>
</thead>
<tbody className="bg-white">
{executions.map((exec: ExecutionSummary) => (
<WorkflowExecutionRow
key={exec.id}
execution={exec}
workflowActionRefs={workflowActionRefs}
selectedExecutionId={selectedExecutionId}
onSelectExecution={onSelectExecution}
/>
))}
</tbody>
</table>
</div>
<Pagination
page={page}
setPage={setPage}
pageSize={pageSize}
total={total}
/>
</div>
);
});
WorkflowExecutionTree.displayName = "WorkflowExecutionTree";
export default WorkflowExecutionTree;