Merge pull request #34080 from BerriAI/litellm_/brave-bell-58da35

refactor(ui): migrate audit logs table onto shared DataTable
This commit is contained in:
yuneng-jiang 2026-07-21 10:27:48 -07:00 committed by GitHub
commit 925beda06d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 597 additions and 330 deletions

View file

@ -2,7 +2,7 @@ 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 { AuditLogEntry } from "../AuditLogsTableColumns";
import DefaultProxyAdminTag from "../../common_components/DefaultProxyAdminTag";
const { Text } = Typography;

View file

@ -0,0 +1,138 @@
import { useCallback, useState } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table";
import { resolveLogoSrc } from "@/lib/assetPaths";
import { uiAuditLogsCall } from "../networking";
import { AuditLogEntry } from "./AuditLogsTableColumns";
import { AuditLogsTable } from "./AuditLogsTable";
import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer";
interface AuditLogsProps {
accessToken: string | null;
token: string | null;
userRole: string | null;
userID: string | null;
isActive: boolean;
premiumUser: boolean;
}
const asset_logos_folder = "/ui/assets/";
const auditLogsPreviewImg = `${asset_logos_folder}audit-logs-preview.png`;
const PAGE_SIZE = 50;
interface AuditLogsResponse {
audit_logs: AuditLogEntry[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export default function AuditLogsPanel({
userID,
userRole,
token,
accessToken,
isActive,
premiumUser,
}: AuditLogsProps) {
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [selectedLog, setSelectedLog] = useState<AuditLogEntry | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const getFilterValue = (columnId: string): string | undefined => {
const entry = columnFilters.find((filter) => filter.id === columnId);
return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined;
};
const canQueryAuditLogs = !!accessToken && !!token && !!userRole && !!userID && isActive && premiumUser;
const query = useQuery<AuditLogsResponse>({
queryKey: ["audit_logs", pagination.pageIndex, pagination.pageSize, columnFilters],
queryFn: async () => {
if (!accessToken) {
return { audit_logs: [], total: 0, page: 1, page_size: pagination.pageSize, total_pages: 0 };
}
return uiAuditLogsCall({
accessToken,
page: pagination.pageIndex + 1,
page_size: pagination.pageSize,
params: {
object_id: getFilterValue("object_id"),
changed_by: getFilterValue("changed_by"),
object_key_hash: getFilterValue("key_hash"),
object_team_id: getFilterValue("team_id"),
action: getFilterValue("action"),
table_name: getFilterValue("table_name"),
sort_by: "updated_at",
sort_order: "desc",
},
});
},
enabled: canQueryAuditLogs,
placeholderData: keepPreviousData,
});
const handleColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>((updaterOrValue) => {
setColumnFilters(updaterOrValue);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
const handleViewLog = useCallback((log: AuditLogEntry) => {
setSelectedLog(log);
setDrawerOpen(true);
}, []);
if (!premiumUser) {
return (
<div style={{ textAlign: "center", marginTop: "20px" }}>
<h1 style={{ display: "block", marginBottom: "10px" }}> Enterprise Feature.</h1>
<p style={{ display: "block", marginBottom: "10px" }}>
This is a LiteLLM Enterprise feature, and requires a valid key to use.
</p>
<p style={{ display: "block", marginBottom: "20px", fontStyle: "italic" }}>
Here&apos;s a preview of what Audit Logs offer:
</p>
<img
src={resolveLogoSrc(auditLogsPreviewImg)}
alt="Audit Logs Preview"
style={{
maxWidth: "100%",
maxHeight: "700px",
borderRadius: "8px",
boxShadow: "0 4px 8px rgba(0,0,0,0.1)",
margin: "0 auto",
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
);
}
return (
<>
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">Audit Logs</h1>
</div>
<AuditLogsTable
data={query.data?.audit_logs ?? []}
rowCount={query.data?.total ?? 0}
isLoading={query.isLoading}
isRefreshing={query.isFetching}
pagination={pagination}
onPaginationChange={setPagination}
columnFilters={columnFilters}
onColumnFiltersChange={handleColumnFiltersChange}
onRefresh={() => query.refetch()}
onViewLog={handleViewLog}
/>
<AuditLogDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} log={selectedLog} />
</>
);
}

View file

@ -0,0 +1,146 @@
import type { ColumnFiltersState, PaginationState } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { AuditLogsTable } from "./AuditLogsTable";
import type { AuditLogEntry } from "./AuditLogsTableColumns";
const ROWS: AuditLogEntry[] = [
{
id: "log-1",
updated_at: "2026-07-20T12:00:00Z",
changed_by: "default_user_id",
changed_by_api_key: "sk-hash-abc",
action: "created",
table_name: "LiteLLM_TeamTable",
object_id: "team-obj-123",
before_value: {},
updated_values: { foo: "bar" },
},
{
id: "log-2",
updated_at: "2026-07-20T11:00:00Z",
changed_by: "user-42",
changed_by_api_key: "sk-hash-def",
action: "deleted",
table_name: "LiteLLM_UserTable",
object_id: "user-obj-456",
before_value: { a: 1 },
updated_values: {},
},
];
const FIRST_PAGE: PaginationState = { pageIndex: 0, pageSize: 50 };
function renderTable(overrides: Partial<React.ComponentProps<typeof AuditLogsTable>> = {}) {
const props: React.ComponentProps<typeof AuditLogsTable> = {
data: ROWS,
rowCount: ROWS.length,
isLoading: false,
isRefreshing: false,
pagination: FIRST_PAGE,
onPaginationChange: vi.fn(),
columnFilters: [],
onColumnFiltersChange: vi.fn(),
onRefresh: vi.fn(),
onViewLog: vi.fn(),
...overrides,
};
render(<AuditLogsTable {...props} />);
return props;
}
describe("AuditLogsTable", () => {
it("renders each audit column with the migrated shared cells", () => {
renderTable();
// Action -> StatusBadge with a capitalized label
expect(screen.getByText("Created")).toBeInTheDocument();
expect(screen.getByText("Deleted")).toBeInTheDocument();
// Table name -> display mapping
expect(screen.getByText("Teams")).toBeInTheDocument();
expect(screen.getByText("Users")).toBeInTheDocument();
// Changed By -> DefaultProxyAdminTag (default_user_id becomes a labeled tag; other ids stay raw)
expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument();
expect(screen.getByText("user-42")).toBeInTheDocument();
// Object ID + API key hash
expect(screen.getByText("team-obj-123")).toBeInTheDocument();
expect(screen.getByText("sk-hash-abc")).toBeInTheDocument();
});
it("opens the detail drawer from the Object ID identity cell with the full row", async () => {
const user = userEvent.setup();
const props = renderTable();
await user.click(screen.getByText("team-obj-123"));
expect(props.onViewLog).toHaveBeenCalledTimes(1);
expect(props.onViewLog).toHaveBeenCalledWith(ROWS[0]);
});
it("drives the shared footer from the server rowCount and reports page changes", async () => {
const user = userEvent.setup();
const onPaginationChange = vi.fn();
renderTable({ rowCount: 120, onPaginationChange });
// ceil(120 / 50) = 3 pages, proving rowCount (not data length) feeds the footer
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 3");
await user.click(screen.getByTestId("pagination-next"));
expect(onPaginationChange).toHaveBeenCalledTimes(1);
});
it("shows skeleton rows while loading and no data rows", () => {
renderTable({ isLoading: true, data: [] });
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
expect(screen.queryByText("No audit logs yet")).toBeNull();
});
it("uses a distinct empty state for unfiltered vs filtered-empty results", () => {
const { unmount } = render(
<AuditLogsTable
data={[]}
rowCount={0}
isLoading={false}
isRefreshing={false}
pagination={FIRST_PAGE}
onPaginationChange={vi.fn()}
columnFilters={[]}
onColumnFiltersChange={vi.fn()}
onRefresh={vi.fn()}
onViewLog={vi.fn()}
/>,
);
expect(screen.getByText("No audit logs yet")).toBeInTheDocument();
unmount();
renderTable({ data: [], rowCount: 0, columnFilters: [{ id: "action", value: "created" }] });
expect(screen.getByText("No matching audit logs")).toBeInTheDocument();
});
it("renders active filter chips with human-readable labels", () => {
const filters: ColumnFiltersState = [{ id: "action", value: "created" }];
renderTable({ columnFilters: filters });
const chip = screen.getByTestId("filter-chip-action");
expect(chip).toHaveTextContent("Action:");
expect(chip).toHaveTextContent("Created");
});
it("commits a text filter through the filter drawer and reports it to the parent", async () => {
const user = userEvent.setup();
const onColumnFiltersChange = vi.fn();
renderTable({ onColumnFiltersChange });
await user.click(screen.getByTestId("datatable-filters-trigger"));
await user.type(await screen.findByPlaceholderText("Enter object ID…"), "obj-9");
await user.click(screen.getByTestId("filter-drawer-apply"));
expect(onColumnFiltersChange).toHaveBeenCalledTimes(1);
const arg = onColumnFiltersChange.mock.calls[0][0];
const committed = typeof arg === "function" ? arg([]) : arg;
expect(committed).toEqual([{ id: "object_id", value: "obj-9" }]);
});
});

View file

@ -0,0 +1,208 @@
"use client";
import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table";
import { ScrollText } from "lucide-react";
import { useMemo, useState } from "react";
import {
DataTable,
DataTableFilterDrawer,
DataTableFilterField,
DataTableToolbar,
} from "@/components/shared/DataTable";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AUDIT_TABLE_NAME_DISPLAY, AuditLogEntry, getAuditLogsTableColumns } from "./AuditLogsTableColumns";
interface AuditLogsTableProps {
data: AuditLogEntry[];
rowCount: number;
isLoading: boolean;
isRefreshing: boolean;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
columnFilters: ColumnFiltersState;
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>;
onRefresh: () => void;
onViewLog: (log: AuditLogEntry) => void;
}
const ALL_VALUE = "all";
const ACTION_OPTIONS = [
{ label: "Created", value: "created" },
{ label: "Updated", value: "updated" },
{ label: "Deleted", value: "deleted" },
{ label: "Rotated", value: "rotated" },
] as const;
const TABLE_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" },
] as const;
const FILTER_LABELS: Record<string, string> = {
object_id: "Object ID",
changed_by: "Changed By",
team_id: "Team ID",
key_hash: "Key Hash",
action: "Action",
table_name: "Table",
};
const formatFilterValue = (columnId: string, value: unknown): string => {
const raw = String(value);
if (columnId === "action") {
return ACTION_OPTIONS.find((option) => option.value === raw)?.label ?? raw;
}
if (columnId === "table_name") {
return AUDIT_TABLE_NAME_DISPLAY[raw] ?? raw;
}
return raw;
};
function AuditLogsEmptyState({ filtered }: { filtered: boolean }) {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<ScrollText className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">
{filtered ? "No matching audit logs" : "No audit logs yet"}
</div>
<div className="max-w-xs text-center text-sm text-muted-foreground">
{filtered
? "No audit log entries match your filters."
: "Administrative changes to keys, teams, users, and models will appear here."}
</div>
</div>
);
}
export function AuditLogsTable({
data,
rowCount,
isLoading,
isRefreshing,
pagination,
onPaginationChange,
columnFilters,
onColumnFiltersChange,
onRefresh,
onViewLog,
}: AuditLogsTableProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }), [onViewLog]);
return (
<DataTable
data={data}
columns={columns}
getRowId={(row) => row.id}
paginationMode="server"
pagination={pagination}
onPaginationChange={onPaginationChange}
rowCount={rowCount}
filterMode="server"
columnFilters={columnFilters}
onColumnFiltersChange={onColumnFiltersChange}
isLoading={isLoading}
loadingMessage="Loading audit logs…"
noDataMessage={<AuditLogsEmptyState filtered={columnFilters.length > 0} />}
size="compact"
toolbar={(table) => (
<>
<DataTableToolbar
table={table}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
onOpenFilters={() => setFiltersOpen(true)}
filterLabels={FILTER_LABELS}
formatFilterValue={formatFilterValue}
showViewOptions={false}
/>
<DataTableFilterDrawer
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down audit log entries"
>
{({ get, set }) => (
<>
<DataTableFilterField label="Object ID">
<Input
value={(get("object_id") as string) ?? ""}
onChange={(event) => set("object_id", event.target.value)}
placeholder="Enter object ID…"
/>
</DataTableFilterField>
<DataTableFilterField label="Changed By">
<Input
value={(get("changed_by") as string) ?? ""}
onChange={(event) => set("changed_by", event.target.value)}
placeholder="Enter user ID…"
/>
</DataTableFilterField>
<DataTableFilterField label="Team ID">
<Input
value={(get("team_id") as string) ?? ""}
onChange={(event) => set("team_id", event.target.value)}
placeholder="Enter team ID…"
/>
</DataTableFilterField>
<DataTableFilterField label="Key Hash">
<Input
value={(get("key_hash") as string) ?? ""}
onChange={(event) => set("key_hash", event.target.value)}
placeholder="Enter key hash…"
/>
</DataTableFilterField>
<DataTableFilterField label="Action">
<Select
value={(get("action") as string) ?? ALL_VALUE}
onValueChange={(value) => set("action", value === ALL_VALUE ? undefined : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Actions" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>All Actions</SelectItem>
{ACTION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</DataTableFilterField>
<DataTableFilterField label="Table">
<Select
value={(get("table_name") as string) ?? ALL_VALUE}
onValueChange={(value) => set("table_name", value === ALL_VALUE ? undefined : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Tables" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>All Tables</SelectItem>
{TABLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</DataTableFilterField>
</>
)}
</DataTableFilterDrawer>
</>
)}
/>
);
}

View file

@ -0,0 +1,102 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DateCell, IdCell, IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
export type AuditLogEntry = {
id: string;
updated_at: string;
changed_by: string;
changed_by_api_key: string;
action: string;
table_name: string;
object_id: string;
before_value: Record<string, unknown>;
updated_values: Record<string, unknown>;
};
export const AUDIT_TABLE_NAME_DISPLAY: Record<string, string> = {
LiteLLM_VerificationToken: "Keys",
LiteLLM_TeamTable: "Teams",
LiteLLM_UserTable: "Users",
LiteLLM_OrganizationTable: "Organizations",
LiteLLM_ProxyModelTable: "Models",
};
const ACTION_TONE: Record<string, StatusTone> = {
created: "success",
updated: "info",
deleted: "error",
rotated: "warning",
};
const capitalize = (value: string): string => (value ? value.charAt(0).toUpperCase() + value.slice(1) : value);
interface AuditLogsTableColumnsDeps {
onViewLog: (log: AuditLogEntry) => void;
}
export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDeps): ColumnDef<AuditLogEntry>[] => [
{
id: "updated_at",
accessorKey: "updated_at",
header: "Timestamp",
size: 200,
enableSorting: false,
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
},
{
id: "action",
accessorKey: "action",
header: "Action",
size: 110,
enableSorting: false,
cell: ({ row }) => (
<StatusBadge tone={ACTION_TONE[row.original.action] ?? "neutral"} label={capitalize(row.original.action)} />
),
},
{
id: "table_name",
accessorKey: "table_name",
header: "Table",
size: 130,
enableSorting: false,
cell: ({ row }) => (
<span className="text-sm">{AUDIT_TABLE_NAME_DISPLAY[row.original.table_name] ?? row.original.table_name}</span>
),
},
{
id: "object_id",
accessorKey: "object_id",
header: "Object ID",
minSize: 220,
enableSorting: false,
cell: ({ row }) => (
<IdentityCell
title={row.original.object_id}
titleClassName="font-mono text-xs font-normal text-primary"
className="max-w-72"
onClick={() => onViewLog(row.original)}
/>
),
},
{
id: "changed_by",
accessorKey: "changed_by",
header: "Changed By",
size: 200,
enableSorting: false,
cell: ({ row }) => <DefaultProxyAdminTag userId={row.original.changed_by} />,
},
{
id: "changed_by_api_key",
accessorKey: "changed_by_api_key",
header: "API Key (Hash)",
size: 160,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.changed_by_api_key} variant="plain" />,
},
];

View file

@ -1,315 +0,0 @@
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 { resolveLogoSrc } from "@/lib/assetPaths";
import { DateCell, IdCell } from "@/components/shared/table_cells";
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;
token: string | null;
userRole: string | null;
userID: string | null;
isActive: boolean;
premiumUser: boolean;
}
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, token, accessToken, isActive, premiumUser }: AuditLogsProps) {
const [page, setPage] = useState(1);
// 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);
// 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 { audit_logs: [], total: 0, page: 1, page_size: PAGE_SIZE, total_pages: 0 };
}
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,
placeholderData: keepPreviousData,
});
const resetPage = () => setPage(1);
const handleRowClick = (log: AuditLogEntry) => {
setSelectedLog(log);
setDrawerOpen(true);
};
const columns: ColumnsType<AuditLogEntry> = [
{
title: "Timestamp",
dataIndex: "updated_at",
key: "updated_at",
width: 200,
render: (val: string) => <DateCell value={val} />,
},
{
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) => <IdCell value={val} variant="plain" truncate={false} />,
},
{
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) => <IdCell value={val} variant="plain" />,
},
];
if (!premiumUser) {
return (
<div style={{ textAlign: "center", marginTop: "20px" }}>
<h1 style={{ display: "block", marginBottom: "10px" }}> Enterprise Feature.</h1>
<p style={{ display: "block", marginBottom: "10px" }}>
This is a LiteLLM Enterprise feature, and requires a valid key to use.
</p>
<p style={{ display: "block", marginBottom: "20px", fontStyle: "italic" }}>
Here&apos;s a preview of what Audit Logs offer:
</p>
<img
src={resolveLogoSrc(auditLogsPreviewImg)}
alt="Audit Logs Preview"
style={{
maxWidth: "100%",
maxHeight: "700px",
borderRadius: "8px",
boxShadow: "0 4px 8px rgba(0,0,0,0.1)",
margin: "0 auto",
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
);
}
const auditLogs: AuditLogEntry[] = query.data?.audit_logs ?? [];
const total: number = query.data?.total ?? 0;
return (
<>
<div className="bg-white rounded-lg shadow-sm">
{/* Header */}
<div className="border-b px-6 py-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">Audit Logs</h1>
</div>
{/* 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();
}}
/>
{/* 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>
{/* 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} />
</>
);
}

View file

@ -539,15 +539,3 @@ const CollapsibleJsonCell = ({ jsonData }: { jsonData: any }) => {
</div>
);
};
export type AuditLogEntry = {
id: string;
updated_at: string;
changed_by: string;
changed_by_api_key: string;
action: string;
table_name: string;
object_id: string;
before_value: Record<string, any>;
updated_values: Record<string, any>;
};

View file

@ -8,7 +8,7 @@ import { KeyResponse } from "../key_team_helpers/key_list";
import FilterComponent from "../molecules/filter";
import { keyInfoV1Call } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import AuditLogs from "./audit_logs";
import AuditLogsPanel from "./AuditLogsPanel";
import { createColumns, LogEntry, type LogsSortField } from "./columns";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
import { getLogFilterOptions } from "./filter_options";
@ -296,7 +296,7 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
)}
</TabPanel>
<TabPanel>
<AuditLogs
<AuditLogsPanel
userID={userID}
userRole={userRole}
token={token}