Skip to content

Commit ced0669

Browse files
Ekaterina BulatovaEkaterina Bulatova
authored andcommitted
feat(webapp): live-reload the runs list with a new-runs banner
The runs index now live-updates the statuses of visible runs in place (live fields fetched via RunsRepository) and shows a "N new runs / 99+ new runs" refresh banner with a fade-in, via a /live polling endpoint and the useRunsLiveReload hook. The new-runs count comes from listRunIds (capped at 99+), polling pauses while the tab is hidden and stops once the cap is hit.
1 parent deed8e4 commit ced0669

10 files changed

Lines changed: 650 additions & 9 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+
The runs index live-reloads visible run statuses and shows a "new runs created" refresh banner. Polling pauses while the browser tab is hidden.

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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";
2+
import type { ListedRun } from "~/services/runsRepository/runsRepository.server";
3+
4+
export function mapRunToLiveFields(run: ListedRun) {
5+
const hasFinished = isFinalRunStatus(run.status);
6+
const startedAt = run.startedAt ?? run.lockedAt;
7+
8+
return {
9+
friendlyId: run.friendlyId,
10+
status: run.status,
11+
updatedAt: run.updatedAt.toISOString(),
12+
startedAt: startedAt?.toISOString(),
13+
finishedAt: hasFinished ? run.completedAt?.toISOString() ?? run.updatedAt.toISOString() : undefined,
14+
hasFinished,
15+
isCancellable: isCancellableRunStatus(run.status),
16+
isPending: isPendingRunStatus(run.status),
17+
usageDurationMs: Number(run.usageDurationMs),
18+
costInCents: run.costInCents,
19+
baseCostInCents: run.baseCostInCents,
20+
};
21+
}

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

Lines changed: 49 additions & 5 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 [
@@ -89,7 +91,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
8991

9092
const filters = await getRunFiltersFromRequest(request);
9193

92-
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard");
94+
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
95+
project.organizationId,
96+
"standard"
97+
);
9398
const presenter = new NextRunListPresenter($replica, clickhouse);
9499
const list = presenter.call(project.organizationId, environment.id, {
95100
userId,
@@ -203,12 +208,36 @@ function RunsList({
203208
rootOnlyDefault: boolean;
204209
filters: TaskRunListSearchFilters;
205210
}) {
211+
const revalidator = useRevalidator();
206212
const navigation = useNavigation();
207213
const isLoading = navigation.state !== "idle";
208214
const organization = useOrganization();
209215
const project = useProject();
210216
const environment = useEnvironment();
211217
const { has, replace } = useSearchParams();
218+
const { visibleRuns, showNewRunsBanner, newRunsCount, dismissNewRuns, childrenStatusesBasePath } =
219+
useRunsLiveReload({
220+
runs: list.runs,
221+
hasAnyRuns: list.hasAnyRuns,
222+
isLoading,
223+
organizationSlug: organization.slug,
224+
projectSlug: project.slug,
225+
environmentSlug: environment.slug,
226+
});
227+
228+
const onClickShowNewRuns = () => {
229+
const isPaginated = has("cursor") || has("direction");
230+
dismissNewRuns();
231+
if (isPaginated) {
232+
replace({
233+
cursor: undefined,
234+
direction: undefined,
235+
});
236+
return;
237+
}
238+
239+
revalidator.revalidate();
240+
};
212241

213242
// Shortcut keys for bulk actions
214243
useShortcutKeys({
@@ -265,6 +294,20 @@ function RunsList({
265294
rootOnlyDefault={rootOnlyDefault}
266295
/>
267296
<div className="flex items-center justify-end gap-x-2">
297+
{showNewRunsBanner && (
298+
<span className="flex duration-150 animate-in fade-in-0">
299+
<Button
300+
variant="secondary/small"
301+
className="text-text-bright"
302+
onClick={onClickShowNewRuns}
303+
LeadingIcon={<PulsingDot className="h-2 w-2" />}
304+
tooltip="Refresh to see new runs"
305+
aria-label="New runs created. Refresh to see new runs."
306+
>
307+
{newRunsCount >= 100 ? "99+ new runs" : `${newRunsCount} new runs`}
308+
</Button>
309+
</span>
310+
)}
268311
{!isShowingBulkActionInspector && (
269312
<LinkButton
270313
variant="secondary/small"
@@ -303,10 +346,11 @@ function RunsList({
303346
</div>
304347

305348
<TaskRunsTable
306-
total={list.runs.length}
349+
total={visibleRuns.length}
307350
hasFilters={list.hasFilters}
308351
filters={list.filters}
309-
runs={list.runs}
352+
runs={visibleRuns}
353+
childrenStatusesBasePath={childrenStatusesBasePath}
310354
isLoading={isLoading}
311355
allowSelection
312356
rootOnlyDefault={rootOnlyDefault}

0 commit comments

Comments
 (0)