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";