Merge pull request #22476 from BerriAI/litellm_audit_pagination_fix
[Fix] UI - Audit Logs: Server-side pagination, filtering, and drawer view
This commit is contained in:
commit
ba7a6d9bfd
@ -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
|
||||
|
||||
@ -8768,30 +8768,46 @@ export const updateSSOSettings = async (accessToken: string, settings: Record<st
|
||||
}
|
||||
};
|
||||
|
||||
export const uiAuditLogsCall = async (
|
||||
accessToken: string,
|
||||
start_date?: string,
|
||||
end_date?: string,
|
||||
page?: number,
|
||||
page_size?: number,
|
||||
) => {
|
||||
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;
|
||||
|
||||
@ -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<string, string> = {
|
||||
LiteLLM_VerificationToken: "Keys",
|
||||
LiteLLM_TeamTable: "Teams",
|
||||
LiteLLM_UserTable: "Users",
|
||||
LiteLLM_OrganizationTable: "Organizations",
|
||||
LiteLLM_ProxyModelTable: "Models",
|
||||
};
|
||||
|
||||
const ACTION_COLOR: Record<string, string> = {
|
||||
created: "green",
|
||||
updated: "blue",
|
||||
deleted: "red",
|
||||
rotated: "orange",
|
||||
};
|
||||
|
||||
function CopyableJsonBlock({ label, value }: { label: string; value: Record<string, any> }) {
|
||||
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 (
|
||||
<div className="bg-white rounded border overflow-hidden">
|
||||
<div className="flex justify-between items-center px-3 py-2 border-b bg-gray-50">
|
||||
<span className="text-xs font-semibold text-gray-600">{label}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 hover:bg-gray-200 rounded text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="Copy JSON"
|
||||
>
|
||||
{copied ? <CheckOutlined className="text-green-600" /> : <CopyOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 bg-white text-xs font-mono overflow-auto max-h-96 whitespace-pre-wrap break-all m-0">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 py-1.5">
|
||||
<span className="text-xs text-gray-500 w-36 shrink-0">{label}</span>
|
||||
<span className="text-xs text-gray-900 break-all">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, any> = {};
|
||||
const changedAfter: Record<string, any> = {};
|
||||
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<string, any> | null | undefined) => {
|
||||
if (!value || Object.keys(value).length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded border overflow-hidden">
|
||||
<div className="flex items-center px-3 py-2 border-b bg-gray-50">
|
||||
<span className="text-xs font-semibold text-gray-600">{label}</span>
|
||||
</div>
|
||||
<p className="px-3 py-3 text-xs text-gray-400 italic m-0">N/A</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="bg-white rounded border overflow-hidden">
|
||||
<div className="flex items-center px-3 py-2 border-b bg-gray-50">
|
||||
<span className="text-xs font-semibold text-gray-600">{label}</span>
|
||||
</div>
|
||||
<div className="px-3 py-3 space-y-1 text-xs">
|
||||
{value.token !== undefined && (
|
||||
<p><span className="text-gray-500">Token:</span> {value.token ?? "N/A"}</p>
|
||||
)}
|
||||
{value.spend !== undefined && (
|
||||
<p><span className="text-gray-500">Spend:</span> ${Number(value.spend).toFixed(6)}</p>
|
||||
)}
|
||||
{value.max_budget !== undefined && (
|
||||
<p><span className="text-gray-500">Max Budget:</span> ${Number(value.max_budget).toFixed(6)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <CopyableJsonBlock label={label} value={value} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
{renderValue("Before", displayBefore)}
|
||||
{renderValue("After", displayAfter)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Drawer
|
||||
placement="right"
|
||||
width="60%"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
closable={false}
|
||||
mask={true}
|
||||
maskClosable={true}
|
||||
styles={{ body: { padding: 0, display: "flex", flexDirection: "column" }, header: { display: "none" } }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Tag color={actionColor} className="capitalize m-0">
|
||||
{log.action}
|
||||
</Tag>
|
||||
<span className="text-sm text-gray-500">
|
||||
{moment.utc(log.updated_at).local().format("MMM D, YYYY HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-500"
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-5">
|
||||
{/* Metadata */}
|
||||
<div className="bg-gray-50 border rounded-lg p-4 mb-5">
|
||||
<p className="text-xs font-semibold text-gray-700 mb-2 uppercase tracking-wide">
|
||||
Details
|
||||
</p>
|
||||
<MetadataRow label="Table" value={tableDisplay} />
|
||||
<MetadataRow
|
||||
label="Object ID"
|
||||
value={
|
||||
<Text copyable className="font-mono text-xs">
|
||||
{log.object_id}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<MetadataRow
|
||||
label="Changed By"
|
||||
value={<DefaultProxyAdminTag userId={log.changed_by} />}
|
||||
/>
|
||||
<MetadataRow
|
||||
label="API Key (Hash)"
|
||||
value={
|
||||
log.changed_by_api_key ? (
|
||||
<Text copyable className="font-mono text-xs break-all">
|
||||
{log.changed_by_api_key}
|
||||
</Text>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Diff */}
|
||||
<DiffSection log={log} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@ -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<string, string> = {
|
||||
LiteLLM_VerificationToken: "Keys",
|
||||
LiteLLM_TeamTable: "Teams",
|
||||
LiteLLM_UserTable: "Users",
|
||||
LiteLLM_OrganizationTable: "Organizations",
|
||||
LiteLLM_ProxyModelTable: "Models",
|
||||
};
|
||||
|
||||
const ACTION_COLOR: Record<string, string> = {
|
||||
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<string>(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
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);
|
||||
// Filter state
|
||||
const [objectId, setObjectId] = useState("");
|
||||
const [changedBy, setChangedBy] = useState("");
|
||||
const [keyHash, setKeyHash] = useState("");
|
||||
const [teamId, setTeamId] = useState("");
|
||||
const [action, setAction] = useState<string | undefined>(undefined);
|
||||
const [tableName, setTableName] = useState<string | undefined>(undefined);
|
||||
|
||||
const allLogsQuery = useQuery<AuditLogEntry[]>({
|
||||
queryKey: ["all_audit_logs", accessToken, token, userRole, userID, startTime],
|
||||
// Drawer state
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLogEntry | null>(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<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("");
|
||||
}
|
||||
const columns: ColumnsType<AuditLogEntry> = [
|
||||
{
|
||||
title: "Timestamp",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
width: 200,
|
||||
render: (val: string) => (
|
||||
<span className="font-mono text-xs whitespace-nowrap">
|
||||
{moment.utc(val).local().format("MMM D, YYYY HH:mm:ss")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
[accessToken],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
|
||||
let teamIdChanged = false;
|
||||
let keyHashChanged = false;
|
||||
|
||||
if (filters["Team ID"]) {
|
||||
if (selectedTeamId !== filters["Team ID"]) {
|
||||
setSelectedTeamId(filters["Team ID"]);
|
||||
teamIdChanged = true;
|
||||
}
|
||||
} else {
|
||||
if (selectedTeamId !== "") {
|
||||
setSelectedTeamId("");
|
||||
teamIdChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters["Key Hash"]) {
|
||||
if (selectedKeyHash !== filters["Key Hash"]) {
|
||||
setSelectedKeyHash(filters["Key Hash"]);
|
||||
keyHashChanged = true;
|
||||
}
|
||||
} else if (filters["Key Alias"]) {
|
||||
fetchKeyHashForAlias(filters["Key Alias"]);
|
||||
} else {
|
||||
if (selectedKeyHash !== "") {
|
||||
setSelectedKeyHash("");
|
||||
keyHashChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (teamIdChanged || keyHashChanged) {
|
||||
setClientCurrentPage(1);
|
||||
}
|
||||
}, [filters, accessToken, fetchKeyHashForAlias, selectedTeamId, selectedKeyHash]);
|
||||
|
||||
useEffect(() => {
|
||||
setClientCurrentPage(1);
|
||||
}, [selectedTeamId, selectedKeyHash, startTime, objectIdSearch, selectedActionFilter, selectedTableFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (actionFilterRef.current && !actionFilterRef.current.contains(event.target as Node)) {
|
||||
setActionFilterOpen(false);
|
||||
}
|
||||
if (tableFilterRef.current && !tableFilterRef.current.contains(event.target as Node)) {
|
||||
setTableFilterOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const completeFilteredLogs = useMemo(() => {
|
||||
if (!allLogsQuery.data) return [];
|
||||
return allLogsQuery.data.filter((log) => {
|
||||
let matchesTeam = true;
|
||||
let matchesKey = true;
|
||||
let matchesObjectId = true;
|
||||
let matchesAction = true;
|
||||
let matchesTable = true;
|
||||
|
||||
if (selectedTeamId) {
|
||||
const beforeTeamId =
|
||||
typeof log.before_value === "string" ? JSON.parse(log.before_value)?.team_id : log.before_value?.team_id;
|
||||
const updatedTeamId =
|
||||
typeof log.updated_values === "string"
|
||||
? JSON.parse(log.updated_values)?.team_id
|
||||
: log.updated_values?.team_id;
|
||||
matchesTeam = beforeTeamId === selectedTeamId || updatedTeamId === selectedTeamId;
|
||||
}
|
||||
|
||||
if (selectedKeyHash) {
|
||||
try {
|
||||
const beforeBody = typeof log.before_value === "string" ? JSON.parse(log.before_value) : log.before_value;
|
||||
const updatedBody =
|
||||
typeof log.updated_values === "string" ? JSON.parse(log.updated_values) : log.updated_values;
|
||||
|
||||
const beforeKey = beforeBody?.token;
|
||||
const updatedKey = updatedBody?.token;
|
||||
|
||||
matchesKey =
|
||||
(typeof beforeKey === "string" && beforeKey.includes(selectedKeyHash)) ||
|
||||
(typeof updatedKey === "string" && updatedKey.includes(selectedKeyHash));
|
||||
} catch (e) {
|
||||
matchesKey = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (objectIdSearch) {
|
||||
matchesObjectId = log.object_id?.toLowerCase().includes(objectIdSearch.toLowerCase());
|
||||
}
|
||||
|
||||
if (selectedActionFilter !== "all") {
|
||||
matchesAction = log.action?.toLowerCase() === selectedActionFilter.toLowerCase();
|
||||
}
|
||||
|
||||
if (selectedTableFilter !== "all") {
|
||||
let tableMatchName = "";
|
||||
switch (selectedTableFilter) {
|
||||
case "keys":
|
||||
tableMatchName = "litellm_verificationtoken";
|
||||
break;
|
||||
case "teams":
|
||||
tableMatchName = "litellm_teamtable";
|
||||
break;
|
||||
case "users":
|
||||
tableMatchName = "litellm_usertable";
|
||||
break;
|
||||
// Add other direct table names if needed, or rely on a more generic match
|
||||
default:
|
||||
tableMatchName = selectedTableFilter; // Should not happen with current UI options
|
||||
}
|
||||
matchesTable = log.table_name?.toLowerCase() === tableMatchName;
|
||||
}
|
||||
|
||||
return matchesTeam && matchesKey && matchesObjectId && matchesAction && matchesTable;
|
||||
});
|
||||
}, [allLogsQuery.data, selectedTeamId, selectedKeyHash, objectIdSearch, selectedActionFilter, selectedTableFilter]);
|
||||
|
||||
const totalFilteredItems = completeFilteredLogs.length;
|
||||
const totalFilteredPages = Math.ceil(totalFilteredItems / pageSize) || 1;
|
||||
|
||||
const paginatedViewOfFilteredLogs = useMemo(() => {
|
||||
const start = (clientCurrentPage - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
return completeFilteredLogs.slice(start, end);
|
||||
}, [completeFilteredLogs, clientCurrentPage, pageSize]);
|
||||
|
||||
// Check if audit logs are empty (not loading and no data)
|
||||
const showAuditLogsInfo = !allLogsQuery.data || allLogsQuery.data.length === 0;
|
||||
|
||||
// Custom AuditLogsInfoMessage component
|
||||
const AuditLogsInfoMessage = ({ show }: { show: boolean }) => {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start mb-6">
|
||||
<div className="text-blue-500 mr-3 flex-shrink-0 mt-0.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-800">Audit Logs Not Available</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
To enable audit logging, add the following configuration to your LiteLLM proxy configuration file:
|
||||
</p>
|
||||
<pre className="mt-2 bg-white p-3 rounded border border-blue-200 text-xs font-mono overflow-auto">
|
||||
{`litellm_settings:
|
||||
store_audit_logs: true`}
|
||||
</pre>
|
||||
<p className="text-xs text-blue-700 mt-2">
|
||||
Note: This will only affect new requests after the configuration change and proxy restart.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSubComponent = useCallback(({ row }: { row: any }) => {
|
||||
const AuditLogRowExpansionPanel = ({ rowData }: { rowData: AuditLogEntry }) => {
|
||||
const { before_value, updated_values, table_name, action } = rowData;
|
||||
|
||||
const renderValue = (value: Record<string, any>, isKeyTable: boolean) => {
|
||||
if (!value || Object.keys(value).length === 0) return <Text>N/A</Text>;
|
||||
|
||||
if (isKeyTable) {
|
||||
const changedKeys = Object.keys(value);
|
||||
const knownKeyFields = ["token", "spend", "max_budget"];
|
||||
|
||||
const onlyKnownFieldsChanged = changedKeys.every((key) => knownKeyFields.includes(key));
|
||||
|
||||
if (onlyKnownFieldsChanged && changedKeys.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
{changedKeys.includes("token") && (
|
||||
<p>
|
||||
<strong>Token:</strong> {value.token || "N/A"}
|
||||
</p>
|
||||
)}
|
||||
{changedKeys.includes("spend") && (
|
||||
<p>
|
||||
<strong>Spend:</strong>{" "}
|
||||
{value.spend !== undefined ? `$${formatNumberWithCommas(value.spend, 6)}` : "N/A"}
|
||||
</p>
|
||||
)}
|
||||
{changedKeys.includes("max_budget") && (
|
||||
<p>
|
||||
<strong>Max Budget:</strong>{" "}
|
||||
{value.max_budget !== undefined ? `$${formatNumberWithCommas(value.max_budget, 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} />;
|
||||
}, []);
|
||||
{
|
||||
title: "Action",
|
||||
dataIndex: "action",
|
||||
key: "action",
|
||||
width: 100,
|
||||
render: (val: string) => (
|
||||
<Tag color={ACTION_COLOR[val] ?? "default"} className="capitalize">
|
||||
{val}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<span className="font-mono text-xs">{val}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Changed By",
|
||||
dataIndex: "changed_by",
|
||||
key: "changed_by",
|
||||
width: 200,
|
||||
render: (val: string) => <DefaultProxyAdminTag userId={val} />,
|
||||
},
|
||||
{
|
||||
title: "API Key (Hash)",
|
||||
dataIndex: "changed_by_api_key",
|
||||
key: "changed_by_api_key",
|
||||
width: 140,
|
||||
render: (val: string) =>
|
||||
val ? (
|
||||
<span className="font-mono text-xs">{val.slice(0, 12)}…</span>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!premiumUser) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", marginTop: "20px" }}>
|
||||
<h1 style={{ display: "block", marginBottom: "10px" }}>✨ Enterprise Feature.</h1>
|
||||
<Text style={{ display: "block", marginBottom: "10px" }}>
|
||||
<p style={{ display: "block", marginBottom: "10px" }}>
|
||||
This is a LiteLLM Enterprise feature, and requires a valid key to use.
|
||||
</Text>
|
||||
<Text style={{ display: "block", marginBottom: "20px", fontStyle: "italic" }}>
|
||||
</p>
|
||||
<p style={{ display: "block", marginBottom: "20px", fontStyle: "italic" }}>
|
||||
Here's a preview of what Audit Logs offer:
|
||||
</Text>
|
||||
</p>
|
||||
<img
|
||||
src={auditLogsPreviewImg}
|
||||
alt="Audit Logs Preview"
|
||||
@ -446,7 +185,6 @@ export default function AuditLogs({
|
||||
margin: "0 auto",
|
||||
}}
|
||||
onError={(e) => {
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4"></div>
|
||||
{/* <FilterComponent options={auditLogFilterOptions} onApplyFilters={handleFilterChange} onResetFilters={handleFilterReset} /> */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{/* Header */}
|
||||
<div className="border-b px-6 py-4">
|
||||
<h1 className="text-xl font-semibold py-4">Audit Logs</h1>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-semibold">Audit Logs</h1>
|
||||
</div>
|
||||
|
||||
{/* Show Audit Logs Info Message when no data */}
|
||||
<AuditLogsInfoMessage show={showAuditLogsInfo} />
|
||||
{/* Filters + pagination on same row */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Search
|
||||
placeholder="Object ID"
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
onSearch={(val) => { setObjectId(val); resetPage(); }}
|
||||
onChange={(e) => { if (!e.target.value) { setObjectId(""); resetPage(); } }}
|
||||
/>
|
||||
<Search
|
||||
placeholder="Changed By"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
onSearch={(val) => { setChangedBy(val); resetPage(); }}
|
||||
onChange={(e) => { if (!e.target.value) { setChangedBy(""); resetPage(); } }}
|
||||
/>
|
||||
<Search
|
||||
placeholder="Team ID"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
onSearch={(val) => { setTeamId(val); resetPage(); }}
|
||||
onChange={(e) => { if (!e.target.value) { setTeamId(""); resetPage(); } }}
|
||||
/>
|
||||
<Search
|
||||
placeholder="Key Hash"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
onSearch={(val) => { setKeyHash(val); resetPage(); }}
|
||||
onChange={(e) => { if (!e.target.value) { setKeyHash(""); resetPage(); } }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Actions"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: "Created", value: "created" },
|
||||
{ label: "Updated", value: "updated" },
|
||||
{ label: "Deleted", value: "deleted" },
|
||||
{ label: "Rotated", value: "rotated" },
|
||||
]}
|
||||
onChange={(val) => { setAction(val); resetPage(); }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Tables"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ label: "Keys", value: "LiteLLM_VerificationToken" },
|
||||
{ label: "Teams", value: "LiteLLM_TeamTable" },
|
||||
{ label: "Users", value: "LiteLLM_UserTable" },
|
||||
{ label: "Organizations", value: "LiteLLM_OrganizationTable" },
|
||||
{ label: "Models", value: "LiteLLM_ProxyModelTable" },
|
||||
]}
|
||||
onChange={(val) => { setTableName(val); resetPage(); }}
|
||||
/>
|
||||
|
||||
<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>
|
||||
{/* Pagination + refresh pushed to the right */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
icon={<ReloadOutlined spin={query.isFetching} />}
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
/>
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
showTotal={(t) => `${t} total`}
|
||||
showSizeChanger={false}
|
||||
size="small"
|
||||
onChange={(p) => setPage(p)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={auditLogColumns}
|
||||
data={paginatedViewOfFilteredLogs}
|
||||
renderSubComponent={renderSubComponent}
|
||||
getRowCanExpand={() => true}
|
||||
|
||||
{/* Table — pagination handled in header */}
|
||||
<Table<AuditLogEntry>
|
||||
columns={columns}
|
||||
dataSource={auditLogs}
|
||||
rowKey="id"
|
||||
loading={{
|
||||
spinning: query.isLoading,
|
||||
indicator: <Spin indicator={<LoadingOutlined spin />} size="small" />,
|
||||
}}
|
||||
size="small"
|
||||
pagination={false}
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleRowClick(record),
|
||||
style: { cursor: "pointer" },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AuditLogDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
log={selectedLog}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -714,7 +714,6 @@ export default function SpendLogsTable({
|
||||
accessToken={accessToken}
|
||||
isActive={activeTab === "audit logs"}
|
||||
premiumUser={premiumUser}
|
||||
allTeams={allTeams}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel><DeletedKeysPage /></TabPanel>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user