diff --git a/README.md b/README.md index 379ff03..84c0768 100644 --- a/README.md +++ b/README.md @@ -448,6 +448,14 @@ The `/agents` page lists every agent identity seen by the backend, paginated wit - Backend errors are surfaced as a `role="alert"` paragraph; the pagination bar is suppressed while an error is shown. - The single-agent view (`/agents/:agent`) utilizes a semantic `` trail for accessible orientation. +## Service top-agents paging + +The `/services/:serviceId/agents` page requests top agents with `page` and +`limit=25`, shows the shared `Spinner` while each page is loading, renders +`EmptyState` when no agents are returned, and uses the shared `Pagination` +component so the `aria-live` page indicator announces page changes. Agent rows +link to `/agents/:agent` with the agent identifier encoded. + ## Commands | Command | Description | diff --git a/src/app/services/[serviceId]/agents/page.test.tsx b/src/app/services/[serviceId]/agents/page.test.tsx new file mode 100644 index 0000000..8b371f5 --- /dev/null +++ b/src/app/services/[serviceId]/agents/page.test.tsx @@ -0,0 +1,155 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ServiceAgentsPage from "./page"; +import { apiGet } from "@/lib/apiClient"; + +jest.mock("@/lib/apiClient", () => ({ + apiGet: jest.fn(), +})); + +jest.mock("react", () => { + const originalReact = jest.requireActual("react"); + return { + ...originalReact, + use: (usable: unknown) => { + const u = usable as { _value?: unknown } | null | undefined; + if (u && u._value) { + return u._value; + } + return originalReact.use(usable); + }, + }; +}); + +const apiGetMock = apiGet as jest.MockedFunction; + +function agent(agentId: string, total: number) { + return { agent: agentId, total }; +} + +function renderPage(serviceId = "svc-1") { + const params = Promise.resolve({ serviceId }) as Promise<{ + serviceId: string; + }> & { + _value: { serviceId: string }; + }; + params._value = { serviceId }; + return render(); +} + +describe("ServiceAgentsPage", () => { + beforeEach(() => { + apiGetMock.mockReset(); + }); + + it("renders a spinner while the first top-agents page is loading", () => { + apiGetMock.mockReturnValueOnce(new Promise(() => undefined) as never); + + renderPage("svc/one"); + + expect(screen.getByRole("status")).toHaveTextContent(/Loading top agents/i); + expect(apiGetMock).toHaveBeenCalledWith( + "/api/v1/services/svc%2Fone/agents/top?page=1&limit=25", + ); + expect( + screen.queryByRole("navigation", { name: /pagination/i }), + ).not.toBeInTheDocument(); + }); + + it("renders the empty state when the service has no agents", async () => { + apiGetMock.mockResolvedValueOnce({ + items: [], + page: 1, + pageCount: 1, + } as never); + + renderPage(); + + expect( + await screen.findByText("No agents on this service yet."), + ).toBeInTheDocument(); + expect( + screen.getByText(/Agents appear here after they record usage/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole("navigation", { name: /pagination/i }), + ).not.toBeInTheDocument(); + }); + + it("renders top-agent rows as encoded links on a single page", async () => { + apiGetMock.mockResolvedValueOnce({ + items: [agent("agent/one", 42)], + page: 1, + pageCount: 1, + } as never); + + renderPage(); + + const agentLink = await screen.findByRole("link", { name: "agent/one" }); + expect(agentLink).toHaveAttribute("href", "/agents/agent%2Fone"); + expect(screen.getByText("1.")).toBeInTheDocument(); + expect(screen.getByText("42 requests")).toBeInTheDocument(); + expect( + screen.queryByRole("navigation", { name: /pagination/i }), + ).not.toBeInTheDocument(); + }); + + it("shows pagination for multiple pages and refetches on Next", async () => { + apiGetMock + .mockResolvedValueOnce({ + items: [agent("agent-a", 10)], + page: 1, + pageCount: 2, + } as never) + .mockResolvedValueOnce({ + items: [agent("agent-b", 20)], + page: 2, + pageCount: 2, + } as never); + + renderPage("svc-main"); + + expect(await screen.findByText("Page 1 of 2")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /next/i })); + + await waitFor(() => { + expect(apiGetMock).toHaveBeenLastCalledWith( + "/api/v1/services/svc-main/agents/top?page=2&limit=25", + ); + }); + expect( + await screen.findByRole("link", { name: "agent-b" }), + ).toHaveAttribute("href", "/agents/agent-b"); + expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + expect(screen.getByText("26.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /next/i })).toBeDisabled(); + }); + + it("uses the server-confirmed page and supports agents response aliases", async () => { + apiGetMock.mockResolvedValueOnce({ + agents: [agent("alias-agent", 5)], + page: 2, + pageCount: 3, + } as never); + + renderPage(); + + expect( + await screen.findByRole("link", { name: "alias-agent" }), + ).toHaveAttribute("href", "/agents/alias-agent"); + expect(screen.getByText("Page 2 of 3")).toBeInTheDocument(); + expect(screen.getByText("26.")).toBeInTheDocument(); + }); + + it("surfaces backend failures as a role=alert and hides pagination", async () => { + apiGetMock.mockRejectedValueOnce(new Error("top agents unavailable")); + + renderPage(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "top agents unavailable", + ); + expect( + screen.queryByRole("navigation", { name: /pagination/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/services/[serviceId]/agents/page.tsx b/src/app/services/[serviceId]/agents/page.tsx index 6b792e6..29b6046 100644 --- a/src/app/services/[serviceId]/agents/page.tsx +++ b/src/app/services/[serviceId]/agents/page.tsx @@ -3,8 +3,20 @@ import { useEffect, useState, use } from "react"; import Link from "next/link"; import { apiGet } from "@/lib/apiClient"; +import { EmptyState } from "@/components/EmptyState"; +import { Pagination } from "@/components/Pagination"; +import { Spinner } from "@/components/Spinner"; -type TopAgents = { serviceId: string; items: { agent: string; total: number }[] }; +type TopAgent = { agent: string; total: number }; +type TopAgents = { + serviceId: string; + items?: TopAgent[]; + agents?: TopAgent[]; + page?: number; + pageCount?: number; +}; + +const PAGE_SIZE = 25; export default function ServiceAgentsPage({ params, @@ -12,16 +24,55 @@ export default function ServiceAgentsPage({ params: Promise<{ serviceId: string }>; }) { const { serviceId } = use(params); - const [items, setItems] = useState(null); + const [items, setItems] = useState(null); const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [requestedPage, setRequestedPage] = useState(1); + const [pageCount, setPageCount] = useState(1); + + const onPageChange = (nextPage: number) => { + setLoading(true); + setError(null); + setItems(null); + setRequestedPage(nextPage); + }; useEffect(() => { + let cancelled = false; + apiGet( - `/api/v1/services/${encodeURIComponent(serviceId)}/agents/top?limit=25` + `/api/v1/services/${encodeURIComponent( + serviceId, + )}/agents/top?page=${requestedPage}&limit=${PAGE_SIZE}`, ) - .then((b) => setItems(b.items)) - .catch((e) => setError(e.message)); - }, [serviceId]); + .then((body) => { + if (cancelled) return; + + const nextItems = body.items ?? body.agents ?? []; + const nextPageCount = Math.max(body.pageCount ?? 1, 1); + const nextPage = Math.min( + Math.max(body.page ?? requestedPage, 1), + nextPageCount, + ); + + setItems(nextItems); + setPageCount(nextPageCount); + setPage(nextPage); + }) + .catch((e: Error) => { + if (cancelled) return; + setError(e.message ?? "failed to load"); + setPageCount(1); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [requestedPage, serviceId]); return (

- Top agents {serviceId} + Top agents{" "} + {serviceId}

{error && (

{error}

)} - {items && items.length === 0 && ( -

No agents on this service yet.

+ {loading && ( +
+ +
)} - {items && items.length > 0 && ( + {!loading && items && items.length === 0 && ( + + )} + {!loading && items && items.length > 0 && (
    {items.map((a, i) => ( -
  1. - +
  2. + - {i + 1}. + {(page - 1) * PAGE_SIZE + i + 1}. - {a.agent} + + {a.agent} + {a.total} requests @@ -63,6 +131,9 @@ export default function ServiceAgentsPage({ ))}
)} + {!loading && !error && ( + + )}
); }