diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer.tsx deleted file mode 100644 index 8259bc1e00..0000000000 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer.tsx +++ /dev/null @@ -1,422 +0,0 @@ -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/LogDetailsDrawer/DrawerHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx new file mode 100644 index 0000000000..364959b0b5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx @@ -0,0 +1,146 @@ +import { Button, Tag, Tooltip, Typography } from "antd"; +import { CloseOutlined, CopyOutlined, UpOutlined, DownOutlined } from "@ant-design/icons"; +import moment from "moment"; +import { LogEntry } from "../columns"; +import { + DRAWER_HEADER_PADDING, + COLOR_BORDER, + COLOR_BACKGROUND, + SPACING_MEDIUM, + SPACING_LARGE, + FONT_SIZE_HEADER, + FONT_SIZE_MEDIUM, + FONT_FAMILY_MONO, + SPACING_SMALL, +} from "./constants"; + +const { Text } = Typography; + +interface DrawerHeaderProps { + log: LogEntry; + onClose: () => void; + onCopyRequestId: () => void; + onPrevious: () => void; + onNext: () => void; + statusLabel: string; + statusColor: "error" | "success"; + environment: string; +} + +/** + * Header component for the log details drawer. + * Displays request ID, navigation controls, status, environment, and timestamp. + */ +export function DrawerHeader({ + log, + onClose, + onCopyRequestId, + onPrevious, + onNext, + statusLabel, + statusColor, + environment, +}: DrawerHeaderProps) { + return ( +
+ {/* Row 1: Request ID + Actions */} +
+ + +
+ + {/* Row 2: Status + Env + Timestamp */} + +
+ ); +} + +/** + * Request ID display with copy button + */ +function RequestIdSection({ requestId, onCopy }: { requestId: string; onCopy: () => void }) { + return ( +
+ + + {requestId} + + + +
+ ); +} + +/** + * Navigation controls (previous, next, close) + */ +function NavigationSection({ + onPrevious, + onNext, + onClose, +}: { + onPrevious: () => void; + onNext: () => void; + onClose: () => void; +}) { + return ( +
+ +
+ ); +} + +/** + * Status bar with tags and timestamp + */ +function StatusBar({ + log, + statusLabel, + statusColor, + environment, +}: { + log: LogEntry; + statusLabel: string; + statusColor: "error" | "success"; + environment: string; +}) { + return ( +
+ {statusLabel} + Env: {environment} + + {moment(log.startTime).format("MMM D, YYYY h:mm:ss A")} + ({moment(log.startTime).fromNow()}) + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/JsonViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/JsonViewer.tsx new file mode 100644 index 0000000000..1f1d935973 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/JsonViewer.tsx @@ -0,0 +1,66 @@ +import { Typography } from "antd"; +import { JsonView, defaultStyles } from "react-json-view-lite"; +import "react-json-view-lite/dist/index.css"; +import { + JSON_MAX_HEIGHT, + FONT_SIZE_SMALL, + COLOR_BG_LIGHT, + SPACING_LARGE, + FONT_FAMILY_MONO, + VIEW_MODE_JSON, +} from "./constants"; + +const { Text } = Typography; + +export type ViewMode = "formatted" | "json"; + +interface JsonViewerProps { + data: any; + mode: ViewMode; +} + +/** + * Displays JSON data in either formatted tree view or raw JSON format. + * Formatted view uses an interactive tree, JSON view shows raw stringified output. + */ +export function JsonViewer({ data, mode }: JsonViewerProps) { + if (!data) return No data; + + if (mode === VIEW_MODE_JSON) { + return ( +
+        {JSON.stringify(data, null, 2)}
+      
+ ); + } + + // Formatted tree view + return ( +
+
+ +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx new file mode 100644 index 0000000000..e133424418 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx @@ -0,0 +1,516 @@ +import { useState } from "react"; +import { Drawer, Typography, Button, Descriptions, Card, Tag, Tabs, Radio, Alert, message } from "antd"; +import { CopyOutlined } from "@ant-design/icons"; +import moment from "moment"; +import { LogEntry } from "../columns"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import GuardrailViewer from "../GuardrailViewer/GuardrailViewer"; +import { CostBreakdownViewer } from "../CostBreakdownViewer"; +import { ConfigInfoMessage } from "../ConfigInfoMessage"; +import { VectorStoreViewer } from "../VectorStoreViewer"; +import { TruncatedValue } from "./TruncatedValue"; +import { TokenFlow } from "./TokenFlow"; +import { JsonViewer, ViewMode } from "./JsonViewer"; +import { DrawerHeader } from "./DrawerHeader"; +import { copyToClipboard } from "./clipboardUtils"; +import { useKeyboardNavigation } from "./useKeyboardNavigation"; +import { + DRAWER_WIDTH, + DRAWER_CONTENT_PADDING, + API_BASE_MAX_WIDTH, + METADATA_MAX_HEIGHT, + TAB_REQUEST, + TAB_RESPONSE, + VIEW_MODE_FORMATTED, + FONT_SIZE_SMALL, + FONT_FAMILY_MONO, + SPACING_XLARGE, + MESSAGE_REQUEST_ID_COPIED, +} from "./constants"; + +const { Text } = Typography; + +export interface LogDetailsDrawerProps { + open: boolean; + onClose: () => void; + logEntry: LogEntry | null; + onOpenSettings?: () => void; + allLogs?: LogEntry[]; + onSelectLog?: (log: LogEntry) => void; +} + +/** + * Right-side drawer panel for displaying detailed log information. + * Features: + * - Request ID prominently displayed with copy functionality + * - Keyboard navigation (J/K for next/prev, Escape to close) + * - Formatted and JSON view toggle for request/response + * - Smart display of cache fields (hidden when zero) + * - Error alerts for failed requests + * - Collapsible sections for guardrails, vector store, metadata + */ +export function LogDetailsDrawer({ + open, + onClose, + logEntry, + onOpenSettings, + allLogs = [], + onSelectLog, +}: LogDetailsDrawerProps) { + const [activeTab, setActiveTab] = useState(TAB_REQUEST); + const [jsonViewMode, setJsonViewMode] = useState(VIEW_MODE_FORMATTED); + + // Keyboard navigation + const { selectNextLog, selectPreviousLog } = useKeyboardNavigation({ + isOpen: open, + currentLog: logEntry, + allLogs, + onClose, + onSelectLog, + }); + + if (!logEntry) return null; + + const metadata = logEntry.metadata || {}; + const hasError = metadata.status === "failure"; + const errorInfo = hasError ? metadata.error_information : null; + + // Check if request/response data is present + const hasMessages = checkHasMessages(logEntry.messages); + const hasResponse = checkHasResponse(logEntry.response); + const missingData = !hasMessages && !hasResponse; + + // Guardrail data + const guardrailInfo = metadata?.guardrail_information; + const guardrailEntries = normalizeGuardrailEntries(guardrailInfo); + const hasGuardrailData = guardrailEntries.length > 0; + const totalMaskedEntities = calculateTotalMaskedEntities(guardrailEntries); + const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries); + + // Vector store data + const hasVectorStoreData = checkHasVectorStoreData(metadata); + + // Status display values + const statusLabel = metadata.status === "failure" ? "Failure" : "Success"; + const statusColor = metadata.status === "failure" ? ("error" as const) : ("success" as const); + const environment = metadata?.user_api_key_team_alias || "default"; + + const handleCopyRequestId = () => { + navigator.clipboard.writeText(logEntry.request_id); + message.success(MESSAGE_REQUEST_ID_COPIED); + }; + + const getRawRequest = () => { + return formatData(logEntry.proxy_server_request || logEntry.messages); + }; + + const getFormattedResponse = () => { + 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); + }; + + return ( + + + +
+ {/* Error Alert - Show prominently at top for failures */} + {hasError && errorInfo && ( + } + style={{ marginBottom: SPACING_XLARGE }} + /> + )} + + {/* Tags - Only show if present */} + {logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && ( + + )} + + {/* Request Details Section */} + + + {logEntry.model} + {logEntry.custom_llm_provider || "-"} + {logEntry.call_type} + + + + + + + {logEntry.requester_ip_address && ( + {logEntry.requester_ip_address} + )} + {hasGuardrailData && ( + + + + )} + + + + {/* Metrics Section */} + + + {/* Cost Breakdown - Show if cost breakdown data is available */} + + + {/* Configuration Info Message - Show when data is missing */} + + + {/* Request/Response JSON - Using Tabs with View Toggle */} + copyToClipboard(JSON.stringify(data, null, 2), label)} + getRawRequest={getRawRequest} + getFormattedResponse={getFormattedResponse} + /> + + {/* Guardrail Data - Show only if present */} + {hasGuardrailData && ( +
+ +
+ )} + + {/* Vector Store Request Data - Show only if present */} + {hasVectorStoreData && ( +
+ +
+ )} + + {/* Metadata Card - Only show if there's metadata */} + {logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && ( + copyToClipboard(data, "Metadata")} /> + )} +
+
+ ); +} + +// ============================================================================ +// Helper Components +// ============================================================================ + +function ErrorDescription({ errorInfo }: { errorInfo: any }) { + return ( +
+ {errorInfo.error_code && ( +
+ Error Code: {errorInfo.error_code} +
+ )} + {errorInfo.error_message && ( +
+ Message: {errorInfo.error_message} +
+ )} +
+ ); +} + +function TagsSection({ tags }: { tags: Record }) { + return ( +
+ + Tags + +
+ {Object.entries(tags).map(([key, value]) => ( + + {key}: {String(value)} + + ))} +
+
+ ); +} + +function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) { + return ( + <> + {label} + {maskedCount > 0 && ( + + {maskedCount} masked + + )} + + ); +} + +function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: Record }) { + const hasCacheActivity = + logEntry.cache_hit || + (metadata?.additional_usage_values?.cache_read_input_tokens && + metadata.additional_usage_values.cache_read_input_tokens > 0); + + return ( + + + + + + ${formatNumberWithCommas(logEntry.spend || 0, 8)} + {logEntry.duration?.toFixed(3)} s + + {/* Only show cache fields if there's cache activity */} + {hasCacheActivity && ( + <> + + {logEntry.cache_hit || "None"} + + {metadata?.additional_usage_values?.cache_read_input_tokens > 0 && ( + + {formatNumberWithCommas(metadata.additional_usage_values.cache_read_input_tokens)} + + )} + {metadata?.additional_usage_values?.cache_creation_input_tokens > 0 && ( + + {formatNumberWithCommas(metadata.additional_usage_values.cache_creation_input_tokens)} + + )} + + )} + + {metadata?.litellm_overhead_time_ms !== undefined && ( + + {metadata.litellm_overhead_time_ms.toFixed(2)} ms + + )} + + + {moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")} + + + {moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")} + + + + ); +} + +interface RequestResponseSectionProps { + activeTab: typeof TAB_REQUEST | typeof TAB_RESPONSE; + jsonViewMode: ViewMode; + hasResponse: boolean; + onTabChange: (key: typeof TAB_REQUEST | typeof TAB_RESPONSE) => void; + onViewModeChange: (mode: ViewMode) => void; + onCopy: (data: any, label: string) => void; + getRawRequest: () => any; + getFormattedResponse: () => any; +} + +function RequestResponseSection({ + activeTab, + jsonViewMode, + hasResponse, + onTabChange, + onViewModeChange, + onCopy, + getRawRequest, + getFormattedResponse, +}: RequestResponseSectionProps) { + const handleCopy = () => { + const data = activeTab === TAB_REQUEST ? getRawRequest() : getFormattedResponse(); + const label = activeTab === TAB_REQUEST ? "Request" : "Response"; + onCopy(data, label); + }; + + return ( + + onTabChange(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)} + tabBarExtraContent={ +
+ {/* View Mode Toggle */} + onViewModeChange(e.target.value)}> + Formatted + JSON + + + {/* Copy Button */} + +
+ } + items={[ + { + key: TAB_REQUEST, + label: "Request", + children: ( +
+ +
+ ), + }, + { + key: TAB_RESPONSE, + label: "Response", + children: ( +
+ {hasResponse ? ( + + ) : ( +
+ Response data not available +
+ )} +
+ ), + }, + ]} + style={{ padding: `0 ${SPACING_XLARGE}px` }} + /> +
+ ); +} + +function MetadataSection({ metadata, onCopy }: { metadata: Record; onCopy: (data: string) => void }) { + return ( + } + onClick={() => onCopy(JSON.stringify(metadata, null, 2))} + > + Copy + + } + > +
+        {JSON.stringify(metadata, null, 2)}
+      
+
+ ); +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function formatData(input: any) { + if (typeof input === "string") { + try { + return JSON.parse(input); + } catch { + return input; + } + } + return input; +} + +function checkHasMessages(messages: any): boolean { + if (!messages) return false; + if (Array.isArray(messages)) return messages.length > 0; + if (typeof messages === "object") return Object.keys(messages).length > 0; + return false; +} + +function checkHasResponse(response: any): boolean { + if (!response) return false; + return Object.keys(formatData(response)).length > 0; +} + +function normalizeGuardrailEntries(guardrailInfo: any): any[] { + if (Array.isArray(guardrailInfo)) return guardrailInfo; + if (guardrailInfo) return [guardrailInfo]; + return []; +} + +function calculateTotalMaskedEntities(entries: any[]): number { + return entries.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); +} + +function getGuardrailLabel(entries: any[]): string { + if (entries.length === 0) return "-"; + if (entries.length === 1) return entries[0]?.guardrail_name ?? "-"; + return `${entries.length} guardrails`; +} + +function checkHasVectorStoreData(metadata: Record): boolean { + return ( + metadata.vector_store_request_metadata && + Array.isArray(metadata.vector_store_request_metadata) && + metadata.vector_store_request_metadata.length > 0 + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TokenFlow.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TokenFlow.tsx new file mode 100644 index 0000000000..a9a30e55f1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TokenFlow.tsx @@ -0,0 +1,27 @@ +import { Typography } from "antd"; +import { COLOR_SECONDARY, FONT_FAMILY_MONO, FONT_SIZE_MEDIUM, SPACING_SMALL, SPACING_MEDIUM } from "./constants"; + +const { Text } = Typography; + +interface TokenFlowProps { + prompt?: number; + completion?: number; + total?: number; +} + +/** + * Displays token usage in a flow format: "prompt → completion (Σ total)" + * Makes it easy to see the relationship between input, output, and total tokens. + */ +export function TokenFlow({ prompt = 0, completion = 0, total = 0 }: TokenFlowProps) { + return ( + + {prompt.toLocaleString()} + + {completion.toLocaleString()} + + (Σ {total.toLocaleString()}) + + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TruncatedValue.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TruncatedValue.tsx new file mode 100644 index 0000000000..b03895808d --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TruncatedValue.tsx @@ -0,0 +1,35 @@ +import { Typography, Tooltip } from "antd"; +import { DEFAULT_MAX_WIDTH, FONT_FAMILY_MONO, FONT_SIZE_SMALL } from "./constants"; + +const { Text } = Typography; + +interface TruncatedValueProps { + value?: string; + maxWidth?: number; +} + +/** + * Displays a truncated value with tooltip and copy functionality. + * Useful for displaying long IDs, URLs, or other text that may overflow. + */ +export function TruncatedValue({ value, maxWidth = DEFAULT_MAX_WIDTH }: TruncatedValueProps) { + if (!value) return -; + + return ( + + + {value} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts new file mode 100644 index 0000000000..6aae95bb72 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts @@ -0,0 +1,43 @@ +import { message } from "antd"; +import { MESSAGE_COPY_SUCCESS } from "./constants"; + +/** + * Copies text to clipboard with fallback for non-secure contexts. + * Shows success/error message to user. + * + * @param text - Text to copy to clipboard + * @param label - Label for the copied content (e.g., "Request", "Metadata") + * @returns Promise - true if copy succeeded, false otherwise + */ +export async function copyToClipboard(text: string, label: string): Promise { + try { + // Try modern clipboard API first + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + message.success(`${label} ${MESSAGE_COPY_SUCCESS}`); + return true; + } else { + // Fallback for non-secure contexts (like 0.0.0.0) + 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} ${MESSAGE_COPY_SUCCESS}`); + return true; + } + } catch (error) { + console.error("Copy failed:", error); + message.error(`Failed to copy ${label}`); + return false; + } +} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts new file mode 100644 index 0000000000..e1222ce00a --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts @@ -0,0 +1,48 @@ +// Drawer configuration constants +export const DRAWER_WIDTH = "60%"; +export const DRAWER_HEADER_PADDING = "16px 24px"; +export const DRAWER_CONTENT_PADDING = "24px"; + +// Truncation and display limits +export const DEFAULT_MAX_WIDTH = 180; +export const API_BASE_MAX_WIDTH = 200; +export const JSON_MAX_HEIGHT = 400; +export const METADATA_MAX_HEIGHT = 300; + +// Tab keys +export const TAB_REQUEST = "request" as const; +export const TAB_RESPONSE = "response" as const; + +// View modes +export const VIEW_MODE_FORMATTED = "formatted" as const; +export const VIEW_MODE_JSON = "json" as const; + +// Keyboard shortcuts +export const KEY_ESCAPE = "Escape"; +export const KEY_J_LOWER = "j"; +export const KEY_J_UPPER = "J"; +export const KEY_K_LOWER = "k"; +export const KEY_K_UPPER = "K"; + +// Typography +export const FONT_FAMILY_MONO = "monospace"; +export const FONT_SIZE_SMALL = 12; +export const FONT_SIZE_MEDIUM = 13; +export const FONT_SIZE_HEADER = 16; + +// Colors +export const COLOR_BORDER = "#f0f0f0"; +export const COLOR_BACKGROUND = "#fff"; +export const COLOR_SECONDARY = "#8c8c8c"; +export const COLOR_BG_LIGHT = "#fafafa"; + +// Spacing +export const SPACING_SMALL = 4; +export const SPACING_MEDIUM = 8; +export const SPACING_LARGE = 12; +export const SPACING_XLARGE = 16; +export const SPACING_XXLARGE = 24; + +// Messages +export const MESSAGE_COPY_SUCCESS = "copied to clipboard"; +export const MESSAGE_REQUEST_ID_COPIED = "Request ID copied to clipboard"; diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts new file mode 100644 index 0000000000..e1fdd9d2d6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts @@ -0,0 +1,2 @@ +export { LogDetailsDrawer } from "./LogDetailsDrawer"; +export type { LogDetailsDrawerProps } from "./LogDetailsDrawer"; diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts new file mode 100644 index 0000000000..e4fa9bc618 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts @@ -0,0 +1,87 @@ +import { useEffect } from "react"; +import { LogEntry } from "../columns"; +import { KEY_ESCAPE, KEY_J_LOWER, KEY_J_UPPER, KEY_K_LOWER, KEY_K_UPPER } from "./constants"; + +interface UseKeyboardNavigationProps { + isOpen: boolean; + currentLog: LogEntry | null; + allLogs: LogEntry[]; + onClose: () => void; + onSelectLog?: (log: LogEntry) => void; +} + +/** + * Custom hook for keyboard navigation in the log details drawer. + * Handles J/K for next/previous and Escape for close. + * + * Keyboard shortcuts: + * - J: Navigate to next log + * - K: Navigate to previous log + * - Escape: Close drawer + */ +export function useKeyboardNavigation({ + isOpen, + currentLog, + allLogs, + onClose, + onSelectLog, +}: UseKeyboardNavigationProps) { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't trigger if user is typing in an input + if (isUserTyping(e.target)) { + return; + } + + if (!isOpen) return; + + switch (e.key) { + case KEY_ESCAPE: + onClose(); + break; + case KEY_J_LOWER: + case KEY_J_UPPER: + selectNextLog(); + break; + case KEY_K_LOWER: + case KEY_K_UPPER: + selectPreviousLog(); + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, currentLog, allLogs]); + + const selectNextLog = () => { + if (!currentLog || !allLogs.length || !onSelectLog) return; + + const currentIndex = allLogs.findIndex((l) => l.request_id === currentLog.request_id); + if (currentIndex < allLogs.length - 1) { + onSelectLog(allLogs[currentIndex + 1]); + } + }; + + const selectPreviousLog = () => { + if (!currentLog || !allLogs.length || !onSelectLog) return; + + const currentIndex = allLogs.findIndex((l) => l.request_id === currentLog.request_id); + if (currentIndex > 0) { + onSelectLog(allLogs[currentIndex - 1]); + } + }; + + return { + selectNextLog, + selectPreviousLog, + }; +} + +/** + * Checks if the user is currently typing in an input field. + * Used to prevent keyboard shortcuts from interfering with text input. + */ +function isUserTyping(target: EventTarget | null): boolean { + return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement; +} diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 0518880124..3859a5e51f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -368,6 +368,10 @@ export default function SpendLogsTable({ // Optionally keep selectedLog for animation purposes }; + const handleSelectLog = (log: LogEntry) => { + setSelectedLog(log); + }; + // Function to extract unique error codes from logs const extractErrorCodes = (logs: LogEntry[], searchText: string = "") => { const errorCodes = new Set(); @@ -776,6 +780,8 @@ export default function SpendLogsTable({ onClose={handleCloseDrawer} logEntry={selectedLog} onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)} + allLogs={filteredData} + onSelectLog={handleSelectLog} /> );