Spaces:
Configuration error
Configuration error
import { DataTable } from "./table"; | |
import moment from "moment"; | |
import { useRef, useState, useEffect, useCallback, useMemo } from "react"; | |
import { useQuery } from "@tanstack/react-query"; | |
import { uiAuditLogsCall, keyListCall } from "../networking"; | |
import { AuditLogEntry, auditLogColumns } from "./columns"; | |
import { Text } from "@tremor/react"; | |
import { Team } from "../key_team_helpers/key_list"; | |
interface AuditLogsProps { | |
accessToken: string | null; | |
token: string | null; | |
userRole: string | null; | |
userID: string | null; | |
isActive: boolean; | |
premiumUser: boolean; | |
allTeams: Team[]; | |
} | |
export default function AuditLogs({ | |
userID, | |
userRole, | |
token, | |
accessToken, | |
isActive, | |
premiumUser, | |
allTeams, | |
}: AuditLogsProps) { | |
const [startTime, setStartTime] = useState<string>( | |
moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm") | |
); | |
const actionFilterRef = useRef<HTMLDivElement>(null); | |
const tableFilterRef = useRef<HTMLDivElement>(null); | |
const [clientCurrentPage, setClientCurrentPage] = useState(1); | |
const [pageSize] = useState(50); | |
const [filters, setFilters] = useState<Record<string, string>>({}); | |
const [selectedTeamId, setSelectedTeamId] = useState(""); | |
const [selectedKeyHash, setSelectedKeyHash] = useState(""); | |
const [objectIdSearch, setObjectIdSearch] = useState(""); | |
const [selectedActionFilter, setSelectedActionFilter] = useState("all"); | |
const [selectedTableFilter, setSelectedTableFilter] = useState("all"); | |
const [actionFilterOpen, setActionFilterOpen] = useState(false); | |
const [tableFilterOpen, setTableFilterOpen] = useState(false); | |
const allLogsQuery = useQuery<AuditLogEntry[]>({ | |
queryKey: [ | |
"all_audit_logs", | |
accessToken, | |
token, | |
userRole, | |
userID, | |
startTime, | |
], | |
queryFn: async () => { | |
if (!accessToken || !token || !userRole || !userID) { | |
return []; | |
} | |
const formattedStartTimeStr = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); | |
const formattedEndTimeStr = moment().utc().format("YYYY-MM-DD HH:mm:ss"); | |
let accumulatedLogs: AuditLogEntry[] = []; | |
let currentPageToFetch = 1; | |
let totalPagesFromBackend = 1; | |
const backendPageSize = 50; | |
do { | |
const response = await uiAuditLogsCall( | |
accessToken, | |
formattedStartTimeStr, | |
formattedEndTimeStr, | |
currentPageToFetch, | |
backendPageSize | |
); | |
accumulatedLogs = accumulatedLogs.concat(response.audit_logs); | |
totalPagesFromBackend = response.total_pages; | |
currentPageToFetch++; | |
} while (currentPageToFetch <= totalPagesFromBackend); | |
return accumulatedLogs; | |
}, | |
enabled: !!accessToken && !!token && !!userRole && !!userID && isActive, | |
refetchInterval: 5000, | |
refetchIntervalInBackground: true, | |
}); | |
const handleRefresh = () => { | |
allLogsQuery.refetch(); | |
}; | |
const handleFilterChange = (newFilters: Record<string, string>) => { | |
setFilters(newFilters); | |
}; | |
const handleFilterReset = () => { | |
setFilters({}); | |
setSelectedTeamId(""); | |
setSelectedKeyHash(""); | |
setObjectIdSearch(""); | |
setSelectedActionFilter("all"); | |
setSelectedTableFilter("all"); | |
setClientCurrentPage(1); | |
}; | |
const fetchKeyHashForAlias = useCallback(async (keyAlias: string) => { | |
if (!accessToken) return; | |
try { | |
const response = await keyListCall( | |
accessToken, | |
null, | |
null, | |
keyAlias, | |
null, | |
null, | |
1, | |
10 | |
); | |
const selectedKey = response.keys.find( | |
(key: any) => key.key_alias === keyAlias | |
); | |
if (selectedKey) { | |
setSelectedKeyHash(selectedKey.token); | |
} else { | |
setSelectedKeyHash(""); | |
} | |
} catch (error) { | |
console.error("Error fetching key hash for alias:", error); | |
setSelectedKeyHash(""); | |
} | |
}, [accessToken]); | |
useEffect(() => { | |
if(!accessToken) return; | |
let teamIdChanged = false; | |
let keyHashChanged = false; | |
if (filters['Team ID']) { | |
if (selectedTeamId !== filters['Team ID']) { | |
setSelectedTeamId(filters['Team ID']); | |
teamIdChanged = true; | |
} | |
} else { | |
if (selectedTeamId !== "") { | |
setSelectedTeamId(""); | |
teamIdChanged = true; | |
} | |
} | |
if (filters['Key Hash']) { | |
if (selectedKeyHash !== filters['Key Hash']) { | |
setSelectedKeyHash(filters['Key Hash']); | |
keyHashChanged = true; | |
} | |
} else if (filters['Key Alias']) { | |
fetchKeyHashForAlias(filters['Key Alias']); | |
} else { | |
if (selectedKeyHash !== "") { | |
setSelectedKeyHash(""); | |
keyHashChanged = true; | |
} | |
} | |
if (teamIdChanged || keyHashChanged) { | |
setClientCurrentPage(1); | |
} | |
}, [filters, accessToken, fetchKeyHashForAlias, selectedTeamId, selectedKeyHash]); | |
useEffect(() => { | |
setClientCurrentPage(1); | |
}, [selectedTeamId, selectedKeyHash, startTime, objectIdSearch, selectedActionFilter, selectedTableFilter]); | |
useEffect(() => { | |
function handleClickOutside(event: MouseEvent) { | |
if ( | |
actionFilterRef.current && | |
!actionFilterRef.current.contains(event.target as Node) | |
) { | |
setActionFilterOpen(false); | |
} | |
if ( | |
tableFilterRef.current && | |
!tableFilterRef.current.contains(event.target as Node) | |
) { | |
setTableFilterOpen(false); | |
} | |
} | |
document.addEventListener("mousedown", handleClickOutside); | |
return () => | |
document.removeEventListener("mousedown", handleClickOutside); | |
}, []); | |
const completeFilteredLogs = useMemo(() => { | |
if (!allLogsQuery.data) return []; | |
return allLogsQuery.data.filter(log => { | |
let matchesTeam = true; | |
let matchesKey = true; | |
let matchesObjectId = true; | |
let matchesAction = true; | |
let matchesTable = true; | |
if (selectedTeamId) { | |
const beforeTeamId = typeof log.before_value === 'string' ? JSON.parse(log.before_value)?.team_id : log.before_value?.team_id; | |
const updatedTeamId = typeof log.updated_values === 'string' ? JSON.parse(log.updated_values)?.team_id : log.updated_values?.team_id; | |
matchesTeam = beforeTeamId === selectedTeamId || updatedTeamId === selectedTeamId; | |
} | |
if (selectedKeyHash) { | |
try { | |
const beforeBody = typeof log.before_value === 'string' ? JSON.parse(log.before_value) : log.before_value; | |
const updatedBody = typeof log.updated_values === 'string' ? JSON.parse(log.updated_values) : log.updated_values; | |
const beforeKey = beforeBody?.token; | |
const updatedKey = updatedBody?.token; | |
matchesKey = (typeof beforeKey === 'string' && beforeKey.includes(selectedKeyHash)) || | |
(typeof updatedKey === 'string' && updatedKey.includes(selectedKeyHash)); | |
} catch (e) { | |
matchesKey = false; | |
} | |
} | |
if (objectIdSearch) { | |
matchesObjectId = log.object_id?.toLowerCase().includes(objectIdSearch.toLowerCase()); | |
} | |
if (selectedActionFilter !== "all") { | |
matchesAction = log.action?.toLowerCase() === selectedActionFilter.toLowerCase(); | |
} | |
if (selectedTableFilter !== "all") { | |
let tableMatchName = ""; | |
switch(selectedTableFilter) { | |
case "keys": tableMatchName = "litellm_verificationtoken"; break; | |
case "teams": tableMatchName = "litellm_teamtable"; break; | |
case "users": tableMatchName = "litellm_usertable"; break; | |
// Add other direct table names if needed, or rely on a more generic match | |
default: tableMatchName = selectedTableFilter; // Should not happen with current UI options | |
} | |
matchesTable = log.table_name?.toLowerCase() === tableMatchName; | |
} | |
return matchesTeam && matchesKey && matchesObjectId && matchesAction && matchesTable; | |
}); | |
}, [allLogsQuery.data, selectedTeamId, selectedKeyHash, objectIdSearch, selectedActionFilter, selectedTableFilter]); | |
const totalFilteredItems = completeFilteredLogs.length; | |
const totalFilteredPages = Math.ceil(totalFilteredItems / pageSize) || 1; | |
const paginatedViewOfFilteredLogs = useMemo(() => { | |
const start = (clientCurrentPage - 1) * pageSize; | |
const end = start + pageSize; | |
return completeFilteredLogs.slice(start, end); | |
}, [completeFilteredLogs, clientCurrentPage, pageSize]); | |
// Check if audit logs are empty (not loading and no data) | |
const showAuditLogsInfo = (!allLogsQuery.data || allLogsQuery.data.length === 0); | |
// Custom AuditLogsInfoMessage component | |
const AuditLogsInfoMessage = ({ show }: { show: boolean }) => { | |
if (!show) return null; | |
return ( | |
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start mb-6"> | |
<div className="text-blue-500 mr-3 flex-shrink-0 mt-0.5"> | |
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
<circle cx="12" cy="12" r="10"></circle> | |
<line x1="12" y1="16" x2="12" y2="12"></line> | |
<line x1="12" y1="8" x2="12.01" y2="8"></line> | |
</svg> | |
</div> | |
<div> | |
<h4 className="text-sm font-medium text-blue-800">Audit Logs Not Available</h4> | |
<p className="text-sm text-blue-700 mt-1"> | |
To enable audit logging, add the following configuration to your LiteLLM proxy configuration file: | |
</p> | |
<pre className="mt-2 bg-white p-3 rounded border border-blue-200 text-xs font-mono overflow-auto"> | |
{`litellm_settings: | |
store_audit_logs: true`} | |
</pre> | |
<p className="text-xs text-blue-700 mt-2"> | |
Note: This will only affect new requests after the configuration change and proxy restart. | |
</p> | |
</div> | |
</div> | |
); | |
}; | |
const renderSubComponent = useCallback(({ row }: { row: any }) => { | |
const AuditLogRowExpansionPanel = ({ rowData }: { rowData: AuditLogEntry }) => { | |
const { before_value, updated_values, table_name, action } = rowData; | |
const renderValue = (value: Record<string, any>, isKeyTable: boolean) => { | |
if (!value || Object.keys(value).length === 0) return <Text>N/A</Text>; | |
if (isKeyTable) { | |
const changedKeys = Object.keys(value); | |
const knownKeyFields = ['token', 'spend', 'max_budget']; | |
const onlyKnownFieldsChanged = changedKeys.every(key => knownKeyFields.includes(key)); | |
if (onlyKnownFieldsChanged && changedKeys.length > 0) { | |
return ( | |
<div> | |
{changedKeys.includes('token') && <p><strong>Token:</strong> {value.token || 'N/A'}</p>} | |
{changedKeys.includes('spend') && <p><strong>Spend:</strong> {value.spend !== undefined ? `$${Number(value.spend).toFixed(6)}` : 'N/A'}</p>} | |
{changedKeys.includes('max_budget') && <p><strong>Max Budget:</strong> {value.max_budget !== undefined ? `$${Number(value.max_budget).toFixed(6)}` : 'N/A'}</p>} | |
</div> | |
); | |
} else { | |
if (value["No differing fields detected in 'before' state"] || value["No differing fields detected in 'updated' state"] || value["No fields changed"]) { | |
return <Text>{value[Object.keys(value)[0]]}</Text> // Display the N/A message string | |
} | |
return <pre className="p-2 bg-gray-50 border rounded text-xs overflow-auto max-h-60">{JSON.stringify(value, null, 2)}</pre>; | |
} | |
} | |
return <pre className="p-2 bg-gray-50 border rounded text-xs overflow-auto max-h-60">{JSON.stringify(value, null, 2)}</pre>; | |
}; | |
let displayBeforeValue = before_value; | |
let displayUpdatedValue = updated_values; | |
if ((action === "updated" || action === "rotated") && before_value && updated_values) { | |
if (table_name === "LiteLLM_TeamTable" || table_name === "LiteLLM_UserTable" || table_name === "LiteLLM_VerificationToken") { | |
const changedBefore: Record<string, any> = {}; | |
const changedUpdated: Record<string, any> = {}; | |
const allKeys = new Set([...Object.keys(before_value), ...Object.keys(updated_values)]); | |
allKeys.forEach(key => { | |
const beforeValStr = JSON.stringify(before_value[key]); | |
const updatedValStr = JSON.stringify(updated_values[key]); | |
if (beforeValStr !== updatedValStr) { | |
if (before_value.hasOwnProperty(key)) { | |
changedBefore[key] = before_value[key]; | |
} | |
if (updated_values.hasOwnProperty(key)) { | |
changedUpdated[key] = updated_values[key]; | |
} | |
} | |
}); | |
Object.keys(before_value).forEach(key => { | |
if (!updated_values.hasOwnProperty(key) && !changedBefore.hasOwnProperty(key)) { | |
changedBefore[key] = before_value[key]; | |
changedUpdated[key] = undefined; | |
} | |
}); | |
Object.keys(updated_values).forEach(key => { | |
if (!before_value.hasOwnProperty(key) && !changedUpdated.hasOwnProperty(key)) { | |
changedUpdated[key] = updated_values[key]; | |
changedBefore[key] = undefined; | |
} | |
}); | |
displayBeforeValue = Object.keys(changedBefore).length > 0 ? changedBefore : { "No differing fields detected in 'before' state": "N/A" }; | |
displayUpdatedValue = Object.keys(changedUpdated).length > 0 ? changedUpdated : { "No differing fields detected in 'updated' state": "N/A" }; | |
if (Object.keys(changedBefore).length === 0 && Object.keys(changedUpdated).length === 0) { | |
displayBeforeValue = {"No fields changed": "N/A"}; | |
displayUpdatedValue = {"No fields changed": "N/A"}; | |
} | |
} | |
} | |
return ( | |
<div className="-mx-4 p-4 bg-slate-100 border-y border-slate-300 grid grid-cols-1 md:grid-cols-2 gap-4"> | |
<div> | |
<h4 className="font-semibold mb-2 text-sm text-slate-700">Before Value:</h4> | |
{renderValue(displayBeforeValue, table_name === "LiteLLM_VerificationToken")} | |
</div> | |
<div> | |
<h4 className="font-semibold mb-2 text-sm text-slate-700">Updated Value:</h4> | |
{renderValue(displayUpdatedValue, table_name === "LiteLLM_VerificationToken")} | |
</div> | |
</div> | |
); | |
}; | |
return <AuditLogRowExpansionPanel rowData={row.original as AuditLogEntry} />; | |
}, []); | |
if (!premiumUser) { | |
return ( | |
<div> | |
<Text>This is a LiteLLM Enterprise feature, and requires a valid key to use. Get a trial key <a href="https://litellm.ai/pricing" target="_blank" rel="noopener noreferrer">here</a>.</Text> | |
</div> | |
); | |
} | |
const currentDisplayItemsStart = totalFilteredItems > 0 ? (clientCurrentPage - 1) * pageSize + 1 : 0; | |
const currentDisplayItemsEnd = Math.min(clientCurrentPage * pageSize, totalFilteredItems); | |
return ( | |
<> | |
<div className="flex items-center justify-between mb-4"> | |
</div> | |
{/* <FilterComponent options={auditLogFilterOptions} onApplyFilters={handleFilterChange} onResetFilters={handleFilterReset} /> */} | |
<div className="bg-white rounded-lg shadow"> | |
<div className="border-b px-6 py-4"> | |
<h1 className="text-xl font-semibold py-4"> | |
Audit Logs | |
</h1> | |
{/* Show Audit Logs Info Message when no data */} | |
<AuditLogsInfoMessage show={showAuditLogsInfo} /> | |
<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="flex items-center gap-2"> | |
<div className="flex items-center"> | |
<input | |
type="text" | |
placeholder="Search by Object ID..." | |
value={objectIdSearch} | |
onChange={(e) => setObjectIdSearch(e.target.value)} | |
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> | |
<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 ${allLogsQuery.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> | |
</div> | |
<div className="flex items-center space-x-4"> | |
{/* Custom Action Filter Dropdown */} | |
<div className="relative" ref={actionFilterRef}> | |
<label htmlFor="actionFilterDisplay" className="mr-2 text-sm font-medium text-gray-700 sr-only">Action:</label> | |
<button | |
id="actionFilterDisplay" | |
onClick={() => setActionFilterOpen(!actionFilterOpen)} | |
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 bg-white w-40 text-left justify-between" | |
> | |
<span> | |
{selectedActionFilter === "all" && "All Actions"} | |
{selectedActionFilter === "created" && "Created"} | |
{selectedActionFilter === "updated" && "Updated"} | |
{selectedActionFilter === "deleted" && "Deleted"} | |
{selectedActionFilter === "rotated" && "Rotated"} | |
</span> | |
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path></svg> | |
</button> | |
{actionFilterOpen && ( | |
<div className="absolute left-0 mt-2 w-40 bg-white rounded-lg shadow-lg border p-1 z-50"> | |
<div className="space-y-1"> | |
{[ | |
{ label: "All Actions", value: "all" }, | |
{ label: "Created", value: "created" }, | |
{ label: "Updated", value: "updated" }, | |
{ label: "Deleted", value: "deleted" }, | |
{ label: "Rotated", value: "rotated" }, | |
].map((option) => ( | |
<button | |
key={option.value} | |
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${ | |
selectedActionFilter === option.value ? 'bg-blue-50 text-blue-600 font-medium' : 'font-normal' | |
}`} | |
onClick={() => { | |
setSelectedActionFilter(option.value); | |
setActionFilterOpen(false); | |
}} | |
> | |
{option.label} | |
</button> | |
))} | |
</div> | |
</div> | |
)} | |
</div> | |
{/* Custom Table Filter Dropdown */} | |
<div className="relative" ref={tableFilterRef}> | |
<label htmlFor="tableFilterDisplay" className="mr-2 text-sm font-medium text-gray-700 sr-only">Table:</label> | |
<button | |
id="tableFilterDisplay" | |
onClick={() => setTableFilterOpen(!tableFilterOpen)} | |
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 bg-white w-40 text-left justify-between" | |
> | |
<span> | |
{selectedTableFilter === "all" && "All Tables"} | |
{selectedTableFilter === "keys" && "Keys"} | |
{selectedTableFilter === "teams" && "Teams"} | |
{selectedTableFilter === "users" && "Users"} | |
</span> | |
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path></svg> | |
</button> | |
{tableFilterOpen && ( | |
<div className="absolute left-0 mt-2 w-40 bg-white rounded-lg shadow-lg border p-1 z-50"> | |
<div className="space-y-1"> | |
{[ | |
{ label: "All Tables", value: "all" }, | |
{ label: "Keys", value: "keys" }, | |
{ label: "Teams", value: "teams" }, | |
{ label: "Users", value: "users" }, | |
].map((option) => ( | |
<button | |
key={option.value} | |
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${ | |
selectedTableFilter === option.value ? 'bg-blue-50 text-blue-600 font-medium' : 'font-normal' | |
}`} | |
onClick={() => { | |
setSelectedTableFilter(option.value); | |
setTableFilterOpen(false); | |
}} | |
> | |
{option.label} | |
</button> | |
))} | |
</div> | |
</div> | |
)} | |
</div> | |
<span className="text-sm text-gray-700"> | |
Showing{" "} | |
{allLogsQuery.isLoading | |
? "..." | |
: currentDisplayItemsStart}{" "} | |
-{" "} | |
{allLogsQuery.isLoading | |
? "..." | |
: currentDisplayItemsEnd}{" "} | |
of{" "} | |
{allLogsQuery.isLoading | |
? "..." | |
: totalFilteredItems}{" "} | |
results | |
</span> | |
<div className="flex items-center space-x-2"> | |
<span className="text-sm text-gray-700"> | |
Page {allLogsQuery.isLoading ? "..." : clientCurrentPage} of{" "} | |
{allLogsQuery.isLoading | |
? "..." | |
: totalFilteredPages} | |
</span> | |
<button | |
onClick={() => | |
setClientCurrentPage((p) => Math.max(1, p - 1)) | |
} | |
disabled={allLogsQuery.isLoading || clientCurrentPage === 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={() => | |
setClientCurrentPage((p) => | |
Math.min( | |
totalFilteredPages, | |
p + 1, | |
), | |
) | |
} | |
disabled={ | |
allLogsQuery.isLoading || | |
clientCurrentPage === totalFilteredPages | |
} | |
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={auditLogColumns} | |
data={paginatedViewOfFilteredLogs} | |
renderSubComponent={renderSubComponent} | |
getRowCanExpand={() => true} | |
/> | |
</div> | |
</> | |
); | |
} | |