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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Breadcrumb>` 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 |
Expand Down
155 changes: 155 additions & 0 deletions src/app/services/[serviceId]/agents/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof apiGet>;

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(<ServiceAgentsPage params={params} />);
}

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();
});
});
99 changes: 85 additions & 14 deletions src/app/services/[serviceId]/agents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,76 @@
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,
}: {
params: Promise<{ serviceId: string }>;
}) {
const { serviceId } = use(params);
const [items, setItems] = useState<TopAgents["items"] | null>(null);
const [items, setItems] = useState<TopAgent[] | null>(null);
const [error, setError] = useState<string | null>(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<TopAgents>(
`/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 (
<main
Expand All @@ -36,25 +87,42 @@ export default function ServiceAgentsPage({
← Back to service
</Link>
<h1 className="text-3xl font-semibold tracking-tight">
Top agents <span className="font-mono text-base text-zinc-500">{serviceId}</span>
Top agents{" "}
<span className="font-mono text-base text-zinc-500">{serviceId}</span>
</h1>
{error && (
<p role="alert" className="text-sm text-rose-600">
{error}
</p>
)}
{items && items.length === 0 && (
<p className="text-sm text-zinc-500">No agents on this service yet.</p>
{loading && (
<div className="flex justify-center py-10">
<Spinner label="Loading top agents" />
</div>
)}
{items && items.length > 0 && (
{!loading && items && items.length === 0 && (
<EmptyState
title="No agents on this service yet."
description="Agents appear here after they record usage against this service."
/>
)}
{!loading && items && items.length > 0 && (
<ol className="divide-y divide-zinc-200 dark:divide-zinc-800">
{items.map((a, i) => (
<li key={a.agent} className="flex items-center justify-between py-3 text-sm">
<span className="font-mono">
<li
key={a.agent}
className="flex items-center justify-between py-3 text-sm"
>
<span className="flex items-center font-mono">
<span className="mr-3 inline-block w-5 text-right text-zinc-500">
{i + 1}.
{(page - 1) * PAGE_SIZE + i + 1}.
</span>
{a.agent}
<Link
href={`/agents/${encodeURIComponent(a.agent)}`}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
{a.agent}
</Link>
</span>
<span className="text-zinc-700 dark:text-zinc-300">
{a.total} requests
Expand All @@ -63,6 +131,9 @@ export default function ServiceAgentsPage({
))}
</ol>
)}
{!loading && !error && (
<Pagination page={page} pageCount={pageCount} onChange={onPageChange} />
)}
</main>
);
}