Skip to content

Commit a762cef

Browse files
authored
Add real-time polling to dashboard (#272)
* Add real-time polling to dashboard The dashboard previously required a manual page reload to see updated run statuses and new step attempts. This adds a `usePolling` hook that calls `router.invalidate()` on a 2s interval to re-run active route loaders, giving the UI live updates with zero new dependencies. Polling pauses when the browser tab is hidden and resumes (with an immediate refresh) when it becomes visible again. On the run detail page, polling is disabled once the run reaches a terminal state. * fix(dashboard): skip polling start when tab is initially hidden Guard start() with a document.hidden check so polling does not begin when the page loads in a background tab. Add a test covering the initially-hidden mount case. * chore(knip.json): remove unnecessary testing libraries from ignoreDependencies
1 parent 7b6e310 commit a762cef

6 files changed

Lines changed: 243 additions & 3 deletions

File tree

knip.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@
66
"ignoreDependencies": [
77
"@tanstack/react-router-ssr-query",
88
"@tanstack/router-plugin",
9-
"@testing-library/dom",
10-
"@testing-library/react",
11-
"jsdom",
129
"postgres",
1310
"web-vitals"
1411
],

packages/dashboard/src/lib/status.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ export const STEP_STATUS_CONFIG: Record<
8787
},
8888
};
8989

90+
/** Run statuses that represent a finished workflow (no further updates expected). */
91+
export const TERMINAL_RUN_STATUSES: ReadonlySet<WorkflowRunStatus> = new Set([
92+
"completed",
93+
"succeeded",
94+
"failed",
95+
"canceled",
96+
]);
97+
9098
const fallbackStatusColor = "text-yellow-500";
9199
const fallbackStatusBadgeClass =
92100
"bg-yellow-500/10 text-yellow-500 border-yellow-500/20";
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// @vitest-environment jsdom
2+
import { usePolling } from "./use-polling";
3+
import { cleanup, renderHook } from "@testing-library/react";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
6+
const invalidate = vi.fn();
7+
8+
vi.mock("@tanstack/react-router", () => ({
9+
useRouter: () => ({ invalidate }),
10+
}));
11+
12+
describe("usePolling", () => {
13+
beforeEach(() => {
14+
vi.useFakeTimers();
15+
invalidate.mockClear();
16+
Object.defineProperty(document, "hidden", {
17+
value: false,
18+
writable: true,
19+
configurable: true,
20+
});
21+
});
22+
23+
afterEach(() => {
24+
cleanup();
25+
vi.useRealTimers();
26+
});
27+
28+
it("calls router.invalidate on the default interval", () => {
29+
renderHook(() => {
30+
usePolling();
31+
});
32+
33+
expect(invalidate).not.toHaveBeenCalled();
34+
35+
vi.advanceTimersByTime(2000);
36+
expect(invalidate).toHaveBeenCalledTimes(1);
37+
38+
vi.advanceTimersByTime(2000);
39+
expect(invalidate).toHaveBeenCalledTimes(2);
40+
});
41+
42+
it("respects a custom interval", () => {
43+
renderHook(() => {
44+
usePolling({ interval: 5000 });
45+
});
46+
47+
vi.advanceTimersByTime(4999);
48+
expect(invalidate).not.toHaveBeenCalled();
49+
50+
vi.advanceTimersByTime(1);
51+
expect(invalidate).toHaveBeenCalledTimes(1);
52+
});
53+
54+
it("does not poll when enabled is false", () => {
55+
renderHook(() => {
56+
usePolling({ enabled: false });
57+
});
58+
59+
vi.advanceTimersByTime(10_000);
60+
expect(invalidate).not.toHaveBeenCalled();
61+
});
62+
63+
it("stops polling on unmount", () => {
64+
const { unmount } = renderHook(() => {
65+
usePolling();
66+
});
67+
68+
vi.advanceTimersByTime(2000);
69+
expect(invalidate).toHaveBeenCalledTimes(1);
70+
71+
unmount();
72+
73+
vi.advanceTimersByTime(10_000);
74+
expect(invalidate).toHaveBeenCalledTimes(1);
75+
});
76+
77+
it("pauses polling when the tab is hidden", () => {
78+
renderHook(() => {
79+
usePolling();
80+
});
81+
82+
vi.advanceTimersByTime(2000);
83+
expect(invalidate).toHaveBeenCalledTimes(1);
84+
85+
// Simulate tab becoming hidden
86+
Object.defineProperty(document, "hidden", {
87+
value: true,
88+
writable: true,
89+
configurable: true,
90+
});
91+
document.dispatchEvent(new Event("visibilitychange"));
92+
93+
vi.advanceTimersByTime(10_000);
94+
expect(invalidate).toHaveBeenCalledTimes(1);
95+
});
96+
97+
it("does not start polling when mounted with the tab already hidden", () => {
98+
Object.defineProperty(document, "hidden", {
99+
value: true,
100+
writable: true,
101+
configurable: true,
102+
});
103+
104+
renderHook(() => {
105+
usePolling();
106+
});
107+
108+
vi.advanceTimersByTime(10_000);
109+
expect(invalidate).not.toHaveBeenCalled();
110+
});
111+
112+
it("resumes polling and immediately invalidates when the tab becomes visible", () => {
113+
renderHook(() => {
114+
usePolling();
115+
});
116+
117+
// Hide tab
118+
Object.defineProperty(document, "hidden", {
119+
value: true,
120+
writable: true,
121+
configurable: true,
122+
});
123+
document.dispatchEvent(new Event("visibilitychange"));
124+
invalidate.mockClear();
125+
126+
// Show tab again
127+
Object.defineProperty(document, "hidden", {
128+
value: false,
129+
writable: true,
130+
configurable: true,
131+
});
132+
document.dispatchEvent(new Event("visibilitychange"));
133+
134+
// Should immediately invalidate on visibility restore
135+
expect(invalidate).toHaveBeenCalledTimes(1);
136+
137+
// And resume the interval
138+
vi.advanceTimersByTime(2000);
139+
expect(invalidate).toHaveBeenCalledTimes(2);
140+
});
141+
142+
it("starts polling when enabled changes from false to true", () => {
143+
const { rerender } = renderHook(
144+
({ enabled }) => {
145+
usePolling({ enabled });
146+
},
147+
{ initialProps: { enabled: false } },
148+
);
149+
150+
vi.advanceTimersByTime(4000);
151+
expect(invalidate).not.toHaveBeenCalled();
152+
153+
rerender({ enabled: true });
154+
155+
vi.advanceTimersByTime(2000);
156+
expect(invalidate).toHaveBeenCalledTimes(1);
157+
});
158+
159+
it("stops polling when enabled changes from true to false", () => {
160+
const { rerender } = renderHook(
161+
({ enabled }) => {
162+
usePolling({ enabled });
163+
},
164+
{ initialProps: { enabled: true } },
165+
);
166+
167+
vi.advanceTimersByTime(2000);
168+
expect(invalidate).toHaveBeenCalledTimes(1);
169+
170+
rerender({ enabled: false });
171+
172+
vi.advanceTimersByTime(10_000);
173+
expect(invalidate).toHaveBeenCalledTimes(1);
174+
});
175+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useRouter } from "@tanstack/react-router";
2+
import { useEffect } from "react";
3+
4+
interface UsePollingOptions {
5+
interval?: number;
6+
enabled?: boolean;
7+
}
8+
9+
export function usePolling({
10+
interval = 2000,
11+
enabled = true,
12+
}: UsePollingOptions = {}) {
13+
const router = useRouter();
14+
15+
useEffect(() => {
16+
if (!enabled) return;
17+
18+
let timer: ReturnType<typeof setInterval> | null = null;
19+
20+
function start() {
21+
if (timer) return;
22+
timer = setInterval(() => {
23+
void router.invalidate();
24+
}, interval);
25+
}
26+
27+
function stop() {
28+
if (timer) {
29+
clearInterval(timer);
30+
timer = null;
31+
}
32+
}
33+
34+
function handleVisibilityChange() {
35+
if (document.hidden) {
36+
stop();
37+
} else {
38+
void router.invalidate();
39+
start();
40+
}
41+
}
42+
43+
if (!document.hidden) {
44+
start();
45+
}
46+
document.addEventListener("visibilitychange", handleVisibilityChange);
47+
48+
return () => {
49+
stop();
50+
document.removeEventListener("visibilitychange", handleVisibilityChange);
51+
};
52+
}, [router, interval, enabled]);
53+
}

packages/dashboard/src/routes/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AppLayout } from "@/components/app-layout";
22
import { RunList } from "@/components/run-list";
33
import { WorkflowStats } from "@/components/workflow-stats";
44
import { listWorkflowRunsServerFn } from "@/lib/api";
5+
import { usePolling } from "@/lib/use-polling";
56
import { createFileRoute } from "@tanstack/react-router";
67

78
export const Route = createFileRoute("/")({
@@ -14,6 +15,7 @@ export const Route = createFileRoute("/")({
1415

1516
function HomePage() {
1617
const { data: runs } = Route.useLoaderData();
18+
usePolling();
1719

1820
return (
1921
<AppLayout>

packages/dashboard/src/routes/runs/$runId.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { Card } from "@/components/ui/card";
55
import { getWorkflowRunServerFn, listStepAttemptsServerFn } from "@/lib/api";
66
import {
77
STEP_STATUS_CONFIG,
8+
TERMINAL_RUN_STATUSES,
89
getStatusColor,
910
getStatusBadgeClass,
1011
} from "@/lib/status";
12+
import { usePolling } from "@/lib/use-polling";
1113
import { cn } from "@/lib/utils";
1214
import { computeDuration, formatRelativeTime } from "@/utils";
1315
import {
@@ -33,6 +35,9 @@ export const Route = createFileRoute("/runs/$runId")({
3335
function RunDetailsPage() {
3436
const { run, steps } = Route.useLoaderData();
3537
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
38+
usePolling({
39+
enabled: !!run && !TERMINAL_RUN_STATUSES.has(run.status),
40+
});
3641

3742
function toggleStep(stepId: string) {
3843
setExpandedSteps((prev) => {

0 commit comments

Comments
 (0)