Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 81 additions & 8 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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<T>(
path: string | null,
intervalMs: number,
options?: { initialPaused?: boolean },
): PollingState<T>;
```

Import from:

```ts
import { usePolling } from "@/lib/usePolling";
```

Return shape:

```ts
type PollingState<T> = {
status: "loading" | "error" | "ok";
data: T | null;
error: string | null;
lastUpdated: Date | null;
paused: boolean;
pause: () => void;
resume: () => void;
refresh: () => Promise<void>;
};
```

Parameters:

- `path`: backend API path passed to `apiGet<T>`. 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<Stats>("/api/v1/stats", 5000);

if (state.error) {
return <p role="alert">{state.error}</p>;
}

return <p>{state.data?.totalRequests ?? 0} requests</p>;
}
```

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
Expand Down Expand Up @@ -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`.
60 changes: 21 additions & 39 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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 };

Expand All @@ -19,6 +20,8 @@ type ToggleState = {
confirmLabel: string;
};

const ADMIN_STATUS_POLL_INTERVAL_MS = 5000;

const getToggleState = (paused: boolean): ToggleState => {
if (paused) {
return {
Expand All @@ -43,38 +46,20 @@ const getToggleState = (paused: boolean): ToggleState => {

export default function AdminPage() {
const toast = useToast();
const {
data: status,
error: pollingError,
refresh: refreshStatus,
} = usePolling<AdminStatus>(
"/api/v1/admin/status",
ADMIN_STATUS_POLL_INTERVAL_MS
);

const [paused, setPaused] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
const paused = status?.paused ?? null;
const [actionError, setActionError] = useState<string | null>(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<AdminStatus>("/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;
Expand All @@ -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 {
Expand All @@ -107,18 +92,16 @@ 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);
}
}, [endpoint, paused, refreshAfterAction, toast]);

const onOpenConfirm = useCallback(() => {
if (paused === null) return;
if (pending) return;
setConfirmOpen(true);
}, [paused, pending]);
}, []);

const statusVariant = paused ? "down" : "ok";

Expand Down Expand Up @@ -166,7 +149,6 @@ export default function AdminPage() {
void onConfirm();
}}
onCancel={() => {
if (pending) return;
setConfirmOpen(false);
}}
/>
Expand Down
3 changes: 2 additions & 1 deletion src/app/agents/[agent]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 }[] };
Expand Down
1 change: 1 addition & 0 deletions src/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand Down
99 changes: 99 additions & 0 deletions src/app/stats/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof apiGet>;

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(<StatsPage />);

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(<StatsPage />);

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(<StatsPage />);

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(<StatsPage />);

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(<StatsPage />);

expect(await screen.findByText("42")).toBeInTheDocument();
unmount();

await act(async () => {
jest.advanceTimersByTime(15000);
});

await waitFor(() => {
expect(apiGetMock).toHaveBeenCalledTimes(1);
});
});
});
22 changes: 4 additions & 18 deletions src/app/stats/page.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,22 +11,9 @@ type Stats = {
};

export default function StatsPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
const tick = () =>
apiGet<Stats>("/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<Stats>("/api/v1/stats", 5000);
const stats = statsState.data;
const error = statsState.error;

return (
<main
Expand Down
Loading
Loading