diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/TabRouteBar.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/TabRouteBar.test.tsx new file mode 100644 index 000000000000..f4b149c96670 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/TabRouteBar.test.tsx @@ -0,0 +1,56 @@ +/* @vitest-environment jsdom */ +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockPush } = vi.hoisted(() => ({ mockPush: vi.fn() })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush }) })); +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +import { createTabRoutes } from "@/utils/tabRoutes"; +import { TabRouteBar } from "./TabRouteBar"; + +const routes = createTabRoutes("caching", ["health", "settings"] as const); +const TABS = [ + { key: "analytics", label: "Cache Analytics" }, + { key: "health", label: "Cache Health" }, + { key: "settings", label: "Cache Settings" }, +]; + +const renderBar = (activeKey = "analytics") => + render(); + +describe("TabRouteBar", () => { + beforeEach(() => { + mockPush.mockClear(); + }); + + it("renders each tab as an anchor with its trailing-slash href (base tab maps to the root)", () => { + const { getByRole } = renderBar(); + expect(getByRole("tab", { name: "Cache Analytics" })).toHaveAttribute("href", "/ui/caching/"); + expect(getByRole("tab", { name: "Cache Health" })).toHaveAttribute("href", "/ui/caching/health/"); + expect(getByRole("tab", { name: "Cache Settings" })).toHaveAttribute("href", "/ui/caching/settings/"); + }); + + it("marks the active tab selected from activeKey", () => { + const { getByRole } = renderBar("health"); + expect(getByRole("tab", { name: "Cache Health" })).toHaveAttribute("aria-selected", "true"); + expect(getByRole("tab", { name: "Cache Analytics" })).toHaveAttribute("aria-selected", "false"); + }); + + it("soft-navigates on a plain left click (preventing the full-page anchor load)", async () => { + const user = userEvent.setup(); + const { getByRole } = renderBar(); + await user.click(getByRole("tab", { name: "Cache Health" })); + expect(mockPush).toHaveBeenCalledWith("/ui/caching/health/"); + }); + + it("lets the browser handle a modifier-click so open-in-new-tab works", async () => { + const user = userEvent.setup(); + const { getByRole } = renderBar(); + await user.keyboard("[ControlLeft>]"); + await user.click(getByRole("tab", { name: "Cache Health" })); + await user.keyboard("[/ControlLeft]"); + expect(mockPush).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/TabRouteBar.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/TabRouteBar.tsx new file mode 100644 index 000000000000..60cbe53e3264 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/TabRouteBar.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { MouseEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { TabRoutes } from "@/utils/tabRoutes"; + +export interface TabRouteItem { + key: string; + label: string; +} + +interface TabRouteBarProps { + routes: Pick, "tabHref">; + baseTabKey: string; + activeKey: string; + tabs: readonly TabRouteItem[]; + className?: string; +} + +export function TabRouteBar({ routes, baseTabKey, activeKey, tabs, className }: TabRouteBarProps) { + const router = useRouter(); + + const navigate = (href: string) => (event: MouseEvent) => { + const commandModifier = event.metaKey || event.ctrlKey; + const otherModifier = event.shiftKey || event.altKey; + if (commandModifier || otherModifier) { + return; + } + event.preventDefault(); + router.push(href); + }; + + return ( + + + {tabs.map(({ key, label }) => { + const href = routes.tabHref(key === baseTabKey ? "" : key); + return ( + }> + {label} + + ); + })} + + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx deleted file mode 100644 index 46aa23fcfc0f..000000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { fireEvent, render } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("./UsageTab", () => ({ __esModule: true, default: () =>
})); -vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () =>
})); -vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () =>
})); -vi.mock("./PromptCachingTab", () => ({ __esModule: true, default: () =>
})); - -import CostOptimizationView from "./CostOptimizationView"; - -const renderView = () => render(); - -describe("CostOptimizationView", () => { - it("renders all four cost-optimization tabs", () => { - const { getByText } = renderView(); - - expect(getByText("Usage")).toBeInTheDocument(); - expect(getByText("Prompt Compression")).toBeInTheDocument(); - expect(getByText("Autorouter")).toBeInTheDocument(); - expect(getByText("Prompt Caching")).toBeInTheDocument(); - }); - - it("defaults to the Usage tab and switches the active tab on click", () => { - const { getByRole } = renderView(); - - expect(getByRole("tab", { name: "Usage" })).toHaveAttribute("aria-selected", "true"); - expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "false"); - - fireEvent.click(getByRole("tab", { name: "Prompt Compression" })); - - expect(getByRole("tab", { name: "Usage" })).toHaveAttribute("aria-selected", "false"); - expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "true"); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx deleted file mode 100644 index 3bab6afee57e..000000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ /dev/null @@ -1,81 +0,0 @@ -"use client"; - -import React from "react"; -import { PiggyBank } from "lucide-react"; -import { Alert, Tabs } from "antd"; - -import UsageTab from "./UsageTab"; -import PromptCompressionTab from "./PromptCompressionTab"; -import AutorouterTab from "./AutorouterTab"; -import PromptCachingTab from "./PromptCachingTab"; -import { useDailyActivityRange } from "./useDailyActivityRange"; - -interface CostOptimizationViewProps { - accessToken: string | null; - userId: string | null; - userRole: string; -} - -const CostOptimizationView: React.FC = ({ accessToken, userId, userRole }) => { - const activity = useDailyActivityRange(accessToken, userId, userRole); - - const items = [ - { - key: "usage", - label: "Usage", - children: , - }, - { - key: "compression", - label: "Prompt Compression", - children: , - }, - { - key: "autorouter", - label: "Autorouter", - children: , - }, - { - key: "caching", - label: "Prompt Caching", - children: , - }, - ]; - - return ( -
-
-
- -

Cost Optimization

-
-

- Track and configure the mechanisms that save you money: prompt compression, prompt caching, and auto routing -

-
- - - Have feedback? Join the discussion{" "} - - here - - - } - /> - - -
- ); -}; - -export default CostOptimizationView; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/autorouter/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/autorouter/page.tsx new file mode 100644 index 000000000000..6cf9700fdcbd --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/autorouter/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import AutorouterTab from "@/app/(dashboard)/cost-optimization/_components/AutorouterTab"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function AutorouterPage() { + const { accessToken, userId, userRole } = useAuthorized(); + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/caching/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/caching/page.tsx new file mode 100644 index 000000000000..52a4a33eb930 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/caching/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import PromptCachingTab from "@/app/(dashboard)/cost-optimization/_components/PromptCachingTab"; +import { useDailyActivityRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function PromptCachingPage() { + const { accessToken, userId, userRole } = useAuthorized(); + const activity = useDailyActivityRange(accessToken, userId, userRole); + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/compression/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/compression/page.tsx new file mode 100644 index 000000000000..6bfbff16d84b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/compression/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import PromptCompressionTab from "@/app/(dashboard)/cost-optimization/_components/PromptCompressionTab"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function PromptCompressionPage() { + const { accessToken } = useAuthorized(); + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/layout.test.tsx new file mode 100644 index 000000000000..640179039fe4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/layout.test.tsx @@ -0,0 +1,70 @@ +/* @vitest-environment jsdom */ +import { act, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CostOptimizationLayout from "./layout"; + +const { mockPush, navState } = vi.hoisted(() => ({ + mockPush: vi.fn(), + navState: { pathname: "/cost-optimization" }, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => navState.pathname, + useRouter: () => ({ push: mockPush }), +})); + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +const renderLayout = () => + render( + +
CHILD
+
, + ); + +describe("CostOptimizationLayout", () => { + beforeEach(() => { + navState.pathname = "/cost-optimization"; + mockPush.mockClear(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + }); + + it("renders the four tabs and the active tab's page content", () => { + const { getByRole, getByTestId } = renderLayout(); + for (const name of ["Usage", "Prompt Compression", "Autorouter", "Prompt Caching"]) { + expect(getByRole("tab", { name })).toBeInTheDocument(); + } + expect(getByTestId("tab-content")).toHaveTextContent("CHILD"); + }); + + it("marks the base route's Usage tab active", () => { + const { getByRole } = renderLayout(); + expect(getByRole("tab", { name: "Usage" })).toHaveAttribute("aria-selected", "true"); + }); + + it("derives the active tab from a nested pathname", () => { + navState.pathname = "/ui/cost-optimization/compression"; + const { getByRole } = renderLayout(); + expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "true"); + expect(getByRole("tab", { name: "Usage" })).toHaveAttribute("aria-selected", "false"); + }); + + it("redirects to the base cost-optimization 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 = "/cost-optimization/bogus"; + await act(async () => { + renderLayout(); + }); + expect(replaceMock).toHaveBeenCalledWith(expect.stringMatching(/\/cost-optimization\/$/)); + Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/layout.tsx new file mode 100644 index 000000000000..420d382e015d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/layout.tsx @@ -0,0 +1,57 @@ +"use client"; + +import type { ReactNode } from "react"; +import { PiggyBank, Info } from "lucide-react"; +import { costOptimizationRoutes } from "@/app/(dashboard)/cost-optimization/tabRoutes"; +import { useTabRouting } from "@/app/(dashboard)/hooks/useTabRouting"; +import { TabRouteBar } from "@/app/(dashboard)/components/TabRouteBar"; + +const BASE_TAB_KEY = "usage"; + +const TABS = [ + { key: BASE_TAB_KEY, label: "Usage" }, + { key: "compression", label: "Prompt Compression" }, + { key: "autorouter", label: "Autorouter" }, + { key: "caching", label: "Prompt Caching" }, +] as const; + +export default function CostOptimizationLayout({ children }: { children: ReactNode }) { + const { activeKey } = useTabRouting({ + routes: costOptimizationRoutes, + baseTabKey: BASE_TAB_KEY, + visibleKeys: costOptimizationRoutes.slugs, + }); + + return ( +
+
+
+ +

Cost Optimization

+
+

+ Track and configure the mechanisms that save you money: prompt compression, prompt caching, and auto routing +

+
+ +
+ + + This is an experimental dashboard. Have feedback? Join the discussion{" "} + + here + + +
+ + + +
{children}
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/page.tsx index e82cc633ae27..10afd198db2b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/page.tsx @@ -1,9 +1,11 @@ "use client"; -import CostOptimizationView from "./_components/CostOptimizationView"; +import UsageTab from "./_components/UsageTab"; +import { useDailyActivityRange } from "./_components/useDailyActivityRange"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function CostOptimizationPage() { const { accessToken, userId, userRole } = useAuthorized(); - return ; + const activity = useDailyActivityRange(accessToken, userId, userRole); + return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/tabRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/tabRoutes.ts new file mode 100644 index 000000000000..76f0f4e52067 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/tabRoutes.ts @@ -0,0 +1,9 @@ +import { createTabRoutes } from "@/utils/tabRoutes"; + +export const costOptimizationRoutes = createTabRoutes("cost-optimization", [ + "compression", + "autorouter", + "caching", +] as const); + +export type CostOptimizationTabSlug = (typeof costOptimizationRoutes.slugs)[number]; diff --git a/ui/litellm-dashboard/src/utils/migratedPages.test.ts b/ui/litellm-dashboard/src/utils/migratedPages.test.ts index 5812c1eec40d..f4b118748129 100644 --- a/ui/litellm-dashboard/src/utils/migratedPages.test.ts +++ b/ui/litellm-dashboard/src/utils/migratedPages.test.ts @@ -236,4 +236,13 @@ describe("legacyKeyForPathname", () => { expect(legacyKeyForPathname("/team-x/ui/api-reference")).toBe("api_ref"); expect(legacyKeyForPathname("/ui/api-reference")).toBeNull(); }); + + it("resolves a nested tab route to its sidebar key via the first path segment", async () => { + vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); + const { legacyKeyForPathname } = await import("./migratedPages"); + + expect(legacyKeyForPathname("/ui/cost-optimization/compression")).toBe("cost-optimization"); + expect(legacyKeyForPathname("/ui/cost-optimization/caching/")).toBe("cost-optimization"); + expect(legacyKeyForPathname("/ui/some-legacy-page/nested")).toBeNull(); + }); }); diff --git a/ui/litellm-dashboard/src/utils/migratedPages.ts b/ui/litellm-dashboard/src/utils/migratedPages.ts index 73ab71ce4ac3..34908a31f659 100644 --- a/ui/litellm-dashboard/src/utils/migratedPages.ts +++ b/ui/litellm-dashboard/src/utils/migratedPages.ts @@ -76,8 +76,10 @@ export function legacyPageHref(pageKey: string): string { export function legacyKeyForPathname(pathname: string): string | null { const base = uiBase(); const rel = (pathname.startsWith(base) ? pathname.slice(base.length) : pathname).replace(/^\/+|\/+$/g, ""); + const firstSegment = rel.split("/")[0] ?? ""; + if (!firstSegment) return null; for (const [key, segment] of Object.entries(MIGRATED_PAGES)) { - if (rel === segment) return key; + if (firstSegment === segment) return key; } return null; }