diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 8bd7f675a0c..ec1e3ac05ba 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1142,6 +1142,11 @@ "count": 1 } }, + "src/app/(dashboard)/models-and-endpoints/layout.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts": { "prefer-const": { "count": 6 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTabRouting.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTabRouting.test.tsx new file mode 100644 index 00000000000..24900bae798 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTabRouting.test.tsx @@ -0,0 +1,82 @@ +/* @vitest-environment jsdom */ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockPush, navState } = vi.hoisted(() => ({ + mockPush: vi.fn(), + navState: { pathname: "/logs" }, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => navState.pathname, + useRouter: () => ({ push: mockPush }), +})); + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +import { createTabRoutes } from "@/utils/tabRoutes"; +import { useTabRouting } from "./useTabRouting"; + +const routes = createTabRoutes("logs", ["audit", "deleted-keys", "deleted-teams"] as const); + +const render = (ready = true) => { + const config = { + routes, + baseTabKey: "request-logs", + visibleKeys: ["audit", "deleted-keys", "deleted-teams"], + ready, + }; + return renderHook(() => useTabRouting(config)); +}; + +describe("useTabRouting", () => { + beforeEach(() => { + navState.pathname = "/logs"; + mockPush.mockClear(); + }); + + it("maps the base path to the base tab key", () => { + const { result } = render(); + expect(result.current.activeSlug).toBe(""); + expect(result.current.activeKey).toBe("request-logs"); + }); + + it("uses the slug itself as the active key for a known nested tab", () => { + navState.pathname = "/ui/logs/audit"; + const { result } = render(); + expect(result.current.activeKey).toBe("audit"); + }); + + it("falls back to the base tab key for an unknown slug", () => { + navState.pathname = "/ui/logs/bogus"; + const { result } = render(); + expect(result.current.activeKey).toBe("request-logs"); + }); + + it("redirects an unknown slug to the base href once ready", () => { + const replaceMock = vi.fn(); + const originalLocation = window.location; + Object.defineProperty(window, "location", { configurable: true, value: { replace: replaceMock } }); + navState.pathname = "/ui/logs/bogus"; + render(true); + expect(replaceMock).toHaveBeenCalledWith("/ui/logs/"); + Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); + }); + + it("does not redirect while not ready (role/creds still loading)", () => { + const replaceMock = vi.fn(); + const originalLocation = window.location; + Object.defineProperty(window, "location", { configurable: true, value: { replace: replaceMock } }); + navState.pathname = "/ui/logs/bogus"; + render(false); + expect(replaceMock).not.toHaveBeenCalled(); + Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); + }); + + it("pushes the tab href on change, mapping the base key back to the empty slug", () => { + const { result } = render(); + result.current.onTabChange("audit"); + expect(mockPush).toHaveBeenCalledWith("/ui/logs/audit/"); + result.current.onTabChange("request-logs"); + expect(mockPush).toHaveBeenCalledWith("/ui/logs/"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTabRouting.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTabRouting.ts new file mode 100644 index 00000000000..c17d71b4855 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTabRouting.ts @@ -0,0 +1,38 @@ +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import type { TabRoutes } from "@/utils/tabRoutes"; + +interface UseTabRoutingArgs { + routes: Pick, "tabHref" | "slugFromPathname">; + baseTabKey: string; + visibleKeys: readonly string[]; + ready?: boolean; +} + +interface TabRoutingState { + activeSlug: string; + activeKey: string; + onTabChange: (key: string) => void; +} + +export function useTabRouting({ routes, baseTabKey, visibleKeys, ready = true }: UseTabRoutingArgs): TabRoutingState { + const { tabHref, slugFromPathname } = routes; + const pathname = usePathname(); + const router = useRouter(); + + const activeSlug = slugFromPathname(pathname); + const isKnownSlug = activeSlug === "" || visibleKeys.includes(activeSlug); + const activeKey = isKnownSlug ? activeSlug || baseTabKey : baseTabKey; + + useEffect(() => { + if (ready && activeSlug !== "" && !isKnownSlug) { + window.location.replace(tabHref("")); + } + }, [ready, activeSlug, isKnownSlug, tabHref]); + + const onTabChange = (key: string) => { + router.push(tabHref(key === baseTabKey ? "" : key)); + }; + + return { activeSlug, activeKey, onTabChange }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.tsx index 1aea5330c5a..e855ebeb5c8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.tsx @@ -1,19 +1,19 @@ "use client"; import type { ReactNode } from "react"; -import { useEffect, useMemo, useState } from "react"; -import { usePathname, useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; import { Tabs } from "antd"; import { RefreshIcon } from "@heroicons/react/outline"; import { useQueryClient } from "@tanstack/react-query"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import { useTabRouting } from "@/app/(dashboard)/hooks/useTabRouting"; import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles"; import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner"; import ModelInfoView from "@/components/model_info_view"; import TeamInfoView from "@/components/team/TeamInfo"; -import { modelTabHref, slugFromPathname, type ModelTabSlug } from "@/app/(dashboard)/models-and-endpoints/tabRoutes"; +import { modelsRoutes, type ModelTabSlug } from "@/app/(dashboard)/models-and-endpoints/tabRoutes"; import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation"; import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData"; @@ -33,8 +33,6 @@ export default function ModelsAndEndpointsLayout({ children }: { children: React const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); const { data: teams, isLoading: teamsLoading } = useTeams(); const { data: uiSettings, isLoading: uiSettingsLoading } = useUISettings(); - const pathname = usePathname(); - const router = useRouter(); const queryClient = useQueryClient(); const { modelId, teamId, close } = useModelDetailRouting(); const { availableModelAccessGroups, allModelsOnProxy } = useModelDashboardData(); @@ -60,18 +58,13 @@ export default function ModelsAndEndpointsLayout({ children }: { children: React [shouldHideAddModelTab, isAdmin], ); - const activeSlug = slugFromPathname(pathname); - const isKnownSlug = visibleSlugs.some((slug) => slug === activeSlug); - const activeKey = isKnownSlug ? activeSlug || BASE_TAB_KEY : BASE_TAB_KEY; - - useEffect(() => { - if (teamsLoading || uiSettingsLoading) { - return; - } - if (activeSlug !== "" && !isKnownSlug) { - window.location.replace(modelTabHref("")); - } - }, [activeSlug, isKnownSlug, teamsLoading, uiSettingsLoading]); + const tabRoutingConfig = { + routes: modelsRoutes, + baseTabKey: BASE_TAB_KEY, + visibleKeys: visibleSlugs.filter(Boolean), + ready: !teamsLoading && !uiSettingsLoading, + }; + const { activeKey, onTabChange } = useTabRouting(tabRoutingConfig); const allModelsLabel = isAdmin ? "All Models" : "Your Models"; const tabItems = visibleSlugs.map((slug) => { @@ -137,7 +130,7 @@ export default function ModelsAndEndpointsLayout({ children }: { children: React ) : ( router.push(modelTabHref(key === BASE_TAB_KEY ? "" : key))} + onChange={onTabChange} items={tabItems} tabBarExtraContent={{ right: ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.ts index ddf9546c5c8..e56a664df45 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.ts @@ -1,8 +1,6 @@ -import { migratedHref } from "@/utils/migratedPages"; +import { createTabRoutes } from "@/utils/tabRoutes"; -export const MODELS_BASE_SEGMENT = "models-and-endpoints"; - -export const MODEL_TAB_SLUGS = [ +export const modelsRoutes = createTabRoutes("models-and-endpoints", [ "add", "llm-credentials", "pass-through", @@ -10,20 +8,11 @@ export const MODEL_TAB_SLUGS = [ "retry-settings", "model-group-alias", "price-data", -] as const; +] as const); -export type ModelTabSlug = (typeof MODEL_TAB_SLUGS)[number]; +export type ModelTabSlug = (typeof modelsRoutes.slugs)[number]; -export function modelTabHref(slug: string): string { - const base = migratedHref(MODELS_BASE_SEGMENT); - return slug ? `${base}/${slug}/` : `${base}/`; -} - -export function slugFromPathname(pathname: string): string { - const parts = pathname.split("/").filter(Boolean); - const idx = parts.indexOf(MODELS_BASE_SEGMENT); - if (idx === -1) { - return ""; - } - return parts[idx + 1] ?? ""; -} +export const MODELS_BASE_SEGMENT = modelsRoutes.baseSegment; +export const MODEL_TAB_SLUGS = modelsRoutes.slugs; +export const modelTabHref = modelsRoutes.tabHref; +export const slugFromPathname = modelsRoutes.slugFromPathname; diff --git a/ui/litellm-dashboard/src/utils/tabRoutes.test.ts b/ui/litellm-dashboard/src/utils/tabRoutes.test.ts new file mode 100644 index 00000000000..402be55c33a --- /dev/null +++ b/ui/litellm-dashboard/src/utils/tabRoutes.test.ts @@ -0,0 +1,47 @@ +/* @vitest-environment jsdom */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +import { createTabRoutes } from "./tabRoutes"; + +const routes = createTabRoutes("logs", ["audit", "deleted-keys", "deleted-teams"] as const); + +describe("createTabRoutes.slugFromPathname", () => { + it("returns empty string for the base path with or without a trailing slash", () => { + expect(routes.slugFromPathname("/logs")).toBe(""); + expect(routes.slugFromPathname("/logs/")).toBe(""); + }); + + it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => { + expect(routes.slugFromPathname("/logs/audit")).toBe("audit"); + expect(routes.slugFromPathname("/ui/logs/deleted-teams/")).toBe("deleted-teams"); + }); + + it("returns the raw segment for an unknown tab so the caller can redirect to base", () => { + expect(routes.slugFromPathname("/ui/logs/bogus")).toBe("bogus"); + }); + + it("returns empty string when the base segment is not in the path", () => { + expect(routes.slugFromPathname("/teams")).toBe(""); + }); +}); + +describe("createTabRoutes.tabHref", () => { + it("builds the trailing-slash base href for the empty slug", () => { + expect(routes.tabHref("")).toBe("/ui/logs/"); + }); + + it("builds a trailing-slash href for every tab slug (required by static export)", () => { + for (const slug of routes.slugs) { + expect(routes.tabHref(slug)).toBe(`/ui/logs/${slug}/`); + } + }); +}); + +describe("createTabRoutes metadata", () => { + it("preserves the base segment and slug tuple", () => { + expect(routes.baseSegment).toBe("logs"); + expect(routes.slugs).toEqual(["audit", "deleted-keys", "deleted-teams"]); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/tabRoutes.ts b/ui/litellm-dashboard/src/utils/tabRoutes.ts new file mode 100644 index 00000000000..f27b1f5d49f --- /dev/null +++ b/ui/litellm-dashboard/src/utils/tabRoutes.ts @@ -0,0 +1,26 @@ +import { migratedHref } from "@/utils/migratedPages"; + +export interface TabRoutes { + baseSegment: string; + slugs: readonly Slug[]; + tabHref: (slug: string) => string; + slugFromPathname: (pathname: string) => string; +} + +export function createTabRoutes(baseSegment: string, slugs: readonly Slug[]): TabRoutes { + const tabHref = (slug: string): string => { + const base = migratedHref(baseSegment); + return slug ? `${base}/${slug}/` : `${base}/`; + }; + + const slugFromPathname = (pathname: string): string => { + const parts = pathname.split("/").filter(Boolean); + const idx = parts.indexOf(baseSegment); + if (idx === -1) { + return ""; + } + return parts[idx + 1] ?? ""; + }; + + return { baseSegment, slugs, tabHref, slugFromPathname }; +}