From 99fa38504a5d90e792667909bb596fd4c9003272 Mon Sep 17 00:00:00 2001 From: yassin Date: Tue, 15 Sep 2026 21:55:07 +0000 Subject: [PATCH] fix(ui): bound Models table page, page size and sort_by read from the URL Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/AllModelsTab.test.tsx | 19 +++++++++-- .../components/AllModelsTab.tsx | 34 +++++++++++++------ .../components/ModelsTableColumns.tsx | 13 +++++++ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx index 7e45ff6834b..a5eb149e1f0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx @@ -317,13 +317,28 @@ describe("AllModelsTab", () => { expect(screen.queryByText(/To access these models/)).not.toBeInTheDocument(); }); - it("falls back to the first page and default size when the URL carries values the server rejects", () => { - renderWithProviders(, { searchParams: { page: "0", page_size: "-5" } }); + it("clamps a hand-edited page and page size into the range the table supports", () => { + renderWithProviders(, { searchParams: { page: "0", page_size: "5000" } }); expect(lastModelsInfoCall().page).toBe(1); + expect(lastModelsInfoCall().size).toBe(100); + }); + + it("keeps the default page size when the URL value is not a number", () => { + renderWithProviders(, { searchParams: { page_size: "lots" } }); + expect(lastModelsInfoCall().size).toBe(50); }); + it("ignores a sort_by the table cannot sort by instead of forwarding it to the server", () => { + renderWithProviders(, { + searchParams: { sort_by: "litellm_credential_name", sort_order: "desc" }, + }); + + expect(lastModelsInfoCall().sortBy).toBeUndefined(); + expect(lastModelsInfoCall().sortOrder).toBeUndefined(); + }); + it("writes sort changes to the URL with the page cleared", async () => { setModelsInfo([makeRow()], 200); const user = userEvent.setup(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index efe74a273d6..2217bca0fa0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -13,7 +13,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { ColumnFiltersState, functionalUpdate, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; import { Info } from "lucide-react"; -import { parseAsInteger, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs"; +import { createParser, parseAsInteger, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs"; import { useCallback, useMemo, useState } from "react"; import { useModelsInfo } from "../../hooks/models/useModels"; @@ -25,22 +25,39 @@ import { PERSONAL_TEAM_VALUE, WILDCARD_MODEL_GROUP_VALUE, } from "./AllModelsTable"; -import { ACCESS_GROUPS_COLUMN_ID, MODEL_NAME_COLUMN_ID, toServerSortField } from "./ModelsTableColumns"; +import { + ACCESS_GROUPS_COLUMN_ID, + isModelTableSortColumnId, + MODEL_NAME_COLUMN_ID, + MODEL_TABLE_SORT_COLUMN_IDS, + toServerSortField, +} from "./ModelsTableColumns"; const SEARCH_DEBOUNCE_WAIT_MS = 200; const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 100; +const MAX_PAGE = 100_000; const MODEL_VIEW_MODES = ["current_team", "all"] as const satisfies readonly ModelViewMode[]; +const boundedInteger = (min: number, max: number, fallback: number) => + createParser({ + parse: (value: string) => { + const parsed = parseAsInteger.parse(value); + return parsed === null ? null : Math.min(Math.max(parsed, min), max); + }, + serialize: String, + }).withDefault(fallback); + const TABLE_STATE = { model_search: parseAsString.withDefault(""), view_mode: parseAsStringLiteral(MODEL_VIEW_MODES).withDefault("current_team"), filter_team: parseAsString.withDefault(PERSONAL_TEAM_VALUE), access_group: parseAsString.withDefault(""), - sort_by: parseAsString.withDefault(""), + sort_by: parseAsStringLiteral(MODEL_TABLE_SORT_COLUMN_IDS), sort_order: parseAsStringLiteral(["asc", "desc"] as const).withDefault("asc"), - page: parseAsInteger.withDefault(1), - page_size: parseAsInteger.withDefault(DEFAULT_PAGE_SIZE), + page: boundedInteger(1, MAX_PAGE, 1), + page_size: boundedInteger(1, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE), }; interface AllModelsTabProps { @@ -72,10 +89,7 @@ const AllModelsTab = ({ const selectedTeamValue = tableState.filter_team; const selectedModelAccessGroupFilter = tableState.access_group || null; const pagination = useMemo( - () => ({ - pageIndex: Math.max(tableState.page, 1) - 1, - pageSize: tableState.page_size >= 1 ? tableState.page_size : DEFAULT_PAGE_SIZE, - }), + () => ({ pageIndex: tableState.page - 1, pageSize: tableState.page_size }), [tableState.page, tableState.page_size], ); const sorting = useMemo( @@ -177,7 +191,7 @@ const AllModelsTab = ({ const handleSortingChange: OnChangeFn = (updater) => { const active = functionalUpdate(updater, sorting)[0]; void setTableState({ - sort_by: active?.id ?? null, + sort_by: active && isModelTableSortColumnId(active.id) ? active.id : null, sort_order: active?.desc ? "desc" : null, page: null, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx index 0cc1207e547..c5bab598a8b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx @@ -24,6 +24,19 @@ export const TEAM_ID_COLUMN_ID = "model_info_team_id"; export const ACCESS_GROUPS_COLUMN_ID = "model_info_access_groups"; export const STATUS_COLUMN_ID = "model_info_db_model"; +export const MODEL_TABLE_SORT_COLUMN_IDS = [ + MODEL_NAME_COLUMN_ID, + CREATED_BY_COLUMN_ID, + UPDATED_AT_COLUMN_ID, + COSTS_COLUMN_ID, + STATUS_COLUMN_ID, +] as const; + +export type ModelTableSortColumnId = (typeof MODEL_TABLE_SORT_COLUMN_IDS)[number]; + +export const isModelTableSortColumnId = (columnId: string): columnId is ModelTableSortColumnId => + (MODEL_TABLE_SORT_COLUMN_IDS as readonly string[]).includes(columnId); + const COLUMN_ID_TO_SERVER_SORT_FIELD: Record = { [COSTS_COLUMN_ID]: "costs", [STATUS_COLUMN_ID]: "status",