fix(ui): make Admin UI table pagination honor the selected page size

All Models now pushes the model group, access group and wildcard filters into
/v2/model/info (new optional access_group and wildcard_only params) so the
server total_count matches the rendered rows. Request Logs defaults to 25,
uses the shared page size options and counts rendered rows in the footer.
Deleted Teams gets the shared DataTable server pagination footer instead of a
hard-coded page size of 100. Per-user usage and the remaining unbounded list
tables get paginationMode so the size selector renders.

Resolves LIT-4738

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan 2026-09-03 23:59:58 +00:00
parent a53c550951
commit 9ba6cab889
40 changed files with 462 additions and 102 deletions

View file

@ -13474,6 +13474,27 @@ def _is_auto_router_model(model: Mapping[str, object]) -> bool:
return isinstance(litellm_model, str) and litellm_model.startswith("auto_router/")
def _model_in_access_group(model: Mapping[str, object], access_group: str) -> bool:
model_info: Final = model.get("model_info")
if not isinstance(model_info, Mapping):
return False
access_groups: Final = model_info.get("access_groups")
return isinstance(access_groups, (list, tuple)) and access_group in access_groups
def _matches_model_info_filters(
model: Mapping[str, object],
exclude_auto_routers: bool | None,
access_group: str | None,
wildcard_only: bool | None,
) -> bool:
if exclude_auto_routers is True and _is_auto_router_model(model):
return False
if isinstance(access_group, str) and not _model_in_access_group(model, access_group):
return False
return wildcard_only is not True or "*" in str(model.get("model_name") or "")
def _paginate_models_response(
all_models: list[dict[str, Any]],
page: int,
@ -13784,6 +13805,14 @@ async def model_info_v2(
"existing callers are unaffected"
),
),
access_group: str | None = fastapi.Query(
None,
description="Only return deployments whose `model_info.access_groups` contains this access group",
),
wildcard_only: bool | None = fastapi.Query(
False,
description="Only return wildcard deployments, i.e. those whose `model_name` contains `*`",
),
):
"""
Paginated model metadata for proxy deployments (pricing, provider, team access).
@ -13801,6 +13830,8 @@ async def model_info_v2(
modelId: Return a single deployment by LiteLLM model id.
teamId: Filter to models with direct access or team membership for this team id.
sortBy / sortOrder: Sort by model_name, created_at, updated_at, costs, or status.
access_group: Only return deployments in this model access group.
wildcard_only: Only return deployments whose `model_name` contains `*`.
Example request:
```
@ -13954,8 +13985,9 @@ async def model_info_v2(
# `is True` because direct-call tests bypass FastAPI, so the Query default arrives as a
# truthy sentinel object rather than False.
if exclude_auto_routers is True:
all_models = [m for m in all_models if not _is_auto_router_model(m)]
all_models = [
m for m in all_models if _matches_model_info_filters(m, exclude_auto_routers, access_group, wildcard_only)
]
# Update total count to include agents
search_total_count = len(all_models)

View file

@ -452,3 +452,92 @@ async def test_model_info_v2_query_sentinel_does_not_filter(monkeypatch, mixed_a
)
assert "tri-tier-router" in [m["model_name"] for m in resp["data"]]
# ---------------------------------------------------------------------------
# GET /v2/model/info?access_group / ?wildcard_only
# ---------------------------------------------------------------------------
@pytest.fixture
def access_group_router(monkeypatch):
"""Router with one sales-team deployment, one wildcard sales-team deployment and one ungrouped one."""
model_list = [
{
"model_name": "gpt-4o-mini",
"litellm_params": {"model": "openai/gpt-4o-mini"},
"model_info": {"id": "sales-1", "db_model": False, "access_groups": ["sales-team"]},
},
{
"model_name": "openai/*",
"litellm_params": {"model": "openai/*"},
"model_info": {"id": "sales-wildcard", "db_model": False, "access_groups": ["sales-team", "eng"]},
},
{
"model_name": "claude-opus",
"litellm_params": {"model": "anthropic/claude-opus-4-6"},
"model_info": {"id": "plain-1", "db_model": False},
},
]
from unittest.mock import AsyncMock
router = MagicMock()
router.model_list = model_list
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "llm_model_list", model_list)
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(proxy_server, "user_model", None)
monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={}))
monkeypatch.setattr(
proxy_server,
"_apply_search_filter_to_models",
AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))),
)
monkeypatch.setattr(proxy_server, "_enrich_model_info_with_litellm_data", lambda model, **kw: model)
import litellm.proxy.agent_endpoints.model_list_helpers as mlh
monkeypatch.setattr(mlh, "append_agents_to_model_info", AsyncMock(side_effect=lambda models, **kw: models))
yield router
def test_v2_model_info_without_new_filters_returns_everything(client, auth_as, access_group_router):
with auth_as():
response = client.get("/v2/model/info")
payload = response.json()
assert payload["total_count"] == 3
assert len(payload["data"]) == 3
def test_v2_model_info_access_group_filters_rows_and_total(client, auth_as, access_group_router):
"""The table pages off total_count, so the filter must shrink the total, not only the page."""
with auth_as():
response = client.get("/v2/model/info", params={"access_group": "sales-team"})
payload = response.json()
assert _model_names(payload) == ["gpt-4o-mini", "openai/*"]
assert payload["total_count"] == 2
def test_v2_model_info_unknown_access_group_is_empty(client, auth_as, access_group_router):
with auth_as():
response = client.get("/v2/model/info", params={"access_group": "nobody"})
payload = response.json()
assert payload["data"] == []
assert payload["total_count"] == 0
def test_v2_model_info_wildcard_only_filters_rows_and_total(client, auth_as, access_group_router):
with auth_as():
response = client.get("/v2/model/info", params={"wildcard_only": "true"})
payload = response.json()
assert _model_names(payload) == ["openai/*"]
assert payload["total_count"] == 1
def test_v2_model_info_access_group_paginates_over_the_filtered_set(client, auth_as, access_group_router):
with auth_as():
response = client.get("/v2/model/info", params={"access_group": "sales-team", "page": 2, "size": 1})
payload = response.json()
assert _model_names(payload) == ["openai/*"]
assert payload["total_count"] == 2
assert payload["total_pages"] == 2

View file

@ -72,6 +72,7 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
return (
<DataTable
data={filteredAgents}
paginationMode="client"
columns={columns}
getRowId={(agent, index) => agent.agent_id || String(index)}
sortingMode="client"

View file

@ -46,6 +46,7 @@ const GuardrailTable: React.FC<GuardrailTableProps> = ({
return (
<DataTable
data={guardrailsList}
paginationMode="client"
columns={columns}
getRowId={(guardrail, index) => guardrail.guardrail_id || String(index)}
sortingMode="client"

View file

@ -39,6 +39,8 @@ export const useModelsInfo = (
sortOrder?: string,
excludeAutoRouters: boolean = false,
modelName?: string,
accessGroup?: string,
wildcardOnly: boolean = false,
) => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery<PaginatedModelInfoResponse>({
@ -57,6 +59,8 @@ export const useModelsInfo = (
// Part of the key: callers that exclude auto-routers must not share a cache entry
// with callers that keep them.
...(excludeAutoRouters && { excludeAutoRouters: "true" }),
...(accessGroup && { accessGroup }),
...(wildcardOnly && { wildcardOnly: "true" }),
},
}),
queryFn: async () =>
@ -73,6 +77,8 @@ export const useModelsInfo = (
sortOrder,
excludeAutoRouters,
modelName,
accessGroup,
wildcardOnly,
),
enabled: Boolean(accessToken && userId && userRole),
});

View file

@ -671,7 +671,7 @@ describe("useDeletedTeams", () => {
it("should return deleted teams data when query is successful", async () => {
(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ teams: mockDeletedTeams }),
json: async () => ({ teams: mockDeletedTeams, total: 2, page: 1, page_size: 10, total_pages: 1 }),
});
const { result } = renderHook(() => useDeletedTeams(1, 10, {}), { wrapper });
@ -684,10 +684,26 @@ describe("useDeletedTeams", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockDeletedTeams);
expect(result.current.data).toEqual({ teams: mockDeletedTeams, total: 2 });
expect(result.current.error).toBeNull();
});
it("should keep the server total so the table can paginate beyond the current page", async () => {
(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ teams: mockDeletedTeams, total: 137, page: 1, page_size: 2, total_pages: 69 }),
});
const { result } = renderHook(() => useDeletedTeams(1, 2, {}), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.total).toBe(137);
expect((global.fetch as any).mock.calls[0][0]).toContain("page_size=2");
});
it("should handle error when API call fails", async () => {
(global.fetch as any).mockResolvedValue({
ok: false,
@ -744,7 +760,7 @@ describe("useDeletedTeams", () => {
rerender({ page: 2 });
expect(result.current.data).toEqual(mockDeletedTeams);
expect(result.current.data?.teams).toEqual(mockDeletedTeams);
});
it("should pass options to API call", async () => {
@ -785,7 +801,7 @@ describe("useDeletedTeams", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockDeletedTeams);
expect(result.current.data).toEqual({ teams: mockDeletedTeams, total: 2 });
expect(result.current.error).toBeNull();
});
});

View file

@ -20,6 +20,11 @@ export interface DeletedTeam extends Team {
deleted_by: string;
}
export interface DeletedTeamsResponse {
teams: DeletedTeam[];
total: number;
}
export interface TeamListCallOptions {
organizationID?: string | null;
teamID?: string | null;
@ -209,7 +214,7 @@ const deletedTeamListCall = async (
page: number,
pageSize: number,
options: TeamListCallOptions = {},
) => {
): Promise<DeletedTeamsResponse> => {
/**
* Get deleted teams from proxy
*/
@ -251,14 +256,12 @@ const deletedTeamListCall = async (
throw new Error(errorMessage);
}
const data = await response.json();
const data: DeletedTeam[] | (Partial<DeletedTeamsResponse> & { teams: DeletedTeam[] }) = await response.json();
// Extract teams array from response if it's wrapped in a response object
// Otherwise return the data directly if it's already an array
if (data && typeof data === "object" && "teams" in data) {
return data.teams as DeletedTeam[];
if (Array.isArray(data)) {
return { teams: data, total: data.length };
}
return data as DeletedTeam[];
return { teams: data.teams, total: data.total ?? data.teams.length };
} catch (error) {
console.error("Failed to list deleted teams:", error);
throw error;
@ -270,10 +273,10 @@ export const useDeletedTeams = (
page: number,
pageSize: number,
options: TeamListCallOptions = {},
): UseQueryResult<DeletedTeam[]> => {
): UseQueryResult<DeletedTeamsResponse> => {
const { accessToken } = useAuthorized();
return useQuery<DeletedTeam[]>({
return useQuery<DeletedTeamsResponse>({
queryKey: deletedTeamKeys.list({ page, limit: pageSize, ...options }),
queryFn: async () => await deletedTeamListCall(accessToken!, page, pageSize, options),
enabled: Boolean(accessToken),

View file

@ -451,6 +451,7 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
<DataTable
data={toolsets}
paginationMode="client"
columns={columns}
getRowId={(toolset, index) => toolset.toolset_id || String(index)}
sortingMode="client"

View file

@ -34,6 +34,8 @@ interface ModelsInfoArgs {
sortBy?: string;
sortOrder?: string;
modelName?: string;
accessGroup?: string;
wildcardOnly?: boolean;
}
const modelsInfoCalls: ModelsInfoArgs[] = [];
@ -50,12 +52,24 @@ type UseModelsInfoArgs = [
sortOrder?: string,
excludeAutoRouters?: boolean,
modelName?: string,
accessGroup?: string,
wildcardOnly?: boolean,
];
vi.mock("../../hooks/models/useModels", () => ({
useModelsInfo: (...args: UseModelsInfoArgs) => {
const [page, size, search, , teamId, sortBy, sortOrder, , modelName] = args;
const call: ModelsInfoArgs = { page, size, search, teamId, sortBy, sortOrder, modelName };
const [page, size, search, , teamId, sortBy, sortOrder, , modelName, accessGroup, wildcardOnly] = args;
const call: ModelsInfoArgs = {
page,
size,
search,
teamId,
sortBy,
sortOrder,
modelName,
accessGroup,
wildcardOnly,
};
modelsInfoCalls.push(call);
return { ...modelsInfoResult, refetch: mockRefetch };
},
@ -254,13 +268,38 @@ describe("AllModelsTab", () => {
});
});
it("filters the fetched page down to the selected model group", () => {
setModelsInfo([makeRow(), { ...makeRow(), model_name: "claude-opus" }], 2);
it("renders every row the server returned for the selected model group so rows match the footer total", () => {
setModelsInfo([makeRow(), { ...makeRow({ model_info: { id: "model-2" } }), model_name: "claude-opus" }], 2);
render(<AllModelsTab {...defaultProps} selectedModelGroup="claude-opus" />);
const table = screen.getByRole("table");
expect(within(table).getByText("claude-opus")).toBeInTheDocument();
expect(within(table).queryByText("gpt-4")).not.toBeInTheDocument();
expect(within(table).getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-2 of 2");
});
it("asks the server for wildcard deployments instead of hiding rows client-side", () => {
setModelsInfo([makeRow(), { ...makeRow({ model_info: { id: "model-2" } }), model_name: "openai/*" }], 2);
render(<AllModelsTab {...defaultProps} selectedModelGroup="wildcard" />);
expect(lastModelsInfoCall().wildcardOnly).toBe(true);
expect(within(screen.getByRole("table")).getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-2 of 2");
});
it("asks the server for the selected access group instead of hiding rows client-side", async () => {
const user = userEvent.setup();
render(<AllModelsTab {...defaultProps} />);
expect(lastModelsInfoCall().wildcardOnly).toBe(false);
await user.click(screen.getByTestId("datatable-filters-trigger"));
await user.click(await screen.findByPlaceholderText("Filter by Model Access Group"));
await user.click(await screen.findByRole("option", { name: "sales-team" }));
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => expect(lastModelsInfoCall().accessGroup).toBe("sales-team"));
expect(within(screen.getByRole("table")).getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-1 of 1");
});
it("asks the server for the exact selected model group so deployments beyond the first page are found", () => {

View file

@ -86,6 +86,11 @@ const AllModelsTab = ({
selectedModelGroup !== ALL_MODEL_GROUPS_VALUE &&
selectedModelGroup !== WILDCARD_MODEL_GROUP_VALUE;
const modelNameForQuery = isConcreteModelGroup ? selectedModelGroup ?? undefined : undefined;
const accessGroupForQuery =
selectedModelAccessGroupFilter && selectedModelAccessGroupFilter !== ALL_MODEL_GROUPS_VALUE
? selectedModelAccessGroupFilter
: undefined;
const wildcardOnlyForQuery = selectedModelGroup === WILDCARD_MODEL_GROUP_VALUE;
const sortBy = useMemo(() => {
if (sorting.length === 0) return undefined;
@ -114,6 +119,8 @@ const AllModelsTab = ({
// lists and manages them. Excluded server-side so total_count stays honest.
true,
modelNameForQuery,
accessGroupForQuery,
wildcardOnlyForQuery,
);
const isLoading = isLoadingModelsInfo || isLoadingModelCostMap;
@ -129,32 +136,11 @@ const AllModelsTab = ({
[modelCostMapData],
);
const modelData = useMemo(() => {
const modelData = useMemo<{ data: ModelData[] }>(() => {
if (!rawModelData) return { data: [] };
return transformModelData(rawModelData, getProviderFromModel);
}, [rawModelData, getProviderFromModel]);
const filteredData = useMemo<ModelData[]>(() => {
if (!modelData || !modelData.data || modelData.data.length === 0) {
return [];
}
return modelData.data.filter((model: ModelData) => {
const modelNameMatch =
selectedModelGroup === ALL_MODEL_GROUPS_VALUE ||
model.model_name === selectedModelGroup ||
!selectedModelGroup ||
(selectedModelGroup === WILDCARD_MODEL_GROUP_VALUE && model.model_name?.includes("*"));
const accessGroupMatch =
selectedModelAccessGroupFilter === ALL_MODEL_GROUPS_VALUE ||
model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter ?? "") ||
!selectedModelAccessGroupFilter;
return modelNameMatch && accessGroupMatch;
});
}, [modelData, selectedModelGroup, selectedModelAccessGroupFilter]);
const columnFilters = useMemo<ColumnFiltersState>(
() =>
[
@ -270,7 +256,7 @@ const AllModelsTab = ({
<div className="w-full">
<div className="flex flex-col gap-3">
<AllModelsTable
data={filteredData}
data={modelData.data}
rowCount={rawModelData?.total_count ?? 0}
isLoading={isLoading}
isRefreshing={isFetchingModelsInfo}

View file

@ -84,6 +84,7 @@ export default function AccessGroupBudgetsPanel() {
<DataTable
data={accessGroups ?? []}
paginationMode="client"
columns={columns}
getRowId={(group) => group.access_group}
sortingMode="client"

View file

@ -192,6 +192,23 @@ describe("OrganizationsTable", () => {
expect(screen.queryByText("ShouldNotShow")).not.toBeInTheDocument();
});
it("pages long lists client-side with the shared size selector and footer", async () => {
const user = userEvent.setup();
const organizations = Array.from({ length: 30 }, (_, index) =>
makeOrganization({ organization_id: `org-${index}`, organization_alias: `Org ${index}` }),
);
render(<OrganizationsTable {...baseProps} organizations={organizations} />);
expect(screen.getAllByRole("row")).toHaveLength(26);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 30");
await user.click(screen.getByTestId("pagination-page-size"));
await user.click(await screen.findByRole("option", { name: "50" }));
expect(screen.getAllByRole("row")).toHaveLength(31);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-30 of 30");
});
it("uses a search-aware empty state", () => {
const { rerender } = render(<OrganizationsTable {...baseProps} searchActive={false} organizations={[]} />);
expect(screen.getByText("No organizations yet")).toBeInTheDocument();

View file

@ -59,6 +59,7 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
return (
<DataTable
data={organizations}
paginationMode="client"
columns={columns}
getRowId={(organization, index) => organization.organization_id || String(index)}
sortingMode="client"

View file

@ -50,6 +50,7 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
return (
<DataTable
data={attachments}
paginationMode="client"
columns={columns}
getRowId={(row) => row.attachment_id}
sortingMode="client"

View file

@ -71,6 +71,7 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
return (
<DataTable
data={rows}
paginationMode="client"
columns={columns}
getRowId={(row) => `${row.primaryPolicy.definition_location ?? "db"}:${row.policy_name}`}
sortingMode="client"

View file

@ -73,6 +73,7 @@ const PromptTable: React.FC<PromptTableProps> = ({
return (
<DataTable
data={promptsList}
paginationMode="client"
columns={columns}
getRowId={(prompt, index) =>
prompt.prompt_id ? `${prompt.prompt_id}::${prompt.environment || "development"}` : String(index)

View file

@ -50,6 +50,7 @@ const SearchToolTable: React.FC<SearchToolTableProps> = ({
return (
<DataTable
data={searchTools}
paginationMode="client"
columns={columns}
getRowId={(tool, index) => searchToolKey(tool) || String(index)}
sortingMode="client"

View file

@ -42,6 +42,7 @@ const PluginTable: React.FC<PluginTableProps> = ({ pluginsList, isLoading, onDel
return (
<DataTable
data={pluginsList}
paginationMode="client"
columns={columns}
getRowId={(plugin, index) => plugin.id || String(index)}
sortingMode="client"

View file

@ -39,6 +39,7 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
return (
<DataTable
data={data}
paginationMode="client"
columns={columns}
getRowId={(tag, index) => tag.name || String(index)}
sortingMode="client"

View file

@ -46,6 +46,7 @@ const IndexesTable: React.FC<IndexesTableProps> = ({
return (
<DataTable
data={data}
paginationMode="client"
columns={columns}
getRowId={(row, index) => row.id || String(index)}
sortingMode="client"

View file

@ -41,6 +41,7 @@ const VectorStoreTable: React.FC<VectorStoreTableProps> = ({ data, onView, onEdi
return (
<DataTable
data={data}
paginationMode="client"
columns={columns}
getRowId={(vectorStore, index) => vectorStore.vector_store_id || String(index)}
sortingMode="client"

View file

@ -474,6 +474,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
{/* Model Table */}
<DataTable
data={filteredData}
paginationMode="client"
columns={modelColumns}
getRowId={(model, index) => model.model_group || String(index)}
sortingMode="client"
@ -540,6 +541,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
{/* Agent Table */}
<DataTable
data={filteredAgentData}
paginationMode="client"
columns={agentColumns}
getRowId={(agent, index) => agent.agent_id || agent.name || String(index)}
sortingMode="client"
@ -581,6 +583,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
{/* MCP Server Table */}
<DataTable
data={mcpHubData || []}
paginationMode="client"
columns={mcpColumns}
getRowId={(server, index) => server.server_id || String(index)}
sortingMode="client"

View file

@ -162,6 +162,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
</div>
<DataTable
data={filteredSkills}
paginationMode="client"
columns={columns}
getRowId={(skill, index) => skill.id || String(index)}
sortingMode="client"

View file

@ -1,4 +1,5 @@
import { screen } from "@testing-library/react";
import { fireEvent, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, it, expect, beforeEach, MockedFunction } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import DeletedTeamsPage from "./DeletedTeamsPage";
@ -31,7 +32,7 @@ beforeEach(() => {
vi.clearAllMocks();
mockUseDeletedTeams.mockReturnValue({
data: [mockDeletedTeam],
data: { teams: [mockDeletedTeam], total: 1 },
isLoading: false,
} as unknown as ReturnType<typeof useDeletedTeams>);
});
@ -42,6 +43,49 @@ it("should render DeletedTeamsPage component", () => {
expect(screen.getByText("Test Team")).toBeInTheDocument();
});
it("requests the first page of 25 deleted teams and shows the server total in the footer", () => {
mockUseDeletedTeams.mockReturnValue({
data: { teams: [mockDeletedTeam], total: 137 },
isLoading: false,
} as unknown as ReturnType<typeof useDeletedTeams>);
renderWithProviders(<DeletedTeamsPage />);
expect(mockUseDeletedTeams).toHaveBeenLastCalledWith(1, 25);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 137");
expect(screen.getByTestId("pagination-next")).toBeEnabled();
});
it("requests the next page from the server when Next is clicked", () => {
mockUseDeletedTeams.mockReturnValue({
data: { teams: [mockDeletedTeam], total: 137 },
isLoading: false,
} as unknown as ReturnType<typeof useDeletedTeams>);
renderWithProviders(<DeletedTeamsPage />);
fireEvent.click(screen.getByTestId("pagination-next"));
expect(mockUseDeletedTeams).toHaveBeenLastCalledWith(2, 25);
});
it("offers the shared page sizes and refetches with the selected one", async () => {
const user = userEvent.setup();
mockUseDeletedTeams.mockReturnValue({
data: { teams: [mockDeletedTeam], total: 137 },
isLoading: false,
} as unknown as ReturnType<typeof useDeletedTeams>);
renderWithProviders(<DeletedTeamsPage />);
await user.click(screen.getByTestId("pagination-page-size"));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["25", "50", "100"]);
await user.click(screen.getByRole("option", { name: "100" }));
expect(mockUseDeletedTeams).toHaveBeenLastCalledWith(1, 100);
});
it("should show the enterprise notice for a non-premium user", () => {
renderWithProviders(<DeletedTeamsPage />);

View file

@ -1,13 +1,20 @@
"use client";
import { PaginationState } from "@tanstack/react-table";
import { Info } from "lucide-react";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable";
import { useDeletedTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { DeletedTeamsTable } from "./DeletedTeamsTable/DeletedTeamsTable";
export default function DeletedTeamsPage() {
const { premiumUser } = useAuthorized();
const { data: teamsData, isLoading } = useDeletedTeams(1, 100);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: DEFAULT_PAGE_SIZE_OPTIONS[0],
});
const { data: teamsData, isLoading } = useDeletedTeams(pagination.pageIndex + 1, pagination.pageSize);
return (
<div className="flex flex-col gap-4">
@ -20,7 +27,13 @@ export default function DeletedTeamsPage() {
</AlertDescription>
</Alert>
)}
<DeletedTeamsTable teams={teamsData || []} isLoading={isLoading} />
<DeletedTeamsTable
teams={teamsData?.teams ?? []}
isLoading={isLoading}
pagination={pagination}
onPaginationChange={setPagination}
rowCount={teamsData?.total ?? 0}
/>
</div>
);
}

View file

@ -22,12 +22,19 @@ const makeDeletedTeam = (overrides: Partial<DeletedTeam> = {}): DeletedTeam => (
...overrides,
});
const paginationProps = {
pagination: { pageIndex: 0, pageSize: 25 },
onPaginationChange: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should display team information", () => {
renderWithProviders(<DeletedTeamsTable teams={[makeDeletedTeam()]} isLoading={false} />);
renderWithProviders(
<DeletedTeamsTable teams={[makeDeletedTeam()]} isLoading={false} rowCount={1} {...paginationProps} />,
);
expect(screen.getByText("Test Team")).toBeInTheDocument();
expect(screen.getByText("team-1")).toBeInTheDocument();
@ -39,7 +46,7 @@ it("should sort teams by deleted_at descending by default", () => {
makeDeletedTeam({ team_id: "team-old", team_alias: "older-team", deleted_at: "2024-01-01T10:00:00Z" }),
makeDeletedTeam({ team_id: "team-new", team_alias: "newer-team", deleted_at: "2024-06-01T10:00:00Z" }),
];
renderWithProviders(<DeletedTeamsTable teams={teams} isLoading={false} />);
renderWithProviders(<DeletedTeamsTable teams={teams} isLoading={false} rowCount={2} {...paginationProps} />);
const rows = screen.getAllByRole("row").slice(1);
expect(within(rows[0]).getByText("newer-team")).toBeInTheDocument();
@ -47,13 +54,30 @@ it("should sort teams by deleted_at descending by default", () => {
});
it("should show skeleton rows when loading", () => {
renderWithProviders(<DeletedTeamsTable teams={[]} isLoading />);
renderWithProviders(<DeletedTeamsTable teams={[]} isLoading rowCount={0} {...paginationProps} />);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
});
it("should show the empty state when there are no deleted teams", () => {
renderWithProviders(<DeletedTeamsTable teams={[]} isLoading={false} />);
renderWithProviders(<DeletedTeamsTable teams={[]} isLoading={false} rowCount={0} {...paginationProps} />);
expect(screen.getByText("No deleted teams found")).toBeInTheDocument();
});
it("renders the shared pagination footer with the server row count", () => {
renderWithProviders(
<DeletedTeamsTable
teams={[makeDeletedTeam()]}
isLoading={false}
rowCount={137}
pagination={{ pageIndex: 2, pageSize: 50 }}
onPaginationChange={vi.fn()}
/>,
);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 101-137 of 137");
expect(screen.getByTestId("pagination-page-size")).toHaveTextContent("50");
expect(screen.getByTestId("pagination-prev")).toBeEnabled();
expect(screen.getByTestId("pagination-next")).toBeDisabled();
});

View file

@ -1,6 +1,6 @@
"use client";
import { SortingState } from "@tanstack/react-table";
import { OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { Inbox } from "lucide-react";
import { useMemo, useState } from "react";
@ -12,6 +12,9 @@ import { getDeletedTeamsTableColumns } from "./DeletedTeamsTableColumns";
interface DeletedTeamsTableProps {
teams: DeletedTeam[];
isLoading: boolean;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
rowCount: number;
}
const DEFAULT_SORTING: SortingState = [{ id: "deleted_at", desc: true }];
@ -28,7 +31,13 @@ function EmptyState() {
);
}
export function DeletedTeamsTable({ teams, isLoading }: DeletedTeamsTableProps) {
export function DeletedTeamsTable({
teams,
isLoading,
pagination,
onPaginationChange,
rowCount,
}: DeletedTeamsTableProps) {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(() => getDeletedTeamsTableColumns(), []);
@ -41,6 +50,10 @@ export function DeletedTeamsTable({ teams, isLoading }: DeletedTeamsTableProps)
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
paginationMode="server"
pagination={pagination}
onPaginationChange={onPaginationChange}
rowCount={rowCount}
isLoading={isLoading}
loadingMessage="Loading deleted teams…"
noDataMessage={<EmptyState />}

View file

@ -41,6 +41,7 @@ export function PassThroughEndpointsTable({
return (
<DataTable
data={endpoints}
paginationMode="client"
columns={columns}
getRowId={(endpoint, index) => endpoint.id || endpoint.path || String(index)}
isLoading={isLoading}

View file

@ -48,6 +48,7 @@ const CredentialsTable: React.FC<CredentialsTableProps> = ({
return (
<DataTable
data={credentials}
paginationMode="client"
columns={columns}
getRowId={(credential, index) => credential.credential_name || String(index)}
sortingMode="client"

View file

@ -1692,6 +1692,8 @@ export const modelInfoCall = async (
sortOrder?: string,
excludeAutoRouters?: boolean,
modelName?: string,
accessGroup?: string,
wildcardOnly?: boolean,
) => {
/**
* Get all models on proxy
@ -1723,6 +1725,12 @@ export const modelInfoCall = async (
if (excludeAutoRouters) {
params.append("exclude_auto_routers", "true");
}
if (accessGroup && accessGroup.trim()) {
params.append("access_group", accessGroup.trim());
}
if (wildcardOnly) {
params.append("wildcard_only", "true");
}
if (params.toString()) {
url += `?${params.toString()}`;
}

View file

@ -78,6 +78,25 @@ describe("PerUserUsage", () => {
});
});
it("shows every fetched row with a footer that matches the server total and page size", async () => {
const results = Array.from({ length: 25 }, (_, index) => userRow(`user-${index}`, "curl/8.0", index));
mockPerUserAnalyticsCall.mockResolvedValue({ ...mockResponse, results, total_count: 60, total_pages: 3 });
render(<PerUserUsage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("user-24")).toBeInTheDocument();
});
expect(mockPerUserAnalyticsCall).toHaveBeenLastCalledWith("test-token", 1, 25, undefined);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 60");
fireEvent.click(screen.getByTestId("pagination-next"));
await waitFor(() => {
expect(mockPerUserAnalyticsCall).toHaveBeenLastCalledWith("test-token", 2, 25, undefined);
});
});
it("keeps both tab panels mounted so switching tabs does not reset their state", async () => {
render(<PerUserUsage {...defaultProps} />);

View file

@ -1,8 +1,7 @@
import React, { useState, useEffect } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import type { ColumnDef, PaginationState } from "@tanstack/react-table";
import { BarChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { Button } from "@/components/ui/button";
import { DataTable, DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { perUserAnalyticsCall } from "./networking";
@ -42,7 +41,10 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
total_pages: 0,
});
const [currentPage, setCurrentPage] = useState(1);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: DEFAULT_PAGE_SIZE_OPTIONS[0],
});
const fetchPerUserData = async () => {
if (!accessToken) return;
@ -50,8 +52,8 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
try {
const response = await perUserAnalyticsCall(
accessToken,
currentPage,
50,
pagination.pageIndex + 1,
pagination.pageSize,
selectedTags.length > 0 ? selectedTags : undefined,
);
setPerUserData(response);
@ -62,19 +64,7 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
useEffect(() => {
fetchPerUserData();
}, [accessToken, selectedTags, currentPage]);
const handleNextPage = () => {
if (currentPage < perUserData.total_pages) {
setCurrentPage(currentPage + 1);
}
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
}, [accessToken, selectedTags, pagination.pageIndex, pagination.pageSize]);
const columns: ColumnDef<PerUserMetrics>[] = [
{
@ -137,30 +127,15 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
<TabsContent value="details" keepMounted>
<DataTable
columns={columns}
data={perUserData.results.slice(0, 10)}
data={perUserData.results}
getRowId={(row) => row.user_id}
paginationMode="server"
pagination={pagination}
onPaginationChange={setPagination}
rowCount={perUserData.total_count}
noDataMessage="No per-user usage data"
size="compact"
/>
{perUserData.results.length > 10 && (
<div className="mt-4 flex justify-between items-center">
<p className="text-sm text-muted-foreground">Showing 10 of {perUserData.total_count} results</p>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={handlePrevPage} disabled={currentPage === 1}>
Previous
</Button>
<Button
size="sm"
variant="secondary"
onClick={handleNextPage}
disabled={currentPage >= perUserData.total_pages}
>
Next
</Button>
</div>
</div>
)}
</TabsContent>
{/* Tab 2: Usage Distribution Histogram */}

View file

@ -587,6 +587,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<DataTable
data={filteredData}
paginationMode="client"
columns={modelColumns}
getRowId={(model, index) => model.model_group || String(index)}
sortingMode="client"
@ -656,6 +657,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<DataTable
data={filteredAgentData}
paginationMode="client"
columns={agentColumns}
getRowId={(agent, index) => agent.name || String(index)}
sortingMode="client"
@ -722,6 +724,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<DataTable
data={filteredMcpData}
paginationMode="client"
columns={mcpColumns}
getRowId={(server, index) => server.server_id || String(index)}
sortingMode="client"

View file

@ -64,6 +64,7 @@ const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({
return (
<DataTable
data={groups}
paginationMode="client"
columns={columns}
getRowId={(group) => group.group_name}
sortingMode="client"

View file

@ -46,6 +46,7 @@ const AvailableTeamsTable: React.FC<AvailableTeamsTableProps> = ({ teams, isLoad
return (
<DataTable
data={teams}
paginationMode="client"
columns={columns}
getRowId={(team, index) => team.team_id || String(index)}
sortingMode="client"

View file

@ -148,13 +148,60 @@ describe("RequestLogsPanel", () => {
});
describe("server-grouped session pagination (#38060)", () => {
it("requests session-grouped pages of 10 rows by default without a cursor", async () => {
it("requests session-grouped pages of 25 rows by default without a cursor", async () => {
renderPanel();
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCall()?.params?.group_by_session).toBe(true);
expect(lastCall()?.params?.session_cursor).toBeUndefined();
expect(lastCall()?.page_size).toBe(10);
expect(lastCall()?.page_size).toBe(25);
});
it("offers the same page sizes as the other tables", async () => {
const user = userEvent.setup();
respondWith([logEntry({ request_id: "req-a" })]);
renderPanel();
await waitFor(() => expect(row("req-a")).not.toBeNull());
await user.click(screen.getByTestId("pagination-page-size"));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["25", "50", "100"]);
});
it("counts the rendered rows in the footer instead of the server's session total", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue({
data: [logEntry({ request_id: "req-a" }), logEntry({ request_id: "req-b" }), logEntry({ request_id: "req-c" })],
total: 40,
page: 1,
page_size: 25,
total_pages: 2,
next_session_cursor: null,
has_more: false,
});
renderPanel();
await waitFor(() => expect(row("req-a")).not.toBeNull());
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-3 of 3");
expect(screen.getByTestId("pagination-next")).toBeDisabled();
});
it("keeps Next enabled from the server total while more session pages remain", async () => {
const firstPage = Array.from({ length: 25 }, (_, index) => logEntry({ request_id: `req-${index}` }));
vi.mocked(uiSpendLogsCall).mockResolvedValue({
data: firstPage,
total: 80,
page: 1,
page_size: 25,
total_pages: 4,
next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1",
has_more: true,
});
renderPanel();
await waitFor(() => expect(row("req-0")).not.toBeNull());
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 80");
expect(screen.getByTestId("pagination-next")).toBeEnabled();
});
it("renders every row the server returns without client-side collapsing", async () => {

View file

@ -6,13 +6,13 @@ import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } fr
import moment from "moment";
import { useCallback, useEffect, useMemo, useState } from "react";
import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable";
import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import type { KeyResponse } from "../key_team_helpers/key_list";
import { keyInfoV1Call, uiSpendLogsCall } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import type { LogEntry } from "./columns";
import { LOGS_PAGE_SIZE_OPTIONS } from "./constants";
import {
DEFAULT_LOGS_SORTING,
formatLogsWindow,
@ -26,7 +26,7 @@ import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar";
import { RequestLogsTable } from "./RequestLogsTable";
const PAGE_SIZE = LOGS_PAGE_SIZE_OPTIONS[0];
const PAGE_SIZE = DEFAULT_PAGE_SIZE_OPTIONS[0];
const DEFAULT_INTERVAL = { value: 24, unit: "hours" };
interface RequestLogsPanelProps {
@ -166,6 +166,10 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
const isDrawerOpen = displayLog !== null || displaySessionId !== null;
const rows: LogEntry[] = filteredLogs.data;
const rowsThroughThisPage = pagination.pageIndex * pagination.pageSize + rows.length;
const isLastPage =
filteredLogs.has_more === false || (filteredLogs.has_more === undefined && rows.length < pagination.pageSize);
const rowCount = isLastPage ? rowsThroughThisPage : Math.max(filteredLogs.total, rowsThroughThisPage);
const handleSearchChange = useCallback((value: string) => {
setColumnFilters((previous) => {
@ -290,7 +294,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
<RequestLogsTable
data={rows}
rowCount={filteredLogs.total}
rowCount={rowCount}
isLoading={logsQuery.isLoading}
isRefreshing={logsQuery.isFetching}
pagination={pagination}

View file

@ -8,7 +8,6 @@ import { DataTable, DataTableFilterDrawer, DataTableToolbar } from "@/components
import type { Team } from "../key_team_helpers/key_list";
import type { LogEntry } from "./columns";
import { LOGS_PAGE_SIZE_OPTIONS } from "./constants";
import { LOG_FILTER_LABELS, type LogsWindow } from "./log_filter_logic";
import { RequestLogsFilters } from "./RequestLogsFilters";
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
@ -92,7 +91,6 @@ export function RequestLogsTable({
paginationMode="server"
pagination={pagination}
onPaginationChange={onPaginationChange}
pageSizeOptions={LOGS_PAGE_SIZE_OPTIONS}
rowCount={rowCount}
filterMode="server"
columnFilters={columnFilters}

View file

@ -12,9 +12,6 @@ export const ERROR_CODE_OPTIONS: { label: string; value: string }[] = [
{ label: "529 - Overloaded", value: "529" },
];
/** Page sizes the logs tables offer; the first entry is the default. */
export const LOGS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
/** Call types that represent MCP tool invocations (shared across columns, index, drawer). */
export const MCP_CALL_TYPES = ["call_mcp_tool", "list_mcp_tools"];

View file

@ -21141,6 +21141,8 @@ export interface paths {
* modelId: Return a single deployment by LiteLLM model id.
* teamId: Filter to models with direct access or team membership for this team id.
* sortBy / sortOrder: Sort by model_name, created_at, updated_at, costs, or status.
* access_group: Only return deployments in this model access group.
* wildcard_only: Only return deployments whose `model_name` contains `*`.
*
* Example request:
* ```
@ -66182,6 +66184,10 @@ export interface operations {
sortOrder?: string | null;
/** @description Omit auto-router deployments (litellm model prefixed `auto_router/`). They select among deployments rather than being deployments themselves, so a caller rendering a deployment list can leave them out. Defaults to false, so existing callers are unaffected */
exclude_auto_routers?: boolean | null;
/** @description Only return deployments whose `model_info.access_groups` contains this access group */
access_group?: string | null;
/** @description Only return wildcard deployments, i.e. those whose `model_name` contains `*` */
wildcard_only?: boolean | null;
};
header?: never;
path?: never;