diff --git a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py index d1b00420d3..18ac29b978 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,27 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth router = APIRouter() +def _build_json_field_or_condition(json_key: str, value: str) -> Dict[str, Any]: + """ + 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). + + Example result (team_id="t1"): + {"OR": [ + {"before_value": {"path": ["team_id"], "string_contains": "t1"}}, + {"updated_values": {"path": ["team_id"], "string_contains": "t1"}}, + ]} + """ + return { + "OR": [ + {"before_value": {"path": [json_key], "string_contains": value}}, + {"updated_values": {"path": [json_key], "string_contains": value}}, + ] + } + + @router.get( "/audit", tags=["Audit Logging"], @@ -49,6 +70,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 +89,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 +114,29 @@ 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) — each filter is AND'd with the + # others, but checks both before_value and updated_values internally (OR). + if object_team_id: + where_conditions["AND"] = where_conditions.get("AND", []) + [ + _build_json_field_or_condition("team_id", object_team_id) + ] + if object_key_hash: + where_conditions["AND"] = where_conditions.get("AND", []) + [ + _build_json_field_or_condition("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 f64e909ae3..e231f7c43e 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -8768,30 +8768,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: { @@ -8807,8 +8823,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..19989ef488 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx @@ -0,0 +1,260 @@ +import { Drawer, Tag, Typography } from "antd"; +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"; + +const { Text } = Typography; + +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 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 ( +
+
+ {label} + +
+
+        {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 = (label: string, value: Record | null | undefined) => { + if (!value || Object.keys(value).length === 0) { + return ( +
+
+ {label} +
+

N/A

+
+ ); + } + + // 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 ( +
+
+ {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 ( +
+ {renderValue("Before", displayBefore)} + {renderValue("After", displayAfter)} +
+ ); +} + +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} + + ) : ( + "—" + ) + } + /> +
+ + {/* Diff */} + +
+
+ ); +} 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..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,12 +1,15 @@ -import { DataTable } from "./table"; +import { useState } from "react"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; +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 { 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"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { uiAuditLogsCall } from "../networking"; +import { AuditLogEntry } from "./columns"; +import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer"; +import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; + +const { Search } = Input; interface AuditLogsProps { accessToken: string | null; @@ -15,12 +18,28 @@ interface AuditLogsProps { userID: string | null; isActive: boolean; premiumUser: boolean; - allTeams: Team[]; } 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 +47,133 @@ 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 resetPage = () => setPage(1); + + const handleRowClick = (log: AuditLogEntry) => { + setSelectedLog(log); + setDrawerOpen(true); }; - const handleFilterChange = (newFilters: Record) => { - 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(""); - } + 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) => , + }, + { + title: "API Key (Hash)", + 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 +192,117 @@ 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 */} - + {/* Filters + pagination on same row */} +
+ { setObjectId(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setObjectId(""); resetPage(); } }} + /> + { setChangedBy(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setChangedBy(""); resetPage(); } }} + /> + { setTeamId(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setTeamId(""); resetPage(); } }} + /> + { setKeyHash(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setKeyHash(""); resetPage(); } }} + /> + { setTableName(val); resetPage(); }} + /> -
-
-
-
- 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} - - - -
+ {/* Pagination + refresh pushed to the right */} +
+
- true} + + {/* Table — pagination handled in header */} + + columns={columns} + dataSource={auditLogs} + rowKey="id" + loading={{ + spinning: query.isLoading, + indicator: } size="small" />, + }} + size="small" + pagination={false} + onRow={(record) => ({ + onClick: () => handleRowClick(record), + style: { cursor: "pointer" }, + })} />
+ + setDrawerOpen(false)} + log={selectedLog} + /> ); } 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} />