Litellm audit log staging (#11418)

* Audit logs added (#11226)

* audit logs added

* audit logs populated

* adding json response

* collapsible json columns

* add created at column

* added changed field

* added premiumUser description

* added paginated filtered logs

* convert table names

* remove test file

* added new ui for audit logs

* only show the difference in before value and updated value

* fix: add lucide-react to package json

---------

Co-authored-by: tanjiro <56165694+NANDINI-star@users.noreply.github.com>
This commit is contained in:
Krish Dholakia
2025-06-04 14:34:17 -07:00
committed by GitHub
parent a24077fb4e
commit 9da32d9e14
8 changed files with 1116 additions and 283 deletions
+9 -16
View File
@@ -22,6 +22,7 @@
"fs": "^0.0.1-security",
"jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.513.0",
"moment": "^2.30.1",
"next": "^14.2.26",
"openai": "^4.93.0",
@@ -557,7 +558,6 @@
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -4352,6 +4352,14 @@
"node": "14 || >=16.14"
}
},
"node_modules/lucide-react": {
"version": "0.513.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.513.0.tgz",
"integrity": "sha512-CJZKq2g8Y8yN4Aq002GahSXbG2JpFv9kXwyiOAMvUBv7pxeOFHUWKB0mO7MiY4ZVFCV4aNjv2BJFq/z3DgKPQg==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz",
@@ -7993,21 +8001,6 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.26",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.26.tgz",
"integrity": "sha512-GQWg/Vbz9zUGi9X80lOeGsz1rMH/MtFO/XqigDznhhhTfDlDoynCM6982mPCbSlxJ/aveZcKtTlwfAjwhyxDpg==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+1
View File
@@ -23,6 +23,7 @@
"fs": "^0.0.1-security",
"jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.513.0",
"moment": "^2.30.1",
"next": "^14.2.26",
"openai": "^4.93.0",
+2 -1
View File
@@ -405,7 +405,8 @@ export default function CreateKeyPage() {
userRole={userRole}
token={token}
accessToken={accessToken}
allTeams={(teams as Team[]) ?? []}
allTeams={teams as Team[] ?? []}
premiumUser={premiumUser}
/>
) : page == "mcp-servers" ? (
<MCPServers
@@ -5356,3 +5356,49 @@ export const updateGuardrailCall = async (
throw error;
}
};
export const uiAuditLogsCall = async (
accessToken: String,
start_date?: string,
end_date?: string,
page?: number,
page_size?: number,
) => {
try {
// Construct base URL
let url = proxyBaseUrl ? `${proxyBaseUrl}/audit` : `/audit`;
// Add query parameters if they exist
const queryParams = new URLSearchParams();
// if (start_date) queryParams.append('start_date', start_date);
// if (end_date) queryParams.append('end_date', end_date);
if (page) queryParams.append('page', page.toString());
if (page_size) queryParams.append('page_size', page_size.toString());
// Append query parameters to URL if any exist
const queryString = queryParams.toString();
if (queryString) {
url += `?${queryString}`;
}
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch spend logs:", error);
throw error;
}
};
@@ -0,0 +1,568 @@
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]);
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>
<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>
</>
);
}
@@ -1,12 +1,10 @@
import type { ColumnDef } from "@tanstack/react-table";
import { getCountryFromIP } from "./ip_lookup";
import moment from "moment";
import React from "react";
import { CountryCell } from "./country_cell";
import React, { useState } from "react";
import { getProviderLogoAndName } from "../provider_info_helpers";
import { Tooltip } from "antd";
import { TimeCell } from "./time_cell";
import { Button } from "@tremor/react";
import { Button, Badge } from "@tremor/react";
import { Eye, EyeOff} from "lucide-react"
// Helper to get the appropriate logo URL
const getLogoUrl = (
@@ -361,3 +359,189 @@ export const RequestResponsePanel = ({ request, response }: { request: any; resp
</div>
);
};
// New component for collapsible JSON display
const CollapsibleJsonCell = ({ jsonData }: { jsonData: any }) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const jsonString = JSON.stringify(jsonData, null, 2);
if (!jsonData || Object.keys(jsonData).length === 0) {
return <span>-</span>;
}
return (
<div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-blue-500 hover:text-blue-700 text-xs"
>
{isExpanded ? 'Hide JSON' : 'Show JSON'} ({Object.keys(jsonData).length} fields)
</button>
{isExpanded && (
<pre className="mt-2 p-2 bg-gray-50 border rounded text-xs overflow-auto max-h-60">
{jsonString}
</pre>
)}
</div>
);
};
export type AuditLogEntry = {
id: string;
updated_at: string;
changed_by: string;
changed_by_api_key: string;
action: string;
table_name: string;
object_id: string;
before_value: Record<string, any>;
updated_values: Record<string, any>;
}
const getActionBadge = (action: string) => {
const actionColors = {
created: "blue",
updated: "slate",
deleted: "red",
rotated: "amber",
} as const;
return (
<Badge
color={actionColors[action as keyof typeof actionColors] || "gray"}
className="flex items-center gap-1"
>
<span className="whitespace-nowrap text-xs">{action}</span>
</Badge>
)
}
export const auditLogColumns: ColumnDef<AuditLogEntry>[] = [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
const ExpanderCell = () => {
const [localExpanded, setLocalExpanded] = React.useState(row.getIsExpanded());
const toggleHandler = React.useCallback(() => {
setLocalExpanded((prev) => !prev);
row.getToggleExpandedHandler()();
}, [row]);
return row.getCanExpand() ? (
<button
onClick={toggleHandler}
style={{ cursor: "pointer" }}
aria-label={localExpanded ? "Collapse row" : "Expand row"}
className="w-6 h-6 flex items-center justify-center focus:outline-none"
>
{localExpanded ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
) : (
<span className="w-6 h-6 flex items-center justify-center text-gray-300"></span>
);
};
return <ExpanderCell />;
},
},
{
header: "Created At",
accessorKey: "before_value.created_at",
cell: (info: any) => {
const createdAt = info.row.original.before_value?.created_at;
return createdAt ? <TimeCell utcTime={createdAt} /> : "-";
},
},
{
header: "Updated At",
accessorKey: "updated_at",
cell: (info: any) => <TimeCell utcTime={info.getValue()} />,
},
{
header: "Action",
accessorKey: "action",
cell: (info: any) => <span>{getActionBadge(info.getValue())}</span>,
},
{
header: "Changed By",
accessorKey: "changed_by",
cell: (info: any) => {
const changedBy = info.row.original.changed_by;
const apiKey = info.row.original.changed_by_api_key;
return (
<div className="space-y-1">
<div className="font-medium">{changedBy}</div>
{apiKey && ( // Only show API key if it exists
<Tooltip title={apiKey}>
<div className="text-xs text-muted-foreground max-w-[15ch] truncate"> {/* Apply max-width and truncate */}
{apiKey}
</div>
</Tooltip>
)}
</div>
);
},
},
{
header: "Table Name",
accessorKey: "table_name",
cell: (info: any) => {
const tableName = info.getValue();
let displayValue = tableName;
switch (tableName) {
case "LiteLLM_VerificationToken":
displayValue = "Keys";
break;
case "LiteLLM_TeamTable":
displayValue = "Teams";
break;
case "LiteLLM_OrganizationTable":
displayValue = "Organizations";
break;
case "LiteLLM_UserTable":
displayValue = "Users";
break;
default:
displayValue = tableName;
}
return <span>{displayValue}</span>;
},
},
{
header: "Affected Item ID",
accessorKey: "object_id",
cell: (props) => {
const ObjectIdDisplay = () => {
const objectId = props.getValue();
const [copied, setCopied] = useState(false);
if (!objectId) return <>-</>;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(String(objectId));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (err) {
console.error("Failed to copy object ID: ", err);
}
};
return (
<Tooltip title={copied ? "Copied!" : String(objectId)}>
<span
className="max-w-[20ch] truncate block cursor-pointer hover:text-blue-600"
onClick={handleCopy}
>
{String(objectId)}
</span>
</Tooltip>
);
};
return <ObjectIdDisplay />;
},
},
]
@@ -22,6 +22,16 @@ 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;
@@ -29,6 +39,7 @@ interface SpendLogsTableProps {
userRole: string | null;
userID: string | null;
allTeams: Team[];
premiumUser: boolean;
}
export interface PaginatedResponse {
@@ -50,6 +61,7 @@ export default function SpendLogsTable({
userRole,
userID,
allTeams,
premiumUser,
}: SpendLogsTableProps) {
const [searchTerm, setSearchTerm] = useState("");
const [showFilters, setShowFilters] = useState(false);
@@ -82,6 +94,7 @@ export default function SpendLogsTable({
const [filterByCurrentUser, setFilterByCurrentUser] = useState(
userRole && internalUserRoles.includes(userRole)
);
const [activeTab, setActiveTab] = useState("request logs");
const [expandedRequestId, setExpandedRequestId] = useState<string | null>(null);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
@@ -208,7 +221,7 @@ export default function SpendLogsTable({
return response;
},
enabled: !!accessToken && !!token && !!userRole && !!userID,
enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs",
refetchInterval: 5000,
refetchIntervalInBackground: true,
});
@@ -354,25 +367,7 @@ export default function SpendLogsTable({
logs.refetch();
};
// Add this function to format the time range display
const getTimeRangeDisplay = () => {
if (isCustomDate) {
return `${moment(startTime).format('MMM D, h:mm A')} - ${moment(endTime).format('MMM D, h:mm A')}`;
}
const now = moment();
const start = moment(startTime);
const diffMinutes = now.diff(start, 'minutes');
if (diffMinutes <= 15) return 'Last 15 Minutes';
if (diffMinutes <= 60) return 'Last Hour';
const diffHours = now.diff(start, 'hours');
if (diffHours <= 4) return 'Last 4 Hours';
if (diffHours <= 24) return 'Last 24 Hours';
if (diffHours <= 168) return 'Last 7 Days';
return `${start.format('MMM D')} - ${now.format('MMM D')}`;
};
const handleRowExpand = (requestId: string | null) => {
setExpandedRequestId(requestId);
@@ -447,252 +442,276 @@ export default function SpendLogsTable({
return (
<div className="w-full p-6">
<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)} />
) : 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}>
<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
onClick={() => setQuickSelectOpen(!quickSelectOpen)}
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
className="ml-4 px-3 py-1 text-sm border rounded hover:bg-gray-50"
onClick={() => setSelectedSessionId(null)}
>
<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()}
Back to All Logs
</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() === 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>
</>
) : (
"Request Logs"
)}
</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>
</h1>
</div>
</div>
<DataTable
columns={columns}
data={filteredData}
renderSubComponent={RequestViewer}
getRowCanExpand={() => true}
/>
</div>
</>
)}
{selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView ? (
<KeyInfoView keyId={selectedKeyIdInfoView} keyData={selectedKeyInfo} accessToken={accessToken} userID={userID} userRole={userRole} teams={allTeams} onClose={() => setSelectedKeyIdInfoView(null)} />
) : 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>
);
}
@@ -0,0 +1,21 @@
import moment from "moment";
// Add this function to format the time range display
export const getTimeRangeDisplay = (isCustomDate: boolean, startTime: string, endTime: string) => {
if (isCustomDate) {
return `${moment(startTime).format('MMM D, h:mm A')} - ${moment(endTime).format('MMM D, h:mm A')}`;
}
const now = moment();
const start = moment(startTime);
const diffMinutes = now.diff(start, 'minutes');
if (diffMinutes <= 15) return 'Last 15 Minutes';
if (diffMinutes <= 60) return 'Last Hour';
const diffHours = now.diff(start, 'hours');
if (diffHours <= 4) return 'Last 4 Hours';
if (diffHours <= 24) return 'Last 24 Hours';
if (diffHours <= 168) return 'Last 7 Days';
return `${start.format('MMM D')} - ${now.format('MMM D')}`;
};