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:
ryan-crabbe-berri 2026-08-05 14:58:59 -07:00
parent 6d604d27a6
commit 6c2fc93fd1
11 changed files with 451 additions and 36 deletions

View file

@ -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();

View file

@ -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);

View file

@ -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";
}

View file

@ -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();
});
});

View file

@ -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>
) : (
"—"
)

View file

@ -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");
});
});

View file

@ -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",
},

View file

@ -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" },
]),
);
});
});

View file

@ -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">

View file

@ -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} />
),
},
];

View file

@ -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)}`;
}