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" ], 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..eafd456a --- /dev/null +++ b/packages/dashboard/src/lib/use-polling.test.ts @@ -0,0 +1,175 @@ +// @vitest-environment jsdom +import { usePolling } from "./use-polling"; +import { cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +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("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(); + }); + + // 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..71f5bad9 --- /dev/null +++ b/packages/dashboard/src/lib/use-polling.ts @@ -0,0 +1,53 @@ +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(); + } + } + + if (!document.hidden) { + 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) => {