[WIP] Workflows
This commit is contained in:
297
web/src/components/executions/ExecutionPreviewPanel.tsx
Normal file
297
web/src/components/executions/ExecutionPreviewPanel.tsx
Normal 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;
|
||||
78
web/src/components/executions/Pagination.tsx
Normal file
78
web/src/components/executions/Pagination.tsx
Normal 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;
|
||||
618
web/src/components/executions/WorkflowExecutionTree.tsx
Normal file
618
web/src/components/executions/WorkflowExecutionTree.tsx
Normal 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">—</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">—</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;
|
||||
Reference in New Issue
Block a user