feat(ui): migrate view_logs page + modals to shadcn

Migrates the page-level chrome of the view_logs subtree:

- index.tsx: Tremor TabGroup/Tab/TabList/TabPanels/TabPanel -> shadcn
  Tabs/TabsList/TabsTrigger/TabsContent; Tremor Switch -> shadcn Switch;
  antd Button/Tag/Tooltip -> shadcn Button/Badge/Tooltip. Filter
  dropdowns, custom date range inputs, and pagination controls remain
  unchanged. The inline RequestViewer component (used by the legacy
  expanded-row view) also migrates its Tooltip/Tag usages.
- audit_logs.tsx: antd Table -> DataTable (shadcn Table driven by
  @tanstack/react-table) with ColumnDef<AuditLogEntry>[]; antd
  Input/Select/Button/Pagination/Spin -> shadcn Input/Select/Button +
  custom prev/next pagination per BLUEPRINT. Action palette (green/
  blue/red/orange for created/updated/deleted/rotated) kept raw.
- SpendLogsSettingsModal.tsx: antd Form.useForm -> react-hook-form
  with FormProvider + Controller; antd Form.Item tooltip -> shadcn
  Tooltip around a lucide HelpCircle; antd Switch -> shadcn Switch;
  antd-coupled button-class test (.ant-btn-loading) replaced with
  semantic data-loading attribute; skeleton selector updated to match
  shadcn's .animate-pulse class.

Adds index.tsx and audit_logs.tsx to the .eslintrc.json override list
(they keep categorical status palettes for log-level action badges).

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-24 10:41:25 +00:00
parent d3708b967f
commit 889d12f9b9
No known key found for this signature in database
5 changed files with 498 additions and 284 deletions

View file

@ -100,6 +100,8 @@
"src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx",
"src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx",
"src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx",
"src/components/view_logs/index.tsx",
"src/components/view_logs/audit_logs.tsx",
"src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx",
"src/components/permissions/VectorStorePermissions.tsx",
"src/components/permissions/AgentPermissions.tsx",

View file

@ -281,7 +281,7 @@ describe("SpendLogsSettingsModal", () => {
const saveButton = screen.getByRole("button", { name: /Saving/i });
expect(saveButton).toBeInTheDocument();
expect(saveButton.className).toContain("ant-btn-loading");
expect(saveButton).toHaveAttribute("data-loading", "true");
});
it("should show loading state on save button when deleting field", () => {
@ -294,7 +294,7 @@ describe("SpendLogsSettingsModal", () => {
const saveButton = screen.getByRole("button", { name: /Saving/i });
expect(saveButton).toBeInTheDocument();
expect(saveButton.className).toContain("ant-btn-loading");
expect(saveButton).toHaveAttribute("data-loading", "true");
});
it("should call onCancel when cancel button is clicked after modifying form", async () => {
@ -422,8 +422,8 @@ describe("SpendLogsSettingsModal", () => {
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText("e.g., 7d, 30d")).not.toBeInTheDocument();
// Check for skeleton elements (Ant Design Skeleton.Input renders with ant-skeleton class)
const skeletons = document.querySelectorAll(".ant-skeleton");
// Check for shadcn Skeleton elements — they apply "animate-pulse" as part of the base class.
const skeletons = document.querySelectorAll(".animate-pulse");
expect(skeletons.length).toBeGreaterThan(0);
});

View file

@ -1,7 +1,15 @@
"use client";
import { ConfigType, GeneralSettingsFieldName, useDeleteProxyConfigField, useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig";
import { StoreRequestInSpendLogsParams, useStoreRequestInSpendLogs } from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs";
import {
ConfigType,
GeneralSettingsFieldName,
useDeleteProxyConfigField,
useProxyConfig,
} from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig";
import {
StoreRequestInSpendLogsParams,
useStoreRequestInSpendLogs,
} from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { Button } from "@/components/ui/button";
@ -12,11 +20,19 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Clock } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Form, Switch } from "antd";
import React, { useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Clock, HelpCircle, LoaderCircle } from "lucide-react";
import React, { useEffect } from "react";
import { useForm, FormProvider, Controller, useFormContext } from "react-hook-form";
interface SpendLogsSettingsModalProps {
isVisible: boolean;
@ -24,41 +40,66 @@ interface SpendLogsSettingsModalProps {
onSuccess?: () => void;
}
const SpendLogsSettingsModal: React.FC<SpendLogsSettingsModalProps> = ({ isVisible, onCancel, onSuccess }) => {
const [form] = Form.useForm();
interface FormValues {
store_prompts_in_spend_logs: boolean;
maximum_spend_logs_retention_period?: string;
}
const STORE_PROMPTS_TOOLTIP_FALLBACK =
"When enabled, prompts will be stored in spend logs for tracking and analysis purposes.";
const RETENTION_PERIOD_TOOLTIP_FALLBACK =
"Set the maximum retention period for spend logs (e.g., '7d' for 7 days, '30d' for 30 days). Leave empty for no limit.";
const SpendLogsSettingsModal: React.FC<SpendLogsSettingsModalProps> = ({
isVisible,
onCancel,
onSuccess,
}) => {
const { mutateAsync, isPending } = useStoreRequestInSpendLogs();
const { mutateAsync: deleteField, isPending: isDeletingField } = useDeleteProxyConfigField();
const { data: proxyConfigData, isLoading: isLoadingConfig, refetch } = useProxyConfig(ConfigType.GENERAL_SETTINGS);
const storePromptsValue = Form.useWatch('store_prompts_in_spend_logs', form);
const {
data: proxyConfigData,
isLoading: isLoadingConfig,
refetch,
} = useProxyConfig(ConfigType.GENERAL_SETTINGS);
// Refetch config when modal opens to ensure we have the latest values
const form = useForm<FormValues>({
defaultValues: {
store_prompts_in_spend_logs: false,
maximum_spend_logs_retention_period: "",
},
});
// Reapply backend-provided defaults whenever the modal opens or the config changes.
useEffect(() => {
if (isVisible) {
refetch();
}
if (!isVisible) return;
refetch();
}, [isVisible, refetch]);
// Compute initial values from fetched config data
const initialValues = useMemo(() => {
if (!proxyConfigData) {
return {
store_prompts_in_spend_logs: false,
maximum_spend_logs_retention_period: undefined,
};
}
useEffect(() => {
if (!proxyConfigData) return;
const storePromptsField = proxyConfigData.find(
(field) => field.field_name === "store_prompts_in_spend_logs",
);
const retentionPeriodField = proxyConfigData.find(
(field) => field.field_name === "maximum_spend_logs_retention_period",
);
form.reset({
store_prompts_in_spend_logs: Boolean(storePromptsField?.field_value),
maximum_spend_logs_retention_period:
(retentionPeriodField?.field_value as string | undefined) ?? "",
});
}, [proxyConfigData, form]);
const storePromptsField = proxyConfigData.find(field => field.field_name === 'store_prompts_in_spend_logs');
const retentionPeriodField = proxyConfigData.find(field => field.field_name === 'maximum_spend_logs_retention_period');
const storePromptsDescription =
proxyConfigData?.find((f) => f.field_name === "store_prompts_in_spend_logs")?.field_description ||
STORE_PROMPTS_TOOLTIP_FALLBACK;
const retentionPeriodDescription =
proxyConfigData?.find((f) => f.field_name === "maximum_spend_logs_retention_period")
?.field_description || RETENTION_PERIOD_TOOLTIP_FALLBACK;
return {
store_prompts_in_spend_logs: storePromptsField?.field_value ?? false,
maximum_spend_logs_retention_period: retentionPeriodField?.field_value ?? undefined,
};
}, [proxyConfigData]);
const handleFormSubmit = async (formValues: StoreRequestInSpendLogsParams) => {
const handleFormSubmit = async (formValues: FormValues) => {
try {
// If maximum_spend_logs_retention_period is empty/null, delete the field first
const retentionPeriodValue = formValues.maximum_spend_logs_retention_period;
const shouldDeleteRetentionPeriod =
!retentionPeriodValue ||
@ -72,121 +113,89 @@ const SpendLogsSettingsModal: React.FC<SpendLogsSettingsModalProps> = ({ isVisib
});
} catch (deleteError) {
// If field doesn't exist, that's okay - continue with update
console.warn("Failed to delete retention period field (may not exist):", deleteError);
console.warn(
"Failed to delete retention period field (may not exist):",
deleteError,
);
}
}
// Update the settings (excluding maximum_spend_logs_retention_period if it's empty)
const updateParams: StoreRequestInSpendLogsParams = {
store_prompts_in_spend_logs: formValues.store_prompts_in_spend_logs,
...(retentionPeriodValue &&
typeof retentionPeriodValue === "string" &&
retentionPeriodValue.trim() !== "" && {
maximum_spend_logs_retention_period: retentionPeriodValue,
}),
maximum_spend_logs_retention_period: retentionPeriodValue,
}),
};
await mutateAsync(updateParams, {
onSuccess: () => {
NotificationsManager.success("Spend logs settings updated successfully");
refetch(); // Refetch config to get updated values
refetch();
onSuccess?.();
},
onError: (error) => {
NotificationsManager.fromBackend("Failed to save spend logs settings: " + parseErrorMessage(error));
NotificationsManager.fromBackend(
"Failed to save spend logs settings: " + parseErrorMessage(error),
);
},
});
} catch (error) {
NotificationsManager.fromBackend("Failed to save spend logs settings: " + parseErrorMessage(error));
NotificationsManager.fromBackend(
"Failed to save spend logs settings: " + parseErrorMessage(error),
);
}
};
const handleCancel = () => {
form.resetFields();
form.reset();
onCancel();
};
const isSubmitting = isPending || isDeletingField;
return (
<Dialog
open={isVisible}
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
>
<Dialog open={isVisible} onOpenChange={(o) => (!o ? handleCancel() : undefined)}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-base font-semibold">
Spend Logs Settings
</DialogTitle>
</DialogHeader>
<Form
key={proxyConfigData ? JSON.stringify(initialValues) : "loading"}
form={form}
layout="horizontal"
onFinish={handleFormSubmit}
initialValues={initialValues}
>
<Form.Item
label="Store Prompts in Spend Logs"
name="store_prompts_in_spend_logs"
tooltip={
proxyConfigData?.find(
(f) => f.field_name === "store_prompts_in_spend_logs",
)?.field_description ||
"When enabled, prompts will be stored in spend logs for tracking and analysis purposes."
}
valuePropName="checked"
>
<div>
{isLoadingConfig ? (
<Skeleton className="h-6 w-full" />
) : (
<Switch
checked={storePromptsValue ?? false}
onChange={(checked) =>
form.setFieldValue(
"store_prompts_in_spend_logs",
checked,
)
}
/>
)}
</div>
</Form.Item>
<Form.Item
label="Maximum Spend Logs Retention Period (Optional)"
name="maximum_spend_logs_retention_period"
tooltip={
proxyConfigData?.find(
(f) =>
f.field_name ===
"maximum_spend_logs_retention_period",
)?.field_description ||
"Set the maximum retention period for spend logs (e.g., '7d' for 7 days, '30d' for 30 days). Leave empty for no limit."
}
>
{isLoadingConfig ? (
<Skeleton className="h-6 w-full" />
) : (
<div className="relative">
<Clock className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input placeholder="e.g., 7d, 30d" className="pl-8" />
</div>
)}
</Form.Item>
</Form>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-4">
<StorePromptsField
isLoading={isLoadingConfig}
description={storePromptsDescription}
/>
<RetentionPeriodField
isLoading={isLoadingConfig}
description={retentionPeriodDescription}
/>
</form>
</FormProvider>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
disabled={isPending || isDeletingField || isLoadingConfig}
disabled={isSubmitting || isLoadingConfig}
>
Cancel
</Button>
<Button
onClick={() => form.submit()}
disabled={isPending || isDeletingField || isLoadingConfig}
onClick={form.handleSubmit(handleFormSubmit)}
disabled={isSubmitting || isLoadingConfig}
data-loading={isSubmitting || undefined}
>
{isPending || isDeletingField ? "Saving..." : "Save Settings"}
{isSubmitting ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save Settings"
)}
</Button>
</DialogFooter>
</DialogContent>
@ -194,4 +203,93 @@ const SpendLogsSettingsModal: React.FC<SpendLogsSettingsModalProps> = ({ isVisib
);
};
function FieldTooltip({ text }: { text: string }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
aria-label="More info"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-xs">{text}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function StorePromptsField({
isLoading,
description,
}: {
isLoading: boolean;
description: string;
}) {
const { control } = useFormContext<FormValues>();
return (
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-1">
<Label htmlFor="store-prompts-switch" className="text-sm font-medium">
Store Prompts in Spend Logs
</Label>
<FieldTooltip text={description} />
</div>
<div>
{isLoading ? (
<Skeleton className="h-6 w-12" />
) : (
<Controller
control={control}
name="store_prompts_in_spend_logs"
render={({ field }) => (
<Switch
id="store-prompts-switch"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
)}
</div>
</div>
);
}
function RetentionPeriodField({
isLoading,
description,
}: {
isLoading: boolean;
description: string;
}) {
const { register } = useFormContext<FormValues>();
return (
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label htmlFor="retention-period-input" className="text-sm font-medium">
Maximum Spend Logs Retention Period (Optional)
</Label>
<FieldTooltip text={description} />
</div>
{isLoading ? (
<Skeleton className="h-9 w-full" />
) : (
<div className="relative">
<Clock className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
id="retention-period-input"
placeholder="e.g., 7d, 30d"
className="pl-8"
{...register("maximum_spend_logs_retention_period")}
/>
</div>
)}
</div>
);
}
export default SpendLogsSettingsModal;

View file

@ -1,15 +1,24 @@
import { useState } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { Table, Tag, Input, Select, Button, Pagination, Spin } from "antd";
import { RefreshCcw as ReloadOutlined, Loader2 as LoadingOutlined } from "lucide-react";
import type { ColumnsType } from "antd/es/table";
import type { ColumnDef } from "@tanstack/react-table";
import moment from "moment";
import { RefreshCcw as ReloadOutlined, LoaderCircle, Search as SearchIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { uiAuditLogsCall } from "../networking";
import { AuditLogEntry } from "./columns";
import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
const { Search } = Input;
import { DataTable } from "./table";
interface AuditLogsProps {
accessToken: string | null;
@ -31,15 +40,78 @@ const TABLE_NAME_DISPLAY: Record<string, string> = {
LiteLLM_ProxyModelTable: "Models",
};
const ACTION_COLOR: Record<string, string> = {
created: "green",
updated: "blue",
deleted: "red",
rotated: "orange",
/** Per-action palette — categorical; kept raw via eslint override. */
const ACTION_BADGE_CLASS: Record<string, string> = {
created: "bg-green-100 text-green-700 hover:bg-green-100",
updated: "bg-blue-100 text-blue-700 hover:bg-blue-100",
deleted: "bg-red-100 text-red-700 hover:bg-red-100",
rotated: "bg-orange-100 text-orange-700 hover:bg-orange-100",
};
const PAGE_SIZE = 50;
const ACTION_OPTIONS = [
{ label: "All Actions", value: "__all__" },
{ label: "Created", value: "created" },
{ label: "Updated", value: "updated" },
{ label: "Deleted", value: "deleted" },
{ label: "Rotated", value: "rotated" },
];
const TABLE_OPTIONS = [
{ label: "All Tables", value: "__all__" },
{ label: "Keys", value: "LiteLLM_VerificationToken" },
{ label: "Teams", value: "LiteLLM_TeamTable" },
{ label: "Users", value: "LiteLLM_UserTable" },
{ label: "Organizations", value: "LiteLLM_OrganizationTable" },
{ label: "Models", value: "LiteLLM_ProxyModelTable" },
];
/** Small debounced-on-enter/clear text input used for filter fields. */
function FilterSearchInput({
placeholder,
width,
onSubmit,
onClear,
}: {
placeholder: string;
width: number;
onSubmit: (val: string) => void;
onClear: () => void;
}) {
const [value, setValue] = useState("");
return (
<div className="relative" style={{ width }}>
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
value={value}
onChange={(e) => {
setValue(e.target.value);
if (!e.target.value) onClear();
}}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit(value);
}}
placeholder={placeholder}
className="pl-8 pr-8 h-9 text-sm"
/>
{value && (
<button
type="button"
onClick={() => {
setValue("");
onClear();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-sm"
aria-label="Clear"
>
×
</button>
)}
</div>
);
}
export default function AuditLogs({
userID,
userRole,
@ -105,85 +177,75 @@ export default function AuditLogs({
setDrawerOpen(true);
};
const columns: ColumnsType<AuditLogEntry> = [
const columns: ColumnDef<AuditLogEntry>[] = [
{
title: "Timestamp",
dataIndex: "updated_at",
key: "updated_at",
width: 200,
render: (val: string) => (
header: "Timestamp",
accessorKey: "updated_at",
cell: (info) => (
<span className="font-mono text-xs whitespace-nowrap">
{moment.utc(val).local().format("MMM D, YYYY HH:mm:ss")}
{moment.utc(info.getValue() as string).local().format("MMM D, YYYY HH:mm:ss")}
</span>
),
},
{
title: "Action",
dataIndex: "action",
key: "action",
width: 100,
render: (val: string) => (
<Tag color={ACTION_COLOR[val] ?? "default"} className="capitalize">
{val}
</Tag>
),
header: "Action",
accessorKey: "action",
cell: (info) => {
const val = info.getValue() as string;
return (
<Badge className={`capitalize ${ACTION_BADGE_CLASS[val] ?? "bg-muted text-foreground hover:bg-muted"}`}>
{val}
</Badge>
);
},
},
{
title: "Table",
dataIndex: "table_name",
key: "table_name",
width: 130,
render: (val: string) => TABLE_NAME_DISPLAY[val] ?? val,
header: "Table",
accessorKey: "table_name",
cell: (info) => {
const val = info.getValue() as string;
return <span>{TABLE_NAME_DISPLAY[val] ?? val}</span>;
},
},
{
title: "Object ID",
dataIndex: "object_id",
key: "object_id",
render: (val: string) => (
<span className="font-mono text-xs">{val}</span>
),
header: "Object ID",
accessorKey: "object_id",
cell: (info) => <span className="font-mono text-xs">{info.getValue() as string}</span>,
},
{
title: "Changed By",
dataIndex: "changed_by",
key: "changed_by",
width: 200,
render: (val: string) => <DefaultProxyAdminTag userId={val} />,
header: "Changed By",
accessorKey: "changed_by",
cell: (info) => <DefaultProxyAdminTag userId={info.getValue() as string} />,
},
{
title: "API Key (Hash)",
dataIndex: "changed_by_api_key",
key: "changed_by_api_key",
width: 140,
render: (val: string) =>
val ? (
header: "API Key (Hash)",
accessorKey: "changed_by_api_key",
cell: (info) => {
const val = info.getValue() as string;
return val ? (
<span className="font-mono text-xs">{val.slice(0, 12)}…</span>
) : (
"—"
),
<span>—</span>
);
},
},
];
if (!premiumUser) {
return (
<div style={{ textAlign: "center", marginTop: "20px" }}>
<h1 style={{ display: "block", marginBottom: "10px" }}>✨ Enterprise Feature.</h1>
<p style={{ display: "block", marginBottom: "10px" }}>
<div className="text-center mt-5">
<h1 className="block mb-2.5 text-xl font-semibold">✨ Enterprise Feature.</h1>
<p className="block mb-2.5">
This is a LiteLLM Enterprise feature, and requires a valid key to use.
</p>
<p style={{ display: "block", marginBottom: "20px", fontStyle: "italic" }}>
<p className="block mb-5 italic text-muted-foreground">
Here&apos;s a preview of what Audit Logs offer:
</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={auditLogsPreviewImg}
alt="Audit Logs Preview"
style={{
maxWidth: "100%",
maxHeight: "700px",
borderRadius: "8px",
boxShadow: "0 4px 8px rgba(0,0,0,0.1)",
margin: "0 auto",
}}
className="max-w-full max-h-[700px] rounded-lg shadow-md mx-auto"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
@ -194,10 +256,11 @@ export default function AuditLogs({
const auditLogs: AuditLogEntry[] = query.data?.audit_logs ?? [];
const total: number = query.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<>
<div className="bg-white rounded-lg shadow">
<div className="bg-background rounded-lg shadow">
{/* Header */}
<div className="border-b px-6 py-4">
<div className="flex items-center justify-between mb-4">
@ -206,96 +269,125 @@ export default function AuditLogs({
{/* Filters + pagination on same row */}
<div className="flex flex-wrap items-center gap-3">
<Search
<FilterSearchInput
placeholder="Object ID"
allowClear
style={{ width: 200 }}
onSearch={(val) => { setObjectId(val); resetPage(); }}
onChange={(e) => { if (!e.target.value) { setObjectId(""); resetPage(); } }}
width={200}
onSubmit={(val) => { setObjectId(val); resetPage(); }}
onClear={() => { setObjectId(""); resetPage(); }}
/>
<Search
<FilterSearchInput
placeholder="Changed By"
allowClear
style={{ width: 180 }}
onSearch={(val) => { setChangedBy(val); resetPage(); }}
onChange={(e) => { if (!e.target.value) { setChangedBy(""); resetPage(); } }}
width={180}
onSubmit={(val) => { setChangedBy(val); resetPage(); }}
onClear={() => { setChangedBy(""); resetPage(); }}
/>
<Search
<FilterSearchInput
placeholder="Team ID"
allowClear
style={{ width: 180 }}
onSearch={(val) => { setTeamId(val); resetPage(); }}
onChange={(e) => { if (!e.target.value) { setTeamId(""); resetPage(); } }}
width={180}
onSubmit={(val) => { setTeamId(val); resetPage(); }}
onClear={() => { setTeamId(""); resetPage(); }}
/>
<Search
<FilterSearchInput
placeholder="Key Hash"
allowClear
style={{ width: 180 }}
onSearch={(val) => { setKeyHash(val); resetPage(); }}
onChange={(e) => { if (!e.target.value) { setKeyHash(""); resetPage(); } }}
width={180}
onSubmit={(val) => { setKeyHash(val); resetPage(); }}
onClear={() => { setKeyHash(""); resetPage(); }}
/>
<Select
placeholder="All Actions"
allowClear
style={{ width: 140 }}
options={[
{ label: "Created", value: "created" },
{ label: "Updated", value: "updated" },
{ label: "Deleted", value: "deleted" },
{ label: "Rotated", value: "rotated" },
]}
onChange={(val) => { setAction(val); resetPage(); }}
/>
value={action ?? "__all__"}
onValueChange={(val) => {
setAction(val === "__all__" ? undefined : val);
resetPage();
}}
>
<SelectTrigger className="h-9 w-[140px] text-sm">
<SelectValue placeholder="All Actions" />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
placeholder="All Tables"
allowClear
style={{ width: 150 }}
options={[
{ label: "Keys", value: "LiteLLM_VerificationToken" },
{ label: "Teams", value: "LiteLLM_TeamTable" },
{ label: "Users", value: "LiteLLM_UserTable" },
{ label: "Organizations", value: "LiteLLM_OrganizationTable" },
{ label: "Models", value: "LiteLLM_ProxyModelTable" },
]}
onChange={(val) => { setTableName(val); resetPage(); }}
/>
value={tableName ?? "__all__"}
onValueChange={(val) => {
setTableName(val === "__all__" ? undefined : val);
resetPage();
}}
>
<SelectTrigger className="h-9 w-[150px] text-sm">
<SelectValue placeholder="All Tables" />
</SelectTrigger>
<SelectContent>
{TABLE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Pagination + refresh pushed to the right */}
<div className="ml-auto flex items-center gap-2">
<Button
icon={<ReloadOutlined className="animate-spin" />}
variant="outline"
size="icon"
onClick={() => query.refetch()}
disabled={query.isFetching}
/>
<Pagination
current={page}
pageSize={PAGE_SIZE}
total={total}
showTotal={(t) => `${t} total`}
showSizeChanger={false}
size="small"
onChange={(p) => setPage(p)}
/>
aria-label="Refresh"
className="h-9 w-9"
>
<ReloadOutlined className={`h-4 w-4 ${query.isFetching ? "animate-spin" : ""}`} />
</Button>
<div className="flex items-center gap-1 text-sm">
<span className="text-muted-foreground whitespace-nowrap">{total} total</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="h-8"
>
Prev
</Button>
<span className="whitespace-nowrap">
Page {page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="h-8"
>
Next
</Button>
</div>
</div>
</div>
</div>
{/* Table — pagination handled in header */}
<Table<AuditLogEntry>
columns={columns}
dataSource={auditLogs}
rowKey="id"
loading={{
spinning: query.isLoading,
indicator: <Spin indicator={<LoadingOutlined className="animate-spin" />} size="small" />,
}}
size="small"
pagination={false}
onRow={(record) => ({
onClick: () => handleRowClick(record),
style: { cursor: "pointer" },
})}
/>
{query.isLoading ? (
<div className="p-6 space-y-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<div className="flex items-center justify-center pt-2 text-muted-foreground gap-2">
<LoaderCircle className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading audit logs…</span>
</div>
</div>
) : (
<DataTable<AuditLogEntry, unknown>
columns={columns}
data={auditLogs}
onRowClick={handleRowClick}
/>
)}
</div>
<AuditLogDrawer

View file

@ -6,9 +6,16 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
import { truncateString } from "@/utils/textUtils";
import { Settings as SettingOutlined, RefreshCcw as SyncOutlined } from "lucide-react";
import { Row } from "@tanstack/react-table";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Switch, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import { Button, Tag, Tooltip } from "antd";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { internalUserRoles } from "../../utils/roles";
import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage";
import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage";
@ -156,8 +163,8 @@ export default function SpendLogsTable({
const LiveTailControls = () => {
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">Live Tail</span>
<Switch color="green" checked={isLiveTail} defaultChecked={true} onChange={setIsLiveTail} />
<span className="text-sm font-medium text-foreground">Live Tail</span>
<Switch checked={isLiveTail} onCheckedChange={setIsLiveTail} />
</div>
);
};
@ -472,22 +479,27 @@ export default function SpendLogsTable({
return (
<div className="w-full max-w-screen p-6 overflow-x-hidden box-border">
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "request logs" : "audit logs")}>
<TabList>
<Tab>Request Logs</Tab>
<Tab>Audit Logs</Tab>
<Tab>Deleted Keys</Tab>
<Tab>Deleted Teams</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Tabs
defaultValue="request-logs"
onValueChange={(val) => setActiveTab(val === "request-logs" ? "request logs" : val === "audit-logs" ? "audit logs" : val)}
>
<TabsList>
<TabsTrigger value="request-logs">Request Logs</TabsTrigger>
<TabsTrigger value="audit-logs">Audit Logs</TabsTrigger>
<TabsTrigger value="deleted-keys">Deleted Keys</TabsTrigger>
<TabsTrigger value="deleted-teams">Deleted Teams</TabsTrigger>
</TabsList>
<TabsContent value="request-logs">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">Request Logs</h1>
<Button
icon={<SettingOutlined />}
variant="outline"
size="icon"
onClick={() => setIsSpendLogsSettingsModalVisible(true)}
title="Spend Logs Settings"
/>
>
<SettingOutlined className="h-4 w-4" />
</Button>
</div>
{selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView ? (
<KeyInfoView
@ -522,7 +534,7 @@ export default function SpendLogsTable({
onChange={(e) => setSearchTerm(e.target.value)}
/>
<svg
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -593,12 +605,13 @@ export default function SpendLogsTable({
<LiveTailControls />
<Button
type="default"
icon={<SyncOutlined className={isButtonLoading ? "animate-spin" : undefined} />}
variant="outline"
onClick={handleRefresh}
disabled={isButtonLoading}
title="Fetch data"
className="h-9"
>
<SyncOutlined className={`mr-2 h-4 w-4 ${isButtonLoading ? "animate-spin" : ""}`} />
{isButtonLoading ? "Fetching" : "Fetch"}
</Button>
</div>
@ -616,7 +629,7 @@ export default function SpendLogsTable({
className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<span className="text-gray-500">to</span>
<span className="text-muted-foreground">to</span>
<div>
<input
type="datetime-local"
@ -633,7 +646,7 @@ export default function SpendLogsTable({
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700 whitespace-nowrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing {logs.isLoading ? "..." : filteredLogs ? (currentPage - 1) * pageSize + 1 : 0} -{" "}
{logs.isLoading
? "..."
@ -643,7 +656,7 @@ export default function SpendLogsTable({
of {logs.isLoading ? "..." : filteredLogs ? filteredLogs.total : 0} results
</span>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-700 min-w-[90px]">
<span className="text-sm text-muted-foreground min-w-[90px]">
Page {logs.isLoading ? "..." : currentPage} of{" "}
{logs.isLoading ? "..." : filteredLogs ? filteredLogs.total_pages : 1}
</span>
@ -695,21 +708,20 @@ export default function SpendLogsTable({
</div>
</>
)}
</TabPanel>
<TabPanel>
<AuditLogs
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
isActive={activeTab === "audit logs"}
premiumUser={premiumUser}
/>
</TabPanel>
<TabPanel><DeletedKeysPage /></TabPanel>
<TabPanel><DeletedTeamsPage /></TabPanel>
</TabPanels>
</TabGroup>
</TabsContent>
<TabsContent value="audit-logs">
<AuditLogs
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
isActive={activeTab === "audit logs"}
premiumUser={premiumUser}
/>
</TabsContent>
<TabsContent value="deleted-keys"><DeletedKeysPage /></TabsContent>
<TabsContent value="deleted-teams"><DeletedTeamsPage /></TabsContent>
</Tabs>
{/* Log Details Drawer */}
<LogDetailsDrawer
@ -823,9 +835,14 @@ export function RequestViewer({ row, onOpenSettings }: { row: Row<LogEntry>; onO
<div className="flex">
<span className="font-medium w-1/3">Request ID:</span>
{row.original.request_id.length > 64 ? (
<Tooltip title={row.original.request_id}>
<span className="font-mono text-sm">{truncatedRequestId}</span>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="font-mono text-sm">{truncatedRequestId}</span>
</TooltipTrigger>
<TooltipContent>{row.original.request_id}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="font-mono text-sm">{row.original.request_id}</span>
)}
@ -848,9 +865,14 @@ export function RequestViewer({ row, onOpenSettings }: { row: Row<LogEntry>; onO
</div>
<div className="flex">
<span className="font-medium w-1/3">API Base:</span>
<Tooltip title={row.original.api_base || "-"}>
<span className="max-w-[15ch] truncate block">{row.original.api_base || "-"}</span>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="max-w-[15ch] truncate block">{row.original.api_base || "-"}</span>
</TooltipTrigger>
<TooltipContent>{row.original.api_base || "-"}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{row?.original?.requester_ip_address && (
<div className="flex">
@ -936,7 +958,7 @@ export function RequestViewer({ row, onOpenSettings }: { row: Row<LogEntry>; onO
{row.original.metadata?.attempted_retries !== undefined && row.original.metadata?.attempted_retries !== null
? row.original.metadata.attempted_retries > 0
? `${row.original.metadata.attempted_retries}${row.original.metadata.max_retries !== undefined && row.original.metadata.max_retries !== null ? ` / ${row.original.metadata.max_retries}` : ''}`
: <Tag color="green">None</Tag>
: <Badge className="bg-green-100 text-green-700 hover:bg-green-100">None</Badge>
: '-'}
</span>
</div>