diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx index f460b77c2c7..e15e76fc50c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx @@ -1,5 +1,6 @@ import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils"; @@ -95,7 +96,7 @@ const DEPLOYMENTS = [ }, ]; -const pageOf = (data: typeof DEPLOYMENTS) => ({ +const pageOf = (data: readonly unknown[]) => ({ data, total_count: data.length, current_page: 1, @@ -127,6 +128,12 @@ const A_FULL_PAGE_AND_TWO_MORE = Array.from({ length: 12 }, (_, index) => ({ }, })); +const JUST_CREATED_ROUTER = { + ...A_FULL_PAGE_AND_TWO_MORE[0], + model_name: "router-13-just-created", + model_info: { id: "bulk-13", db_model: true, created_at: "2026-08-13T00:00:00.000000+00:00" }, +}; + /** Row order as rendered, header row dropped. */ const routerNamesInOrder = () => screen @@ -134,7 +141,7 @@ const routerNamesInOrder = () => .slice(1) .map((row) => row.querySelector("span.text-sm.font-medium")?.textContent ?? ""); -const renderPanel = (canModify = true) => +const renderPanel = (canModify = true, url: { searchParams?: string; onUrlUpdate?: OnUrlUpdateFunction } = {}) => renderWithProviders( teams={null} createScope={canModify ? "unscoped-ok" : "forbidden"} />, + url, ); +const lastUrl = (onUrlUpdate: ReturnType>) => { + const lastCall = onUrlUpdate.mock.calls.at(-1); + if (!lastCall) throw new Error("expected a URL update"); + return lastCall[0].searchParams; +}; + describe("AutoRoutersPanel", () => { beforeEach(() => { // The shared test client caches with staleTime: Infinity and refetchOnMount: false, so @@ -301,6 +315,16 @@ describe("AutoRoutersPanel", () => { ]); }); + it("breaks created-at ties by name whatever order the proxy returns them in", async () => { + modelInfoCall.mockResolvedValue(pageOf([...DEPLOYMENTS].reverse())); + + renderPanel(); + + await screen.findByText("tri-tier-router"); + + expect(routerNamesInOrder()).toEqual(["tri-tier-router", "support-router", "adaptive-router", "config-router"]); + }); + // The reported bug: the newest router was rendered last, so it landed on page 2 and read // as never created. it("puts a just-created router on the first page of a list longer than one page", async () => { @@ -313,4 +337,61 @@ describe("AutoRoutersPanel", () => { expect(screen.queryByRole("button", { name: "router-01-oldest" })).not.toBeInTheDocument(); expect(routerNamesInOrder()[0]).toBe("router-12-newest"); }); + + describe("URL state", () => { + it("opens on the page named in the URL once the routers load", async () => { + modelInfoCall.mockResolvedValue(pageOf(A_FULL_PAGE_AND_TWO_MORE)); + + renderPanel(true, { searchParams: "?auto_routers_page=2" }); + + expect(await screen.findByRole("button", { name: "router-01-oldest" })).toBeInTheDocument(); + expect(routerNamesInOrder()).toEqual(["router-2", "router-01-oldest"]); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2"); + }); + + it("returns to the first page after a create so the new router is in view", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + modelInfoCall.mockResolvedValue(pageOf(A_FULL_PAGE_AND_TWO_MORE)); + renderPanel(true, { searchParams: "?auto_routers_page=2", onUrlUpdate }); + + await screen.findByRole("button", { name: "router-01-oldest" }); + modelInfoCall.mockResolvedValue(pageOf([...A_FULL_PAGE_AND_TWO_MORE, JUST_CREATED_ROUTER])); + + await user.click(screen.getByRole("button", { name: "Add Auto Router" })); + await user.click(await screen.findByRole("button", { name: "Submit auto router" })); + + expect(await screen.findByRole("button", { name: "router-13-just-created" })).toBeInTheDocument(); + await waitFor(() => expect(lastUrl(onUrlUpdate).has("auto_routers_page")).toBe(false)); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2"); + }); + + it("sorts by the column and direction named in the URL", async () => { + renderPanel(true, { searchParams: "?auto_routers_sort_by=name&auto_routers_sort_order=asc" }); + + await screen.findByText("tri-tier-router"); + + expect(routerNamesInOrder()).toEqual(["adaptive-router", "config-router", "support-router", "tri-tier-router"]); + }); + + it("writes the page and the sort column to the URL", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + modelInfoCall.mockResolvedValue(pageOf(A_FULL_PAGE_AND_TWO_MORE)); + renderPanel(true, { onUrlUpdate }); + + await screen.findByRole("button", { name: "router-12-newest" }); + await user.click(screen.getByTestId("pagination-next")); + + await waitFor(() => expect(lastUrl(onUrlUpdate).get("auto_routers_page")).toBe("2")); + expect(routerNamesInOrder()).toEqual(["router-2", "router-01-oldest"]); + + await user.click(screen.getByTestId("sort-header-name")); + + await waitFor(() => expect(lastUrl(onUrlUpdate).get("auto_routers_sort_by")).toBe("name")); + expect(lastUrl(onUrlUpdate).get("auto_routers_sort_order")).toBe("asc"); + expect(lastUrl(onUrlUpdate).get("auto_routers_page")).toBeNull(); + expect(routerNamesInOrder()[0]).toBe("router-01-oldest"); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx index 607b676d304..c242eb8e532 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx @@ -14,7 +14,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { type ModelWriteScope } from "@/utils/modelPermissions"; import { Team } from "@/components/networking"; -import { AutoRoutersTable } from "./AutoRoutersTable"; +import { AutoRoutersTable, useAutoRoutersTableState } from "./AutoRoutersTable"; import { AutoRouterRow, toAutoRouterRows } from "./autoRouterRows"; interface AutoRoutersPanelProps { @@ -42,6 +42,7 @@ export function AutoRoutersPanel({ // router gets the full ModelInfoView: Model Settings, Edit Settings, Edit Auto Router and // Delete. A separate detail view here would be a worse copy of it. const { openModel } = useModelDetailRouting(); + const { onPaginationChange } = useAutoRoutersTableState(); const [isCreating, setIsCreating] = useState(false); const [deletingRouter, setDeletingRouter] = useState(null); const [isDeleting, setIsDeleting] = useState(false); @@ -53,6 +54,7 @@ export function AutoRoutersPanel({ const handleCreated = () => { setIsCreating(false); + onPaginationChange((page) => ({ ...page, pageIndex: 0 })); void invalidateAutoRouters(); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx index 2102f5e55d9..2894e90fb5c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx @@ -1,9 +1,13 @@ "use client"; -import { SortingState } from "@tanstack/react-table"; import { useMemo } from "react"; -import { DataTable } from "@/components/shared/DataTable"; +import { + DataTable, + useUrlTableState, + type UrlTableState, + type UrlTableStateOptions, +} from "@/components/shared/DataTable"; import { AutoRouterIcon } from "@/components/shared/table_cells"; import { getAutoRoutersTableColumns } from "./AutoRoutersTableColumns"; @@ -19,10 +23,20 @@ interface AutoRoutersTableProps { const PAGE_SIZE_OPTIONS = [10, 25, 50]; -const DEFAULT_SORTING: SortingState = [ - { id: "createdAt", desc: true }, - { id: "name", desc: false }, -]; +const TABLE_STATE_OPTIONS: UrlTableStateOptions = { + sortFields: ["name", "createdAt"], + defaultSort: { id: "createdAt", desc: true }, + defaultPageSize: PAGE_SIZE_OPTIONS[0], + maxPageSize: Math.max(...PAGE_SIZE_OPTIONS), + filterColumns: [], + keyPrefix: "auto_routers_", +}; + +const byName = (a: AutoRouterRow, b: AutoRouterRow) => a.name.localeCompare(b.name); + +export function useAutoRoutersTableState(): UrlTableState { + return useUrlTableState(TABLE_STATE_OPTIONS); +} function EmptyState({ canModify }: { canModify: boolean }) { return ( @@ -47,19 +61,24 @@ export function AutoRoutersTable({ onRouterClick, onDeleteClick, }: AutoRoutersTableProps) { + const { sorting, onSortingChange, pagination, onPaginationChange } = useAutoRoutersTableState(); const columns = useMemo( () => getAutoRoutersTableColumns({ canModify, onRouterClick, onDeleteClick }), [canModify, onRouterClick, onDeleteClick], ); + const routersByName = useMemo(() => [...routers].sort(byName), [routers]); return ( router.id} sortingMode="client" - defaultSorting={DEFAULT_SORTING} + sorting={sorting} + onSortingChange={onSortingChange} paginationMode="client" + pagination={pagination} + onPaginationChange={onPaginationChange} pageSizeOptions={PAGE_SIZE_OPTIONS} isLoading={isLoading} loadingMessage="Loading auto routers…" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx index 4d0818987ce..4b9d88eb8a6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx @@ -1,9 +1,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, renderWithProviders, screen, waitFor, within } from "@/../tests/test-utils"; + const { GET, PUT, DELETE, userRole } = vi.hoisted(() => ({ GET: vi.fn(), PUT: vi.fn(), @@ -40,15 +42,22 @@ const FREE_GROUP = { budget: null, }; -const renderPanel = () => { +const renderPanel = (url: { searchParams?: string; onUrlUpdate?: OnUrlUpdateFunction } = {}) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( + return renderWithProviders( , + url, ); }; +const groupNamesInOrder = () => + screen + .getAllByRole("row") + .slice(1) + .map((row) => within(row).getAllByRole("cell")[0].textContent); + const openActions = async (accessGroup: string) => { await userEvent.click(await screen.findByTestId(`access-group-actions-${accessGroup}`)); }; @@ -162,4 +171,68 @@ describe("AccessGroupBudgetsPanel", () => { }), ); }); + + describe("URL table state", () => { + const manyGroups = Array.from({ length: 27 }, (_, index) => ({ + ...FREE_GROUP, + access_group: `group-${String(index + 1).padStart(2, "0")}`, + })); + + it("sorts by the column and direction named in the URL", async () => { + renderPanel({ searchParams: "?ag_budgets_sort_by=spend&ag_budgets_sort_order=asc" }); + + await screen.findByText("premium"); + + expect(groupNamesInOrder()).toEqual(["shared", "premium"]); + }); + + it("sorts by deployment count when the URL names it", async () => { + GET.mockResolvedValue({ + data: { + access_groups: [BUDGETED_GROUP, FREE_GROUP, { ...FREE_GROUP, access_group: "a-many", deployment_count: 3 }], + }, + }); + + renderPanel({ searchParams: "?ag_budgets_sort_by=deployment_count&ag_budgets_sort_order=asc" }); + + await screen.findByText("a-many"); + + expect(groupNamesInOrder()).toEqual(["premium", "shared", "a-many"]); + }); + + it("writes a header sort to the URL", async () => { + const onUrlUpdate = vi.fn(); + renderPanel({ onUrlUpdate }); + await screen.findByText("premium"); + + await userEvent.click(screen.getByTestId("sort-header-access_group")); + + await waitFor(() => + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("ag_budgets_sort_order")).toBe("desc"), + ); + expect(groupNamesInOrder()).toEqual(["shared", "premium"]); + }); + + it("opens on the page named in the URL once the groups load", async () => { + GET.mockResolvedValue({ data: { access_groups: manyGroups } }); + + renderPanel({ searchParams: "?ag_budgets_page=2" }); + + expect(await screen.findByText("group-27")).toBeInTheDocument(); + expect(groupNamesInOrder()).toEqual(["group-26", "group-27"]); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2"); + }); + + it("writes a page change to the URL", async () => { + GET.mockResolvedValue({ data: { access_groups: manyGroups } }); + const onUrlUpdate = vi.fn(); + renderPanel({ onUrlUpdate }); + await screen.findByText("group-01"); + + await userEvent.click(screen.getByTestId("pagination-next")); + + await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("ag_budgets_page")).toBe("2")); + expect(groupNamesInOrder()).toEqual(["group-26", "group-27"]); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx index 20eb7b2e64c..f4bfaca0576 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx @@ -1,11 +1,15 @@ "use client"; -import { SortingState } from "@tanstack/react-table"; import { Inbox } from "lucide-react"; import React, { useMemo, useState } from "react"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; -import { DataTable } from "@/components/shared/DataTable"; +import { + DataTable, + DEFAULT_PAGE_SIZE_OPTIONS, + useUrlTableState, + type UrlTableStateOptions, +} from "@/components/shared/DataTable"; import { toast } from "@/lib/toast"; import { isProxyAdminRole } from "@/utils/roles"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; @@ -18,7 +22,13 @@ import { import AccessGroupBudgetModal from "@/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal"; import { getAccessGroupBudgetColumns } from "@/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns"; -const DEFAULT_SORTING: SortingState = [{ id: "access_group", desc: false }]; +const TABLE_STATE_OPTIONS: UrlTableStateOptions = { + sortFields: ["access_group", "deployment_count", "spend"], + defaultSort: { id: "access_group", desc: false }, + defaultPageSize: DEFAULT_PAGE_SIZE_OPTIONS[0], + filterColumns: [], + keyPrefix: "ag_budgets_", +}; function EmptyState() { return ( @@ -40,7 +50,7 @@ export default function AccessGroupBudgetsPanel() { const setBudget = useSetModelAccessGroupBudget(); const clearBudget = useDeleteModelAccessGroupBudget(); - const [sorting, setSorting] = useState(DEFAULT_SORTING); + const { sorting, onSortingChange, pagination, onPaginationChange } = useUrlTableState(TABLE_STATE_OPTIONS); const [editing, setEditing] = useState(null); const [clearing, setClearing] = useState(null); @@ -85,11 +95,13 @@ export default function AccessGroupBudgetsPanel() { group.access_group} sortingMode="client" sorting={sorting} - onSortingChange={setSorting} + onSortingChange={onSortingChange} isLoading={isLoading} loadingMessage="Loading model access groups…" noDataMessage={} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.integration.test.tsx new file mode 100644 index 00000000000..7d3e076fae9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.integration.test.tsx @@ -0,0 +1,82 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { act, render, screen, waitFor } from "@/../tests/test-utils"; + +import HealthStatusPanel from "./HealthStatusPanel"; + +const { modelInfoCall, latestHealthChecksCall } = vi.hoisted(() => ({ + modelInfoCall: vi.fn(), + latestHealthChecksCall: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ + modelInfoCall, + latestHealthChecksCall, + individualModelHealthCheckCall: vi.fn(), + modelHubCall: vi.fn(), + modelAvailableCall: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "sk-test", userId: "u-admin", userRole: "Admin" }), +})); +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) })); +vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: () => ({ data: {} }) })); + +const PAGE_ARG = 3; +const TEN_MODELS_FIRST_PAGE = { data: [], total_count: 10, current_page: 1, total_pages: 1, size: 50 }; + +const renderPanel = (searchParams: string, onUrlUpdate: OnUrlUpdateFunction) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); + return queryClient; +}; + +const flushUrlUpdates = () => act(() => new Promise((resolve) => setTimeout(resolve, 50))); + +describe("HealthStatusPanel health_page", () => { + beforeEach(() => { + modelInfoCall.mockReset(); + latestHealthChecksCall.mockReset(); + latestHealthChecksCall.mockResolvedValue({ latest_health_checks: {} }); + }); + + it("snaps a page past the end back to the first page once the models load", async () => { + modelInfoCall.mockResolvedValue(TEN_MODELS_FIRST_PAGE); + const onUrlUpdate = vi.fn(); + + renderPanel("?health_page=3", onUrlUpdate); + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + expect(modelInfoCall.mock.calls[0][PAGE_ARG]).toBe(3); + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has("health_page")).toBe(false); + }); + + it("keeps the page in the URL when fetching it fails", async () => { + modelInfoCall.mockRejectedValue(new Error("proxy unavailable")); + const onUrlUpdate = vi.fn(); + + const queryClient = renderPanel("?health_page=3", onUrlUpdate); + + await waitFor(() => expect(queryClient.getQueryCache().getAll()[0]?.state.status).toBe("error")); + await flushUrlUpdates(); + + expect(modelInfoCall.mock.calls[0][PAGE_ARG]).toBe(3); + expect(onUrlUpdate).not.toHaveBeenCalled(); + expect(screen.queryByText("No models found")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.test.tsx index 56b7016a325..c35753ee384 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.test.tsx @@ -1,23 +1,38 @@ /* @vitest-environment jsdom */ -import { render } from "@testing-library/react"; -import { withNuqsTestingAdapter } from "nuqs/adapters/testing"; +import type { OnChangeFn, PaginationState } from "@tanstack/react-table"; +import { act, render, waitFor } from "@testing-library/react"; +import { type OnUrlUpdateFunction, withNuqsTestingAdapter } from "nuqs/adapters/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; import HealthStatusPanel from "./HealthStatusPanel"; -const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null); +interface CapturedHealthCheckProps { + all_models_on_proxy?: string[]; + pagination?: PaginationState; + onPaginationChange?: OnChangeFn; +} + +const mockHealthCheckComponent = vi.fn((_props: CapturedHealthCheckProps) => null); vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({ - default: (props: { all_models_on_proxy?: string[] }) => { + default: (props: CapturedHealthCheckProps) => { mockHealthCheckComponent(props); return null; }, })); +const lastHealthCheckProps = (): CapturedHealthCheckProps => { + const lastCall = mockHealthCheckComponent.mock.calls.at(-1); + if (!lastCall) throw new Error("HealthCheckComponent was not rendered"); + return lastCall[0]; +}; + vi.mock("@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer", () => ({ transformModelData: () => ({ data: [] }), })); const mockUseModelsInfo = vi.fn(); -vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useModelsInfo: () => mockUseModelsInfo() })); +vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ + useModelsInfo: (...args: unknown[]) => mockUseModelsInfo(...args), +})); vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: () => ({ data: {} }) })); vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) })); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "123" }) })); @@ -25,6 +40,31 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ acce describe("HealthStatusPanel", () => { beforeEach(() => { mockHealthCheckComponent.mockClear(); + mockUseModelsInfo.mockReset(); + mockUseModelsInfo.mockReturnValue({ data: undefined, isLoading: true }); + }); + + it("fetches and shows the page and page size named in the URL", () => { + render(, { + wrapper: withNuqsTestingAdapter({ searchParams: "?health_page=3&health_page_size=25" }), + }); + + expect(mockUseModelsInfo).toHaveBeenLastCalledWith(3, 25); + expect(lastHealthCheckProps().pagination).toEqual({ pageIndex: 2, pageSize: 25 }); + }); + + it("writes table page changes to the URL and refetches that page", async () => { + const onUrlUpdate = vi.fn(); + render(, { wrapper: withNuqsTestingAdapter({ onUrlUpdate, hasMemory: true }) }); + expect(mockUseModelsInfo).toHaveBeenLastCalledWith(1, 50); + + act(() => lastHealthCheckProps().onPaginationChange?.({ pageIndex: 1, pageSize: 50 })); + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const params = onUrlUpdate.mock.calls.at(-1)?.[0].searchParams; + expect(params?.get("health_page")).toBe("2"); + expect(params?.get("health_page_size")).toBeNull(); + await waitFor(() => expect(mockUseModelsInfo).toHaveBeenLastCalledWith(2, 50)); }); it("passes deployment ids (not model names) to HealthCheckComponent as all_models_on_proxy", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.tsx index 451088c87d8..3154e32bb8c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel.tsx @@ -1,8 +1,8 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; -import type { PaginationState } from "@tanstack/react-table"; +import { useCallback, useMemo } from "react"; import HealthCheckComponent from "@/components/model_dashboard/HealthCheckComponent"; +import { useHealthTableUrlState } from "@/components/model_dashboard/useHealthTableUrlState"; import { getDisplayModelName } from "@/components/view_model/model_name_display"; import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels"; import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; @@ -11,15 +11,18 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { transformModelData } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer"; import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation"; -const HEALTH_PAGE_SIZE = 50; - export default function HealthStatusPanel() { const { accessToken } = useAuthorized(); const { data: teams } = useTeams(); const { data: modelCostMapData } = useModelCostMap(); const { openModel } = useModelDetailRouting(); - const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: HEALTH_PAGE_SIZE }); - const { data: healthModelDataResponse, isLoading } = useModelsInfo(pagination.pageIndex + 1, pagination.pageSize); + const { pagination, onPaginationChange } = useHealthTableUrlState(); + const { + data: healthModelDataResponse, + isLoading, + isError, + } = useModelsInfo(pagination.pageIndex + 1, pagination.pageSize); + const pageUnavailable = isLoading || (isError && !healthModelDataResponse); const getProviderFromModel = useCallback( (model: string) => { @@ -54,9 +57,9 @@ export default function HealthStatusPanel() { getDisplayModelName={getDisplayModelName} setSelectedModelId={openModel} teams={teams ?? null} - isLoading={isLoading} + isLoading={pageUnavailable} pagination={pagination} - onPaginationChange={setPagination} + onPaginationChange={onPaginationChange} rowCount={healthModelDataResponse?.total_count ?? 0} /> ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel.test.tsx new file mode 100644 index 00000000000..785bef5c799 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel.test.tsx @@ -0,0 +1,126 @@ +import userEvent from "@testing-library/user-event"; +import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { act, chooseSelectOption, render, screen, waitFor } from "@/../tests/test-utils"; + +import ModelRetrySettingsPanel from "./ModelRetrySettingsPanel"; + +const { getCallbacksCall, dashboardData } = vi.hoisted(() => ({ + getCallbacksCall: vi.fn(), + dashboardData: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ getCallbacksCall })); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "sk-test", userId: "u-admin", userRole: "Admin" }), +})); + +vi.mock("@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy", () => ({ + useUpdateRetryPolicy: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/app/(dashboard)/models-and-endpoints/useModelDashboardData", () => ({ + useModelDashboardData: () => dashboardData(), +})); + +const MODEL_GROUPS = ["claude-opus", "gpt-5"]; + +const loadedGroups = () => ({ availableModelGroups: MODEL_GROUPS, isLoading: false }); +const loadingGroups = () => ({ availableModelGroups: [], isLoading: true }); +const failedGroups = () => ({ availableModelGroups: [], isLoading: false }); + +const renderPanel = (searchParams = "", onUrlUpdate?: OnUrlUpdateFunction) => + render(, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); + +const lastUrl = (onUrlUpdate: ReturnType>) => { + const lastCall = onUrlUpdate.mock.calls.at(-1); + if (!lastCall) throw new Error("expected a URL update"); + return lastCall[0].searchParams; +}; + +describe("ModelRetrySettingsPanel retry_scope", () => { + beforeEach(() => { + getCallbacksCall.mockReset(); + getCallbacksCall.mockResolvedValue({ router_settings: { num_retries: 2 } }); + dashboardData.mockReset(); + dashboardData.mockImplementation(loadedGroups); + }); + + it("shows the global policy when the URL names no scope", () => { + renderPanel(); + + expect(screen.getByRole("heading", { name: "Global Retry Policy" })).toBeInTheDocument(); + }); + + it("shows the model group named in the URL", () => { + renderPanel("?retry_scope=gpt-5"); + + expect(screen.getByRole("heading", { name: "Retry Policy for gpt-5" })).toBeInTheDocument(); + }); + + it("keeps a model group scope in the URL while the model groups are still loading", async () => { + dashboardData.mockImplementation(loadingGroups); + const onUrlUpdate = vi.fn(); + const { rerender } = renderPanel("?retry_scope=gpt-5", onUrlUpdate); + + expect(screen.getByRole("heading", { name: "Retry Policy for gpt-5" })).toBeInTheDocument(); + + dashboardData.mockImplementation(loadedGroups); + rerender(); + + expect(screen.getByRole("heading", { name: "Retry Policy for gpt-5" })).toBeInTheDocument(); + await waitFor(() => expect(getCallbacksCall).toHaveBeenCalled()); + expect(onUrlUpdate).not.toHaveBeenCalled(); + }); + + it("keeps a model group scope in the URL when the model groups fail to load", async () => { + dashboardData.mockImplementation(failedGroups); + const onUrlUpdate = vi.fn(); + renderPanel("?retry_scope=gpt-5", onUrlUpdate); + + expect(screen.getByRole("heading", { name: "Retry Policy for gpt-5" })).toBeInTheDocument(); + await waitFor(() => expect(getCallbacksCall).toHaveBeenCalled()); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); + expect(onUrlUpdate).not.toHaveBeenCalled(); + }); + + it("falls back to the global policy and drops a model group that no longer exists", async () => { + const onUrlUpdate = vi.fn(); + renderPanel("?retry_scope=retired-model&tab=retry-settings", onUrlUpdate); + + expect(screen.getByRole("heading", { name: "Global Retry Policy" })).toBeInTheDocument(); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + expect(lastUrl(onUrlUpdate).has("retry_scope")).toBe(false); + expect(lastUrl(onUrlUpdate).get("tab")).toBe("retry-settings"); + }); + + it("writes the selected scope to the URL and clears it for the global default", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderPanel("", onUrlUpdate); + + await chooseSelectOption(user, screen.getByRole("combobox", { name: /retry policy scope/i }), "gpt-5"); + + await waitFor(() => expect(lastUrl(onUrlUpdate).get("retry_scope")).toBe("gpt-5")); + expect(screen.getByRole("heading", { name: "Retry Policy for gpt-5" })).toBeInTheDocument(); + + await chooseSelectOption(user, screen.getByRole("combobox", { name: /retry policy scope/i }), "Global Default"); + + await waitFor(() => expect(lastUrl(onUrlUpdate).has("retry_scope")).toBe(false)); + expect(screen.getByRole("heading", { name: "Global Retry Policy" })).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel.tsx index a5c10ca8a8b..23a9f9a3d42 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel.tsx @@ -1,12 +1,17 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { parseAsString, useQueryState } from "nuqs"; +import { useCallback, useEffect, useMemo, useState } from "react"; import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab"; import { getCallbacksCall } from "@/components/networking"; import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy"; import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { toast } from "@/lib/toast"; +import { useUrlTab } from "@/hooks/useUrlTab"; + +const GLOBAL_SCOPE = "global"; +const RETRY_SCOPE_KEY = "retry_scope"; interface RetryPolicyObject { [key: string]: { [retryPolicyKey: string]: number } | undefined; @@ -27,7 +32,13 @@ export default function ModelRetrySettingsPanel() { const { availableModelGroups } = useModelDashboardData(); const updateRetryPolicy = useUpdateRetryPolicy(accessToken); - const [retryScope, setRetryScope] = useState("global"); + const [requestedScope] = useQueryState(RETRY_SCOPE_KEY, parseAsString.withDefault(GLOBAL_SCOPE)); + const allowedScopes = useMemo( + () => [GLOBAL_SCOPE, ...(availableModelGroups.length > 0 ? availableModelGroups : [requestedScope])], + [requestedScope, availableModelGroups], + ); + const [retryScope, setRetryScope] = useUrlTab(allowedScopes, GLOBAL_SCOPE, RETRY_SCOPE_KEY); + const selectRetryScope = useCallback((scope: string | null) => setRetryScope(scope ?? GLOBAL_SCOPE), [setRetryScope]); const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState(null); const [globalRetryPolicy, setGlobalRetryPolicy] = useState(null); const [defaultRetry, setDefaultRetry] = useState(0); @@ -86,7 +97,7 @@ export default function ModelRetrySettingsPanel() { return ( { }); it("should render a row per endpoint with path and target", () => { - render(); + renderWithProviders(); expect(screen.getByText("/v1/rerank")).toBeInTheDocument(); expect(screen.getByText("https://api.cohere.com/v1/rerank")).toBeInTheDocument(); expect(screen.getByText("/bria")).toBeInTheDocument(); @@ -45,26 +47,26 @@ describe("PassThroughEndpointsTable", () => { it("should open the endpoint when its ID is clicked", async () => { const user = userEvent.setup(); const onEndpointClick = vi.fn(); - render(); + renderWithProviders(); await user.click(screen.getByRole("button", { name: "ep-1" })); expect(onEndpointClick).toHaveBeenCalledWith("ep-1"); }); it("should show method chips, or ALL when no methods are set", () => { - render(); + renderWithProviders(); expect(screen.getByText("POST")).toBeInTheDocument(); expect(screen.getByText("ALL")).toBeInTheDocument(); }); it("should show authentication as Yes or No", () => { - render(); + renderWithProviders(); expect(screen.getByText("Yes")).toBeInTheDocument(); expect(screen.getByText("No")).toBeInTheDocument(); }); it("should mask headers until the visibility toggle is clicked", async () => { const user = userEvent.setup(); - render(); + renderWithProviders(); expect(screen.queryByText(/secret-value/)).not.toBeInTheDocument(); const toggles = screen.getAllByRole("button", { name: "Show headers" }); @@ -76,7 +78,7 @@ describe("PassThroughEndpointsTable", () => { const user = userEvent.setup(); const onEndpointClick = vi.fn(); const onDeleteClick = vi.fn(); - render( + renderWithProviders( , ); @@ -100,7 +102,7 @@ describe("PassThroughEndpointsTable", () => { headers: {}, is_from_config: true, }; - render( + renderWithProviders( { it("should not show the config hint for DB endpoints", async () => { const user = userEvent.setup(); - render(); + renderWithProviders(); await user.click(screen.getByTestId("endpoint-actions-ep-1")); await screen.findByTestId("endpoint-action-delete"); @@ -143,7 +145,7 @@ describe("PassThroughEndpointsTable", () => { headers: {}, is_from_config: true, }; - render(); + renderWithProviders(); expect(screen.getByText("Config")).toBeInTheDocument(); expect(screen.getAllByText("DB")).toHaveLength(2); }); @@ -153,7 +155,7 @@ describe("PassThroughEndpointsTable", () => { const onEndpointClick = vi.fn(); const onDeleteClick = vi.fn(); const endpointWithoutId: passThroughItem = { path: "/legacy", target: "https://legacy.example.com", headers: {} }; - render( + renderWithProviders( { }); it("should show the empty state when there are no endpoints", () => { - render(); + renderWithProviders(); expect(screen.getByText("No pass-through endpoints configured")).toBeInTheDocument(); }); it("should show skeleton rows while loading", () => { - render(); + renderWithProviders(); expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); expect(screen.queryByText("No pass-through endpoints configured")).not.toBeInTheDocument(); }); + + describe("URL page state", () => { + const manyEndpoints: passThroughItem[] = Array.from({ length: 27 }, (_, index) => ({ + id: `ep-${index + 1}`, + path: `/route-${index + 1}`, + target: `https://upstream.example.com/${index + 1}`, + headers: {}, + })); + + it("keeps the page named in the URL once the endpoints finish loading", () => { + const { rerender } = renderWithProviders( + , + { searchParams: "?pass_through_page=2" }, + ); + + rerender(); + + expect(screen.getByText("/route-26")).toBeInTheDocument(); + expect(screen.getByText("/route-27")).toBeInTheDocument(); + expect(screen.queryByText("/route-1")).not.toBeInTheDocument(); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2"); + }); + + it("writes a page change to the URL", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); + + await user.click(screen.getByTestId("pagination-next")); + + await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("pass_through_page")).toBe("2")); + expect(screen.getByText("/route-26")).toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.tsx index 35f0bd4bc62..42605d7e16c 100644 --- a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.tsx +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.tsx @@ -3,7 +3,12 @@ import { Waypoints } from "lucide-react"; import { useMemo } from "react"; -import { DataTable } from "@/components/shared/DataTable"; +import { + DataTable, + DEFAULT_PAGE_SIZE_OPTIONS, + useUrlTableState, + type UrlTableStateOptions, +} from "@/components/shared/DataTable"; import { getPassThroughEndpointsTableColumns } from "./PassThroughEndpointsTableColumns"; import type { passThroughItem } from "./PassThroughSettings"; @@ -15,6 +20,14 @@ interface PassThroughEndpointsTableProps { onDeleteClick: (endpointId: string) => void; } +const TABLE_STATE_OPTIONS: UrlTableStateOptions = { + sortFields: [], + defaultSort: { id: "", desc: false }, + defaultPageSize: DEFAULT_PAGE_SIZE_OPTIONS[0], + filterColumns: [], + keyPrefix: "pass_through_", +}; + function EmptyState() { return (
@@ -33,6 +46,7 @@ export function PassThroughEndpointsTable({ onEndpointClick, onDeleteClick, }: PassThroughEndpointsTableProps) { + const { pagination, onPaginationChange } = useUrlTableState(TABLE_STATE_OPTIONS); const columns = useMemo( () => getPassThroughEndpointsTableColumns({ onEndpointClick, onDeleteClick }), [onEndpointClick, onDeleteClick], @@ -42,6 +56,8 @@ export function PassThroughEndpointsTable({ endpoint.id || endpoint.path || String(index)} isLoading={isLoading} diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.test.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.test.tsx index d2b1ea3913b..d20240516a6 100644 --- a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.test.tsx @@ -1,7 +1,8 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; import { deletePassThroughEndpointsCall, getPassThroughEndpointsCall } from "../networking"; import PassThroughSettings from "./PassThroughSettings"; import type { PassThroughEndpointsTable } from "./PassThroughEndpointsTable"; @@ -16,8 +17,21 @@ vi.mock("../add_pass_through", () => ({ })); vi.mock("../pass_through_info", () => ({ - default: ({ endpointData }: { endpointData: { id?: string } }) => ( -
{endpointData.id}
+ default: ({ + endpointData, + onClose, + isAdmin, + }: { + endpointData: { id?: string; path: string }; + onClose: () => void; + isAdmin: boolean; + }) => ( +
+ {endpointData.id} + +
), })); @@ -57,7 +71,7 @@ describe("PassThroughSettings", () => { }); it("should render nothing without an access token", () => { - const { container } = render(); + const { container } = renderWithProviders(); expect(container).toBeEmptyDOMElement(); }); @@ -69,7 +83,7 @@ describe("PassThroughSettings", () => { }), ); - render(); + renderWithProviders(); expect(screen.getByTestId("endpoints-table")).toHaveAttribute("data-loading", "true"); await act(async () => { @@ -82,7 +96,7 @@ describe("PassThroughSettings", () => { }); it("should resolve loading without fetching when the user id is missing", async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByTestId("endpoints-table")).toHaveAttribute("data-loading", "false"); @@ -92,7 +106,7 @@ describe("PassThroughSettings", () => { it("should swap to the endpoint info view when an endpoint is opened", async () => { const user = userEvent.setup(); - render(); + renderWithProviders(); await user.click(await screen.findByText("open-ep-1")); expect(screen.getByTestId("endpoint-info")).toHaveTextContent("ep-1"); @@ -101,7 +115,7 @@ describe("PassThroughSettings", () => { it("should confirm before deleting an endpoint", async () => { const user = userEvent.setup(); mockDeleteEndpoint.mockResolvedValue(undefined); - render(); + renderWithProviders(); await user.click(await screen.findByText("delete-ep-1")); expect(screen.getByText("Delete Pass-Through Endpoint")).toBeInTheDocument(); @@ -112,4 +126,112 @@ describe("PassThroughSettings", () => { expect(mockDeleteEndpoint).toHaveBeenCalledWith("token", "ep-1"); }); }); + + describe("endpoint in the URL", () => { + const lastUrlUpdate = (onUrlUpdate: ReturnType>) => { + const lastCall = onUrlUpdate.mock.calls.at(-1); + if (!lastCall) throw new Error("expected a URL update"); + return lastCall[0]; + }; + + it("opens the endpoint named in the URL once loaded, without flashing not found first", async () => { + let resolveEndpoints: (value: { endpoints: (typeof endpoint)[] }) => void = () => {}; + mockGetEndpoints.mockReturnValue( + new Promise((resolve) => { + resolveEndpoints = resolve; + }), + ); + + renderWithProviders(, { searchParams: "?endpoint=ep-1" }); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.queryByText("Endpoint not found")).not.toBeInTheDocument(); + expect(screen.queryByTestId("endpoints-table")).not.toBeInTheDocument(); + + await act(async () => { + resolveEndpoints({ endpoints: [endpoint] }); + }); + + expect(await screen.findByTestId("endpoint-info")).toHaveTextContent("ep-1"); + }); + + it("resolves a config endpoint without an id by its path", async () => { + const configEndpoint = { path: "/from-config", target: "https://config.example.com", headers: {} }; + mockGetEndpoints.mockResolvedValue({ endpoints: [endpoint, configEndpoint] }); + + renderWithProviders(, { searchParams: "?endpoint=%2Ffrom-config" }); + + expect(await screen.findByTestId("endpoint-info")).toHaveAttribute("data-path", "/from-config"); + }); + + it("says the endpoint is not found when the URL names one that does not exist, with a way back", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "?endpoint=ep-gone&endpoint_tab=settings", + onUrlUpdate, + }); + + expect(await screen.findByText("Endpoint not found")).toBeInTheDocument(); + expect(screen.queryByTestId("endpoint-info")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /back/i })); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate).searchParams.has("endpoint")).toBe(false)); + expect(lastUrlUpdate(onUrlUpdate).searchParams.has("endpoint_tab")).toBe(false); + expect(await screen.findByTestId("endpoints-table")).toBeInTheDocument(); + }); + + it("offers admins the edit view for a dashboard endpoint", async () => { + renderWithProviders(, { searchParams: "?endpoint=ep-1" }); + + expect(await screen.findByTestId("endpoint-info")).toHaveAttribute("data-admin", "true"); + }); + + it.each([ + ["a config endpoint without an id", "%2Ffrom-config", { path: "/from-config" }], + ["a config endpoint with an id", "cfg-1", { id: "cfg-1", path: "/cfg", is_from_config: true }], + ])("keeps admins read-only on %s, which the dashboard cannot edit", async (_label, param, configFields) => { + const configEndpoint = { target: "https://config.example.com", headers: {}, ...configFields }; + mockGetEndpoints.mockResolvedValue({ endpoints: [endpoint, configEndpoint] }); + + renderWithProviders(, { searchParams: `?endpoint=${param}` }); + + const info = await screen.findByTestId("endpoint-info"); + expect(info).toHaveAttribute("data-path", configEndpoint.path); + expect(info).toHaveAttribute("data-admin", "false"); + }); + + it("pushes the opened endpoint to the URL and drops a stale detail tab", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "?endpoint_tab=settings", + onUrlUpdate, + }); + + await user.click(await screen.findByText("open-ep-1")); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate).searchParams.get("endpoint")).toBe("ep-1")); + expect(lastUrlUpdate(onUrlUpdate).searchParams.has("endpoint_tab")).toBe(false); + expect(lastUrlUpdate(onUrlUpdate).options.history).toBe("push"); + expect(screen.getByTestId("endpoint-info")).toHaveTextContent("ep-1"); + }); + + it("clears the endpoint and its tab from the URL when the detail closes", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "?endpoint=ep-1&endpoint_tab=settings", + onUrlUpdate, + }); + + await user.click(await screen.findByRole("button", { name: "close-endpoint" })); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate).searchParams.has("endpoint")).toBe(false)); + expect(lastUrlUpdate(onUrlUpdate).searchParams.has("endpoint_tab")).toBe(false); + expect(lastUrlUpdate(onUrlUpdate).options.history).toBe("push"); + expect(screen.getByTestId("endpoints-table")).toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx index 8ef2766d412..d564eac5678 100644 --- a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx @@ -14,6 +14,7 @@ import AddPassThroughEndpoint from "../add_pass_through"; import PassThroughInfoView from "../pass_through_info"; import { toast } from "@/lib/toast"; import { PassThroughEndpointsTable } from "./PassThroughEndpointsTable"; +import { findPassThroughEndpoint, usePassThroughDetailRouting } from "./passThroughDetailRouting"; interface PassThroughSettingsProps { accessToken: string | null; @@ -40,7 +41,7 @@ export interface passThroughItem { const PassThroughSettings: React.FC = ({ accessToken, userRole, userID, premiumUser }) => { const [generalSettings, setGeneralSettings] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [selectedEndpointId, setSelectedEndpointId] = useState(null); + const { endpointKey, openEndpoint, closeEndpoint } = usePassThroughDetailRouting(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [endpointToDelete, setEndpointToDelete] = useState(null); @@ -103,19 +104,33 @@ const PassThroughSettings: React.FC = ({ accessToken, return null; } - if (selectedEndpointId) { - const selectedEndpoint = generalSettings.find((endpoint) => endpoint.id === selectedEndpointId); + if (endpointKey && isLoading) { + return
Loading...
; + } + + if (endpointKey) { + const selectedEndpoint = findPassThroughEndpoint(generalSettings, endpointKey); if (!selectedEndpoint) { - return
Endpoint not found
; + return ( +
+ +
Endpoint not found
+
+ ); } + const editableOnDashboard = Boolean(selectedEndpoint.id) && !selectedEndpoint.is_from_config; + return ( setSelectedEndpointId(null)} + onClose={closeEndpoint} accessToken={accessToken} - isAdmin={userRole === "Admin" || userRole === "admin"} + isAdmin={(userRole === "Admin" || userRole === "admin") && editableOnDashboard} premiumUser={premiumUser} onEndpointUpdated={handleEndpointUpdated} /> @@ -139,7 +154,7 @@ const PassThroughSettings: React.FC = ({ accessToken, diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/passThroughDetailRouting.ts b/ui/litellm-dashboard/src/components/PassThroughSettings/passThroughDetailRouting.ts new file mode 100644 index 00000000000..821364fad80 --- /dev/null +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/passThroughDetailRouting.ts @@ -0,0 +1,49 @@ +import { parseAsString, useQueryStates } from "nuqs"; +import { useCallback } from "react"; + +import { useUrlTab } from "@/hooks/useUrlTab"; + +const ENDPOINT_TAB_KEY = "endpoint_tab"; +const DETAIL_PARSERS = { endpoint: parseAsString, detailTab: parseAsString }; +const DETAIL_OPTIONS = { history: "push", urlKeys: { detailTab: ENDPOINT_TAB_KEY } } as const; + +const DETAIL_TABS = ["overview", "settings"] as const; +type PassThroughDetailTab = (typeof DETAIL_TABS)[number]; +const OVERVIEW_ONLY: readonly PassThroughDetailTab[] = ["overview"]; + +export interface PassThroughDetailRouting { + endpointKey: string | null; + openEndpoint: (endpointKey: string) => void; + closeEndpoint: () => void; +} + +export function usePassThroughDetailRouting(): PassThroughDetailRouting { + const [{ endpoint }, setParams] = useQueryStates(DETAIL_PARSERS, DETAIL_OPTIONS); + + const openEndpoint = useCallback( + (endpointKey: string) => { + void setParams({ endpoint: endpointKey, detailTab: null }); + }, + [setParams], + ); + + const closeEndpoint = useCallback(() => { + void setParams({ endpoint: null, detailTab: null }); + }, [setParams]); + + return { endpointKey: endpoint, openEndpoint, closeEndpoint }; +} + +export function usePassThroughDetailTab( + canViewSettings: boolean, +): [PassThroughDetailTab, (tab: PassThroughDetailTab) => void] { + return useUrlTab(canViewSettings ? DETAIL_TABS : OVERVIEW_ONLY, "overview", ENDPOINT_TAB_KEY); +} + +export function findPassThroughEndpoint( + endpoints: readonly T[], + endpointKey: string, +): T | undefined { + const byId = endpoints.find((endpoint) => endpoint.id === endpointKey); + return byId ?? endpoints.find((endpoint) => endpoint.path === endpointKey); +} diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx index e6138151391..6c43f88df0a 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsPanel.test.tsx @@ -1,11 +1,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { CredentialItem, credentialCreateCall, credentialUpdateCall } from "@/components/networking"; import { toast } from "@/lib/toast"; +import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; + import CredentialsPanel from "./CredentialsPanel"; const mockUseAuthorized = vi.fn(); @@ -89,12 +90,13 @@ const createQueryClient = () => }, }); -const renderPanel = () => - render( - - - , - ); +const panel = (queryClient: QueryClient) => ( + + + +); + +const renderPanel = (searchParams?: string) => renderWithProviders(panel(createQueryClient()), { searchParams }); describe("CredentialsPanel", () => { beforeEach(() => { @@ -138,6 +140,26 @@ describe("CredentialsPanel", () => { expect(screen.queryByText("No credentials configured")).not.toBeInTheDocument(); }); + it("keeps the page named in the URL once credentials finish loading", () => { + const manyCredentials: CredentialItem[] = Array.from({ length: 27 }, (_, index) => ({ + credential_name: `cred-${String(index + 1).padStart(2, "0")}`, + credential_values: {}, + credential_info: { custom_llm_provider: "openai" }, + })); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + mockUseCredentials.mockReturnValue({ data: undefined, isLoading: true, refetch: vi.fn() }); + const queryClient = createQueryClient(); + const { rerender } = renderWithProviders(panel(queryClient), { searchParams: "?credentials_page=2" }); + + mockUseCredentials.mockReturnValue({ data: { credentials: manyCredentials }, isLoading: false, refetch: vi.fn() }); + rerender(panel(queryClient)); + + expect(screen.getByText("cred-26")).toBeInTheDocument(); + expect(screen.getByText("cred-27")).toBeInTheDocument(); + expect(screen.queryByText("cred-01")).not.toBeInTheDocument(); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2"); + }); + it("opens the add modal when the add button is clicked", async () => { const user = userEvent.setup(); mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx index 63399e01b18..8e90a517e2d 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.test.tsx @@ -1,9 +1,11 @@ -import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { CredentialItem } from "@/components/networking"; +import { renderWithProviders, screen, waitFor, within } from "../../../tests/test-utils"; + import CredentialsTable from "./CredentialsTable"; vi.mock("@/components/provider_info_helpers", () => ({ @@ -45,20 +47,20 @@ describe("CredentialsTable", () => { }); it("should render the data column headers", () => { - render(); + renderWithProviders(); for (const header of ["Credential Name", "Provider"]) { expect(screen.getByText(header)).toBeInTheDocument(); } }); it("should display each credential name", () => { - render(); + renderWithProviders(); expect(screen.getByText("b-openai-key")).toBeInTheDocument(); expect(screen.getByText("a-azure-key")).toBeInTheDocument(); }); it("should render provider display names from the logo helper", () => { - render(); + renderWithProviders(); expect(screen.getByText("OpenAI")).toBeInTheDocument(); expect(screen.getByText("Azure")).toBeInTheDocument(); }); @@ -87,26 +89,26 @@ describe("CredentialsTable", () => { const credentials: CredentialItem[] = [ { credential_name: "no-provider", credential_values: {}, credential_info: {} }, ]; - render(); + renderWithProviders(); const row = screen.getAllByRole("row").slice(1)[0]; expect(within(row).getByText("-")).toBeInTheDocument(); }); it("should sort by credential name ascending by default", () => { - render(); + renderWithProviders(); const rows = screen.getAllByRole("row").slice(1); expect(within(rows[0]).getByText("a-azure-key")).toBeInTheDocument(); expect(within(rows[1]).getByText("b-openai-key")).toBeInTheDocument(); }); it("should display the empty state when there are no credentials", () => { - render(); + renderWithProviders(); expect(screen.getByText("No credentials configured")).toBeInTheDocument(); }); it("should edit a credential through the actions menu", async () => { const user = userEvent.setup(); - render(); + renderWithProviders(); await user.click(screen.getByTestId("credential-actions-b-openai-key")); await user.click(await screen.findByTestId("credential-action-edit")); expect(mockOnEdit).toHaveBeenCalledWith(mockCredentials[0]); @@ -114,7 +116,7 @@ describe("CredentialsTable", () => { it("should delete a credential through the actions menu", async () => { const user = userEvent.setup(); - render(); + renderWithProviders(); await user.click(screen.getByTestId("credential-actions-b-openai-key")); await user.click(await screen.findByTestId("credential-action-delete")); expect(mockOnDelete).toHaveBeenCalledWith(mockCredentials[0]); @@ -122,18 +124,77 @@ describe("CredentialsTable", () => { it("should copy the credential name through the actions menu", async () => { const user = userEvent.setup(); - render(); + renderWithProviders(); await user.click(screen.getByTestId("credential-actions-b-openai-key")); await user.click(await screen.findByTestId("credential-action-copy")); expect(await window.navigator.clipboard.readText()).toBe("b-openai-key"); }); it("should not render the actions menu when the user cannot modify credentials", () => { - render(); + renderWithProviders(); // Read parity: names still render... expect(screen.getByText("b-openai-key")).toBeInTheDocument(); // ...but there is no per-row actions trigger. expect(screen.queryByTestId("credential-actions-b-openai-key")).not.toBeInTheDocument(); expect(screen.queryByTestId("credential-actions-a-azure-key")).not.toBeInTheDocument(); }); + + describe("URL state", () => { + const manyCredentials: CredentialItem[] = Array.from({ length: 30 }, (_, index) => ({ + credential_name: `cred-${String(index + 1).padStart(2, "0")}`, + credential_values: {}, + credential_info: { custom_llm_provider: "openai" }, + })); + + const credentialNamesInOrder = () => + screen + .getAllByRole("row") + .slice(1) + .map((row) => within(row).getAllByRole("cell")[0].textContent); + + const lastUrl = (onUrlUpdate: ReturnType>) => { + const lastCall = onUrlUpdate.mock.calls.at(-1); + if (!lastCall) throw new Error("expected a URL update"); + return lastCall[0].searchParams; + }; + + it("sorts in the direction named in the URL", () => { + renderWithProviders(, { + searchParams: "?credentials_sort_order=desc", + }); + + expect(credentialNamesInOrder()).toEqual(["b-openai-key", "a-azure-key"]); + }); + + it("writes a sort toggle to the URL", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); + + await user.click(screen.getByTestId("sort-header-credential_name")); + + await waitFor(() => expect(lastUrl(onUrlUpdate).get("credentials_sort_order")).toBe("desc")); + expect(lastUrl(onUrlUpdate).has("credentials_sort_by")).toBe(false); + expect(credentialNamesInOrder()[0]).toBe("b-openai-key"); + }); + + it("shows the page named in the URL", () => { + renderWithProviders(, { + searchParams: "?credentials_page=2", + }); + + expect(credentialNamesInOrder()).toEqual(["cred-26", "cred-27", "cred-28", "cred-29", "cred-30"]); + }); + + it("writes a page change to the URL", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); + + await user.click(screen.getByTestId("pagination-next")); + + await waitFor(() => expect(lastUrl(onUrlUpdate).get("credentials_page")).toBe("2")); + expect(credentialNamesInOrder()[0]).toBe("cred-26"); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx index 835cd57ae92..6cfe904e840 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx @@ -1,11 +1,15 @@ "use client"; -import { SortingState } from "@tanstack/react-table"; import { KeyRound } from "lucide-react"; -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { CredentialItem } from "@/components/networking"; -import { DataTable } from "@/components/shared/DataTable"; +import { + DataTable, + DEFAULT_PAGE_SIZE_OPTIONS, + useUrlTableState, + type UrlTableStateOptions, +} from "@/components/shared/DataTable"; import { getCredentialsTableColumns } from "./CredentialsTableColumns"; @@ -17,7 +21,13 @@ interface CredentialsTableProps { isLoading?: boolean; } -const DEFAULT_SORTING: SortingState = [{ id: "credential_name", desc: false }]; +const TABLE_STATE_OPTIONS: UrlTableStateOptions = { + sortFields: ["credential_name"], + defaultSort: { id: "credential_name", desc: false }, + defaultPageSize: DEFAULT_PAGE_SIZE_OPTIONS[0], + filterColumns: [], + keyPrefix: "credentials_", +}; function EmptyState() { return ( @@ -38,7 +48,7 @@ const CredentialsTable: React.FC = ({ onDelete, isLoading = false, }) => { - const [sorting, setSorting] = useState(DEFAULT_SORTING); + const { sorting, onSortingChange, pagination, onPaginationChange } = useUrlTableState(TABLE_STATE_OPTIONS); const columns = useMemo( () => getCredentialsTableColumns({ canModifyCredentials, onEdit, onDelete }), @@ -49,11 +59,13 @@ const CredentialsTable: React.FC = ({ credential.credential_name || String(index)} sortingMode="client" sorting={sorting} - onSortingChange={setSorting} + onSortingChange={onSortingChange} isLoading={isLoading} loadingMessage="Loading credentials…" noDataMessage={} diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx index 1b0194b6478..9ec8172ac67 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx @@ -1,10 +1,10 @@ /* @vitest-environment jsdom */ import type { PaginationState } from "@tanstack/react-table"; -import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderWithProviders, screen } from "../../../tests/test-utils"; import HealthCheckComponent from "./HealthCheckComponent"; const mockIndividualModelHealthCheckCall = vi.fn(); @@ -58,7 +58,7 @@ function Harness({ modelData, allModelsOnProxy, rowCount = 1, onPageIndexChange const renderHealthCheck = async (props: HarnessProps) => { await act(async () => { - render(); + renderWithProviders(); }); await act(async () => { await new Promise((r) => setTimeout(r, 0)); @@ -87,7 +87,7 @@ describe("HealthCheckComponent", () => { }); it("should call individualModelHealthCheckCall with model id when run health check is triggered", async () => { - render( + renderWithProviders( , ); diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.test.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.test.tsx index fc5606d46b4..11e6e1f6fe4 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.test.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.test.tsx @@ -1,9 +1,11 @@ /* @vitest-environment jsdom */ import type { PaginationState, RowSelectionState } from "@tanstack/react-table"; -import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; import { useState } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, type Mock, vi } from "vitest"; + +import { renderWithProviders, screen } from "../../../tests/test-utils"; import { HealthChecksTable } from "./HealthChecksTable"; import type { HealthCheckData, HealthStatus } from "./HealthChecksTableColumns"; @@ -63,7 +65,7 @@ const rowIds = (): string[] => describe("HealthChecksTable client sorting", () => { it("orders health status healthy > checking > unknown > unhealthy", async () => { const user = userEvent.setup(); - render( + renderWithProviders( { it("floats in-progress checks to the top, sinks never-checked, and sorts real checks most-recent-first", async () => { const user = userEvent.setup(); - render( + renderWithProviders( { // "Never succeeded" is ranked below "None" -- both sink, but not to the same slot. it("sinks None below real successes and Never succeeded below None", async () => { const user = userEvent.setup(); - render( + renderWithProviders( { }); }); +const statusRows = [ + makeRow({ id: "unhealthy-row", health_status: "unhealthy" }), + makeRow({ id: "healthy-row", health_status: "healthy" }), + makeRow({ id: "weird-row", health_status: "some-other-status" }), + makeRow({ id: "checking-row", health_status: "checking" }), +]; + +const lastUrl = (onUrlUpdate: Mock): URLSearchParams => { + const lastCall = onUrlUpdate.mock.calls.at(-1); + if (!lastCall) throw new Error("expected a URL update"); + return lastCall[0].searchParams; +}; + +describe("HealthChecksTable URL sort state", () => { + it("keeps the fetched row order when the URL names no sort column", () => { + renderWithProviders(); + + expect(rowIds()).toEqual(["unhealthy-row", "healthy-row", "weird-row", "checking-row"]); + }); + + it("sorts by health_sort_by and health_sort_order from the URL", () => { + renderWithProviders(, { + searchParams: "?health_sort_by=health_status&health_sort_order=desc", + }); + + expect(rowIds()).toEqual(["weird-row", "unhealthy-row", "checking-row", "healthy-row"]); + }); + + it("writes header clicks to the URL and toggles direction from the URL value", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); + + await user.click(screen.getByTestId("sort-header-health_status")); + + expect(lastUrl(onUrlUpdate).get("health_sort_by")).toBe("health_status"); + expect(lastUrl(onUrlUpdate).get("health_sort_order")).toBeNull(); + expect(rowIds()).toEqual(["healthy-row", "checking-row", "unhealthy-row", "weird-row"]); + + await user.click(screen.getByTestId("sort-header-health_status")); + + expect(lastUrl(onUrlUpdate).get("health_sort_by")).toBe("health_status"); + expect(lastUrl(onUrlUpdate).get("health_sort_order")).toBe("desc"); + expect(rowIds()).toEqual(["weird-row", "unhealthy-row", "checking-row", "healthy-row"]); + }); + + it("keeps the fetched page when sorting, since the sort only reorders that page", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "?health_page=3&health_page_size=25", + onUrlUpdate, + }); + + await user.click(screen.getByTestId("sort-header-model_name")); + + expect(lastUrl(onUrlUpdate).get("health_sort_by")).toBe("model_name"); + expect(lastUrl(onUrlUpdate).get("health_page")).toBe("3"); + expect(lastUrl(onUrlUpdate).get("health_page_size")).toBe("25"); + expect(rowIds()).toEqual(["checking-row", "healthy-row", "unhealthy-row", "weird-row"]); + }); + + const sortableRow = (suffix: string, healthStatus: string, checkedAt: string): HealthCheckData => { + const fields = { + id: `row-${suffix}`, + model_name: `model-${suffix}`, + model_info: { id: `row-${suffix}`, team_id: `team-${suffix}` }, + health_status: healthStatus, + last_check: checkedAt, + last_success: checkedAt, + }; + return makeRow(fields); + }; + const newerRow = sortableRow("a", "healthy", "2024-06-01T10:00:00Z"); + const olderRow = sortableRow("b", "unhealthy", "2024-01-01T10:00:00Z"); + + it.each(["model_id", "model_name", "team_id", "health_status", "last_check", "last_success"])( + "sorts by %s when the URL names it", + (column) => { + renderWithProviders(, { searchParams: `?health_sort_by=${column}` }); + + expect(rowIds()).toEqual(["row-a", "row-b"]); + }, + ); +}); + describe("HealthChecksTable rows", () => { it("renders the live checking cell while a row is loading and disables its run button", () => { - render(); + renderWithProviders(); expect(screen.getByText("Checking...")).toBeInTheDocument(); expect(screen.getByTestId("run-health-check-btn")).toBeDisabled(); @@ -128,7 +216,7 @@ describe("HealthChecksTable rows", () => { it("runs a health check for the row's model id", async () => { const user = userEvent.setup(); const onRunHealthCheck = vi.fn(); - render(); + renderWithProviders(); await user.click(screen.getByTestId("run-health-check-btn")); @@ -138,7 +226,7 @@ describe("HealthChecksTable rows", () => { it("opens the model detail from the identity cell", async () => { const user = userEvent.setup(); const onSelectModel = vi.fn(); - render(); + renderWithProviders(); await user.click(screen.getByRole("button", { name: /deployment-9/ })); @@ -146,7 +234,7 @@ describe("HealthChecksTable rows", () => { }); it("surfaces the error detail button only when a fuller error exists", () => { - const { rerender } = render( + const { rerender } = renderWithProviders( { }); it("renders the empty state when the page has no models", () => { - render(); + renderWithProviders(); expect(screen.getByText("No models found")).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.tsx index 5c0470315bb..978172af970 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.tsx @@ -1,13 +1,14 @@ "use client"; -import { OnChangeFn, PaginationState, RowSelectionState, SortingState } from "@tanstack/react-table"; +import { OnChangeFn, PaginationState, RowSelectionState } from "@tanstack/react-table"; import { HeartPulse } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { Team } from "@/components/key_team_helpers/key_list"; import { DataTable } from "@/components/shared/DataTable"; import { getHealthChecksTableColumns, type HealthCheckData, type HealthStatus } from "./HealthChecksTableColumns"; +import { useHealthTableUrlState } from "./useHealthTableUrlState"; interface HealthChecksTableProps { data: HealthCheckData[]; @@ -54,7 +55,7 @@ export function HealthChecksTable({ onSelectModel, teams, }: HealthChecksTableProps) { - const [sorting, setSorting] = useState([]); + const { sorting, onSortingChange } = useHealthTableUrlState(); const columns = useMemo(() => { const columnDeps = { @@ -76,7 +77,7 @@ export function HealthChecksTable({ getRowId={(row, index) => row.model_info?.id ?? String(index)} sortingMode="client" sorting={sorting} - onSortingChange={setSorting} + onSortingChange={onSortingChange} paginationMode="server" pagination={pagination} onPaginationChange={onPaginationChange} diff --git a/ui/litellm-dashboard/src/components/model_dashboard/useHealthTableUrlState.ts b/ui/litellm-dashboard/src/components/model_dashboard/useHealthTableUrlState.ts new file mode 100644 index 00000000000..0de1b148d01 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_dashboard/useHealthTableUrlState.ts @@ -0,0 +1,35 @@ +import { functionalUpdate, type OnChangeFn, type SortingState } from "@tanstack/react-table"; +import { useCallback, useMemo } from "react"; + +import { useUrlTableState, type UrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable"; + +const UNSORTED_COLUMN_ID = ""; + +const HEALTH_TABLE_STATE_OPTIONS: UrlTableStateOptions = { + sortFields: ["model_id", "model_name", "team_id", "health_status", "last_check", "last_success"], + defaultSort: { id: UNSORTED_COLUMN_ID, desc: false }, + defaultPageSize: 50, + filterColumns: [], + keyPrefix: "health_", +}; + +export function useHealthTableUrlState(): UrlTableState { + const state = useUrlTableState(HEALTH_TABLE_STATE_OPTIONS); + const { sorting: urlSorting, onSortingChange: setUrlSorting, pagination, onPaginationChange } = state; + + const sorting = useMemo( + () => urlSorting.filter((entry) => entry.id !== UNSORTED_COLUMN_ID), + [urlSorting], + ); + + const onSortingChange = useCallback>( + (updater) => { + const pageBeforeSort = pagination; + setUrlSorting(functionalUpdate(updater, sorting)); + onPaginationChange(pageBeforeSort); + }, + [setUrlSorting, sorting, onPaginationChange, pagination], + ); + + return useMemo(() => ({ ...state, sorting, onSortingChange }), [state, sorting, onSortingChange]); +} diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 037ebc4040e..3812e095dbb 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -1,7 +1,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import React, { ReactNode } from "react"; +import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import ModelInfoView from "./model_info_view"; import { toast } from "@/lib/toast"; @@ -186,8 +187,20 @@ describe("ModelInfoView", () => { mockCredentialCreateCall.mockResolvedValue({}); }); - const wrapper = ({ children }: { children: ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); + const wrapperWithUrl = (searchParams = "", onUrlUpdate?: OnUrlUpdateFunction, keepMountUpdates = false) => + function UrlWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; + const wrapper = wrapperWithUrl(); it("should render", async () => { render(, { wrapper }); @@ -430,6 +443,73 @@ describe("ModelInfoView", () => { }); }); + describe("model_tab in the URL", () => { + const lastUrl = (onUrlUpdate: ReturnType>) => { + const lastCall = onUrlUpdate.mock.calls.at(-1); + if (!lastCall) throw new Error("expected a URL update"); + return lastCall[0].searchParams; + }; + + it("opens on the Raw JSON tab named in the URL", async () => { + render(, { wrapper: wrapperWithUrl("?model=123&model_tab=raw") }); + + expect(await screen.findByRole("tab", { name: /raw json/i })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText(/"model_name": "GPT-4"/, { selector: "pre" })).toBeVisible(); + }); + + it("writes the selected tab to the URL", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + render(, { wrapper: wrapperWithUrl("?model=123", onUrlUpdate) }); + + await user.click(await screen.findByRole("tab", { name: /raw json/i })); + + await waitFor(() => expect(lastUrl(onUrlUpdate).get("model_tab")).toBe("raw")); + expect(lastUrl(onUrlUpdate).get("model")).toBe("123"); + }); + + it("drops the tab from the URL when going back to the models list", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + const onClose = vi.fn(); + render(, { + wrapper: wrapperWithUrl("?model=123&model_tab=raw", onUrlUpdate), + }); + + await user.click(await screen.findByRole("button", { name: /back to models/i })); + + expect(onClose).toHaveBeenCalledTimes(1); + await waitFor(() => expect(lastUrl(onUrlUpdate).has("model_tab")).toBe(false)); + }); + + it("drops the tab from the URL after deleting the model", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + const onClose = vi.fn(); + render(, { + wrapper: wrapperWithUrl("?model=123&model_tab=raw", onUrlUpdate), + }); + + await user.click(await screen.findByTestId("delete-model-button")); + await user.click(await screen.findByRole("button", { name: /^delete$/i })); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(mockModelDeleteCall).toHaveBeenCalledWith("test-token", "123"); + await waitFor(() => expect(lastUrl(onUrlUpdate).has("model_tab")).toBe(false)); + }); + + it("opens on Overview and clears a tab value it does not know", async () => { + const onUrlUpdate = vi.fn(); + render(, { + wrapper: wrapperWithUrl("?model=123&model_tab=bogus", onUrlUpdate, true), + }); + + expect(await screen.findByRole("tab", { name: /overview/i })).toHaveAttribute("aria-selected", "true"); + await waitFor(() => expect(lastUrl(onUrlUpdate).has("model_tab")).toBe(false)); + expect(lastUrl(onUrlUpdate).get("model")).toBe("123"); + }); + }); + it("keeps the edit form and its touched fields alive across a tab switch", async () => { const user = userEvent.setup(); render(, { wrapper }); diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 4fe49a28936..6b214e88767 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -47,6 +47,7 @@ import UpdateModelCredentialsModal from "./update_model_credentials_modal"; import ModelInfoEditForm, { type ModelEditFormValues, type TouchedPricingField } from "./ModelInfoEditForm"; import { Tag } from "./tag_management/types"; import { getDisplayModelName } from "./view_model/model_name_display"; +import { useModelInfoTab } from "./useModelInfoTab"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; interface ModelInfoViewProps { @@ -71,6 +72,7 @@ export default function ModelInfoView({ modelAccessGroups, }: ModelInfoViewProps) { const queryClient = useQueryClient(); + const { tab: detailTab, setTab: setDetailTab, close } = useModelInfoTab(onClose); const [localModelData, setLocalModelData] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); @@ -440,7 +442,7 @@ export default function ModelInfoView({ if (isLoadingModel) { return (
- @@ -453,7 +455,7 @@ export default function ModelInfoView({ if (!modelData) { return (
- @@ -522,7 +524,7 @@ export default function ModelInfoView({ }); } - onClose(); + close(); } catch (error) { console.error("Error deleting the model:", error); toast.fromError("Failed to delete model"); @@ -562,7 +564,7 @@ export default function ModelInfoView({
- @@ -635,7 +637,7 @@ export default function ModelInfoView({
- + Overview diff --git a/ui/litellm-dashboard/src/components/pass_through_info.integration.test.tsx b/ui/litellm-dashboard/src/components/pass_through_info.integration.test.tsx index 90994a8a2cd..fa5ff38b3b4 100644 --- a/ui/litellm-dashboard/src/components/pass_through_info.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/pass_through_info.integration.test.tsx @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; -import { renderWithProviders, screen, waitFor } from "../../tests/test-utils"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import { render, renderWithProviders, screen, testQueryClient, waitFor } from "../../tests/test-utils"; import PassThroughInfoView from "./pass_through_info"; const updatePassThroughEndpoint = vi.fn(); @@ -180,3 +182,52 @@ describe("pass_through_info update payload", () => { expect(updatePassThroughEndpoint).toHaveBeenCalledTimes(1); }); }); + +describe("pass_through_info detail tab in the URL", () => { + const renderWithUrl = (isAdmin: boolean, searchParams: string, onUrlUpdate?: OnUrlUpdateFunction) => + render( + , + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + it("opens on the Settings tab named in the URL", () => { + renderWithUrl(true, "?endpoint=ep-1&endpoint_tab=settings"); + + expect(screen.getByRole("tab", { name: "Settings" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeVisible(); + }); + + it("writes the selected tab to the URL and clears it again on Overview", async () => { + const user = setup(); + const onUrlUpdate = vi.fn(); + renderWithUrl(true, "?endpoint=ep-1", onUrlUpdate); + + await user.click(screen.getByRole("tab", { name: "Settings" })); + await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("endpoint_tab")).toBe("settings")); + + await user.click(screen.getByRole("tab", { name: "Overview" })); + await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has("endpoint_tab")).toBe(false)); + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("endpoint")).toBe("ep-1"); + }); + + it("falls back to Overview and drops a Settings tab a non-admin cannot see", async () => { + const onUrlUpdate = vi.fn(); + renderWithUrl(false, "?endpoint=ep-1&endpoint_tab=settings", onUrlUpdate); + + expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true"); + await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has("endpoint_tab")).toBe(false)); + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("endpoint")).toBe("ep-1"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/pass_through_info.tsx b/ui/litellm-dashboard/src/components/pass_through_info.tsx index f44afdd9bf8..c5279cd7c63 100644 --- a/ui/litellm-dashboard/src/components/pass_through_info.tsx +++ b/ui/litellm-dashboard/src/components/pass_through_info.tsx @@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { useZodForm } from "@/lib/forms/useZodForm"; +import { usePassThroughDetailTab } from "./PassThroughSettings/passThroughDetailRouting"; const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const; const HTTP_METHOD_OPTIONS = HTTP_METHODS.map((method) => ({ label: method, value: method })); @@ -143,6 +144,7 @@ const PassThroughInfoView: React.FC = ({ onEndpointUpdated, }) => { const [endpointData, setEndpointData] = useState(initialEndpointData); + const [detailTab, setDetailTab] = usePassThroughDetailTab(isAdmin); const [loading] = useState(false); const [isEditing, setIsEditing] = useState(false); const [guardrails, setGuardrails] = useState< @@ -247,7 +249,7 @@ const PassThroughInfoView: React.FC = ({
- + Overview diff --git a/ui/litellm-dashboard/src/components/useModelInfoTab.ts b/ui/litellm-dashboard/src/components/useModelInfoTab.ts new file mode 100644 index 00000000000..c0d56d3b438 --- /dev/null +++ b/ui/litellm-dashboard/src/components/useModelInfoTab.ts @@ -0,0 +1,24 @@ +import { useCallback } from "react"; + +import { useUrlTab } from "@/hooks/useUrlTab"; + +const MODEL_INFO_TABS = ["overview", "raw"] as const; +type ModelInfoTab = (typeof MODEL_INFO_TABS)[number]; +const DEFAULT_MODEL_INFO_TAB: ModelInfoTab = "overview"; + +export interface ModelInfoTabState { + tab: ModelInfoTab; + setTab: (tab: ModelInfoTab) => void; + close: () => void; +} + +export function useModelInfoTab(onClose: () => void): ModelInfoTabState { + const [tab, setTab] = useUrlTab(MODEL_INFO_TABS, DEFAULT_MODEL_INFO_TAB, "model_tab"); + + const close = useCallback(() => { + setTab(DEFAULT_MODEL_INFO_TAB); + onClose(); + }, [setTab, onClose]); + + return { tab, setTab, close }; +}