diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index e3a494746d7..ce7cc9a13db 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -28,6 +28,7 @@ "lucide-react": "0.513.0", "moment": "2.30.1", "next": "16.2.11", + "nuqs": "^2.9.4", "openai": "4.104.0", "openapi-fetch": "^0.17.0", "openapi-react-query": "^0.5.4", @@ -10509,6 +10510,43 @@ "dev": true, "license": "MIT" }, + "node_modules/nuqs": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.9.4.tgz", + "integrity": "sha512-lsz3NyCOKmuNAyW052i9RWqcTntoYb2Qm6FxSWnkTDwOJnGS6fzpXDAp0VcwTevw3xgnWebYpDr9rm6+o4DHbw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/franky47" + }, + "peerDependencies": { + "@remix-run/react": ">=2", + "@tanstack/react-router": "^1", + "next": ">=14.2.0", + "react": ">=18.2.0 || ^19.0.0-0", + "react-router": "^5 || ^6 || ^7 || ^8", + "react-router-dom": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "next": { + "optional": true + }, + "react-router": { + "optional": true + }, + "react-router-dom": { + "optional": true + } + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 98b8108f774..a9ac92cd023 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -40,6 +40,7 @@ "lucide-react": "0.513.0", "moment": "2.30.1", "next": "16.2.11", + "nuqs": "^2.9.4", "openai": "4.104.0", "openapi-fetch": "^0.17.0", "openapi-react-query": "^0.5.4", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.test.ts deleted file mode 100644 index a829d921600..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* @vitest-environment jsdom */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useKeyDetailRouting } from "./detailNavigation"; - -vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); - -describe("useKeyDetailRouting", () => { - beforeEach(() => { - window.history.pushState(null, "", "/api-keys/"); - }); - - it("openKey sets ?key= via history.pushState (no full navigation)", () => { - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useKeyDetailRouting()); - act(() => result.current.openKey("88a145505dd6")); - expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("key=88a145505dd6")); - spy.mockRestore(); - }); - - it("openKey preserves unrelated query params like the legacy ?page=", () => { - window.history.pushState(null, "", "/?page=api-keys"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useKeyDetailRouting()); - act(() => result.current.openKey("88a145505dd6")); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("page=api-keys"); - expect(url).toContain("key=88a145505dd6"); - spy.mockRestore(); - }); - - it("close removes only the key param", () => { - window.history.pushState(null, "", "/?page=api-keys&key=88a145505dd6"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useKeyDetailRouting()); - act(() => result.current.close()); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("page=api-keys"); - expect(url).not.toContain("key="); - spy.mockRestore(); - }); - - it("exposes keyId from ?key=", () => { - window.history.pushState(null, "", "/api-keys/?key=88a145505dd6"); - const { result } = renderHook(() => useKeyDetailRouting()); - expect(result.current.keyId).toBe("88a145505dd6"); - }); - - it("keyId is null when no key param is present", () => { - const { result } = renderHook(() => useKeyDetailRouting()); - expect(result.current.keyId).toBeNull(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.ts deleted file mode 100644 index 85b3a6e046d..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/detailNavigation.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useSearchParams } from "next/navigation"; -import { useCallback } from "react"; - -import { navigateWithParams } from "../navigateWithParams"; - -export interface KeyDetailRouting { - keyId: string | null; - openKey: (id: string) => void; - close: () => void; -} - -export function useKeyDetailRouting(): KeyDetailRouting { - const searchParams = useSearchParams(); - - const openKey = useCallback((id: string) => { - navigateWithParams((params) => { - params.set("key", id); - }); - }, []); - - const close = useCallback(() => { - navigateWithParams((params) => { - params.delete("key"); - }); - }, []); - - return { - keyId: searchParams?.get("key") ?? null, - openKey, - close, - }; -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts index 717fdc85e28..292b27618bd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts @@ -1,51 +1,55 @@ -/* @vitest-environment jsdom */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { withNuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing"; +import { describe, expect, it, vi } from "vitest"; import { useModelDetailRouting } from "./detailNavigation"; -// The detail overlay is driven by ?model=/?team= on the current path. Under the -// /ui static mount a router.push to the same path (query-only change) is a no-op, -// so navigation goes through history.pushState (client-side, no full reload). -vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); - describe("useModelDetailRouting", () => { - beforeEach(() => { - window.history.pushState(null, "", "/models-and-endpoints/"); + it("openModel sets ?model= with a history push", async () => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + const { result } = renderHook(() => useModelDetailRouting(), { + wrapper: withNuqsTestingAdapter({ onUrlUpdate }), + }); + await act(async () => { + result.current.openModel("abc-1"); + }); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const event = onUrlUpdate.mock.calls.at(-1)?.[0]; + expect(event?.searchParams.get("model")).toBe("abc-1"); + expect(event?.options.history).toBe("push"); }); - it("openModel sets ?model= via history.pushState (no full navigation)", () => { - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useModelDetailRouting()); - act(() => result.current.openModel("abc-1")); - expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("model=abc-1")); - spy.mockRestore(); + it("openTeam sets ?team= and drops any model param", async () => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + const { result } = renderHook(() => useModelDetailRouting(), { + wrapper: withNuqsTestingAdapter({ searchParams: "?model=abc-1", onUrlUpdate }), + }); + await act(async () => { + result.current.openTeam("team-9"); + }); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const event = onUrlUpdate.mock.calls.at(-1)?.[0]; + expect(event?.searchParams.get("team")).toBe("team-9"); + expect(event?.searchParams.has("model")).toBe(false); }); - it("openTeam sets ?team= and drops any model param", () => { - window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useModelDetailRouting()); - act(() => result.current.openTeam("team-9")); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("team=team-9"); - expect(url).not.toContain("model="); - spy.mockRestore(); - }); - - it("close removes both model and team params", () => { - window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useModelDetailRouting()); - act(() => result.current.close()); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).not.toContain("model="); - expect(url).not.toContain("team="); - spy.mockRestore(); + it("close removes both model and team params", async () => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + const { result } = renderHook(() => useModelDetailRouting(), { + wrapper: withNuqsTestingAdapter({ searchParams: "?model=abc-1&team=team-9", onUrlUpdate }), + }); + await act(async () => { + result.current.close(); + }); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const event = onUrlUpdate.mock.calls.at(-1)?.[0]; + expect(event?.searchParams.has("model")).toBe(false); + expect(event?.searchParams.has("team")).toBe(false); }); it("reads modelId and teamId from the query string", () => { - window.history.pushState(null, "", "/models-and-endpoints/?model=xyz"); - const { result } = renderHook(() => useModelDetailRouting()); + const { result } = renderHook(() => useModelDetailRouting(), { + wrapper: withNuqsTestingAdapter({ searchParams: "?model=xyz" }), + }); expect(result.current.modelId).toBe("xyz"); expect(result.current.teamId).toBeNull(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts index 71f8bc82a07..2cfad341d25 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts @@ -1,8 +1,6 @@ -import { useSearchParams } from "next/navigation"; +import { parseAsString, useQueryStates } from "nuqs"; import { useCallback } from "react"; -import { navigateWithParams } from "../navigateWithParams"; - export interface ModelDetailRouting { modelId: string | null; teamId: string | null; @@ -12,32 +10,32 @@ export interface ModelDetailRouting { } export function useModelDetailRouting(): ModelDetailRouting { - const searchParams = useSearchParams(); + const [{ model, team }, setParams] = useQueryStates( + { model: parseAsString, team: parseAsString }, + { history: "push" }, + ); - const openModel = useCallback((id: string) => { - navigateWithParams((params) => { - params.delete("team"); - params.set("model", id); - }); - }, []); + const openModel = useCallback( + (id: string) => { + void setParams({ model: id, team: null }); + }, + [setParams], + ); - const openTeam = useCallback((id: string) => { - navigateWithParams((params) => { - params.delete("model"); - params.set("team", id); - }); - }, []); + const openTeam = useCallback( + (id: string) => { + void setParams({ model: null, team: id }); + }, + [setParams], + ); const close = useCallback(() => { - navigateWithParams((params) => { - params.delete("model"); - params.delete("team"); - }); - }, []); + void setParams({ model: null, team: null }); + }, [setParams]); return { - modelId: searchParams?.get("model") ?? null, - teamId: searchParams?.get("team") ?? null, + modelId: model, + teamId: team, openModel, openTeam, close, 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 e0f35b5f3b8..56b7016a325 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,14 +1,9 @@ /* @vitest-environment jsdom */ import { render } from "@testing-library/react"; +import { withNuqsTestingAdapter } from "nuqs/adapters/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; import HealthStatusPanel from "./HealthStatusPanel"; -vi.mock("next/navigation", () => ({ - usePathname: () => "/models-and-endpoints/health", - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - useSearchParams: () => new URLSearchParams(""), -})); - const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null); vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({ default: (props: { all_models_on_proxy?: string[] }) => { @@ -44,7 +39,7 @@ describe("HealthStatusPanel", () => { isLoading: false, }); - render(); + render(, { wrapper: withNuqsTestingAdapter() }); expect(mockHealthCheckComponent).toHaveBeenCalled(); const props = mockHealthCheckComponent.mock.calls[0][0]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/navigateWithParams.ts b/ui/litellm-dashboard/src/app/(dashboard)/navigateWithParams.ts deleted file mode 100644 index 5acf444a359..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/navigateWithParams.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function navigateWithParams(mutate: (params: URLSearchParams) => void, mode: "push" | "replace" = "push"): void { - const params = new URLSearchParams(window.location.search); - mutate(params); - const qs = params.toString(); - const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname; - if (mode === "replace") { - window.history.replaceState(null, "", url); - } else { - window.history.pushState(null, "", url); - } -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx index 3f9de478069..c15b9fcaddb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, render, screen } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { NuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type OrganizationsTableComponent from "./OrganizationsTable"; @@ -40,62 +41,66 @@ vi.mock("@/components/organization/organization_view", () => ({ }, })); -// The selected org is URL-derived (?org=) via useOrgDetailRouting. Next's real useSearchParams -// re-renders subscribers on history.pushState/replaceState; mirror that so URL changes propagate. -vi.mock("next/navigation", async () => { - const { useSyncExternalStore } = await import("react"); - const LOCATION_CHANGE_EVENT = "test-locationchange"; - for (const method of ["pushState", "replaceState"] as const) { - const original = window.history[method].bind(window.history); - window.history[method] = (...args: Parameters) => { - original(...args); - window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT)); - }; - } - const subscribe = (onChange: () => void) => { - window.addEventListener(LOCATION_CHANGE_EVENT, onChange); - window.addEventListener("popstate", onChange); - return () => { - window.removeEventListener(LOCATION_CHANGE_EVENT, onChange); - window.removeEventListener("popstate", onChange); - }; - }; - return { - useSearchParams: () => new URLSearchParams(useSyncExternalStore(subscribe, () => window.location.search)), - }; -}); - import OrganizationsPanel from "./OrganizationsPanel"; -const renderWithQueryClient = (ui: React.ReactElement) => { +const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + +interface RenderPanelOptions { + premiumUser?: boolean; + searchParams?: string; +} + +const renderPanel = ({ premiumUser = true, searchParams = "" }: RenderPanelOptions = {}) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - return render({ui}); + const url = { current: searchParams }; + const handleUrlUpdate = (event: UrlUpdateEvent) => { + onUrlUpdate(event); + url.current = event.queryString; + }; + const tree = (currentSearchParams: string) => ( + + + + + + ); + const { rerender } = render(tree(searchParams)); + return { + navigate: (nextSearchParams: string) => { + rerender(tree(url.current)); + rerender(tree(nextSearchParams)); + url.current = nextSearchParams; + }, + }; }; +const expectQueryString = (queryString: string) => + waitFor(() => expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString }))); + beforeEach(() => { capturedTableProps = null; mockOrgInfoView.mockClear(); - window.history.replaceState(null, "", "/organizations/"); + onUrlUpdate.mockClear(); }); describe("OrganizationsPanel", () => { it("gates non-premium users behind the enterprise notice", () => { - renderWithQueryClient(); + renderPanel({ premiumUser: false }); expect(screen.getByText(/LiteLLM Enterprise feature/i)).toBeInTheDocument(); expect(screen.queryByText("+ Create New Organization")).not.toBeInTheDocument(); }); it("shows the create button for a premium admin", () => { - renderWithQueryClient(); + renderPanel(); expect(screen.getByText("+ Create New Organization")).toBeInTheDocument(); }); it("resolves the loading skeleton to false when the query is disabled (no token)", () => { - renderWithQueryClient(); + renderPanel(); // A disabled React Query keeps isPending true forever; feeding isLoading avoids a stuck skeleton. expect(screen.getByTestId("organizations-table")).toHaveTextContent("isLoading:false"); @@ -103,18 +108,20 @@ describe("OrganizationsPanel", () => { }); describe("OrganizationsPanel - org detail deep link (?org=)", () => { - it("clicking an organization pushes ?org= and opens the detail view", () => { - renderWithQueryClient(); + it("clicking an organization pushes ?org= and opens the detail view", async () => { + renderPanel(); act(() => capturedTableProps?.onOrganizationClick("org-deep-link")); - expect(window.location.search).toContain("org=org-deep-link"); + await expectQueryString("?org=org-deep-link"); + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ options: expect.objectContaining({ history: "push" }) }), + ); expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ organizationId: "org-deep-link" })); }); it("opens the org detail directly from a ?org= deep link", () => { - window.history.replaceState(null, "", "/organizations/?org=org-from-url"); - renderWithQueryClient(); + renderPanel({ searchParams: "?org=org-from-url" }); expect(mockOrgInfoView).toHaveBeenLastCalledWith( expect.objectContaining({ organizationId: "org-from-url", editOrg: false }), @@ -122,37 +129,40 @@ describe("OrganizationsPanel - org detail deep link (?org=)", () => { expect(screen.queryByTestId("organizations-table")).not.toBeInTheDocument(); }); - it("closing the org detail removes ?org= and returns to the list", () => { - window.history.replaceState(null, "", "/organizations/?org=org-from-url"); - renderWithQueryClient(); + it("closing the org detail removes ?org= and returns to the list", async () => { + renderPanel({ searchParams: "?org=org-from-url" }); act(() => mockOrgInfoView.mock.calls.at(-1)?.[0].onClose()); - expect(window.location.search).not.toContain("org="); + await expectQueryString(""); expect(screen.queryByTestId("organization-info-view")).not.toBeInTheDocument(); expect(screen.getByTestId("organizations-table")).toBeInTheDocument(); }); - it("the edit action opens the detail in edit mode with ?org= set", () => { - renderWithQueryClient(); + it("the edit action opens the detail in edit mode with ?org= set", async () => { + renderPanel(); act(() => capturedTableProps?.onEditClick("org-edit")); - expect(window.location.search).toContain("org=org-edit"); + await expectQueryString("?org=org-edit"); expect(mockOrgInfoView).toHaveBeenLastCalledWith( expect.objectContaining({ organizationId: "org-edit", editOrg: true }), ); }); - it("a plain row click after leaving an edit view via browser history does not reopen in edit mode", () => { - renderWithQueryClient(); + it("a plain row click after leaving an edit view via browser history does not reopen in edit mode", async () => { + const { navigate } = renderPanel(); act(() => capturedTableProps?.onEditClick("org-edit")); + await expectQueryString("?org=org-edit"); expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ editOrg: true })); - act(() => window.history.pushState(null, "", "/organizations/")); + navigate(""); + expect(screen.getByTestId("organizations-table")).toBeInTheDocument(); + act(() => capturedTableProps?.onOrganizationClick("org-plain")); + await expectQueryString("?org=org-plain"); expect(mockOrgInfoView).toHaveBeenLastCalledWith( expect.objectContaining({ organizationId: "org-plain", editOrg: false }), ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx index a21c0669677..65be15294f6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx @@ -1,8 +1,8 @@ import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels"; -import { useOrgDetailRouting } from "@/app/(dashboard)/organizations/detailNavigation"; import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters"; import { useQueryClient } from "@tanstack/react-query"; +import { parseAsString, useQueryState } from "nuqs"; import React, { useState } from "react"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; @@ -20,7 +20,7 @@ interface OrganizationsPanelProps { } const OrganizationsPanel: React.FC = ({ userRole, accessToken, premiumUser }) => { - const { orgId: selectedOrgId, openOrg, close: closeOrgDetail } = useOrgDetailRouting(); + const [selectedOrgId, setSelectedOrgId] = useQueryState("org", parseAsString.withOptions({ history: "push" })); const [editOrg, setEditOrg] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [orgToDelete, setOrgToDelete] = useState(null); @@ -109,7 +109,7 @@ const OrganizationsPanel: React.FC = ({ userRole, acces { - closeOrgDetail(); + void setSelectedOrgId(null); setEditOrg(false); }} accessToken={accessToken} @@ -135,10 +135,10 @@ const OrganizationsPanel: React.FC = ({ userRole, acces searchActive={searchActive} onOrganizationClick={(organizationId) => { setEditOrg(false); - openOrg(organizationId); + void setSelectedOrgId(organizationId); }} onEditClick={(organizationId) => { - openOrg(organizationId); + void setSelectedOrgId(organizationId); setEditOrg(true); }} onDeleteClick={handleDelete} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.test.ts deleted file mode 100644 index 46b7c4313ea..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* @vitest-environment jsdom */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useOrgDetailRouting } from "./detailNavigation"; - -vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); - -describe("useOrgDetailRouting", () => { - beforeEach(() => { - window.history.pushState(null, "", "/organizations/"); - }); - - it("openOrg sets ?org= via history.pushState (no full navigation)", () => { - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useOrgDetailRouting()); - act(() => result.current.openOrg("org-abc123")); - expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("org=org-abc123")); - spy.mockRestore(); - }); - - it("openOrg preserves unrelated query params", () => { - window.history.pushState(null, "", "/organizations/?foo=bar"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useOrgDetailRouting()); - act(() => result.current.openOrg("org-abc123")); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("foo=bar"); - expect(url).toContain("org=org-abc123"); - spy.mockRestore(); - }); - - it("close removes only the org param", () => { - window.history.pushState(null, "", "/organizations/?foo=bar&org=org-abc123"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useOrgDetailRouting()); - act(() => result.current.close()); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("foo=bar"); - expect(url).not.toContain("org="); - spy.mockRestore(); - }); - - it("exposes orgId from ?org=", () => { - window.history.pushState(null, "", "/organizations/?org=org-abc123"); - const { result } = renderHook(() => useOrgDetailRouting()); - expect(result.current.orgId).toBe("org-abc123"); - }); - - it("orgId is null when no org param is present", () => { - const { result } = renderHook(() => useOrgDetailRouting()); - expect(result.current.orgId).toBeNull(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.ts deleted file mode 100644 index 8c55c7b750c..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/detailNavigation.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useSearchParams } from "next/navigation"; -import { useCallback } from "react"; - -import { navigateWithParams } from "../navigateWithParams"; - -export interface OrgDetailRouting { - orgId: string | null; - openOrg: (id: string) => void; - close: () => void; -} - -export function useOrgDetailRouting(): OrgDetailRouting { - const searchParams = useSearchParams(); - - const openOrg = useCallback((id: string) => { - navigateWithParams((params) => { - params.set("org", id); - }); - }, []); - - const close = useCallback(() => { - navigateWithParams((params) => { - params.delete("org"); - }); - }, []); - - return { - orgId: searchParams?.get("org") ?? null, - openOrg, - close, - }; -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.test.ts deleted file mode 100644 index e5d5b1a4073..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* @vitest-environment jsdom */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useTeamDetailRouting } from "./detailNavigation"; - -vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); - -describe("useTeamDetailRouting", () => { - beforeEach(() => { - window.history.pushState(null, "", "/teams/"); - }); - - it("openTeam sets ?team= via history.pushState (no full navigation)", () => { - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useTeamDetailRouting()); - act(() => result.current.openTeam("team-abc123")); - expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("team=team-abc123")); - spy.mockRestore(); - }); - - it("openTeam preserves unrelated query params", () => { - window.history.pushState(null, "", "/teams/?foo=bar"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useTeamDetailRouting()); - act(() => result.current.openTeam("team-abc123")); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("foo=bar"); - expect(url).toContain("team=team-abc123"); - spy.mockRestore(); - }); - - it("close removes only the team param", () => { - window.history.pushState(null, "", "/teams/?foo=bar&team=team-abc123"); - const spy = vi.spyOn(window.history, "pushState"); - const { result } = renderHook(() => useTeamDetailRouting()); - act(() => result.current.close()); - const url = spy.mock.calls.at(-1)?.[2] as string; - expect(url).toContain("foo=bar"); - expect(url).not.toContain("team="); - spy.mockRestore(); - }); - - it("exposes teamId from ?team=", () => { - window.history.pushState(null, "", "/teams/?team=team-abc123"); - const { result } = renderHook(() => useTeamDetailRouting()); - expect(result.current.teamId).toBe("team-abc123"); - }); - - it("teamId is null when no team param is present", () => { - const { result } = renderHook(() => useTeamDetailRouting()); - expect(result.current.teamId).toBeNull(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.ts deleted file mode 100644 index d5208f094cb..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/detailNavigation.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useSearchParams } from "next/navigation"; -import { useCallback } from "react"; - -import { navigateWithParams } from "../navigateWithParams"; - -export interface TeamDetailRouting { - teamId: string | null; - openTeam: (id: string) => void; - close: () => void; -} - -export function useTeamDetailRouting(): TeamDetailRouting { - const searchParams = useSearchParams(); - - const openTeam = useCallback((id: string) => { - navigateWithParams((params) => { - params.set("team", id); - }); - }, []); - - const close = useCallback(() => { - navigateWithParams((params) => { - params.delete("team"); - }); - }, []); - - return { - teamId: searchParams?.get("team") ?? null, - openTeam, - close, - }; -} diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx index a73921ce35b..3d6c6e4c2eb 100644 --- a/ui/litellm-dashboard/src/app/layout.tsx +++ b/ui/litellm-dashboard/src/app/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; + import AntdGlobalProvider from "@/contexts/AntdGlobalProvider"; import { AuthProvider } from "@/contexts/AuthContext"; import ReactQueryProvider from "@/contexts/ReactQueryProvider"; @@ -22,11 +24,13 @@ export default function RootLayout({ return ( - - - {children} - - + + + + {children} + + + ); diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx index 2afc4bbc586..2bda0f72cec 100644 --- a/ui/litellm-dashboard/src/components/Teams.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { NuqsTestingAdapter, OnUrlUpdateFunction } from "nuqs/adapters/testing"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema"; @@ -78,31 +79,6 @@ vi.mock("@/components/team/TeamInfo", () => ({ }, })); -// The selected team is URL-derived (?team=) via useTeamDetailRouting. Next's real useSearchParams -// re-renders subscribers on history.pushState/replaceState; mirror that so URL changes propagate. -vi.mock("next/navigation", async () => { - const { useSyncExternalStore } = await import("react"); - const LOCATION_CHANGE_EVENT = "test-locationchange"; - for (const method of ["pushState", "replaceState"] as const) { - const original = window.history[method].bind(window.history); - window.history[method] = (...args: Parameters) => { - original(...args); - window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT)); - }; - } - const subscribe = (onChange: () => void) => { - window.addEventListener(LOCATION_CHANGE_EVENT, onChange); - window.addEventListener("popstate", onChange); - return () => { - window.removeEventListener(LOCATION_CHANGE_EVENT, onChange); - window.removeEventListener("popstate", onChange); - }; - }; - return { - useSearchParams: () => new URLSearchParams(useSyncExternalStore(subscribe, () => window.location.search)), - }; -}); - vi.mock("./ModelSelect/ModelSelect", () => { const ModelSelect = React.forwardRef(({ value, onChange, dataTestId, id }: any, ref: any) => { return ( @@ -182,15 +158,21 @@ const createQueryClient = () => { }); }; -const renderWithQueryClient = (component: React.ReactElement) => { +const renderWithQueryClient = ( + component: React.ReactElement, + options?: { searchParams?: string; onUrlUpdate?: OnUrlUpdateFunction }, +) => { const queryClient = createQueryClient(); - return render({component}); + return render( + + {component} + , + ); }; // Re-establish safe defaults before every test (clearAllMocks keeps return values, so restore them here). beforeEach(() => { mockTeamsTableProps = null; - window.history.replaceState(null, "", "/teams/"); }); describe("Teams - handleCreate organization handling", () => { @@ -479,32 +461,42 @@ describe("Teams - team detail deep link (?team=)", () => { }); it("selecting a team pushes ?team= to the URL", async () => { - renderWithQueryClient(); + const onUrlUpdate = vi.fn(); + renderWithQueryClient(, { onUrlUpdate }); await waitFor(() => expect(mockTeamsTableProps).not.toBeNull()); act(() => mockTeamsTableProps.onSelectTeam({ ...baseTableTeam, team_id: "team-deep-link" })); - expect(window.location.search).toContain("team=team-deep-link"); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0]; + expect(lastUpdate.searchParams.get("team")).toBe("team-deep-link"); + expect(lastUpdate.options.history).toBe("push"); + await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ teamId: "team-deep-link" })); }); it("opens the team detail view directly from a ?team= deep link", async () => { - window.history.replaceState(null, "", "/teams/?team=team-from-url"); - renderWithQueryClient(); + renderWithQueryClient(, { + searchParams: "?team=team-from-url", + }); await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ teamId: "team-from-url" })); }); it("closing the team detail view removes ?team= from the URL", async () => { - window.history.replaceState(null, "", "/teams/?team=team-from-url"); - renderWithQueryClient(); + const onUrlUpdate = vi.fn(); + renderWithQueryClient(, { + searchParams: "?team=team-from-url", + onUrlUpdate, + }); await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); act(() => mockTeamInfoView.mock.calls.at(-1)?.[0].onClose()); - expect(window.location.search).not.toContain("team="); + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + expect(onUrlUpdate.mock.calls.at(-1)![0].searchParams.has("team")).toBe(false); await waitFor(() => expect(screen.queryByTestId("team-info-view")).not.toBeInTheDocument()); }); }); diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index fc89751db6a..edf376cb8d4 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -12,7 +12,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { PageHeader } from "@/components/shared/PageHeader"; import { Button as UIButton } from "@/components/ui/button"; import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams"; -import { useTeamDetailRouting } from "@/app/(dashboard)/teams/detailNavigation"; +import { parseAsString, useQueryState } from "nuqs"; import { TeamsTable } from "./TeamsPage/TeamsTable"; import AccessGroupSelector from "./common_components/AccessGroupSelector"; import MetadataKeyValueFields, { metadataPairsToObject } from "./common_components/MetadataKeyValueFields"; @@ -140,7 +140,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser const [editModalVisible, setEditModalVisible] = useState(false); const [selectedTeam, setSelectedTeam] = useState(null); - const { teamId: selectedTeamId, openTeam, close: closeTeamDetail } = useTeamDetailRouting(); + const [selectedTeamId, setSelectedTeamId] = useQueryState("team", parseAsString.withOptions({ history: "push" })); const [editTeam, setEditTeam] = useState(false); const [isTeamModalVisible, setIsTeamModalVisible] = useState(false); @@ -473,12 +473,12 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser userID={userID} onSelectTeam={(team) => { setSelectedTeam(team); - openTeam(team.team_id); + void setSelectedTeamId(team.team_id); setEditTeam(false); }} onEditTeam={(team) => { setSelectedTeam(team); - openTeam(team.team_id); + void setSelectedTeamId(team.team_id); setEditTeam(true); }} onDeleteTeam={handleDelete} @@ -538,7 +538,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser }} onClose={() => { setSelectedTeam(null); - closeTeamDetail(); + void setSelectedTeamId(null); setEditTeam(false); }} accessToken={accessToken} diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 708990647c8..0755ddb96fc 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -1,6 +1,7 @@ import { screen, waitFor, within, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { vi, it, expect, beforeEach, describe, MockedFunction } from "vitest"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import { vi, it, expect, beforeEach, describe, Mock, MockedFunction } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; import { VirtualKeysTable } from "./VirtualKeysTable"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; @@ -8,8 +9,6 @@ import { useKeyInfo } from "@/app/(dashboard)/hooks/keys/useKeyInfo"; import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; -vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) })); - // Resolve debounced values synchronously so an applied filter lands in the useKeys query within the test tick. vi.mock("@tanstack/react-pacer/debouncer", async () => { const React = await vi.importActual("react"); @@ -169,11 +168,12 @@ const keysResult = (keys: KeyResponse[], data: Partial = {}, extra const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" })); +const lastKeyParam = (onUrlUpdate: Mock) => + onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("key"); + beforeEach(() => { vi.clearAllMocks(); - window.history.pushState(null, "", "/"); - mockUseKeys.mockReturnValue(keysResult([mockKey])); mockUseKeyInfo.mockReturnValue(keyInfoResult(undefined)); @@ -359,7 +359,8 @@ it("sorts by spend ascending when 'Spend ascending' is chosen from the Spend / B }); it("clicking the key cell deep-links via ?key=", async () => { - renderWithProviders(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); await waitFor(() => { expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); @@ -367,13 +368,14 @@ it("clicking the key cell deep-links via ?key=", async () => { fireEvent.click(screen.getByText("Test Key Alias")); - expect(window.location.search).toContain(`key=${encodeURIComponent(mockKey.token)}`); + await waitFor(() => { + expect(lastKeyParam(onUrlUpdate)).toBe(mockKey.token); + }); }); it("renders KeyInfoView when the URL has ?key= for a key on the current page, without refetching it", async () => { - window.history.pushState(null, "", `/?key=${encodeURIComponent(mockKey.token)}`); - - renderWithProviders(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { searchParams: { key: mockKey.token }, onUrlUpdate }); await waitFor(() => { expect(screen.getByText("Back to Keys")).toBeInTheDocument(); @@ -383,16 +385,18 @@ it("renders KeyInfoView when the URL has ?key= for a key on the current page, wi fireEvent.click(screen.getByText("Back to Keys")); - expect(window.location.search).not.toContain("key="); + await waitFor(() => { + expect(lastKeyParam(onUrlUpdate)).toBeNull(); + }); + expect(screen.getByTestId("pagination-range")).toBeInTheDocument(); }); it("fetches the key by id when the URL has ?key= for a key not in the loaded page", async () => { - window.history.pushState(null, "", "/?key=other-key-hash"); mockUseKeyInfo.mockReturnValue( keyInfoResult({ ...mockKey, token: "other-key-hash", key_alias: "Fetched Key Alias" }), ); - renderWithProviders(); + renderWithProviders(, { searchParams: { key: "other-key-hash" } }); await waitFor(() => { expect(screen.getByText("Back to Keys")).toBeInTheDocument(); @@ -402,19 +406,16 @@ it("fetches the key by id when the URL has ?key= for a key not in the loaded pag }); it("shows a loading state while a deep-linked key is being fetched", () => { - window.history.pushState(null, "", "/?key=other-key-hash"); - - renderWithProviders(); + renderWithProviders(, { searchParams: { key: "other-key-hash" } }); expect(screen.getByText("Loading key...")).toBeInTheDocument(); expect(screen.queryByTestId("pagination-range")).not.toBeInTheDocument(); }); it("shows 'Key not found' when the deep-linked key fails to load", async () => { - window.history.pushState(null, "", "/?key=missing-key-hash"); mockUseKeyInfo.mockReturnValue(keyInfoResult(undefined, true)); - renderWithProviders(); + renderWithProviders(, { searchParams: { key: "missing-key-hash" } }); await waitFor(() => { expect(screen.getByText("Key not found")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index eb96136ed6b..fa0360c0dda 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -1,6 +1,5 @@ "use client"; -import { useKeyDetailRouting } from "@/app/(dashboard)/api-keys/detailNavigation"; import { useKeyInfo } from "@/app/(dashboard)/hooks/keys/useKeyInfo"; import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; @@ -18,6 +17,7 @@ import { Input } from "@/components/ui/input"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; import { KeyRound } from "lucide-react"; +import { parseAsString, useQueryState } from "nuqs"; import React, { useCallback, useMemo, useState } from "react"; import { Team } from "../key_team_helpers/key_list"; @@ -49,7 +49,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { const { data: fetchedTeams } = useAllTeams(); const allTeams = useMemo(() => fetchedTeams ?? [], [fetchedTeams]); - const { keyId: selectedKeyId, openKey, close: closeKeyDetail } = useKeyDetailRouting(); + const [selectedKeyId, setSelectedKeyId] = useQueryState("key", parseAsString.withOptions({ history: "push" })); const [sorting, setSorting] = useState(DEFAULT_SORTING); const [tablePagination, setTablePagination] = useState({ pageIndex: 0, pageSize: 50 }); const [columnFilters, setColumnFilters] = useState([]); @@ -105,8 +105,8 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { }, []); const columns = useMemo( - () => getKeyTableColumns({ allTeams, organizations, onSelectKey: (key) => openKey(key.token) }), - [allTeams, organizations, openKey], + () => getKeyTableColumns({ allTeams, organizations, onSelectKey: (key) => void setSelectedKeyId(key.token) }), + [allTeams, organizations, setSelectedKeyId], ); const selectedKeyFromList = useMemo( @@ -161,7 +161,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
void setSelectedKeyId(null)} keyData={selectedKey} teams={allTeams} onDelete={refetch} diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx index 6a66e845675..852f146c786 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx @@ -1,9 +1,11 @@ +import { QueryClientProvider } from "@tanstack/react-query"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import moment from "moment"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { NuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import { render, renderWithProviders, testQueryClient } from "../../../tests/test-utils"; import type { LogEntry } from "./columns"; import RequestLogsPanel from "./RequestLogsPanel"; @@ -52,45 +54,6 @@ vi.mock("./LogDetailsDrawer", () => ({ }, })); -vi.mock("next/navigation", async (importOriginal) => { - const actual = await importOriginal(); - const { useSyncExternalStore } = await import("react"); - return { - ...actual, - useSearchParams: () => { - const search = useSyncExternalStore( - (onChange: () => void) => { - window.addEventListener("test-locationchange", onChange); - window.addEventListener("popstate", onChange); - return () => { - window.removeEventListener("test-locationchange", onChange); - window.removeEventListener("popstate", onChange); - }; - }, - () => window.location.search, - ); - return new URLSearchParams(search); - }, - }; -}); - -const originalPushState = window.history.pushState.bind(window.history); -const originalReplaceState = window.history.replaceState.bind(window.history); -beforeAll(() => { - window.history.pushState = (data, unused, url) => { - originalPushState(data, unused, url); - window.dispatchEvent(new Event("test-locationchange")); - }; - window.history.replaceState = (data, unused, url) => { - originalReplaceState(data, unused, url); - window.dispatchEvent(new Event("test-locationchange")); - }; -}); -afterAll(() => { - window.history.pushState = originalPushState; - window.history.replaceState = originalReplaceState; -}); - import { uiSpendLogsCall } from "../networking"; const logEntry = (overrides: Partial): LogEntry => ({ @@ -132,12 +95,46 @@ const defaultProps = { const row = (requestId: string) => document.querySelector(`[data-row-id="${requestId}"]`); const lastCall = () => vi.mocked(uiSpendLogsCall).mock.calls.at(-1)?.[0]; +const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); +const renderPanel = (searchParams?: string) => + renderWithProviders(, { searchParams, onUrlUpdate }); + +const renderPanelWithHistory = () => { + const stack = [""]; + const handleUrlUpdate = (event: UrlUpdateEvent) => { + onUrlUpdate(event); + if (event.options.history === "push") { + stack.push(event.queryString); + } else { + stack[stack.length - 1] = event.queryString; + } + }; + const tree = (searchParams: string) => ( + + + + + + ); + const view = render(tree("")); + return { + goBack: () => { + const current = stack[stack.length - 1] ?? ""; + stack.pop(); + const target = stack[stack.length - 1] ?? ""; + view.rerender(tree(current)); + view.rerender(tree(target)); + }, + }; +}; +const urlParams = () => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams ?? new URLSearchParams(); +const historyModes = () => onUrlUpdate.mock.calls.map(([event]) => event.options.history); + describe("RequestLogsPanel", () => { beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); testQueryClient.clear(); - window.history.replaceState(null, "", "/logs/"); respondWith([]); }); @@ -150,7 +147,7 @@ describe("RequestLogsPanel", () => { it("collapses a multi-call session to a single representative row", async () => { respondWith(sessionRows); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-mcp") ?? row("req-llm") ?? row("req-llm-2")).not.toBeNull()); @@ -160,7 +157,7 @@ describe("RequestLogsPanel", () => { it("prefers an LLM call over an MCP call as the session's representative", async () => { respondWith(sessionRows); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-llm")).not.toBeNull()); expect(row("req-mcp")).toBeNull(); @@ -168,7 +165,7 @@ describe("RequestLogsPanel", () => { it("shows the session's call count and composition on the representative row", async () => { respondWith(sessionRows); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-llm")).not.toBeNull()); expect(within(row("req-llm") as HTMLElement).getByText("3")).toBeInTheDocument(); @@ -179,7 +176,7 @@ describe("RequestLogsPanel", () => { logEntry({ request_id: "req-solo-a", session_id: "sess-a", session_total_count: 1 }), logEntry({ request_id: "req-solo-b" }), ]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-solo-a")).not.toBeNull()); expect(row("req-solo-b")).not.toBeNull(); @@ -189,7 +186,7 @@ describe("RequestLogsPanel", () => { describe("search by request id (LIT-3981)", () => { it("sends the typed request id to the server on the first page instead of filtering the loaded rows", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); @@ -207,7 +204,7 @@ describe("RequestLogsPanel", () => { describe("time range", () => { it("requests a ~15 minute window when Last 15 Minutes is picked", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); @@ -226,7 +223,7 @@ describe("RequestLogsPanel", () => { it("restores the default 24 hour window when filters are reset", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); @@ -256,12 +253,13 @@ describe("RequestLogsPanel", () => { it("clicking a row writes ?log_id= to the URL and opens the drawer", async () => { const user = userEvent.setup(); respondWith([logEntry({ request_id: "req-1" })]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-1")).not.toBeNull()); await user.click(row("req-1") as HTMLElement); - expect(new URLSearchParams(window.location.search).get("log_id")).toBe("req-1"); + await waitFor(() => expect(urlParams().get("log_id")).toBe("req-1")); + expect(historyModes()).toEqual(["push"]); await waitFor(() => { expect(drawer()).toHaveTextContent("open"); expect(drawer()).toHaveAttribute("data-log-id", "req-1"); @@ -269,9 +267,8 @@ describe("RequestLogsPanel", () => { }); it("opens the drawer on load when ?log_id= matches a log in the loaded page", async () => { - window.history.replaceState(null, "", "/logs/?log_id=req-2"); respondWith([logEntry({ request_id: "req-1" }), logEntry({ request_id: "req-2" })]); - renderWithProviders(); + renderPanel("?log_id=req-2"); await waitFor(() => { expect(drawer()).toHaveTextContent("open"); @@ -280,13 +277,12 @@ describe("RequestLogsPanel", () => { }); it("fetches the log by request_id and opens the drawer when it is not in the loaded page", async () => { - window.history.replaceState(null, "", "/logs/?log_id=req-old"); vi.mocked(uiSpendLogsCall).mockImplementation(async ({ params }) => params?.request_id === "req-old" ? { data: [logEntry({ request_id: "req-old" })], total: 1, page: 1, page_size: 1, total_pages: 1 } : { data: [], total: 0, page: 1, page_size: 50, total_pages: 0 }, ); - renderWithProviders(); + renderPanel("?log_id=req-old"); await waitFor(() => { expect(drawer()).toHaveTextContent("open"); @@ -304,7 +300,7 @@ describe("RequestLogsPanel", () => { it("closing the drawer removes ?log_id= from the URL and closes the drawer", async () => { const user = userEvent.setup(); respondWith([logEntry({ request_id: "req-1" })]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-1")).not.toBeNull()); await user.click(row("req-1") as HTMLElement); @@ -312,14 +308,14 @@ describe("RequestLogsPanel", () => { await user.click(screen.getByRole("button", { name: "close-drawer" })); - expect(new URLSearchParams(window.location.search).get("log_id")).toBeNull(); + await waitFor(() => expect(urlParams().get("log_id")).toBeNull()); await waitFor(() => expect(drawer()).toHaveTextContent("closed")); }); it("switching logs inside the drawer replaces the URL, so back closes the drawer in one step", async () => { const user = userEvent.setup(); respondWith([logEntry({ request_id: "req-1" }), logEntry({ request_id: "req-2" })]); - renderWithProviders(); + const { goBack } = renderPanelWithHistory(); await waitFor(() => expect(row("req-1")).not.toBeNull()); await user.click(row("req-1") as HTMLElement); @@ -327,25 +323,25 @@ describe("RequestLogsPanel", () => { await user.click(screen.getByRole("button", { name: "select-next-log" })); await waitFor(() => expect(drawer()).toHaveAttribute("data-log-id", "req-2")); - expect(new URLSearchParams(window.location.search).get("log_id")).toBe("req-2"); - - window.history.back(); + expect(urlParams().get("log_id")).toBe("req-2"); + expect(historyModes()).toEqual(["push", "replace"]); + goBack(); await waitFor(() => expect(drawer()).toHaveTextContent("closed")); - expect(new URLSearchParams(window.location.search).get("log_id")).toBeNull(); + expect(drawer()).toHaveAttribute("data-log-id", ""); }); it("clicking a session id writes ?session_id= and ?log_id= and opens the session drawer", async () => { const user = userEvent.setup(); respondWith([logEntry({ request_id: "req-solo", session_id: "sess-solo", session_total_count: 1 })]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-solo")).not.toBeNull()); await user.click(within(row("req-solo") as HTMLElement).getByText("sess-solo")); - const params = new URLSearchParams(window.location.search); - expect(params.get("session_id")).toBe("sess-solo"); - expect(params.get("log_id")).toBe("req-solo"); + await waitFor(() => expect(urlParams().get("session_id")).toBe("sess-solo")); + expect(urlParams().get("log_id")).toBe("req-solo"); + expect(historyModes()).toEqual(["push"]); await waitFor(() => { expect(drawer()).toHaveTextContent("open"); expect(drawer()).toHaveAttribute("data-session-id", "sess-solo"); @@ -358,44 +354,43 @@ describe("RequestLogsPanel", () => { logEntry({ request_id: "req-a", session_id: "sess-a", session_total_count: 1 }), logEntry({ request_id: "req-b" }), ]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-a")).not.toBeNull()); await user.click(within(row("req-a") as HTMLElement).getByText("sess-a")); - await waitFor(() => expect(new URLSearchParams(window.location.search).get("session_id")).toBe("sess-a")); + await waitFor(() => expect(urlParams().get("session_id")).toBe("sess-a")); await user.click(row("req-b") as HTMLElement); - const params = new URLSearchParams(window.location.search); - expect(params.get("log_id")).toBe("req-b"); - expect(params.get("session_id")).toBeNull(); + await waitFor(() => expect(urlParams().get("log_id")).toBe("req-b")); + expect(urlParams().get("session_id")).toBeNull(); await waitFor(() => { expect(drawer()).toHaveAttribute("data-log-id", "req-b"); expect(drawer()).toHaveAttribute("data-session-id", ""); }); }); - it("browser back after opening via a session id closes the drawer", async () => { + it("closing a drawer opened via a session id clears both params", async () => { const user = userEvent.setup(); respondWith([logEntry({ request_id: "req-solo", session_id: "sess-solo", session_total_count: 1 })]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-solo")).not.toBeNull()); await user.click(within(row("req-solo") as HTMLElement).getByText("sess-solo")); await waitFor(() => expect(drawer()).toHaveTextContent("open")); - window.history.back(); + await user.click(screen.getByRole("button", { name: "close-drawer" })); await waitFor(() => expect(drawer()).toHaveTextContent("closed")); - expect(new URLSearchParams(window.location.search).get("session_id")).toBeNull(); + expect(urlParams().get("session_id")).toBeNull(); + expect(urlParams().get("log_id")).toBeNull(); }); it("opens a deep-linked multi-call session log in session mode", async () => { - window.history.replaceState(null, "", "/logs/?log_id=req-llm"); respondWith([ logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }), ]); - renderWithProviders(); + renderPanel("?log_id=req-llm"); await waitFor(() => { expect(drawer()).toHaveTextContent("open"); @@ -410,32 +405,30 @@ describe("RequestLogsPanel", () => { logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }), logEntry({ request_id: "req-llm-2", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }), ]); - renderWithProviders(); + renderPanel(); await waitFor(() => expect(row("req-llm")).not.toBeNull()); await user.click(row("req-llm") as HTMLElement); - const params = new URLSearchParams(window.location.search); - expect(params.get("session_id")).toBe("sess-1"); - expect(params.get("log_id")).toBe("req-llm"); + await waitFor(() => expect(urlParams().get("session_id")).toBe("sess-1")); + expect(urlParams().get("log_id")).toBe("req-llm"); await waitFor(() => expect(drawer()).toHaveAttribute("data-session-id", "sess-1")); }); it("selecting another log while a session view is open keeps the session open", async () => { const user = userEvent.setup(); - window.history.replaceState(null, "", "/logs/?log_id=req-llm"); respondWith([ logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }), logEntry({ request_id: "req-unenriched" }), ]); - renderWithProviders(); + renderPanel("?log_id=req-llm"); await waitFor(() => expect(drawer()).toHaveAttribute("data-session-id", "sess-1")); await user.click(screen.getByRole("button", { name: "select-next-log" })); await waitFor(() => expect(drawer()).toHaveAttribute("data-log-id", "req-unenriched")); - expect(new URLSearchParams(window.location.search).get("session_id")).toBe("sess-1"); + expect(urlParams().get("session_id")).toBe("sess-1"); expect(drawer()).toHaveAttribute("data-session-id", "sess-1"); }); }); @@ -443,7 +436,7 @@ describe("RequestLogsPanel", () => { describe("live tail", () => { it("shows the auto-refresh banner on the first page and hides it once stopped", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderPanel(); expect(await screen.findByText("Auto-refreshing every 15 seconds")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/view_logs/logDetailRouting.ts b/ui/litellm-dashboard/src/components/view_logs/logDetailRouting.ts index b37a4604585..967164053c3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/logDetailRouting.ts +++ b/ui/litellm-dashboard/src/components/view_logs/logDetailRouting.ts @@ -1,8 +1,6 @@ -import { useSearchParams } from "next/navigation"; +import { parseAsString, useQueryStates } from "nuqs"; import { useCallback } from "react"; -import { navigateWithParams } from "@/app/(dashboard)/navigateWithParams"; - export const LOG_ID_QUERY_PARAM = "log_id"; export const SESSION_ID_QUERY_PARAM = "session_id"; @@ -16,45 +14,41 @@ export interface LogDetailRouting { } export function useLogDetailRouting(): LogDetailRouting { - const searchParams = useSearchParams(); + const [{ log_id, session_id }, setParams] = useQueryStates( + { log_id: parseAsString, session_id: parseAsString }, + { history: "push" }, + ); - const openLog = useCallback((requestId: string) => { - navigateWithParams((params) => { - params.set(LOG_ID_QUERY_PARAM, requestId); - params.delete(SESSION_ID_QUERY_PARAM); - }); - }, []); + const openLog = useCallback( + (requestId: string) => { + void setParams({ log_id: requestId, session_id: null }); + }, + [setParams], + ); - const openSession = useCallback((sessionId: string, requestId: string | null) => { - navigateWithParams((params) => { - params.set(SESSION_ID_QUERY_PARAM, sessionId); - if (requestId === null) { - params.delete(LOG_ID_QUERY_PARAM); - } else { - params.set(LOG_ID_QUERY_PARAM, requestId); - } - }); - }, []); + const openSession = useCallback( + (sessionId: string, requestId: string | null) => { + void setParams({ session_id: sessionId, log_id: requestId }); + }, + [setParams], + ); - const selectLog = useCallback((requestId: string, sessionId?: string | null) => { - navigateWithParams((params) => { - params.set(LOG_ID_QUERY_PARAM, requestId); - if (sessionId) { - params.set(SESSION_ID_QUERY_PARAM, sessionId); - } - }, "replace"); - }, []); + const selectLog = useCallback( + (requestId: string, sessionId?: string | null) => { + void setParams(sessionId ? { log_id: requestId, session_id: sessionId } : { log_id: requestId }, { + history: "replace", + }); + }, + [setParams], + ); const close = useCallback(() => { - navigateWithParams((params) => { - params.delete(LOG_ID_QUERY_PARAM); - params.delete(SESSION_ID_QUERY_PARAM); - }); - }, []); + void setParams({ log_id: null, session_id: null }); + }, [setParams]); return { - logId: searchParams?.get(LOG_ID_QUERY_PARAM) ?? null, - sessionId: searchParams?.get(SESSION_ID_QUERY_PARAM) ?? null, + logId: log_id, + sessionId: session_id, openLog, openSession, selectLog, diff --git a/ui/litellm-dashboard/tests/test-utils.tsx b/ui/litellm-dashboard/tests/test-utils.tsx index ba07af0f376..2573fbdfeac 100644 --- a/ui/litellm-dashboard/tests/test-utils.tsx +++ b/ui/litellm-dashboard/tests/test-utils.tsx @@ -1,6 +1,7 @@ import React, { PropsWithChildren } from "react"; import { render, RenderOptions } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NuqsTestingAdapter, OnUrlUpdateFunction } from "nuqs/adapters/testing"; // Create a client for testing export const testQueryClient = new QueryClient({ @@ -19,11 +20,19 @@ export const testQueryClient = new QueryClient({ }, }); -const Providers: React.FC = ({ children }) => { - return {children}; +interface ProviderOptions { + searchParams?: string | Record | URLSearchParams; + onUrlUpdate?: OnUrlUpdateFunction; +} + +export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions & ProviderOptions) => { + const { searchParams, onUrlUpdate, ...renderOptions } = options ?? {}; + const Providers: React.FC = ({ children }) => ( + + {children} + + ); + return render(ui, { wrapper: Providers, ...renderOptions }); }; -export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions) => - render(ui, { wrapper: Providers, ...options }); - export * from "@testing-library/react";