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$/); +});