From d495da4ce4cc8e068467afff2f07eca332391ed5 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:58:12 -0700 Subject: [PATCH] feat(chat-ui): add personal Logs view scoped to the current user (#33829) * feat(chat-ui): add personal Logs view scoped to the current user Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(chat-ui): show request payload from proxy_server_request in logs detail Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(chat-ui): address logs panel review feedback (stable detail key, error state) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Krrish Dholakia Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/app/chat/logs/page.tsx | 14 + .../chat/ChatShell.serverRootPath.test.ts | 1 + .../src/components/chat/ChatShell.test.tsx | 14 + .../src/components/chat/ChatShell.tsx | 9 +- .../src/components/chat/LogsPanel.test.tsx | 104 ++++++ .../src/components/chat/LogsPanel.tsx | 348 ++++++++++++++++++ 6 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/app/chat/logs/page.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/LogsPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/LogsPanel.tsx diff --git a/ui/litellm-dashboard/src/app/chat/logs/page.tsx b/ui/litellm-dashboard/src/app/chat/logs/page.tsx new file mode 100644 index 00000000000..7c6daff1405 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/logs/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts b/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts index 1fa054396ea..de1f9d2aa50 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts +++ b/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts @@ -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"); }); diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx index 01dca80acd5..e48a83020f0 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx @@ -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( + +
+ , + ); + 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", () => { diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx index 102fc627f98..c71bc0723f7 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx @@ -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"; @@ -16,6 +16,7 @@ export function getChatRoutes() { integrations: `${base}/integrations`, credentials: `${base}/credentials`, apiKeys: `${base}/api-keys`, + logs: `${base}/logs`, usage: `${base}/usage`, }; } @@ -109,6 +110,12 @@ const ChatShell: React.FC = ({ children }) => { onClick={() => router.push(routes.apiKeys)} active={pathname === routes.apiKeys} /> + } + label="Logs" + onClick={() => router.push(routes.logs)} + active={pathname === routes.logs} + /> } label="Usage" diff --git a/ui/litellm-dashboard/src/components/chat/LogsPanel.test.tsx b/ui/litellm-dashboard/src/components/chat/LogsPanel.test.tsx new file mode 100644 index 00000000000..75d755a75a4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/LogsPanel.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + fireEvent.click(await screen.findByText("gpt-4o")); + + expect(await screen.findByText(/hello from proxy/)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/chat/LogsPanel.tsx b/ui/litellm-dashboard/src/components/chat/LogsPanel.tsx new file mode 100644 index 00000000000..d1bddfa423b --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/LogsPanel.tsx @@ -0,0 +1,348 @@ +"use client"; + +import React, { useState } from "react"; +import moment from "moment"; +import { AlertCircle, ScrollText } from "lucide-react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { uiSpendLogDetailsCall, uiSpendLogsCall } from "../networking"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; + +const LOGS_QUERY_KEY = "chat-user-logs"; +const PAGE_SIZE = 50; + +interface Props { + accessToken: string; + userId: string; +} + +type TimeRange = "24h" | "7d" | "30d"; + +const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [ + { value: "24h", label: "24h" }, + { value: "7d", label: "7d" }, + { value: "30d", label: "30d" }, +]; + +function getStartMoment(range: TimeRange): moment.Moment { + if (range === "24h") return moment().subtract(24, "hours"); + if (range === "7d") return moment().subtract(7, "days"); + return moment().subtract(30, "days"); +} + +interface LogRow { + request_id: string; + model: string; + custom_llm_provider?: string; + status?: string; + spend: number; + total_tokens: number; + prompt_tokens: number; + completion_tokens: number; + startTime: string; + endTime: string; + request_duration_ms?: number; +} + +interface PaginatedLogs { + data: LogRow[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +interface LogDetails { + messages?: unknown; + response?: unknown; + proxy_server_request?: unknown; +} + +function formatTokens(n: number): string { + return (n ?? 0).toLocaleString(); +} + +function formatCost(spend: number): string { + const value = spend ?? 0; + if (value === 0) return "$0"; + if (value < 0.01) return `$${value.toFixed(6)}`; + return `$${value.toFixed(4)}`; +} + +function durationMs(row: LogRow): number | null { + if (row.request_duration_ms != null) return row.request_duration_ms; + if (row.startTime && row.endTime) return Date.parse(row.endTime) - Date.parse(row.startTime); + return null; +} + +function formatDuration(row: LogRow): string { + const ms = durationMs(row); + if (ms == null || Number.isNaN(ms)) return "-"; + return `${(ms / 1000).toFixed(2)}s`; +} + +function StatusBadge({ status }: { status?: string }) { + const isFailure = status === "failure"; + return ( + + + {isFailure ? "Failure" : "Success"} + + ); +} + +function JsonBlock({ value }: { value: unknown }) { + if (value == null || value === "") { + return

Not available

; + } + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + return ( +
+      {text}
+    
+ ); +} + +function LogsSkeleton() { + return ( +
+
+ {[...Array(8)].map((_, i) => ( +
+ + + + +
+ ))} +
+
+ ); +} + +function LogsEmpty() { + return ( +
+ + No logs for this period +
+ ); +} + +function LogsError({ onRetry }: { onRetry: () => void }) { + return ( +
+ + Failed to load your logs + +
+ ); +} + +function LogsTable({ rows, onRowClick }: { rows: LogRow[]; onRowClick: (row: LogRow) => void }) { + return ( +
+ + + + Time + Model + Status + Tokens + Duration + Cost + + + + {rows.map((row) => ( + onRowClick(row)}> + + {moment(row.startTime).format("MMM D, HH:mm:ss")} + + {row.model || "-"} + + + + {formatTokens(row.total_tokens)} + + {formatDuration(row)} + + {formatCost(row.spend)} + + ))} + +
+
+ ); +} + +function LogDetailDialog({ + log, + details, + isLoading, + onClose, +}: { + log: LogRow | null; + details: LogDetails | undefined; + isLoading: boolean; + onClose: () => void; +}) { + return ( + !open && onClose()}> + + + Request details + {log?.request_id} + + {log && ( +
+
+
+
Model
+
{log.model || "-"}
+
+
+
Cost
+
{formatCost(log.spend)}
+
+
+
Tokens
+
+ {formatTokens(log.total_tokens)} ({formatTokens(log.prompt_tokens)} in /{" "} + {formatTokens(log.completion_tokens)} out) +
+
+
+
Duration
+
{formatDuration(log)}
+
+
+ +
+
Request
+ {isLoading ? ( + + ) : ( + + )} +
+
+
Response
+ {isLoading ? : } +
+
+ )} +
+
+ ); +} + +const LogsPanel: React.FC = ({ accessToken, userId }) => { + const [timeRange, setTimeRange] = useState("24h"); + const [page, setPage] = useState(1); + const [selectedLog, setSelectedLog] = useState(null); + + const startDate = getStartMoment(timeRange).utc().format("YYYY-MM-DD HH:mm:ss"); + const endDate = moment().utc().format("YYYY-MM-DD HH:mm:ss"); + + const logsCallOptions = { + accessToken, + start_date: startDate, + end_date: endDate, + page, + page_size: PAGE_SIZE, + params: { user_id: userId, sort_by: "startTime", sort_order: "desc" as const }, + }; + const logsQueryOptions = { + queryKey: [LOGS_QUERY_KEY, accessToken, userId, timeRange, page], + queryFn: () => uiSpendLogsCall(logsCallOptions), + enabled: !!accessToken && !!userId, + placeholderData: keepPreviousData, + }; + const { data, isLoading, isError, refetch } = useQuery(logsQueryOptions); + + const logs = data as PaginatedLogs | undefined; + const rows = logs?.data ?? []; + const totalPages = logs?.total_pages ?? 0; + const total = logs?.total ?? 0; + + const detailStartDate = selectedLog ? moment(selectedLog.startTime).utc().format("YYYY-MM-DD HH:mm:ss") : ""; + const { data: detailData, isLoading: isDetailLoading } = useQuery({ + queryKey: [LOGS_QUERY_KEY, "detail", accessToken, selectedLog?.request_id, selectedLog?.startTime], + queryFn: () => uiSpendLogDetailsCall(accessToken, selectedLog!.request_id, detailStartDate), + enabled: !!accessToken && !!selectedLog, + }); + const details = detailData as LogDetails | undefined; + + const renderBody = () => { + if (isLoading) return ; + if (isError) return refetch()} />; + if (rows.length === 0) return ; + return ( + <> + +
+

+ {total.toLocaleString()} request{total === 1 ? "" : "s"} + {totalPages > 1 ? ` ยท Page ${page} of ${totalPages}` : ""} +

+ {totalPages > 1 && ( +
+ + +
+ )} +
+ + ); + }; + + return ( +
+
+
+

Your Logs

+

Request logs for your account only

+
+
+ {TIME_RANGE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {renderBody()} + + setSelectedLog(null)} + /> +
+ ); +}; + +export default LogsPanel;