import { useState, useEffect, useMemo } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query' import { executionsApi, workflowsApi, nodesApi, type ExecutionListItem } from '../api/workflows' import { useExecutionListUpdates } from '../hooks/useExecutionListUpdates' import { toMillis } from '../utils/timestamps' import { formatDistanceToNow } from 'date-fns' import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle, Octagon, Square, Hourglass, Search, X } from 'lucide-react' import clsx from 'clsx' // Helper to safely format timestamps that might be invalid or too large function safeFormatDistanceToNow(timestamp: number): string { if (!timestamp || timestamp < 0 || timestamp > 8640000000000000) { return 'unknown' } try { return formatDistanceToNow(timestamp, { addSuffix: true }) } catch { return 'unknown' } } // An execution that has not finished has finishedAt 0, so its duration has to // be measured against now. "Running..." told you nothing about whether a run had // been going for two seconds or two hours. function formatElapsed(startedAt: number | null, finishedAt: number | null, now: number): string { if (!startedAt) return '-' const end = finishedAt ?? now const seconds = (end - startedAt) / 1000 if (seconds < 0) return '-' if (seconds < 60) return `${seconds.toFixed(2)}s` const minutes = Math.floor(seconds / 60) const rest = Math.floor(seconds % 60) if (minutes < 60) return `${minutes}m ${rest}s` const hours = Math.floor(minutes / 60) return `${hours}h ${minutes % 60}m` } // Statuses a run can still leave on its own, so a Stop is meaningful. const IN_FLIGHT = ['running', 'pending', 'waiting', 'resuming'] const PAGE_SIZE = 20 // Offered as filters. "pending" and "resuming" are deliberately absent: they are // momentary states nobody can catch on purpose, and they are covered by the // in-flight handling elsewhere on the row. const STATUS_FILTERS = ['running', 'waiting', 'completed', 'failed', 'cancelled'] as const type RangeKey = 'any' | '15m' | '1h' | '24h' | '7d' | '30d' const RANGES: { key: RangeKey; label: string; ms: number }[] = [ { key: 'any', label: 'Any time', ms: 0 }, { key: '15m', label: 'Last 15 minutes', ms: 15 * 60 * 1000 }, { key: '1h', label: 'Last hour', ms: 60 * 60 * 1000 }, { key: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 }, { key: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 }, { key: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 }, ] // The first two are ordered by the database over the whole result set. The // duration ones cannot be: duration is not a stored field, so nothing can sort // or page by it server-side, and they reorder the page in the browser instead. // The labels say so rather than letting the numbers imply a global ranking. type SortKey = 'newest' | 'oldest' | 'longest' | 'shortest' const SORTS: { key: SortKey; label: string }[] = [ { key: 'newest', label: 'Newest first' }, { key: 'oldest', label: 'Oldest first' }, { key: 'longest', label: 'Longest duration (this page)' }, { key: 'shortest', label: 'Shortest duration (this page)' }, ] // Trigger types the engine writes itself, so they never appear among the node // definitions. Without these the trigger filter would be missing the most // common value of all, "manual". const ENGINE_TRIGGER_TYPES: { value: string; label: string }[] = [ { value: 'manual', label: 'Manual' }, { value: 'webhook', label: 'Webhook' }, { value: 'schedule', label: 'Schedule' }, { value: 'workflow', label: 'Called by another workflow' }, { value: 'error-workflow', label: 'Error handler' }, ] function durationOf(execution: ExecutionListItem, now: number): number { const started = toMillis(execution.startedAt) if (started === null) return -1 return (toMillis(execution.finishedAt) ?? now) - started } export default function ExecutionsPage() { const navigate = useNavigate() const queryClient = useQueryClient() const [searchParams, setSearchParams] = useSearchParams() // The URL is the single source of truth for the filters, so a filtered view // can be linked to and survives a reload. const search = searchParams.get('q') || '' const statuses = (searchParams.get('status') || '').split(',').filter(Boolean) const workflowId = searchParams.get('workflow') || '' const triggerType = searchParams.get('trigger') || '' const range = (searchParams.get('range') || 'any') as RangeKey const sort = (searchParams.get('sort') || 'newest') as SortKey const page = Math.max(1, Number(searchParams.get('page')) || 1) const hasFilters = search !== '' || statuses.length > 0 || workflowId !== '' || triggerType !== '' || range !== 'any' // Built from the previous params rather than from the ones this render closed // over: the search box writes on a timer, and a filter changed while that // timer was pending would otherwise be undone when it fired. const updateParams = (changes: Record, keepPage = false) => { setSearchParams(previous => { const next = new URLSearchParams(previous) for (const [key, value] of Object.entries(changes)) { if (value === null || value === '') { next.delete(key) } else { next.set(key, value) } } // Any change to what is being asked for invalidates the page number: page // 7 of the old result set says nothing about the new one. if (!keepPage) { next.delete('page') } return next }, { replace: true }) } // The search box is typed into, so it holds its own value and pushes it to the // URL once typing stops. Writing every keystroke to the URL would fire a query // per character. const [searchInput, setSearchInput] = useState(search) useEffect(() => { setSearchInput(search) }, [search]) useEffect(() => { if (searchInput === search) return const timer = setTimeout(() => updateParams({ q: searchInput }), 300) return () => clearTimeout(timer) }, [searchInput]) // The range presets are relative to now, but the boundary is fixed when the // range is chosen and not recomputed per render or per page. A sliding // boundary would move the result set underneath the pagination, so paging // through "Last hour" would skip and repeat rows. const startedAfter = useMemo(() => { const found = RANGES.find(r => r.key === range) if (!found || found.ms === 0) return undefined return Date.now() - found.ms }, [range]) // The workflow editor already renders an execution across the canvas with each // node's input and output, so open it there rather than building a second // viewer that would show less. const openExecution = (execution: { id: string; workflowId: string }) => { if (!execution.workflowId) { return } navigate(`/workflows/${execution.workflowId}?execution=${encodeURIComponent(execution.id)}`) } const { data, isLoading, isFetching, refetch } = useQuery({ // sort belongs in the key because "oldest first" is a different request, not // a different rendering of the same one. queryKey: ['executions', { search, statuses, workflowId, triggerType, range, sort, page }], queryFn: () => executionsApi.query({ search, status: statuses, triggerType: triggerType ? [triggerType] : undefined, workflowId: workflowId || undefined, startedAfter, sortOrder: sort === 'oldest' ? 'asc' : 'desc', page, pageSize: PAGE_SIZE, }), // Without this the table empties on every filter change and the layout // jumps; the old rows stay until the new ones arrive. placeholderData: keepPreviousData, }) // Names for the workflow filter. A large pageSize because this is a picker, // not a listing, and paging it would hide workflows from the filter. const { data: workflowsData } = useQuery({ queryKey: ['workflows', 'all-for-filter'], queryFn: () => workflowsApi.list(1, 200), staleTime: 5 * 60 * 1000, }) // Trigger node definitions, to name the trigger types with something a person // recognises rather than the node id stored on the execution. const { data: nodesData } = useQuery({ queryKey: ['node-definitions'], queryFn: () => nodesApi.list(), staleTime: 5 * 60 * 1000, }) const triggerOptions = useMemo(() => { const options = new Map() for (const engine of ENGINE_TRIGGER_TYPES) { options.set(engine.value, engine.label) } for (const definition of nodesData?.nodes || []) { if (definition.isTrigger) { options.set(definition.id, definition.name || definition.id) } } // A run recorded under a trigger type this build no longer knows about would // otherwise silently vanish from the dropdown while still selected in the URL. if (triggerType && !options.has(triggerType)) { options.set(triggerType, triggerType) } return [...options.entries()].sort((a, b) => a[1].localeCompare(b[1])) }, [nodesData, triggerType]) const workflowOptions = useMemo(() => { const options = new Map() for (const workflow of workflowsData?.workflows || []) { options.set(workflow.id, workflow.name || workflow.id) } // Executions outlive the workflows that produced them, so a filtered link // can name a workflow that has since been deleted. Keep it selectable. if (workflowId && !options.has(workflowId)) { options.set(workflowId, workflowId) } return [...options.entries()].sort((a, b) => a[1].localeCompare(b[1])) }, [workflowsData, workflowId]) // Subscribe to WebSocket for real-time updates instead of polling useExecutionListUpdates() // A ticking clock only while something is actually in flight. Without the // guard this would re-render the page once a second for ever on a list of // finished runs. const [now, setNow] = useState(() => Date.now()) const hasInFlight = (data?.executions || []).some(e => IN_FLIGHT.includes(e.status)) useEffect(() => { if (!hasInFlight) return const timer = setInterval(() => setNow(Date.now()), 1000) return () => clearInterval(timer) }, [hasInFlight]) const cancelMutation = useMutation({ mutationFn: (id: string) => executionsApi.cancel(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['executions'] }) }, }) const total = data?.total || 0 const totalPages = Math.ceil(total / PAGE_SIZE) const hasMore = data?.hasMore || false const executions = useMemo(() => { const rows = data?.executions || [] if (sort !== 'longest' && sort !== 'shortest') { return rows } const direction = sort === 'longest' ? -1 : 1 return [...rows].sort((a, b) => (durationOf(a, now) - durationOf(b, now)) * direction) }, [data, sort, now]) const toggleStatus = (status: string) => { const next = statuses.includes(status) ? statuses.filter(s => s !== status) : [...statuses, status] updateParams({ status: next.join(',') }) } const clearFilters = () => { setSearchParams(new URLSearchParams(), { replace: true }) } const getStatusIcon = (status: string) => { switch (status) { case 'completed': return case 'failed': return case 'running': return case 'cancelled': return case 'waiting': // Distinct from running on purpose: a waiting run is not working, it // needs an answer, and stopping it is a different act from stopping // something mid-flight. return default: return } } const getStatusBadgeClass = (status: string) => { switch (status) { case 'completed': return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' case 'failed': return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' case 'running': return 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' case 'cancelled': return 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-400' case 'waiting': return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' default: return 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400' } } const selectClass = 'px-3 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-700 dark:text-gray-200' return (

Executions

setSearchInput(e.target.value)} placeholder="Search workflow name, or paste an execution id" className="w-80 pl-9 pr-8 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-700 dark:text-gray-200 placeholder-gray-400" /> {searchInput && ( )}
{hasFilters && ( )}
{STATUS_FILTERS.map(status => ( ))} {statuses.length === 0 && ( All statuses )}
{isLoading ? (
) : executions.length === 0 ? (
{/* Three different nothings, which need three different answers: an empty page past the end of the results, a filter that excludes everything, and a platform on which nothing has ever run. */} {page > 1 ? ( <>

Nothing on page {page}. The results may have changed since this link was made.

) : hasFilters ? ( <>

No executions match these filters.

) : ( <>

No executions yet

A run appears here as soon as a workflow is started, by hand or by a trigger.

)}
) : ( <>
{executions.map(execution => { const startedAt = toMillis(execution.startedAt) const finishedAt = toMillis(execution.finishedAt) return ( openExecution(execution)} title="Open this execution in the workflow editor" className="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" > ) })}
Status Workflow Trigger Runner Started Duration Actions
{getStatusIcon(execution.status)} {execution.status}
{execution.workflowName || execution.workflowId}
{execution.id}
{execution.error && (
{execution.error}
)} {/* A run ended by a stop node carries a message the same way a failed one carries an error, and it is the only record of why a completed run did not do the rest of its work. Shown in a neutral colour: this outcome was chosen, not suffered. */} {!execution.error && execution.stopReason && (
{execution.stopReason}
)}
{execution.triggerType || '-'} {execution.runnerId || '-'} {startedAt ? safeFormatDistanceToNow(startedAt) : '-'} {formatElapsed(startedAt, finishedAt, now)} {IN_FLIGHT.includes(execution.status) && ( )}
{(sort === 'longest' || sort === 'shortest') && (

Duration is not stored, so it can only be ordered within the {executions.length} rows on this page - not across all {total} results.

)} {/* Pagination */}
Showing {((page - 1) * PAGE_SIZE) + 1} - {Math.min(page * PAGE_SIZE, total)} of {total} {hasFilters ? ' matching executions' : ' executions'}
Page {page} of {totalPages || 1}
)}
) }