From 1211259a972822e1fd66a2eab0f84c00c4924fbb Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 23 Jul 2026 14:19:00 -0700 Subject: [PATCH] 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 - - - - - - - - - - - - - - - - -
- ); -}