mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
feat(ui): show aliases and entity links in audit logs table
The audit logs tab only showed raw ids: no aliases for the audited object, no email for the acting user, no alias for the acting key, and nothing was clickable, so tracing an entry back to its key, team, user, or org meant copying ids around by hand. The team filter also only matched exact team ids buried in the change JSON, and there was no way to narrow by date Renders the new nullable GET /audit enrichment fields (object_alias, changed_by_user_email, changed_by_key_alias) beside the ids they describe, with an em-dash fallback until the backend PR that adds them lands. Object ids and changed-by users now link to their entity detail pages through the shared BadgeLink and new entityLinks href builders, the row itself opens the audit detail drawer, the Team filter accepts an id or alias and sends the new object_team param, and a date range filter feeds the existing start_date/end_date params as UTC timestamps The users page gains a ?user= deep link (same nuqs pattern as the teams and organizations pages) so audit links can land on a specific user's detail view
This commit is contained in:
parent
6d604d27a6
commit
6c2fc93fd1
11 changed files with 451 additions and 36 deletions
|
|
@ -2,6 +2,7 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NuqsTestingAdapter } from "nuqs/adapters/testing";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
|
|
@ -77,11 +78,13 @@ const defaultProps = {
|
|||
teams: [],
|
||||
};
|
||||
|
||||
const renderDashboard = () =>
|
||||
const renderDashboard = (searchParams?: Record<string, string>) =>
|
||||
render(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<ViewUserDashboard {...defaultProps} />
|
||||
</QueryClientProvider>,
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<ViewUserDashboard {...defaultProps} />
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
describe("ViewUserDashboard", () => {
|
||||
|
|
@ -142,6 +145,12 @@ describe("ViewUserDashboard", () => {
|
|||
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open the detail view directly from a ?user= deep link", async () => {
|
||||
renderDashboard({ user: "user-1" });
|
||||
|
||||
expect(await screen.findByTestId("user-info-view")).toHaveTextContent("detail:user-1:false");
|
||||
});
|
||||
|
||||
it("should open the detail view in edit mode from the row actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDashboard();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "antd";
|
||||
|
|
@ -72,7 +73,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false);
|
||||
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useQueryState("user", parseAsString.withOptions({ history: "push" }));
|
||||
const [openInEditMode, setOpenInEditMode] = useState(false);
|
||||
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
|
|
@ -139,15 +140,18 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
setRowSelection({});
|
||||
}, []);
|
||||
|
||||
const handleUserClick = useCallback((userId: string, openInEdit: boolean = false) => {
|
||||
setSelectedUserId(userId);
|
||||
setOpenInEditMode(openInEdit);
|
||||
}, []);
|
||||
const handleUserClick = useCallback(
|
||||
(userId: string, openInEdit: boolean = false) => {
|
||||
setSelectedUserId(userId);
|
||||
setOpenInEditMode(openInEdit);
|
||||
},
|
||||
[setSelectedUserId],
|
||||
);
|
||||
|
||||
const handleCloseUserInfo = useCallback(() => {
|
||||
setSelectedUserId(null);
|
||||
setOpenInEditMode(false);
|
||||
}, []);
|
||||
}, [setSelectedUserId]);
|
||||
|
||||
const handleDelete = useCallback((user: UserInfo) => {
|
||||
setUserToDelete(user);
|
||||
|
|
|
|||
|
|
@ -6447,7 +6447,10 @@ interface UiAuditLogsParams {
|
|||
changed_by?: string;
|
||||
changed_by_api_key?: string;
|
||||
object_team_id?: string;
|
||||
object_team?: string;
|
||||
object_key_hash?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: "asc" | "desc";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AuditLogEntry } from "../AuditLogsTableColumns";
|
||||
import { AuditLogDrawer } from "./AuditLogDrawer";
|
||||
|
||||
const BASE_LOG: AuditLogEntry = {
|
||||
id: "log-1",
|
||||
updated_at: "2026-07-20T12:00:00Z",
|
||||
changed_by: "user-42",
|
||||
changed_by_api_key: "sk-hash-abc",
|
||||
action: "updated",
|
||||
table_name: "LiteLLM_TeamTable",
|
||||
object_id: "team-obj-123",
|
||||
before_value: { spend: 1 },
|
||||
updated_values: { spend: 2 },
|
||||
};
|
||||
|
||||
const ENRICHED_LOG: AuditLogEntry = {
|
||||
...BASE_LOG,
|
||||
object_alias: "prod-team",
|
||||
changed_by_user_email: "admin@example.com",
|
||||
changed_by_key_alias: "admin-key",
|
||||
};
|
||||
|
||||
function renderDrawer(log: AuditLogEntry) {
|
||||
render(<AuditLogDrawer open={true} onClose={vi.fn()} log={log} />);
|
||||
}
|
||||
|
||||
describe("AuditLogDrawer", () => {
|
||||
it("shows the alias, changed-by email, and key alias alongside the raw id and hash", () => {
|
||||
renderDrawer(ENRICHED_LOG);
|
||||
|
||||
expect(screen.getByText("prod-team")).toBeInTheDocument();
|
||||
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("user-42")).toBeInTheDocument();
|
||||
expect(screen.getByText("admin-key")).toBeInTheDocument();
|
||||
expect(screen.getByText("sk-hash-abc")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back gracefully when the enrichment fields are absent", () => {
|
||||
renderDrawer(BASE_LOG);
|
||||
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
expect(screen.getByText("user-42")).toBeInTheDocument();
|
||||
expect(screen.getByText("sk-hash-abc")).toBeInTheDocument();
|
||||
expect(screen.queryByText("admin@example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -229,14 +229,26 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
|
|||
</Text>
|
||||
}
|
||||
/>
|
||||
<MetadataRow label="Changed By" value={<DefaultProxyAdminTag userId={log.changed_by} />} />
|
||||
<MetadataRow label="Alias" value={log.object_alias || "—"} />
|
||||
<MetadataRow
|
||||
label="Changed By"
|
||||
value={
|
||||
<span className="flex flex-col gap-0.5">
|
||||
{!!log.changed_by_user_email && <span>{log.changed_by_user_email}</span>}
|
||||
<DefaultProxyAdminTag userId={log.changed_by} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
{!!log.changed_by_key_alias && <span>{log.changed_by_key_alias}</span>}
|
||||
<Text copyable className="font-mono text-xs break-all">
|
||||
{log.changed_by_api_key}
|
||||
</Text>
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import moment from "moment";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
|
||||
import AuditLogsPanel from "./AuditLogsPanel";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const uiAuditLogsCall = vi.fn();
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
uiAuditLogsCall: (...args: unknown[]) => uiAuditLogsCall(...args),
|
||||
serverRootPath: "",
|
||||
}));
|
||||
|
||||
const EMPTY_RESPONSE = { audit_logs: [], total: 0, page: 1, page_size: 50, total_pages: 0 };
|
||||
|
||||
function renderPanel() {
|
||||
return renderWithProviders(
|
||||
<AuditLogsPanel
|
||||
accessToken="test-access-token"
|
||||
token="test-token"
|
||||
userRole="Admin"
|
||||
userID="user-1"
|
||||
isActive={true}
|
||||
premiumUser={true}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async function applyFilter(fill: () => Promise<void> | void) {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
await fill();
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
}
|
||||
|
||||
function lastCallParams(): Record<string, unknown> {
|
||||
const lastCall = uiAuditLogsCall.mock.calls.at(-1)?.[0] as { params: Record<string, unknown> };
|
||||
return lastCall.params;
|
||||
}
|
||||
|
||||
describe("AuditLogsPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
uiAuditLogsCall.mockResolvedValue(EMPTY_RESPONSE);
|
||||
});
|
||||
|
||||
it("sends the team filter as the object_team query param", async () => {
|
||||
renderPanel();
|
||||
await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled());
|
||||
|
||||
const user = userEvent.setup();
|
||||
await applyFilter(async () => {
|
||||
await user.type(await screen.findByPlaceholderText("Team ID or alias…"), "ml-platform");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastCallParams().object_team).toBe("ml-platform");
|
||||
});
|
||||
expect(lastCallParams().object_team_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends the date range as UTC start_date and end_date query params", async () => {
|
||||
renderPanel();
|
||||
await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled());
|
||||
|
||||
await applyFilter(async () => {
|
||||
fireEvent.change(await screen.findByTestId("audit-filter-start-date"), {
|
||||
target: { value: "2026-07-01T10:00" },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("audit-filter-end-date"), { target: { value: "2026-07-02T18:30" } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastCallParams().start_date).toBe(moment("2026-07-01T10:00").utc().format("YYYY-MM-DD HH:mm:ss"));
|
||||
});
|
||||
expect(lastCallParams().end_date).toBe(moment("2026-07-02T18:30").utc().format("YYYY-MM-DD HH:mm:ss"));
|
||||
});
|
||||
|
||||
it("keeps the pre-existing filters mapped to their unchanged query params", async () => {
|
||||
renderPanel();
|
||||
await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled());
|
||||
|
||||
const user = userEvent.setup();
|
||||
await applyFilter(async () => {
|
||||
await user.type(await screen.findByPlaceholderText("Enter object ID…"), "obj-1");
|
||||
await user.type(screen.getByPlaceholderText("Enter user ID…"), "changer-1");
|
||||
await user.type(screen.getByPlaceholderText("Enter key hash…"), "hash-1");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastCallParams().object_id).toBe("obj-1");
|
||||
});
|
||||
expect(lastCallParams().changed_by).toBe("changer-1");
|
||||
expect(lastCallParams().object_key_hash).toBe("hash-1");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import moment from "moment";
|
||||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
import { uiAuditLogsCall } from "../networking";
|
||||
import { AuditLogEntry } from "./AuditLogsTableColumns";
|
||||
|
|
@ -21,6 +22,9 @@ const auditLogsPreviewImg = `${asset_logos_folder}audit-logs-preview.png`;
|
|||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const toUtcTimestamp = (value: string | undefined): string | undefined =>
|
||||
value === undefined ? undefined : moment(value).utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
interface AuditLogsResponse {
|
||||
audit_logs: AuditLogEntry[];
|
||||
total: number;
|
||||
|
|
@ -63,9 +67,11 @@ export default function AuditLogsPanel({
|
|||
object_id: getFilterValue("object_id"),
|
||||
changed_by: getFilterValue("changed_by"),
|
||||
object_key_hash: getFilterValue("key_hash"),
|
||||
object_team_id: getFilterValue("team_id"),
|
||||
object_team: getFilterValue("object_team"),
|
||||
action: getFilterValue("action"),
|
||||
table_name: getFilterValue("table_name"),
|
||||
start_date: toUtcTimestamp(getFilterValue("start_date")),
|
||||
end_date: toUtcTimestamp(getFilterValue("end_date")),
|
||||
sort_by: "updated_at",
|
||||
sort_order: "desc",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
import type { ColumnFiltersState, PaginationState } from "@tanstack/react-table";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, 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";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
serverRootPath: "",
|
||||
}));
|
||||
|
||||
const ROWS: AuditLogEntry[] = [
|
||||
{
|
||||
id: "log-1",
|
||||
|
|
@ -17,6 +25,9 @@ const ROWS: AuditLogEntry[] = [
|
|||
object_id: "team-obj-123",
|
||||
before_value: {},
|
||||
updated_values: { foo: "bar" },
|
||||
object_alias: "prod-team",
|
||||
changed_by_user_email: null,
|
||||
changed_by_key_alias: null,
|
||||
},
|
||||
{
|
||||
id: "log-2",
|
||||
|
|
@ -28,9 +39,21 @@ const ROWS: AuditLogEntry[] = [
|
|||
object_id: "user-obj-456",
|
||||
before_value: { a: 1 },
|
||||
updated_values: {},
|
||||
changed_by_user_email: "admin@example.com",
|
||||
changed_by_key_alias: "admin-key",
|
||||
},
|
||||
];
|
||||
|
||||
const makeRow = (overrides: Partial<AuditLogEntry> & Pick<AuditLogEntry, "id" | "table_name" | "object_id">) => ({
|
||||
updated_at: "2026-07-20T10:00:00Z",
|
||||
changed_by: "user-42",
|
||||
changed_by_api_key: "sk-hash-xyz",
|
||||
action: "updated",
|
||||
before_value: {},
|
||||
updated_values: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const FIRST_PAGE: PaginationState = { pageIndex: 0, pageSize: 50 };
|
||||
|
||||
function renderTable(overrides: Partial<React.ComponentProps<typeof AuditLogsTable>> = {}) {
|
||||
|
|
@ -69,16 +92,81 @@ describe("AuditLogsTable", () => {
|
|||
expect(screen.getByText("sk-hash-abc")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the detail drawer from the Object ID identity cell with the full row", async () => {
|
||||
it("renders the alias column with the object alias and an em-dash fallback when absent", () => {
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByText("prod-team")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("—").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows the changed-by email and key alias while keeping the raw id and hash visible", () => {
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("user-42")).toBeInTheDocument();
|
||||
expect(screen.getByText("admin-key")).toBeInTheDocument();
|
||||
expect(screen.getByText("sk-hash-def")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links object ids and changed-by users to their entity detail pages", () => {
|
||||
renderTable({
|
||||
data: [
|
||||
...ROWS,
|
||||
makeRow({ id: "log-3", table_name: "LiteLLM_VerificationToken", object_id: "keyhash-1" }),
|
||||
makeRow({ id: "log-4", table_name: "LiteLLM_OrganizationTable", object_id: "org-1" }),
|
||||
makeRow({ id: "log-5", table_name: "LiteLLM_ProxyModelTable", object_id: "model-1" }),
|
||||
makeRow({ id: "log-6", table_name: "SomeUnknownTable", object_id: "mystery-1" }),
|
||||
],
|
||||
rowCount: 6,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("link", { name: "team-obj-123" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/teams?team=team-obj-123"),
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "user-obj-456" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/users?user=user-obj-456"),
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "keyhash-1" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/api-keys?key=keyhash-1"),
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "org-1" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/organizations?org=org-1"),
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "model-1" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/models-and-endpoints?model=model-1"),
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: "admin@example.com" })[0]).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/users?user=user-42"),
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: "mystery-1" })).toBeNull();
|
||||
expect(screen.getByText("mystery-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the detail drawer from a row click with the full row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderTable();
|
||||
|
||||
await user.click(screen.getByText("team-obj-123"));
|
||||
await user.click(screen.getByText("Teams"));
|
||||
|
||||
expect(props.onViewLog).toHaveBeenCalledTimes(1);
|
||||
expect(props.onViewLog).toHaveBeenCalledWith(ROWS[0]);
|
||||
});
|
||||
|
||||
it("does not open the drawer when the object id entity link is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderTable();
|
||||
|
||||
await user.click(screen.getByRole("link", { name: "team-obj-123" }));
|
||||
|
||||
expect(props.onViewLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drives the shared footer from the server rowCount and reports page changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPaginationChange = vi.fn();
|
||||
|
|
@ -143,4 +231,38 @@ describe("AuditLogsTable", () => {
|
|||
const committed = typeof arg === "function" ? arg([]) : arg;
|
||||
expect(committed).toEqual([{ id: "object_id", value: "obj-9" }]);
|
||||
});
|
||||
|
||||
it("commits the team filter as an object_team filter accepting id or alias", 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("Team ID or alias…"), "ml-platform");
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
const arg = onColumnFiltersChange.mock.calls[0][0];
|
||||
const committed = typeof arg === "function" ? arg([]) : arg;
|
||||
expect(committed).toEqual([{ id: "object_team", value: "ml-platform" }]);
|
||||
});
|
||||
|
||||
it("commits the date range as start_date and end_date filters", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onColumnFiltersChange = vi.fn();
|
||||
renderTable({ onColumnFiltersChange });
|
||||
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
fireEvent.change(await screen.findByTestId("audit-filter-start-date"), { target: { value: "2026-07-01T10:00" } });
|
||||
fireEvent.change(screen.getByTestId("audit-filter-end-date"), { target: { value: "2026-07-02T18:30" } });
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
const arg = onColumnFiltersChange.mock.calls[0][0];
|
||||
const committed = typeof arg === "function" ? arg([]) : arg;
|
||||
expect(committed).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: "start_date", value: "2026-07-01T10:00" },
|
||||
{ id: "end_date", value: "2026-07-02T18:30" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import { ScrollText } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
|
|
@ -46,9 +47,11 @@ const TABLE_OPTIONS = [
|
|||
] as const;
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
start_date: "Start Date",
|
||||
end_date: "End Date",
|
||||
object_id: "Object ID",
|
||||
changed_by: "Changed By",
|
||||
team_id: "Team ID",
|
||||
object_team: "Team",
|
||||
key_hash: "Key Hash",
|
||||
action: "Action",
|
||||
table_name: "Table",
|
||||
|
|
@ -62,6 +65,9 @@ const formatFilterValue = (columnId: string, value: unknown): string => {
|
|||
if (columnId === "table_name") {
|
||||
return AUDIT_TABLE_NAME_DISPLAY[raw] ?? raw;
|
||||
}
|
||||
if (columnId === "start_date" || columnId === "end_date") {
|
||||
return moment(raw).format("MMM D, YYYY HH:mm");
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
|
|
@ -96,7 +102,7 @@ export function AuditLogsTable({
|
|||
onViewLog,
|
||||
}: AuditLogsTableProps) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }), [onViewLog]);
|
||||
const columns = useMemo(() => getAuditLogsTableColumns(), []);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
@ -114,6 +120,7 @@ export function AuditLogsTable({
|
|||
loadingMessage="Loading audit logs…"
|
||||
noDataMessage={<AuditLogsEmptyState filtered={columnFilters.length > 0} />}
|
||||
size="compact"
|
||||
onRowClick={onViewLog}
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
|
|
@ -134,6 +141,23 @@ export function AuditLogsTable({
|
|||
>
|
||||
{({ get, set }) => (
|
||||
<>
|
||||
<DataTableFilterField label="Date Range">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
data-testid="audit-filter-start-date"
|
||||
value={(get("start_date") as string) ?? ""}
|
||||
onChange={(event) => set("start_date", event.target.value)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">to</span>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
data-testid="audit-filter-end-date"
|
||||
value={(get("end_date") as string) ?? ""}
|
||||
onChange={(event) => set("end_date", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Object ID">
|
||||
<Input
|
||||
value={(get("object_id") as string) ?? ""}
|
||||
|
|
@ -148,11 +172,11 @@ export function AuditLogsTable({
|
|||
placeholder="Enter user ID…"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Team ID">
|
||||
<DataTableFilterField label="Team">
|
||||
<Input
|
||||
value={(get("team_id") as string) ?? ""}
|
||||
onChange={(event) => set("team_id", event.target.value)}
|
||||
placeholder="Enter team ID…"
|
||||
value={(get("object_team") as string) ?? ""}
|
||||
onChange={(event) => set("object_team", event.target.value)}
|
||||
placeholder="Team ID or alias…"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Key Hash">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { DateCell, IdCell, IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import { BadgeLink } from "@/components/shared/BadgeLink";
|
||||
import { DateCell, IdCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import { keyDetailHref, modelDetailHref, orgDetailHref, teamDetailHref, userDetailHref } from "@/utils/entityLinks";
|
||||
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
|
||||
|
|
@ -16,6 +18,9 @@ export type AuditLogEntry = {
|
|||
object_id: string;
|
||||
before_value: Record<string, unknown>;
|
||||
updated_values: Record<string, unknown>;
|
||||
object_alias?: string | null;
|
||||
changed_by_user_email?: string | null;
|
||||
changed_by_key_alias?: string | null;
|
||||
};
|
||||
|
||||
export const AUDIT_TABLE_NAME_DISPLAY: Record<string, string> = {
|
||||
|
|
@ -26,6 +31,17 @@ export const AUDIT_TABLE_NAME_DISPLAY: Record<string, string> = {
|
|||
LiteLLM_ProxyModelTable: "Models",
|
||||
};
|
||||
|
||||
const OBJECT_HREF_BY_TABLE: Record<string, (objectId: string) => string> = {
|
||||
LiteLLM_VerificationToken: keyDetailHref,
|
||||
LiteLLM_TeamTable: teamDetailHref,
|
||||
LiteLLM_UserTable: userDetailHref,
|
||||
LiteLLM_OrganizationTable: orgDetailHref,
|
||||
LiteLLM_ProxyModelTable: modelDetailHref,
|
||||
};
|
||||
|
||||
const auditObjectDetailHref = (tableName: string, objectId: string): string | undefined =>
|
||||
objectId ? OBJECT_HREF_BY_TABLE[tableName]?.(objectId) : undefined;
|
||||
|
||||
const ACTION_TONE: Record<string, StatusTone> = {
|
||||
created: "success",
|
||||
updated: "info",
|
||||
|
|
@ -33,13 +49,45 @@ const ACTION_TONE: Record<string, StatusTone> = {
|
|||
rotated: "warning",
|
||||
};
|
||||
|
||||
const DEFAULT_PROXY_ADMIN_USER_ID = "default_user_id";
|
||||
|
||||
const capitalize = (value: string): string => (value ? value.charAt(0).toUpperCase() + value.slice(1) : value);
|
||||
|
||||
interface AuditLogsTableColumnsDeps {
|
||||
onViewLog: (log: AuditLogEntry) => void;
|
||||
function ChangedByCell({ userId, userEmail }: { userId: string; userEmail: string | null | undefined }) {
|
||||
if (!userId || userId === DEFAULT_PROXY_ADMIN_USER_ID) {
|
||||
return <DefaultProxyAdminTag userId={userId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-start gap-0.5">
|
||||
<BadgeLink href={userDetailHref(userId)} className="max-w-full font-normal">
|
||||
<span className="truncate">{userEmail || userId}</span>
|
||||
</BadgeLink>
|
||||
{!!userEmail && (
|
||||
<span className="max-w-full truncate font-mono text-xs text-muted-foreground" title={userId}>
|
||||
{userId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDeps): ColumnDef<AuditLogEntry>[] => [
|
||||
function ChangedByApiKeyCell({ keyHash, keyAlias }: { keyHash: string; keyAlias: string | null | undefined }) {
|
||||
if (!keyAlias) {
|
||||
return <IdCell value={keyHash} variant="plain" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-start gap-0.5">
|
||||
<span className="max-w-full truncate text-sm" title={keyAlias}>
|
||||
{keyAlias}
|
||||
</span>
|
||||
<IdCell value={keyHash} variant="plain" className="text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getAuditLogsTableColumns = (): ColumnDef<AuditLogEntry>[] => [
|
||||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
|
|
@ -75,21 +123,38 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep
|
|||
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)}
|
||||
/>
|
||||
<BadgeLink
|
||||
href={auditObjectDetailHref(row.original.table_name, row.original.object_id)}
|
||||
className="max-w-72 font-mono text-xs font-normal"
|
||||
>
|
||||
<span className="truncate">{row.original.object_id}</span>
|
||||
</BadgeLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "object_alias",
|
||||
accessorKey: "object_alias",
|
||||
header: "Alias",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
row.original.object_alias ? (
|
||||
<span className="block max-w-56 truncate text-sm" title={row.original.object_alias}>
|
||||
{row.original.object_alias}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "changed_by",
|
||||
accessorKey: "changed_by",
|
||||
header: "Changed By",
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <DefaultProxyAdminTag userId={row.original.changed_by} />,
|
||||
cell: ({ row }) => (
|
||||
<ChangedByCell userId={row.original.changed_by} userEmail={row.original.changed_by_user_email} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "changed_by_api_key",
|
||||
|
|
@ -97,6 +162,8 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep
|
|||
header: "API Key (Hash)",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.changed_by_api_key} variant="plain" />,
|
||||
cell: ({ row }) => (
|
||||
<ChangedByApiKeyCell keyHash={row.original.changed_by_api_key} keyAlias={row.original.changed_by_key_alias} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3,3 +3,19 @@ import { migratedHref } from "@/utils/migratedPages";
|
|||
export function teamDetailHref(teamId: string): string {
|
||||
return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`;
|
||||
}
|
||||
|
||||
export function keyDetailHref(keyTokenHash: string): string {
|
||||
return `${migratedHref("api-keys")}?key=${encodeURIComponent(keyTokenHash)}`;
|
||||
}
|
||||
|
||||
export function userDetailHref(userId: string): string {
|
||||
return `${migratedHref("users")}?user=${encodeURIComponent(userId)}`;
|
||||
}
|
||||
|
||||
export function orgDetailHref(organizationId: string): string {
|
||||
return `${migratedHref("organizations")}?org=${encodeURIComponent(organizationId)}`;
|
||||
}
|
||||
|
||||
export function modelDetailHref(modelId: string): string {
|
||||
return `${migratedHref("models-and-endpoints")}?model=${encodeURIComponent(modelId)}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue