Skip to content

Commit deed8e4

Browse files
Ekaterina BulatovaEkaterina Bulatova
authored andcommitted
feat(webapp): child run status breakdown tooltip on the runs table
Root run status cells on the runs list now show a tooltip that breaks down the statuses of their child runs. Roots are resolved through RunsRepository (ClickHouse-backed in production) and child counts come from a new getChildRunStatusCounts query (FINAL + a created_at lower bound), exposed via a /children-statuses resource route and rendered in RunStatusCellTooltip.
1 parent 6961004 commit deed8e4

10 files changed

Lines changed: 783 additions & 8 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Root run status cells on the runs table show a tooltip with a breakdown of child run statuses, aggregated in ClickHouse (roots resolved in Postgres by friendly ID).

apps/webapp/app/components/primitives/Tooltip.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function SimpleTooltip({
6666
sideOffset,
6767
open,
6868
onOpenChange,
69+
delayDuration,
6970
}: {
7071
button: React.ReactNode;
7172
content: React.ReactNode;
@@ -80,12 +81,13 @@ function SimpleTooltip({
8081
sideOffset?: number;
8182
open?: boolean;
8283
onOpenChange?: (open: boolean) => void;
84+
delayDuration?: number;
8385
}) {
8486
return (
8587
<TooltipProvider disableHoverableContent={disableHoverableContent}>
86-
<Tooltip open={open} onOpenChange={onOpenChange}>
88+
<Tooltip open={open} onOpenChange={onOpenChange} delayDuration={delayDuration}>
8789
<TooltipTrigger
88-
type="button"
90+
type={asChild ? undefined : "button"}
8991
tabIndex={-1}
9092
className={cn(!asChild && "h-fit", buttonClassName)}
9193
style={buttonStyle}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { useFetcher } from "@remix-run/react";
2+
import { AnimatePresence, motion } from "framer-motion";
3+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4+
import { SimpleTooltip } from "~/components/primitives/Tooltip";
5+
import type { NextRunListItem } from "~/presenters/v3/NextRunListPresenter.server";
6+
import type { loader as childStatusesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.children-statuses";
7+
import { isFinalRunStatus } from "~/v3/taskStatus";
8+
import {
9+
descriptionForTaskRunStatus,
10+
filterableTaskRunStatuses,
11+
TaskRunStatusCombo,
12+
} from "./TaskRunStatus";
13+
14+
const TOOLTIP_OPEN_DELAY_MS = 400;
15+
const TOOLTIP_POLL_INTERVAL_MS = 3000;
16+
17+
type ChildStatusEntry = { status: NextRunListItem["status"]; count: number };
18+
19+
// Compare status/count pairs so unchanged polling responses don't
20+
// re-render or re-animate the tooltip.
21+
function childStatusesKey(statuses: ChildStatusEntry[]) {
22+
return [...statuses]
23+
.sort((a, b) => a.status.localeCompare(b.status))
24+
.map((entry) => `${entry.status}:${entry.count}`)
25+
.join("|");
26+
}
27+
28+
function areChildStatusesEqual(previous: ChildStatusEntry[] | undefined, next: ChildStatusEntry[]) {
29+
if (previous === undefined) return false;
30+
return childStatusesKey(previous) === childStatusesKey(next);
31+
}
32+
33+
function hasActiveChildStatuses(statuses: ChildStatusEntry[] | undefined) {
34+
if (statuses === undefined) return false;
35+
36+
return statuses.some((entry) => entry.count > 0 && !isFinalRunStatus(entry.status));
37+
}
38+
39+
function shouldPollWhileTooltipOpen(
40+
statuses: ChildStatusEntry[] | undefined,
41+
rootHasFinished: boolean
42+
) {
43+
if (statuses === undefined) return true;
44+
// Empty child statuses while the root is still running can mean
45+
// children have not been created yet, so keep polling.
46+
if (statuses.length === 0) return !rootHasFinished;
47+
48+
// All current children may be final while the root is still running — more
49+
// dependents can still be created.
50+
return hasActiveChildStatuses(statuses) || !rootHasFinished;
51+
}
52+
53+
function ChildStatusBreakdown({
54+
orderedChildStatuses,
55+
}: {
56+
orderedChildStatuses: { status: NextRunListItem["status"]; count: number }[];
57+
}) {
58+
return (
59+
<div className="flex min-w-[10rem] flex-col gap-1 p-1">
60+
<p className="mb-1 text-xs text-text-dimmed">Child run statuses</p>
61+
<AnimatePresence initial={false} mode="popLayout">
62+
{orderedChildStatuses.map((entry) => (
63+
<motion.div
64+
key={entry.status}
65+
layout
66+
initial={{ opacity: 0, y: -4 }}
67+
animate={{ opacity: 1, y: 0 }}
68+
exit={{ opacity: 0, y: 4 }}
69+
transition={{ duration: 0.2, ease: "easeOut" }}
70+
className="flex items-center justify-between gap-2"
71+
>
72+
<TaskRunStatusCombo status={entry.status} />
73+
<motion.span
74+
key={entry.count}
75+
layout
76+
initial={{ opacity: 0.6, scale: 0.95 }}
77+
animate={{ opacity: 1, scale: 1 }}
78+
transition={{ duration: 0.15, ease: "easeOut" }}
79+
className="text-xs tabular-nums text-text-bright"
80+
>
81+
{entry.count}
82+
</motion.span>
83+
</motion.div>
84+
))}
85+
</AnimatePresence>
86+
</div>
87+
);
88+
}
89+
90+
function useChildRunStatusesTooltip({
91+
friendlyId,
92+
hasFinished,
93+
childrenStatusesBasePath,
94+
}: {
95+
friendlyId: string;
96+
hasFinished: boolean;
97+
childrenStatusesBasePath: string;
98+
}) {
99+
const fetcher = useFetcher<typeof childStatusesLoader>({
100+
key: `child-statuses-${friendlyId}`,
101+
});
102+
const fetcherStateRef = useRef(fetcher.state);
103+
fetcherStateRef.current = fetcher.state;
104+
105+
const [childStatuses, setChildStatuses] = useState<ChildStatusEntry[] | undefined>();
106+
const isOpenRef = useRef(false);
107+
const pollIntervalRef = useRef<ReturnType<typeof setInterval>>();
108+
const prevHasFinishedRef = useRef(hasFinished);
109+
110+
const childrenStatusesUrl = useMemo(
111+
() => `${childrenStatusesBasePath}/children-statuses?runIds=${encodeURIComponent(friendlyId)}`,
112+
[childrenStatusesBasePath, friendlyId]
113+
);
114+
115+
const loadChildStatuses = useCallback(() => {
116+
if (fetcherStateRef.current !== "idle") return;
117+
fetcher.load(childrenStatusesUrl);
118+
}, [childrenStatusesUrl, fetcher]);
119+
120+
// Keep the latest loader callback available to the polling interval
121+
// without recreating the interval on every render.
122+
const loadChildStatusesRef = useRef(loadChildStatuses);
123+
loadChildStatusesRef.current = loadChildStatuses;
124+
125+
const stopPolling = useCallback(() => {
126+
if (pollIntervalRef.current) {
127+
clearInterval(pollIntervalRef.current);
128+
pollIntervalRef.current = undefined;
129+
}
130+
}, []);
131+
132+
const startPolling = useCallback(() => {
133+
if (pollIntervalRef.current) return;
134+
135+
pollIntervalRef.current = setInterval(() => {
136+
if (document.visibilityState !== "visible") return;
137+
loadChildStatusesRef.current();
138+
}, TOOLTIP_POLL_INTERVAL_MS);
139+
}, []);
140+
141+
useEffect(() => {
142+
if (!fetcher.data?.runs) return;
143+
144+
const entry = fetcher.data.runs.find((run) => run.friendlyId === friendlyId);
145+
if (!entry) return;
146+
147+
setChildStatuses((previous) =>
148+
areChildStatusesEqual(previous, entry.statuses) ? previous : entry.statuses
149+
);
150+
151+
if (isOpenRef.current && !shouldPollWhileTooltipOpen(entry.statuses, hasFinished)) {
152+
stopPolling();
153+
}
154+
}, [fetcher.data, friendlyId, hasFinished, stopPolling]);
155+
156+
const onOpenChange = useCallback(
157+
(open: boolean) => {
158+
isOpenRef.current = open;
159+
if (open) {
160+
loadChildStatuses();
161+
startPolling();
162+
} else {
163+
stopPolling();
164+
}
165+
},
166+
[loadChildStatuses, startPolling, stopPolling]
167+
);
168+
169+
useEffect(() => {
170+
prevHasFinishedRef.current = hasFinished;
171+
stopPolling();
172+
setChildStatuses(undefined);
173+
if (isOpenRef.current) {
174+
loadChildStatuses();
175+
startPolling();
176+
}
177+
// Only reset when the hovered run changes, not when hasFinished toggles.
178+
// eslint-disable-next-line react-hooks/exhaustive-deps -- friendlyId
179+
}, [friendlyId]);
180+
181+
useEffect(() => {
182+
if (!isOpenRef.current) return;
183+
if (prevHasFinishedRef.current === hasFinished) return;
184+
185+
prevHasFinishedRef.current = hasFinished;
186+
loadChildStatuses();
187+
}, [hasFinished, loadChildStatuses]);
188+
189+
useEffect(() => () => stopPolling(), [stopPolling]);
190+
191+
return {
192+
childStatuses,
193+
onOpenChange,
194+
};
195+
}
196+
197+
export function RunStatusCellTooltip({
198+
friendlyId,
199+
status,
200+
hasFinished,
201+
childrenStatusesBasePath,
202+
}: {
203+
friendlyId: string;
204+
status: NextRunListItem["status"];
205+
hasFinished: boolean;
206+
childrenStatusesBasePath: string;
207+
}) {
208+
const { childStatuses, onOpenChange } = useChildRunStatusesTooltip({
209+
friendlyId,
210+
hasFinished,
211+
childrenStatusesBasePath,
212+
});
213+
214+
const orderedChildStatuses = useMemo(() => {
215+
const childStatusesMap = new Map(
216+
(childStatuses ?? []).map((entry) => [entry.status, entry.count])
217+
);
218+
219+
return filterableTaskRunStatuses
220+
.map((s) => ({
221+
status: s,
222+
count: childStatusesMap.get(s) ?? 0,
223+
}))
224+
.filter((entry) => entry.count > 0);
225+
}, [childStatuses]);
226+
227+
const hasChildStatuses = orderedChildStatuses.length > 0;
228+
229+
return (
230+
<SimpleTooltip
231+
asChild
232+
delayDuration={TOOLTIP_OPEN_DELAY_MS}
233+
onOpenChange={onOpenChange}
234+
content={
235+
hasChildStatuses ? (
236+
<ChildStatusBreakdown orderedChildStatuses={orderedChildStatuses} />
237+
) : (
238+
descriptionForTaskRunStatus(status)
239+
)
240+
}
241+
disableHoverableContent
242+
button={
243+
<span className="inline-flex min-w-full items-center">
244+
<TaskRunStatusCombo status={status} />
245+
</span>
246+
}
247+
/>
248+
);
249+
}

apps/webapp/app/components/runs/v3/TaskRunsTable.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
filterableTaskRunStatuses,
5858
TaskRunStatusCombo,
5959
} from "./TaskRunStatus";
60+
import { RunStatusCellTooltip } from "./RunStatusCellTooltip";
6061
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
6162
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
6263
import { useSearchParams } from "~/hooks/useSearchParam";
@@ -74,6 +75,7 @@ type RunsTableProps = {
7475
variant?: TableVariant;
7576
disableAdjacentRows?: boolean;
7677
additionalTableState?: Record<string, string>;
78+
childrenStatusesBasePath?: string;
7779
};
7880

7981
export function TaskRunsTable({
@@ -87,6 +89,7 @@ export function TaskRunsTable({
8789
allowSelection = false,
8890
variant = "dimmed",
8991
additionalTableState,
92+
childrenStatusesBasePath,
9093
}: RunsTableProps) {
9194
const regions = useRegions();
9295
const regionByMasterQueue = new Map(regions.map((r) => [r.masterQueue, r] as const));
@@ -371,11 +374,20 @@ export function TaskRunsTable({
371374
</TableCell>
372375
<TableCell to={path}>{run.version ?? "–"}</TableCell>
373376
<TableCell to={path}>
374-
<SimpleTooltip
375-
content={descriptionForTaskRunStatus(run.status)}
376-
disableHoverableContent
377-
button={<TaskRunStatusCombo status={run.status} />}
378-
/>
377+
{run.rootTaskRunId === null && childrenStatusesBasePath ? (
378+
<RunStatusCellTooltip
379+
friendlyId={run.friendlyId}
380+
status={run.status}
381+
hasFinished={run.hasFinished}
382+
childrenStatusesBasePath={childrenStatusesBasePath}
383+
/>
384+
) : (
385+
<SimpleTooltip
386+
content={descriptionForTaskRunStatus(run.status)}
387+
disableHoverableContent
388+
button={<TaskRunStatusCombo status={run.status} />}
389+
/>
390+
)}
379391
</TableCell>
380392
<TableCell to={path}>
381393
{run.startedAt ? <DateTime date={run.startedAt} /> : "–"}

0 commit comments

Comments
 (0)