From 63dd46659a369edc4f4ccc1ea3cde43073712627 Mon Sep 17 00:00:00 2001 From: Peter Szoke Date: Tue, 10 Feb 2026 19:17:38 +0100 Subject: [PATCH 1/3] 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. --- packages/dashboard/src/lib/status.ts | 8 ++ .../dashboard/src/lib/use-polling.test.ts | 134 ++++++++++++++++++ packages/dashboard/src/lib/use-polling.ts | 51 +++++++ packages/dashboard/src/routes/index.tsx | 2 + packages/dashboard/src/routes/runs/$runId.tsx | 5 + 5 files changed, 200 insertions(+) create mode 100644 packages/dashboard/src/lib/use-polling.test.ts create mode 100644 packages/dashboard/src/lib/use-polling.ts diff --git a/packages/dashboard/src/lib/status.ts b/packages/dashboard/src/lib/status.ts index 138cd27f..8f93991a 100644 --- a/packages/dashboard/src/lib/status.ts +++ b/packages/dashboard/src/lib/status.ts @@ -87,6 +87,14 @@ export const STEP_STATUS_CONFIG: Record< }, }; +/** Run statuses that represent a finished workflow (no further updates expected). */ +export const TERMINAL_RUN_STATUSES: ReadonlySet = new Set([ + "completed", + "succeeded", + "failed", + "canceled", +]); + const fallbackStatusColor = "text-yellow-500"; const fallbackStatusBadgeClass = "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"; diff --git a/packages/dashboard/src/lib/use-polling.test.ts b/packages/dashboard/src/lib/use-polling.test.ts new file mode 100644 index 00000000..3cf11119 --- /dev/null +++ b/packages/dashboard/src/lib/use-polling.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +import { cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { usePolling } from "./use-polling"; + +const invalidate = vi.fn(); + +vi.mock("@tanstack/react-router", () => ({ + useRouter: () => ({ invalidate }), +})); + +describe("usePolling", () => { + beforeEach(() => { + vi.useFakeTimers(); + invalidate.mockClear(); + Object.defineProperty(document, "hidden", { + value: false, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("calls router.invalidate on the default interval", () => { + renderHook(() => { + usePolling() + }); + + expect(invalidate).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(2000); + expect(invalidate).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2000); + expect(invalidate).toHaveBeenCalledTimes(2); + }); + + it("respects a custom interval", () => { + renderHook(() => { usePolling({ interval: 5000 }); }); + + vi.advanceTimersByTime(4999); + expect(invalidate).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(invalidate).toHaveBeenCalledTimes(1); + }); + + it("does not poll when enabled is false", () => { + renderHook(() => { usePolling({ enabled: false }); }); + + vi.advanceTimersByTime(10_000); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("stops polling on unmount", () => { + const { unmount } = renderHook(() => { usePolling(); }); + + vi.advanceTimersByTime(2000); + expect(invalidate).toHaveBeenCalledTimes(1); + + unmount(); + + vi.advanceTimersByTime(10_000); + expect(invalidate).toHaveBeenCalledTimes(1); + }); + + it("pauses polling when the tab is hidden", () => { + renderHook(() => { usePolling(); }); + + vi.advanceTimersByTime(2000); + expect(invalidate).toHaveBeenCalledTimes(1); + + // Simulate tab becoming hidden + Object.defineProperty(document, "hidden", { value: true, writable: true, configurable: true }); + document.dispatchEvent(new Event("visibilitychange")); + + vi.advanceTimersByTime(10_000); + expect(invalidate).toHaveBeenCalledTimes(1); + }); + + it("resumes polling and immediately invalidates when the tab becomes visible", () => { + renderHook(() => { usePolling(); }); + + // Hide tab + Object.defineProperty(document, "hidden", { value: true, writable: true, configurable: true }); + document.dispatchEvent(new Event("visibilitychange")); + invalidate.mockClear(); + + // Show tab again + Object.defineProperty(document, "hidden", { value: false, writable: true, configurable: true }); + document.dispatchEvent(new Event("visibilitychange")); + + // Should immediately invalidate on visibility restore + expect(invalidate).toHaveBeenCalledTimes(1); + + // And resume the interval + vi.advanceTimersByTime(2000); + expect(invalidate).toHaveBeenCalledTimes(2); + }); + + it("starts polling when enabled changes from false to true", () => { + const { rerender } = renderHook( + ({ enabled }) => { usePolling({ enabled }); }, + { initialProps: { enabled: false } }, + ); + + vi.advanceTimersByTime(4000); + expect(invalidate).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + + vi.advanceTimersByTime(2000); + expect(invalidate).toHaveBeenCalledTimes(1); + }); + + it("stops polling when enabled changes from true to false", () => { + const { rerender } = renderHook( + ({ enabled }) => { usePolling({ enabled }); }, + { initialProps: { enabled: true } }, + ); + + vi.advanceTimersByTime(2000); + expect(invalidate).toHaveBeenCalledTimes(1); + + rerender({ enabled: false }); + + vi.advanceTimersByTime(10_000); + expect(invalidate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/src/lib/use-polling.ts b/packages/dashboard/src/lib/use-polling.ts new file mode 100644 index 00000000..bb821f5c --- /dev/null +++ b/packages/dashboard/src/lib/use-polling.ts @@ -0,0 +1,51 @@ +import { useRouter } from "@tanstack/react-router"; +import { useEffect } from "react"; + +interface UsePollingOptions { + interval?: number; + enabled?: boolean; +} + +export function usePolling({ + interval = 2000, + enabled = true, +}: UsePollingOptions = {}) { + const router = useRouter(); + + useEffect(() => { + if (!enabled) return; + + let timer: ReturnType | null = null; + + function start() { + if (timer) return; + timer = setInterval(() => { + void router.invalidate(); + }, interval); + } + + function stop() { + if (timer) { + clearInterval(timer); + timer = null; + } + } + + function handleVisibilityChange() { + if (document.hidden) { + stop(); + } else { + void router.invalidate(); + start(); + } + } + + start(); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + stop(); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [router, interval, enabled]); +} diff --git a/packages/dashboard/src/routes/index.tsx b/packages/dashboard/src/routes/index.tsx index ad3e6241..0a2c9d51 100644 --- a/packages/dashboard/src/routes/index.tsx +++ b/packages/dashboard/src/routes/index.tsx @@ -2,6 +2,7 @@ import { AppLayout } from "@/components/app-layout"; import { RunList } from "@/components/run-list"; import { WorkflowStats } from "@/components/workflow-stats"; import { listWorkflowRunsServerFn } from "@/lib/api"; +import { usePolling } from "@/lib/use-polling"; import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ @@ -14,6 +15,7 @@ export const Route = createFileRoute("/")({ function HomePage() { const { data: runs } = Route.useLoaderData(); + usePolling(); return ( diff --git a/packages/dashboard/src/routes/runs/$runId.tsx b/packages/dashboard/src/routes/runs/$runId.tsx index 6e389695..f2232bd3 100644 --- a/packages/dashboard/src/routes/runs/$runId.tsx +++ b/packages/dashboard/src/routes/runs/$runId.tsx @@ -5,9 +5,11 @@ import { Card } from "@/components/ui/card"; import { getWorkflowRunServerFn, listStepAttemptsServerFn } from "@/lib/api"; import { STEP_STATUS_CONFIG, + TERMINAL_RUN_STATUSES, getStatusColor, getStatusBadgeClass, } from "@/lib/status"; +import { usePolling } from "@/lib/use-polling"; import { cn } from "@/lib/utils"; import { computeDuration, formatRelativeTime } from "@/utils"; import { @@ -33,6 +35,9 @@ export const Route = createFileRoute("/runs/$runId")({ function RunDetailsPage() { const { run, steps } = Route.useLoaderData(); const [expandedSteps, setExpandedSteps] = useState>(new Set()); + usePolling({ + enabled: !!run && !TERMINAL_RUN_STATUSES.has(run.status), + }); function toggleStep(stepId: string) { setExpandedSteps((prev) => { From 6d156336fefc0909583f33e789176705765757a5 Mon Sep 17 00:00:00 2001 From: Peter Szoke Date: Tue, 10 Feb 2026 21:02:53 +0100 Subject: [PATCH 2/3] 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. Co-authored-by: Cursor --- .../dashboard/src/lib/use-polling.test.ts | 65 +++++++++++++++---- packages/dashboard/src/lib/use-polling.ts | 4 +- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/packages/dashboard/src/lib/use-polling.test.ts b/packages/dashboard/src/lib/use-polling.test.ts index 3cf11119..eafd456a 100644 --- a/packages/dashboard/src/lib/use-polling.test.ts +++ b/packages/dashboard/src/lib/use-polling.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom +import { usePolling } from "./use-polling"; import { cleanup, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { usePolling } from "./use-polling"; const invalidate = vi.fn(); @@ -27,7 +27,7 @@ describe("usePolling", () => { it("calls router.invalidate on the default interval", () => { renderHook(() => { - usePolling() + usePolling(); }); expect(invalidate).not.toHaveBeenCalled(); @@ -40,7 +40,9 @@ describe("usePolling", () => { }); it("respects a custom interval", () => { - renderHook(() => { usePolling({ interval: 5000 }); }); + renderHook(() => { + usePolling({ interval: 5000 }); + }); vi.advanceTimersByTime(4999); expect(invalidate).not.toHaveBeenCalled(); @@ -50,14 +52,18 @@ describe("usePolling", () => { }); it("does not poll when enabled is false", () => { - renderHook(() => { usePolling({ enabled: false }); }); + renderHook(() => { + usePolling({ enabled: false }); + }); vi.advanceTimersByTime(10_000); expect(invalidate).not.toHaveBeenCalled(); }); it("stops polling on unmount", () => { - const { unmount } = renderHook(() => { usePolling(); }); + const { unmount } = renderHook(() => { + usePolling(); + }); vi.advanceTimersByTime(2000); expect(invalidate).toHaveBeenCalledTimes(1); @@ -69,29 +75,60 @@ describe("usePolling", () => { }); it("pauses polling when the tab is hidden", () => { - renderHook(() => { usePolling(); }); + renderHook(() => { + usePolling(); + }); vi.advanceTimersByTime(2000); expect(invalidate).toHaveBeenCalledTimes(1); // Simulate tab becoming hidden - Object.defineProperty(document, "hidden", { value: true, writable: true, configurable: true }); + Object.defineProperty(document, "hidden", { + value: true, + writable: true, + configurable: true, + }); document.dispatchEvent(new Event("visibilitychange")); vi.advanceTimersByTime(10_000); expect(invalidate).toHaveBeenCalledTimes(1); }); + it("does not start polling when mounted with the tab already hidden", () => { + Object.defineProperty(document, "hidden", { + value: true, + writable: true, + configurable: true, + }); + + renderHook(() => { + usePolling(); + }); + + vi.advanceTimersByTime(10_000); + expect(invalidate).not.toHaveBeenCalled(); + }); + it("resumes polling and immediately invalidates when the tab becomes visible", () => { - renderHook(() => { usePolling(); }); + renderHook(() => { + usePolling(); + }); // Hide tab - Object.defineProperty(document, "hidden", { value: true, writable: true, configurable: true }); + Object.defineProperty(document, "hidden", { + value: true, + writable: true, + configurable: true, + }); document.dispatchEvent(new Event("visibilitychange")); invalidate.mockClear(); // Show tab again - Object.defineProperty(document, "hidden", { value: false, writable: true, configurable: true }); + Object.defineProperty(document, "hidden", { + value: false, + writable: true, + configurable: true, + }); document.dispatchEvent(new Event("visibilitychange")); // Should immediately invalidate on visibility restore @@ -104,7 +141,9 @@ describe("usePolling", () => { it("starts polling when enabled changes from false to true", () => { const { rerender } = renderHook( - ({ enabled }) => { usePolling({ enabled }); }, + ({ enabled }) => { + usePolling({ enabled }); + }, { initialProps: { enabled: false } }, ); @@ -119,7 +158,9 @@ describe("usePolling", () => { it("stops polling when enabled changes from true to false", () => { const { rerender } = renderHook( - ({ enabled }) => { usePolling({ enabled }); }, + ({ enabled }) => { + usePolling({ enabled }); + }, { initialProps: { enabled: true } }, ); diff --git a/packages/dashboard/src/lib/use-polling.ts b/packages/dashboard/src/lib/use-polling.ts index bb821f5c..71f5bad9 100644 --- a/packages/dashboard/src/lib/use-polling.ts +++ b/packages/dashboard/src/lib/use-polling.ts @@ -40,7 +40,9 @@ export function usePolling({ } } - start(); + if (!document.hidden) { + start(); + } document.addEventListener("visibilitychange", handleVisibilityChange); return () => { From 6f893a66900d272b9d550035d9fe1d6b3a1c592d Mon Sep 17 00:00:00 2001 From: Peter Szoke Date: Tue, 10 Feb 2026 22:34:39 +0100 Subject: [PATCH 3/3] chore(knip.json): remove unnecessary testing libraries from ignoreDependencies --- knip.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/knip.json b/knip.json index 9938e38e..275f08e6 100644 --- a/knip.json +++ b/knip.json @@ -6,9 +6,6 @@ "ignoreDependencies": [ "@tanstack/react-router-ssr-query", "@tanstack/router-plugin", - "@testing-library/dom", - "@testing-library/react", - "jsdom", "postgres", "web-vitals" ],