Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@
"ignoreDependencies": [
"@tanstack/react-router-ssr-query",
"@tanstack/router-plugin",
"@testing-library/dom",
"@testing-library/react",
"jsdom",
"postgres",
"web-vitals"
],
Expand Down
8 changes: 8 additions & 0 deletions packages/dashboard/src/lib/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkflowRunStatus> = new Set([
"completed",
"succeeded",
"failed",
"canceled",
]);

const fallbackStatusColor = "text-yellow-500";
const fallbackStatusBadgeClass =
"bg-yellow-500/10 text-yellow-500 border-yellow-500/20";
Expand Down
175 changes: 175 additions & 0 deletions packages/dashboard/src/lib/use-polling.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
53 changes: 53 additions & 0 deletions packages/dashboard/src/lib/use-polling.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | 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]);
}
2 changes: 2 additions & 0 deletions packages/dashboard/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("/")({
Expand All @@ -14,6 +15,7 @@ export const Route = createFileRoute("/")({

function HomePage() {
const { data: runs } = Route.useLoaderData();
usePolling();

return (
<AppLayout>
Expand Down
5 changes: 5 additions & 0 deletions packages/dashboard/src/routes/runs/$runId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,6 +35,9 @@ export const Route = createFileRoute("/runs/$runId")({
function RunDetailsPage() {
const { run, steps } = Route.useLoaderData();
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
usePolling({
enabled: !!run && !TERMINAL_RUN_STATUSES.has(run.status),
});

function toggleStep(stepId: string) {
setExpandedSteps((prev) => {
Expand Down