Skip to content
Merged
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
14 changes: 14 additions & 0 deletions ui/litellm-dashboard/src/app/chat/logs/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"use client";

import { useChatShell } from "@/contexts/ChatShellContext";
import LogsPanel from "@/components/chat/LogsPanel";

export default function LogsPage() {
const { accessToken, userId } = useChatShell();

return (
<div className="flex-1 min-h-0 overflow-auto w-full py-8 px-8">
<LogsPanel accessToken={accessToken} userId={userId} />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("getChatRoutes under server_root_path", () => {
expect(routes.integrations).toBe("/gw/ui/chat/integrations");
expect(routes.credentials).toBe("/gw/ui/chat/credentials");
expect(routes.apiKeys).toBe("/gw/ui/chat/api-keys");
expect(routes.logs).toBe("/gw/ui/chat/logs");
expect(routes.usage).toBe("/gw/ui/chat/usage");
});

Expand Down
14 changes: 14 additions & 0 deletions ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ describe("ChatShell", () => {

fireEvent.click(screen.getByRole("button", { name: "Usage" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/usage");

fireEvent.click(screen.getByRole("button", { name: "Logs" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/logs");
});

it("marks Logs active on the logs route", () => {
mockUsePathname.mockReturnValue("/ui/chat/logs");
render(
<ChatShell>
<div />
</ChatShell>,
);
expect(screen.getByRole("button", { name: "Logs" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Usage" })).not.toHaveAttribute("aria-current");
});

it("tolerates a trailing slash on the current pathname when matching the active route", () => {
Expand Down
9 changes: 8 additions & 1 deletion ui/litellm-dashboard/src/components/chat/ChatShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import React from "react";
import { usePathname, useRouter } from "next/navigation";
import { Plus, MessageSquare, LayoutGrid, KeyRound, Lock, BarChart3 } from "lucide-react";
import { Plus, MessageSquare, LayoutGrid, KeyRound, Lock, BarChart3, ScrollText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { migratedHref } from "@/utils/migratedPages";
Expand All @@ -16,6 +16,7 @@ export function getChatRoutes() {
integrations: `${base}/integrations`,
credentials: `${base}/credentials`,
apiKeys: `${base}/api-keys`,
logs: `${base}/logs`,
usage: `${base}/usage`,
};
}
Expand Down Expand Up @@ -109,6 +110,12 @@ const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
onClick={() => router.push(routes.apiKeys)}
active={pathname === routes.apiKeys}
/>
<NavItem
icon={<ScrollText className="h-4 w-4" />}
label="Logs"
onClick={() => router.push(routes.logs)}
active={pathname === routes.logs}
/>
<NavItem
icon={<BarChart3 className="h-4 w-4" />}
label="Usage"
Expand Down
104 changes: 104 additions & 0 deletions ui/litellm-dashboard/src/components/chat/LogsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LogsPanel from "./LogsPanel";
import { renderWithProviders } from "../../../tests/test-utils";
import { uiSpendLogDetailsCall, uiSpendLogsCall } from "../networking";

vi.mock("../networking", () => ({
uiSpendLogsCall: vi.fn(),
uiSpendLogDetailsCall: vi.fn(),
}));

const mockedLogsCall = vi.mocked(uiSpendLogsCall);
const mockedDetailsCall = vi.mocked(uiSpendLogDetailsCall);

const sampleRow = {
request_id: "req-abc-123",
model: "gpt-4o",
status: "success",
spend: 0.0123,
total_tokens: 1500,
prompt_tokens: 1000,
completion_tokens: 500,
startTime: "2026-07-18T10:00:00Z",
endTime: "2026-07-18T10:00:02Z",
request_duration_ms: 2000,
};

const paginated = (rows: unknown[]) => ({
data: rows,
total: rows.length,
page: 1,
page_size: 50,
total_pages: rows.length > 0 ? 1 : 0,
});

describe("LogsPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedLogsCall.mockResolvedValue(paginated([sampleRow]));
mockedDetailsCall.mockResolvedValue({ messages: [{ role: "user", content: "hi" }], response: { ok: true } });
});

it("scopes the query to the current user so it only shows their own logs", async () => {
renderWithProviders(<LogsPanel accessToken="tok-scope" userId="user-42" />);

await waitFor(() => expect(mockedLogsCall).toHaveBeenCalled());
expect(mockedLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: "tok-scope",
params: expect.objectContaining({ user_id: "user-42" }),
}),
);
});

it("renders a row for each returned log", async () => {
renderWithProviders(<LogsPanel accessToken="tok-rows" userId="user-1" />);

expect(await screen.findByText("gpt-4o")).toBeInTheDocument();
expect(screen.getByText("1,500")).toBeInTheDocument();
expect(screen.getByText("Success")).toBeInTheDocument();
});

it("shows an empty state when there are no logs", async () => {
mockedLogsCall.mockResolvedValue(paginated([]));
renderWithProviders(<LogsPanel accessToken="tok-empty" userId="user-1" />);

expect(await screen.findByText("No logs for this period")).toBeInTheDocument();
});

it("opens the detail dialog and lazily loads request/response when a row is clicked", async () => {
renderWithProviders(<LogsPanel accessToken="tok-detail" userId="user-1" />);

const modelCell = await screen.findByText("gpt-4o");
expect(mockedDetailsCall).not.toHaveBeenCalled();

fireEvent.click(modelCell);

expect(await screen.findByText("Request details")).toBeInTheDocument();
await waitFor(() =>
expect(mockedDetailsCall).toHaveBeenCalledWith("tok-detail", "req-abc-123", expect.any(String)),
);
});

it("shows an error state (not the empty state) when the logs query fails", async () => {
mockedLogsCall.mockRejectedValue(new Error("boom"));
renderWithProviders(<LogsPanel accessToken="tok-err" userId="user-1" />);

expect(await screen.findByText("Failed to load your logs")).toBeInTheDocument();
expect(screen.queryByText("No logs for this period")).not.toBeInTheDocument();
});

it("falls back to proxy_server_request when messages is empty for the request payload", async () => {
mockedDetailsCall.mockResolvedValue({
messages: {},
proxy_server_request: { body: { messages: [{ role: "user", content: "hello from proxy" }] } },
response: { ok: true },
});
renderWithProviders(<LogsPanel accessToken="tok-fallback" userId="user-1" />);

fireEvent.click(await screen.findByText("gpt-4o"));

expect(await screen.findByText(/hello from proxy/)).toBeInTheDocument();
});
});
Loading
Loading