Skip to content

Commit 3930a5e

Browse files
Ekaterina BulatovaEkaterina Bulatova
authored andcommitted
feat(webapp): live-reload runs index status and new-runs banner
Poll runs/live every 3s for unfinished visible runs and patch status in place. Detect newer runs matching filters (~6s) and show a pulsing refresh banner. Pause polling when the tab is hidden or the banner is shown.
1 parent 9cb6fd1 commit 3930a5e

13 files changed

Lines changed: 749 additions & 7 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+
Runs index live-reloads visible run status, shows a new-runs refresh banner, and child-status tooltips on root rows.

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,12 +318,12 @@ export function ButtonContent(props: ButtonContentPropsType) {
318318

319319
type ButtonPropsType = Pick<
320320
JSX.IntrinsicElements["button"],
321-
"type" | "disabled" | "onClick" | "name" | "value" | "form" | "autoFocus"
321+
"type" | "disabled" | "onClick" | "name" | "value" | "form" | "autoFocus" | "aria-label"
322322
> &
323323
React.ComponentProps<typeof ButtonContent>;
324324

325325
export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
326-
({ type, disabled, autoFocus, onClick, ...props }, ref) => {
326+
({ type, disabled, autoFocus, onClick, "aria-label": ariaLabel, ...props }, ref) => {
327327
const innerRef = useRef<HTMLButtonElement>(null);
328328
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
329329

@@ -352,6 +352,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
352352
ref={innerRef}
353353
form={props.form}
354354
autoFocus={autoFocus}
355+
aria-label={ariaLabel}
355356
>
356357
<ButtonContent
357358
{...props}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { cn } from "~/utils/cn";
2+
3+
export function PulsingDot({
4+
className,
5+
ringClassName,
6+
dotClassName,
7+
}: {
8+
className?: string;
9+
ringClassName?: string;
10+
dotClassName?: string;
11+
}) {
12+
return (
13+
<span className={cn("relative flex size-2", className)}>
14+
<span
15+
className={cn(
16+
"absolute h-full w-full animate-ping rounded-full border border-blue-500 opacity-100 duration-1000",
17+
ringClassName
18+
)}
19+
/>
20+
<span className={cn("size-2 rounded-full bg-blue-500", dotClassName)} />
21+
</span>
22+
);
23+
}

apps/webapp/app/hooks/useInterval.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ type UseIntervalOptions = {
66
onLoad?: boolean;
77
onFocus?: boolean;
88
disabled?: boolean;
9+
/** Skip interval ticks while the document tab is hidden */
10+
pauseWhenHidden?: boolean;
911
callback: () => void;
1012
};
1113

@@ -14,6 +16,7 @@ export function useInterval({
1416
onLoad = true,
1517
onFocus = true,
1618
disabled = false,
19+
pauseWhenHidden = false,
1720
callback,
1821
}: UseIntervalOptions) {
1922
// Always keep the latest callback in a ref so the effects below
@@ -28,11 +31,14 @@ export function useInterval({
2831
if (!interval || interval <= 0 || disabled) return;
2932

3033
const intervalId = setInterval(() => {
34+
if (pauseWhenHidden && document.visibilityState !== "visible") {
35+
return;
36+
}
3137
latestCallback.current();
3238
}, interval);
3339

3440
return () => clearInterval(intervalId);
35-
}, [interval, disabled]);
41+
}, [interval, disabled, pauseWhenHidden]);
3642

3743
// On focus
3844
useEffect(() => {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { type TaskRunStatus } from "@trigger.dev/database";
2+
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";
3+
4+
type LiveRunRow = {
5+
friendlyId: string;
6+
status: TaskRunStatus;
7+
updatedAt: Date;
8+
startedAt: Date | null;
9+
lockedAt: Date | null;
10+
completedAt: Date | null;
11+
usageDurationMs: bigint | number;
12+
costInCents: number;
13+
baseCostInCents: number;
14+
};
15+
16+
export function mapRunToLiveFields(run: LiveRunRow) {
17+
const hasFinished = isFinalRunStatus(run.status);
18+
const startedAt = run.startedAt ?? run.lockedAt;
19+
20+
return {
21+
friendlyId: run.friendlyId,
22+
status: run.status,
23+
updatedAt: run.updatedAt.toISOString(),
24+
startedAt: startedAt?.toISOString(),
25+
finishedAt: hasFinished ? run.completedAt?.toISOString() ?? run.updatedAt.toISOString() : undefined,
26+
hasFinished,
27+
isCancellable: isCancellableRunStatus(run.status),
28+
isPending: isPendingRunStatus(run.status),
29+
usageDurationMs: Number(run.usageDurationMs),
30+
costInCents: run.costInCents,
31+
baseCostInCents: run.baseCostInCents,
32+
};
33+
}

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

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
2-
import { type MetaFunction, useNavigation } from "@remix-run/react";
2+
import { type MetaFunction, useNavigation, useRevalidator } from "@remix-run/react";
33
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
44
import { Suspense } from "react";
55
import {
@@ -14,11 +14,12 @@ import { DevDisconnectedBanner, useDevPresence } from "~/components/DevPresence"
1414
import { StepContentContainer } from "~/components/StepContentContainer";
1515
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
1616
import { Badge } from "~/components/primitives/Badge";
17-
import { LinkButton } from "~/components/primitives/Buttons";
17+
import { Button, LinkButton } from "~/components/primitives/Buttons";
1818
import { Header1 } from "~/components/primitives/Headers";
1919
import { InfoPanel } from "~/components/primitives/InfoPanel";
2020
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
2121
import { Paragraph } from "~/components/primitives/Paragraph";
22+
import { PulsingDot } from "~/components/primitives/PulsingDot";
2223
import {
2324
RESIZABLE_PANEL_ANIMATION,
2425
ResizableHandle,
@@ -64,6 +65,7 @@ import { throwNotFound } from "~/utils/httpErrors";
6465
import { ListPagination } from "../../components/ListPagination";
6566
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
6667
import { Callout } from "~/components/primitives/Callout";
68+
import { useRunsLiveReload } from "./useRunsLiveReload";
6769

6870
export const meta: MetaFunction = () => {
6971
return [
@@ -203,12 +205,36 @@ function RunsList({
203205
rootOnlyDefault: boolean;
204206
filters: TaskRunListSearchFilters;
205207
}) {
208+
const revalidator = useRevalidator();
206209
const navigation = useNavigation();
207210
const isLoading = navigation.state !== "idle";
208211
const organization = useOrganization();
209212
const project = useProject();
210213
const environment = useEnvironment();
211214
const { has, replace } = useSearchParams();
215+
const { visibleRuns, showNewRunsBanner, dismissNewRuns, childrenStatusesBasePath } =
216+
useRunsLiveReload({
217+
runs: list.runs,
218+
hasAnyRuns: list.hasAnyRuns,
219+
isLoading,
220+
organizationSlug: organization.slug,
221+
projectSlug: project.slug,
222+
environmentSlug: environment.slug,
223+
});
224+
225+
const onClickShowNewRuns = () => {
226+
const isPaginated = has("cursor") || has("direction");
227+
dismissNewRuns();
228+
if (isPaginated) {
229+
replace({
230+
cursor: undefined,
231+
direction: undefined,
232+
});
233+
return;
234+
}
235+
236+
revalidator.revalidate();
237+
};
212238

213239
// Shortcut keys for bulk actions
214240
useShortcutKeys({
@@ -265,6 +291,18 @@ function RunsList({
265291
rootOnlyDefault={rootOnlyDefault}
266292
/>
267293
<div className="flex items-center justify-end gap-x-2">
294+
{showNewRunsBanner && (
295+
<Button
296+
variant="secondary/small"
297+
className="text-text-bright"
298+
onClick={onClickShowNewRuns}
299+
LeadingIcon={<PulsingDot className="h-2 w-2" />}
300+
tooltip="Refresh to see new runs"
301+
aria-label="New runs created. Refresh to see new runs."
302+
>
303+
New runs created
304+
</Button>
305+
)}
268306
{!isShowingBulkActionInspector && (
269307
<LinkButton
270308
variant="secondary/small"
@@ -303,10 +341,11 @@ function RunsList({
303341
</div>
304342

305343
<TaskRunsTable
306-
total={list.runs.length}
344+
total={visibleRuns.length}
307345
hasFilters={list.hasFilters}
308346
filters={list.filters}
309-
runs={list.runs}
347+
runs={visibleRuns}
348+
childrenStatusesBasePath={childrenStatusesBasePath}
310349
isLoading={isLoading}
311350
allowSelection
312351
rootOnlyDefault={rootOnlyDefault}

0 commit comments

Comments
 (0)