Merge pull request #39682 from BerriAI/litellm_lit_4738_per_user_usage_pagination

fix(ui): paginate per-user usage with the shared server-side DataTable footer
This commit is contained in:
ryan-crabbe-berri 2026-09-04 12:53:12 -07:00 committed by GitHub
commit d71fe43c1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 189 additions and 51 deletions

View file

@ -1,4 +1,5 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import PerUserUsage from "./per_user_usage";
import * as networking from "./networking";
@ -94,6 +95,157 @@ describe("PerUserUsage", () => {
expect(screen.getByText("u1")).toBeInTheDocument();
});
describe("server pagination", () => {
const TOTAL_USERS = 120;
const pageOfUsers = (page: number, pageSize: number, total: number): UserRow[] => {
const start = (page - 1) * pageSize;
const count = Math.max(0, Math.min(pageSize, total - start));
return Array.from({ length: count }, (_, index) => userRow(`user-${start + index + 1}`, "curl/8.0", 5));
};
const serveUsers = (total: number) => {
mockPerUserAnalyticsCall.mockImplementation(async (_token, page = 1, pageSize = 50) => ({
results: pageOfUsers(page, pageSize, total),
total_count: total,
page,
page_size: pageSize,
total_pages: Math.ceil(total / pageSize),
}));
};
beforeEach(() => {
serveUsers(TOTAL_USERS);
});
const lastCall = () => mockPerUserAnalyticsCall.mock.calls[mockPerUserAnalyticsCall.mock.calls.length - 1];
it("renders every row the server returns and shows the range from total_count", async () => {
render(<PerUserUsage {...defaultProps} />);
expect(await screen.findByText("user-50")).toBeInTheDocument();
expect(screen.getByText("user-1")).toBeInTheDocument();
expect(screen.getAllByRole("row")).toHaveLength(51);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 120");
expect(screen.getByTestId("pagination-prev")).toBeDisabled();
expect(screen.getByTestId("pagination-next")).toBeEnabled();
});
it("refetches the next page when Next is clicked", async () => {
const user = userEvent.setup();
render(<PerUserUsage {...defaultProps} />);
await screen.findByText("user-1");
await user.click(screen.getByTestId("pagination-next"));
expect(await screen.findByText("user-51")).toBeInTheDocument();
expect(lastCall()).toEqual(["test-token", 2, 50, undefined]);
expect(screen.queryByText("user-1")).not.toBeInTheDocument();
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 51-100 of 120");
expect(screen.getByTestId("pagination-prev")).toBeEnabled();
});
it("disables Next once the response says this is the last page", async () => {
const user = userEvent.setup();
render(<PerUserUsage {...defaultProps} />);
await screen.findByText("user-1");
await user.click(screen.getByTestId("pagination-last"));
expect(await screen.findByText("user-120")).toBeInTheDocument();
expect(lastCall()).toEqual(["test-token", 3, 50, undefined]);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 101-120 of 120");
expect(screen.getByTestId("pagination-next")).toBeDisabled();
});
it("falls back to the last existing page when the data shrinks under the current page", async () => {
const user = userEvent.setup();
render(<PerUserUsage {...defaultProps} />);
await screen.findByText("user-1");
await user.click(screen.getByTestId("pagination-next"));
await screen.findByText("user-51");
serveUsers(60);
await user.click(screen.getByTestId("pagination-next"));
expect(await screen.findByText("user-60")).toBeInTheDocument();
expect(mockPerUserAnalyticsCall.mock.calls.slice(-2)).toEqual([
["test-token", 3, 50, undefined],
["test-token", 2, 50, undefined],
]);
expect(screen.getAllByRole("row")).toHaveLength(11);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 51-60 of 60");
expect(screen.getByTestId("pagination-next")).toBeDisabled();
});
it("goes back to the first page when the data disappears under the current page", async () => {
const user = userEvent.setup();
render(<PerUserUsage {...defaultProps} />);
await screen.findByText("user-1");
await user.click(screen.getByTestId("pagination-next"));
await screen.findByText("user-51");
serveUsers(0);
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => {
expect(lastCall()).toEqual(["test-token", 1, 50, undefined]);
});
expect(mockPerUserAnalyticsCall.mock.calls.slice(-2)).toEqual([
["test-token", 3, 50, undefined],
["test-token", 1, 50, undefined],
]);
expect(screen.getByText("No per-user usage data")).toBeInTheDocument();
expect(screen.getByTestId("pagination-range")).toHaveTextContent("No results");
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
expect(screen.getByTestId("pagination-first")).toBeDisabled();
expect(screen.getByTestId("pagination-prev")).toBeDisabled();
});
it("refetches with the selected page size and goes back to the first page", async () => {
const user = userEvent.setup();
render(<PerUserUsage {...defaultProps} />);
await screen.findByText("user-1");
await user.click(screen.getByTestId("pagination-next"));
await screen.findByText("user-51");
await user.click(screen.getByTestId("pagination-page-size"));
await user.click(await screen.findByRole("option", { name: "100" }));
expect(await screen.findByText("user-100")).toBeInTheDocument();
expect(lastCall()).toEqual(["test-token", 1, 100, undefined]);
expect(screen.getAllByRole("row")).toHaveLength(101);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-100 of 120");
});
it("goes back to the first page when the tag filter changes", async () => {
const user = userEvent.setup();
const { rerender } = render(<PerUserUsage {...defaultProps} />);
await screen.findByText("user-1");
await user.click(screen.getByTestId("pagination-next"));
await screen.findByText("user-51");
const callsBeforeTagChange = mockPerUserAnalyticsCall.mock.calls.length;
rerender(<PerUserUsage {...defaultProps} selectedTags={["curl/8.0"]} />);
await waitFor(() => {
expect(lastCall()).toEqual(["test-token", 1, 50, ["curl/8.0"]]);
});
expect(mockPerUserAnalyticsCall.mock.calls.slice(callsBeforeTagChange)).toEqual([
["test-token", 1, 50, ["curl/8.0"]],
]);
expect(await screen.findByText("user-1")).toBeInTheDocument();
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 120");
});
it("does not request anything without an access token", () => {
render(<PerUserUsage {...defaultProps} accessToken={null} />);
expect(mockPerUserAnalyticsCall).not.toHaveBeenCalled();
expect(screen.getByText("No per-user usage data")).toBeInTheDocument();
});
});
it("renders the usage distribution as a stacked bar chart with the explicit palette and users formatter", async () => {
render(<PerUserUsage {...defaultProps} />);

View file

@ -1,8 +1,7 @@
import React, { useState, useEffect } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import React, { useState, useEffect, useCallback } from "react";
import type { ColumnDef, OnChangeFn, PaginationState } from "@tanstack/react-table";
import { BarChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { perUserAnalyticsCall } from "./networking";
@ -42,39 +41,41 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
total_pages: 0,
});
const [currentPage, setCurrentPage] = useState(1);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 50 });
const [pagedTags, setPagedTags] = useState(selectedTags);
const fetchPerUserData = async () => {
if (!accessToken) return;
try {
const response = await perUserAnalyticsCall(
accessToken,
currentPage,
50,
selectedTags.length > 0 ? selectedTags : undefined,
);
setPerUserData(response);
} catch (error) {
console.error("Failed to fetch per-user data:", error);
}
};
if (pagedTags !== selectedTags) {
setPagedTags(selectedTags);
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
}
useEffect(() => {
fetchPerUserData();
}, [accessToken, selectedTags, currentPage]);
if (!accessToken) return;
const handleNextPage = () => {
if (currentPage < perUserData.total_pages) {
setCurrentPage(currentPage + 1);
}
};
let stale = false;
perUserAnalyticsCall(
accessToken,
pagination.pageIndex + 1,
pagination.pageSize,
pagedTags.length > 0 ? pagedTags : undefined,
)
.then((response) => {
if (stale) return;
setPerUserData(response);
})
.catch((error) => console.error("Failed to fetch per-user data:", error));
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
return () => {
stale = true;
};
}, [accessToken, pagedTags, pagination]);
const handlePaginationChange = useCallback<OnChangeFn<PaginationState>>((updaterOrValue) => {
setPagination((prev) => {
const next = typeof updaterOrValue === "function" ? updaterOrValue(prev) : updaterOrValue;
return next.pageSize === prev.pageSize ? next : { pageIndex: 0, pageSize: next.pageSize };
});
}, []);
const columns: ColumnDef<PerUserMetrics>[] = [
{
@ -137,30 +138,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={handlePaginationChange}
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 */}