Spaces:
Configuration error
Configuration error
import moment from "moment"; | |
import { useQuery } from "@tanstack/react-query"; | |
import { useState, useRef, useEffect, useCallback } from "react"; | |
import { useQueryClient } from "@tanstack/react-query"; | |
import { uiSpendLogsCall, keyInfoV1Call, sessionSpendLogsCall, keyListCall } from "../networking"; | |
import { DataTable } from "./table"; | |
import { columns, LogEntry } from "./columns"; | |
import { Row } from "@tanstack/react-table"; | |
import { prefetchLogDetails } from "./prefetch"; | |
import { RequestResponsePanel } from './RequestResponsePanel'; | |
import { ErrorViewer } from './ErrorViewer'; | |
import { internalUserRoles } from "../../utils/roles"; | |
import { ConfigInfoMessage } from './ConfigInfoMessage'; | |
import { Tooltip } from "antd"; | |
import { KeyResponse, Team } from "../key_team_helpers/key_list"; | |
import KeyInfoView from "../key_info_view"; | |
import { SessionView } from './SessionView'; | |
import { VectorStoreViewer } from './VectorStoreViewer'; | |
import { GuardrailViewer } from './GuardrailViewer'; | |
import FilterComponent from "../common_components/filter"; | |
import { FilterOption } from "../common_components/filter"; | |
import { useLogFilterLogic } from "./log_filter_logic"; | |
import { fetchAllKeyAliases } from "../key_team_helpers/filter_helpers"; | |
import { | |
Tab, | |
TabGroup, | |
TabList, | |
TabPanels, | |
TabPanel, | |
Text, | |
} from "@tremor/react"; | |
import AuditLogs from "./audit_logs"; | |
import { getTimeRangeDisplay } from "./logs_utils"; | |
interface SpendLogsTableProps { | |
accessToken: string | null; | |
token: string | null; | |
userRole: string | null; | |
userID: string | null; | |
allTeams: Team[]; | |
premiumUser: boolean; | |
} | |
export interface PaginatedResponse { | |
data: LogEntry[]; | |
total: number; | |
page: number; | |
page_size: number; | |
total_pages: number; | |
} | |
interface PrefetchedLog { | |
messages: any[]; | |
response: any; | |
} | |
export default function SpendLogsTable({ | |
accessToken, | |
token, | |
userRole, | |
userID, | |
allTeams, | |
premiumUser, | |
}: SpendLogsTableProps) { | |
const [searchTerm, setSearchTerm] = useState(""); | |
const [showFilters, setShowFilters] = useState(false); | |
const [showColumnDropdown, setShowColumnDropdown] = useState(false); | |
const [currentPage, setCurrentPage] = useState(1); | |
const [pageSize] = useState(50); | |
const dropdownRef = useRef<HTMLDivElement>(null); | |
const filtersRef = useRef<HTMLDivElement>(null); | |
const quickSelectRef = useRef<HTMLDivElement>(null); | |
// New state variables for Start and End Time | |
const [startTime, setStartTime] = useState<string>( | |
moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm") | |
); | |
const [endTime, setEndTime] = useState<string>( | |
moment().format("YYYY-MM-DDTHH:mm") | |
); | |
// Add these new state variables at the top with other useState declarations | |
const [isCustomDate, setIsCustomDate] = useState(false); | |
const [quickSelectOpen, setQuickSelectOpen] = useState(false); | |
const [tempTeamId, setTempTeamId] = useState(""); | |
const [tempKeyHash, setTempKeyHash] = useState(""); | |
const [selectedTeamId, setSelectedTeamId] = useState(""); | |
const [selectedKeyHash, setSelectedKeyHash] = useState(""); | |
const [selectedModel, setSelectedModel] = useState(""); | |
const [selectedKeyInfo, setSelectedKeyInfo] = useState<KeyResponse | null>(null); | |
const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState<string | null>(null); | |
const [selectedStatus, setSelectedStatus] = useState(""); | |
const [filterByCurrentUser, setFilterByCurrentUser] = useState( | |
userRole && internalUserRoles.includes(userRole) | |
); | |
const [activeTab, setActiveTab] = useState("request logs"); | |
const [expandedRequestId, setExpandedRequestId] = useState<string | null>(null); | |
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null); | |
const queryClient = useQueryClient(); | |
useEffect(() => { | |
const fetchKeyInfo = async () => { | |
if (selectedKeyIdInfoView && accessToken) { | |
const keyData = await keyInfoV1Call(accessToken, selectedKeyIdInfoView); | |
const keyResponse: KeyResponse = { | |
...keyData["info"], | |
"token": selectedKeyIdInfoView, | |
"api_key": selectedKeyIdInfoView, | |
}; | |
setSelectedKeyInfo(keyResponse); | |
} | |
}; | |
fetchKeyInfo(); | |
}, [selectedKeyIdInfoView, accessToken]); | |
// Close dropdown when clicking outside | |
useEffect(() => { | |
function handleClickOutside(event: MouseEvent) { | |
if ( | |
dropdownRef.current && | |
!dropdownRef.current.contains(event.target as Node) | |
) { | |
setShowColumnDropdown(false); | |
} | |
if ( | |
filtersRef.current && | |
!filtersRef.current.contains(event.target as Node) | |
) { | |
setShowFilters(false); | |
} | |
if ( | |
quickSelectRef.current && | |
!quickSelectRef.current.contains(event.target as Node) | |
) { | |
setQuickSelectOpen(false); | |
} | |
} | |
document.addEventListener("mousedown", handleClickOutside); | |
return () => | |
document.removeEventListener("mousedown", handleClickOutside); | |
}, []); | |
useEffect(() => { | |
if (userRole && internalUserRoles.includes(userRole)) { | |
setFilterByCurrentUser(true); | |
} | |
}, [userRole]); | |
const logs = useQuery<PaginatedResponse>({ | |
queryKey: [ | |
"logs", | |
"table", | |
currentPage, | |
pageSize, | |
startTime, | |
endTime, | |
selectedTeamId, | |
selectedKeyHash, | |
filterByCurrentUser ? userID : null, | |
selectedStatus, | |
selectedModel | |
], | |
queryFn: async () => { | |
if (!accessToken || !token || !userRole || !userID) { | |
return { | |
data: [], | |
total: 0, | |
page: 1, | |
page_size: pageSize, | |
total_pages: 0, | |
}; | |
} | |
const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); | |
const formattedEndTime = isCustomDate | |
? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") | |
: moment().utc().format("YYYY-MM-DD HH:mm:ss"); | |
// Get base response from API | |
const response = await uiSpendLogsCall( | |
accessToken, | |
selectedKeyHash || undefined, | |
selectedTeamId || undefined, | |
undefined, | |
formattedStartTime, | |
formattedEndTime, | |
currentPage, | |
pageSize, | |
filterByCurrentUser ? userID : undefined, | |
selectedStatus, | |
selectedModel | |
); | |
// Trigger prefetch for all logs | |
await prefetchLogDetails( | |
response.data, | |
formattedStartTime, | |
accessToken, | |
queryClient | |
); | |
// Update logs with prefetched data if available | |
response.data = response.data.map((log: LogEntry) => { | |
const prefetchedData = queryClient.getQueryData<PrefetchedLog>( | |
["logDetails", log.request_id, formattedStartTime] | |
); | |
if (prefetchedData?.messages && prefetchedData?.response) { | |
log.messages = prefetchedData.messages; | |
log.response = prefetchedData.response; | |
return log; | |
} | |
return log; | |
}); | |
return response; | |
}, | |
enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs", | |
refetchInterval: 5000, | |
refetchIntervalInBackground: true, | |
}); | |
const logsData = logs.data || { | |
data: [], | |
total: 0, | |
page: 1, | |
page_size: pageSize || 10, | |
total_pages: 1 | |
}; | |
const { | |
filters, | |
filteredLogs, | |
allTeams: hookAllTeams, | |
allKeyAliases, | |
handleFilterChange, | |
handleFilterReset | |
} = useLogFilterLogic({ | |
logs: logsData, | |
accessToken, | |
startTime, | |
endTime, | |
pageSize, | |
isCustomDate, | |
setCurrentPage, | |
userID, | |
userRole | |
}) | |
const fetchKeyHashForAlias = useCallback(async (keyAlias: string) => { | |
if (!accessToken) return; | |
try { | |
const response = await keyListCall( | |
accessToken, | |
null, | |
null, | |
keyAlias, | |
null, | |
null, | |
currentPage, | |
pageSize | |
); | |
const selectedKey = response.keys.find( | |
(key: any) => key.key_alias === keyAlias | |
); | |
if (selectedKey) { | |
setSelectedKeyHash(selectedKey.token); | |
} | |
} catch (error) { | |
console.error("Error fetching key hash for alias:", error); | |
} | |
}, [accessToken, currentPage, pageSize]); | |
// Add this effect to update selected filters when filter changes | |
useEffect(() => { | |
if(!accessToken) return; | |
if (filters['Team ID']) { | |
setSelectedTeamId(filters['Team ID']); | |
} else { | |
setSelectedTeamId(""); | |
} | |
setSelectedStatus(filters['Status'] || ""); | |
setSelectedModel(filters['Model'] || ""); | |
if (filters['Key Hash']) { | |
setSelectedKeyHash(filters['Key Hash']); | |
} else if (filters['Key Alias']) { | |
fetchKeyHashForAlias(filters['Key Alias']); | |
} else { | |
setSelectedKeyHash(""); | |
} | |
}, [filters, accessToken, fetchKeyHashForAlias]); | |
// Fetch logs for a session if selected | |
const sessionLogs = useQuery<PaginatedResponse>({ | |
queryKey: ["sessionLogs", selectedSessionId], | |
queryFn: async () => { | |
if (!accessToken || !selectedSessionId) return { data: [], total: 0, page: 1, page_size: 50, total_pages: 1 }; | |
const response = await sessionSpendLogsCall(accessToken, selectedSessionId); | |
// If the API returns an array, wrap it in the same shape as PaginatedResponse | |
return { | |
data: response.data || response || [], | |
total: (response.data || response || []).length, | |
page: 1, | |
page_size: 1000, | |
total_pages: 1, | |
}; | |
}, | |
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; | |
} | |
const filteredData = | |
filteredLogs.data.filter((log) => { | |
const matchesSearch = | |
!searchTerm || | |
log.request_id.includes(searchTerm) || | |
log.model.includes(searchTerm) || | |
(log.user && log.user.includes(searchTerm)); | |
// No need for additional filtering since we're now handling this in the API call | |
return matchesSearch; | |
}).map(log => ({ | |
...log, | |
onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash), | |
onSessionClick: (sessionId: string) => { | |
if (sessionId) setSelectedSessionId(sessionId); | |
}, | |
})) || []; | |
// For session logs, add onKeyHashClick/onSessionClick as well | |
const sessionData = | |
sessionLogs.data?.data?.map(log => ({ | |
...log, | |
onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash), | |
onSessionClick: (sessionId: string) => {}, | |
})) || []; | |
// Add this function to handle manual refresh | |
const handleRefresh = () => { | |
logs.refetch(); | |
}; | |
const handleRowExpand = (requestId: string | null) => { | |
setExpandedRequestId(requestId); | |
}; | |
const logFilterOptions: FilterOption[] = [ | |
{ | |
name: 'Team ID', | |
label: 'Team ID', | |
isSearchable: true, | |
searchFn: async (searchText: string) => { | |
if (!allTeams || allTeams.length === 0) return []; | |
const filtered = allTeams.filter((team: Team) =>{ | |
return team.team_id.toLowerCase().includes(searchText.toLowerCase()) || | |
(team.team_alias && team.team_alias.toLowerCase().includes(searchText.toLowerCase())) | |
}); | |
return filtered.map((team: Team) => ({ | |
label: `${team.team_alias || team.team_id} (${team.team_id})`, | |
value: team.team_id | |
})); | |
} | |
}, | |
{ | |
name:'Status', | |
label:'Status', | |
isSearchable: false, | |
options: [ | |
{ label: 'Success', value: 'success' }, | |
{ label: 'Failure', value: 'failure' } | |
] | |
}, | |
{ | |
name: 'Model', | |
label: 'Model', | |
isSearchable: false, | |
}, | |
{ | |
name: 'Key Alias', | |
label: 'Key Alias', | |
isSearchable: true, | |
searchFn: async (searchText: string) => { | |
if (!accessToken) return []; | |
const keyAliases = await fetchAllKeyAliases(accessToken); | |
const filtered = keyAliases.filter(alias => | |
alias.toLowerCase().includes(searchText.toLowerCase()) | |
); | |
return filtered.map(alias => ({ | |
label: alias, | |
value: alias | |
})); | |
} | |
}, | |
{ | |
name: 'Key Hash', | |
label: 'Key Hash', | |
isSearchable: false, | |
}, | |
] | |
// When a session is selected, render the SessionView component | |
if (selectedSessionId && sessionLogs.data) { | |
return ( | |
<div className="w-full p-6"> | |
<SessionView | |
sessionId={selectedSessionId} | |
logs={sessionLogs.data.data} | |
onBack={() => setSelectedSessionId(null)} | |
/> | |
</div> | |
); | |
} | |
return ( | |
<div className="w-full p-6"> | |
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "request logs" : "audit logs")}> | |
<TabList> | |
<Tab> | |
Request Logs | |
</Tab> | |
<Tab> | |
Audit Logs | |
</Tab> | |
</TabList> | |
<TabPanels> | |
<TabPanel> | |
<div className="flex items-center justify-between mb-4"> | |
<h1 className="text-xl font-semibold"> | |
{selectedSessionId ? ( | |
<> | |
Session: <span className="font-mono">{selectedSessionId}</span> | |
<button | |
className="ml-4 px-3 py-1 text-sm border rounded hover:bg-gray-50" | |
onClick={() => setSelectedSessionId(null)} | |
> | |
← Back to All Logs | |
</button> | |
</> | |
) : ( | |
"Request Logs" | |
)} | |
</h1> | |
</div> | |
{selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView ? ( | |
<KeyInfoView keyId={selectedKeyIdInfoView} keyData={selectedKeyInfo} accessToken={accessToken} userID={userID} userRole={userRole} teams={allTeams} onClose={() => setSelectedKeyIdInfoView(null)} premiumUser={premiumUser}/> | |
) : selectedSessionId ? ( | |
<div className="bg-white rounded-lg shadow"> | |
<DataTable | |
columns={columns} | |
data={sessionData} | |
renderSubComponent={RequestViewer} | |
getRowCanExpand={() => true} | |
// Optionally: add session-specific row expansion state | |
/> | |
</div> | |
) : ( | |
<> | |
<FilterComponent options={logFilterOptions} onApplyFilters={handleFilterChange} onResetFilters={handleFilterReset} /> | |
<div className="bg-white rounded-lg shadow"> | |
<div className="border-b px-6 py-4"> | |
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0"> | |
<div className="flex flex-wrap items-center gap-3"> | |
<div className="relative w-64"> | |
<input | |
type="text" | |
placeholder="Search by Request ID" | |
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" | |
value={searchTerm} | |
onChange={(e) => setSearchTerm(e.target.value)} | |
/> | |
<svg | |
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" | |
fill="none" | |
stroke="currentColor" | |
viewBox="0 0 24 24" | |
> | |
<path | |
strokeLinecap="round" | |
strokeLinejoin="round" | |
strokeWidth={2} | |
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" | |
/> | |
</svg> | |
</div> | |
<div className="flex items-center gap-2"> | |
<div className="relative" ref={quickSelectRef}> | |
<button | |
onClick={() => setQuickSelectOpen(!quickSelectOpen)} | |
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2" | |
> | |
<svg | |
className="w-4 h-4" | |
fill="none" | |
stroke="currentColor" | |
viewBox="0 0 24 24" | |
> | |
<path | |
strokeLinecap="round" | |
strokeLinejoin="round" | |
strokeWidth={2} | |
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" | |
/> | |
</svg> | |
{getTimeRangeDisplay(isCustomDate, startTime, endTime)} | |
</button> | |
{quickSelectOpen && ( | |
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border p-2 z-50"> | |
<div className="space-y-1"> | |
{[ | |
{ label: "Last 15 Minutes", value: 15, unit: "minutes" }, | |
{ label: "Last Hour", value: 1, unit: "hours" }, | |
{ label: "Last 4 Hours", value: 4, unit: "hours" }, | |
{ label: "Last 24 Hours", value: 24, unit: "hours" }, | |
{ label: "Last 7 Days", value: 7, unit: "days" }, | |
].map((option) => ( | |
<button | |
key={option.label} | |
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${ | |
getTimeRangeDisplay(isCustomDate, startTime, endTime) === option.label ? 'bg-blue-50 text-blue-600' : '' | |
}`} | |
onClick={() => { | |
setEndTime(moment().format("YYYY-MM-DDTHH:mm")); | |
setStartTime( | |
moment() | |
.subtract(option.value, option.unit as any) | |
.format("YYYY-MM-DDTHH:mm") | |
); | |
setQuickSelectOpen(false); | |
setIsCustomDate(false); | |
}} | |
> | |
{option.label} | |
</button> | |
))} | |
<div className="border-t my-2" /> | |
<button | |
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${ | |
isCustomDate ? 'bg-blue-50 text-blue-600' : '' | |
}`} | |
onClick={() => setIsCustomDate(!isCustomDate)} | |
> | |
Custom Range | |
</button> | |
</div> | |
</div> | |
)} | |
</div> | |
<button | |
onClick={handleRefresh} | |
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2" | |
title="Refresh data" | |
> | |
<svg | |
className={`w-4 h-4 ${logs.isFetching ? 'animate-spin' : ''}`} | |
fill="none" | |
stroke="currentColor" | |
viewBox="0 0 24 24" | |
> | |
<path | |
strokeLinecap="round" | |
strokeLinejoin="round" | |
strokeWidth={2} | |
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" | |
/> | |
</svg> | |
<span>Refresh</span> | |
</button> | |
</div> | |
{isCustomDate && ( | |
<div className="flex items-center gap-2"> | |
<div> | |
<input | |
type="datetime-local" | |
value={startTime} | |
onChange={(e) => { | |
setStartTime(e.target.value); | |
setCurrentPage(1); | |
}} | |
className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" | |
/> | |
</div> | |
<span className="text-gray-500">to</span> | |
<div> | |
<input | |
type="datetime-local" | |
value={endTime} | |
onChange={(e) => { | |
setEndTime(e.target.value); | |
setCurrentPage(1); | |
}} | |
className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" | |
/> | |
</div> | |
</div> | |
)} | |
</div> | |
<div className="flex items-center space-x-4"> | |
<span className="text-sm text-gray-700"> | |
Showing{" "} | |
{logs.isLoading | |
? "..." | |
: filteredLogs | |
? (currentPage - 1) * pageSize + 1 | |
: 0}{" "} | |
-{" "} | |
{logs.isLoading | |
? "..." | |
: filteredLogs | |
? Math.min(currentPage * pageSize, filteredLogs.total) | |
: 0}{" "} | |
of{" "} | |
{logs.isLoading | |
? "..." | |
: filteredLogs | |
? filteredLogs.total | |
: 0}{" "} | |
results | |
</span> | |
<div className="flex items-center space-x-2"> | |
<span className="text-sm text-gray-700"> | |
Page {logs.isLoading ? "..." : currentPage} of{" "} | |
{logs.isLoading | |
? "..." | |
: filteredLogs | |
? filteredLogs.total_pages | |
: 1} | |
</span> | |
<button | |
onClick={() => | |
setCurrentPage((p) => Math.max(1, p - 1)) | |
} | |
disabled={logs.isLoading || currentPage === 1} | |
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" | |
> | |
Previous | |
</button> | |
<button | |
onClick={() => | |
setCurrentPage((p) => | |
Math.min( | |
filteredLogs.total_pages || 1, | |
p + 1, | |
), | |
) | |
} | |
disabled={ | |
logs.isLoading || | |
currentPage === (filteredLogs.total_pages || 1) | |
} | |
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" | |
> | |
Next | |
</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
<DataTable | |
columns={columns} | |
data={filteredData} | |
renderSubComponent={RequestViewer} | |
getRowCanExpand={() => true} | |
/> | |
</div> | |
</> | |
)} | |
</TabPanel> | |
<TabPanel> | |
<AuditLogs | |
userID={userID} | |
userRole={userRole} | |
token={token} | |
accessToken={accessToken} | |
isActive={activeTab === "audit logs"} | |
premiumUser={premiumUser} | |
allTeams={allTeams} | |
/> | |
</TabPanel> | |
</TabPanels> | |
</TabGroup> | |
</div> | |
); | |
} | |
export function RequestViewer({ row }: { row: Row<LogEntry> }) { | |
// 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; | |
}; | |
// New helper function to get raw request | |
const getRawRequest = () => { | |
// First check if proxy_server_request exists in metadata | |
if (row.original?.proxy_server_request) { | |
return formatData(row.original.proxy_server_request); | |
} | |
// Fall back to messages if proxy_server_request is empty | |
return formatData(row.original.messages); | |
}; | |
// Extract error information from metadata if available | |
const metadata = row.original.metadata || {}; | |
const hasError = metadata.status === "failure"; | |
const errorInfo = hasError ? metadata.error_information : null; | |
// Check if request/response data is missing | |
const hasMessages = row.original.messages && | |
(Array.isArray(row.original.messages) ? row.original.messages.length > 0 : Object.keys(row.original.messages).length > 0); | |
const hasResponse = row.original.response && Object.keys(formatData(row.original.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(row.original.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 hasGuardrailData = row.original.metadata && row.original.metadata.guardrail_information; | |
// Calculate total masked entities if guardrail data exists | |
const getTotalMaskedEntities = (): number => { | |
if (!hasGuardrailData || !row.original.metadata?.guardrail_information.masked_entity_count) { | |
return 0; | |
} | |
return Object.values(row.original.metadata.guardrail_information.masked_entity_count) | |
.reduce((sum: number, count: any) => sum + (typeof count === 'number' ? count : 0), 0); | |
}; | |
const totalMaskedEntities = getTotalMaskedEntities(); | |
return ( | |
<div className="p-6 bg-gray-50 space-y-6"> | |
{/* Combined Info Card */} | |
<div className="bg-white rounded-lg shadow"> | |
<div className="p-4 border-b"> | |
<h3 className="text-lg font-medium">Request Details</h3> | |
</div> | |
<div className="grid grid-cols-2 gap-4 p-4"> | |
<div className="space-y-2"> | |
<div className="flex"> | |
<span className="font-medium w-1/3">Request ID:</span> | |
<span className="font-mono text-sm">{row.original.request_id}</span> | |
</div> | |
<div className="flex"> | |
<span className="font-medium w-1/3">Model:</span> | |
<span>{row.original.model}</span> | |
</div> | |
<div className="flex"> | |
<span className="font-medium w-1/3">Model ID:</span> | |
<span>{row.original.model_id}</span> | |
</div> | |
<div className="flex"> | |
<span className="font-medium w-1/3">Call Type:</span> | |
<span>{row.original.call_type}</span> | |
</div> | |
<div className="flex"> | |
<span className="font-medium w-1/3">Provider:</span> | |
<span>{row.original.custom_llm_provider || "-"}</span> | |
</div> | |
<div className="flex"> | |
<span className="font-medium w-1/3">API Base:</span> | |
<Tooltip title={row.original.api_base || "-"}> | |
<span className="max-w-[15ch] truncate block">{row.original.api_base || "-"}</span> | |
</Tooltip> | |
</div> | |
{row?.original?.requester_ip_address && ( | |
<div className="flex"> | |
<span className="font-medium w-1/3">IP Address:</span> | |
<span>{row?.original?.requester_ip_address}</span> | |
</div> | |
)} | |
{hasGuardrailData && ( | |
<div className="flex"> | |
<span className="font-medium w-1/3">Guardrail:</span> | |
<div> | |
<span className="font-mono">{row.original.metadata!.guardrail_information.guardrail_name}</span> | |
{totalMaskedEntities > 0 && ( | |
<span className="ml-2 px-2 py-0.5 bg-blue-50 text-blue-700 rounded-md text-xs font-medium"> | |
{totalMaskedEntities} masked | |
</span> | |
)} | |
</div> | |
</div> | |
)} | |
</div> | |
<div className="space-y-2"> | |
<div className="flex"> | |
<span className="font-medium w-1/3">Tokens:</span> | |
<span>{row.original.total_tokens} ({row.original.prompt_tokens}+{row.original.completion_tokens})</span> | |
</div> | |
<div className="flex"> | |
<span className="font-medium w-1/3">Cost:</span> | |
<span>${Number(row.original.spend || 0).toFixed(6)}</span> | |
</div> | |
<div className="flex"> | |
<span className="font-medium w-1/3">Cache Hit:</span> | |
<span>{row.original.cache_hit}</span> | |
</div> | |
<div className="flex"> | |
<span className="font-medium w-1/3">Status:</span> | |
<span className={`px-2 py-1 rounded-md text-xs font-medium inline-block text-center w-16 ${ | |
(row.original.metadata?.status || "Success").toLowerCase() !== "failure" | |
? 'bg-green-100 text-green-800' | |
: 'bg-red-100 text-red-800' | |
}`}> | |
{(row.original.metadata?.status || "Success").toLowerCase() !== "failure" ? "Success" : "Failure"} | |
</span> | |
</div> | |
<div className="flex"> | |
<span className="font-medium w-1/3">Start Time:</span> | |
<span>{row.original.startTime}</span> | |
</div> | |
<div className="flex"> | |
<span className="font-medium w-1/3">End Time:</span> | |
<span>{row.original.endTime}</span> | |
</div> | |
</div> | |
</div> | |
</div> | |
{/* Configuration Info Message - Show when data is missing */} | |
<ConfigInfoMessage show={missingData} /> | |
{/* Request/Response Panel */} | |
<RequestResponsePanel | |
row={row} | |
hasMessages={hasMessages} | |
hasResponse={hasResponse} | |
hasError={hasError} | |
errorInfo={errorInfo} | |
getRawRequest={getRawRequest} | |
formattedResponse={formattedResponse} | |
/> | |
{/* Guardrail Data - Show only if present */} | |
{hasGuardrailData && ( | |
<GuardrailViewer data={row.original.metadata!.guardrail_information} /> | |
)} | |
{/* Vector Store Request Data - Show only if present */} | |
{hasVectorStoreData && ( | |
<VectorStoreViewer data={metadata.vector_store_request_metadata} /> | |
)} | |
{/* Error Card - Only show for failures */} | |
{hasError && errorInfo && <ErrorViewer errorInfo={errorInfo} />} | |
{/* Tags Card - Only show if there are tags */} | |
{row.original.request_tags && Object.keys(row.original.request_tags).length > 0 && ( | |
<div className="bg-white rounded-lg shadow"> | |
<div className="flex justify-between items-center p-4 border-b"> | |
<h3 className="text-lg font-medium">Request Tags</h3> | |
</div> | |
<div className="p-4"> | |
<div className="flex flex-wrap gap-2"> | |
{Object.entries(row.original.request_tags).map(([key, value]) => ( | |
<span key={key} className="px-2 py-1 bg-gray-100 rounded-full text-xs"> | |
{key}: {String(value)} | |
</span> | |
))} | |
</div> | |
</div> | |
</div> | |
)} | |
{/* Metadata Card - Only show if there's metadata */} | |
{row.original.metadata && Object.keys(row.original.metadata).length > 0 && ( | |
<div className="bg-white rounded-lg shadow"> | |
<div className="flex justify-between items-center p-4 border-b"> | |
<h3 className="text-lg font-medium">Metadata</h3> | |
<button | |
onClick={() => { | |
navigator.clipboard.writeText(JSON.stringify(row.original.metadata, null, 2)); | |
}} | |
className="p-1 hover:bg-gray-200 rounded" | |
title="Copy metadata" | |
> | |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> | |
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> | |
</svg> | |
</button> | |
</div> | |
<div className="p-4 overflow-auto max-h-64"> | |
<pre className="text-xs font-mono whitespace-pre-wrap break-all"> | |
{JSON.stringify(row.original.metadata, null, 2)} | |
</pre> | |
</div> | |
</div> | |
)} | |
</div> | |
); | |
} |