feat(ui): view_logs/AuditLogDrawer to shadcn Sheet

- antd Drawer/Tag/Typography + @ant-design Close/Copy/Check \u2192
  shadcn Sheet/Badge + lucide X/Copy/Check.
- Typography.Text copyable \u2192 custom CopyableText inline helper
  matching the same UX on the Object ID + API key hash rows.
- Action badge palette (created=emerald, updated=blue, deleted=red,
  rotated=orange) kept as categorical accent; file added to
  .eslintrc overrides.
- Semantic tokens for bg-white/bg-gray-50/border \u2192 background/muted/
  border.

Gates: TS \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 20:47:20 +00:00
parent a477e6c85e
commit a521a267de
No known key found for this signature in database
2 changed files with 181 additions and 100 deletions

View file

@ -91,6 +91,7 @@
"src/components/transform_request.tsx",
"src/components/Navbar/UserDropdown/UserDropdown.tsx",
"src/components/playground/chat_ui/ChatMessageBubble.tsx",
"src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx",
"src/components/permissions/VectorStorePermissions.tsx",
"src/components/permissions/AgentPermissions.tsx",
"src/components/permissions/MCPServerPermissions.tsx",

View file

@ -1,12 +1,15 @@
import { Drawer, Tag, Typography } from "antd";
import { CloseOutlined, CopyOutlined, CheckOutlined } from "@ant-design/icons";
import {
Sheet,
SheetContent,
} from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Check, Copy, X } from "lucide-react";
import { useState, useCallback } from "react";
import moment from "moment";
import { AuditLogEntry } from "../columns";
import DefaultProxyAdminTag from "../../common_components/DefaultProxyAdminTag";
const { Text } = Typography;
interface AuditLogDrawerProps {
open: boolean;
onClose: () => void;
@ -21,14 +24,56 @@ const TABLE_NAME_DISPLAY: Record<string, string> = {
LiteLLM_ProxyModelTable: "Models",
};
const ACTION_COLOR: Record<string, string> = {
created: "green",
updated: "blue",
deleted: "red",
rotated: "orange",
const ACTION_BADGE_CLASSES: Record<string, string> = {
created:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
updated: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300",
deleted: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300",
rotated:
"bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300",
};
function CopyableJsonBlock({ label, value }: { label: string; value: Record<string, any> }) {
function CopyableText({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
}
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (e) {
console.error("Copy failed:", e);
}
}, [value]);
return (
<span className="inline-flex items-center gap-1">
<span>{value}</span>
<button
type="button"
onClick={handleCopy}
className="text-muted-foreground hover:text-foreground"
aria-label="Copy"
>
{copied ? (
<Check className="h-3 w-3 text-emerald-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</span>
);
}
function CopyableJsonBlock({
label,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value,
}: {
label: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: Record<string, any>;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
@ -55,29 +100,45 @@ function CopyableJsonBlock({ label, value }: { label: string; value: Record<stri
}, [value]);
return (
<div className="bg-white rounded border overflow-hidden">
<div className="flex justify-between items-center px-3 py-2 border-b bg-gray-50">
<span className="text-xs font-semibold text-gray-600">{label}</span>
<div className="bg-background rounded border border-border overflow-hidden">
<div className="flex justify-between items-center px-3 py-2 border-b border-border bg-muted">
<span className="text-xs font-semibold text-muted-foreground">
{label}
</span>
<button
type="button"
onClick={handleCopy}
className="p-1 hover:bg-gray-200 rounded text-gray-500 hover:text-gray-700 transition-colors"
className="p-1 hover:bg-muted-foreground/10 rounded text-muted-foreground hover:text-foreground transition-colors"
title="Copy JSON"
aria-label="Copy JSON"
>
{copied ? <CheckOutlined className="text-green-600" /> : <CopyOutlined />}
{copied ? (
<Check className="h-3.5 w-3.5 text-emerald-600" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
<pre className="p-3 bg-white text-xs font-mono overflow-auto max-h-96 whitespace-pre-wrap break-all m-0">
<pre className="p-3 bg-background text-xs font-mono overflow-auto max-h-96 whitespace-pre-wrap break-all m-0">
{JSON.stringify(value, null, 2)}
</pre>
</div>
);
}
function MetadataRow({ label, value }: { label: string; value: React.ReactNode }) {
function MetadataRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="flex items-start gap-2 py-1.5">
<span className="text-xs text-gray-500 w-36 shrink-0">{label}</span>
<span className="text-xs text-gray-900 break-all">{value}</span>
<span className="text-xs text-muted-foreground w-36 shrink-0">
{label}
</span>
<span className="text-xs text-foreground break-all">{value}</span>
</div>
);
}
@ -91,7 +152,9 @@ function DiffSection({ log }: { log: AuditLogEntry }) {
let displayAfter = updated_values;
if (isUpdateAction && before_value && updated_values) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const changedBefore: Record<string, any> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const changedAfter: Record<string, any> = {};
const allKeys = new Set([
...Object.keys(before_value),
@ -107,7 +170,6 @@ function DiffSection({ log }: { log: AuditLogEntry }) {
}
});
// Fields only in before (removed)
Object.keys(before_value).forEach((key) => {
if (!(key in updated_values) && !(key in changedBefore)) {
changedBefore[key] = before_value[key];
@ -115,7 +177,6 @@ function DiffSection({ log }: { log: AuditLogEntry }) {
}
});
// Fields only in after (added)
Object.keys(updated_values).forEach((key) => {
if (!(key in before_value) && !(key in changedAfter)) {
changedAfter[key] = updated_values[key];
@ -133,37 +194,57 @@ function DiffSection({ log }: { log: AuditLogEntry }) {
: { note: "No differing fields detected" };
}
const renderValue = (label: string, value: Record<string, any> | null | undefined) => {
const renderValue = (
label: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: Record<string, any> | null | undefined,
) => {
if (!value || Object.keys(value).length === 0) {
return (
<div className="bg-white rounded border overflow-hidden">
<div className="flex items-center px-3 py-2 border-b bg-gray-50">
<span className="text-xs font-semibold text-gray-600">{label}</span>
<div className="bg-background rounded border border-border overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-border bg-muted">
<span className="text-xs font-semibold text-muted-foreground">
{label}
</span>
</div>
<p className="px-3 py-3 text-xs text-gray-400 italic m-0">N/A</p>
<p className="px-3 py-3 text-xs text-muted-foreground italic m-0">
N/A
</p>
</div>
);
}
// For key table updates, show only meaningful fields as plain text
if (isKeyTable && isUpdateAction) {
const knownKeyFields = ["token", "spend", "max_budget"];
const hasOnlyKnown = Object.keys(value).every((k) => knownKeyFields.includes(k));
const hasOnlyKnown = Object.keys(value).every((k) =>
knownKeyFields.includes(k),
);
if (hasOnlyKnown && !("note" in value)) {
return (
<div className="bg-white rounded border overflow-hidden">
<div className="flex items-center px-3 py-2 border-b bg-gray-50">
<span className="text-xs font-semibold text-gray-600">{label}</span>
<div className="bg-background rounded border border-border overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-border bg-muted">
<span className="text-xs font-semibold text-muted-foreground">
{label}
</span>
</div>
<div className="px-3 py-3 space-y-1 text-xs">
{value.token !== undefined && (
<p><span className="text-gray-500">Token:</span> {value.token ?? "N/A"}</p>
<p>
<span className="text-muted-foreground">Token:</span>{" "}
{value.token ?? "N/A"}
</p>
)}
{value.spend !== undefined && (
<p><span className="text-gray-500">Spend:</span> ${Number(value.spend).toFixed(6)}</p>
<p>
<span className="text-muted-foreground">Spend:</span> $
{Number(value.spend).toFixed(6)}
</p>
)}
{value.max_budget !== undefined && (
<p><span className="text-gray-500">Max Budget:</span> ${Number(value.max_budget).toFixed(6)}</p>
<p>
<span className="text-muted-foreground">Max Budget:</span> $
{Number(value.max_budget).toFixed(6)}
</p>
)}
</div>
</div>
@ -186,75 +267,74 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
if (!log) return null;
const tableDisplay = TABLE_NAME_DISPLAY[log.table_name] ?? log.table_name;
const actionColor = ACTION_COLOR[log.action] ?? "default";
const actionClasses =
ACTION_BADGE_CLASSES[log.action] ?? "bg-muted text-muted-foreground";
return (
<Drawer
placement="right"
width="60%"
open={open}
onClose={onClose}
closable={false}
mask={true}
maskClosable={true}
styles={{ body: { padding: 0, display: "flex", flexDirection: "column" }, header: { display: "none" } }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-white shrink-0">
<div className="flex items-center gap-3">
<Tag color={actionColor} className="capitalize m-0">
{log.action}
</Tag>
<span className="text-sm text-gray-500">
{moment.utc(log.updated_at).local().format("MMM D, YYYY HH:mm:ss")}
</span>
</div>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-500"
aria-label="Close"
>
<CloseOutlined />
</button>
</div>
{/* Body */}
<div className="px-6 py-5">
{/* Metadata */}
<div className="bg-gray-50 border rounded-lg p-4 mb-5">
<p className="text-xs font-semibold text-gray-700 mb-2 uppercase tracking-wide">
Details
</p>
<MetadataRow label="Table" value={tableDisplay} />
<MetadataRow
label="Object ID"
value={
<Text copyable className="font-mono text-xs">
{log.object_id}
</Text>
}
/>
<MetadataRow
label="Changed By"
value={<DefaultProxyAdminTag userId={log.changed_by} />}
/>
<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>
) : (
"—"
)
}
/>
<Sheet open={open} onOpenChange={(o) => (!o ? onClose() : undefined)}>
<SheetContent
side="right"
className="w-[60%] sm:max-w-[60%] p-0 flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-background shrink-0">
<div className="flex items-center gap-3">
<Badge className={cn("capitalize", actionClasses)}>
{log.action}
</Badge>
<span className="text-sm text-muted-foreground">
{moment
.utc(log.updated_at)
.local()
.format("MMM D, YYYY HH:mm:ss")}
</span>
</div>
<button
type="button"
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded hover:bg-muted text-muted-foreground"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Diff */}
<DiffSection log={log} />
</div>
</Drawer>
{/* Body */}
<div className="px-6 py-5 overflow-auto">
<div className="bg-muted border border-border rounded-lg p-4 mb-5">
<p className="text-xs font-semibold text-foreground mb-2 uppercase tracking-wide">
Details
</p>
<MetadataRow label="Table" value={tableDisplay} />
<MetadataRow
label="Object ID"
value={
<span className="font-mono text-xs">
<CopyableText value={log.object_id} />
</span>
}
/>
<MetadataRow
label="Changed By"
value={<DefaultProxyAdminTag userId={log.changed_by} />}
/>
<MetadataRow
label="API Key (Hash)"
value={
log.changed_by_api_key ? (
<span className="font-mono text-xs break-all">
<CopyableText value={log.changed_by_api_key} />
</span>
) : (
"—"
)
}
/>
</div>
<DiffSection log={log} />
</div>
</SheetContent>
</Sheet>
);
}