Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions ui/litellm-dashboard/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -4529,4 +4521,4 @@
"count": 1
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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(<TabRouteBar routes={routes} baseTabKey="analytics" activeKey={activeKey} tabs={TABS} />);

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();
});
});
Original file line number Diff line number Diff line change
@@ -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<TabRoutes<string>, "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<HTMLAnchorElement>) => {
const commandModifier = event.metaKey || event.ctrlKey;
const otherModifier = event.shiftKey || event.altKey;
if (commandModifier || otherModifier) {
return;
}
event.preventDefault();
router.push(href);
};

return (
<Tabs value={activeKey} className={className}>
<TabsList variant="line">
{tabs.map(({ key, label }) => {
const href = routes.tabHref(key === baseTabKey ? "" : key);
return (
<TabsTrigger key={key} value={key} render={<a href={href} onClick={navigate(href)} />}>
{label}
</TabsTrigger>
);
})}
</TabsList>
</Tabs>
);
}
18 changes: 18 additions & 0 deletions ui/litellm-dashboard/src/app/(dashboard)/logs/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuditLogsPanel
userID={userId}
userRole={userRole}
token={token}
accessToken={accessToken}
isActive
premiumUser={premiumUser}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import DeletedKeysPage from "@/components/DeletedKeysPage/DeletedKeysPage";

export default function DeletedKeysRoute() {
return <DeletedKeysPage />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import DeletedTeamsPage from "@/components/DeletedTeamsPage/DeletedTeamsPage";

export default function DeletedTeamsRoute() {
return <DeletedTeamsPage />;
}
83 changes: 83 additions & 0 deletions ui/litellm-dashboard/src/app/(dashboard)/logs/layout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/* @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(
<LogsLayout>
<div data-testid="tab-content">CHILD</div>
</LogsLayout>,
);

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

it("derives the active tab from a nested pathname", () => {
navState.pathname = "/ui/logs/audit";
const { getByRole } = renderLayout();
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 () => {
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();
});
});
44 changes: 44 additions & 0 deletions ui/litellm-dashboard/src/app/(dashboard)/logs/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import type { ReactNode } from "react";
import { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
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 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 { activeKey } = useTabRouting({
routes: logsRoutes,
baseTabKey: BASE_TAB_KEY,
visibleKeys: logsRoutes.slugs,
});

const hasCredentials = Boolean(accessToken && token);
const hasIdentity = Boolean(userRole && userId);

if (!hasCredentials || !hasIdentity) {
return (
<div className="flex items-center justify-center h-64">
<AntDLoadingSpinner size="large" />
</div>
);
}

return (
<div className="w-full p-6 overflow-x-hidden box-border">
<TabRouteBar routes={logsRoutes} baseTabKey={BASE_TAB_KEY} activeKey={activeKey} tabs={TABS} />
<div className="mt-4">{children}</div>
</div>
);
}
22 changes: 10 additions & 12 deletions ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SpendLogsTable
userID={userId}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
);
export default function RequestLogsPage() {
const { accessToken, token, userRole, userId } = useAuthorized();
if (!accessToken || !token) {
return null;
}
if (!userRole || !userId) {
return null;
}
return <RequestLogsPanel accessToken={accessToken} token={token} userRole={userRole} userID={userId} isActive />;
}
5 changes: 5 additions & 0 deletions ui/litellm-dashboard/src/app/(dashboard)/logs/tabRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createTabRoutes } from "@/utils/tabRoutes";

export const logsRoutes = createTabRoutes("logs", ["audit", "deleted-keys", "deleted-teams"] as const);

export type LogsTabSlug = (typeof logsRoutes.slugs)[number];
Loading
Loading