mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
parent
a53c550951
commit
9ba6cab889
40 changed files with 462 additions and 102 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export default function AccessGroupBudgetsPanel() {
|
|||
|
||||
<DataTable
|
||||
data={accessGroups ?? []}
|
||||
paginationMode="client"
|
||||
columns={columns}
|
||||
getRowId={(group) => group.access_group}
|
||||
sortingMode="client"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
|||
return (
|
||||
<DataTable
|
||||
data={attachments}
|
||||
paginationMode="client"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.attachment_id}
|
||||
sortingMode="client"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({
|
|||
return (
|
||||
<DataTable
|
||||
data={groups}
|
||||
paginationMode="client"
|
||||
columns={columns}
|
||||
getRowId={(group) => group.group_name}
|
||||
sortingMode="client"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
6
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
6
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue