From 1211259a972822e1fd66a2eab0f84c00c4924fbb Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 23 Jul 2026 14:19:00 -0700 Subject: [PATCH 1/3] refactor(ui): give each Logs tab its own route Split the Logs page's four tabs (Request Logs, Audit Logs, Deleted Keys, Deleted Teams) into their own prerendered paths under /logs, mirroring the earlier per-tab routing migrations. A shared layout gates on credentials (the existing loading-spinner contract), 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 /logs/audit, /logs/deleted-keys and /logs/deleted-teams resolve to real static HTML with no nginx change. The former SpendLogsTable god-component (a Tremor TabGroup wrapping the four panels) is gone; its tab bar is rebuilt on the shadcn Tabs primitive. Because each tab now mounts only when its route is active, the Request Logs and Audit Logs panels receive isActive directly instead of a shared activeTab flag, which also drops the quirk where selecting Deleted Keys/Teams marked the audit-logs panel active. --- ui/litellm-dashboard/eslint-suppressions.json | 10 +- .../src/app/(dashboard)/logs/audit/page.tsx | 18 ++++ .../(dashboard)/logs/deleted-keys/page.tsx | 7 ++ .../(dashboard)/logs/deleted-teams/page.tsx | 7 ++ .../src/app/(dashboard)/logs/layout.test.tsx | 94 +++++++++++++++++++ .../src/app/(dashboard)/logs/layout.tsx | 63 +++++++++++++ .../src/app/(dashboard)/logs/page.tsx | 22 ++--- .../app/(dashboard)/logs/tabRoutes.test.ts | 38 ++++++++ .../src/app/(dashboard)/logs/tabRoutes.ts | 21 +++++ .../src/components/view_logs/index.test.tsx | 75 --------------- .../src/components/view_logs/index.tsx | 67 ------------- 11 files changed, 259 insertions(+), 163 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/logs/audit/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/logs/deleted-keys/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/logs/deleted-teams/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/logs/layout.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/logs/layout.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/index.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/index.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ec1e3ac05ba..71ee26e2326 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -4393,14 +4393,6 @@ "count": 1 } }, - "src/components/view_logs/index.tsx": { - "local/filename-pascal-case": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/view_logs/log_filter_logic.tsx": { "local/filename-pascal-case": { "count": 1 @@ -4529,4 +4521,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/audit/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/audit/page.tsx new file mode 100644 index 00000000000..1081ed83cf5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/audit/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import AuditLogsPanel from "@/components/view_logs/AuditLogsPanel"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function AuditLogsPage() { + const { accessToken, token, userRole, userId, premiumUser } = useAuthorized(); + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/deleted-keys/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/deleted-keys/page.tsx new file mode 100644 index 00000000000..a4accd8f2f9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/deleted-keys/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import DeletedKeysPage from "@/components/DeletedKeysPage/DeletedKeysPage"; + +export default function DeletedKeysRoute() { + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/deleted-teams/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/deleted-teams/page.tsx new file mode 100644 index 00000000000..4f7c597c4ae --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/deleted-teams/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import DeletedTeamsPage from "@/components/DeletedTeamsPage/DeletedTeamsPage"; + +export default function DeletedTeamsRoute() { + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.test.tsx new file mode 100644 index 00000000000..30e71487609 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.test.tsx @@ -0,0 +1,94 @@ +/* @vitest-environment jsdom */ +import { act, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import LogsLayout from "./layout"; + +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: "" })); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() })); + +const READY = { accessToken: "at", token: "tok", userRole: "Admin", userId: "u1", premiumUser: false }; + +const renderLayout = () => + render( + +
CHILD
+
, + ); + +describe("LogsLayout", () => { + beforeEach(() => { + navState.pathname = "/logs"; + mockPush.mockClear(); + mockUseAuthorized.mockReturnValue(READY); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + }); + + it("renders the four log tabs and the active tab's page content", () => { + const { getByRole, getByTestId } = renderLayout(); + for (const name of ["Request Logs", "Audit Logs", "Deleted Keys", "Deleted Teams"]) { + expect(getByRole("tab", { name })).toBeInTheDocument(); + } + expect(getByTestId("tab-content")).toHaveTextContent("CHILD"); + }); + + it("marks the base route's Request Logs tab active", () => { + const { getByRole } = renderLayout(); + expect(getByRole("tab", { name: "Request Logs" })).toHaveAttribute("aria-selected", "true"); + expect(getByRole("tab", { name: "Audit Logs" })).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: "Audit Logs" }).click(); + }); + expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/logs\/audit\/$/)); + }); + + it("routes the base tab back to the logs root (no slug)", async () => { + navState.pathname = "/logs/audit"; + const { getByRole } = renderLayout(); + await act(async () => { + getByRole("tab", { name: "Request Logs" }).click(); + }); + expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/logs\/$/)); + }); + + it("redirects to the base logs 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 = "/logs/bogus"; + await act(async () => { + renderLayout(); + }); + expect(replaceMock).toHaveBeenCalledWith(expect.stringMatching(/\/logs\/$/)); + Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); + }); + + it("shows a loading spinner and no tabs until credentials resolve", () => { + mockUseAuthorized.mockReturnValue({ ...READY, accessToken: null }); + const { container, queryByRole } = renderLayout(); + expect(container.querySelector(".ant-spin")).toBeInTheDocument(); + expect(queryByRole("tab", { name: "Request Logs" })).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.tsx new file mode 100644 index 00000000000..a31368d6623 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.tsx @@ -0,0 +1,63 @@ +"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 { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { logsTabHref, slugFromPathname, type LogsTabSlug } from "@/app/(dashboard)/logs/tabRoutes"; + +const BASE_TAB_KEY = "request-logs"; + +const ORDERED_KEYS: Array<"" | LogsTabSlug> = ["", "audit", "deleted-keys", "deleted-teams"]; + +const TAB_LABELS: Record<"" | LogsTabSlug, string> = { + "": "Request Logs", + audit: "Audit Logs", + "deleted-keys": "Deleted Keys", + "deleted-teams": "Deleted Teams", +}; + +export default function LogsLayout({ children }: { children: ReactNode }) { + const { accessToken, token, userRole, userId } = useAuthorized(); + 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(logsTabHref("")); + } + }, [activeSlug, isKnownSlug]); + + const hasCredentials = Boolean(accessToken && token); + const hasIdentity = Boolean(userRole && userId); + + if (!hasCredentials || !hasIdentity) { + return ( +
+ +
+ ); + } + + return ( +
+ router.push(logsTabHref(key === BASE_TAB_KEY ? "" : key))}> + + {ORDERED_KEYS.map((slug) => ( + + {TAB_LABELS[slug]} + + ))} + + + +
{children}
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx index 88909e3b87f..e17059c1765 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx @@ -1,17 +1,15 @@ "use client"; -import SpendLogsTable from "@/components/view_logs"; +import RequestLogsPanel from "@/components/view_logs/RequestLogsPanel"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -export default function Logs() { - const { accessToken, userRole, userId, token, premiumUser } = useAuthorized(); - return ( - - ); +export default function RequestLogsPage() { + const { accessToken, token, userRole, userId } = useAuthorized(); + if (!accessToken || !token) { + return null; + } + if (!userRole || !userId) { + return null; + } + return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.test.ts new file mode 100644 index 00000000000..2f5f14c22f8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.test.ts @@ -0,0 +1,38 @@ +/* @vitest-environment jsdom */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +import { LOGS_TAB_SLUGS, logsTabHref, slugFromPathname } from "./tabRoutes"; + +describe("slugFromPathname", () => { + it("returns empty string for the base path with or without a trailing slash", () => { + expect(slugFromPathname("/logs")).toBe(""); + expect(slugFromPathname("/logs/")).toBe(""); + }); + + it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => { + expect(slugFromPathname("/logs/audit")).toBe("audit"); + expect(slugFromPathname("/ui/logs/deleted-teams/")).toBe("deleted-teams"); + }); + + it("returns the raw segment for an unknown tab so the layout can redirect to base", () => { + expect(slugFromPathname("/ui/logs/bogus")).toBe("bogus"); + }); + + it("returns empty string when the logs base segment is not in the path", () => { + expect(slugFromPathname("/teams")).toBe(""); + }); +}); + +describe("logsTabHref", () => { + it("builds the trailing-slash base href for the empty slug", () => { + expect(logsTabHref("")).toBe("/ui/logs/"); + }); + + it("builds a trailing-slash href for every tab slug (required by static export)", () => { + for (const slug of LOGS_TAB_SLUGS) { + expect(logsTabHref(slug)).toBe(`/ui/logs/${slug}/`); + } + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.ts new file mode 100644 index 00000000000..ab672daaeda --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.ts @@ -0,0 +1,21 @@ +import { migratedHref } from "@/utils/migratedPages"; + +export const LOGS_BASE_SEGMENT = "logs"; + +export const LOGS_TAB_SLUGS = ["audit", "deleted-keys", "deleted-teams"] as const; + +export type LogsTabSlug = (typeof LOGS_TAB_SLUGS)[number]; + +export function logsTabHref(slug: string): string { + const base = migratedHref(LOGS_BASE_SEGMENT); + return slug ? `${base}/${slug}/` : `${base}/`; +} + +export function slugFromPathname(pathname: string): string { + const parts = pathname.split("/").filter(Boolean); + const idx = parts.indexOf(LOGS_BASE_SEGMENT); + if (idx === -1) { + return ""; + } + return parts[idx + 1] ?? ""; +} diff --git a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx deleted file mode 100644 index b2e77ec7fd5..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import SpendLogsTable from "./index"; -import { renderWithProviders } from "../../../tests/test-utils"; - -vi.mock("./RequestLogsPanel", () => ({ - default: function RequestLogsPanelMock({ isActive }: { isActive: boolean }) { - return
{isActive ? "active" : "inactive"}
; - }, -})); - -vi.mock("./AuditLogsPanel", () => ({ - default: function AuditLogsPanelMock({ isActive }: { isActive: boolean }) { - return
{isActive ? "active" : "inactive"}
; - }, -})); - -vi.mock("../DeletedKeysPage/DeletedKeysPage", () => ({ - default: function DeletedKeysPageMock() { - return
; - }, -})); - -vi.mock("../DeletedTeamsPage/DeletedTeamsPage", () => ({ - default: function DeletedTeamsPageMock() { - return
; - }, -})); - -const defaultProps = { - accessToken: "test-token", - token: "test-token", - userRole: "Admin", - userID: "user-1", - premiumUser: false, -}; - -describe("SpendLogsTable", () => { - it("renders the four log tabs", () => { - renderWithProviders(); - - for (const label of ["Request Logs", "Audit Logs", "Deleted Keys", "Deleted Teams"]) { - expect(screen.getByRole("tab", { name: label })).toBeInTheDocument(); - } - }); - - it("marks only the visible tab's panel active so background tabs do not query", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("active"); - - await user.click(screen.getByRole("tab", { name: "Audit Logs" })); - - expect(await screen.findByTestId("audit-logs-panel")).toHaveTextContent("active"); - expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive"); - }); - - describe("auth-not-ready guard", () => { - it("shows a loading spinner when credentials are not yet resolved", () => { - renderWithProviders(); - - expect(document.querySelector(".ant-spin")).toBeInTheDocument(); - expect(screen.queryByRole("tab", { name: "Request Logs" })).not.toBeInTheDocument(); - }); - - it("renders the tabs (no spinner) once all credentials are present", () => { - renderWithProviders(); - - expect(document.querySelector(".ant-spin")).not.toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Request Logs" })).toBeInTheDocument(); - }); - }); -}); diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx deleted file mode 100644 index 8e7423e3fae..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useState } from "react"; -import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; -import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage"; -import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage"; -import AuditLogsPanel from "./AuditLogsPanel"; -import RequestLogsPanel from "./RequestLogsPanel"; -import { AntDLoadingSpinner } from "../ui/AntDLoadingSpinner"; - -interface SpendLogsTableProps { - accessToken: string | null; - token: string | null; - userRole: string | null; - userID: string | null; - premiumUser: boolean; -} - -export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) { - const [activeTab, setActiveTab] = useState("request logs"); - - if (!accessToken || !token || !userRole || !userID) { - return ( -
- -
- ); - } - - return ( -
- setActiveTab(index === 0 ? "request logs" : "audit logs")}> - - Request Logs - Audit Logs - Deleted Keys - Deleted Teams - - - - - - - - - - - - - - - - -
- ); -} From fcbdb3b655079dab70c1720116a7df0fa07474d4 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 23 Jul 2026 14:19:00 -0700 Subject: [PATCH 2/3] fix(ui): resolve nested tab routes to their sidebar item legacyKeyForPathname matched the full relative path against a single route segment, so a nested tab route like /logs/audit resolved to no key and the shell fell back to the default page, leaving the Logs nav item unhighlighted. Match on the first path segment instead, which fixes every migrated page with nested tab routes. --- ui/litellm-dashboard/src/utils/migratedPages.test.ts | 9 +++++++++ ui/litellm-dashboard/src/utils/migratedPages.ts | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/utils/migratedPages.test.ts b/ui/litellm-dashboard/src/utils/migratedPages.test.ts index 5812c1eec40..47c6bfafc7c 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/logs/audit")).toBe("logs"); + expect(legacyKeyForPathname("/ui/logs/deleted-keys/")).toBe("logs"); + 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 73ab71ce4ac..34908a31f65 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; } From 92003610c3ef98095ea7b2badb06448707cb807b Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 23 Jul 2026 16:58:13 -0700 Subject: [PATCH 3/3] refactor(ui): adopt shared tab-routing helpers + anchor TabRouteBar in Logs Replace the page's hand-written tabRoutes.ts and layout routing engine with createTabRoutes + useTabRouting + the shared , keeping the credentials loading-spinner guard inline. The per-page tabRoutes.test.ts is dropped in favor of the central factory test. --- .../components/TabRouteBar.test.tsx | 56 +++++++++++++++++++ .../(dashboard)/components/TabRouteBar.tsx | 48 ++++++++++++++++ .../src/app/(dashboard)/logs/layout.test.tsx | 21 ++----- .../src/app/(dashboard)/logs/layout.tsx | 49 +++++----------- .../app/(dashboard)/logs/tabRoutes.test.ts | 38 ------------- .../src/app/(dashboard)/logs/tabRoutes.ts | 22 +------- 6 files changed, 127 insertions(+), 107 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/components/TabRouteBar.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/components/TabRouteBar.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.test.ts 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 00000000000..f4b149c9667 --- /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 00000000000..60cbe53e326 --- /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)/logs/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.test.tsx index 30e71487609..149a5d6a040 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.test.tsx @@ -39,7 +39,7 @@ describe("LogsLayout", () => { }; }); - it("renders the four log tabs and the active tab's page content", () => { + it("renders the four tabs and the active tab's page content", () => { const { getByRole, getByTestId } = renderLayout(); for (const name of ["Request Logs", "Audit Logs", "Deleted Keys", "Deleted Teams"]) { expect(getByRole("tab", { name })).toBeInTheDocument(); @@ -50,24 +50,13 @@ describe("LogsLayout", () => { it("marks the base route's Request Logs tab active", () => { const { getByRole } = renderLayout(); expect(getByRole("tab", { name: "Request Logs" })).toHaveAttribute("aria-selected", "true"); - expect(getByRole("tab", { name: "Audit Logs" })).toHaveAttribute("aria-selected", "false"); }); - it("navigates to a tab's path when its tab is clicked", async () => { + it("derives the active tab from a nested pathname", () => { + navState.pathname = "/ui/logs/audit"; const { getByRole } = renderLayout(); - await act(async () => { - getByRole("tab", { name: "Audit Logs" }).click(); - }); - expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/logs\/audit\/$/)); - }); - - it("routes the base tab back to the logs root (no slug)", async () => { - navState.pathname = "/logs/audit"; - const { getByRole } = renderLayout(); - await act(async () => { - getByRole("tab", { name: "Request Logs" }).click(); - }); - expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/logs\/$/)); + expect(getByRole("tab", { name: "Audit Logs" })).toHaveAttribute("aria-selected", "true"); + expect(getByRole("tab", { name: "Request Logs" })).toHaveAttribute("aria-selected", "false"); }); it("redirects to the base logs path when the tab slug is unknown", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.tsx index a31368d6623..58ef88802b5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/layout.tsx @@ -1,38 +1,28 @@ "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 { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { logsTabHref, slugFromPathname, type LogsTabSlug } from "@/app/(dashboard)/logs/tabRoutes"; +import { logsRoutes } from "@/app/(dashboard)/logs/tabRoutes"; +import { useTabRouting } from "@/app/(dashboard)/hooks/useTabRouting"; +import { TabRouteBar } from "@/app/(dashboard)/components/TabRouteBar"; const BASE_TAB_KEY = "request-logs"; -const ORDERED_KEYS: Array<"" | LogsTabSlug> = ["", "audit", "deleted-keys", "deleted-teams"]; - -const TAB_LABELS: Record<"" | LogsTabSlug, string> = { - "": "Request Logs", - audit: "Audit Logs", - "deleted-keys": "Deleted Keys", - "deleted-teams": "Deleted Teams", -}; +const TABS = [ + { key: BASE_TAB_KEY, label: "Request Logs" }, + { key: "audit", label: "Audit Logs" }, + { key: "deleted-keys", label: "Deleted Keys" }, + { key: "deleted-teams", label: "Deleted Teams" }, +] as const; export default function LogsLayout({ children }: { children: ReactNode }) { const { accessToken, token, userRole, userId } = useAuthorized(); - 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(logsTabHref("")); - } - }, [activeSlug, isKnownSlug]); + const { activeKey } = useTabRouting({ + routes: logsRoutes, + baseTabKey: BASE_TAB_KEY, + visibleKeys: logsRoutes.slugs, + }); const hasCredentials = Boolean(accessToken && token); const hasIdentity = Boolean(userRole && userId); @@ -47,16 +37,7 @@ export default function LogsLayout({ children }: { children: ReactNode }) { return (
- router.push(logsTabHref(key === BASE_TAB_KEY ? "" : key))}> - - {ORDERED_KEYS.map((slug) => ( - - {TAB_LABELS[slug]} - - ))} - - - +
{children}
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.test.ts deleted file mode 100644 index 2f5f14c22f8..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* @vitest-environment jsdom */ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@/components/networking", () => ({ serverRootPath: "" })); - -import { LOGS_TAB_SLUGS, logsTabHref, slugFromPathname } from "./tabRoutes"; - -describe("slugFromPathname", () => { - it("returns empty string for the base path with or without a trailing slash", () => { - expect(slugFromPathname("/logs")).toBe(""); - expect(slugFromPathname("/logs/")).toBe(""); - }); - - it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => { - expect(slugFromPathname("/logs/audit")).toBe("audit"); - expect(slugFromPathname("/ui/logs/deleted-teams/")).toBe("deleted-teams"); - }); - - it("returns the raw segment for an unknown tab so the layout can redirect to base", () => { - expect(slugFromPathname("/ui/logs/bogus")).toBe("bogus"); - }); - - it("returns empty string when the logs base segment is not in the path", () => { - expect(slugFromPathname("/teams")).toBe(""); - }); -}); - -describe("logsTabHref", () => { - it("builds the trailing-slash base href for the empty slug", () => { - expect(logsTabHref("")).toBe("/ui/logs/"); - }); - - it("builds a trailing-slash href for every tab slug (required by static export)", () => { - for (const slug of LOGS_TAB_SLUGS) { - expect(logsTabHref(slug)).toBe(`/ui/logs/${slug}/`); - } - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.ts index ab672daaeda..070edc87929 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.ts @@ -1,21 +1,5 @@ -import { migratedHref } from "@/utils/migratedPages"; +import { createTabRoutes } from "@/utils/tabRoutes"; -export const LOGS_BASE_SEGMENT = "logs"; +export const logsRoutes = createTabRoutes("logs", ["audit", "deleted-keys", "deleted-teams"] as const); -export const LOGS_TAB_SLUGS = ["audit", "deleted-keys", "deleted-teams"] as const; - -export type LogsTabSlug = (typeof LOGS_TAB_SLUGS)[number]; - -export function logsTabHref(slug: string): string { - const base = migratedHref(LOGS_BASE_SEGMENT); - return slug ? `${base}/${slug}/` : `${base}/`; -} - -export function slugFromPathname(pathname: string): string { - const parts = pathname.split("/").filter(Boolean); - const idx = parts.indexOf(LOGS_BASE_SEGMENT); - if (idx === -1) { - return ""; - } - return parts[idx + 1] ?? ""; -} +export type LogsTabSlug = (typeof logsRoutes.slugs)[number];