diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer.tsx new file mode 100644 index 0000000000..8259bc1e00 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer.tsx @@ -0,0 +1,422 @@ +import { Drawer, Typography, Button, Descriptions, Card, Tag, Tooltip, Tabs, message } from "antd"; +import { CloseOutlined, CopyOutlined } from "@ant-design/icons"; +import { Row } from "@tanstack/react-table"; +import { LogEntry } from "./columns"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { truncateString } from "@/utils/textUtils"; +import GuardrailViewer from "./GuardrailViewer/GuardrailViewer"; +import { CostBreakdownViewer } from "./CostBreakdownViewer"; +import { ConfigInfoMessage } from "./ConfigInfoMessage"; +import { RequestResponsePanel } from "./RequestResponsePanel"; +import { VectorStoreViewer } from "./VectorStoreViewer"; +import { ErrorViewer } from "./ErrorViewer"; +import { JsonView, defaultStyles } from "react-json-view-lite"; +import "react-json-view-lite/dist/index.css"; + +const { Title, Text } = Typography; + +interface LogDetailsDrawerProps { + open: boolean; + onClose: () => void; + logEntry: LogEntry | null; + onOpenSettings?: () => void; +} + +export function LogDetailsDrawer({ open, onClose, logEntry, onOpenSettings }: LogDetailsDrawerProps) { + if (!logEntry) return null; + + // Helper function to clean metadata by removing specific fields + const formatData = (input: any) => { + if (typeof input === "string") { + try { + return JSON.parse(input); + } catch { + return input; + } + } + return input; + }; + + // Helper function to get raw request + const getRawRequest = () => { + // First check if proxy_server_request exists in metadata + if (logEntry?.proxy_server_request) { + return formatData(logEntry.proxy_server_request); + } + // Fall back to messages if proxy_server_request is empty + return formatData(logEntry.messages); + }; + + // Extract error information from metadata if available + const metadata = logEntry.metadata || {}; + const hasError = metadata.status === "failure"; + const errorInfo = hasError ? metadata.error_information : null; + + // Check if request/response data is missing + const hasMessages = + logEntry.messages && + (Array.isArray(logEntry.messages) + ? logEntry.messages.length > 0 + : Object.keys(logEntry.messages).length > 0); + const hasResponse = logEntry.response && Object.keys(formatData(logEntry.response)).length > 0; + const missingData = !hasMessages && !hasResponse; + + // Format the response with error details if present + const formattedResponse = () => { + if (hasError && errorInfo) { + return { + error: { + message: errorInfo.error_message || "An error occurred", + type: errorInfo.error_class || "error", + code: errorInfo.error_code || "unknown", + param: null, + }, + }; + } + return formatData(logEntry.response); + }; + + // Extract vector store request metadata if available + const hasVectorStoreData = + metadata.vector_store_request_metadata && + Array.isArray(metadata.vector_store_request_metadata) && + metadata.vector_store_request_metadata.length > 0; + + // Extract guardrail information from metadata if available + const guardrailInfo = logEntry.metadata?.guardrail_information; + const guardrailEntries = Array.isArray(guardrailInfo) ? guardrailInfo : guardrailInfo ? [guardrailInfo] : []; + const hasGuardrailData = guardrailEntries.length > 0; + + // Calculate total masked entities if guardrail data exists + const totalMaskedEntities = guardrailEntries.reduce((sum, entry) => { + const maskedCounts = entry?.masked_entity_count; + if (!maskedCounts) { + return sum; + } + return ( + sum + + Object.values(maskedCounts).reduce((acc, count) => (typeof count === "number" ? acc + count : acc), 0) + ); + }, 0); + + const primaryGuardrailLabel = + guardrailEntries.length === 1 + ? guardrailEntries[0]?.guardrail_name ?? "-" + : guardrailEntries.length > 1 + ? `${guardrailEntries.length} guardrails` + : "-"; + + const handleCopyRequestId = () => { + navigator.clipboard.writeText(logEntry.request_id); + message.success("Request ID copied to clipboard"); + }; + + const copyToClipboard = async (text: string, label: string) => { + try { + // Try modern clipboard API first + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + message.success(`${label} copied to clipboard`); + return true; + } else { + // Fallback for non-secure contexts + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand("copy"); + document.body.removeChild(textArea); + + if (!successful) { + throw new Error("execCommand failed"); + } + message.success(`${label} copied to clipboard`); + return true; + } + } catch (error) { + console.error("Copy failed:", error); + message.error(`Failed to copy ${label}`); + return false; + } + }; + + return ( + + {/* Custom Header with Request ID prominently displayed */} +
+ {/* Request ID at top - like Langfuse trace ID */} +
+
+ + + {logEntry.request_id} + + + +
+
+ + {/* Status and timestamp row */} +
+ + {metadata.status === "failure" ? "Failure" : "Success"} + + + {logEntry.startTime} + +
+
+ + {/* Scrollable content area */} +
+ {/* Request Details Section */} + + + {logEntry.model} + {logEntry.custom_llm_provider || "-"} + {logEntry.call_type} + {logEntry.model_id} + + + + {logEntry.api_base || "-"} + + + + {logEntry.requester_ip_address && ( + {logEntry.requester_ip_address} + )} + {hasGuardrailData && ( + + {primaryGuardrailLabel} + {totalMaskedEntities > 0 && ( + + {totalMaskedEntities} masked + + )} + + )} + + + + {/* Metrics Section */} + + + + {logEntry.total_tokens} ({logEntry.prompt_tokens} prompt + {logEntry.completion_tokens} completion) + + ${formatNumberWithCommas(logEntry.spend || 0, 6)} + {logEntry.duration} s + {logEntry.cache_hit} + + {formatNumberWithCommas(metadata?.additional_usage_values?.cache_read_input_tokens || 0)} + + + {formatNumberWithCommas(metadata?.additional_usage_values?.cache_creation_input_tokens || 0)} + + {logEntry.startTime} + {logEntry.endTime} + {metadata?.litellm_overhead_time_ms !== undefined && ( + {metadata.litellm_overhead_time_ms} ms + )} + + + + {/* Cost Breakdown - Show if cost breakdown data is available */} + + + {/* Configuration Info Message - Show when data is missing */} + + + {/* Request/Response JSON - Using Tabs */} + + + +
+
+ +
+
+
+ ), + }, + { + key: "response", + label: "Response", + children: ( +
+ +
+ {hasResponse ? ( +
+ +
+ ) : ( +
+ Response data not available +
+ )} +
+
+ ), + }, + ]} + /> + + + {/* Guardrail Data - Show only if present */} + {hasGuardrailData && ( +
+ +
+ )} + + {/* Vector Store Request Data - Show only if present */} + {hasVectorStoreData && ( +
+ +
+ )} + + {/* Error Card - Only show for failures */} + {hasError && errorInfo && ( +
+ +
+ )} + + {/* Tags Card - Only show if there are tags */} + {logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && ( + +
+ {Object.entries(logEntry.request_tags).map(([key, value]) => ( + + {key}: {String(value)} + + ))} +
+
+ )} + + {/* Metadata Card - Only show if there's metadata */} + {logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && ( + } + onClick={() => copyToClipboard(JSON.stringify(logEntry.metadata, null, 2), "Metadata")} + > + Copy + + } + > +
+              {JSON.stringify(logEntry.metadata, null, 2)}
+            
+
+ )} + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 2da1e83747..3e72c8e13b 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -49,46 +49,6 @@ export type LogEntry = { }; export const columns: ColumnDef[] = [ - { - id: "expander", - header: () => null, - cell: ({ row }) => { - // Convert the cell function to a React component to properly use hooks - const ExpanderCell = () => { - const [localExpanded, setLocalExpanded] = React.useState(row.getIsExpanded()); - - // Memoize the toggle handler to prevent unnecessary re-renders - const toggleHandler = React.useCallback(() => { - setLocalExpanded((prev) => !prev); - row.getToggleExpandedHandler()(); - }, [row]); - - return row.getCanExpand() ? ( - - ) : ( - - ); - }; - - // Return the component - return ; - }, - }, { header: "Time", accessorKey: "startTime", diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 826fc7ccc0..0518880124 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -31,6 +31,7 @@ import SpendLogsSettingsModal from "./SpendLogsSettingsModal/SpendLogsSettingsMo import { DataTable } from "./table"; import { VectorStoreViewer } from "./VectorStoreViewer"; import NewBadge from "../common_components/NewBadge"; +import { LogDetailsDrawer } from "./LogDetailsDrawer"; interface SpendLogsTableProps { accessToken: string | null; @@ -89,7 +90,8 @@ export default function SpendLogsTable({ const [filterByCurrentUser, setFilterByCurrentUser] = useState(userRole && internalUserRoles.includes(userRole)); const [activeTab, setActiveTab] = useState("request logs"); - const [expandedRequestId, setExpandedRequestId] = useState(null); + const [selectedLog, setSelectedLog] = useState(null); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [selectedSessionId, setSelectedSessionId] = useState(null); const [isSpendLogsSettingsModalVisible, setIsSpendLogsSettingsModalVisible] = useState(false); @@ -317,17 +319,6 @@ export default function SpendLogsTable({ enabled: !!accessToken && !!selectedSessionId, }); - // Add this effect to preserve expanded state when data refreshes - useEffect(() => { - if (logs.data?.data && expandedRequestId) { - // Check if the expanded request ID still exists in the new data - const stillExists = logs.data.data.some((log) => log.request_id === expandedRequestId); - if (!stillExists) { - // If the request ID no longer exists in the data, clear the expanded state - setExpandedRequestId(null); - } - } - }, [logs.data?.data, expandedRequestId]); if (!accessToken || !token || !userRole || !userID) { return null; @@ -367,8 +358,14 @@ export default function SpendLogsTable({ logs.refetch(); }; - const handleRowExpand = (requestId: string | null) => { - setExpandedRequestId(requestId); + const handleRowClick = (log: LogEntry) => { + setSelectedLog(log); + setIsDrawerOpen(true); + }; + + const handleCloseDrawer = () => { + setIsDrawerOpen(false); + // Optionally keep selectedLog for animation purposes }; // Function to extract unique error codes from logs @@ -554,9 +551,7 @@ export default function SpendLogsTable({ setIsSpendLogsSettingsModalVisible(true)} />} - getRowCanExpand={() => true} - // Optionally: add session-specific row expansion state + onRowClick={handleRowClick} /> ) : ( @@ -753,8 +748,7 @@ export default function SpendLogsTable({ setIsSpendLogsSettingsModalVisible(true)} />} - getRowCanExpand={() => true} + onRowClick={handleRowClick} /> @@ -775,6 +769,14 @@ export default function SpendLogsTable({ + + {/* Log Details Drawer */} + setIsSpendLogsSettingsModalVisible(true)} + /> ); } diff --git a/ui/litellm-dashboard/src/components/view_logs/table.tsx b/ui/litellm-dashboard/src/components/view_logs/table.tsx index 605341cb2e..fb7706cba1 100644 --- a/ui/litellm-dashboard/src/components/view_logs/table.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/table.tsx @@ -6,8 +6,10 @@ import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } fro interface DataTableProps { data: TData[]; columns: ColumnDef[]; - renderSubComponent: (props: { row: Row }) => React.ReactElement; - getRowCanExpand: (row: Row) => boolean; + onRowClick?: (row: TData) => void; + // Legacy props for backward compatibility (audit logs) + renderSubComponent?: (props: { row: Row }) => React.ReactElement; + getRowCanExpand?: (row: Row) => boolean; isLoading?: boolean; loadingMessage?: string; noDataMessage?: string; @@ -16,22 +18,26 @@ interface DataTableProps { export function DataTable({ data = [], columns, - getRowCanExpand, + onRowClick, renderSubComponent, + getRowCanExpand, isLoading = false, loadingMessage = "🚅 Loading logs...", noDataMessage = "No logs found", }: DataTableProps) { + // Determine if we're in legacy expansion mode or new drawer mode + const isLegacyMode = !!renderSubComponent && !!getRowCanExpand; + const table = useReactTable({ data, columns, - getRowCanExpand, + ...(isLegacyMode && { getRowCanExpand }), getRowId: (row: TData, index: number) => { const _row: any = row as any; return _row?.request_id ?? String(index); }, getCoreRowModel: getCoreRowModel(), - getExpandedRowModel: getExpandedRowModel(), + ...(isLegacyMode && { getExpandedRowModel: getExpandedRowModel() }), }); return ( @@ -62,7 +68,10 @@ export function DataTable({ ) : table.getRowModel().rows.length > 0 ? ( table.getRowModel().rows.map((row) => ( - + !isLegacyMode && onRowClick?.(row.original)} + > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -70,7 +79,8 @@ export function DataTable({ ))} - {row.getIsExpanded() && ( + {/* Legacy expansion mode for audit logs */} + {isLegacyMode && row.getIsExpanded() && renderSubComponent && (
{renderSubComponent({ row })}