From bcd72967d692fef661f5762b58f9f310e1fbca65 Mon Sep 17 00:00:00 2001 From: samuelfrancis163-eng Date: Tue, 28 Jul 2026 20:02:55 +0100 Subject: [PATCH 1/3] docs(api-keys): document component contract --- README.md | 2 +- docs/api-keys.md | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 docs/api-keys.md diff --git a/README.md b/README.md index 4761604..4459681 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Backend endpoints are taken from the companion documentation page `src/app/docs/ | `/admin` | Admin control surface (pause/unpause/status) | `POST /api/v1/admin/pause`, `POST /api/v1/admin/unpause`, _(reads status via GET `/api/v1/admin/status` in code)_ | | `/agents` | Agents overview | _(reads agents list via `/api/v1/agents` in code)_ | | `/agents/:agent` | Single-agent view | _(reads agent details via `/api/v1/agents/:agent` in code)_ | -| `/api-keys` | API keys management with created-at timestamps and a no-keys empty state | _(list/create/delete/update endpoints in code)_ | +| `/api-keys` | API keys management with created-at timestamps and a no-keys empty state — see [docs/api-keys.md](docs/api-keys.md) | _(list/create/delete/update endpoints in code)_ | | `/changelog` | Changelog | _(static or calls `/api/v1/changelog` depending on implementation)_ | | `/docs` | Short API endpoint reference — see [docs/docs-page.md](docs/docs-page.md) | `GET /api/v1/openapi.json` plus the prose list rendered from `src/app/docs/page.tsx` (usage, settle, services, admin pause/unpause), filterable via a debounced search input. Each endpoint includes a copyable curl example. | | `/events` | Event log renderer | _(reads events stream/poll via `/api/v1/events` endpoints in code)_ | diff --git a/docs/api-keys.md b/docs/api-keys.md new file mode 100644 index 0000000..69619ed --- /dev/null +++ b/docs/api-keys.md @@ -0,0 +1,34 @@ +# api-keys + +The `api-keys` component (`src/app/api-keys/page.tsx`) provides the API keys management view for the dashboard. It handles listing, creating, and revoking API keys. + +## Props + +This is a Next.js route component and accepts no custom React props. It is rendered automatically by the App Router at `/api-keys`. + +## States + +The component maintains the following internal state to manage the lifecycle of API keys: + +- `items`: `KeyItem[] | null` - The list of loaded API keys, or null before the initial load completes. +- `label`: `string` - The current input value for a new key's label in the creation form. +- `created`: `string | null` - The newly created raw key value, which is shown only once upon creation. +- `showFull`: `boolean` - Toggles whether to reveal the full `created` key value or keep it masked. +- `error`: `string | null` - The current error message from any failed API interactions (load, create, revoke). +- `pendingRevoke`: `KeyItem | null` - The key currently selected for revocation, used to show the confirmation dialog. + +## Minimal usage example + +Because this is a route-level page component, it is not imported and rendered manually as a typical React component. It is accessed by navigating to its route: + +```tsx +import Link from "next/link"; + +export function SettingsNav() { + return ( + + ); +} +``` From e3cd844f11c49a570a95abb0a3fe6e7d2d3ff7c8 Mon Sep 17 00:00:00 2001 From: samuelfrancis163-eng Date: Tue, 28 Jul 2026 20:13:27 +0100 Subject: [PATCH 2/3] a11y(stats): announce updates politely --- src/app/stats/page.test.tsx | 49 +++++++++++++++++++++++++++++++++++++ src/app/stats/page.tsx | 28 +++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/app/stats/page.test.tsx b/src/app/stats/page.test.tsx index ee2b7eb..ebb17b4 100644 --- a/src/app/stats/page.test.tsx +++ b/src/app/stats/page.test.tsx @@ -119,4 +119,53 @@ describe("StatsPage polling", () => { expect(screen.getByText(/backend is currently paused/i)).toBeInTheDocument(); expect(screen.getAllByRole("definition")).toHaveLength(4); }); + + it("announces meaningful stats changes via a debounced live region, avoiding initial mount, handling zero states", async () => { + const fetchMock = jest.fn(async () => jsonResponse(STATS)); // paused: true + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + const { container } = render(); + + const liveRegion = container.querySelector('[aria-live="polite"]'); + expect(liveRegion).toBeInTheDocument(); + + // Initial data fetch + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await act(async () => jest.advanceTimersByTime(500)); // debounce window + + // Should NOT announce on initial mount + expect(liveRegion).toHaveTextContent(""); + + // Simulate poll with changes (unpaused, updated counts) + fetchMock.mockImplementationOnce(async () => jsonResponse({ ...STATS, totalRequests: 17, paused: false })); + await act(async () => jest.advanceTimersByTime(5_000)); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + // Fast-forward partial debounce time + await act(async () => jest.advanceTimersByTime(200)); + expect(liveRegion).toHaveTextContent(""); + + // Fast-forward remainder of debounce window + await act(async () => jest.advanceTimersByTime(300)); + expect(liveRegion).toHaveTextContent("Stats updated: 4 services, 8 API keys, 17 requests, 2 agents"); + + // Simulate zero-results state + fetchMock.mockImplementationOnce(async () => jsonResponse({ totalServices: 0, totalApiKeys: 0, totalRequests: 0, uniqueAgents: 0, paused: false })); + await act(async () => jest.advanceTimersByTime(5_000)); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + + // Rapid successive update (another update happens before the previous 500ms debounce completes) + // We can simulate this by resolving another fetch instantly, though usePolling is 5s. + // Instead we'll just wait 200ms, then trigger another state change by simulating a paused state fetch + await act(async () => jest.advanceTimersByTime(200)); + + // Force a re-render with new data (simulating a rapid update perhaps from another source, or just we test debounce) + fetchMock.mockImplementationOnce(async () => jsonResponse({ ...STATS, paused: true })); + await act(async () => jest.advanceTimersByTime(5_000)); // wait for next poll + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); + + await act(async () => jest.advanceTimersByTime(500)); + // The previous update (0 results) should have been skipped/overwritten, only the final update announced + expect(liveRegion).toHaveTextContent("Stats updated: Backend is paused"); + }); }); diff --git a/src/app/stats/page.tsx b/src/app/stats/page.tsx index 48b561e..3ed7cfc 100644 --- a/src/app/stats/page.tsx +++ b/src/app/stats/page.tsx @@ -1,8 +1,10 @@ "use client"; +import { useEffect, useRef, useState } from "react"; import { ErrorMessage } from "@/components/ErrorMessage"; import { PageShell } from "@/components/PageShell"; import { TimeAgo } from "@/components/TimeAgo"; +import { useDebounce } from "@/lib/useDebounce"; import { usePolling } from "@/lib/usePolling"; type Stats = { @@ -19,8 +21,34 @@ export default function StatsPage() { const error = statsState.error; const lastUpdated = statsState.lastUpdated; + const statsSummary = stats + ? stats.paused + ? "Stats updated: Backend is paused" + : `Stats updated: ${stats.totalServices} services, ${stats.totalApiKeys} API keys, ${stats.totalRequests} requests, ${stats.uniqueAgents} agents` + : ""; + + const debouncedSummary = useDebounce(statsSummary, 500); + const [announcement, setAnnouncement] = useState(""); + const isFirstMount = useRef(true); + + useEffect(() => { + if (isFirstMount.current) { + if (debouncedSummary) { + isFirstMount.current = false; + } + return; + } + if (debouncedSummary) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setAnnouncement(debouncedSummary); + } + }, [debouncedSummary]); + return ( +
+ {announcement} +

Stats

From b585171d9b0401b7dd6ea722620d1bd2d821a404 Mon Sep 17 00:00:00 2001 From: samuelfrancis163-eng Date: Tue, 28 Jul 2026 20:23:44 +0100 Subject: [PATCH 3/3] docs(admin): document component contract --- README.md | 2 +- docs/admin-page.md | 41 +++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 docs/admin-page.md diff --git a/README.md b/README.md index 4459681..8779f34 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Backend endpoints are taken from the companion documentation page `src/app/docs/ | ----------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `/` | Main dashboard landing | _(check app code in `src/app/page.tsx` and any hooks it uses)_ | | `/about` | About page | _(static UI unless the page calls APIs)_ | -| `/admin` | Admin control surface (pause/unpause/status) | `POST /api/v1/admin/pause`, `POST /api/v1/admin/unpause`, _(reads status via GET `/api/v1/admin/status` in code)_ | +| `/admin` | Admin control surface (pause/unpause/status) — see [docs/admin-page.md](docs/admin-page.md) | `POST /api/v1/admin/pause`, `POST /api/v1/admin/unpause`, _(reads status via GET `/api/v1/admin/status` in code)_ | | `/agents` | Agents overview | _(reads agents list via `/api/v1/agents` in code)_ | | `/agents/:agent` | Single-agent view | _(reads agent details via `/api/v1/agents/:agent` in code)_ | | `/api-keys` | API keys management with created-at timestamps and a no-keys empty state — see [docs/api-keys.md](docs/api-keys.md) | _(list/create/delete/update endpoints in code)_ | diff --git a/docs/admin-page.md b/docs/admin-page.md new file mode 100644 index 0000000..bdb0dff --- /dev/null +++ b/docs/admin-page.md @@ -0,0 +1,41 @@ +# Admin page (`/admin`) + +Control surface to pause or unpause backend writes across the protocol. + +## Files + +| File | Role | +| --- | --- | +| `src/app/admin/page.tsx` | Client component (`"use client"`). Renders the Admin live status panel and confirmation dialog. | +| `src/app/admin/layout.tsx` | Nested layout setting the `` metadata to `Admin`. | + +## Component + +The component exported is `AdminPage`. + +### Props + +None. This component takes no props. + +### States + +- **Loading**: While `usePolling` first fetches `/api/v1/admin/status`, a polite live region displays "Loading status…". +- **Error (initial load)**: If the first poll fails, an `EmptyState` renders with a "Retry" button. +- **Empty (no data)**: If the backend returns an empty response but succeeds, an `EmptyState` prompts to "Refresh". +- **Populated (Live status panel)**: Renders a `StatusDot` ("Paused" or "Live") and a toggle button. +- **Pending action**: When the pause/unpause POST request is in flight, the button is disabled and reads "Working…". +- **Confirmation dialog**: Clicking the toggle opens a `ConfirmDialog` (`confirmOpen`) asking the user to confirm pausing or resuming writes. +- **Action Error**: Errors from the POST request (`actionError`) are caught and displayed via `AlertError` at the bottom of the page, as well as pushed to `useToast`. + +## Minimal usage example + +Since `AdminPage` is a Next.js App Router page, it is used by being mounted at the `/admin` route. It requires no props and relies on the global `ToastProvider` provided by the root layout. + +```tsx +import AdminPage from "@/app/admin/page"; + +// Rendered by Next.js at the /admin route: +export default function Page() { + return <AdminPage />; +} +``` diff --git a/docs/architecture.md b/docs/architecture.md index a9115fd..c599fc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ Segments may define their own `layout.tsx` (invariably thin wrappers that return |------|-----------|-------------|-------------------------|------------------------------| | `/` | Home | Server (no `"use client"`) | None — static landing with quick-link navigation | Root layout; title `AgentPay` (root default) | | `/about` | About | Server (no `"use client"`) | None — static surface overview | Root layout; title `About — AgentPay` (exported from `page.tsx`) | -| `/admin` | Admin | Client (`"use client"`) | `GET /api/v1/admin/status`; `POST /api/v1/admin/pause`; `POST /api/v1/admin/unpause` | `admin/layout.tsx`; title `Admin` (`pageTitles.admin`) | +| `/admin` | Admin — see [docs/admin-page.md](./admin-page.md) | Client (`"use client"`) | `GET /api/v1/admin/status`; `POST /api/v1/admin/pause`; `POST /api/v1/admin/unpause` | `admin/layout.tsx`; title `Admin` (`pageTitles.admin`) | | `/agents` | Agents | Client (`"use client"`) | `GET /api/v1/stats`; `GET /api/v1/agents?page=N&limit=25` | `agents/layout.tsx`; title `Agents` (`pageTitles.agents`) | | `/agents/:agent` | Agent detail | Client (`"use client"`) | `GET /api/v1/agents/:agent/usage`; `GET /api/v1/agents/:agent/total` | `agents/[agent]/layout.tsx`; title `Agent <agent>` (`agentTitle`) | | `/api-keys` | API keys | Client (`"use client"`) | `GET /api/v1/api-keys`; `POST /api/v1/api-keys`; `DELETE /api/v1/api-keys/:prefix` | `api-keys/layout.tsx`; title `API keys` (`pageTitles.apiKeys`) |