Skip to content

Commit 748ec65

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 live child status counts on root rows in all filter modes. Load on tooltip open with a 400ms delay (to prevent accidental hover-triggered requests); poll while open and children may still appear.
1 parent d359084 commit 748ec65

4 files changed

Lines changed: 416 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: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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+
function childStatusesKey(statuses: ChildStatusEntry[]) {
20+
return [...statuses]
21+
.sort((a, b) => a.status.localeCompare(b.status))
22+
.map((entry) => `${entry.status}:${entry.count}`)
23+
.join("|");
24+
}
25+
26+
function areChildStatusesEqual(
27+
previous: ChildStatusEntry[] | undefined,
28+
next: ChildStatusEntry[]
29+
) {
30+
if (previous === undefined) return false;
31+
return childStatusesKey(previous) === childStatusesKey(next);
32+
}
33+
34+
function hasActiveChildStatuses(statuses: ChildStatusEntry[] | undefined) {
35+
if (statuses === undefined) return false;
36+
37+
return statuses.some((entry) => entry.count > 0 && !isFinalRunStatus(entry.status));
38+
}
39+
40+
function shouldPollWhileTooltipOpen(
41+
statuses: ChildStatusEntry[] | undefined,
42+
rootHasFinished: boolean
43+
) {
44+
if (statuses === undefined) return true;
45+
if (statuses.length === 0) return !rootHasFinished;
46+
47+
return hasActiveChildStatuses(statuses);
48+
}
49+
50+
function ChildStatusBreakdown({
51+
orderedChildStatuses,
52+
}: {
53+
orderedChildStatuses: { status: NextRunListItem["status"]; count: number }[];
54+
}) {
55+
return (
56+
<div className="flex min-w-[10rem] flex-col gap-1 p-1">
57+
<AnimatePresence initial={false} mode="popLayout">
58+
{orderedChildStatuses.map((entry) => (
59+
<motion.div
60+
key={entry.status}
61+
layout
62+
initial={{ opacity: 0, y: -4 }}
63+
animate={{ opacity: 1, y: 0 }}
64+
exit={{ opacity: 0, y: 4 }}
65+
transition={{ duration: 0.2, ease: "easeOut" }}
66+
className="flex items-center justify-between gap-2"
67+
>
68+
<TaskRunStatusCombo status={entry.status} />
69+
<motion.span
70+
key={entry.count}
71+
layout
72+
initial={{ opacity: 0.6, scale: 0.95 }}
73+
animate={{ opacity: 1, scale: 1 }}
74+
transition={{ duration: 0.15, ease: "easeOut" }}
75+
className="text-xs tabular-nums text-text-bright"
76+
>
77+
{entry.count}
78+
</motion.span>
79+
</motion.div>
80+
))}
81+
</AnimatePresence>
82+
</div>
83+
);
84+
}
85+
86+
function useChildRunStatusesTooltip({
87+
friendlyId,
88+
hasFinished,
89+
childrenStatusesBasePath,
90+
}: {
91+
friendlyId: string;
92+
hasFinished: boolean;
93+
childrenStatusesBasePath: string;
94+
}) {
95+
const fetcher = useFetcher<typeof childStatusesLoader>({
96+
key: `child-statuses-${friendlyId}`,
97+
});
98+
const fetcherStateRef = useRef(fetcher.state);
99+
fetcherStateRef.current = fetcher.state;
100+
101+
const [childStatuses, setChildStatuses] = useState<ChildStatusEntry[] | undefined>();
102+
const isOpenRef = useRef(false);
103+
const pollIntervalRef = useRef<ReturnType<typeof setInterval>>();
104+
const prevHasFinishedRef = useRef(hasFinished);
105+
const wasPollingActiveRef = useRef(false);
106+
const lastFetchAtRef = useRef(0);
107+
108+
const childrenStatusesUrl = useMemo(
109+
() =>
110+
`${childrenStatusesBasePath}/children-statuses?runIds=${encodeURIComponent(friendlyId)}`,
111+
[childrenStatusesBasePath, friendlyId]
112+
);
113+
114+
const loadChildStatuses = useCallback(
115+
(options?: { force?: boolean }) => {
116+
const isForce = options?.force === true;
117+
if (!isForce && fetcherStateRef.current !== "idle") return;
118+
119+
const now = Date.now();
120+
if (!isForce && now - lastFetchAtRef.current < TOOLTIP_POLL_INTERVAL_MS) {
121+
return;
122+
}
123+
124+
lastFetchAtRef.current = now;
125+
fetcher.load(childrenStatusesUrl);
126+
},
127+
[childrenStatusesUrl, fetcher]
128+
);
129+
130+
const loadChildStatusesRef = useRef(loadChildStatuses);
131+
loadChildStatusesRef.current = loadChildStatuses;
132+
133+
const requestChildStatuses = useCallback((options?: { force?: boolean }) => {
134+
loadChildStatusesRef.current(options);
135+
}, []);
136+
137+
useEffect(() => {
138+
if (!fetcher.data?.runs) return;
139+
140+
const entry = fetcher.data.runs.find((run) => run.friendlyId === friendlyId);
141+
if (!entry) return;
142+
143+
setChildStatuses((previous) =>
144+
areChildStatusesEqual(previous, entry.statuses) ? previous : entry.statuses
145+
);
146+
}, [fetcher.data, friendlyId]);
147+
148+
const stopPolling = useCallback(() => {
149+
wasPollingActiveRef.current = false;
150+
if (pollIntervalRef.current) {
151+
clearInterval(pollIntervalRef.current);
152+
pollIntervalRef.current = undefined;
153+
}
154+
}, []);
155+
156+
const startPolling = useCallback(() => {
157+
if (pollIntervalRef.current) return;
158+
159+
wasPollingActiveRef.current = true;
160+
pollIntervalRef.current = setInterval(() => {
161+
if (document.visibilityState !== "visible") return;
162+
loadChildStatusesRef.current();
163+
}, TOOLTIP_POLL_INTERVAL_MS);
164+
}, []);
165+
166+
const syncPollingForOpenTooltip = useCallback(() => {
167+
if (!isOpenRef.current) {
168+
stopPolling();
169+
return;
170+
}
171+
172+
if (!shouldPollWhileTooltipOpen(childStatuses, hasFinished)) {
173+
stopPolling();
174+
return;
175+
}
176+
177+
startPolling();
178+
}, [childStatuses, hasFinished, startPolling, stopPolling]);
179+
180+
const refreshOpenTooltip = useCallback(() => {
181+
if (!isOpenRef.current) {
182+
stopPolling();
183+
return;
184+
}
185+
186+
requestChildStatuses({ force: true });
187+
syncPollingForOpenTooltip();
188+
}, [requestChildStatuses, syncPollingForOpenTooltip, stopPolling]);
189+
190+
const onOpenChange = useCallback(
191+
(open: boolean) => {
192+
isOpenRef.current = open;
193+
if (open) {
194+
refreshOpenTooltip();
195+
} else {
196+
stopPolling();
197+
}
198+
},
199+
[refreshOpenTooltip, stopPolling]
200+
);
201+
202+
useEffect(() => {
203+
prevHasFinishedRef.current = hasFinished;
204+
stopPolling();
205+
setChildStatuses(undefined);
206+
if (isOpenRef.current) {
207+
requestChildStatuses({ force: true });
208+
}
209+
// Only reset when the hovered run changes, not when hasFinished toggles.
210+
// eslint-disable-next-line react-hooks/exhaustive-deps -- friendlyId
211+
}, [friendlyId]);
212+
213+
useEffect(() => {
214+
if (!isOpenRef.current) return;
215+
216+
const shouldPoll = shouldPollWhileTooltipOpen(childStatuses, hasFinished);
217+
if (shouldPoll === wasPollingActiveRef.current) return;
218+
219+
syncPollingForOpenTooltip();
220+
}, [childStatuses, hasFinished, syncPollingForOpenTooltip]);
221+
222+
useEffect(() => {
223+
if (!isOpenRef.current) return;
224+
if (prevHasFinishedRef.current === hasFinished) return;
225+
226+
prevHasFinishedRef.current = hasFinished;
227+
requestChildStatuses({ force: true });
228+
}, [hasFinished, requestChildStatuses]);
229+
230+
useEffect(() => () => stopPolling(), [stopPolling]);
231+
232+
return { childStatuses, onOpenChange };
233+
}
234+
235+
export function RunStatusCellTooltip({
236+
friendlyId,
237+
status,
238+
hasFinished,
239+
childrenStatusesBasePath,
240+
}: {
241+
friendlyId: string;
242+
status: NextRunListItem["status"];
243+
hasFinished: boolean;
244+
childrenStatusesBasePath: string;
245+
}) {
246+
const { childStatuses, onOpenChange } = useChildRunStatusesTooltip({
247+
friendlyId,
248+
hasFinished,
249+
childrenStatusesBasePath,
250+
});
251+
252+
const orderedChildStatuses = useMemo(() => {
253+
const childStatusesMap = new Map(
254+
(childStatuses ?? []).map((entry) => [entry.status, entry.count])
255+
);
256+
257+
return filterableTaskRunStatuses
258+
.map((s) => ({
259+
status: s,
260+
count: childStatusesMap.get(s) ?? 0,
261+
}))
262+
.filter((entry) => entry.count > 0);
263+
}, [childStatuses]);
264+
265+
const hasChildStatuses = orderedChildStatuses.length > 0;
266+
267+
return (
268+
<SimpleTooltip
269+
asChild
270+
delayDuration={TOOLTIP_OPEN_DELAY_MS}
271+
onOpenChange={onOpenChange}
272+
content={
273+
childStatuses === undefined ? (
274+
<span className="text-xs text-text-dimmed">Loading child runs…</span>
275+
) : hasChildStatuses ? (
276+
<ChildStatusBreakdown orderedChildStatuses={orderedChildStatuses} />
277+
) : (
278+
descriptionForTaskRunStatus(status)
279+
)
280+
}
281+
disableHoverableContent
282+
button={
283+
<span className="inline-flex min-w-full items-center">
284+
<TaskRunStatusCombo status={status} />
285+
</span>
286+
}
287+
/>
288+
);
289+
}

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)