mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
commit
d71fe43c1a
2 changed files with 189 additions and 51 deletions
|
|
@ -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} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue