From e6b286250794e129f1483649df6869a1889b5655 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 23 Jul 2026 13:53:54 -0700 Subject: [PATCH] refactor(ui): give each Router Settings tab its own route Split the Router Settings page's five tabs (Loadbalancing, Routing Groups, Fallbacks, Prompt Caching, General) into their own prerendered paths under /router-settings, mirroring the earlier per-tab routing migrations. A shared layout renders the tab bar and derives the active tab from the pathname; each tab is its own page.tsx, so deep links and hard-loads to /router-settings/fallbacks, /prompt-caching, /general and /routing-groups resolve to real static HTML with no nginx change. The former GeneralSettings god-component (a Tremor TabGroup wrapping all five panels) is gone. Its two registry-backed tabs, Prompt Caching and General, shared the general-settings config fetch and mutations; that logic moves into a useGeneralSettings hook, and each tab becomes a self-contained component (PromptCachingSettingsTab, GeneralConfigTab) that owns its own data. The tab bar is rebuilt on the shadcn Tabs primitive. PromptCachingPanel and generalSettingsItem stay exported because the Cost Optimization page reuses them. --- ui/litellm-dashboard/eslint-suppressions.json | 8 - .../_components/general_settings.test.tsx | 22 +- .../_components/general_settings.tsx | 216 +++++++----------- .../router-settings/fallbacks/page.tsx | 9 + .../router-settings/general/page.tsx | 12 + .../router-settings/layout.test.tsx | 81 +++++++ .../(dashboard)/router-settings/layout.tsx | 64 ++++++ .../app/(dashboard)/router-settings/page.tsx | 4 +- .../router-settings/prompt-caching/page.tsx | 12 + .../router-settings/routing-groups/page.tsx | 7 + .../router-settings/tabRoutes.test.ts | 38 +++ .../(dashboard)/router-settings/tabRoutes.ts | 21 ++ 12 files changed, 336 insertions(+), 158 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/router-settings/fallbacks/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/router-settings/general/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/router-settings/prompt-caching/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/router-settings/routing-groups/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.ts diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ec1e3ac05ba..28b4a1e0469 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -241,11 +241,6 @@ "count": 1 } }, - "src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx": { "no-restricted-imports": { "count": 1 @@ -1804,9 +1799,6 @@ "count": 1 }, "no-restricted-imports": { - "count": 3 - }, - "prefer-const": { "count": 2 } }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx index c4cfa98b2a1..f0b75358445 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx @@ -1,7 +1,7 @@ import { renderWithProviders, screen, within } from "../../../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; -import GeneralSettings from "./general_settings"; +import { GeneralConfigTab } from "./general_settings"; import { deleteConfigFieldSetting, getGeneralSettingsCall, updateConfigFieldSetting } from "@/components/networking"; vi.mock("@/components/networking", () => ({ @@ -10,10 +10,6 @@ vi.mock("@/components/networking", () => ({ deleteConfigFieldSetting: vi.fn().mockResolvedValue({}), })); -vi.mock("@/components/router_settings", () => ({ default: () => null })); -vi.mock("@/components/Settings/RouterSettings/Fallbacks/Fallbacks", () => ({ default: () => null })); -vi.mock("@/components/routing_groups", () => ({ default: () => null })); - // Mirrors the /config/list ordering: the two prompt-caching rows sit between the // General-tab rows in the unfiltered response but are filtered out of the General // tab's table, so any index-based lookup into the unfiltered array reads the wrong @@ -63,7 +59,7 @@ const settingsRow = async (fieldName: string) => { return row as HTMLElement; }; -describe("GeneralSettings General tab", () => { +describe("GeneralConfigTab", () => { beforeEach(() => { vi.mocked(getGeneralSettingsCall).mockResolvedValue([...SETTINGS_FIXTURE.map((s) => ({ ...s }))]); vi.mocked(updateConfigFieldSetting).mockClear(); @@ -72,9 +68,8 @@ describe("GeneralSettings General tab", () => { it("updates max_ui_session_budget with its own value, not the value at its filtered index", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(); - await user.click(screen.getByText("General")); const row = await settingsRow("max_ui_session_budget"); await user.click(within(row).getByRole("button", { name: /update/i })); @@ -84,9 +79,8 @@ describe("GeneralSettings General tab", () => { it("reset shows the field's default value instead of an empty input", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(); - await user.click(screen.getByText("General")); const row = await settingsRow("max_ui_session_budget"); expect(within(row).getByRole("spinbutton")).toHaveValue("7.50"); @@ -98,4 +92,12 @@ describe("GeneralSettings General tab", () => { expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget"); expect(within(row).getByRole("spinbutton")).toHaveValue("1.00"); }); + + it("hides prompt-caching-tab rows from the General table", async () => { + renderWithProviders(); + + await settingsRow("max_ui_session_budget"); + expect(screen.queryByText("enable_anthropic_prompt_caching")).not.toBeInTheDocument(); + expect(screen.queryByText("anthropic_prompt_caching_ttl")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx index fa3447e0cbf..42af400f230 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx @@ -13,26 +13,15 @@ import { Icon, Switch, } from "@tremor/react"; -import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react"; import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking"; import { InputNumber, Select as AntdSelect } from "antd"; import { TrashIcon } from "@heroicons/react/outline"; import { StatusBadge } from "@/components/shared/table_cells"; -import RouterSettings from "@/components/router_settings"; -import Fallbacks from "@/components/Settings/RouterSettings/Fallbacks/Fallbacks"; -import RoutingGroups from "@/components/routing_groups"; - const PROMPT_CACHING_TAB = "prompt_caching"; const ENABLE_ANTHROPIC_PROMPT_CACHING = "enable_anthropic_prompt_caching"; const ANTHROPIC_PROMPT_CACHING_TTL = "anthropic_prompt_caching_ttl"; -interface GeneralSettingsPageProps { - accessToken: string | null; - userRole: string | null; - userID: string | null; -} - export interface generalSettingsItem { field_name: string; field_type: string; @@ -162,154 +151,105 @@ export const PromptCachingPanel: React.FC<{ ); }; -const GeneralSettings: React.FC = ({ accessToken, userRole, userID }) => { +const useGeneralSettings = (accessToken: string) => { const [generalSettings, setGeneralSettings] = useState([]); useEffect(() => { - if (!accessToken) { - return; - } getGeneralSettingsCall(accessToken).then((data) => { - let general_settings = data; - setGeneralSettings(general_settings); + setGeneralSettings(data); }); }, [accessToken]); const handleInputChange = (fieldName: string, newValue: any) => { - // Update the value in the state - const updatedSettings = generalSettings.map((setting) => - setting.field_name === fieldName ? { ...setting, field_value: newValue } : setting, + setGeneralSettings((prev) => + prev.map((setting) => (setting.field_name === fieldName ? { ...setting, field_value: newValue } : setting)), ); - setGeneralSettings(updatedSettings); }; const handleUpdateField = (fieldName: string) => { - if (!accessToken) { + const fieldValue = generalSettings.find((setting) => setting.field_name === fieldName)?.field_value; + if (fieldValue == null) { return; } - - let fieldValue = generalSettings.find((setting) => setting.field_name === fieldName)?.field_value; - - if (fieldValue == null || fieldValue == undefined) { - return; - } - try { - updateConfigFieldSetting(accessToken, fieldName, fieldValue); - // update value in state - - const updatedSettings = generalSettings.map((setting) => - setting.field_name === fieldName ? { ...setting, stored_in_db: true } : setting, - ); - setGeneralSettings(updatedSettings); - } catch (error) { - // do something - } + updateConfigFieldSetting(accessToken, fieldName, fieldValue); + setGeneralSettings((prev) => + prev.map((setting) => (setting.field_name === fieldName ? { ...setting, stored_in_db: true } : setting)), + ); }; const handleResetField = (fieldName: string) => { - if (!accessToken) { - return; - } - - try { - deleteConfigFieldSetting(accessToken, fieldName); - // update value in state - - const updatedSettings = generalSettings.map((setting) => + deleteConfigFieldSetting(accessToken, fieldName); + setGeneralSettings((prev) => + prev.map((setting) => setting.field_name === fieldName ? { ...setting, stored_in_db: null, field_value: setting.field_default_value ?? null } : setting, - ); - setGeneralSettings(updatedSettings); - } catch (error) { - // do something - } + ), + ); }; - if (!accessToken) { - return null; - } - - return ( -
- - - Loadbalancing - Routing Groups - Fallbacks - Prompt Caching - General - - - - - - - - - - - - - - - - - - - - Setting - Value - Status - Action - - - - {generalSettings - .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB) - .map((value, index) => ( - - - {value.field_name} -

- {value.field_description} -

-
- - - - - {value.stored_in_db == true ? ( - - ) : value.stored_in_db == false ? ( - - ) : ( - - )} - - - - handleResetField(value.field_name)}> - Reset - - -
- ))} -
-
-
-
-
-
-
- ); + return { generalSettings, handleInputChange, handleUpdateField, handleResetField }; }; -export default GeneralSettings; +export const PromptCachingSettingsTab: React.FC<{ accessToken: string }> = ({ accessToken }) => { + const { generalSettings, handleInputChange } = useGeneralSettings(accessToken); + return ; +}; + +export const GeneralConfigTab: React.FC<{ accessToken: string }> = ({ accessToken }) => { + const { generalSettings, handleInputChange, handleUpdateField, handleResetField } = useGeneralSettings(accessToken); + + return ( + + + + + Setting + Value + Status + Action + + + + {generalSettings + .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB) + .map((value, index) => ( + + + {value.field_name} +

+ {value.field_description} +

+
+ + + + + {value.stored_in_db == true ? ( + + ) : value.stored_in_db == false ? ( + + ) : ( + + )} + + + + handleResetField(value.field_name)}> + Reset + + +
+ ))} +
+
+
+ ); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/fallbacks/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/fallbacks/page.tsx new file mode 100644 index 00000000000..04bc917d015 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/fallbacks/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import Fallbacks from "@/components/Settings/RouterSettings/Fallbacks/Fallbacks"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function FallbacksPage() { + const { accessToken, userRole, userId } = useAuthorized(); + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/general/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/general/page.tsx new file mode 100644 index 00000000000..d94056add4a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/general/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { GeneralConfigTab } from "@/app/(dashboard)/router-settings/_components/general_settings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function GeneralConfigPage() { + const { accessToken } = useAuthorized(); + if (!accessToken) { + return null; + } + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.test.tsx new file mode 100644 index 00000000000..0fda48b9d5a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.test.tsx @@ -0,0 +1,81 @@ +/* @vitest-environment jsdom */ +import { act, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import RouterSettingsLayout from "./layout"; + +const { mockPush, navState } = vi.hoisted(() => ({ + mockPush: vi.fn(), + navState: { pathname: "/router-settings" }, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => navState.pathname, + useRouter: () => ({ push: mockPush }), +})); + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +const renderLayout = () => + render( + +
CHILD
+
, + ); + +describe("RouterSettingsLayout", () => { + beforeEach(() => { + navState.pathname = "/router-settings"; + mockPush.mockClear(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + }); + + it("renders the tab bar and the active tab's page content", () => { + const { getByRole, getByTestId } = renderLayout(); + for (const name of ["Loadbalancing", "Routing Groups", "Fallbacks", "Prompt Caching", "General"]) { + expect(getByRole("tab", { name })).toBeInTheDocument(); + } + expect(getByTestId("tab-content")).toHaveTextContent("CHILD"); + }); + + it("marks the base route's Loadbalancing tab active", () => { + const { getByRole } = renderLayout(); + expect(getByRole("tab", { name: "Loadbalancing" })).toHaveAttribute("aria-selected", "true"); + expect(getByRole("tab", { name: "General" })).toHaveAttribute("aria-selected", "false"); + }); + + it("navigates to a tab's path when its tab is clicked", async () => { + const { getByRole } = renderLayout(); + await act(async () => { + getByRole("tab", { name: "Prompt Caching" }).click(); + }); + expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/router-settings\/prompt-caching\/$/)); + }); + + it("routes the base tab back to the router-settings root (no slug)", async () => { + navState.pathname = "/router-settings/general"; + const { getByRole } = renderLayout(); + await act(async () => { + getByRole("tab", { name: "Loadbalancing" }).click(); + }); + expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/router-settings\/$/)); + }); + + it("redirects to the base router-settings path when the tab slug is unknown", async () => { + const replaceMock = vi.fn(); + const originalLocation = window.location; + Object.defineProperty(window, "location", { + configurable: true, + value: { replace: replaceMock, assign: vi.fn(), href: "http://localhost/", pathname: "/", search: "" }, + }); + navState.pathname = "/router-settings/bogus"; + await act(async () => { + renderLayout(); + }); + expect(replaceMock).toHaveBeenCalledWith(expect.stringMatching(/\/router-settings\/$/)); + Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.tsx new file mode 100644 index 00000000000..6f5cfef99d3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.tsx @@ -0,0 +1,64 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + routerSettingsTabHref, + slugFromPathname, + type RouterSettingsTabSlug, +} from "@/app/(dashboard)/router-settings/tabRoutes"; + +const BASE_TAB_KEY = "loadbalancing"; + +const ORDERED_KEYS: Array<"" | RouterSettingsTabSlug> = [ + "", + "routing-groups", + "fallbacks", + "prompt-caching", + "general", +]; + +const TAB_LABELS: Record<"" | RouterSettingsTabSlug, string> = { + "": "Loadbalancing", + "routing-groups": "Routing Groups", + fallbacks: "Fallbacks", + "prompt-caching": "Prompt Caching", + general: "General", +}; + +export default function RouterSettingsLayout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + + const activeSlug = slugFromPathname(pathname); + const isKnownSlug = ORDERED_KEYS.some((slug) => slug === activeSlug); + const activeKey = isKnownSlug ? activeSlug || BASE_TAB_KEY : BASE_TAB_KEY; + + useEffect(() => { + if (activeSlug !== "" && !isKnownSlug) { + window.location.replace(routerSettingsTabHref("")); + } + }, [activeSlug, isKnownSlug]); + + return ( +
+ router.push(routerSettingsTabHref(key === BASE_TAB_KEY ? "" : key))} + className="px-8 pt-4" + > + + {ORDERED_KEYS.map((slug) => ( + + {TAB_LABELS[slug]} + + ))} + + + +
{children}
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx index 90f41ac58a4..869be8548b3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx @@ -1,9 +1,9 @@ "use client"; -import GeneralSettings from "./_components/general_settings"; +import RouterSettings from "@/components/router_settings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function RouterSettingsPage() { const { accessToken, userRole, userId } = useAuthorized(); - return ; + return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/prompt-caching/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/prompt-caching/page.tsx new file mode 100644 index 00000000000..1a7d689e085 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/prompt-caching/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PromptCachingSettingsTab } from "@/app/(dashboard)/router-settings/_components/general_settings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function PromptCachingPage() { + const { accessToken } = useAuthorized(); + if (!accessToken) { + return null; + } + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/routing-groups/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/routing-groups/page.tsx new file mode 100644 index 00000000000..e972c84204f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/routing-groups/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import RoutingGroups from "@/components/routing_groups"; + +export default function RoutingGroupsPage() { + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.test.ts new file mode 100644 index 00000000000..e4d1880c5e4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.test.ts @@ -0,0 +1,38 @@ +/* @vitest-environment jsdom */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +import { ROUTER_SETTINGS_TAB_SLUGS, routerSettingsTabHref, slugFromPathname } from "./tabRoutes"; + +describe("slugFromPathname", () => { + it("returns empty string for the base path with or without a trailing slash", () => { + expect(slugFromPathname("/router-settings")).toBe(""); + expect(slugFromPathname("/router-settings/")).toBe(""); + }); + + it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => { + expect(slugFromPathname("/router-settings/fallbacks")).toBe("fallbacks"); + expect(slugFromPathname("/ui/router-settings/prompt-caching/")).toBe("prompt-caching"); + }); + + it("returns the raw segment for an unknown tab so the layout can redirect to base", () => { + expect(slugFromPathname("/ui/router-settings/bogus")).toBe("bogus"); + }); + + it("returns empty string when the router-settings base segment is not in the path", () => { + expect(slugFromPathname("/teams")).toBe(""); + }); +}); + +describe("routerSettingsTabHref", () => { + it("builds the trailing-slash base href for the empty slug", () => { + expect(routerSettingsTabHref("")).toBe("/ui/router-settings/"); + }); + + it("builds a trailing-slash href for every tab slug (required by static export)", () => { + for (const slug of ROUTER_SETTINGS_TAB_SLUGS) { + expect(routerSettingsTabHref(slug)).toBe(`/ui/router-settings/${slug}/`); + } + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.ts new file mode 100644 index 00000000000..9761d6f43ab --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.ts @@ -0,0 +1,21 @@ +import { migratedHref } from "@/utils/migratedPages"; + +export const ROUTER_SETTINGS_BASE_SEGMENT = "router-settings"; + +export const ROUTER_SETTINGS_TAB_SLUGS = ["routing-groups", "fallbacks", "prompt-caching", "general"] as const; + +export type RouterSettingsTabSlug = (typeof ROUTER_SETTINGS_TAB_SLUGS)[number]; + +export function routerSettingsTabHref(slug: string): string { + const base = migratedHref(ROUTER_SETTINGS_BASE_SEGMENT); + return slug ? `${base}/${slug}/` : `${base}/`; +} + +export function slugFromPathname(pathname: string): string { + const parts = pathname.split("/").filter(Boolean); + const idx = parts.indexOf(ROUTER_SETTINGS_BASE_SEGMENT); + if (idx === -1) { + return ""; + } + return parts[idx + 1] ?? ""; +}