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 - - - - - - - - - - - - - - - - -
- ); -}