Skip to content

Commit dc75e89

Browse files
Ekaterina BulatovaEkaterina Bulatova
authored andcommitted
feat(webapp): live child-status breakdown in root run tooltips
Add runs/children-statuses resource route with PG groupBy per root. Show breakdown on root rows after a 400ms hover delay; fetch when the tooltip opens; poll every 3s while open until children settle.
1 parent 3930a5e commit dc75e89

4 files changed

Lines changed: 375 additions & 7 deletions

File tree

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(
29+
previous: ChildStatusEntry[] | undefined,
30+
next: ChildStatusEntry[]
31+
) {
32+
if (previous === undefined) return false;
33+
return childStatusesKey(previous) === childStatusesKey(next);
34+
}
35+
36+
function hasActiveChildStatuses(statuses: ChildStatusEntry[] | undefined) {
37+
if (statuses === undefined) return false;
38+
39+
return statuses.some((entry) => entry.count > 0 && !isFinalRunStatus(entry.status));
40+
}
41+
42+
function shouldPollWhileTooltipOpen(
43+
statuses: ChildStatusEntry[] | undefined,
44+
rootHasFinished: boolean
45+
) {
46+
if (statuses === undefined) return true;
47+
// Empty child statuses while the root is still running can mean
48+
// children have not been created yet, so keep polling.
49+
if (statuses.length === 0) return !rootHasFinished;
50+
51+
return hasActiveChildStatuses(statuses);
52+
}
53+
54+
function ChildStatusBreakdown({
55+
orderedChildStatuses,
56+
}: {
57+
orderedChildStatuses: { status: NextRunListItem["status"]; count: number }[];
58+
}) {
59+
return (
60+
<div className="flex min-w-[10rem] flex-col gap-1 p-1">
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+
() =>
112+
`${childrenStatusesBasePath}/children-statuses?runIds=${encodeURIComponent(friendlyId)}`,
113+
[childrenStatusesBasePath, friendlyId]
114+
);
115+
116+
const loadChildStatuses = useCallback(() => {
117+
if (fetcherStateRef.current !== "idle") return;
118+
fetcher.load(childrenStatusesUrl);
119+
}, [childrenStatusesUrl, fetcher]);
120+
121+
// Keep the latest loader callback available to the polling interval
122+
// without recreating the interval on every render.
123+
const loadChildStatusesRef = useRef(loadChildStatuses);
124+
loadChildStatusesRef.current = loadChildStatuses;
125+
126+
const stopPolling = useCallback(() => {
127+
if (pollIntervalRef.current) {
128+
clearInterval(pollIntervalRef.current);
129+
pollIntervalRef.current = undefined;
130+
}
131+
}, []);
132+
133+
const startPolling = useCallback(() => {
134+
if (pollIntervalRef.current) return;
135+
136+
pollIntervalRef.current = setInterval(() => {
137+
if (document.visibilityState !== "visible") return;
138+
loadChildStatusesRef.current();
139+
}, TOOLTIP_POLL_INTERVAL_MS);
140+
}, []);
141+
142+
useEffect(() => {
143+
if (!fetcher.data?.runs) return;
144+
145+
const entry = fetcher.data.runs.find((run) => run.friendlyId === friendlyId);
146+
if (!entry) return;
147+
148+
setChildStatuses((previous) =>
149+
areChildStatusesEqual(previous, entry.statuses) ? previous : entry.statuses
150+
);
151+
152+
if (isOpenRef.current && !shouldPollWhileTooltipOpen(entry.statuses, hasFinished)) {
153+
stopPolling();
154+
}
155+
}, [fetcher.data, friendlyId, hasFinished, stopPolling]);
156+
157+
const onOpenChange = useCallback(
158+
(open: boolean) => {
159+
isOpenRef.current = open;
160+
if (open) {
161+
loadChildStatuses();
162+
startPolling();
163+
} else {
164+
stopPolling();
165+
}
166+
},
167+
[loadChildStatuses, startPolling, stopPolling]
168+
);
169+
170+
useEffect(() => {
171+
prevHasFinishedRef.current = hasFinished;
172+
stopPolling();
173+
setChildStatuses(undefined);
174+
if (isOpenRef.current) {
175+
loadChildStatuses();
176+
startPolling();
177+
}
178+
// Only reset when the hovered run changes, not when hasFinished toggles.
179+
// eslint-disable-next-line react-hooks/exhaustive-deps -- friendlyId
180+
}, [friendlyId]);
181+
182+
useEffect(() => {
183+
if (!isOpenRef.current) return;
184+
if (prevHasFinishedRef.current === hasFinished) return;
185+
186+
prevHasFinishedRef.current = hasFinished;
187+
loadChildStatuses();
188+
}, [hasFinished, loadChildStatuses]);
189+
190+
useEffect(() => () => stopPolling(), [stopPolling]);
191+
192+
return { childStatuses, onOpenChange };
193+
}
194+
195+
export function RunStatusCellTooltip({
196+
friendlyId,
197+
status,
198+
hasFinished,
199+
childrenStatusesBasePath,
200+
}: {
201+
friendlyId: string;
202+
status: NextRunListItem["status"];
203+
hasFinished: boolean;
204+
childrenStatusesBasePath: string;
205+
}) {
206+
const { childStatuses, onOpenChange } = useChildRunStatusesTooltip({
207+
friendlyId,
208+
hasFinished,
209+
childrenStatusesBasePath,
210+
});
211+
212+
const orderedChildStatuses = useMemo(() => {
213+
const childStatusesMap = new Map(
214+
(childStatuses ?? []).map((entry) => [entry.status, entry.count])
215+
);
216+
217+
return filterableTaskRunStatuses
218+
.map((s) => ({
219+
status: s,
220+
count: childStatusesMap.get(s) ?? 0,
221+
}))
222+
.filter((entry) => entry.count > 0);
223+
}, [childStatuses]);
224+
225+
const hasChildStatuses = orderedChildStatuses.length > 0;
226+
227+
return (
228+
<SimpleTooltip
229+
asChild
230+
delayDuration={TOOLTIP_OPEN_DELAY_MS}
231+
onOpenChange={onOpenChange}
232+
content={
233+
childStatuses === undefined ? (
234+
<span className="text-xs text-text-dimmed">Loading child runs…</span>
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: 13 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,16 @@ 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+
<TaskRunStatusCombo status={run.status} />
386+
)}
379387
</TableCell>
380388
<TableCell to={path}>
381389
{run.startedAt ? <DateTime date={run.startedAt} /> : "–"}

0 commit comments

Comments
 (0)