diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 6819b2851f5..229d6c797c2 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -126,11 +126,6 @@ "count": 2 } }, - "src/app/(dashboard)/budgets/_components/budget_panel.test.tsx": { - "unused-imports/no-unused-imports": { - "count": 2 - } - }, "src/app/(dashboard)/budgets/_components/budget_panel.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx index 2b97bcbc072..0c485adf4f2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx @@ -1,22 +1,58 @@ -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "@/../tests/test-utils"; +import { renderWithProviders, testQueryClient } from "@/../tests/test-utils"; import BudgetTable from "./BudgetTable"; -import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import type { ResourceListResult } from "@/app/(dashboard)/hooks/common/useResourceList"; +import { ApiError } from "@/lib/http/client"; + +const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() })); + +vi.mock("@/utils/dataUtils", async (importOriginal) => ({ + ...(await importOriginal()), + copyToClipboard: copyToClipboardMock, +})); const makeBudget = (overrides: Partial = {}): budgetItem => ({ budget_id: "budget-1", max_budget: 100, + soft_budget: null, tpm_limit: 1000, rpm_limit: 10, + budget_duration: "30d", + budget_reset_at: null, + created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", ...overrides, }); -const defaultProps = { - budgets: [makeBudget()], +const makeList = (overrides: Partial> = {}): ResourceListResult => ({ + rows: [makeBudget()], + rowCount: 1, isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + sorting: [{ id: "created_at", desc: true }], + onSortingChange: vi.fn(), + pagination: { pageIndex: 0, pageSize: 50 }, + onPaginationChange: vi.fn(), + columnFilters: [], + onColumnFiltersChange: vi.fn(), + searchValue: "", + onSearchChange: vi.fn(), + ...overrides, +}); + +const FORBIDDEN_PROBLEM = { + type: "about:blank", + title: "Forbidden", + status: 403, + detail: "Only proxy admins can view budgets", +}; + +const defaultProps = { canModify: true, onEditClick: vi.fn(), onDeleteClick: vi.fn(), @@ -25,72 +61,134 @@ const defaultProps = { describe("BudgetTable", () => { beforeEach(() => { vi.clearAllMocks(); + testQueryClient.clear(); }); it("should display budget information", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("budget-1")).toBeInTheDocument(); expect(screen.getByText("$100.00")).toBeInTheDocument(); expect(screen.getByText("1000")).toBeInTheDocument(); expect(screen.getByText("10")).toBeInTheDocument(); }); - it("should render the budget id without a fixed character-count clamp", () => { + it("should render the reset column with the friendly duration label", () => { + renderWithProviders(); + expect(screen.getByText("monthly")).toBeInTheDocument(); + }); + + it("should render 'Not set' when a budget has no reset duration", () => { + const list = makeList({ rows: [makeBudget({ budget_duration: null })] }); + renderWithProviders(); + expect(screen.getByText("Not set")).toBeInTheDocument(); + }); + + it("should render the budget id in full, with no truncation", () => { const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d"; - renderWithProviders(); + const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] }); + renderWithProviders(); const idCell = screen.getByText(budgetId); + expect(idCell.className).not.toContain("truncate"); expect(idCell.className).not.toMatch(/max-w-\[\d+(ch|rem|px)\]/); - expect(idCell.className).toContain("max-w-full"); - expect(idCell.className).toContain("truncate"); + }); + + it("should keep the budget id on a single line", () => { + const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d"; + const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] }); + renderWithProviders(); + expect(screen.getByText(budgetId).className).toContain("whitespace-nowrap"); + }); + + it("should copy the budget id from the cell's copy button", async () => { + const user = userEvent.setup(); + const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d"; + const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: "Copy ID" })); + expect(copyToClipboardMock).toHaveBeenCalledWith(budgetId); + }); + + it("should offer sorting on every backend-sortable column", async () => { + renderWithProviders(); + for (const field of ["budget_id", "max_budget", "tpm_limit", "rpm_limit", "created_at"]) { + expect(screen.getByTestId(`sort-header-${field}`)).toBeInTheDocument(); + } + }); + + it("should not make the reset column sortable", () => { + renderWithProviders(); + expect(screen.queryByTestId("sort-header-budget_duration")).not.toBeInTheDocument(); + expect(screen.getByText("Reset")).toBeInTheDocument(); + }); + + it("should ask the list for a new sort when a sortable header is clicked", async () => { + const user = userEvent.setup(); + const onSortingChange = vi.fn(); + renderWithProviders(); + await user.click(screen.getByTestId("sort-header-max_budget")); + expect(onSortingChange).toHaveBeenCalled(); }); it("should show n/a for missing rate limits and Unlimited for a missing max budget", () => { - renderWithProviders( - , - ); + const list = makeList({ rows: [makeBudget({ max_budget: null, tpm_limit: null, rpm_limit: null })] }); + renderWithProviders(); expect(screen.getAllByText("n/a")).toHaveLength(2); expect(screen.getByText("Unlimited")).toBeInTheDocument(); }); - it("should sort budgets by updated_at descending", () => { - const budgets = [ - makeBudget({ budget_id: "budget-old", updated_at: "2024-01-01T00:00:00Z" }), - makeBudget({ budget_id: "budget-new", updated_at: "2024-06-01T00:00:00Z" }), - ]; - renderWithProviders(); - const rows = screen.getAllByRole("row").slice(1); - expect(within(rows[0]).getByText("budget-new")).toBeInTheDocument(); - expect(within(rows[1]).getByText("budget-old")).toBeInTheDocument(); - }); - it("should call onEditClick from the actions menu", async () => { const user = userEvent.setup(); - renderWithProviders(); + const list = makeList(); + renderWithProviders(); await user.click(screen.getByTestId("budget-actions-budget-1")); await user.click(await screen.findByTestId("budget-action-edit")); - expect(defaultProps.onEditClick).toHaveBeenCalledWith(defaultProps.budgets[0]); + expect(defaultProps.onEditClick).toHaveBeenCalledWith(list.rows[0]); }); it("should call onDeleteClick from the actions menu", async () => { const user = userEvent.setup(); - renderWithProviders(); + const list = makeList(); + renderWithProviders(); await user.click(screen.getByTestId("budget-actions-budget-1")); await user.click(await screen.findByTestId("budget-action-delete")); - expect(defaultProps.onDeleteClick).toHaveBeenCalledWith(defaultProps.budgets[0]); + expect(defaultProps.onDeleteClick).toHaveBeenCalledWith(list.rows[0]); }); it("should not render the actions menu when the user cannot modify budgets", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.queryByTestId("budget-actions-budget-1")).not.toBeInTheDocument(); }); it("should show skeleton rows when loading", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); }); it("should show the empty state when there are no budgets", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("No budgets yet")).toBeInTheDocument(); }); + + it("should tell the user their search matched nothing rather than that no budgets exist", () => { + const list = makeList({ rows: [], rowCount: 0, searchValue: "nope" }); + renderWithProviders(); + expect(screen.getByText("No matching budgets")).toBeInTheDocument(); + }); + + it("should render an access-denied state for a 403 instead of an empty table", () => { + const error = new ApiError("Only proxy admins can view budgets", 403, FORBIDDEN_PROBLEM); + const list = makeList({ rows: [], rowCount: 0, error }); + const { container } = renderWithProviders(); + expect(screen.getByText("You do not have access to budgets")).toBeInTheDocument(); + expect(screen.queryByText("No budgets yet")).not.toBeInTheDocument(); + expect(container.querySelector(".lucide-shield-alert")).not.toBeNull(); + }); + + it("should surface the problem detail for a non-403 failure", () => { + const error = new ApiError("budget store unavailable", 500, null); + const list = makeList({ rows: [], rowCount: 0, error }); + renderWithProviders(); + expect(screen.getByText("Could not load budgets")).toBeInTheDocument(); + expect(screen.getByText("budget store unavailable")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx index 4bc06425f80..76355c874f8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx @@ -1,55 +1,269 @@ "use client"; -import { Inbox } from "lucide-react"; -import React, { useMemo } from "react"; +import { Inbox, ShieldAlert } from "lucide-react"; +import React, { useMemo, useState } from "react"; -import { DataTable } from "@/components/shared/DataTable"; -import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import { + BUDGET_DURATION_FILTER_OPTIONS, + BUDGET_DURATION_UNSET, + type CreatedAtFilterValue, + type MaxBudgetFilterValue, +} from "@/app/(dashboard)/hooks/budgets/budgetFilters"; +import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import type { ResourceListResult } from "@/app/(dashboard)/hooks/common/useResourceList"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableToolbar, + type FilterDraft, +} from "@/components/shared/DataTable"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ApiError } from "@/lib/http/client"; import { getBudgetTableColumns } from "./BudgetTableColumns"; interface BudgetTableProps { - budgets: budgetItem[]; - isLoading: boolean; + list: ResourceListResult; canModify: boolean; onEditClick: (budget: budgetItem) => void; onDeleteClick: (budget: budgetItem) => void; } -function EmptyState() { +const PAGE_SIZE_OPTIONS = [25, 50, 100]; + +const FILTER_LABELS: Record = { + budget_duration: "Reset", + max_budget: "Max Budget", + created_at: "Created", +}; + +const durationLabel = (value: string): string => + BUDGET_DURATION_FILTER_OPTIONS.find((option) => option.value === value)?.label ?? value; + +const formatFilterValue = (columnId: string, value: unknown): string => { + if (columnId === "budget_duration") { + return (Array.isArray(value) ? value : []).map((entry) => durationLabel(String(entry))).join(", "); + } + if (columnId === "max_budget") { + const { min, max, unlimitedOnly } = (value ?? {}) as MaxBudgetFilterValue; + return unlimitedOnly === true ? "Unlimited only" : `${min ? `$${min}` : "any"} to ${max ? `$${max}` : "any"}`; + } + if (columnId === "created_at") { + const { from, to } = (value ?? {}) as CreatedAtFilterValue; + return `${from || "any"} to ${to || "any"}`; + } + return String(value); +}; + +/** The drawer keeps any non-empty object as an active filter, so collapse a blank draft to nothing. */ +const normalizeMaxBudget = (draft: MaxBudgetFilterValue): MaxBudgetFilterValue | undefined => { + if (draft.unlimitedOnly === true) { + return { unlimitedOnly: true }; + } + const min = draft.min?.trim() ?? ""; + const max = draft.max?.trim() ?? ""; + if (min === "" && max === "") { + return undefined; + } + return { ...(min === "" ? {} : { min }), ...(max === "" ? {} : { max }) }; +}; + +const normalizeCreatedAt = (draft: CreatedAtFilterValue): CreatedAtFilterValue | undefined => { + const from = draft.from ?? ""; + const to = draft.to ?? ""; + if (from === "" && to === "") { + return undefined; + } + return { ...(from === "" ? {} : { from }), ...(to === "" ? {} : { to }) }; +}; + +function EmptyState({ hasQuery }: { hasQuery: boolean }) { return (
-
No budgets yet
+
{hasQuery ? "No matching budgets" : "No budgets yet"}
- Create a budget to set spend, TPM and RPM limits for customers. + {hasQuery + ? "No budget matches your search or filters." + : "Create a budget to set spend, TPM and RPM limits for customers."}
); } -const BudgetTable: React.FC = ({ budgets, isLoading, canModify, onEditClick, onDeleteClick }) => { - const rows = useMemo( - () => [...budgets].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()), - [budgets], +function ErrorState({ error }: { error: Error }) { + const forbidden = error instanceof ApiError && error.status === 403; + return ( +
+
+ +
+
+ {forbidden ? "You do not have access to budgets" : "Could not load budgets"} +
+
+ {forbidden ? "Ask a proxy admin to grant you the admin viewer role." : error.message} +
+
); +} + +/** "Not set" and the concrete durations are exclusive; see serializeBudgetFilters for why. */ +function DurationFilter({ selected, onChange }: { selected: string[]; onChange: (selected: string[]) => void }) { + const toggle = (value: string, checked: boolean): void => { + if (!checked) { + onChange(selected.filter((entry) => entry !== value)); + return; + } + const kept = value === BUDGET_DURATION_UNSET ? [] : selected.filter((entry) => entry !== BUDGET_DURATION_UNSET); + onChange([...kept, value]); + }; + + return ( +
+ {BUDGET_DURATION_FILTER_OPTIONS.map((option) => ( + + ))} +
+ ); +} + +function BudgetFilterFields({ get, set }: FilterDraft) { + const maxBudget = (get("max_budget") as MaxBudgetFilterValue | undefined) ?? {}; + const created = (get("created_at") as CreatedAtFilterValue | undefined) ?? {}; + const unlimitedOnly = maxBudget.unlimitedOnly === true; + + return ( + <> + + set("budget_duration", selected)} + /> + + +
+ set("max_budget", normalizeMaxBudget({ ...maxBudget, min: event.target.value }))} + placeholder="Min" + aria-label="Minimum max budget" + data-testid="budget-filter-max-budget-min" + /> + set("max_budget", normalizeMaxBudget({ ...maxBudget, max: event.target.value }))} + placeholder="Max" + aria-label="Maximum max budget" + data-testid="budget-filter-max-budget-max" + /> +
+ +
+ +
+ set("created_at", normalizeCreatedAt({ ...created, from: event.target.value }))} + aria-label="Created from" + data-testid="budget-filter-created-from" + /> + set("created_at", normalizeCreatedAt({ ...created, to: event.target.value }))} + aria-label="Created to" + data-testid="budget-filter-created-to" + /> +
+
+ + ); +} + +const BudgetTable: React.FC = ({ list, canModify, onEditClick, onDeleteClick }) => { + const [filtersOpen, setFiltersOpen] = useState(false); const columns = useMemo( () => getBudgetTableColumns({ canModify, onEditClick, onDeleteClick }), [canModify, onEditClick, onDeleteClick], ); + const hasQuery = list.searchValue.trim() !== "" || list.columnFilters.length > 0; + const emptyMessage = list.error === null ? : ; + return ( budget.budget_id || String(index)} - isLoading={isLoading} + sortingMode="server" + sorting={list.sorting} + onSortingChange={list.onSortingChange} + paginationMode="server" + pagination={list.pagination} + onPaginationChange={list.onPaginationChange} + rowCount={list.rowCount} + pageSizeOptions={PAGE_SIZE_OPTIONS} + filterMode="server" + columnFilters={list.columnFilters} + onColumnFiltersChange={list.onColumnFiltersChange} + isLoading={list.isLoading} loadingMessage="Loading budgets…" - noDataMessage={} + noDataMessage={emptyMessage} size="compact" + toolbar={(table) => ( + <> + setFiltersOpen(true)} + onRefresh={list.refetch} + isRefreshing={list.isFetching} + filterLabels={FILTER_LABELS} + formatFilterValue={formatFilterValue} + /> + + {(draft) => } + + + )} /> ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx index e3fbc9dba08..8cca2caf214 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx @@ -1,11 +1,13 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; +import { ColumnDef, FilterFn } from "@tanstack/react-table"; import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"; -import { IdCell, MoneyCell } from "@/components/shared/table_cells"; -import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; +import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; import { buttonVariants } from "@/components/ui/button"; +import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown"; import { DropdownMenu, DropdownMenuContent, @@ -15,6 +17,15 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/cva.config"; +/** + * Filtering happens on the server, so this never runs as a predicate. It exists to override + * TanStack's auto-remove heuristic, which infers a filter shape from the column's first cell + * and silently discards a filter whose value is not that shape (a range object on a numeric + * column, for instance). + */ +const serverFilter: FilterFn = () => true; +serverFilter.autoRemove = () => false; + function RateLimitCell({ value }: { value: number | null }) { if (value == null) { return n/a; @@ -22,6 +33,13 @@ function RateLimitCell({ value }: { value: number | null }) { return {value}; } +function BudgetDurationCell({ value }: { value: string | null }) { + if (!value) { + return Not set; + } + return {getBudgetDurationLabel(value)}; +} + interface BudgetRowActionsProps { budget: budgetItem; onEditClick: (budget: budgetItem) => void; @@ -72,38 +90,56 @@ export const getBudgetTableColumns = ({ id: "budget_id", accessorKey: "budget_id", meta: { title: "Budget ID" }, - header: "Budget ID", - size: 220, - enableSorting: false, - cell: ({ row }) => , + header: ({ column }) => , + cell: ({ row }) => ( + + ), }, { id: "max_budget", accessorKey: "max_budget", + filterFn: serverFilter, meta: { title: "Max Budget", numeric: true }, - header: "Max Budget", + header: ({ column }) => , size: 120, - enableSorting: false, cell: ({ row }) => , }, { id: "tpm_limit", accessorKey: "tpm_limit", meta: { title: "TPM", numeric: true }, - header: "TPM", + header: ({ column }) => , size: 100, - enableSorting: false, cell: ({ row }) => , }, { id: "rpm_limit", accessorKey: "rpm_limit", meta: { title: "RPM", numeric: true }, - header: "RPM", + header: ({ column }) => , size: 100, - enableSorting: false, cell: ({ row }) => , }, + { + id: "budget_duration", + accessorKey: "budget_duration", + filterFn: serverFilter, + meta: { title: "Reset" }, + // "7d"/"30d" sort lexicographically, not chronologically, so the route does not offer it. + enableSorting: false, + header: ({ column }) => , + size: 110, + cell: ({ row }) => , + }, + { + id: "created_at", + accessorKey: "created_at", + filterFn: serverFilter, + meta: { title: "Created" }, + header: ({ column }) => , + size: 160, + cell: ({ row }) => , + }, ...(canModify ? [ { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx index 392616f1935..46f72cd8886 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx @@ -1,217 +1,254 @@ -import { fireEvent, render, waitFor, screen } from "@testing-library/react"; -import { act } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiError } from "@/lib/http/client"; + import BudgetPanel from "./budget_panel"; -const mockBudgets = [ - { - budget_id: "budget-1", - max_budget: 100, - rpm_limit: 10, - tpm_limit: 1000, - updated_at: "2024-01-01T00:00:00Z", - }, -]; - -vi.mock("@/app/(dashboard)/hooks/budgets/useBudgets", () => ({ - useBudgets: vi.fn().mockReturnValue({ data: [], isLoading: false }), - useDeleteBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn(), isPending: false }), - useCreateBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn() }), - useUpdateBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn() }), +const { getMock, budgetDeleteMock } = vi.hoisted(() => ({ + getMock: vi.fn(), + budgetDeleteMock: vi.fn(), })); -import { - useBudgets, - useDeleteBudget, - useCreateBudget, - useUpdateBudget, -} from "@/app/(dashboard)/hooks/budgets/useBudgets"; +vi.mock("@/components/networking", () => ({ + apiClient: { get: getMock }, + budgetCreateCall: vi.fn(), + budgetUpdateCall: vi.fn(), + budgetDeleteCall: budgetDeleteMock, + getProxyBaseUrl: () => "", +})); -const createQueryClient = () => - new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, - }); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "sk-test", userRole: "Admin", userId: "u1" }), +})); -function renderWithProviders(ui: React.ReactElement) { - const qc = createQueryClient(); - return render({ui}); +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), info: vi.fn(), fromBackend: vi.fn() }, +})); + +interface BudgetSeed { + budget_id: string; + max_budget: number | null; + budget_duration: string | null; } +const budgetRow = (seed: BudgetSeed) => ({ + soft_budget: null, + tpm_limit: 1000, + rpm_limit: 10, + budget_reset_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...seed, +}); + +const FORBIDDEN_PROBLEM = { + type: "about:blank", + title: "Forbidden", + status: 403, + detail: "Only proxy admins can view budgets", +}; + +const DEFAULT_ROWS = [ + budgetRow({ budget_id: "ecc1869c-6231-4380-a56d-1a0be457477d", max_budget: 100, budget_duration: "30d" }), +]; + +const respondWith = (rows: ReturnType[], totalCount: number) => { + getMock.mockResolvedValue({ + data: rows, + meta: { total_count: totalCount, page: 1, page_size: 50, total_pages: Math.ceil(totalCount / 50) }, + }); +}; + +type QueryRecord = Record; + +const queries = (): QueryRecord[] => getMock.mock.calls.map((call) => (call[1] as { query: QueryRecord }).query); +const lastQuery = (): QueryRecord => queries()[queries().length - 1]; +const paths = (): string[] => getMock.mock.calls.map((call) => String(call[0])); + +const renderPanel = () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + , + ); +}; + +const openFilters = async (user: ReturnType) => { + await user.click(screen.getByTestId("datatable-filters-trigger")); + await screen.findByTestId("filter-drawer-body"); +}; + describe("Budget Panel", () => { - afterEach(() => { + beforeEach(() => { vi.clearAllMocks(); + respondWith(DEFAULT_ROWS, 1); }); - it("should render the budget panel and load budgets", async () => { - vi.mocked(useBudgets).mockReturnValue({ - data: mockBudgets, - isLoading: false, - } as any); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Create a budget to assign to customers.")).toBeInTheDocument(); - expect(screen.getByText("budget-1")).toBeInTheDocument(); - }); + it("loads the first page of budgets, newest first", async () => { + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + expect(paths()[0]).toBe("/management/v1/budgets"); + expect(queries()[0]).toEqual({ page: 1, page_size: 50, sort: "-created_at" }); + expect(await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d")).toBeInTheDocument(); }); - it("should open delete modal from the actions menu", async () => { + it("asks the server to sort when a sortable header is clicked", async () => { const user = userEvent.setup(); - vi.mocked(useBudgets).mockReturnValue({ - data: [ - { - budget_id: "budget-to-delete", - max_budget: 200, - rpm_limit: 20, - tpm_limit: 2000, - updated_at: "2024-01-02T00:00:00Z", - }, - ], - isLoading: false, - } as any); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); - renderWithProviders(); + await user.click(screen.getByTestId("sort-header-max_budget")); + await waitFor(() => expect(lastQuery().sort).toBe("-max_budget")); - await waitFor(() => { - expect(screen.getByText("budget-to-delete")).toBeInTheDocument(); - }); + await user.click(screen.getByTestId("sort-header-max_budget")); + await waitFor(() => expect(lastQuery().sort).toBe("max_budget")); - await user.click(screen.getByTestId("budget-actions-budget-to-delete")); + await user.click(screen.getByTestId("sort-header-budget_id")); + await waitFor(() => expect(lastQuery().sort).toBe("budget_id")); + }); + + it("searches on budget_id with a debounced q", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await user.type(screen.getByTestId("datatable-search"), "ecc"); + await waitFor(() => expect(lastQuery().q).toBe("ecc")); + expect(queries().some((query) => query.q === "e" || query.q === "ec")).toBe(false); + }); + + it("filters by reset duration and clears it again", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await openFilters(user); + await user.click(screen.getByTestId("budget-filter-duration-7d")); + await user.click(screen.getByTestId("budget-filter-duration-30d")); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastQuery()["filter[budget_duration][in]"]).toBe("7d,30d")); + + await user.click(screen.getByTestId("filter-chip-remove-budget_duration")); + await waitFor(() => expect(lastQuery()).not.toHaveProperty("filter[budget_duration][in]")); + }); + + it("filters by budgets with no reset duration", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await openFilters(user); + await user.click(screen.getByTestId("budget-filter-duration-__unset__")); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastQuery()["filter[budget_duration][is_null]"]).toBe("true")); + expect(lastQuery()).not.toHaveProperty("filter[budget_duration][in]"); + }); + + it("filters by a max budget range and clears it again", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await openFilters(user); + await user.type(screen.getByTestId("budget-filter-max-budget-min"), "10"); + await user.type(screen.getByTestId("budget-filter-max-budget-max"), "500"); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastQuery()["filter[max_budget][gte]"]).toBe("10")); + expect(lastQuery()["filter[max_budget][lte]"]).toBe("500"); + + await user.click(screen.getByTestId("datatable-clear-filters")); + await waitFor(() => expect(lastQuery()).not.toHaveProperty("filter[max_budget][gte]")); + expect(lastQuery()).not.toHaveProperty("filter[max_budget][lte]"); + }); + + it("filters to unlimited budgets only", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await openFilters(user); + await user.type(screen.getByTestId("budget-filter-max-budget-min"), "10"); + await user.click(screen.getByTestId("budget-filter-max-budget-unlimited")); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastQuery()["filter[max_budget][is_null]"]).toBe("true")); + expect(lastQuery()).not.toHaveProperty("filter[max_budget][gte]"); + }); + + it("filters by a created date range covering whole local days", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await openFilters(user); + await user.type(screen.getByTestId("budget-filter-created-from"), "2026-01-05"); + await user.type(screen.getByTestId("budget-filter-created-to"), "2026-01-06"); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => + expect(lastQuery()["filter[created_at][gte]"]).toBe(new Date("2026-01-05T00:00:00.000").toISOString()), + ); + expect(lastQuery()["filter[created_at][lte]"]).toBe(new Date("2026-01-06T23:59:59.999").toISOString()); + }); + + it("pages through the results and changes page size", async () => { + const user = userEvent.setup(); + respondWith(DEFAULT_ROWS, 400); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await user.click(screen.getByTestId("pagination-next")); + await waitFor(() => expect(lastQuery().page).toBe(2)); + expect(lastQuery().page_size).toBe(50); + + await user.click(screen.getByTestId("pagination-page-size")); + await user.click(await screen.findByRole("option", { name: "25" })); + await waitFor(() => expect(lastQuery().page_size).toBe(25)); + }); + + it("renders an access-denied state when the route rejects the caller", async () => { + getMock.mockRejectedValue(new ApiError("Only proxy admins can view budgets", 403, FORBIDDEN_PROBLEM)); + renderPanel(); + expect(await screen.findByText("You do not have access to budgets")).toBeInTheDocument(); + expect(screen.queryByText("No budgets yet")).not.toBeInTheDocument(); + }); + + it("deletes a budget from the actions menu", async () => { + const user = userEvent.setup(); + budgetDeleteMock.mockResolvedValue(undefined); + renderPanel(); + await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d"); + + await user.click(screen.getByTestId("budget-actions-ecc1869c-6231-4380-a56d-1a0be457477d")); await user.click(await screen.findByTestId("budget-action-delete")); + await screen.findByText("Delete Budget?"); + await user.click(screen.getByRole("button", { name: /^delete$/i })); - await waitFor(() => { - expect(screen.getByText("Delete Budget?")).toBeInTheDocument(); - }); + await waitFor(() => + expect(budgetDeleteMock).toHaveBeenCalledWith("sk-test", "ecc1869c-6231-4380-a56d-1a0be457477d"), + ); }); - it("should successfully delete a budget", async () => { + it("refetches the current page after a delete", async () => { const user = userEvent.setup(); - const deleteMutateAsync = vi.fn().mockResolvedValue(undefined); - vi.mocked(useBudgets).mockReturnValue({ - data: [ - { - budget_id: "budget-to-delete", - max_budget: 200, - rpm_limit: 20, - tpm_limit: 2000, - updated_at: "2024-01-02T00:00:00Z", - }, - ], - isLoading: false, - } as any); - vi.mocked(useDeleteBudget).mockReturnValue({ - mutateAsync: deleteMutateAsync, - isPending: false, - } as any); + budgetDeleteMock.mockResolvedValue(undefined); + renderPanel(); + await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d"); + const before = getMock.mock.calls.length; - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("budget-to-delete")).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId("budget-actions-budget-to-delete")); + await user.click(screen.getByTestId("budget-actions-ecc1869c-6231-4380-a56d-1a0be457477d")); await user.click(await screen.findByTestId("budget-action-delete")); + await screen.findByText("Delete Budget?"); + await user.click(screen.getByRole("button", { name: /^delete$/i })); - await waitFor(() => { - expect(screen.getByText("Delete Budget?")).toBeInTheDocument(); - }); - - const confirmButton = screen.getByRole("button", { name: /delete/i }); - act(() => { - fireEvent.click(confirmButton); - }); - - await waitFor(() => { - expect(deleteMutateAsync).toHaveBeenCalledWith("budget-to-delete"); - }); - }); - - it("should render empty state without crashing", async () => { - vi.mocked(useBudgets).mockReturnValue({ - data: [], - isLoading: false, - } as any); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Create a budget to assign to customers.")).toBeInTheDocument(); - }); - }); - - it("should handle delete error", async () => { - const user = userEvent.setup(); - const deleteMutateAsync = vi.fn().mockRejectedValue(new Error("Delete failed")); - vi.mocked(useBudgets).mockReturnValue({ - data: [ - { - budget_id: "budget-to-delete", - max_budget: 200, - rpm_limit: 20, - tpm_limit: 2000, - updated_at: "2024-01-02T00:00:00Z", - }, - ], - isLoading: false, - } as any); - vi.mocked(useDeleteBudget).mockReturnValue({ - mutateAsync: deleteMutateAsync, - isPending: false, - } as any); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("budget-to-delete")).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId("budget-actions-budget-to-delete")); - await user.click(await screen.findByTestId("budget-action-delete")); - - await waitFor(() => { - expect(screen.getByText("Delete Budget?")).toBeInTheDocument(); - }); - - const confirmButton = screen.getByRole("button", { name: /delete/i }); - act(() => { - fireEvent.click(confirmButton); - }); - - await waitFor(() => { - expect(deleteMutateAsync).toHaveBeenCalledWith("budget-to-delete"); - }); - }); - - it("should open edit modal from the actions menu", async () => { - const user = userEvent.setup(); - vi.mocked(useBudgets).mockReturnValue({ - data: [ - { - budget_id: "budget-to-edit", - max_budget: 300, - rpm_limit: 30, - tpm_limit: 3000, - updated_at: "2024-01-03T00:00:00Z", - }, - ], - isLoading: false, - } as any); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("budget-to-edit")).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId("budget-actions-budget-to-edit")); - await user.click(await screen.findByTestId("budget-action-edit")); - - await waitFor(() => { - expect(screen.getByText("Edit Budget")).toBeInTheDocument(); - }); + await waitFor(() => expect(getMock.mock.calls.length).toBeGreaterThan(before)); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx index 2cf2a4c06ec..78c2c0ca74a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx @@ -3,13 +3,13 @@ * */ -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; -import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import { useBudgetList, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; import BudgetModal from "./budget_modal"; import BudgetTable from "./BudgetTable"; import EditBudgetModal from "./edit_budget_modal"; @@ -31,21 +31,25 @@ const BudgetPanel: React.FC = ({ accessToken }) => { // Admin Viewer follows the read-parity rule: see budgets, no writes. const canModify = isProxyAdminRole(userRole ?? ""); - const { data: budgetList = [], isLoading } = useBudgets(); + const budgetList = useBudgetList(); const deleteBudget = useDeleteBudget(); - const handleEditCall = async (budget: budgetItem) => { - if (accessToken == null) { - return; - } - setSelectedBudget(budget); - setIsEditModalVisible(true); - }; + // Stable identities keep the memoized column defs stable; new ones remount every header and cell. + const handleEditCall = useCallback( + (budget: budgetItem) => { + if (accessToken == null) { + return; + } + setSelectedBudget(budget); + setIsEditModalVisible(true); + }, + [accessToken], + ); - const handleDeleteClick = (budget: budgetItem) => { + const handleDeleteClick = useCallback((budget: budgetItem) => { setSelectedBudget(budget); setIsDeleteModalVisible(true); - }; + }, []); const handleDeleteConfirm = async () => { if (!selectedBudget || accessToken == null) { @@ -99,8 +103,7 @@ const BudgetPanel: React.FC = ({ accessToken }) => { )}

Create a budget to assign to customers.

{ + it("sends nothing when no filter is active", () => { + expect(serializeBudgetFilters([])).toEqual({}); + }); + + it("maps selected durations onto the in operator", () => { + expect(serializeBudgetFilters([{ id: "budget_duration", value: ["7d", "30d"] }])).toEqual({ + "filter[budget_duration][in]": "7d,30d", + }); + }); + + it("maps 'Not set' onto is_null instead of in", () => { + expect(serializeBudgetFilters([{ id: "budget_duration", value: [BUDGET_DURATION_UNSET] }])).toEqual({ + "filter[budget_duration][is_null]": "true", + }); + }); + + it("never sends in alongside is_null for the same field", () => { + const params = serializeBudgetFilters([{ id: "budget_duration", value: ["7d", BUDGET_DURATION_UNSET] }]); + expect(params["filter[budget_duration][in]"]).toBeUndefined(); + expect(params["filter[budget_duration][is_null]"]).toBe("true"); + }); + + it("maps a max budget range onto gte and lte", () => { + expect(serializeBudgetFilters([{ id: "max_budget", value: { min: "10", max: "250.5" } }])).toEqual({ + "filter[max_budget][gte]": "10", + "filter[max_budget][lte]": "250.5", + }); + }); + + it("sends only the bound that was filled in", () => { + expect(serializeBudgetFilters([{ id: "max_budget", value: { min: "10", max: "" } }])).toEqual({ + "filter[max_budget][gte]": "10", + }); + }); + + it("maps 'Unlimited only' onto is_null and drops the range", () => { + const params = serializeBudgetFilters([{ id: "max_budget", value: { min: "10", unlimitedOnly: true } }]); + expect(params).toEqual({ "filter[max_budget][is_null]": "true" }); + }); + + it("widens a created-at day range to cover the whole local days", () => { + const params = serializeBudgetFilters([{ id: "created_at", value: { from: "2026-01-05", to: "2026-01-06" } }]); + expect(params["filter[created_at][gte]"]).toBe(new Date("2026-01-05T00:00:00.000").toISOString()); + expect(params["filter[created_at][lte]"]).toBe(new Date("2026-01-06T23:59:59.999").toISOString()); + }); + + it("ignores an unparseable date rather than sending a broken bound", () => { + expect(serializeBudgetFilters([{ id: "created_at", value: { from: "not-a-date" } }])).toEqual({}); + }); + + it("ignores filter ids the route does not declare", () => { + expect(serializeBudgetFilters([{ id: "spend", value: "5" }])).toEqual({}); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/budgetFilters.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/budgetFilters.ts new file mode 100644 index 00000000000..f54eddb3913 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/budgetFilters.ts @@ -0,0 +1,91 @@ +import type { ColumnFilter, ColumnFiltersState } from "@tanstack/react-table"; + +export const BUDGET_DURATION_UNSET = "__unset__"; + +export const BUDGET_DURATION_FILTER_OPTIONS: readonly { value: string; label: string }[] = [ + { value: "1h", label: "hourly" }, + { value: "24h", label: "daily" }, + { value: "7d", label: "weekly" }, + { value: "30d", label: "monthly" }, + { value: BUDGET_DURATION_UNSET, label: "Not set" }, +]; + +export interface MaxBudgetFilterValue { + min?: string; + max?: string; + unlimitedOnly?: boolean; +} + +export interface CreatedAtFilterValue { + from?: string; + to?: string; +} + +type QueryEntry = readonly [string, string]; + +const entries = (key: string, value: string): QueryEntry[] => (value === "" ? [] : [[key, value]]); + +const asStringArray = (value: unknown): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; + +const asRecord = (value: unknown): Record => + typeof value === "object" && value !== null ? (value as Record) : {}; + +const asTrimmed = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); + +/** The date inputs give a calendar day; the route wants an instant, so widen to the viewer's whole local day. */ +const isoAt = (day: string, time: string): string => { + if (day === "") { + return ""; + } + const parsed = new Date(`${day}T${time}`); + return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString(); +}; + +/** + * "Not set" is exclusive with the concrete durations. The route's contract does not say how it + * combines `in` with `is_null` on one field, and under AND semantics that pair can only match + * nothing, so we never send both. + */ +const durationParams = (value: unknown): QueryEntry[] => { + const selected = asStringArray(value); + if (selected.includes(BUDGET_DURATION_UNSET)) { + return [["filter[budget_duration][is_null]", "true"]]; + } + return entries("filter[budget_duration][in]", selected.join(",")); +}; + +const maxBudgetParams = (value: unknown): QueryEntry[] => { + const draft = asRecord(value); + if (draft.unlimitedOnly === true) { + return [["filter[max_budget][is_null]", "true"]]; + } + return [ + ...entries("filter[max_budget][gte]", asTrimmed(draft.min)), + ...entries("filter[max_budget][lte]", asTrimmed(draft.max)), + ]; +}; + +const createdAtParams = (value: unknown): QueryEntry[] => { + const draft = asRecord(value); + return [ + ...entries("filter[created_at][gte]", isoAt(asTrimmed(draft.from), "00:00:00.000")), + ...entries("filter[created_at][lte]", isoAt(asTrimmed(draft.to), "23:59:59.999")), + ]; +}; + +const filterParams = (filter: ColumnFilter): QueryEntry[] => { + switch (filter.id) { + case "budget_duration": + return durationParams(filter.value); + case "max_budget": + return maxBudgetParams(filter.value); + case "created_at": + return createdAtParams(filter.value); + default: + return []; + } +}; + +export const serializeBudgetFilters = (filters: ColumnFiltersState): Readonly> => + Object.fromEntries(filters.flatMap(filterParams)); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/useBudgets.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/useBudgets.ts index 0d8d94f2369..e5f24e5412d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/useBudgets.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/useBudgets.ts @@ -1,28 +1,58 @@ -import { useQuery, useMutation, useQueryClient, UseQueryResult } from "@tanstack/react-query"; -import { createQueryKeys } from "../common/queryKeysFactory"; -import { getBudgetList, budgetCreateCall, budgetUpdateCall, budgetDeleteCall } from "@/components/networking"; +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { SortingState } from "@tanstack/react-table"; +import { useCallback } from "react"; + import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { apiClient, budgetCreateCall, budgetUpdateCall, budgetDeleteCall } from "@/components/networking"; + +import { createQueryKeys } from "../common/queryKeysFactory"; +import { + useResourceList, + type ResourceListPage, + type ResourceListQuery, + type ResourceListResult, +} from "../common/useResourceList"; +import { serializeBudgetFilters } from "./budgetFilters"; export interface budgetItem { budget_id: string; max_budget: number | null; + soft_budget: number | null; rpm_limit: number | null; tpm_limit: number | null; + budget_duration: string | null; + budget_reset_at: string | null; + created_at: string; updated_at: string; } +export const BUDGET_LIST_PATH = "/management/v1/budgets"; + export const budgetKeys = createQueryKeys("budgets"); -export const useBudgets = (): UseQueryResult => { +const DEFAULT_PAGE_SIZE = 50; +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + +export const useBudgetList = (): ResourceListResult => { const { accessToken } = useAuthorized(); - return useQuery({ - queryKey: budgetKeys.list({}), - queryFn: async () => { - const data = await getBudgetList(accessToken!); - return (data ?? []).filter((item: budgetItem | null): item is budgetItem => item != null); - }, + + const fetchPage = useCallback( + (query: ResourceListQuery, signal: AbortSignal): Promise> => + apiClient.get>(BUDGET_LIST_PATH, { accessToken, query, signal }), + [accessToken], + ); + + const listOptions = { + queryKey: budgetKeys.lists(), + fetchPage, + serializeFilters: serializeBudgetFilters, + defaultSorting: DEFAULT_SORTING, + defaultPageSize: DEFAULT_PAGE_SIZE, enabled: Boolean(accessToken), - }); + }; + return useResourceList(listOptions); }; export const useCreateBudget = () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.test.tsx new file mode 100644 index 00000000000..3ca67b082ec --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.test.tsx @@ -0,0 +1,161 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ColumnFiltersState } from "@tanstack/react-table"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import React, { type PropsWithChildren } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + toSortParam, + useResourceList, + type ResourceListPage, + type ResourceListQuery, + type UseResourceListOptions, +} from "./useResourceList"; + +interface Row { + id: string; +} + +const page = (rows: Row[], totalCount: number): ResourceListPage => ({ + data: rows, + meta: { total_count: totalCount, page: 1, page_size: 50, total_pages: 1 }, +}); + +const noFilters = (): Readonly> => ({}); + +const calls: ResourceListQuery[] = []; + +const renderList = (overrides: Partial> = {}) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const fetchPage = vi.fn((query: ResourceListQuery) => { + calls.push(query); + return Promise.resolve(page([{ id: "a" }], 3)); + }); + const options: UseResourceListOptions = { + queryKey: ["widgets", "list"], + fetchPage, + serializeFilters: noFilters, + defaultSorting: [{ id: "created_at", desc: true }], + defaultPageSize: 50, + enabled: true, + ...overrides, + }; + return renderHook(() => useResourceList(options), { wrapper }); +}; + +const lastCall = (): ResourceListQuery => calls[calls.length - 1]; + +describe("toSortParam", () => { + it("prefixes descending fields with a minus and joins with commas", () => { + expect(toSortParam([{ id: "created_at", desc: true }])).toBe("-created_at"); + expect(toSortParam([{ id: "max_budget", desc: false }])).toBe("max_budget"); + expect( + toSortParam([ + { id: "a", desc: false }, + { id: "b", desc: true }, + ]), + ).toBe("a,-b"); + }); +}); + +describe("useResourceList", () => { + beforeEach(() => { + calls.length = 0; + }); + + it("requests the first page with the default sort", async () => { + const { result } = renderList(); + await waitFor(() => expect(result.current.rowCount).toBe(3)); + expect(lastCall()).toEqual({ page: 1, page_size: 50, sort: "-created_at" }); + }); + + it("exposes the returned rows and total count", async () => { + const { result } = renderList(); + await waitFor(() => expect(result.current.rows).toEqual([{ id: "a" }])); + expect(result.current.rowCount).toBe(3); + }); + + it("does not fetch while disabled", async () => { + const { result } = renderList({ enabled: false }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(calls).toHaveLength(0); + }); + + it("sends the new sort and returns to the first page", async () => { + const { result } = renderList(); + await waitFor(() => expect(calls).toHaveLength(1)); + + act(() => result.current.onPaginationChange({ pageIndex: 2, pageSize: 50 })); + await waitFor(() => expect(lastCall().page).toBe(3)); + + act(() => result.current.onSortingChange([{ id: "max_budget", desc: false }])); + await waitFor(() => expect(lastCall().sort).toBe("max_budget")); + expect(lastCall().page).toBe(1); + }); + + it("omits sort entirely when nothing is sorted", async () => { + const { result } = renderList({ defaultSorting: [] }); + await waitFor(() => expect(calls).toHaveLength(1)); + expect(result.current.sorting).toEqual([]); + expect(lastCall()).not.toHaveProperty("sort"); + }); + + it("debounces the search into a single trimmed q and returns to the first page", async () => { + const { result } = renderList(); + await waitFor(() => expect(calls).toHaveLength(1)); + + act(() => result.current.onPaginationChange({ pageIndex: 1, pageSize: 50 })); + await waitFor(() => expect(lastCall().page).toBe(2)); + + act(() => result.current.onSearchChange("bud")); + act(() => result.current.onSearchChange("budg ")); + + await waitFor(() => expect(lastCall().q).toBe("budg")); + expect(lastCall().page).toBe(1); + expect(calls.some((call) => call.q === "bud")).toBe(false); + }); + + it("stops sending q once the search box is cleared", async () => { + const { result } = renderList(); + act(() => result.current.onSearchChange("budget")); + await waitFor(() => expect(lastCall().q).toBe("budget")); + + act(() => result.current.onSearchChange("")); + await waitFor(() => expect(lastCall()).not.toHaveProperty("q")); + }); + + it("merges serialized filters into the request and returns to the first page", async () => { + const serializeFilters = (filters: ColumnFiltersState): Readonly> => + filters.length === 0 ? {} : { "filter[colour][in]": String(filters[0].value) }; + const { result } = renderList({ serializeFilters }); + await waitFor(() => expect(calls).toHaveLength(1)); + + act(() => result.current.onPaginationChange({ pageIndex: 3, pageSize: 50 })); + await waitFor(() => expect(lastCall().page).toBe(4)); + + act(() => result.current.onColumnFiltersChange([{ id: "colour", value: "red" }])); + await waitFor(() => expect(lastCall()["filter[colour][in]"]).toBe("red")); + expect(lastCall().page).toBe(1); + + act(() => result.current.onColumnFiltersChange([])); + await waitFor(() => expect(lastCall()).not.toHaveProperty("filter[colour][in]")); + }); + + it("sends the requested page size", async () => { + const { result } = renderList(); + await waitFor(() => expect(calls).toHaveLength(1)); + + act(() => result.current.onPaginationChange({ pageIndex: 0, pageSize: 25 })); + await waitFor(() => expect(lastCall().page_size).toBe(25)); + }); + + it("surfaces a failed page as an error instead of empty rows", async () => { + const fetchPage = vi.fn(() => Promise.reject(new Error("boom"))); + const { result } = renderList({ fetchPage }); + await waitFor(() => expect(result.current.error?.message).toBe("boom")); + expect(result.current.rows).toEqual([]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.ts new file mode 100644 index 00000000000..fb40d108234 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.ts @@ -0,0 +1,142 @@ +"use client"; + +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import { useCallback, useMemo, useState } from "react"; + +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; + +export type ResourceListQuery = Readonly>; + +export interface ResourceListMeta { + total_count: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface ResourceListPage { + data: TRow[]; + meta: ResourceListMeta; +} + +export interface UseResourceListOptions { + /** Prefix every list variant hangs off, so invalidating the resource root refetches whichever page is on screen. */ + queryKey: readonly unknown[]; + fetchPage: (query: ResourceListQuery, signal: AbortSignal) => Promise>; + /** Must be referentially stable; it feeds the query key. */ + serializeFilters: (filters: ColumnFiltersState) => Readonly>; + defaultSorting: SortingState; + defaultPageSize: number; + enabled: boolean; +} + +export interface ResourceListResult { + rows: TRow[]; + rowCount: number; + isLoading: boolean; + isFetching: boolean; + error: Error | null; + refetch: () => void; + + sorting: SortingState; + onSortingChange: OnChangeFn; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + columnFilters: ColumnFiltersState; + onColumnFiltersChange: OnChangeFn; + searchValue: string; + onSearchChange: (value: string) => void; +} + +/** JSON:API sort form: comma separated fields, `-` prefix for descending. */ +export const toSortParam = (sorting: SortingState): string => + sorting.map((entry) => (entry.desc ? `-${entry.id}` : entry.id)).join(","); + +/** + * State container for a table whose sorting, paging, search and filtering all run + * on the server. It owns those four pieces of state, folds them into one JSON:API + * query, and returns the exact props DataTable's server modes want. + * + * Empty parameters are dropped rather than sent blank because the management + * routes reject query params they do not declare. + */ +export function useResourceList(options: UseResourceListOptions): ResourceListResult { + const { queryKey, fetchPage, serializeFilters, defaultSorting, defaultPageSize, enabled } = options; + + const [sorting, setSorting] = useState(defaultSorting); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: defaultPageSize }); + const [columnFilters, setColumnFilters] = useState([]); + const [searchValue, setSearchValue] = useState(""); + const [debouncedSearch] = useDebouncedValue(searchValue, { wait: DEBOUNCE_WAIT_MS }); + + const query = useMemo(() => { + const sort = toSortParam(sorting); + const search = debouncedSearch.trim(); + return { + page: pagination.pageIndex + 1, + page_size: pagination.pageSize, + ...(sort === "" ? {} : { sort }), + ...(search === "" ? {} : { q: search }), + ...serializeFilters(columnFilters), + }; + }, [sorting, pagination.pageIndex, pagination.pageSize, debouncedSearch, columnFilters, serializeFilters]); + + const queryOptions: UseQueryOptions, Error, ResourceListPage, readonly unknown[]> = { + queryKey: [...queryKey, query], + queryFn: ({ signal }) => fetchPage(query, signal), + enabled, + placeholderData: (previous) => previous, + }; + const { data, isLoading, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions); + + const toFirstPage = useCallback(() => setPagination((previous) => ({ ...previous, pageIndex: 0 })), []); + + const onSortingChange = useCallback>( + (updater) => { + setSorting(updater); + toFirstPage(); + }, + [toFirstPage], + ); + + const onColumnFiltersChange = useCallback>( + (updater) => { + setColumnFilters(updater); + toFirstPage(); + }, + [toFirstPage], + ); + + const onSearchChange = useCallback( + (value: string) => { + setSearchValue(value); + toFirstPage(); + }, + [toFirstPage], + ); + + const refetch = useCallback(() => { + void refetchQuery(); + }, [refetchQuery]); + + const rows = useMemo(() => data?.data ?? [], [data]); + + return { + rows, + rowCount: data?.meta.total_count ?? 0, + isLoading, + isFetching, + error, + refetch, + sorting, + onSortingChange, + pagination, + onPaginationChange: setPagination, + columnFilters, + onColumnFiltersChange, + searchValue, + onSearchChange, + }; +}