Skip to content
Closed
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
8 changes: 8 additions & 0 deletions .server-changes/runs-live-and-row-polling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
area: webapp
type: feature
---

Add a "Live" toggle on the Runs index page that auto-revalidates page 1 every 3s, prepending new runs as they arrive. Visible only when on the first page.

Add per-row polling for runs in non-terminal statuses on every page (always on, regardless of the Live toggle). Visible non-terminal rows are refreshed in place via a new resource route `…/runs/refresh?ids=…`, which returns the current row data from Postgres (skipping ClickHouse to avoid replication lag). Polling pauses when the tab is hidden.
6 changes: 6 additions & 0 deletions .server-changes/runs-table-new-runs-banner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Add a "X new runs" 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.
74 changes: 74 additions & 0 deletions apps/webapp/app/components/runs/v3/RunsLiveControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { useRevalidator } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useNewRunsCount } from "~/hooks/useNewRunsCount";

const LIVE_INTERVAL_MS = 3000;

export function RunsLiveControl({
isLive,
onChange,
topRowId,
countNewUrl,
}: {
isLive: boolean;
onChange: (next: boolean) => void;
topRowId: string | undefined;
countNewUrl: string;
}) {
const revalidator = useRevalidator();

// When live, the loader auto-revalidates and the count banner is hidden.
// When not live, we poll for new runs and show the banner if any exist.
useAutoRevalidate({ interval: LIVE_INTERVAL_MS, disabled: !isLive });

const { count, hasMore } = useNewRunsCount({
sinceId: topRowId,
countNewUrl,
intervalMs: LIVE_INTERVAL_MS,
disabled: isLive,
});

const showCountBanner = !isLive && count > 0;
const label = hasMore ? `${count}+` : String(count);
const noun = count === 1 && !hasMore ? "run" : "runs";

return (
<>
{showCountBanner && (
<Button
variant="secondary/small"
onClick={() => revalidator.revalidate()}
LeadingIcon={ArrowPathIcon}
tooltip="Load new runs. Click the Live button to enable auto-refresh."
>
<span className="text-text-bright">
{label} new {noun}
</span>
</Button>
)}
<Button
variant="secondary/small"
onClick={() => onChange(!isLive)}
tooltip={isLive ? "Pause live updates" : "Auto-refresh new runs"}
LeadingIcon={() => <LiveDot isLive={isLive} />}
>
<span className="text-text-bright">Live</span>
</Button>
</>
);
}

function LiveDot({ isLive }: { isLive: boolean }) {
if (!isLive) {
return <span className="size-2 rounded-full bg-charcoal-500" aria-hidden />;
}

return (
<span className="relative flex size-2 items-center justify-center" aria-hidden>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-rose-500 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-rose-500" />
</span>
);
}
13 changes: 11 additions & 2 deletions apps/webapp/app/hooks/useAutoRevalidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ type UseAutoRevalidateOptions = {
interval?: number; // in milliseconds
onFocus?: boolean;
disabled?: boolean;
pauseWhenHidden?: boolean;
};

export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
const { interval = 5000, onFocus = true, disabled = false } = options;
const {
interval = 5000,
onFocus = true,
disabled = false,
pauseWhenHidden = true,
} = options;
const revalidator = useRevalidator();

useEffect(() => {
Expand All @@ -18,11 +24,14 @@ export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
if (revalidator.state === "loading") {
return;
}
if (pauseWhenHidden && document.visibilityState !== "visible") {
return;
}
revalidator.revalidate();
}, interval);

return () => clearInterval(intervalId);
}, [interval, disabled]);
}, [interval, disabled, pauseWhenHidden]);

useEffect(() => {
if (!onFocus || disabled) return;
Expand Down
92 changes: 92 additions & 0 deletions apps/webapp/app/hooks/useNewRunsCount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useEffect, useRef, useState } from "react";

type CountNewResponse = { count: number; hasMore: boolean };

type UseNewRunsCountOptions = {
sinceId: string | undefined;
countNewUrl: string;
intervalMs?: number;
disabled?: boolean;
};

const DEFAULT_INTERVAL_MS = 3000;

/**
* Polls the runs.count-new resource route to count runs newer than the
* top visible row. Uses a plain `fetch` rather than `useFetcher` so Remix's
* automatic fetcher revalidation (e.g. from useAutoRevalidate in Live mode)
* does not re-fire the request when the hook is disabled.
*/
export function useNewRunsCount({
sinceId,
countNewUrl,
intervalMs = DEFAULT_INTERVAL_MS,
disabled = false,
}: UseNewRunsCountOptions): { count: number; hasMore: boolean } {
const [state, setState] = useState<CountNewResponse>({ count: 0, hasMore: false });
const inFlightRef = useRef(false);

// Reset baseline whenever the cursor or url changes, or when disabling.
useEffect(() => {
setState({ count: 0, hasMore: false });
}, [sinceId, countNewUrl, disabled]);

useEffect(() => {
if (disabled) return;
if (!sinceId) return;
if (typeof document === "undefined") return;

const url = appendSinceParam(countNewUrl, sinceId);
let cancelled = false;
const controller = new AbortController();

const tick = async () => {
if (inFlightRef.current) return;
if (document.visibilityState !== "visible") return;
inFlightRef.current = true;
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { Accept: "application/json" },
credentials: "same-origin",
});
if (!res.ok) return;
const data = (await res.json()) as CountNewResponse;
if (!cancelled) {
setState({ count: data.count, hasMore: data.hasMore });
}
} catch {
// Ignore aborts and transient network errors; next tick will retry.
} finally {
inFlightRef.current = false;
}
};

const handleVisibility = () => {
if (document.visibilityState === "visible") {
tick();
}
};

const intervalId = setInterval(tick, intervalMs);
document.addEventListener("visibilitychange", handleVisibility);

return () => {
cancelled = true;
controller.abort();
clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [sinceId, countNewUrl, intervalMs, disabled]);

if (disabled || !sinceId) {
return { count: 0, hasMore: false };
}

return state;
}

function appendSinceParam(url: string, sinceId: string): string {
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}since=${encodeURIComponent(sinceId)}`;
}
108 changes: 108 additions & 0 deletions apps/webapp/app/hooks/useRunsRowPolling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTypedFetcher } from "remix-typedjson";
import { type NextRunListItem } from "~/presenters/v3/NextRunListPresenter.server";
import { type loader as runsRefreshLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.refresh";
import { isFinalRunStatus } from "~/v3/taskStatus";

type UseRunsRowPollingOptions = {
runs: NextRunListItem[];
refreshUrl: string;
intervalMs?: number;
};

const DEFAULT_INTERVAL_MS = 3000;

/**
* Polls the runs.refresh resource route for the visible non-terminal rows
* and returns a map of overrides keyed by internal run id. Consumers should
* merge the overrides on top of the loader's runs array.
*
* The hook pauses when the tab is hidden and triggers an immediate fetch
* when it becomes visible again.
*/
export function useRunsRowPolling({
runs,
refreshUrl,
intervalMs = DEFAULT_INTERVAL_MS,
}: UseRunsRowPollingOptions): Map<string, NextRunListItem> {
const fetcher = useTypedFetcher<typeof runsRefreshLoader>();
const [overrides, setOverrides] = useState<Map<string, NextRunListItem>>(() => new Map());

// Compute pollable IDs against the *merged* view, so that once an override
// flips a row to a terminal status, we stop polling it.
const nonTerminalIds = useMemo(() => {
const ids: string[] = [];
for (const run of runs) {
const merged = overrides.get(run.id) ?? run;
if (!isFinalRunStatus(merged.status)) {
ids.push(merged.id);
}
}
ids.sort();
return ids;
}, [runs, overrides]);

const idsKey = nonTerminalIds.join(",");

// Drop overrides for IDs that are no longer visible to keep the map bounded.
useEffect(() => {
setOverrides((prev) => {
if (prev.size === 0) return prev;
const visibleIds = new Set(runs.map((r) => r.id));
let changed = false;
const next = new Map<string, NextRunListItem>();
for (const [id, run] of prev) {
if (visibleIds.has(id)) {
next.set(id, run);
} else {
changed = true;
}
}
return changed ? next : prev;
});
}, [runs]);

// Apply incoming fetcher data to the overrides map.
const lastAppliedDataRef = useRef<unknown>(null);
useEffect(() => {
if (!fetcher.data) return;
if (fetcher.data === lastAppliedDataRef.current) return;
lastAppliedDataRef.current = fetcher.data;
const incoming = fetcher.data.runs;
setOverrides((prev) => {
const next = new Map(prev);
for (const run of incoming) {
next.set(run.id, run);
}
return next;
});
}, [fetcher.data]);

// Schedule polling.
useEffect(() => {
if (!idsKey) return;
if (typeof document === "undefined") return;

const tick = () => {
if (fetcher.state !== "idle") return;
if (document.visibilityState !== "visible") return;
fetcher.load(`${refreshUrl}?ids=${idsKey}`);
};

const handleVisibility = () => {
if (document.visibilityState === "visible") {
tick();
}
};

const intervalId = setInterval(tick, intervalMs);
document.addEventListener("visibilitychange", handleVisibility);

return () => {
clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [idsKey, refreshUrl, intervalMs]);

return overrides;
}
Loading