mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-16 00:23:01 +00:00
address feedback
This commit is contained in:
@@ -7,6 +7,7 @@ export interface CostBreakdown {
|
||||
output_cost?: number;
|
||||
total_cost?: number;
|
||||
tool_usage_cost?: number;
|
||||
additional_costs?: Record<string, number>;
|
||||
original_cost?: number;
|
||||
discount_percent?: number;
|
||||
discount_amount?: number;
|
||||
@@ -59,7 +60,7 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden">
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Accordion>
|
||||
<AccordionHeader className="p-4 border-b hover:bg-gray-50 transition-colors text-left">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
@@ -88,6 +89,17 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
||||
<span className="text-gray-900">{formatCost(costBreakdown.tool_usage_cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Additional Costs (free-form) */}
|
||||
{costBreakdown.additional_costs && Object.keys(costBreakdown.additional_costs).length > 0 && (
|
||||
<>
|
||||
{Object.entries(costBreakdown.additional_costs).map(([key, value]) => (
|
||||
<div key={key} className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">{key}:</span>
|
||||
<span className="text-gray-900">{formatCost(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtotal / Original Cost */}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Button, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import { CloseOutlined, UpOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import moment from "moment";
|
||||
import { LogEntry } from "../columns";
|
||||
import { getProviderLogoAndName } from "../../provider_info_helpers";
|
||||
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;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
statusLabel: string;
|
||||
statusColor: "error" | "success";
|
||||
environment: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header component for the log details drawer.
|
||||
* Displays model/provider, request ID, navigation controls, status, environment, and timestamp.
|
||||
*/
|
||||
export function DrawerHeader({
|
||||
log,
|
||||
onClose,
|
||||
onPrevious,
|
||||
onNext,
|
||||
statusLabel,
|
||||
statusColor,
|
||||
environment,
|
||||
}: DrawerHeaderProps) {
|
||||
const provider = log.custom_llm_provider || "";
|
||||
const providerInfo = provider ? getProviderLogoAndName(provider) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: DRAWER_HEADER_PADDING,
|
||||
borderBottom: `1px solid ${COLOR_BORDER}`,
|
||||
backgroundColor: COLOR_BACKGROUND,
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* Row 0: Model + Provider with Logo */}
|
||||
<ModelProviderSection model={log.model} providerLogo={providerInfo?.logo} providerName={providerInfo?.displayName} />
|
||||
|
||||
{/* Row 1: Request ID + Actions */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: SPACING_MEDIUM }}>
|
||||
<RequestIdSection requestId={log.request_id} />
|
||||
<NavigationSection onPrevious={onPrevious} onNext={onNext} onClose={onClose} />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Status + Env + Timestamp */}
|
||||
<StatusBar log={log} statusLabel={statusLabel} statusColor={statusColor} environment={environment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Model and Provider display with logo
|
||||
*/
|
||||
function ModelProviderSection({
|
||||
model,
|
||||
providerLogo,
|
||||
providerName,
|
||||
}: {
|
||||
model: string;
|
||||
providerLogo?: string;
|
||||
providerName?: string;
|
||||
}) {
|
||||
return (
|
||||
<Space size={SPACING_MEDIUM} style={{ marginBottom: SPACING_MEDIUM }}>
|
||||
{providerLogo && (
|
||||
<img
|
||||
src={providerLogo}
|
||||
alt={providerName || "Provider"}
|
||||
style={{ width: 24, height: 24 }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Space size={SPACING_MEDIUM} direction="horizontal">
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
{model}
|
||||
</Text>
|
||||
{providerName && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{providerName}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request ID display with copy functionality
|
||||
*/
|
||||
function RequestIdSection({ requestId }: { requestId: string }) {
|
||||
return (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip title={requestId}>
|
||||
<Text
|
||||
strong
|
||||
copyable={{ text: requestId, tooltips: ["Copy Request ID", "Copied!"] }}
|
||||
style={{
|
||||
fontSize: FONT_SIZE_HEADER,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{requestId}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation controls (previous, next, close)
|
||||
* Shows keyboard shortcuts with bounding boxes for visibility
|
||||
*/
|
||||
function NavigationSection({
|
||||
onPrevious,
|
||||
onNext,
|
||||
onClose,
|
||||
}: {
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const keyboardShortcutStyle = {
|
||||
border: "1px solid #d9d9d9",
|
||||
borderRadius: 4,
|
||||
padding: "0 4px",
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
marginLeft: 4,
|
||||
background: "#fafafa",
|
||||
};
|
||||
|
||||
return (
|
||||
<Space size={SPACING_SMALL} split={<div style={{ width: 1, height: 20, background: COLOR_BORDER }} />}>
|
||||
<Button type="text" size="small" onClick={onPrevious}>
|
||||
<UpOutlined />
|
||||
<span style={keyboardShortcutStyle}>K</span>
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={onNext}>
|
||||
<DownOutlined />
|
||||
<span style={keyboardShortcutStyle}>J</span>
|
||||
</Button>
|
||||
<Tooltip title="ESC to close">
|
||||
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status bar with tags and timestamp
|
||||
*/
|
||||
function StatusBar({
|
||||
log,
|
||||
statusLabel,
|
||||
statusColor,
|
||||
environment,
|
||||
}: {
|
||||
log: LogEntry;
|
||||
statusLabel: string;
|
||||
statusColor: "error" | "success";
|
||||
environment: string;
|
||||
}) {
|
||||
return (
|
||||
<Space size={SPACING_LARGE}>
|
||||
<Tag color={statusColor}>{statusLabel}</Tag>
|
||||
<Tag>Env: {environment}</Tag>
|
||||
<Space size={SPACING_MEDIUM}>
|
||||
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
{moment(log.startTime).format("MMM D, YYYY h:mm:ss A")}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
({moment(log.startTime).fromNow()})
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Typography } from "antd";
|
||||
import { JsonView, defaultStyles } from "react-json-view-lite";
|
||||
import "react-json-view-lite/dist/index.css";
|
||||
import { JSON_MAX_HEIGHT, COLOR_BG_LIGHT, SPACING_LARGE } from "./constants";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface JsonViewerProps {
|
||||
data: any;
|
||||
mode: "formatted";
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays JSON data in formatted tree view.
|
||||
* Uses an interactive tree component for easy navigation.
|
||||
*/
|
||||
export function JsonViewer({ data }: JsonViewerProps) {
|
||||
if (!data) return <Text type="secondary">No data</Text>;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxHeight: JSON_MAX_HEIGHT,
|
||||
overflow: "auto",
|
||||
background: COLOR_BG_LIGHT,
|
||||
padding: SPACING_LARGE,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<div className="[&_[role='tree']]:bg-white [&_[role='tree']]:text-slate-900">
|
||||
<JsonView data={data} style={defaultStyles} clickToExpandNode={true} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { useState } from "react";
|
||||
import { Drawer, Typography, Space, Descriptions, Card, Tag, Tabs, Alert } from "antd";
|
||||
import { Accordion, AccordionHeader, AccordionBody } from "@tremor/react";
|
||||
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 } from "./JsonViewer";
|
||||
import { DrawerHeader } from "./DrawerHeader";
|
||||
import { useKeyboardNavigation } from "./useKeyboardNavigation";
|
||||
import {
|
||||
formatData,
|
||||
checkHasMessages,
|
||||
checkHasResponse,
|
||||
normalizeGuardrailEntries,
|
||||
calculateTotalMaskedEntities,
|
||||
getGuardrailLabel,
|
||||
checkHasVectorStoreData,
|
||||
} from "./utils";
|
||||
import {
|
||||
DRAWER_WIDTH,
|
||||
DRAWER_CONTENT_PADDING,
|
||||
API_BASE_MAX_WIDTH,
|
||||
METADATA_MAX_HEIGHT,
|
||||
TAB_REQUEST,
|
||||
TAB_RESPONSE,
|
||||
FONT_SIZE_SMALL,
|
||||
FONT_FAMILY_MONO,
|
||||
SPACING_XLARGE,
|
||||
SPACING_MEDIUM,
|
||||
} 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<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
|
||||
|
||||
// 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 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 (
|
||||
<Drawer
|
||||
title={null}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
width={DRAWER_WIDTH}
|
||||
closable={false}
|
||||
mask={true}
|
||||
maskClosable={true}
|
||||
styles={{
|
||||
body: { padding: 0, overflow: "hidden" },
|
||||
header: { display: "none" },
|
||||
}}
|
||||
>
|
||||
<DrawerHeader
|
||||
log={logEntry}
|
||||
onClose={onClose}
|
||||
onPrevious={selectPreviousLog}
|
||||
onNext={selectNextLog}
|
||||
statusLabel={statusLabel}
|
||||
statusColor={statusColor}
|
||||
environment={environment}
|
||||
/>
|
||||
|
||||
<div style={{ height: "calc(100vh - 100px)", overflowY: "auto", padding: `${DRAWER_CONTENT_PADDING} ${DRAWER_CONTENT_PADDING} 0` }}>
|
||||
{/* Error Alert - Show prominently at top for failures */}
|
||||
{hasError && errorInfo && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Request Failed"
|
||||
description={<ErrorDescription errorInfo={errorInfo} />}
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tags - Only show if present */}
|
||||
{logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && (
|
||||
<TagsSection tags={logEntry.request_tags} />
|
||||
)}
|
||||
|
||||
{/* Request Details Section */}
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Card title="Request Details" size="small" bordered={false} style={{ marginBottom: 0 }}>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="Model">{logEntry.model}</Descriptions.Item>
|
||||
<Descriptions.Item label="Provider">{logEntry.custom_llm_provider || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Call Type">{logEntry.call_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="Model ID">
|
||||
<TruncatedValue value={logEntry.model_id} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="API Base">
|
||||
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
|
||||
</Descriptions.Item>
|
||||
{logEntry.requester_ip_address && (
|
||||
<Descriptions.Item label="IP Address">{logEntry.requester_ip_address}</Descriptions.Item>
|
||||
)}
|
||||
{hasGuardrailData && (
|
||||
<Descriptions.Item label="Guardrail">
|
||||
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Metrics Section */}
|
||||
<MetricsSection logEntry={logEntry} metadata={metadata} />
|
||||
|
||||
{/* Cost Breakdown - Show if cost breakdown data is available */}
|
||||
<CostBreakdownViewer costBreakdown={metadata?.cost_breakdown} totalSpend={logEntry.spend || 0} />
|
||||
|
||||
{/* Configuration Info Message - Show when data is missing */}
|
||||
{missingData && (
|
||||
<div className="mb-6">
|
||||
<ConfigInfoMessage show={missingData} onOpenSettings={onOpenSettings} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Request/Response JSON - Collapsible */}
|
||||
<RequestResponseSection
|
||||
hasResponse={hasResponse}
|
||||
getRawRequest={getRawRequest}
|
||||
getFormattedResponse={getFormattedResponse}
|
||||
/>
|
||||
|
||||
{/* Guardrail Data - Show only if present */}
|
||||
{hasGuardrailData && <GuardrailViewer data={guardrailInfo} />}
|
||||
|
||||
{/* Vector Store Request Data - Show only if present */}
|
||||
{hasVectorStoreData && <VectorStoreViewer data={metadata.vector_store_request_metadata} />}
|
||||
|
||||
{/* Metadata Card - Only show if there's metadata */}
|
||||
{logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && (
|
||||
<MetadataSection metadata={logEntry.metadata} />
|
||||
)}
|
||||
|
||||
{/* Bottom spacing for scroll area */}
|
||||
<div style={{ height: DRAWER_CONTENT_PADDING }} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Components
|
||||
// ============================================================================
|
||||
|
||||
function ErrorDescription({ errorInfo }: { errorInfo: any }) {
|
||||
return (
|
||||
<div>
|
||||
{errorInfo.error_code && (
|
||||
<div>
|
||||
<Text strong>Error Code:</Text> {errorInfo.error_code}
|
||||
</div>
|
||||
)}
|
||||
{errorInfo.error_message && (
|
||||
<div>
|
||||
<Text strong>Message:</Text> {errorInfo.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsSection({ tags }: { tags: Record<string, any> }) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden p-4 mb-6">
|
||||
<Text strong style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
|
||||
Tags
|
||||
</Text>
|
||||
<Space size={SPACING_MEDIUM} wrap>
|
||||
{Object.entries(tags).map(([key, value]) => (
|
||||
<Tag key={key}>
|
||||
{key}: {String(value)}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) {
|
||||
return (
|
||||
<Space size={SPACING_MEDIUM}>
|
||||
<span>{label}</span>
|
||||
{maskedCount > 0 && (
|
||||
<Tag color="blue">
|
||||
{maskedCount} masked
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: Record<string, any> }) {
|
||||
const hasCacheActivity =
|
||||
logEntry.cache_hit ||
|
||||
(metadata?.additional_usage_values?.cache_read_input_tokens &&
|
||||
metadata.additional_usage_values.cache_read_input_tokens > 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Card title="Metrics" size="small" bordered={false} style={{ marginBottom: 0 }}>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="Tokens">
|
||||
<TokenFlow
|
||||
prompt={logEntry.prompt_tokens}
|
||||
completion={logEntry.completion_tokens}
|
||||
total={logEntry.total_tokens}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Duration">{logEntry.duration?.toFixed(3)} s</Descriptions.Item>
|
||||
|
||||
{/* Only show cache fields if there's cache activity */}
|
||||
{hasCacheActivity && (
|
||||
<>
|
||||
<Descriptions.Item label="Cache Hit">
|
||||
<Tag color={logEntry.cache_hit ? "green" : "default"}>{logEntry.cache_hit || "None"}</Tag>
|
||||
</Descriptions.Item>
|
||||
{metadata?.additional_usage_values?.cache_read_input_tokens > 0 && (
|
||||
<Descriptions.Item label="Cache Read Tokens">
|
||||
{formatNumberWithCommas(metadata.additional_usage_values.cache_read_input_tokens)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{metadata?.additional_usage_values?.cache_creation_input_tokens > 0 && (
|
||||
<Descriptions.Item label="Cache Creation Tokens">
|
||||
{formatNumberWithCommas(metadata.additional_usage_values.cache_creation_input_tokens)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
|
||||
<Descriptions.Item label="LiteLLM Overhead">
|
||||
{metadata.litellm_overhead_time_ms.toFixed(2)} ms
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
|
||||
<Descriptions.Item label="Start Time">
|
||||
{moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="End Time">
|
||||
{moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RequestResponseSectionProps {
|
||||
hasResponse: boolean;
|
||||
getRawRequest: () => any;
|
||||
getFormattedResponse: () => any;
|
||||
}
|
||||
|
||||
function RequestResponseSection({
|
||||
hasResponse,
|
||||
getRawRequest,
|
||||
getFormattedResponse,
|
||||
}: RequestResponseSectionProps) {
|
||||
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
|
||||
|
||||
const getCopyText = () => {
|
||||
const data = activeTab === TAB_REQUEST ? getRawRequest() : getFormattedResponse();
|
||||
return JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Accordion>
|
||||
<AccordionHeader className="p-4 border-b hover:bg-gray-50 transition-colors text-left">
|
||||
<h3 className="text-lg font-medium text-gray-900">Request & Response</h3>
|
||||
</AccordionHeader>
|
||||
<AccordionBody className="px-0">
|
||||
<div style={{ padding: "0 24px" }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
|
||||
tabBarExtraContent={
|
||||
<Text
|
||||
copyable={{
|
||||
text: getCopyText(),
|
||||
tooltips: ["Copy JSON", "Copied!"]
|
||||
}}
|
||||
disabled={activeTab === TAB_RESPONSE && !hasResponse}
|
||||
/>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: TAB_REQUEST,
|
||||
label: "Request",
|
||||
children: (
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
<JsonViewer data={getRawRequest()} mode="formatted" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: TAB_RESPONSE,
|
||||
label: "Response",
|
||||
children: (
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
{hasResponse ? (
|
||||
<JsonViewer data={getFormattedResponse()} mode="formatted" />
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
|
||||
Response data not available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataSection({ metadata }: { metadata: Record<string, any> }) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Card
|
||||
title="Metadata"
|
||||
size="small"
|
||||
bordered={false}
|
||||
style={{ marginBottom: 0 }}
|
||||
extra={
|
||||
<Text
|
||||
copyable={{
|
||||
text: JSON.stringify(metadata, null, 2),
|
||||
tooltips: ["Copy Metadata", "Copied!"]
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
maxHeight: METADATA_MAX_HEIGHT,
|
||||
overflowY: "auto",
|
||||
fontSize: FONT_SIZE_SMALL,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Typography } from "antd";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TokenFlowProps {
|
||||
prompt?: number;
|
||||
completion?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays token usage in LiteLLM format: "12 (9 prompt tokens + 3 completion tokens)"
|
||||
* Shows total with breakdown of prompt and completion tokens.
|
||||
*/
|
||||
export function TokenFlow({ prompt = 0, completion = 0, total = 0 }: TokenFlowProps) {
|
||||
return (
|
||||
<Text>
|
||||
{total.toLocaleString()} ({prompt.toLocaleString()} prompt tokens + {completion.toLocaleString()} completion
|
||||
tokens)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -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 <Text type="secondary">-</Text>;
|
||||
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<Text
|
||||
copyable={{ text: value, tooltips: ["Copy", "Copied!"] }}
|
||||
style={{
|
||||
maxWidth,
|
||||
display: "inline-block",
|
||||
verticalAlign: "bottom",
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
fontSize: FONT_SIZE_SMALL,
|
||||
}}
|
||||
ellipsis
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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<boolean> - true if copy succeeded, false otherwise
|
||||
*/
|
||||
export async function copyToClipboard(text: string, label: string): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,4 @@ 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";
|
||||
// Messages (kept for backwards compatibility if needed elsewhere)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LogDetailsDrawer } from "./LogDetailsDrawer";
|
||||
export type { LogDetailsDrawerProps } from "./LogDetailsDrawer";
|
||||
+87
@@ -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;
|
||||
}
|
||||
@@ -49,46 +49,6 @@ export type LogEntry = {
|
||||
};
|
||||
|
||||
export const columns: ColumnDef<LogEntry>[] = [
|
||||
{
|
||||
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() ? (
|
||||
<button
|
||||
onClick={toggleHandler}
|
||||
style={{ cursor: "pointer" }}
|
||||
aria-label={localExpanded ? "Collapse row" : "Expand row"}
|
||||
className="w-6 h-6 flex items-center justify-center focus:outline-none"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transform transition-transform duration-75 ${localExpanded ? "rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-6 h-6 flex items-center justify-center">●</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Return the component
|
||||
return <ExpanderCell />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Time",
|
||||
accessorKey: "startTime",
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(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,18 @@ 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
|
||||
};
|
||||
|
||||
const handleSelectLog = (log: LogEntry) => {
|
||||
setSelectedLog(log);
|
||||
};
|
||||
|
||||
// Function to extract unique error codes from logs
|
||||
@@ -554,9 +555,7 @@ export default function SpendLogsTable({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={sessionData}
|
||||
renderSubComponent={({ row }) => <RequestViewer row={row} onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)} />}
|
||||
getRowCanExpand={() => true}
|
||||
// Optionally: add session-specific row expansion state
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -753,8 +752,7 @@ export default function SpendLogsTable({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
renderSubComponent={({ row }) => <RequestViewer row={row} onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)} />}
|
||||
getRowCanExpand={() => true}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -775,6 +773,16 @@ export default function SpendLogsTable({
|
||||
<TabPanel><DeletedTeamsPage /></TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
{/* Log Details Drawer */}
|
||||
<LogDetailsDrawer
|
||||
open={isDrawerOpen}
|
||||
onClose={handleCloseDrawer}
|
||||
logEntry={selectedLog}
|
||||
onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)}
|
||||
allLogs={filteredData}
|
||||
onSelectLog={handleSelectLog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } fro
|
||||
interface DataTableProps<TData, TValue> {
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
renderSubComponent: (props: { row: Row<TData> }) => React.ReactElement;
|
||||
getRowCanExpand: (row: Row<TData>) => boolean;
|
||||
onRowClick?: (row: TData) => void;
|
||||
// Legacy props for backward compatibility (audit logs)
|
||||
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
|
||||
getRowCanExpand?: (row: Row<TData>) => boolean;
|
||||
isLoading?: boolean;
|
||||
loadingMessage?: string;
|
||||
noDataMessage?: string;
|
||||
@@ -16,22 +18,26 @@ interface DataTableProps<TData, TValue> {
|
||||
export function DataTable<TData, TValue>({
|
||||
data = [],
|
||||
columns,
|
||||
getRowCanExpand,
|
||||
onRowClick,
|
||||
renderSubComponent,
|
||||
getRowCanExpand,
|
||||
isLoading = false,
|
||||
loadingMessage = "🚅 Loading logs...",
|
||||
noDataMessage = "No logs found",
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
// Determine if we're in legacy expansion mode or new drawer mode
|
||||
const isLegacyMode = !!renderSubComponent && !!getRowCanExpand;
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
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<TData, TValue>({
|
||||
) : table.getRowModel().rows.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<Fragment key={row.id}>
|
||||
<TableRow className="h-8">
|
||||
<TableRow
|
||||
className={`h-8 ${!isLegacyMode ? "cursor-pointer hover:bg-gray-50" : ""}`}
|
||||
onClick={() => !isLegacyMode && onRowClick?.(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
@@ -70,7 +79,8 @@ export function DataTable<TData, TValue>({
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
{row.getIsExpanded() && (
|
||||
{/* Legacy expansion mode for audit logs */}
|
||||
{isLegacyMode && row.getIsExpanded() && renderSubComponent && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={row.getVisibleCells().length} className="p-0">
|
||||
<div className="w-full max-w-full overflow-hidden box-border">{renderSubComponent({ row })}</div>
|
||||
|
||||
Reference in New Issue
Block a user