diff --git a/README.md b/README.md index 379ff03..bfd3b24 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,12 @@ See [docs/hooks.md](docs/hooks.md) for the shared hook reference, including signatures, return shapes, cancellation and SSR notes, and usage examples for the hooks in `src/lib`. +The stats page uses `usePolling("/api/v1/stats", 5000)` for its five-second +refresh loop, and the admin page uses the same hook for +`/api/v1/admin/status`. Interval cleanup, stale-response guards, action-triggered +refreshes, and error recovery stay in one shared hook instead of being +reimplemented in route code. + The agent detail route uses `useApi` for its primary usage request, keyed by the URL-encoded agent identifier. Navigating between agents aborts the superseded usage request and ignores any stale completion. Its optional lifetime-total diff --git a/docs/hooks.md b/docs/hooks.md index b2809b4..a4804dc 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -11,11 +11,7 @@ contract changes. | `useApi` | `src/lib/useApi.ts` | Exported | | `useDebounce` | `src/lib/useDebounce.ts` | Exported | | `useLocalState` | `src/lib/useLocalState.ts` | Exported | -| `usePolling` | _none_ | Not currently exported in this repo | - -`usePolling` is mentioned in issue #97, but there is no `usePolling` file, -export, or call site in `src/lib` or `src/app` at the time of this reference. -Do not document or import a polling hook until one exists in source. +| `usePolling` | `src/lib/usePolling.ts` | Exported | ## `useApi` @@ -84,6 +80,85 @@ Use this hook for simple GET-backed client views that can be represented as loading, error, or successful data. For write actions or request bodies, use the helpers in `src/lib/apiClient.ts` directly. +## `usePolling` + +```ts +function usePolling( + path: string | null, + intervalMs: number, + options?: { initialPaused?: boolean }, +): PollingState; +``` + +Import from: + +```ts +import { usePolling } from "@/lib/usePolling"; +``` + +Return shape: + +```ts +type PollingState = { + status: "loading" | "error" | "ok"; + data: T | null; + error: string | null; + lastUpdated: Date | null; + paused: boolean; + pause: () => void; + resume: () => void; + refresh: () => Promise; +}; +``` + +Parameters: + +- `path`: backend API path passed to `apiGet`. Pass `null` to skip fetching. +- `intervalMs`: polling cadence in milliseconds. +- `initialPaused`: starts without the initial fetch. Calling `resume()` fetches + immediately and starts the interval. + +Behaviour and gotchas: + +- This is a client hook and must be used from a client component. +- The hook fetches immediately unless `initialPaused` is true. +- Polling uses the shared `apiGet` client, so base URL resolution, JSON parsing, + and API error handling stay consistent with other client views. +- `pause()` clears the interval and prevents further automatic fetches. +- `resume()` restarts polling and fetches immediately. +- `refresh()` performs an on-demand fetch using the same path and resolves after + that request settles, so action handlers can await a follow-up status read. +- Responses from superseded requests, paused/path-changed effects, and unmounted + components are ignored through an internal request id guard. +- Successful responses update `lastUpdated`. Errors preserve the latest data, set + `status: "error"`, and expose a display-ready `error` string. + +Minimal real usage, based on `src/app/stats/page.tsx`: + +```tsx +"use client"; + +import { usePolling } from "@/lib/usePolling"; + +type Stats = { totalRequests: number }; + +export function StatsPreview() { + const state = usePolling("/api/v1/stats", 5000); + + if (state.error) { + return

{state.error}

; + } + + return

{state.data?.totalRequests ?? 0} requests

; +} +``` + +Use this hook for GET-backed views that need a repeated refresh cadence without +copying `setInterval` cleanup, stale-response guards, and pause/resume handling +into each page. Current adopters include the stats page and the admin status +panel; the admin toggle awaits `refresh()` after pause/unpause actions so the +visible status follows the backend result. + ## `useDebounce` ```ts @@ -207,6 +282,4 @@ localStorage. ## Coverage Note This reference covers every hook exported from `src/lib` at the time of writing: -`useApi`, `useDebounce`, and `useLocalState`. It also records that `usePolling` -does not currently exist, so contributors do not accidentally import an -undocumented hook name. +`useApi`, `usePolling`, `useDebounce`, and `useLocalState`. diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1bda492..d898877 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,11 +1,12 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; -import { apiGet, apiPost } from "@/lib/apiClient"; +import { apiPost } from "@/lib/apiClient"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { StatusDot } from "@/components/StatusDot"; import { useToast } from "@/components/ToastProvider"; +import { usePolling } from "@/lib/usePolling"; type AdminStatus = { paused: boolean }; @@ -19,6 +20,8 @@ type ToggleState = { confirmLabel: string; }; +const ADMIN_STATUS_POLL_INTERVAL_MS = 5000; + const getToggleState = (paused: boolean): ToggleState => { if (paused) { return { @@ -43,38 +46,20 @@ const getToggleState = (paused: boolean): ToggleState => { export default function AdminPage() { const toast = useToast(); + const { + data: status, + error: pollingError, + refresh: refreshStatus, + } = usePolling( + "/api/v1/admin/status", + ADMIN_STATUS_POLL_INTERVAL_MS + ); - const [paused, setPaused] = useState(null); - const [error, setError] = useState(null); + const paused = status?.paused ?? null; + const [actionError, setActionError] = useState(null); const [pending, setPending] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); - - /** - * Latest-wins stale-status guard. - * - * Each call to `load()` increments a monotonically increasing sequence number. - * Only the response for the latest in-flight call is allowed to update `paused`/`error`. - * This prevents out-of-order fetch responses from clobbering a newer state. - */ - const loadSeqRef = useRef(0); - - const load = useCallback(async () => { - const callSeq = ++loadSeqRef.current; - setError(null); - - try { - const b = await apiGet("/api/v1/admin/status"); - if (callSeq !== loadSeqRef.current) return; - setPaused(b.paused); - } catch (e) { - if (callSeq !== loadSeqRef.current) return; - setError((e as Error).message); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); + const error = actionError ?? pollingError; const toggleState = useMemo(() => { if (paused === null) return null; @@ -91,14 +76,14 @@ export default function AdminPage() { }, [toggleState]); const refreshAfterAction = useCallback(async () => { - await load(); - }, [load]); + await refreshStatus(); + }, [refreshStatus]); const onConfirm = useCallback(async () => { if (paused === null || !endpoint) return; setConfirmOpen(false); - setError(null); + setActionError(null); setPending(true); try { @@ -107,7 +92,7 @@ export default function AdminPage() { await refreshAfterAction(); } catch (e) { const message = (e as Error).message; - setError(message); + setActionError(message); toast.push(message, "error"); } finally { setPending(false); @@ -115,10 +100,8 @@ export default function AdminPage() { }, [endpoint, paused, refreshAfterAction, toast]); const onOpenConfirm = useCallback(() => { - if (paused === null) return; - if (pending) return; setConfirmOpen(true); - }, [paused, pending]); + }, []); const statusVariant = paused ? "down" : "ok"; @@ -166,7 +149,6 @@ export default function AdminPage() { void onConfirm(); }} onCancel={() => { - if (pending) return; setConfirmOpen(false); }} /> diff --git a/src/app/agents/[agent]/page.tsx b/src/app/agents/[agent]/page.tsx index 561eca1..0685abe 100644 --- a/src/app/agents/[agent]/page.tsx +++ b/src/app/agents/[agent]/page.tsx @@ -1,8 +1,9 @@ "use client"; import { use, useEffect, useState } from "react"; -import Link from "next/link"; +import { Breadcrumb } from "@/components/Breadcrumb"; import { apiGet } from "@/lib/apiClient"; +import { formatRequests } from "@/lib/format"; import { useApi } from "@/lib/useApi"; type Usage = { agent: string; items: { serviceId: string; total: number }[] }; diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 470e6ef..43fae84 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -2,6 +2,7 @@ import { PageShell } from "@/components/PageShell"; import { CurlBlock } from "@/components/CurlBlock"; import { messages } from "@/lib/messages"; import { resolveApiBase } from "@/lib/resolveApiBase"; +import { safeHref } from "@/lib/url"; export const metadata = { title: "Docs — AgentPay" }; diff --git a/src/app/stats/page.test.tsx b/src/app/stats/page.test.tsx new file mode 100644 index 0000000..2129deb --- /dev/null +++ b/src/app/stats/page.test.tsx @@ -0,0 +1,99 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; + +import { apiGet } from "@/lib/apiClient"; +import StatsPage from "./page"; + +jest.mock("@/lib/apiClient", () => ({ + apiGet: jest.fn(), +})); + +const apiGetMock = apiGet as jest.MockedFunction; + +const STATS_FIXTURE = { + totalServices: 3, + totalApiKeys: 2, + totalRequests: 42, + uniqueAgents: 5, + paused: false, +}; + +describe("StatsPage", () => { + beforeEach(() => { + jest.useFakeTimers(); + apiGetMock.mockReset(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it("loads stats through the shared polling hook", async () => { + apiGetMock.mockResolvedValue(STATS_FIXTURE); + + render(); + + expect(await screen.findByText("42")).toBeInTheDocument(); + expect(apiGetMock.mock.calls[0][0]).toBe("/api/v1/stats"); + expect(apiGetMock.mock.calls[0][1]).toMatchObject({ + signal: expect.any(AbortSignal), + }); + expect(screen.getByText("Services")).toBeInTheDocument(); + expect(screen.getByText("API keys")).toBeInTheDocument(); + expect(screen.getByText("Requests")).toBeInTheDocument(); + expect(screen.getByText("Agents")).toBeInTheDocument(); + }); + + it("polls stats again every five seconds", async () => { + apiGetMock + .mockResolvedValueOnce(STATS_FIXTURE) + .mockResolvedValueOnce({ ...STATS_FIXTURE, totalRequests: 43 }); + + render(); + + expect(await screen.findByText("42")).toBeInTheDocument(); + + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(await screen.findByText("43")).toBeInTheDocument(); + expect(apiGetMock).toHaveBeenCalledTimes(2); + }); + + it("keeps the existing alert path for polling failures", async () => { + apiGetMock.mockRejectedValue(new Error("stats failed")); + + render(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("stats failed"); + }); + + it("preserves the paused backend status message", async () => { + apiGetMock.mockResolvedValue({ ...STATS_FIXTURE, paused: true }); + + render(); + + expect( + await screen.findByText(/backend is currently paused/i) + ).toBeInTheDocument(); + }); + + it("does not poll after the page unmounts", async () => { + apiGetMock.mockResolvedValue(STATS_FIXTURE); + + const { unmount } = render(); + + expect(await screen.findByText("42")).toBeInTheDocument(); + unmount(); + + await act(async () => { + jest.advanceTimersByTime(15000); + }); + + await waitFor(() => { + expect(apiGetMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/app/stats/page.tsx b/src/app/stats/page.tsx index ff4c25d..9ca1894 100644 --- a/src/app/stats/page.tsx +++ b/src/app/stats/page.tsx @@ -1,7 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; -import { apiGet } from "@/lib/apiClient"; +import { usePolling } from "@/lib/usePolling"; type Stats = { totalServices: number; @@ -12,22 +11,9 @@ type Stats = { }; export default function StatsPage() { - const [stats, setStats] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - const tick = () => - apiGet("/api/v1/stats") - .then((s) => !cancelled && setStats(s)) - .catch((e) => !cancelled && setError(e.message)); - tick(); - const t = setInterval(tick, 5000); - return () => { - cancelled = true; - clearInterval(t); - }; - }, []); + const statsState = usePolling("/api/v1/stats", 5000); + const stats = statsState.data; + const error = statsState.error; return (
({ + apiGet: jest.fn(), +})); + +const apiGetMock = apiGet as jest.MockedFunction; + +type Payload = { + value: number; +}; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function lastApiGetCall() { + return apiGetMock.mock.calls[apiGetMock.mock.calls.length - 1]; +} + +function expectLastStatsCall() { + const call = lastApiGetCall(); + expect(call[0]).toBe("/api/v1/stats"); + expect(call[1]).toMatchObject({ signal: expect.any(AbortSignal) }); +} + +function Probe({ + initialPaused = false, + path = "/api/v1/stats", +}: { + initialPaused?: boolean; + path?: string | null; +}) { + const state = usePolling(path, 1000, { initialPaused }); + + return ( +
+ {state.status} + {state.data?.value ?? "none"} + {state.error ?? "none"} + {String(state.paused)} + + {state.lastUpdated?.toISOString() ?? "none"} + + + + +
+ ); +} + +describe("usePolling", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-07-04T00:00:00.000Z")); + apiGetMock.mockReset(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it("fetches immediately and again on the interval", async () => { + apiGetMock + .mockResolvedValueOnce({ value: 1 }) + .mockResolvedValueOnce({ value: 2 }); + + render(); + + expect(await screen.findByText("1")).toBeInTheDocument(); + expect(screen.getByTestId("status")).toHaveTextContent("ok"); + expect(screen.getByTestId("last-updated")).toHaveTextContent( + /^2026-07-04T00:00:00\.\d{3}Z$/ + ); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(await screen.findByText("2")).toBeInTheDocument(); + expect(apiGetMock).toHaveBeenCalledTimes(2); + expectLastStatsCall(); + }); + + it("supports starting paused before the first tick", async () => { + apiGetMock.mockResolvedValue({ value: 7 }); + + render(); + + expect(apiGetMock).not.toHaveBeenCalled(); + expect(screen.getByTestId("paused")).toHaveTextContent("true"); + + fireEvent.click(screen.getByRole("button", { name: "resume" })); + + expect(await screen.findByText("7")).toBeInTheDocument(); + expect(apiGetMock).toHaveBeenCalledTimes(1); + }); + + it("pause stops interval fetches and resume fetches immediately", async () => { + apiGetMock + .mockResolvedValueOnce({ value: 1 }) + .mockResolvedValueOnce({ value: 2 }); + + render(); + + expect(await screen.findByText("1")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "pause" })); + + await act(async () => { + jest.advanceTimersByTime(3000); + }); + + expect(apiGetMock).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "resume" })); + + expect(await screen.findByText("2")).toBeInTheDocument(); + expect(apiGetMock).toHaveBeenCalledTimes(2); + }); + + it("aborts the in-flight request when paused", async () => { + const pending = deferred(); + apiGetMock.mockReturnValue(pending.promise); + + render(); + + expect(apiGetMock).toHaveBeenCalledTimes(1); + const signal = apiGetMock.mock.calls[0][1]?.signal as AbortSignal; + + fireEvent.click(screen.getByRole("button", { name: "pause" })); + + expect(signal.aborted).toBe(true); + + await act(async () => { + jest.advanceTimersByTime(3000); + }); + + expect(apiGetMock).toHaveBeenCalledTimes(1); + }); + + it("surfaces errors and recovers on a later poll", async () => { + apiGetMock + .mockRejectedValueOnce(new Error("stats unavailable")) + .mockResolvedValueOnce({ value: 3 }); + + render(); + + expect(await screen.findByText("stats unavailable")).toBeInTheDocument(); + expect(screen.getByTestId("status")).toHaveTextContent("error"); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(await screen.findByText("3")).toBeInTheDocument(); + expect(screen.getByTestId("error")).toHaveTextContent("none"); + }); + + it("falls back to a generic message for empty errors", async () => { + apiGetMock.mockRejectedValueOnce(new Error("")); + + render(); + + expect(await screen.findByText("failed to load")).toBeInTheDocument(); + expect(screen.getByTestId("status")).toHaveTextContent("error"); + }); + + it("skips fetching when the path is null", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "refresh" })); + + expect(apiGetMock).not.toHaveBeenCalled(); + expect(screen.getByTestId("status")).toHaveTextContent("loading"); + }); + + it("manual refresh reuses the same API path", async () => { + apiGetMock + .mockResolvedValueOnce({ value: 1 }) + .mockResolvedValueOnce({ value: 9 }); + + render(); + + expect(await screen.findByText("1")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "refresh" })); + + expect(await screen.findByText("9")).toBeInTheDocument(); + expectLastStatsCall(); + }); + + it("aborts a superseded request when refreshing manually", async () => { + const first = deferred(); + apiGetMock + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce({ value: 2 }); + + render(); + + expect(apiGetMock).toHaveBeenCalledTimes(1); + const firstSignal = apiGetMock.mock.calls[0][1]?.signal as AbortSignal; + + fireEvent.click(screen.getByRole("button", { name: "refresh" })); + + expect(firstSignal.aborted).toBe(true); + expect(await screen.findByText("2")).toBeInTheDocument(); + + await act(async () => { + first.resolve({ value: 1 }); + await Promise.resolve(); + }); + + expect(screen.getByTestId("value")).toHaveTextContent("2"); + }); + + it("ignores pending responses after unmount and clears the interval", async () => { + const pending = deferred(); + apiGetMock.mockReturnValue(pending.promise); + + const { unmount } = render(); + + expect(apiGetMock).toHaveBeenCalledTimes(1); + const signal = apiGetMock.mock.calls[0][1]?.signal as AbortSignal; + unmount(); + expect(signal.aborted).toBe(true); + + await act(async () => { + pending.resolve({ value: 99 }); + await Promise.resolve(); + }); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(apiGetMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/usePolling.ts b/src/lib/usePolling.ts new file mode 100644 index 0000000..d522afc --- /dev/null +++ b/src/lib/usePolling.ts @@ -0,0 +1,155 @@ +"use client"; + +import { useCallback, useEffect, useReducer, useRef } from "react"; + +import { apiGet } from "./apiClient"; + +export type PollingStatus = "loading" | "error" | "ok"; + +export type PollingState = { + status: PollingStatus; + data: T | null; + error: string | null; + lastUpdated: Date | null; + paused: boolean; + pause: () => void; + resume: () => void; + refresh: () => Promise; +}; + +type StoredState = Omit, "pause" | "resume" | "refresh">; + +type Action = + | { type: "loading" } + | { type: "success"; data: T; lastUpdated: Date } + | { type: "error"; error: string } + | { type: "pause" } + | { type: "resume" }; + +export type UsePollingOptions = { + /** Start without the initial fetch. Call `resume` to fetch immediately. */ + initialPaused?: boolean; +}; + +function errorMessage(error: unknown) { + return error instanceof Error && error.message.length > 0 + ? error.message + : "failed to load"; +} + +function reducer(state: StoredState, action: Action): StoredState { + switch (action.type) { + case "loading": + return { + ...state, + status: state.data === null ? "loading" : state.status, + }; + case "success": + return { + ...state, + status: "ok", + data: action.data, + error: null, + lastUpdated: action.lastUpdated, + }; + case "error": + return { + ...state, + status: "error", + error: action.error, + }; + case "pause": + return { ...state, paused: true }; + case "resume": + return { ...state, paused: false }; + } +} + +/** + * Poll a backend API path with the shared `apiGet` client. + * + * @example + * const stats = usePolling("/api/v1/stats", 5000); + * if (stats.status === "error") return

{stats.error}

; + * return ; + */ +export function usePolling( + path: string | null, + intervalMs: number, + options: UsePollingOptions = {} +): PollingState { + const [state, dispatch] = useReducer(reducer, { + status: "loading", + data: null, + error: null, + lastUpdated: null, + paused: options.initialPaused ?? false, + }); + const mountedRef = useRef(false); + const requestIdRef = useRef(0); + const abortRef = useRef(null); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestIdRef.current += 1; + abortRef.current?.abort(); + abortRef.current = null; + }; + }, []); + + const refresh = useCallback(() => { + if (path === null) return Promise.resolve(); + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + dispatch({ type: "loading" }); + + return apiGet(path, { signal: controller.signal }) + .then((data) => { + if (mountedRef.current && requestId === requestIdRef.current) { + dispatch({ type: "success", data, lastUpdated: new Date() }); + } + }) + .catch((error) => { + if (mountedRef.current && requestId === requestIdRef.current) { + dispatch({ type: "error", error: errorMessage(error) }); + } + }) + .finally(() => { + if (abortRef.current === controller) { + abortRef.current = null; + } + }); + }, [path]); + + useEffect(() => { + if (path === null || state.paused) return; + + refresh(); + const timer = setInterval(() => { + refresh(); + }, intervalMs); + + return () => { + requestIdRef.current += 1; + abortRef.current?.abort(); + abortRef.current = null; + clearInterval(timer); + }; + }, [intervalMs, path, refresh, state.paused]); + + const pause = useCallback(() => dispatch({ type: "pause" }), []); + const resume = useCallback(() => dispatch({ type: "resume" }), []); + + return { + ...state, + pause, + resume, + refresh, + }; +}