|
|
@@ -6,7 +6,7 @@ import { executionsApi, workflowsApi, nodesApi, type ExecutionListItem } from '.
|
|
|
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 { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, ChevronDown, AlertCircle, Octagon, Square, Hourglass, Search, X, CornerDownRight } from 'lucide-react'
|
|
|
import clsx from 'clsx'
|
|
|
|
|
|
// Helper to safely format timestamps that might be invalid or too large
|
|
|
@@ -274,6 +274,106 @@ export default function ExecutionsPage() {
|
|
|
return [...rows].sort((a, b) => (durationOf(a, now) - durationOf(b, now)) * direction)
|
|
|
}, [data, sort, now])
|
|
|
|
|
|
+ // Sub-workflow runs are folded into the run that started them, so a pipeline
|
|
|
+ // that calls six workflows takes one row rather than seven.
|
|
|
+ //
|
|
|
+ // The children of every run on this page are fetched in one request rather
|
|
|
+ // than grouped from what the page happens to contain. A child starts after
|
|
|
+ // its parent, so newest-first puts children above the parent - and a parent
|
|
|
+ // at the top of one page has its children at the bottom of the previous one.
|
|
|
+ const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
|
|
+
|
|
|
+ const topLevel = useMemo(
|
|
|
+ // An execution recorded before parentExecutionId existed has no such field,
|
|
|
+ // and reads as top level. That is deliberate: the alternative is a
|
|
|
+ // server-side filter that would hide every run in the existing history.
|
|
|
+ () => executions.filter((e) => !e.parentExecutionId),
|
|
|
+ [executions]
|
|
|
+ )
|
|
|
+
|
|
|
+ // Asked for by the time window this page covers, not by a list of parent ids.
|
|
|
+ //
|
|
|
+ // The obvious query - parentExecutionId in (id, id, ...) - comes back empty,
|
|
|
+ // although each id on its own matches. The same "in" works on status and on
|
|
|
+ // workflowId, which the listing already scopes itself with; what is different
|
|
|
+ // about parentExecutionId is that most executions do not have the field at
|
|
|
+ // all. Rather than build on an operator that behaves differently on a sparse
|
|
|
+ // field, this asks for the runs that WERE started by another workflow -
|
|
|
+ // triggerType is on every record - within the window the page covers, and
|
|
|
+ // does the grouping here.
|
|
|
+ const childWindow = useMemo(() => {
|
|
|
+ if (topLevel.length === 0) return null
|
|
|
+ let from = Infinity
|
|
|
+ let to = 0
|
|
|
+ for (const row of topLevel) {
|
|
|
+ const started = toMillis(row.startedAt) ?? 0
|
|
|
+ const finished = toMillis(row.finishedAt) || now
|
|
|
+ if (started > 0) from = Math.min(from, started)
|
|
|
+ to = Math.max(to, finished)
|
|
|
+ }
|
|
|
+ return from === Infinity ? null : { from, to }
|
|
|
+ // `now` deliberately absent: a still-running parent would otherwise move
|
|
|
+ // the window every second and refetch the children with it.
|
|
|
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
+ }, [topLevel])
|
|
|
+
|
|
|
+ const { data: childRows } = useQuery({
|
|
|
+ queryKey: ['executions', 'children', childWindow?.from, childWindow?.to],
|
|
|
+ queryFn: () =>
|
|
|
+ executionsApi.query({
|
|
|
+ triggerType: ['workflow', 'error-workflow'],
|
|
|
+ startedAfter: childWindow!.from,
|
|
|
+ startedBefore: childWindow!.to,
|
|
|
+ pageSize: 200,
|
|
|
+ }),
|
|
|
+ enabled: !!childWindow,
|
|
|
+ })
|
|
|
+
|
|
|
+ // Grouped by parent, and by their own parent too - a called workflow can call
|
|
|
+ // another, which is what makes this a tree rather than one level of nesting.
|
|
|
+ const childrenByParent = useMemo(() => {
|
|
|
+ const map = new Map<string, ExecutionListItem[]>()
|
|
|
+ for (const child of childRows?.executions || []) {
|
|
|
+ if (!child.parentExecutionId) continue
|
|
|
+ const list = map.get(child.parentExecutionId) || []
|
|
|
+ list.push(child)
|
|
|
+ map.set(child.parentExecutionId, list)
|
|
|
+ }
|
|
|
+ // Newest first, matching the page's own order.
|
|
|
+ for (const list of map.values()) {
|
|
|
+ list.sort((a, b) => toMillis(b.startedAt)! - toMillis(a.startedAt)!)
|
|
|
+ }
|
|
|
+ return map
|
|
|
+ }, [childRows])
|
|
|
+
|
|
|
+ // One flat list the table can render, carrying how deep each row sits.
|
|
|
+ const visibleRows = useMemo(() => {
|
|
|
+ const out: { execution: ExecutionListItem; depth: number; childCount: number }[] = []
|
|
|
+ const walk = (execution: ExecutionListItem, depth: number, seen: Set<string>) => {
|
|
|
+ // A cycle cannot happen through Call Workflow, which refuses to nest more
|
|
|
+ // than a fixed depth - but a walk that trusted the data to be acyclic
|
|
|
+ // would hang the page rather than show a wrong row.
|
|
|
+ if (seen.has(execution.id)) return
|
|
|
+ seen.add(execution.id)
|
|
|
+ const children = childrenByParent.get(execution.id) || []
|
|
|
+ out.push({ execution, depth, childCount: children.length })
|
|
|
+ if (expanded.has(execution.id)) {
|
|
|
+ for (const child of children) walk(child, depth + 1, seen)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (const row of topLevel) walk(row, 0, new Set())
|
|
|
+ return out
|
|
|
+ }, [topLevel, childrenByParent, expanded])
|
|
|
+
|
|
|
+ const toggleExpanded = (id: string) => {
|
|
|
+ setExpanded((previous) => {
|
|
|
+ const next = new Set(previous)
|
|
|
+ if (next.has(id)) next.delete(id)
|
|
|
+ else next.add(id)
|
|
|
+ return next
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
const toggleStatus = (status: string) => {
|
|
|
const next = statuses.includes(status)
|
|
|
? statuses.filter(s => s !== status)
|
|
|
@@ -510,18 +610,44 @@ export default function ExecutionsPage() {
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
|
|
|
- {executions.map(execution => {
|
|
|
+ {visibleRows.map(({ execution, depth, childCount }) => {
|
|
|
const startedAt = toMillis(execution.startedAt)
|
|
|
const finishedAt = toMillis(execution.finishedAt)
|
|
|
+ const isOpen = expanded.has(execution.id)
|
|
|
return (
|
|
|
<tr
|
|
|
key={execution.id}
|
|
|
onClick={() => openExecution(execution)}
|
|
|
title="Open this execution in the workflow editor"
|
|
|
- className="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer"
|
|
|
+ className={clsx(
|
|
|
+ 'hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer',
|
|
|
+ depth > 0 && 'bg-gray-50/60 dark:bg-slate-900/40'
|
|
|
+ )}
|
|
|
>
|
|
|
<td className="px-4 py-3 align-top">
|
|
|
- <div className="flex items-center gap-2">
|
|
|
+ <div
|
|
|
+ className="flex items-center gap-2"
|
|
|
+ style={{ paddingLeft: depth * 18 }}
|
|
|
+ >
|
|
|
+ {childCount > 0 ? (
|
|
|
+ <button
|
|
|
+ onClick={(e) => {
|
|
|
+ // The row itself opens the execution, so the
|
|
|
+ // control that opens the group must not do both.
|
|
|
+ e.stopPropagation()
|
|
|
+ toggleExpanded(execution.id)
|
|
|
+ }}
|
|
|
+ title={isOpen ? 'Hide the runs this one started' : `Show the ${childCount} run(s) this one started`}
|
|
|
+ className="p-0.5 -ml-1 rounded text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-slate-700"
|
|
|
+ >
|
|
|
+ {isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
|
|
+ </button>
|
|
|
+ ) : (
|
|
|
+ <span className="inline-block w-4" />
|
|
|
+ )}
|
|
|
+ {depth > 0 && (
|
|
|
+ <CornerDownRight className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
|
|
+ )}
|
|
|
{getStatusIcon(execution.status)}
|
|
|
<span
|
|
|
className={clsx(
|
|
|
@@ -537,6 +663,24 @@ export default function ExecutionsPage() {
|
|
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
|
|
{execution.workflowName || execution.workflowId}
|
|
|
</span>
|
|
|
+ {/* What a closed group holds, so it does not have to be
|
|
|
+ opened to find out whether anything is in it - and so
|
|
|
+ a failure inside is not hidden behind a chevron that
|
|
|
+ looks like every other chevron. */}
|
|
|
+ {childCount > 0 && !isOpen && (
|
|
|
+ <span
|
|
|
+ className={clsx(
|
|
|
+ 'ml-2 px-2 py-0.5 rounded-full text-xs font-medium',
|
|
|
+ (childrenByParent.get(execution.id) || []).some((c) => c.status === 'failed')
|
|
|
+ ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
|
|
+ : 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-300'
|
|
|
+ )}
|
|
|
+ >
|
|
|
+ {(childrenByParent.get(execution.id) || []).some((c) => c.status === 'failed')
|
|
|
+ ? `${childCount} sub-run${childCount === 1 ? '' : 's'}, one failed`
|
|
|
+ : `${childCount} sub-run${childCount === 1 ? '' : 's'}`}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
|
|
{execution.id}
|
|
|
</div>
|