From 6c2fc93fd1c5fad7e2abacc0a5369f38493404c0 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 5 Aug 2026 14:58:59 -0700 Subject: [PATCH] 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 --- .../users/_components/view_users.test.tsx | 17 ++- .../users/_components/view_users.tsx | 16 ++- .../src/components/networking.tsx | 3 + .../AuditLogDrawer/AuditLogDrawer.test.tsx | 49 +++++++ .../AuditLogDrawer/AuditLogDrawer.tsx | 20 ++- .../view_logs/AuditLogsPanel.test.tsx | 103 ++++++++++++++ .../components/view_logs/AuditLogsPanel.tsx | 8 +- .../view_logs/AuditLogsTable.test.tsx | 128 +++++++++++++++++- .../components/view_logs/AuditLogsTable.tsx | 36 ++++- .../view_logs/AuditLogsTableColumns.tsx | 91 +++++++++++-- ui/litellm-dashboard/src/utils/entityLinks.ts | 16 +++ 11 files changed, 451 insertions(+), 36 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 5fcc55c1e98..e11a2b03359 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -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) => render( - - - , + + + + + , ); 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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx index 2c1d28d82f8..f100a906f96 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx @@ -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 = ({ const [selectionMode, setSelectionMode] = useState(false); const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false); - const [selectedUserId, setSelectedUserId] = useState(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 = ({ 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); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 8e91f7feecc..a00b3bb74b1 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -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"; } diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.test.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.test.tsx new file mode 100644 index 00000000000..ac57b195661 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.test.tsx @@ -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(); +} + +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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx index 81759c80ab6..36cd7f39453 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx @@ -229,14 +229,26 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) { } /> - } /> + + + {!!log.changed_by_user_email && {log.changed_by_user_email}} + + + } + /> - {log.changed_by_api_key} - + + {!!log.changed_by_key_alias && {log.changed_by_key_alias}} + + {log.changed_by_api_key} + + ) : ( "—" ) diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.test.tsx new file mode 100644 index 00000000000..7c1fd2d288f --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.test.tsx @@ -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( + , + ); +} + +async function applyFilter(fill: () => Promise | 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 { + const lastCall = uiAuditLogsCall.mock.calls.at(-1)?.[0] as { params: Record }; + 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"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx index 81bd4a19f76..e19192b6348 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx @@ -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", }, diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx index dbb0a39e2ee..3c52fdabfb0 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx @@ -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 & Pick) => ({ + 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> = {}) { @@ -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" }, + ]), + ); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx index bcdce12fce8..81d5031a513 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx @@ -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 = { + 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 ( 0} />} size="compact" + onRowClick={onViewLog} toolbar={(table) => ( <> {({ get, set }) => ( <> + +
+ set("start_date", event.target.value)} + /> + to + set("end_date", event.target.value)} + /> +
+
- + 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…" /> diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTableColumns.tsx index 6910ca1c2f7..688a8340e70 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTableColumns.tsx @@ -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; updated_values: Record; + object_alias?: string | null; + changed_by_user_email?: string | null; + changed_by_key_alias?: string | null; }; export const AUDIT_TABLE_NAME_DISPLAY: Record = { @@ -26,6 +31,17 @@ export const AUDIT_TABLE_NAME_DISPLAY: Record = { LiteLLM_ProxyModelTable: "Models", }; +const OBJECT_HREF_BY_TABLE: Record 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 = { created: "success", updated: "info", @@ -33,13 +49,45 @@ const ACTION_TONE: Record = { 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 ; + } + + return ( +
+ + {userEmail || userId} + + {!!userEmail && ( + + {userId} + + )} +
+ ); } -export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDeps): ColumnDef[] => [ +function ChangedByApiKeyCell({ keyHash, keyAlias }: { keyHash: string; keyAlias: string | null | undefined }) { + if (!keyAlias) { + return ; + } + + return ( +
+ + {keyAlias} + + +
+ ); +} + +export const getAuditLogsTableColumns = (): ColumnDef[] => [ { id: "updated_at", accessorKey: "updated_at", @@ -75,21 +123,38 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep minSize: 220, enableSorting: false, cell: ({ row }) => ( - onViewLog(row.original)} - /> + + {row.original.object_id} + ), }, + { + id: "object_alias", + accessorKey: "object_alias", + header: "Alias", + size: 160, + enableSorting: false, + cell: ({ row }) => + row.original.object_alias ? ( + + {row.original.object_alias} + + ) : ( + — + ), + }, { id: "changed_by", accessorKey: "changed_by", header: "Changed By", size: 200, enableSorting: false, - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { id: "changed_by_api_key", @@ -97,6 +162,8 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep header: "API Key (Hash)", size: 160, enableSorting: false, - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, ]; diff --git a/ui/litellm-dashboard/src/utils/entityLinks.ts b/ui/litellm-dashboard/src/utils/entityLinks.ts index 2659a307866..4dd6299deaa 100644 --- a/ui/litellm-dashboard/src/utils/entityLinks.ts +++ b/ui/litellm-dashboard/src/utils/entityLinks.ts @@ -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)}`; +}