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
77 changes: 63 additions & 14 deletions ui/src/pages/SubstratePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Text css={{ color: theme.color.textMuted, fontSize: 12 }}>{age}</Text>;
const stalledMs = useStalledFor(computedAt, pollMs);
const at = readAt(computedAt);
if (stalledMs === undefined) {
return <Text css={{ color: theme.color.textMuted, fontSize: 12 }}>{at}</Text>;
}
return (
<Text css={{ color: theme.color.warning, fontSize: 12 }}>
{`${at}, ${elapsed(stalledMs)} ago`}
</Text>
);
}

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 }) {
Expand Down Expand Up @@ -1484,7 +1533,7 @@ export function SubstratePage() {
"& > [data-testid$='-pages']": { marginTop: 0 },
}}
>
<PageAge computedAt={actors.data?.computedAt} />
<PageAge computedAt={actors.data?.computedAt} pollMs={isTicking ? pollMs : undefined} />

<PageControls
testId="substrate-actors-pages"
Expand Down Expand Up @@ -1562,7 +1611,7 @@ export function SubstratePage() {
"& > [data-testid$='-pages']": { marginTop: 0 },
}}
>
<PageAge computedAt={workers.data?.computedAt} />
<PageAge computedAt={workers.data?.computedAt} pollMs={isTicking ? pollMs : undefined} />

<PageControls
testId="substrate-workers-pages"
Expand Down
45 changes: 45 additions & 0 deletions ui/src/pages/substrateReadAge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { ThemeProvider } from "@emotion/react";
import { MemoryRouter } from "react-router-dom";
import { SWRConfig } from "swr";
import { afterEach, expect, it, vi } from "vitest";
import { createRouterTransport } from "@connectrpc/connect";
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
import { SystemService } from "@/generated/kagent/api/v1/system_pb";
import { setApiTransport } from "@/api/transport";
import { themeFor } from "@/theme/theme";
import { SubstratePage } from "./SubstratePage";

afterEach(() => {
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<never>(() => {});
let actorReads = 0;
setApiTransport(createRouterTransport(({ service }) => service(SystemService, {
listNamespaces: () => ({}),
getSubstrateSummary: () => ({}),
listSubstrateActors: () =>
actorReads++ === 0 ? { computedAt: timestampFromDate(new Date()) } : hang(),
listSubstrateWorkers: () => ({ computedAt: timestampFromDate(new Date()) }),
})));
render(<ThemeProvider theme={themeFor("dark")}>
<SWRConfig value={{ provider: () => new Map(), shouldRetryOnError: false }}>
<MemoryRouter><SubstratePage /></MemoryRouter>
</SWRConfig>
</ThemeProvider>);

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