Skip to content

Commit 49e989d

Browse files
fix(executions): show loop iteration taskruns in the Gantt view
A LOOP child execution's taskruns are all orphans: their parentTaskRunId points at the Loop run, which lives in the parent execution and is never present in the child's taskRunList. The Gantt view dropped such orphans (they were pushed to childTasks but never reattached, so they never reached rootTasks), while the Logs view treated them as roots and rendered them. The Gantt tab therefore showed the empty "ended without running any task" state for every loop iteration. Unify the duplicated parent->child ordering of both views into one shared buildTaskRunHierarchy() helper that treats orphans (parentTaskRunId whose target is absent) as roots and assigns depth during the DFS walk. Gantt passes a start-date sibling comparator; the Logs view keeps list order.
1 parent 469323e commit 49e989d

4 files changed

Lines changed: 146 additions & 67 deletions

File tree

ui/src/components/executions/Gantt.vue

Lines changed: 8 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@
246246
import TaskRunActions from "./TaskRunActions.vue"
247247
import ExecutionPending from "./ExecutionPending.vue"
248248
import emptyIllustration from "../../assets/empty_visuals/generic.svg"
249+
import {buildTaskRunHierarchy} from "../../utils/taskRunHierarchy"
249250
import OnboardingSuccessPopup from "../onboarding/OnboardingSuccessPopup.vue"
250251
import SaveExecuteAnimation from "../inputs/SaveExecuteAnimation.vue"
251252
@@ -269,8 +270,7 @@
269270
270271
interface TaskWrapper {
271272
task: TaskRun;
272-
depth: number | undefined;
273-
children?: TaskWrapper[];
273+
depth: number;
274274
}
275275
276276
interface SeriesItem {
@@ -381,48 +381,12 @@
381381
return execution.value?.state?.histories?.[0] ? ts(execution.value.state.histories[0].date) : 0
382382
})
383383
384-
const tasks = computed<TaskWrapper[]>(() => {
385-
const rootTasks: TaskWrapper[] = []
386-
const childTasks: TaskWrapper[] = []
387-
const sortedTasks: TaskWrapper[] = []
388-
const tasksById: Record<string, TaskWrapper> = {}
389-
390-
for (const task of (execution.value?.taskRunList || []) as TaskRun[]) {
391-
const taskWrapper: TaskWrapper = {task, depth: task.parentTaskRunId ? undefined : 0}
392-
if (task.parentTaskRunId) {
393-
childTasks.push(taskWrapper)
394-
} else {
395-
rootTasks.push(taskWrapper)
396-
}
397-
tasksById[task.id] = taskWrapper
398-
}
399-
400-
for (let i = 0; i < childTasks.length; i++) {
401-
const taskWrapper = childTasks[i]
402-
const parentTask = tasksById[taskWrapper.task.parentTaskRunId!]
403-
if (parentTask) {
404-
taskWrapper.depth = parentTask.depth! + 1
405-
tasksById[taskWrapper.task.id] = taskWrapper
406-
if (!parentTask.children) {
407-
parentTask.children = []
408-
}
409-
parentTask.children.push(taskWrapper)
410-
}
411-
}
412-
413-
const nodeStart = (node: TaskWrapper): number => ts(node.task.state.histories[0].date)
414-
const childrenSort = (nodes: TaskWrapper[]): void => {
415-
nodes.sort((n1, n2) => (nodeStart(n1) > nodeStart(n2) ? 1 : -1))
416-
for (const node of nodes) {
417-
sortedTasks.push(node)
418-
if (node.children) {
419-
childrenSort(node.children)
420-
}
421-
}
422-
}
423-
childrenSort(rootTasks)
424-
return sortedTasks
425-
})
384+
const tasks = computed<TaskWrapper[]>(() =>
385+
buildTaskRunHierarchy(
386+
(execution.value?.taskRunList || []) as TaskRun[],
387+
(n1, n2) => ts(n1.state.histories[0].date) - ts(n2.state.histories[0].date),
388+
),
389+
)
426390
427391
const taskTypeByTaskRun = computed<Array<[TaskRun, string | undefined]>>(() => {
428392
return series.value.map(serie => [serie.task, taskType(serie.task)])

ui/src/components/logs/TaskRunDetails.vue

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@
287287
import {apiUrl} from "override/utils/route"
288288
import * as Utils from "../../utils/utils"
289289
import * as LogUtils from "../../utils/logs"
290+
import {buildTaskRunHierarchy} from "../../utils/taskRunHierarchy"
290291
import throttle from "lodash/throttle"
291292
import {useClient, type TaskRun, type TaskRunAttempt} from "@kestra-io/kestra-sdk"
292293
@@ -392,29 +393,7 @@
392393
393394
// Order taskruns parent → child and annotate depth, so dynamically-generated taskruns
394395
// (e.g. each Ansible play/task) render indented under their parent taskrun.
395-
const byId: Record<string, TaskRun> = Object.fromEntries(taskRunList.map((tr) => [tr.id, tr]))
396-
const childrenByParent: Record<string, TaskRun[]> = {}
397-
const roots: TaskRun[] = []
398-
for (const tr of taskRunList) {
399-
if (tr.parentTaskRunId && byId[tr.parentTaskRunId]) {
400-
(childrenByParent[tr.parentTaskRunId] ??= []).push(tr)
401-
} else {
402-
roots.push(tr)
403-
}
404-
}
405-
406-
const ordered: TaskRunWithDepth[] = []
407-
const walk = (nodes: TaskRun[], depth: number) => {
408-
for (const node of nodes) {
409-
ordered.push({...node, depth})
410-
const children = childrenByParent[node.id]
411-
if (children) {
412-
walk(children, depth + 1)
413-
}
414-
}
415-
}
416-
walk(roots, 0)
417-
return ordered
396+
return buildTaskRunHierarchy(taskRunList).map(({task, depth}) => ({...task, depth}))
418397
})
419398
420399
const taskRunById = computed(() =>

ui/src/utils/taskRunHierarchy.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Orders a flat list of taskruns parent → child, annotating each with its nesting depth.
3+
*
4+
* A taskrun is nested under its parent only when that parent is also present in the list. Orphans
5+
* (a `parentTaskRunId` whose target is absent — e.g. a LOOP iteration execution whose tasks point at
6+
* the Loop run living in the parent execution) are treated as roots so they still render.
7+
*
8+
* @param taskRunList the taskruns to order
9+
* @param compareSiblings optional comparator to sort siblings (e.g. by start date); when omitted the
10+
* original list order is preserved
11+
* @return the taskruns flattened depth-first, each paired with its depth (roots at depth 0)
12+
*/
13+
export function buildTaskRunHierarchy<T extends {id: string; parentTaskRunId?: string}>(
14+
taskRunList: T[],
15+
compareSiblings?: (a: T, b: T) => number,
16+
): Array<{task: T; depth: number}> {
17+
const byId = new Set(taskRunList.map((tr) => tr.id))
18+
const childrenByParent: Record<string, T[]> = {}
19+
const roots: T[] = []
20+
21+
for (const tr of taskRunList) {
22+
if (tr.parentTaskRunId && byId.has(tr.parentTaskRunId)) {
23+
(childrenByParent[tr.parentTaskRunId] ??= []).push(tr)
24+
} else {
25+
// root OR orphan-treated-as-root
26+
roots.push(tr)
27+
}
28+
}
29+
30+
if (compareSiblings) {
31+
roots.sort(compareSiblings)
32+
for (const children of Object.values(childrenByParent)) {
33+
children.sort(compareSiblings)
34+
}
35+
}
36+
37+
const ordered: Array<{task: T; depth: number}> = []
38+
const walk = (nodes: T[], depth: number): void => {
39+
for (const node of nodes) {
40+
// depth is computed during the walk — order-independent, parents resolve before children
41+
ordered.push({task: node, depth})
42+
const children = childrenByParent[node.id]
43+
if (children) {
44+
walk(children, depth + 1)
45+
}
46+
}
47+
}
48+
walk(roots, 0)
49+
50+
return ordered
51+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {describe, it, expect} from "vitest"
2+
3+
import {buildTaskRunHierarchy} from "../../../src/utils/taskRunHierarchy"
4+
5+
type TestTaskRun = {id: string; parentTaskRunId?: string; start?: number}
6+
7+
const flatten = (taskRunList: TestTaskRun[], compareSiblings?: (a: TestTaskRun, b: TestTaskRun) => number) =>
8+
buildTaskRunHierarchy(taskRunList, compareSiblings).map(({task, depth}) => [task.id, depth])
9+
10+
describe("buildTaskRunHierarchy", () => {
11+
it("returns an empty array for an empty list", () => {
12+
expect(buildTaskRunHierarchy([] as TestTaskRun[])).toEqual([])
13+
})
14+
15+
it("treats an orphan child (parent not in list) as a depth-0 root", () => {
16+
// A LOOP iteration execution: every taskrun points at the Loop run, which is absent from the list.
17+
const taskRunList: TestTaskRun[] = [
18+
{id: "log-a", parentTaskRunId: "loop-run-not-in-list"},
19+
{id: "log-b", parentTaskRunId: "loop-run-not-in-list"},
20+
]
21+
22+
expect(flatten(taskRunList)).toEqual([
23+
["log-a", 0],
24+
["log-b", 0],
25+
])
26+
})
27+
28+
it("nests present children under their parent with correct depth", () => {
29+
const taskRunList: TestTaskRun[] = [
30+
{id: "parent"},
31+
{id: "child", parentTaskRunId: "parent"},
32+
{id: "root"},
33+
]
34+
35+
expect(flatten(taskRunList)).toEqual([
36+
["parent", 0],
37+
["child", 1],
38+
["root", 0],
39+
])
40+
})
41+
42+
it("handles multi-level nesting", () => {
43+
const taskRunList: TestTaskRun[] = [
44+
{id: "a"},
45+
{id: "b", parentTaskRunId: "a"},
46+
{id: "c", parentTaskRunId: "b"},
47+
]
48+
49+
expect(flatten(taskRunList)).toEqual([
50+
["a", 0],
51+
["b", 1],
52+
["c", 2],
53+
])
54+
})
55+
56+
it("sorts siblings with the provided comparator (Gantt's start-date contract)", () => {
57+
const taskRunList: TestTaskRun[] = [
58+
{id: "late", start: 200},
59+
{id: "early", start: 100},
60+
{id: "late-child", parentTaskRunId: "early", start: 50},
61+
{id: "early-child", parentTaskRunId: "early", start: 10},
62+
]
63+
64+
expect(flatten(taskRunList, (a, b) => (a.start ?? 0) - (b.start ?? 0))).toEqual([
65+
["early", 0],
66+
["early-child", 1],
67+
["late-child", 1],
68+
["late", 0],
69+
])
70+
})
71+
72+
it("preserves list order when no comparator is given (Logs' behaviour)", () => {
73+
const taskRunList: TestTaskRun[] = [
74+
{id: "third"},
75+
{id: "first"},
76+
{id: "second", parentTaskRunId: "third"},
77+
]
78+
79+
expect(flatten(taskRunList)).toEqual([
80+
["third", 0],
81+
["second", 1],
82+
["first", 0],
83+
])
84+
})
85+
})

0 commit comments

Comments
 (0)