diff --git a/ui/src/pages/SubstratePage.tsx b/ui/src/pages/SubstratePage.tsx
index d3e8dc5c74..d9f822c075 100644
--- a/ui/src/pages/SubstratePage.tsx
+++ b/ui/src/pages/SubstratePage.tsx
@@ -692,25 +692,74 @@ function SectionSearch({
const PAGE_SIZE = 25;
-function PageAge({ computedAt }: { computedAt?: string }) {
+/**
+ * When the list was read, as a clock time rather than a running age: an age that ticks
+ * twice a second pulls the eye off the rows while saying nothing new.
+ *
+ * While polling, a list with no new read for `STALLED_AFTER_TICKS` intervals adds how
+ * long it has gone without one, in the warning colour. That is the stall the old age
+ * showed, now shown only when it is happening.
+ */
+function PageAge({ computedAt, pollMs }: { computedAt?: string; pollMs?: number }) {
const theme = useTheme();
- const age = useDataAge(computedAt);
- return {age};
+ const stalledMs = useStalledFor(computedAt, pollMs);
+ const at = readAt(computedAt);
+ if (stalledMs === undefined) {
+ return {at};
+ }
+ return (
+
+ {`${at}, ${elapsed(stalledMs)} ago`}
+
+ );
}
-function useDataAge(computedAt: string | undefined): string {
- const [now, setNow] = useState(() => Date.now());
+const STALLED_AFTER_TICKS = 3;
+// A floor, so a half-second interval does not call one slow read a stall.
+const STALLED_FLOOR_MS = 5_000;
+
+/**
+ * How long a polled list has gone without a new read, once that is a stall.
+ *
+ * Measured on the browser's clock from when `computedAt` last changed, not from
+ * `computedAt` itself, so a skewed server clock cannot raise it.
+ */
+function useStalledFor(computedAt: string | undefined, pollMs: number | undefined) {
+ const [stall, setStall] = useState<{ key?: string; ms: number }>();
+
useEffect(() => {
- const timer = window.setInterval(() => setNow(Date.now()), 500);
+ if (pollMs === undefined) return;
+ const sinceMs = Date.now();
+ const limitMs = Math.max(STALLED_AFTER_TICKS * pollMs, STALLED_FLOOR_MS);
+ const timer = window.setInterval(() => {
+ const ms = Date.now() - sinceMs;
+ setStall(ms >= limitMs ? { key: computedAt, ms } : undefined);
+ }, 1_000);
return () => window.clearInterval(timer);
- }, []);
+ }, [computedAt, pollMs]);
+ // A stall belongs to the read it was measured against; a new read clears it at once.
+ return pollMs !== undefined && stall !== undefined && stall.key === computedAt
+ ? stall.ms
+ : undefined;
+}
+
+function readAt(computedAt: string | undefined): string {
if (!computedAt) return "";
- const at = new Date(computedAt).getTime();
- if (Number.isNaN(at)) return "";
- const seconds = Math.max(0, (now - at) / 1000);
- if (seconds < 1) return "read just now";
- return `read ${seconds.toFixed(1)}s ago`;
+ const at = new Date(computedAt);
+ if (Number.isNaN(at.getTime())) return "";
+ return `Read at ${at.toLocaleTimeString([], {
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ })}`;
+}
+
+/** `12s`, `3m 05s`. */
+function elapsed(ms: number): string {
+ const whole = Math.floor(ms / 1_000);
+ if (whole < 60) return `${whole}s`;
+ return `${Math.floor(whole / 60)}m ${String(whole % 60).padStart(2, "0")}s`;
}
function PageWarning({ message, testId }: { message: string; testId: string }) {
@@ -1484,7 +1533,7 @@ export function SubstratePage() {
"& > [data-testid$='-pages']": { marginTop: 0 },
}}
>
-
+
[data-testid$='-pages']": { marginTop: 0 },
}}
>
-
+
{
+ setApiTransport(undefined);
+ vi.useRealTimers();
+});
+
+// The lists answer once, then hang: polling keeps asking and no new read lands.
+it("shows how long a polled list has gone without a new read", async () => {
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] });
+ const hang = () => new Promise(() => {});
+ let actorReads = 0;
+ setApiTransport(createRouterTransport(({ service }) => service(SystemService, {
+ listNamespaces: () => ({}),
+ getSubstrateSummary: () => ({}),
+ listSubstrateActors: () =>
+ actorReads++ === 0 ? { computedAt: timestampFromDate(new Date()) } : hang(),
+ listSubstrateWorkers: () => ({ computedAt: timestampFromDate(new Date()) }),
+ })));
+ render(
+ new Map(), shouldRetryOnError: false }}>
+
+
+ );
+
+ const [actorsAge] = await screen.findAllByText(/^Read at /);
+ expect(actorsAge).not.toHaveTextContent(/ago$/);
+
+ fireEvent.click(screen.getByTestId("substrate-poll-toggle"));
+ await act(async () => {
+ vi.advanceTimersByTime(8_000);
+ });
+
+ expect(actorsAge).toHaveTextContent(/^Read at .+, \d+s ago$/);
+});