mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
refactored code
This commit is contained in:
parent
8b7a9250ce
commit
f07ef8af00
11 changed files with 976 additions and 422 deletions
|
|
@ -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<number>((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 (
|
|
||||||
<Drawer
|
|
||||||
title={null}
|
|
||||||
placement="right"
|
|
||||||
onClose={onClose}
|
|
||||||
open={open}
|
|
||||||
width="60%"
|
|
||||||
closable={false}
|
|
||||||
mask={true}
|
|
||||||
maskClosable={true}
|
|
||||||
styles={{
|
|
||||||
body: { padding: 0, overflow: "hidden" },
|
|
||||||
header: { display: "none" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Custom Header with Request ID prominently displayed */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "16px 24px",
|
|
||||||
borderBottom: "1px solid #f0f0f0",
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Request ID at top - like Langfuse trace ID */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "8px" }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flex: 1, minWidth: 0 }}>
|
|
||||||
<Tooltip title={logEntry.request_id}>
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
fontSize: "16px",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{logEntry.request_id}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Copy Request ID">
|
|
||||||
<Button type="text" size="small" icon={<CopyOutlined />} onClick={handleCopyRequestId} />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status and timestamp row */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
|
||||||
<Tag color={metadata.status === "failure" ? "error" : "success"}>
|
|
||||||
{metadata.status === "failure" ? "Failure" : "Success"}
|
|
||||||
</Tag>
|
|
||||||
<Text type="secondary" style={{ fontSize: "13px" }}>
|
|
||||||
{logEntry.startTime}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable content area */}
|
|
||||||
<div style={{ height: "calc(100vh - 100px)", overflowY: "auto", padding: "24px" }}>
|
|
||||||
{/* Request Details Section */}
|
|
||||||
<Card title="Request Details" size="small" style={{ marginBottom: "16px" }}>
|
|
||||||
<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">{logEntry.model_id}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="API Base">
|
|
||||||
<Tooltip title={logEntry.api_base || "-"}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
maxWidth: "200px",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
display: "inline-block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{logEntry.api_base || "-"}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Descriptions.Item>
|
|
||||||
{logEntry.requester_ip_address && (
|
|
||||||
<Descriptions.Item label="IP Address">{logEntry.requester_ip_address}</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
{hasGuardrailData && (
|
|
||||||
<Descriptions.Item label="Guardrail">
|
|
||||||
<span>{primaryGuardrailLabel}</span>
|
|
||||||
{totalMaskedEntities > 0 && (
|
|
||||||
<Tag color="blue" style={{ marginLeft: "8px" }}>
|
|
||||||
{totalMaskedEntities} masked
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
</Descriptions>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Metrics Section */}
|
|
||||||
<Card title="Metrics" size="small" style={{ marginBottom: "16px" }}>
|
|
||||||
<Descriptions column={2} size="small">
|
|
||||||
<Descriptions.Item label="Tokens">
|
|
||||||
{logEntry.total_tokens} ({logEntry.prompt_tokens} prompt + {logEntry.completion_tokens} completion)
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 6)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Duration">{logEntry.duration} s</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Cache Hit">{logEntry.cache_hit}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Cache Read Tokens">
|
|
||||||
{formatNumberWithCommas(metadata?.additional_usage_values?.cache_read_input_tokens || 0)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Cache Creation Tokens">
|
|
||||||
{formatNumberWithCommas(metadata?.additional_usage_values?.cache_creation_input_tokens || 0)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Start Time">{logEntry.startTime}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="End Time">{logEntry.endTime}</Descriptions.Item>
|
|
||||||
{metadata?.litellm_overhead_time_ms !== undefined && (
|
|
||||||
<Descriptions.Item label="LiteLLM Overhead">{metadata.litellm_overhead_time_ms} ms</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
</Descriptions>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 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 */}
|
|
||||||
<ConfigInfoMessage show={missingData} onOpenSettings={onOpenSettings} />
|
|
||||||
|
|
||||||
{/* Request/Response JSON - Using Tabs */}
|
|
||||||
<Card title="Request & Response" size="small" style={{ marginBottom: "16px" }}>
|
|
||||||
<Tabs
|
|
||||||
defaultActiveKey="request"
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: "request",
|
|
||||||
label: "Request",
|
|
||||||
children: (
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
style={{ position: "absolute", top: "8px", right: "8px", zIndex: 1 }}
|
|
||||||
onClick={() => copyToClipboard(JSON.stringify(getRawRequest(), null, 2), "Request")}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</Button>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxHeight: "500px",
|
|
||||||
overflowY: "auto",
|
|
||||||
backgroundColor: "#fafafa",
|
|
||||||
padding: "12px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="[&_[role='tree']]:bg-white [&_[role='tree']]:text-slate-900">
|
|
||||||
<JsonView data={getRawRequest()} style={defaultStyles} clickToExpandNode={true} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "response",
|
|
||||||
label: "Response",
|
|
||||||
children: (
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
style={{ position: "absolute", top: "8px", right: "8px", zIndex: 1 }}
|
|
||||||
onClick={() => copyToClipboard(JSON.stringify(formattedResponse(), null, 2), "Response")}
|
|
||||||
disabled={!hasResponse}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</Button>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxHeight: "500px",
|
|
||||||
overflowY: "auto",
|
|
||||||
backgroundColor: "#fafafa",
|
|
||||||
padding: "12px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasResponse ? (
|
|
||||||
<div className="[&_[role='tree']]:bg-white [&_[role='tree']]:text-slate-900">
|
|
||||||
<JsonView data={formattedResponse()} style={defaultStyles} clickToExpandNode />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ textAlign: "center", padding: "20px", color: "#999", fontStyle: "italic" }}>
|
|
||||||
Response data not available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Guardrail Data - Show only if present */}
|
|
||||||
{hasGuardrailData && (
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
|
||||||
<GuardrailViewer data={guardrailInfo} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Vector Store Request Data - Show only if present */}
|
|
||||||
{hasVectorStoreData && (
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
|
||||||
<VectorStoreViewer data={metadata.vector_store_request_metadata} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error Card - Only show for failures */}
|
|
||||||
{hasError && errorInfo && (
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
|
||||||
<ErrorViewer errorInfo={errorInfo} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags Card - Only show if there are tags */}
|
|
||||||
{logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && (
|
|
||||||
<Card title="Request Tags" size="small" style={{ marginBottom: "16px" }}>
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
|
|
||||||
{Object.entries(logEntry.request_tags).map(([key, value]) => (
|
|
||||||
<Tag key={key}>
|
|
||||||
{key}: {String(value)}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Metadata Card - Only show if there's metadata */}
|
|
||||||
{logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && (
|
|
||||||
<Card
|
|
||||||
title="Metadata"
|
|
||||||
size="small"
|
|
||||||
style={{ marginBottom: "16px" }}
|
|
||||||
extra={
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copyToClipboard(JSON.stringify(logEntry.metadata, null, 2), "Metadata")}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<pre
|
|
||||||
style={{
|
|
||||||
maxHeight: "300px",
|
|
||||||
overflowY: "auto",
|
|
||||||
fontSize: "12px",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
wordBreak: "break-all",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{JSON.stringify(logEntry.metadata, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: DRAWER_HEADER_PADDING,
|
||||||
|
borderBottom: `1px solid ${COLOR_BORDER}`,
|
||||||
|
backgroundColor: COLOR_BACKGROUND,
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Row 1: Request ID + Actions */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: SPACING_MEDIUM }}>
|
||||||
|
<RequestIdSection requestId={log.request_id} onCopy={onCopyRequestId} />
|
||||||
|
<NavigationSection onPrevious={onPrevious} onNext={onNext} onClose={onClose} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Status + Env + Timestamp */}
|
||||||
|
<StatusBar log={log} statusLabel={statusLabel} statusColor={statusColor} environment={environment} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request ID display with copy button
|
||||||
|
*/
|
||||||
|
function RequestIdSection({ requestId, onCopy }: { requestId: string; onCopy: () => void }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: SPACING_MEDIUM, flex: 1, minWidth: 0 }}>
|
||||||
|
<Tooltip title={requestId}>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: FONT_SIZE_HEADER,
|
||||||
|
fontFamily: FONT_FAMILY_MONO,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{requestId}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Copy Request ID">
|
||||||
|
<Button type="text" size="small" icon={<CopyOutlined />} onClick={onCopy} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation controls (previous, next, close)
|
||||||
|
*/
|
||||||
|
function NavigationSection({
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: SPACING_SMALL }}>
|
||||||
|
<Tooltip title="Previous (K)">
|
||||||
|
<Button type="text" size="small" icon={<UpOutlined />} onClick={onPrevious} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Next (J)">
|
||||||
|
<Button type="text" size="small" icon={<DownOutlined />} onClick={onNext} />
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div style={{ width: 1, height: 20, background: COLOR_BORDER, margin: `0 ${SPACING_MEDIUM}px` }} />
|
||||||
|
|
||||||
|
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status bar with tags and timestamp
|
||||||
|
*/
|
||||||
|
function StatusBar({
|
||||||
|
log,
|
||||||
|
statusLabel,
|
||||||
|
statusColor,
|
||||||
|
environment,
|
||||||
|
}: {
|
||||||
|
log: LogEntry;
|
||||||
|
statusLabel: string;
|
||||||
|
statusColor: "error" | "success";
|
||||||
|
environment: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: SPACING_LARGE }}>
|
||||||
|
<Tag color={statusColor}>{statusLabel}</Tag>
|
||||||
|
<Tag>Env: {environment}</Tag>
|
||||||
|
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||||
|
{moment(log.startTime).format("MMM D, YYYY h:mm:ss A")}
|
||||||
|
<span style={{ marginLeft: SPACING_MEDIUM }}>({moment(log.startTime).fromNow()})</span>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 <Text type="secondary">No data</Text>;
|
||||||
|
|
||||||
|
if (mode === VIEW_MODE_JSON) {
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
maxHeight: JSON_MAX_HEIGHT,
|
||||||
|
overflow: "auto",
|
||||||
|
fontSize: FONT_SIZE_SMALL,
|
||||||
|
background: COLOR_BG_LIGHT,
|
||||||
|
padding: SPACING_LARGE,
|
||||||
|
borderRadius: 4,
|
||||||
|
fontFamily: FONT_FAMILY_MONO,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JSON.stringify(data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatted tree view
|
||||||
|
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,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<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
|
||||||
|
const [jsonViewMode, setJsonViewMode] = useState<ViewMode>(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 (
|
||||||
|
<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}
|
||||||
|
onCopyRequestId={handleCopyRequestId}
|
||||||
|
onPrevious={selectPreviousLog}
|
||||||
|
onNext={selectNextLog}
|
||||||
|
statusLabel={statusLabel}
|
||||||
|
statusColor={statusColor}
|
||||||
|
environment={environment}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ height: "calc(100vh - 100px)", overflowY: "auto", padding: DRAWER_CONTENT_PADDING }}>
|
||||||
|
{/* Error Alert - Show prominently at top for failures */}
|
||||||
|
{hasError && errorInfo && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Request Failed"
|
||||||
|
description={<ErrorDescription errorInfo={errorInfo} />}
|
||||||
|
style={{ marginBottom: SPACING_XLARGE }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags - Only show if present */}
|
||||||
|
{logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && (
|
||||||
|
<TagsSection tags={logEntry.request_tags} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Request Details Section */}
|
||||||
|
<Card title="Request Details" size="small" style={{ marginBottom: SPACING_XLARGE }}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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 */}
|
||||||
|
<ConfigInfoMessage show={missingData} onOpenSettings={onOpenSettings} />
|
||||||
|
|
||||||
|
{/* Request/Response JSON - Using Tabs with View Toggle */}
|
||||||
|
<RequestResponseSection
|
||||||
|
activeTab={activeTab}
|
||||||
|
jsonViewMode={jsonViewMode}
|
||||||
|
hasResponse={hasResponse}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
onViewModeChange={setJsonViewMode}
|
||||||
|
onCopy={(data, label) => copyToClipboard(JSON.stringify(data, null, 2), label)}
|
||||||
|
getRawRequest={getRawRequest}
|
||||||
|
getFormattedResponse={getFormattedResponse}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Guardrail Data - Show only if present */}
|
||||||
|
{hasGuardrailData && (
|
||||||
|
<div style={{ marginBottom: SPACING_XLARGE }}>
|
||||||
|
<GuardrailViewer data={guardrailInfo} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vector Store Request Data - Show only if present */}
|
||||||
|
{hasVectorStoreData && (
|
||||||
|
<div style={{ marginBottom: SPACING_XLARGE }}>
|
||||||
|
<VectorStoreViewer data={metadata.vector_store_request_metadata} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata Card - Only show if there's metadata */}
|
||||||
|
{logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && (
|
||||||
|
<MetadataSection metadata={logEntry.metadata} onCopy={(data) => copyToClipboard(data, "Metadata")} />
|
||||||
|
)}
|
||||||
|
</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 style={{ marginBottom: SPACING_XLARGE }}>
|
||||||
|
<Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||||
|
Tags
|
||||||
|
</Text>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{Object.entries(tags).map(([key, value]) => (
|
||||||
|
<Tag key={key}>
|
||||||
|
{key}: {String(value)}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span>{label}</span>
|
||||||
|
{maskedCount > 0 && (
|
||||||
|
<Tag color="blue" style={{ marginLeft: 8 }}>
|
||||||
|
{maskedCount} masked
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card title="Metrics" size="small" style={{ marginBottom: SPACING_XLARGE }}>
|
||||||
|
<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 && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card
|
||||||
|
title="Request & Response"
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: SPACING_XLARGE }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={(key) => onTabChange(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
|
||||||
|
tabBarExtraContent={
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, paddingRight: SPACING_XLARGE }}>
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<Radio.Group size="small" value={jsonViewMode} onChange={(e) => onViewModeChange(e.target.value)}>
|
||||||
|
<Radio.Button value={VIEW_MODE_FORMATTED}>Formatted</Radio.Button>
|
||||||
|
<Radio.Button value="json">JSON</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
{/* Copy Button */}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={activeTab === TAB_RESPONSE && !hasResponse}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: TAB_REQUEST,
|
||||||
|
label: "Request",
|
||||||
|
children: (
|
||||||
|
<div style={{ padding: SPACING_XLARGE }}>
|
||||||
|
<JsonViewer data={getRawRequest()} mode={jsonViewMode} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TAB_RESPONSE,
|
||||||
|
label: "Response",
|
||||||
|
children: (
|
||||||
|
<div style={{ padding: SPACING_XLARGE }}>
|
||||||
|
{hasResponse ? (
|
||||||
|
<JsonViewer data={getFormattedResponse()} mode={jsonViewMode} />
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
|
||||||
|
Response data not available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
style={{ padding: `0 ${SPACING_XLARGE}px` }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetadataSection({ metadata, onCopy }: { metadata: Record<string, any>; onCopy: (data: string) => void }) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="Metadata"
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: SPACING_XLARGE }}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => onCopy(JSON.stringify(metadata, null, 2))}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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<number>((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<string, any>): boolean {
|
||||||
|
return (
|
||||||
|
metadata.vector_store_request_metadata &&
|
||||||
|
Array.isArray(metadata.vector_store_request_metadata) &&
|
||||||
|
metadata.vector_store_request_metadata.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<Text style={{ fontFamily: FONT_FAMILY_MONO, fontSize: FONT_SIZE_MEDIUM }}>
|
||||||
|
{prompt.toLocaleString()}
|
||||||
|
<span style={{ color: COLOR_SECONDARY, margin: `0 ${SPACING_SMALL}px` }}>→</span>
|
||||||
|
{completion.toLocaleString()}
|
||||||
|
<span style={{ color: COLOR_SECONDARY, marginLeft: SPACING_MEDIUM }}>
|
||||||
|
(Σ {total.toLocaleString()})
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { LogDetailsDrawer } from "./LogDetailsDrawer";
|
||||||
|
export type { LogDetailsDrawerProps } from "./LogDetailsDrawer";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -368,6 +368,10 @@ export default function SpendLogsTable({
|
||||||
// Optionally keep selectedLog for animation purposes
|
// Optionally keep selectedLog for animation purposes
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectLog = (log: LogEntry) => {
|
||||||
|
setSelectedLog(log);
|
||||||
|
};
|
||||||
|
|
||||||
// Function to extract unique error codes from logs
|
// Function to extract unique error codes from logs
|
||||||
const extractErrorCodes = (logs: LogEntry[], searchText: string = "") => {
|
const extractErrorCodes = (logs: LogEntry[], searchText: string = "") => {
|
||||||
const errorCodes = new Set<string>();
|
const errorCodes = new Set<string>();
|
||||||
|
|
@ -776,6 +780,8 @@ export default function SpendLogsTable({
|
||||||
onClose={handleCloseDrawer}
|
onClose={handleCloseDrawer}
|
||||||
logEntry={selectedLog}
|
logEntry={selectedLog}
|
||||||
onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)}
|
onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)}
|
||||||
|
allLogs={filteredData}
|
||||||
|
onSelectLog={handleSelectLog}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue