Skip to content

Commit f969e24

Browse files
committed
Add a non-live streaming option
1 parent af557b9 commit f969e24

7 files changed

Lines changed: 275 additions & 39 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+
Add a "X new runs, click to update" banner to the runs table. Shows on the first page when Live mode is off, polling every 3s for runs newer than the top visible row. Clicking revalidates the loader to bring in the latest runs. Acts as a halfway house between manual refresh and Live mode auto-refresh — the two are mutually exclusive in the UI.

apps/webapp/app/components/runs/v3/LiveToggleButton.tsx

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { ArrowPathIcon } from "@heroicons/react/20/solid";
2+
import { useRevalidator } from "@remix-run/react";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
5+
import { useNewRunsCount } from "~/hooks/useNewRunsCount";
6+
7+
const LIVE_INTERVAL_MS = 3000;
8+
9+
export function RunsLiveControl({
10+
isLive,
11+
onChange,
12+
topRowId,
13+
countNewUrl,
14+
}: {
15+
isLive: boolean;
16+
onChange: (next: boolean) => void;
17+
topRowId: string | undefined;
18+
countNewUrl: string;
19+
}) {
20+
const revalidator = useRevalidator();
21+
22+
// When live, the loader auto-revalidates and the count banner is hidden.
23+
// When not live, we poll for new runs and show the banner if any exist.
24+
useAutoRevalidate({ interval: LIVE_INTERVAL_MS, disabled: !isLive });
25+
26+
const { count, hasMore } = useNewRunsCount({
27+
sinceId: topRowId,
28+
countNewUrl,
29+
intervalMs: LIVE_INTERVAL_MS,
30+
disabled: isLive,
31+
});
32+
33+
const showCountBanner = !isLive && count > 0;
34+
const label = hasMore ? `${count}+` : String(count);
35+
const noun = count === 1 && !hasMore ? "run" : "runs";
36+
37+
return (
38+
<>
39+
{showCountBanner && (
40+
<Button
41+
variant="secondary/small"
42+
onClick={() => revalidator.revalidate()}
43+
LeadingIcon={ArrowPathIcon}
44+
tooltip="Load new runs. Click the Live button to enable auto-refresh."
45+
>
46+
<span className="text-text-bright">
47+
{label} new {noun}, click to update
48+
</span>
49+
</Button>
50+
)}
51+
<Button
52+
variant="secondary/small"
53+
onClick={() => onChange(!isLive)}
54+
tooltip={isLive ? "Pause live updates" : "Auto-refresh new runs"}
55+
LeadingIcon={() => <LiveDot isLive={isLive} />}
56+
>
57+
<span className="text-text-bright">Live</span>
58+
</Button>
59+
</>
60+
);
61+
}
62+
63+
function LiveDot({ isLive }: { isLive: boolean }) {
64+
if (!isLive) {
65+
return <span className="size-2 rounded-full bg-charcoal-500" aria-hidden />;
66+
}
67+
68+
return (
69+
<span className="relative flex size-2 items-center justify-center" aria-hidden>
70+
<span className="absolute inline-flex size-full animate-ping rounded-full bg-rose-500 opacity-75" />
71+
<span className="relative inline-flex size-2 rounded-full bg-rose-500" />
72+
</span>
73+
);
74+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useEffect, useRef, useState } from "react";
2+
3+
type CountNewResponse = { count: number; hasMore: boolean };
4+
5+
type UseNewRunsCountOptions = {
6+
sinceId: string | undefined;
7+
countNewUrl: string;
8+
intervalMs?: number;
9+
disabled?: boolean;
10+
};
11+
12+
const DEFAULT_INTERVAL_MS = 3000;
13+
14+
/**
15+
* Polls the runs.count-new resource route to count runs newer than the
16+
* top visible row. Uses a plain `fetch` rather than `useFetcher` so Remix's
17+
* automatic fetcher revalidation (e.g. from useAutoRevalidate in Live mode)
18+
* does not re-fire the request when the hook is disabled.
19+
*/
20+
export function useNewRunsCount({
21+
sinceId,
22+
countNewUrl,
23+
intervalMs = DEFAULT_INTERVAL_MS,
24+
disabled = false,
25+
}: UseNewRunsCountOptions): { count: number; hasMore: boolean } {
26+
const [state, setState] = useState<CountNewResponse>({ count: 0, hasMore: false });
27+
const inFlightRef = useRef(false);
28+
29+
// Reset baseline whenever the cursor or url changes, or when disabling.
30+
useEffect(() => {
31+
setState({ count: 0, hasMore: false });
32+
}, [sinceId, countNewUrl, disabled]);
33+
34+
useEffect(() => {
35+
if (disabled) return;
36+
if (!sinceId) return;
37+
if (typeof document === "undefined") return;
38+
39+
const url = appendSinceParam(countNewUrl, sinceId);
40+
let cancelled = false;
41+
const controller = new AbortController();
42+
43+
const tick = async () => {
44+
if (inFlightRef.current) return;
45+
if (document.visibilityState !== "visible") return;
46+
inFlightRef.current = true;
47+
try {
48+
const res = await fetch(url, {
49+
signal: controller.signal,
50+
headers: { Accept: "application/json" },
51+
credentials: "same-origin",
52+
});
53+
if (!res.ok) return;
54+
const data = (await res.json()) as CountNewResponse;
55+
if (!cancelled) {
56+
setState({ count: data.count, hasMore: data.hasMore });
57+
}
58+
} catch {
59+
// Ignore aborts and transient network errors; next tick will retry.
60+
} finally {
61+
inFlightRef.current = false;
62+
}
63+
};
64+
65+
const handleVisibility = () => {
66+
if (document.visibilityState === "visible") {
67+
tick();
68+
}
69+
};
70+
71+
const intervalId = setInterval(tick, intervalMs);
72+
document.addEventListener("visibilitychange", handleVisibility);
73+
74+
return () => {
75+
cancelled = true;
76+
controller.abort();
77+
clearInterval(intervalId);
78+
document.removeEventListener("visibilitychange", handleVisibility);
79+
};
80+
}, [sinceId, countNewUrl, intervalMs, disabled]);
81+
82+
if (disabled || !sinceId) {
83+
return { count: 0, hasMore: false };
84+
}
85+
86+
return state;
87+
}
88+
89+
function appendSinceParam(url: string, sinceId: string): string {
90+
const separator = url.includes("?") ? "&" : "?";
91+
return `${url}${separator}since=${encodeURIComponent(sinceId)}`;
92+
}

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

Lines changed: 16 additions & 6 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, useLocation, useNavigation } from "@remix-run/react";
33
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
44
import { Suspense, useMemo } from "react";
55
import {
@@ -31,12 +31,11 @@ import { ShortcutKey } from "~/components/primitives/ShortcutKey";
3131
import { Spinner } from "~/components/primitives/Spinner";
3232
import { StepNumber } from "~/components/primitives/StepNumber";
3333
import { TextLink } from "~/components/primitives/TextLink";
34-
import { LiveToggleButton } from "~/components/runs/v3/LiveToggleButton";
3534
import { RunsFilters, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
35+
import { RunsLiveControl } from "~/components/runs/v3/RunsLiveControl";
3636
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
3737
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
3838
import { $replica } from "~/db.server";
39-
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
4039
import { useEnvironment } from "~/hooks/useEnvironment";
4140
import { useOrganization } from "~/hooks/useOrganizations";
4241
import { useProject } from "~/hooks/useProject";
@@ -59,6 +58,7 @@ import {
5958
EnvironmentParamSchema,
6059
v3CreateBulkActionPath,
6160
v3ProjectPath,
61+
v3RunsCountNewPath,
6262
v3RunsRefreshPath,
6363
v3TestPath,
6464
v3TestTaskPath,
@@ -204,6 +204,7 @@ function RunsList({
204204
const project = useProject();
205205
const environment = useEnvironment();
206206
const { has, replace, value } = useSearchParams();
207+
const location = useLocation();
207208
const isFirstPage = !list.pagination.previous;
208209

209210
// Shortcut keys for bulk actions
@@ -232,8 +233,6 @@ function RunsList({
232233
const isLive = isLiveAvailable && value("live") === "1";
233234
const setLive = (next: boolean) => replace({ live: next ? "1" : undefined });
234235

235-
useAutoRevalidate({ interval: 3000, disabled: !isLive });
236-
237236
const refreshUrl = v3RunsRefreshPath(organization, project, environment);
238237
const overrides = useRunsRowPolling({
239238
runs: list.runs,
@@ -245,6 +244,10 @@ function RunsList({
245244
[list.runs, overrides]
246245
);
247246

247+
const countNewUrl = `${v3RunsCountNewPath(organization, project, environment)}${
248+
location.search
249+
}`;
250+
248251
const isShowingBulkActionInspector = has("bulkInspector") && list.hasAnyRuns;
249252
return (
250253
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
@@ -278,7 +281,14 @@ function RunsList({
278281
rootOnlyDefault={rootOnlyDefault}
279282
/>
280283
<div className="flex items-center justify-end gap-x-2">
281-
{isLiveAvailable && <LiveToggleButton isLive={isLive} onChange={setLive} />}
284+
{isLiveAvailable && (
285+
<RunsLiveControl
286+
isLive={isLive}
287+
onChange={setLive}
288+
topRowId={list.runs[0]?.id}
289+
countNewUrl={countNewUrl}
290+
/>
291+
)}
282292
{!isShowingBulkActionInspector && (
283293
<LinkButton
284294
variant="secondary/small"
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { typedjson } from "remix-typedjson";
3+
import { z } from "zod";
4+
import { $replica, type PrismaClient } from "~/db.server";
5+
import { findProjectBySlug } from "~/models/project.server";
6+
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
7+
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
8+
import { clickhouseClient } from "~/services/clickhouseInstance.server";
9+
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
10+
import { requireUserId } from "~/services/session.server";
11+
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
12+
13+
const COUNT_CAP = 99;
14+
const RunIdSchema = z.string().cuid();
15+
16+
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
17+
const userId = await requireUserId(request);
18+
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
19+
20+
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
21+
if (!project) {
22+
throw new Response("Project not found", { status: 404 });
23+
}
24+
25+
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
26+
if (!environment) {
27+
throw new Response("Environment not found", { status: 404 });
28+
}
29+
30+
const url = new URL(request.url);
31+
const sinceParam = url.searchParams.get("since");
32+
const since = sinceParam && RunIdSchema.safeParse(sinceParam).success ? sinceParam : null;
33+
34+
if (!since) {
35+
return typedjson(
36+
{ count: 0, hasMore: false },
37+
{ headers: { "Cache-Control": "no-store" } }
38+
);
39+
}
40+
41+
const filters = await getRunFiltersFromRequest(request);
42+
43+
const runsRepository = new RunsRepository({
44+
clickhouse: clickhouseClient,
45+
prisma: $replica as PrismaClient,
46+
});
47+
48+
const ids = await runsRepository.listRunIds({
49+
organizationId: project.organizationId,
50+
projectId: project.id,
51+
environmentId: environment.id,
52+
tasks: filters.tasks,
53+
versions: filters.versions,
54+
statuses: filters.statuses,
55+
tags: filters.tags,
56+
period: filters.period,
57+
from: filters.from,
58+
to: filters.to,
59+
batchId: filters.batchId,
60+
runId: filters.runId,
61+
bulkId: filters.bulkId,
62+
scheduleId: filters.scheduleId,
63+
rootOnly: filters.rootOnly,
64+
queues: filters.queues,
65+
machines: filters.machines,
66+
errorId: filters.errorId,
67+
page: { cursor: since, direction: "backward", size: COUNT_CAP },
68+
});
69+
70+
return typedjson(
71+
{
72+
count: Math.min(ids.length, COUNT_CAP),
73+
hasMore: ids.length > COUNT_CAP,
74+
},
75+
{ headers: { "Cache-Control": "no-store" } }
76+
);
77+
};

apps/webapp/app/utils/pathBuilder.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,16 @@ export function v3RunsRefreshPath(
335335
)}/env/${environmentParam(environment)}/runs/refresh`;
336336
}
337337

338+
export function v3RunsCountNewPath(
339+
organization: OrgForPath,
340+
project: ProjectForPath,
341+
environment: EnvironmentForPath
342+
) {
343+
return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam(
344+
project
345+
)}/env/${environmentParam(environment)}/runs/count-new`;
346+
}
347+
338348
export function v3CreateBulkActionPath(
339349
organization: OrgForPath,
340350
project: ProjectForPath,

0 commit comments

Comments
 (0)