From 889d12f9b97ebea50f54ae031d81c25cf9658fce Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 10:41:25 +0000 Subject: [PATCH] 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[]; 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 --- ui/litellm-dashboard/.eslintrc.json | 2 + .../SpendLogsSettingsModal.test.tsx | 8 +- .../SpendLogsSettingsModal.tsx | 308 +++++++++------ .../src/components/view_logs/audit_logs.tsx | 354 +++++++++++------- .../src/components/view_logs/index.tsx | 110 +++--- 5 files changed, 498 insertions(+), 284 deletions(-) diff --git a/ui/litellm-dashboard/.eslintrc.json b/ui/litellm-dashboard/.eslintrc.json index 4e09cbacd0a..15d2b45de20 100644 --- a/ui/litellm-dashboard/.eslintrc.json +++ b/ui/litellm-dashboard/.eslintrc.json @@ -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", diff --git a/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.test.tsx b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.test.tsx index 40d06d90461..5817f0f5774 100644 --- a/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.test.tsx @@ -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); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx index 8d6cd3a5b48..3799898f7cc 100644 --- a/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx @@ -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 = ({ 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 = ({ + 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({ + 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 = ({ 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 ( - (!o ? handleCancel() : undefined)} - > + (!o ? handleCancel() : undefined)}> Spend Logs Settings -
- 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" - > -
- {isLoadingConfig ? ( - - ) : ( - - form.setFieldValue( - "store_prompts_in_spend_logs", - checked, - ) - } - /> - )} -
-
- - - 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 ? ( - - ) : ( -
- - -
- )} -
-
+ +
+ + + +
@@ -194,4 +203,93 @@ const SpendLogsSettingsModal: React.FC = ({ isVisib ); }; +function FieldTooltip({ text }: { text: string }) { + return ( + + + + + + {text} + + + ); +} + +function StorePromptsField({ + isLoading, + description, +}: { + isLoading: boolean; + description: string; +}) { + const { control } = useFormContext(); + return ( +
+
+ + +
+
+ {isLoading ? ( + + ) : ( + ( + + )} + /> + )} +
+
+ ); +} + +function RetentionPeriodField({ + isLoading, + description, +}: { + isLoading: boolean; + description: string; +}) { + const { register } = useFormContext(); + return ( +
+
+ + +
+ {isLoading ? ( + + ) : ( +
+ + +
+ )} +
+ ); +} + export default SpendLogsSettingsModal; diff --git a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx index ddd80fd3cf2..2b9dc3481b2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx @@ -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 = { LiteLLM_ProxyModelTable: "Models", }; -const ACTION_COLOR: Record = { - created: "green", - updated: "blue", - deleted: "red", - rotated: "orange", +/** Per-action palette — categorical; kept raw via eslint override. */ +const ACTION_BADGE_CLASS: Record = { + 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 ( +
+ + { + 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 && ( + + )} +
+ ); +} + export default function AuditLogs({ userID, userRole, @@ -105,85 +177,75 @@ export default function AuditLogs({ setDrawerOpen(true); }; - const columns: ColumnsType = [ + const columns: ColumnDef[] = [ { - title: "Timestamp", - dataIndex: "updated_at", - key: "updated_at", - width: 200, - render: (val: string) => ( + header: "Timestamp", + accessorKey: "updated_at", + cell: (info) => ( - {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")} ), }, { - title: "Action", - dataIndex: "action", - key: "action", - width: 100, - render: (val: string) => ( - - {val} - - ), + header: "Action", + accessorKey: "action", + cell: (info) => { + const val = info.getValue() as string; + return ( + + {val} + + ); + }, }, { - 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 {TABLE_NAME_DISPLAY[val] ?? val}; + }, }, { - title: "Object ID", - dataIndex: "object_id", - key: "object_id", - render: (val: string) => ( - {val} - ), + header: "Object ID", + accessorKey: "object_id", + cell: (info) => {info.getValue() as string}, }, { - title: "Changed By", - dataIndex: "changed_by", - key: "changed_by", - width: 200, - render: (val: string) => , + header: "Changed By", + accessorKey: "changed_by", + cell: (info) => , }, { - 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 ? ( {val.slice(0, 12)}… ) : ( - "—" - ), + — + ); + }, }, ]; if (!premiumUser) { return ( -
-

✨ Enterprise Feature.

-

+

+

✨ Enterprise Feature.

+

This is a LiteLLM Enterprise feature, and requires a valid key to use.

-

+

Here's a preview of what Audit Logs offer:

+ {/* eslint-disable-next-line @next/next/no-img-element */} Audit Logs Preview { (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 ( <> -
+
{/* Header */}
@@ -206,96 +269,125 @@ export default function AuditLogs({ {/* Filters + pagination on same row */}
- { setObjectId(val); resetPage(); }} - onChange={(e) => { if (!e.target.value) { setObjectId(""); resetPage(); } }} + width={200} + onSubmit={(val) => { setObjectId(val); resetPage(); }} + onClear={() => { setObjectId(""); resetPage(); }} /> - { setChangedBy(val); resetPage(); }} - onChange={(e) => { if (!e.target.value) { setChangedBy(""); resetPage(); } }} + width={180} + onSubmit={(val) => { setChangedBy(val); resetPage(); }} + onClear={() => { setChangedBy(""); resetPage(); }} /> - { setTeamId(val); resetPage(); }} - onChange={(e) => { if (!e.target.value) { setTeamId(""); resetPage(); } }} + width={180} + onSubmit={(val) => { setTeamId(val); resetPage(); }} + onClear={() => { setTeamId(""); resetPage(); }} /> - { setKeyHash(val); resetPage(); }} - onChange={(e) => { if (!e.target.value) { setKeyHash(""); resetPage(); } }} + width={180} + onSubmit={(val) => { setKeyHash(val); resetPage(); }} + onClear={() => { setKeyHash(""); resetPage(); }} /> {/* Pagination + refresh pushed to the right */}
+
+ {total} total + + + Page {page} / {totalPages} + + +
{/* Table — pagination handled in header */} - - columns={columns} - dataSource={auditLogs} - rowKey="id" - loading={{ - spinning: query.isLoading, - indicator: } size="small" />, - }} - size="small" - pagination={false} - onRow={(record) => ({ - onClick: () => handleRowClick(record), - style: { cursor: "pointer" }, - })} - /> + {query.isLoading ? ( +
+ + + +
+ + Loading audit logs… +
+
+ ) : ( + + columns={columns} + data={auditLogs} + onRowClick={handleRowClick} + /> + )}
{ return (
- Live Tail - + Live Tail +
); }; @@ -472,22 +479,27 @@ export default function SpendLogsTable({ return (
- setActiveTab(index === 0 ? "request logs" : "audit logs")}> - - Request Logs - Audit Logs - Deleted Keys - Deleted Teams - - - + setActiveTab(val === "request-logs" ? "request logs" : val === "audit-logs" ? "audit logs" : val)} + > + + Request Logs + Audit Logs + Deleted Keys + Deleted Teams + +

Request Logs

{selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView ? ( setSearchTerm(e.target.value)} />
@@ -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" />
- to + to
- + 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
- + Page {logs.isLoading ? "..." : currentPage} of{" "} {logs.isLoading ? "..." : filteredLogs ? filteredLogs.total_pages : 1} @@ -695,21 +708,20 @@ export default function SpendLogsTable({
)} - - - - - - - - + + + + + + + {/* Log Details Drawer */} ; onO
Request ID: {row.original.request_id.length > 64 ? ( - - {truncatedRequestId} - + + + + {truncatedRequestId} + + {row.original.request_id} + + ) : ( {row.original.request_id} )} @@ -848,9 +865,14 @@ export function RequestViewer({ row, onOpenSettings }: { row: Row; onO
API Base: - - {row.original.api_base || "-"} - + + + + {row.original.api_base || "-"} + + {row.original.api_base || "-"} + +
{row?.original?.requester_ip_address && (
@@ -936,7 +958,7 @@ export function RequestViewer({ row, onOpenSettings }: { row: Row; 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}` : ''}` - : None + : None : '-'}