Skip to content

Commit e4925d1

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 e4925d1

5 files changed

Lines changed: 386 additions & 8 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: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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+
<AnimatePresence initial={false} mode="popLayout">
61+
{orderedChildStatuses.map((entry) => (
62+
<motion.div
63+
key={entry.status}
64+
layout
65+
initial={{ opacity: 0, y: -4 }}
66+
animate={{ opacity: 1, y: 0 }}
67+
exit={{ opacity: 0, y: 4 }}
68+
transition={{ duration: 0.2, ease: "easeOut" }}
69+
className="flex items-center justify-between gap-2"
70+
>
71+
<TaskRunStatusCombo status={entry.status} />
72+
<motion.span
73+
key={entry.count}
74+
layout
75+
initial={{ opacity: 0.6, scale: 0.95 }}
76+
animate={{ opacity: 1, scale: 1 }}
77+
transition={{ duration: 0.15, ease: "easeOut" }}
78+
className="text-xs tabular-nums text-text-bright"
79+
>
80+
{entry.count}
81+
</motion.span>
82+
</motion.div>
83+
))}
84+
</AnimatePresence>
85+
</div>
86+
);
87+
}
88+
89+
function useChildRunStatusesTooltip({
90+
friendlyId,
91+
hasFinished,
92+
childrenStatusesBasePath,
93+
}: {
94+
friendlyId: string;
95+
hasFinished: boolean;
96+
childrenStatusesBasePath: string;
97+
}) {
98+
const fetcher = useFetcher<typeof childStatusesLoader>({
99+
key: `child-statuses-${friendlyId}`,
100+
});
101+
const fetcherStateRef = useRef(fetcher.state);
102+
fetcherStateRef.current = fetcher.state;
103+
104+
const [childStatuses, setChildStatuses] = useState<ChildStatusEntry[] | undefined>();
105+
const isOpenRef = useRef(false);
106+
const pollIntervalRef = useRef<ReturnType<typeof setInterval>>();
107+
const prevHasFinishedRef = useRef(hasFinished);
108+
109+
const childrenStatusesUrl = useMemo(
110+
() => `${childrenStatusesBasePath}/children-statuses?runIds=${encodeURIComponent(friendlyId)}`,
111+
[childrenStatusesBasePath, friendlyId]
112+
);
113+
114+
const loadChildStatuses = useCallback(() => {
115+
if (fetcherStateRef.current !== "idle") return;
116+
fetcher.load(childrenStatusesUrl);
117+
}, [childrenStatusesUrl, fetcher]);
118+
119+
// Keep the latest loader callback available to the polling interval
120+
// without recreating the interval on every render.
121+
const loadChildStatusesRef = useRef(loadChildStatuses);
122+
loadChildStatusesRef.current = loadChildStatuses;
123+
124+
const stopPolling = useCallback(() => {
125+
if (pollIntervalRef.current) {
126+
clearInterval(pollIntervalRef.current);
127+
pollIntervalRef.current = undefined;
128+
}
129+
}, []);
130+
131+
const startPolling = useCallback(() => {
132+
if (pollIntervalRef.current) return;
133+
134+
pollIntervalRef.current = setInterval(() => {
135+
if (document.visibilityState !== "visible") return;
136+
loadChildStatusesRef.current();
137+
}, TOOLTIP_POLL_INTERVAL_MS);
138+
}, []);
139+
140+
useEffect(() => {
141+
if (!fetcher.data?.runs) return;
142+
143+
const entry = fetcher.data.runs.find((run) => run.friendlyId === friendlyId);
144+
if (!entry) return;
145+
146+
setChildStatuses((previous) =>
147+
areChildStatusesEqual(previous, entry.statuses) ? previous : entry.statuses
148+
);
149+
150+
if (isOpenRef.current && !shouldPollWhileTooltipOpen(entry.statuses, hasFinished)) {
151+
stopPolling();
152+
}
153+
}, [fetcher.data, friendlyId, hasFinished, stopPolling]);
154+
155+
const onOpenChange = useCallback(
156+
(open: boolean) => {
157+
isOpenRef.current = open;
158+
if (open) {
159+
loadChildStatuses();
160+
startPolling();
161+
} else {
162+
stopPolling();
163+
}
164+
},
165+
[loadChildStatuses, startPolling, stopPolling]
166+
);
167+
168+
useEffect(() => {
169+
prevHasFinishedRef.current = hasFinished;
170+
stopPolling();
171+
setChildStatuses(undefined);
172+
if (isOpenRef.current) {
173+
loadChildStatuses();
174+
startPolling();
175+
}
176+
// Only reset when the hovered run changes, not when hasFinished toggles.
177+
// eslint-disable-next-line react-hooks/exhaustive-deps -- friendlyId
178+
}, [friendlyId]);
179+
180+
useEffect(() => {
181+
if (!isOpenRef.current) return;
182+
if (prevHasFinishedRef.current === hasFinished) return;
183+
184+
prevHasFinishedRef.current = hasFinished;
185+
loadChildStatuses();
186+
}, [hasFinished, loadChildStatuses]);
187+
188+
useEffect(() => () => stopPolling(), [stopPolling]);
189+
190+
return {
191+
childStatuses,
192+
isFetchingChildStatuses: fetcher.state !== "idle",
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, isFetchingChildStatuses, 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+
const showLoading =
229+
childStatuses === undefined ||
230+
(isFetchingChildStatuses && !hasChildStatuses) ||
231+
(!hasChildStatuses && !hasFinished);
232+
233+
return (
234+
<SimpleTooltip
235+
asChild
236+
delayDuration={TOOLTIP_OPEN_DELAY_MS}
237+
onOpenChange={onOpenChange}
238+
content={
239+
showLoading ? (
240+
<span className="text-xs text-text-dimmed">Loading …</span>
241+
) : hasChildStatuses ? (
242+
<ChildStatusBreakdown orderedChildStatuses={orderedChildStatuses} />
243+
) : (
244+
descriptionForTaskRunStatus(status)
245+
)
246+
}
247+
disableHoverableContent
248+
button={
249+
<span className="inline-flex min-w-full items-center">
250+
<TaskRunStatusCombo status={status} />
251+
</span>
252+
}
253+
/>
254+
);
255+
}

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} /> : "–"}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ function appendNewRunsSearchParams(
5050
filterParams.delete(key);
5151
}
5252
for (const [key, value] of filterParams) {
53-
searchParams.set(key, value);
53+
searchParams.append(key, value);
5454
}
5555
searchParams.set("includeNewRuns", "true");
5656
searchParams.set("since", String(since));

0 commit comments

Comments
 (0)