From 705ef64ffcb20d9ee5cb9557e8f66e713b1b0167 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 28 Feb 2026 18:19:07 -0800 Subject: [PATCH 1/4] fix(ui): Audit logs - server-side pagination, filtering, and drawer view - Replace client-side full-fetch loop with single react-query call using keepPreviousData; remove 5-second polling - All filters (object ID, action, table, changed_by, team ID, key hash) now passed as query params to the backend - Add object_team_id and object_key_hash params to /audit endpoint using Prisma JSON path filtering (PostgreSQL) to search inside before_value and updated_values JSON columns - Migrate table from custom TanStack DataTable to AntD Table with server-side pagination - Replace inline row expansion with a right-side AntD Drawer showing metadata and before/after diff - Refactor uiAuditLogsCall to accept a structured options object Co-Authored-By: Claude Sonnet 4.6 --- .../proxy/audit_logging_endpoints.py | 51 +- .../src/components/networking.tsx | 53 +- .../AuditLogDrawer/AuditLogDrawer.tsx | 217 +++++ .../src/components/view_logs/audit_logs.tsx | 853 ++++++------------ 4 files changed, 571 insertions(+), 603 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx diff --git a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py index d1b00420d3..5ab3669b50 100644 --- a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py @@ -1,13 +1,13 @@ """ AUDIT LOGGING -All /audit logging endpoints. Attempting to write these as CRUD endpoints. +All /audit logging endpoints. Attempting to write these as CRUD endpoints. GET - /audit/{id} - Get audit log by id GET - /audit - Get all audit logs """ -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional #### AUDIT LOGGING #### from fastapi import APIRouter, Depends, HTTPException, Query @@ -22,6 +22,21 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth router = APIRouter() +def _build_json_field_conditions( + field: str, json_key: str, value: str +) -> List[Dict[str, Any]]: + """ + Build OR conditions to match a value inside a JSON column at the given key. + + Uses Prisma's JSON path filtering (PostgreSQL only). Returns a list of + two conditions — one for `before_value` and one for `updated_values` — to + be merged into the caller's top-level OR list. + """ + return [ + {field: {"path": [json_key], "string_contains": value}}, + ] + + @router.get( "/audit", tags=["Audit Logging"], @@ -49,6 +64,14 @@ async def get_audit_logs( ), start_date: Optional[str] = Query(None, description="Filter logs after this date"), end_date: Optional[str] = Query(None, description="Filter logs before this date"), + object_team_id: Optional[str] = Query( + None, + description="Filter by team_id present in before_value or updated_values JSON (PostgreSQL only)", + ), + object_key_hash: Optional[str] = Query( + None, + description="Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only)", + ), # Sorting parameters sort_by: Optional[str] = Query( None, @@ -60,6 +83,9 @@ async def get_audit_logs( Get all audit logs with filtering and pagination. Returns a paginated response of audit logs matching the specified filters. + + Note: object_team_id and object_key_hash use Prisma JSON path filtering, + which requires PostgreSQL. """ from litellm.proxy.proxy_server import prisma_client @@ -82,18 +108,33 @@ async def get_audit_logs( if object_id: where_conditions["object_id"] = object_id if start_date or end_date: - date_filter = {} + date_filter: Dict[str, Any] = {} if start_date: date_filter["gte"] = start_date if end_date: date_filter["lte"] = end_date where_conditions["updated_at"] = date_filter + # JSON field filters (PostgreSQL only) — search inside before_value and + # updated_values for a matching key/value pair. + if object_team_id: + where_conditions["OR"] = [ + *_build_json_field_conditions("before_value", "team_id", object_team_id), + *_build_json_field_conditions("updated_values", "team_id", object_team_id), + ] + if object_key_hash: + existing_or: List[Dict[str, Any]] = where_conditions.get("OR", []) + where_conditions["OR"] = [ + *existing_or, + *_build_json_field_conditions("before_value", "token", object_key_hash), + *_build_json_field_conditions("updated_values", "token", object_key_hash), + ] + # Build sort conditions - order_by = {} + order_by: Dict[str, Any] = {} if sort_by and isinstance(sort_by, str): order_by[sort_by] = sort_order - elif sort_order and isinstance(sort_order, str): + else: order_by["updated_at"] = sort_order # Default sort by updated_at # Get paginated results diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 0df6d813d7..919246ee85 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -8642,30 +8642,46 @@ export const updateSSOSettings = async (accessToken: string, settings: Record { +export interface UiAuditLogsParams { + action?: string; + table_name?: string; + object_id?: string; + changed_by?: string; + changed_by_api_key?: string; + object_team_id?: string; + object_key_hash?: string; + sort_by?: string; + sort_order?: "asc" | "desc"; +} + +export interface UiAuditLogsCallOptions { + accessToken: string; + page?: number; + page_size?: number; + params?: UiAuditLogsParams; +} + +export const uiAuditLogsCall = async ({ + accessToken, + page = 1, + page_size = 50, + params = {}, +}: UiAuditLogsCallOptions) => { 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()); + queryParams.append("page", page.toString()); + queryParams.append("page_size", page_size.toString()); - // Append query parameters to URL if any exist - const queryString = queryParams.toString(); - if (queryString) { - url += `?${queryString}`; + for (const [key, value] of Object.entries(params)) { + if (value != null && value !== "") { + queryParams.append(key, String(value)); + } } + url += `?${queryParams.toString()}`; + const response = await fetch(url, { method: "GET", headers: { @@ -8681,8 +8697,7 @@ export const uiAuditLogsCall = async ( throw new Error(errorMessage); } - const data = await response.json(); - return data; + return await response.json(); } catch (error) { console.error("Failed to fetch audit logs:", error); throw error; diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx new file mode 100644 index 0000000000..1e8dca34f2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx @@ -0,0 +1,217 @@ +import { Drawer, Tag, Tooltip } from "antd"; +import { CloseOutlined } from "@ant-design/icons"; +import moment from "moment"; +import { AuditLogEntry } from "../columns"; + +interface AuditLogDrawerProps { + open: boolean; + onClose: () => void; + log: AuditLogEntry | null; +} + +const TABLE_NAME_DISPLAY: Record = { + LiteLLM_VerificationToken: "Keys", + LiteLLM_TeamTable: "Teams", + LiteLLM_UserTable: "Users", + LiteLLM_OrganizationTable: "Organizations", + LiteLLM_ProxyModelTable: "Models", +}; + +const ACTION_COLOR: Record = { + created: "green", + updated: "blue", + deleted: "red", + rotated: "orange", +}; + +function JsonBlock({ value }: { value: Record }) { + return ( +
+      {JSON.stringify(value, null, 2)}
+    
+ ); +} + +function MetadataRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function DiffSection({ log }: { log: AuditLogEntry }) { + const { action, table_name, before_value, updated_values } = log; + const isKeyTable = table_name === "LiteLLM_VerificationToken"; + const isUpdateAction = action === "updated" || action === "rotated"; + + let displayBefore = before_value; + let displayAfter = updated_values; + + if (isUpdateAction && before_value && updated_values) { + const changedBefore: Record = {}; + const changedAfter: Record = {}; + const allKeys = new Set([ + ...Object.keys(before_value), + ...Object.keys(updated_values), + ]); + + allKeys.forEach((key) => { + const bStr = JSON.stringify(before_value[key]); + const aStr = JSON.stringify(updated_values[key]); + if (bStr !== aStr) { + if (key in before_value) changedBefore[key] = before_value[key]; + if (key in updated_values) changedAfter[key] = updated_values[key]; + } + }); + + // Fields only in before (removed) + Object.keys(before_value).forEach((key) => { + if (!(key in updated_values) && !(key in changedBefore)) { + changedBefore[key] = before_value[key]; + changedAfter[key] = undefined; + } + }); + + // Fields only in after (added) + Object.keys(updated_values).forEach((key) => { + if (!(key in before_value) && !(key in changedAfter)) { + changedAfter[key] = updated_values[key]; + changedBefore[key] = undefined; + } + }); + + displayBefore = + Object.keys(changedBefore).length > 0 + ? changedBefore + : { note: "No differing fields detected" }; + displayAfter = + Object.keys(changedAfter).length > 0 + ? changedAfter + : { note: "No differing fields detected" }; + } + + const renderValue = (value: Record | null | undefined, label: string) => { + if (!value || Object.keys(value).length === 0) { + return

N/A

; + } + + // For key table updates, filter to only show meaningful fields + if (isKeyTable && isUpdateAction) { + const knownKeyFields = ["token", "spend", "max_budget"]; + const hasOnlyKnown = Object.keys(value).every((k) => knownKeyFields.includes(k)); + if (hasOnlyKnown && !("note" in value)) { + return ( +
+ {value.token !== undefined && ( +

Token: {value.token ?? "N/A"}

+ )} + {value.spend !== undefined && ( +

Spend: ${Number(value.spend).toFixed(6)}

+ )} + {value.max_budget !== undefined && ( +

Max Budget: ${Number(value.max_budget).toFixed(6)}

+ )} +
+ ); + } + } + + return ; + }; + + return ( +
+
+

Before

+ {renderValue(displayBefore, "before")} +
+
+

After

+ {renderValue(displayAfter, "after")} +
+
+ ); +} + +export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) { + if (!log) return null; + + const tableDisplay = TABLE_NAME_DISPLAY[log.table_name] ?? log.table_name; + const actionColor = ACTION_COLOR[log.action] ?? "default"; + + return ( + + {/* Header */} +
+
+ + {log.action} + + + {moment.utc(log.updated_at).local().format("MMM D, YYYY HH:mm:ss")} + +
+ +
+ + {/* Body */} +
+ {/* Metadata */} +
+

+ Details +

+ + + {log.object_id} + + } + /> + + + + {log.changed_by_api_key.slice(0, 12)}… + + + ) : ( + "—" + ) + } + /> +
+ + {/* Diff */} +
+

+ Changes +

+ +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx index 918447a614..d3372eca6f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx @@ -1,12 +1,15 @@ -import { DataTable } from "./table"; +import { useState } from "react"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { Table, Tag, Input, Select, Button, Tooltip } from "antd"; +import { ReloadOutlined } from "@ant-design/icons"; +import type { ColumnsType, TablePaginationConfig } from "antd/es/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 { uiAuditLogsCall } from "../networking"; +import { AuditLogEntry } from "./columns"; +import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer"; import { Team } from "../key_team_helpers/key_list"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; + +const { Search } = Input; interface AuditLogsProps { accessToken: string | null; @@ -21,6 +24,23 @@ interface AuditLogsProps { const asset_logos_folder = "../ui/assets/"; export const auditLogsPreviewImg = `${asset_logos_folder}audit-logs-preview.png`; +const TABLE_NAME_DISPLAY: Record = { + LiteLLM_VerificationToken: "Keys", + LiteLLM_TeamTable: "Teams", + LiteLLM_UserTable: "Users", + LiteLLM_OrganizationTable: "Organizations", + LiteLLM_ProxyModelTable: "Models", +}; + +const ACTION_COLOR: Record = { + created: "green", + updated: "blue", + deleted: "red", + rotated: "orange", +}; + +const PAGE_SIZE = 50; + export default function AuditLogs({ userID, userRole, @@ -28,413 +48,144 @@ export default function AuditLogs({ accessToken, isActive, premiumUser, - allTeams, }: AuditLogsProps) { - const [startTime, setStartTime] = useState(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); + const [page, setPage] = useState(1); - const actionFilterRef = useRef(null); - const tableFilterRef = useRef(null); - const [clientCurrentPage, setClientCurrentPage] = useState(1); - const [pageSize] = useState(50); - const [filters, setFilters] = useState>({}); - 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); + // Filter state + const [objectId, setObjectId] = useState(""); + const [changedBy, setChangedBy] = useState(""); + const [keyHash, setKeyHash] = useState(""); + const [teamId, setTeamId] = useState(""); + const [action, setAction] = useState(undefined); + const [tableName, setTableName] = useState(undefined); - const allLogsQuery = useQuery({ - queryKey: ["all_audit_logs", accessToken, token, userRole, userID, startTime], + // Drawer state + const [selectedLog, setSelectedLog] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + const query = useQuery({ + queryKey: [ + "audit_logs", + page, + PAGE_SIZE, + objectId, + changedBy, + keyHash, + teamId, + action, + tableName, + ], queryFn: async () => { if (!accessToken || !token || !userRole || !userID) { - return []; + return { audit_logs: [], total: 0, page: 1, page_size: PAGE_SIZE, total_pages: 0 }; } - - 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; + return uiAuditLogsCall({ + accessToken, + page, + page_size: PAGE_SIZE, + params: { + object_id: objectId || undefined, + changed_by: changedBy || undefined, + object_key_hash: keyHash || undefined, + object_team_id: teamId || undefined, + action: action || undefined, + table_name: tableName || undefined, + sort_by: "updated_at", + sort_order: "desc", + }, + }); }, enabled: !!accessToken && !!token && !!userRole && !!userID && isActive, - refetchInterval: 5000, - refetchIntervalInBackground: true, + placeholderData: keepPreviousData, }); - const handleRefresh = () => { - allLogsQuery.refetch(); + const handleFilterChange = () => { + // Reset to page 1 whenever a filter changes + setPage(1); }; - const handleFilterChange = (newFilters: Record) => { - setFilters(newFilters); + const handleTableChange = (pagination: TablePaginationConfig) => { + setPage(pagination.current ?? 1); }; - const handleFilterReset = () => { - setFilters({}); - setSelectedTeamId(""); - setSelectedKeyHash(""); - setObjectIdSearch(""); - setSelectedActionFilter("all"); - setSelectedTableFilter("all"); - setClientCurrentPage(1); + const handleRowClick = (log: AuditLogEntry) => { + setSelectedLog(log); + setDrawerOpen(true); }; - 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(""); - } + const columns: ColumnsType = [ + { + title: "Timestamp", + dataIndex: "updated_at", + key: "updated_at", + width: 200, + render: (val: string) => ( + + {moment.utc(val).local().format("MMM D, YYYY HH:mm:ss")} + + ), }, - [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 ( -
-
- - - - - -
-
-

Audit Logs Not Available

-

- To enable audit logging, add the following configuration to your LiteLLM proxy configuration file: -

-
-            {`litellm_settings:
-  store_audit_logs: true`}
-          
-

- Note: This will only affect new requests after the configuration change and proxy restart. -

-
-
- ); - }; - - const renderSubComponent = useCallback(({ row }: { row: any }) => { - const AuditLogRowExpansionPanel = ({ rowData }: { rowData: AuditLogEntry }) => { - const { before_value, updated_values, table_name, action } = rowData; - - const renderValue = (value: Record, isKeyTable: boolean) => { - if (!value || Object.keys(value).length === 0) return N/A; - - 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 ( -
- {changedKeys.includes("token") && ( -

- Token: {value.token || "N/A"} -

- )} - {changedKeys.includes("spend") && ( -

- Spend:{" "} - {value.spend !== undefined ? `$${formatNumberWithCommas(value.spend, 6)}` : "N/A"} -

- )} - {changedKeys.includes("max_budget") && ( -

- Max Budget:{" "} - {value.max_budget !== undefined ? `$${formatNumberWithCommas(value.max_budget, 6)}` : "N/A"} -

- )} -
- ); - } else { - if ( - value["No differing fields detected in 'before' state"] || - value["No differing fields detected in 'updated' state"] || - value["No fields changed"] - ) { - return {value[Object.keys(value)[0]]}; // Display the N/A message string - } - return ( -
-                {JSON.stringify(value, null, 2)}
-              
- ); - } - } - - return ( -
-            {JSON.stringify(value, null, 2)}
-          
- ); - }; - - 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 = {}; - const changedUpdated: Record = {}; - 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 ( -
-
-

Before Value:

- {renderValue(displayBeforeValue, table_name === "LiteLLM_VerificationToken")} -
-
-

Updated Value:

- {renderValue(displayUpdatedValue, table_name === "LiteLLM_VerificationToken")} -
-
- ); - }; - - return ; - }, []); + { + title: "Action", + dataIndex: "action", + key: "action", + width: 100, + render: (val: string) => ( + + {val} + + ), + }, + { + title: "Table", + dataIndex: "table_name", + key: "table_name", + width: 130, + render: (val: string) => TABLE_NAME_DISPLAY[val] ?? val, + }, + { + title: "Object ID", + dataIndex: "object_id", + key: "object_id", + render: (val: string) => ( + + {val} + + ), + }, + { + title: "Changed By", + dataIndex: "changed_by", + key: "changed_by", + width: 200, + render: (val: string) => val || "—", + }, + { + title: "API Key", + dataIndex: "changed_by_api_key", + key: "changed_by_api_key", + width: 140, + render: (val: string) => + val ? ( + + {val.slice(0, 12)}… + + ) : ( + "—" + ), + }, + ]; if (!premiumUser) { return (

✨ Enterprise Feature.

- +

This is a LiteLLM Enterprise feature, and requires a valid key to use. - - +

+

Here's a preview of what Audit Logs offer: - +

Audit Logs Preview { - console.error("Failed to load audit logs preview image"); (e.target as HTMLImageElement).style.display = "none"; }} /> @@ -454,204 +204,149 @@ export default function AuditLogs({ ); } - const currentDisplayItemsStart = totalFilteredItems > 0 ? (clientCurrentPage - 1) * pageSize + 1 : 0; - const currentDisplayItemsEnd = Math.min(clientCurrentPage * pageSize, totalFilteredItems); + const auditLogs: AuditLogEntry[] = query.data?.audit_logs ?? []; + const total: number = query.data?.total ?? 0; return ( <> -
- {/* */}
+ {/* Header */}
-

Audit Logs

+
+

Audit Logs

+ +
- {/* Show Audit Logs Info Message when no data */} - - -
-
-
-
- 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" - /> -
- - -
-
- -
- {/* Custom Action Filter Dropdown */} -
- - - {actionFilterOpen && ( -
-
- {[ - { label: "All Actions", value: "all" }, - { label: "Created", value: "created" }, - { label: "Updated", value: "updated" }, - { label: "Deleted", value: "deleted" }, - { label: "Rotated", value: "rotated" }, - ].map((option) => ( - - ))} -
-
- )} -
- - {/* Custom Table Filter Dropdown */} -
- - - {tableFilterOpen && ( -
-
- {[ - { label: "All Tables", value: "all" }, - { label: "Keys", value: "keys" }, - { label: "Teams", value: "teams" }, - { label: "Users", value: "users" }, - ].map((option) => ( - - ))} -
-
- )} -
- - - Showing {allLogsQuery.isLoading ? "..." : currentDisplayItemsStart} -{" "} - {allLogsQuery.isLoading ? "..." : currentDisplayItemsEnd} of{" "} - {allLogsQuery.isLoading ? "..." : totalFilteredItems} results - -
- - Page {allLogsQuery.isLoading ? "..." : clientCurrentPage} of{" "} - {allLogsQuery.isLoading ? "..." : totalFilteredPages} - - - -
-
+ {/* Filters */} +
+ { + setObjectId(val); + handleFilterChange(); + }} + onChange={(e) => { + if (!e.target.value) { + setObjectId(""); + handleFilterChange(); + } + }} + /> + { + setChangedBy(val); + handleFilterChange(); + }} + onChange={(e) => { + if (!e.target.value) { + setChangedBy(""); + handleFilterChange(); + } + }} + /> + { + setTeamId(val); + handleFilterChange(); + }} + onChange={(e) => { + if (!e.target.value) { + setTeamId(""); + handleFilterChange(); + } + }} + /> + { + setKeyHash(val); + handleFilterChange(); + }} + onChange={(e) => { + if (!e.target.value) { + setKeyHash(""); + handleFilterChange(); + } + }} + /> + { + setTableName(val); + handleFilterChange(); + }} + />
- true} + + {/* Table */} + + columns={columns} + dataSource={auditLogs} + rowKey="id" + loading={query.isLoading} + size="small" + onRow={(record) => ({ + onClick: () => handleRowClick(record), + style: { cursor: "pointer" }, + })} + pagination={{ + current: page, + pageSize: PAGE_SIZE, + total, + showTotal: (t) => `${t} total`, + showSizeChanger: false, + onChange: (p) => setPage(p), + }} + onChange={handleTableChange} />
+ + setDrawerOpen(false)} + log={selectedLog} + /> ); } From 657a60ea5b55fa3497e4222b12ec782fc54f2df8 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 14:24:55 -0800 Subject: [PATCH 2/4] fix(audit): AND semantics for combined JSON filters; remove unused allTeams prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix object_team_id + object_key_hash combining incorrectly as OR — each filter now adds an AND clause wrapping an internal OR over before_value and updated_values, so both conditions must be satisfied simultaneously - Rename helper to _build_json_field_or_condition to reflect its purpose - Remove allTeams from AuditLogsProps and its call site in index.tsx Co-Authored-By: Claude Sonnet 4.6 --- .../proxy/audit_logging_endpoints.py | 42 ++++++++++--------- .../src/components/view_logs/audit_logs.tsx | 2 - .../src/components/view_logs/index.tsx | 1 - 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py index 5ab3669b50..18ac29b978 100644 --- a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py @@ -22,19 +22,25 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth router = APIRouter() -def _build_json_field_conditions( - field: str, json_key: str, value: str -) -> List[Dict[str, Any]]: +def _build_json_field_or_condition(json_key: str, value: str) -> Dict[str, Any]: """ - Build OR conditions to match a value inside a JSON column at the given key. + Build an OR condition that matches a value inside a JSON column at the + given key, checking both before_value and updated_values. - Uses Prisma's JSON path filtering (PostgreSQL only). Returns a list of - two conditions — one for `before_value` and one for `updated_values` — to - be merged into the caller's top-level OR list. + Uses Prisma's JSON path filtering (PostgreSQL only). + + Example result (team_id="t1"): + {"OR": [ + {"before_value": {"path": ["team_id"], "string_contains": "t1"}}, + {"updated_values": {"path": ["team_id"], "string_contains": "t1"}}, + ]} """ - return [ - {field: {"path": [json_key], "string_contains": value}}, - ] + return { + "OR": [ + {"before_value": {"path": [json_key], "string_contains": value}}, + {"updated_values": {"path": [json_key], "string_contains": value}}, + ] + } @router.get( @@ -115,19 +121,15 @@ async def get_audit_logs( date_filter["lte"] = end_date where_conditions["updated_at"] = date_filter - # JSON field filters (PostgreSQL only) — search inside before_value and - # updated_values for a matching key/value pair. + # JSON field filters (PostgreSQL only) — each filter is AND'd with the + # others, but checks both before_value and updated_values internally (OR). if object_team_id: - where_conditions["OR"] = [ - *_build_json_field_conditions("before_value", "team_id", object_team_id), - *_build_json_field_conditions("updated_values", "team_id", object_team_id), + where_conditions["AND"] = where_conditions.get("AND", []) + [ + _build_json_field_or_condition("team_id", object_team_id) ] if object_key_hash: - existing_or: List[Dict[str, Any]] = where_conditions.get("OR", []) - where_conditions["OR"] = [ - *existing_or, - *_build_json_field_conditions("before_value", "token", object_key_hash), - *_build_json_field_conditions("updated_values", "token", object_key_hash), + where_conditions["AND"] = where_conditions.get("AND", []) + [ + _build_json_field_or_condition("token", object_key_hash) ] # Build sort conditions diff --git a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx index d3372eca6f..693318acca 100644 --- a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx @@ -7,7 +7,6 @@ import moment from "moment"; import { uiAuditLogsCall } from "../networking"; import { AuditLogEntry } from "./columns"; import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer"; -import { Team } from "../key_team_helpers/key_list"; const { Search } = Input; @@ -18,7 +17,6 @@ interface AuditLogsProps { userID: string | null; isActive: boolean; premiumUser: boolean; - allTeams: Team[]; } const asset_logos_folder = "../ui/assets/"; diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 22cc06a73e..64dc53c0e7 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -714,7 +714,6 @@ export default function SpendLogsTable({ accessToken={accessToken} isActive={activeTab === "audit logs"} premiumUser={premiumUser} - allTeams={allTeams} /> From 326ff422f1faad0a7654289d97824270be6c5686 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 15:54:46 -0800 Subject: [PATCH 3/4] fix(ui): Audit logs table and drawer polish Table: - Use DefaultProxyAdminTag for changed_by column - Remove tooltips on Object ID and API Key columns - Rename API Key column to API Key (Hash) - Move pagination controls to upper-right of filter bar; add icon-only refresh button Drawer: - Object ID is now copyable - API Key (Hash) is copyable and no longer truncated - Changed By uses DefaultProxyAdminTag - Expand JSON view boxes from max-h-72 to max-h-96 - Remove unnecessary vertical scrollbar (drop overflow-auto h-full from body div; use flex column layout so header and content flow naturally) Co-Authored-By: Claude Sonnet 4.6 --- .../AuditLogDrawer/AuditLogDrawer.tsx | 40 +++--- .../src/components/view_logs/audit_logs.tsx | 125 ++++++------------ 2 files changed, 62 insertions(+), 103 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx index 1e8dca34f2..de76709f5b 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx @@ -1,7 +1,10 @@ -import { Drawer, Tag, Tooltip } from "antd"; +import { Drawer, Tag, Typography } from "antd"; import { CloseOutlined } from "@ant-design/icons"; import moment from "moment"; import { AuditLogEntry } from "../columns"; +import DefaultProxyAdminTag from "../../common_components/DefaultProxyAdminTag"; + +const { Text } = Typography; interface AuditLogDrawerProps { open: boolean; @@ -26,7 +29,7 @@ const ACTION_COLOR: Record = { function JsonBlock({ value }: { value: Record }) { return ( -
+    
       {JSON.stringify(value, null, 2)}
     
); @@ -92,7 +95,7 @@ function DiffSection({ log }: { log: AuditLogEntry }) { : { note: "No differing fields detected" }; } - const renderValue = (value: Record | null | undefined, label: string) => { + const renderValue = (value: Record | null | undefined) => { if (!value || Object.keys(value).length === 0) { return

N/A

; } @@ -125,11 +128,11 @@ function DiffSection({ log }: { log: AuditLogEntry }) {

Before

- {renderValue(displayBefore, "before")} + {renderValue(displayBefore)}

After

- {renderValue(displayAfter, "after")} + {renderValue(displayAfter)}
); @@ -150,10 +153,10 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) { closable={false} mask={true} maskClosable={true} - styles={{ body: { padding: 0 }, header: { display: "none" } }} + styles={{ body: { padding: 0, display: "flex", flexDirection: "column" }, header: { display: "none" } }} > {/* Header */} -
+
{log.action} @@ -172,7 +175,7 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
{/* Body */} -
+
{/* Metadata */}

@@ -182,21 +185,22 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) { - {log.object_id} - + + {log.object_id} + } /> - } + /> + - - {log.changed_by_api_key.slice(0, 12)}… - - + + {log.changed_by_api_key} + ) : ( "—" ) diff --git a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx index 693318acca..71300fe042 100644 --- a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx @@ -1,12 +1,13 @@ import { useState } from "react"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { Table, Tag, Input, Select, Button, Tooltip } from "antd"; +import { Table, Tag, Input, Select, Button, Pagination } from "antd"; import { ReloadOutlined } from "@ant-design/icons"; -import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; +import type { ColumnsType } from "antd/es/table"; import moment from "moment"; import { uiAuditLogsCall } from "../networking"; import { AuditLogEntry } from "./columns"; import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer"; +import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; const { Search } = Input; @@ -97,14 +98,7 @@ export default function AuditLogs({ placeholderData: keepPreviousData, }); - const handleFilterChange = () => { - // Reset to page 1 whenever a filter changes - setPage(1); - }; - - const handleTableChange = (pagination: TablePaginationConfig) => { - setPage(pagination.current ?? 1); - }; + const resetPage = () => setPage(1); const handleRowClick = (log: AuditLogEntry) => { setSelectedLog(log); @@ -146,9 +140,7 @@ export default function AuditLogs({ dataIndex: "object_id", key: "object_id", render: (val: string) => ( - - {val} - + {val} ), }, { @@ -156,18 +148,16 @@ export default function AuditLogs({ dataIndex: "changed_by", key: "changed_by", width: 200, - render: (val: string) => val || "—", + render: (val: string) => , }, { - title: "API Key", + title: "API Key (Hash)", dataIndex: "changed_by_api_key", key: "changed_by_api_key", width: 140, render: (val: string) => val ? ( - - {val.slice(0, 12)}… - + {val.slice(0, 12)}… ) : ( "—" ), @@ -212,76 +202,37 @@ export default function AuditLogs({

Audit Logs

-
- {/* Filters */} -
+ {/* Filters + pagination on same row */} +
{ - setObjectId(val); - handleFilterChange(); - }} - onChange={(e) => { - if (!e.target.value) { - setObjectId(""); - handleFilterChange(); - } - }} + onSearch={(val) => { setObjectId(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setObjectId(""); resetPage(); } }} /> { - setChangedBy(val); - handleFilterChange(); - }} - onChange={(e) => { - if (!e.target.value) { - setChangedBy(""); - handleFilterChange(); - } - }} + onSearch={(val) => { setChangedBy(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setChangedBy(""); resetPage(); } }} /> { - setTeamId(val); - handleFilterChange(); - }} - onChange={(e) => { - if (!e.target.value) { - setTeamId(""); - handleFilterChange(); - } - }} + onSearch={(val) => { setTeamId(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setTeamId(""); resetPage(); } }} /> { - setKeyHash(val); - handleFilterChange(); - }} - onChange={(e) => { - if (!e.target.value) { - setKeyHash(""); - handleFilterChange(); - } - }} + onSearch={(val) => { setKeyHash(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setKeyHash(""); resetPage(); } }} /> { - setTableName(val); - handleFilterChange(); - }} + onChange={(val) => { setTableName(val); resetPage(); }} /> + + {/* Pagination + refresh pushed to the right */} +
+
- {/* Table */} + {/* Table — pagination handled in header */} columns={columns} dataSource={auditLogs} rowKey="id" loading={query.isLoading} size="small" + pagination={false} onRow={(record) => ({ onClick: () => handleRowClick(record), style: { cursor: "pointer" }, })} - pagination={{ - current: page, - pageSize: PAGE_SIZE, - total, - showTotal: (t) => `${t} total`, - showSizeChanger: false, - onChange: (p) => setPage(p), - }} - onChange={handleTableChange} />
From 5fa0c6e994de825502c3d5b1f8278a5ef03a4850 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 16:00:46 -0800 Subject: [PATCH 4/4] fix(ui): Copyable JSON blocks in audit drawer; custom table spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace plain JsonBlock with CopyableJsonBlock: header row with label and copy icon (CheckOutlined on success), pre block below — matches the spend logs RequestResponsePanel pattern - Key-table rows use the same card chrome for visual consistency - Remove separate "Changes" section label (now redundant with block headers) - Table loading spinner replaced with custom Spin + LoadingOutlined Co-Authored-By: Claude Sonnet 4.6 --- .../AuditLogDrawer/AuditLogDrawer.tsx | 105 ++++++++++++------ .../src/components/view_logs/audit_logs.tsx | 9 +- 2 files changed, 78 insertions(+), 36 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx index de76709f5b..19989ef488 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx @@ -1,5 +1,6 @@ import { Drawer, Tag, Typography } from "antd"; -import { CloseOutlined } from "@ant-design/icons"; +import { CloseOutlined, CopyOutlined, CheckOutlined } from "@ant-design/icons"; +import { useState, useCallback } from "react"; import moment from "moment"; import { AuditLogEntry } from "../columns"; import DefaultProxyAdminTag from "../../common_components/DefaultProxyAdminTag"; @@ -27,11 +28,48 @@ const ACTION_COLOR: Record = { rotated: "orange", }; -function JsonBlock({ value }: { value: Record }) { +function CopyableJsonBlock({ label, value }: { label: string; value: Record }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + const text = JSON.stringify(value, null, 2); + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + const el = document.createElement("textarea"); + el.value = text; + el.style.position = "fixed"; + el.style.opacity = "0"; + document.body.appendChild(el); + el.focus(); + el.select(); + document.execCommand("copy"); + document.body.removeChild(el); + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error("Copy failed:", e); + } + }, [value]); + return ( -
-      {JSON.stringify(value, null, 2)}
-    
+
+
+ {label} + +
+
+        {JSON.stringify(value, null, 2)}
+      
+
); } @@ -95,45 +133,51 @@ function DiffSection({ log }: { log: AuditLogEntry }) { : { note: "No differing fields detected" }; } - const renderValue = (value: Record | null | undefined) => { + const renderValue = (label: string, value: Record | null | undefined) => { if (!value || Object.keys(value).length === 0) { - return

N/A

; + return ( +
+
+ {label} +
+

N/A

+
+ ); } - // For key table updates, filter to only show meaningful fields + // For key table updates, show only meaningful fields as plain text if (isKeyTable && isUpdateAction) { const knownKeyFields = ["token", "spend", "max_budget"]; const hasOnlyKnown = Object.keys(value).every((k) => knownKeyFields.includes(k)); if (hasOnlyKnown && !("note" in value)) { return ( -
- {value.token !== undefined && ( -

Token: {value.token ?? "N/A"}

- )} - {value.spend !== undefined && ( -

Spend: ${Number(value.spend).toFixed(6)}

- )} - {value.max_budget !== undefined && ( -

Max Budget: ${Number(value.max_budget).toFixed(6)}

- )} +
+
+ {label} +
+
+ {value.token !== undefined && ( +

Token: {value.token ?? "N/A"}

+ )} + {value.spend !== undefined && ( +

Spend: ${Number(value.spend).toFixed(6)}

+ )} + {value.max_budget !== undefined && ( +

Max Budget: ${Number(value.max_budget).toFixed(6)}

+ )} +
); } } - return ; + return ; }; return (
-
-

Before

- {renderValue(displayBefore)} -
-
-

After

- {renderValue(displayAfter)} -
+ {renderValue("Before", displayBefore)} + {renderValue("After", displayAfter)}
); } @@ -209,12 +253,7 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
{/* Diff */} -
-

- Changes -

- -
+
); diff --git a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx index 71300fe042..b16ba30049 100644 --- a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { Table, Tag, Input, Select, Button, Pagination } from "antd"; -import { ReloadOutlined } from "@ant-design/icons"; +import { Table, Tag, Input, Select, Button, Pagination, Spin } from "antd"; +import { ReloadOutlined, LoadingOutlined } from "@ant-design/icons"; import type { ColumnsType } from "antd/es/table"; import moment from "moment"; import { uiAuditLogsCall } from "../networking"; @@ -285,7 +285,10 @@ export default function AuditLogs({ columns={columns} dataSource={auditLogs} rowKey="id" - loading={query.isLoading} + loading={{ + spinning: query.isLoading, + indicator: } size="small" />, + }} size="small" pagination={false} onRow={(record) => ({