mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #34080 from BerriAI/litellm_/brave-bell-58da35
refactor(ui): migrate audit logs table onto shared DataTable
This commit is contained in:
commit
925beda06d
8 changed files with 597 additions and 330 deletions
|
|
@ -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;
|
||||
|
|
|
|||
138
ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx
Normal file
138
ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx
Normal 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'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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
208
ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx
Normal file
208
ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />,
|
||||
},
|
||||
];
|
||||
|
|
@ -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'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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue