Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_dedup_mcp_oauth_hooks

# Conflicts:
#	ui/litellm-dashboard/eslint-metrics.json
This commit is contained in:
ryan-crabbe-berri 2026-07-08 16:17:40 -07:00
commit e1dd4f593d
86 changed files with 1420 additions and 1205 deletions

View file

@ -213,7 +213,7 @@ class GraySwanGuardrail(CustomGuardrail):
verbose_proxy_logger.debug("Gray Swan Guardrail: dynamic extra_body=%s", safe_dumps(dynamic_body))
# Prepare and send payload
payload = self._prepare_payload(messages, dynamic_body, request_data)
payload = self._prepare_payload(messages, dynamic_body, request_data, logging_obj)
if payload is None:
return inputs
@ -502,10 +502,38 @@ class GraySwanGuardrail(CustomGuardrail):
"grayswan-api-key": self.api_key,
}
def _extract_inbound_headers(
self,
request_data: dict,
logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> Optional[dict[str, str]]:
headers = (request_data.get("proxy_server_request") or {}).get("headers")
if not headers:
headers = request_data.get("headers")
if not headers:
headers = (request_data.get("metadata") or {}).get("headers")
if not headers and logging_obj and getattr(logging_obj, "model_call_details", None):
headers = (
(logging_obj.model_call_details or {}).get("litellm_params", {}).get("metadata", {}).get("headers")
)
if not isinstance(headers, dict):
return None
forwarded_header_names = ("shade_scan_id",)
forwarded_headers = {}
for key, value in headers.items():
if str(key).lower() in forwarded_header_names:
forwarded_headers[str(key)] = str(value)
return forwarded_headers or None
def _prepare_payload(
self, messages: List[Dict[str, str]], dynamic_body: dict, request_data: dict
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {"messages": messages}
self,
messages: list[dict[str, str]],
dynamic_body: dict,
request_data: dict,
logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> Optional[dict[str, Any]]:
payload: dict[str, Any] = {"messages": messages}
categories = dynamic_body.get("categories") or self.categories
if categories:
@ -523,10 +551,16 @@ class GraySwanGuardrail(CustomGuardrail):
if "metadata" in dynamic_body:
payload["metadata"] = dynamic_body["metadata"]
inbound_headers = self._extract_inbound_headers(request_data, logging_obj)
litellm_metadata = request_data.get("litellm_metadata")
if isinstance(litellm_metadata, dict) and litellm_metadata:
cleaned_litellm_metadata = dict(litellm_metadata)
# cleaned_litellm_metadata.pop("user_api_key_auth", None)
cleaned_litellm_metadata = dict(litellm_metadata) if isinstance(litellm_metadata, dict) else {}
if inbound_headers:
existing_headers = cleaned_litellm_metadata.get("headers")
cleaned_litellm_metadata["headers"] = (
{**existing_headers, **inbound_headers} if isinstance(existing_headers, dict) else inbound_headers
)
if cleaned_litellm_metadata:
sanitized = safe_json_loads(safe_dumps(cleaned_litellm_metadata), default={})
if isinstance(sanitized, dict) and sanitized:
payload["litellm_metadata"] = sanitized

View file

@ -5,6 +5,7 @@ from fastapi import HTTPException
from litellm.integrations.custom_guardrail import ModifyResponseException
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails.guardrail_hooks.grayswan import grayswan as grayswan_module
from litellm.proxy.guardrails.guardrail_hooks.grayswan.grayswan import (
GraySwanGuardrail,
GraySwanGuardrailAPIError,
@ -70,12 +71,118 @@ def test_prepare_payload_includes_dynamic_metadata(
assert payload["metadata"] == dynamic_body["metadata"]
def test_prepare_payload_forwards_only_scan_id_header(
grayswan_guardrail: GraySwanGuardrail,
) -> None:
messages = [{"role": "user", "content": "hello"}]
request_data = {
"proxy_server_request": {
"headers": {
"SHADE_SCAN_ID": "scan-123",
"authorization": "Bearer secret",
}
},
"litellm_metadata": {"request_id": "request-123"},
}
payload = grayswan_guardrail._prepare_payload(messages, {}, request_data)
assert payload["litellm_metadata"] == {
"request_id": "request-123",
"headers": {"SHADE_SCAN_ID": "scan-123"},
}
def test_prepare_payload_merges_scan_id_with_existing_metadata_headers(
grayswan_guardrail: GraySwanGuardrail,
) -> None:
messages = [{"role": "user", "content": "hello"}]
request_data = {
"proxy_server_request": {
"headers": {
"shade_scan_id": "scan-123",
}
},
"litellm_metadata": {
"request_id": "request-123",
"headers": {"x-existing": "keep-me"},
},
}
payload = grayswan_guardrail._prepare_payload(messages, {}, request_data)
assert payload["litellm_metadata"] == {
"request_id": "request-123",
"headers": {
"x-existing": "keep-me",
"shade_scan_id": "scan-123",
},
}
def test_prepare_payload_sanitizes_headers_when_litellm_metadata_absent(
monkeypatch: pytest.MonkeyPatch,
grayswan_guardrail: GraySwanGuardrail,
) -> None:
messages = [{"role": "user", "content": "hello"}]
request_data = {
"proxy_server_request": {
"headers": {
"shade_scan_id": "scan-123",
}
}
}
monkeypatch.setattr(grayswan_module, "safe_dumps", lambda _data: "{}")
payload = grayswan_guardrail._prepare_payload(messages, {}, request_data)
assert "litellm_metadata" not in payload
def test_prepare_payload_extracts_headers_from_logging_obj(
grayswan_guardrail: GraySwanGuardrail,
) -> None:
messages = [{"role": "user", "content": "hello"}]
request_data = {}
logging_obj = type(
"LoggingObj",
(),
{
"model_call_details": {
"litellm_params": {
"metadata": {
"headers": {
"shade_scan_id": "scan-from-logging",
"authorization": "Bearer secret",
}
}
}
}
},
)()
payload = grayswan_guardrail._prepare_payload(messages, {}, request_data, logging_obj)
assert payload["litellm_metadata"] == {
"headers": {"shade_scan_id": "scan-from-logging"},
}
def test_prepare_payload_ignores_logging_obj_without_model_call_details(
grayswan_guardrail: GraySwanGuardrail,
) -> None:
messages = [{"role": "user", "content": "hello"}]
payload = grayswan_guardrail._prepare_payload(messages, {}, {}, object())
assert "litellm_metadata" not in payload
def test_process_response_does_not_block_under_threshold(
grayswan_guardrail: GraySwanGuardrail,
) -> None:
grayswan_guardrail._process_grayswan_response(
{"violation": 0.3, "violated_rules": []}
)
grayswan_guardrail._process_grayswan_response({"violation": 0.3, "violated_rules": []})
def test_process_response_blocks_when_threshold_exceeded() -> None:
@ -127,16 +234,12 @@ class _DummyClient:
self.calls: list[dict] = []
async def post(self, *, url: str, headers: dict, json: dict, timeout: float):
self.calls.append(
{"url": url, "headers": headers, "json": json, "timeout": timeout}
)
self.calls.append({"url": url, "headers": headers, "json": json, "timeout": timeout})
return _DummyResponse(self.payload)
@pytest.mark.asyncio
async def test_run_guardrail_posts_payload(
monkeypatch, grayswan_guardrail: GraySwanGuardrail
) -> None:
async def test_run_guardrail_posts_payload(monkeypatch, grayswan_guardrail: GraySwanGuardrail) -> None:
dummy_client = _DummyClient({"violation": 0.1})
grayswan_guardrail.async_handler = dummy_client
@ -308,9 +411,7 @@ def test_process_response_passthrough_raises_exception_in_pre_call() -> None:
# Should raise ModifyResponseException
with pytest.raises(ModifyResponseException) as exc:
guardrail._process_grayswan_response(
response_json, data, GuardrailEventHooks.pre_call
)
guardrail._process_grayswan_response(response_json, data, GuardrailEventHooks.pre_call)
assert "Gray Swan Cygnal Guardrail" in exc.value.message
assert exc.value.model == "gpt-4"
@ -338,9 +439,7 @@ def test_process_response_passthrough_raises_exception_in_during_call() -> None:
# Should raise ModifyResponseException
with pytest.raises(ModifyResponseException) as exc:
guardrail._process_grayswan_response(
response_json, data, GuardrailEventHooks.during_call
)
guardrail._process_grayswan_response(response_json, data, GuardrailEventHooks.during_call)
assert "Gray Swan Cygnal Guardrail" in exc.value.message
assert exc.value.model == "gpt-4"
@ -365,9 +464,7 @@ def test_process_response_passthrough_stores_detection_info_in_post_call() -> No
}
# Should NOT raise an exception in post_call
guardrail._process_grayswan_response(
response_json, data, GuardrailEventHooks.post_call
)
guardrail._process_grayswan_response(response_json, data, GuardrailEventHooks.post_call)
# Verify detection info was stored in metadata
assert "metadata" in data
@ -400,9 +497,7 @@ def test_process_response_passthrough_does_not_raise_if_under_threshold() -> Non
}
# Should not raise an exception since under threshold
guardrail._process_grayswan_response(
response_json, data, GuardrailEventHooks.pre_call
)
guardrail._process_grayswan_response(response_json, data, GuardrailEventHooks.pre_call)
# Should not have any detection info since it didn't exceed threshold
assert "guardrail_detections" not in data.get("metadata", {})
@ -436,10 +531,7 @@ def test_format_violation_message() -> None:
assert "Gray Swan Cygnal Guardrail" in message
assert "the input query has a violation score of 0.85" in message
assert "violating the rule(s): 1, 3, 5" in message
assert (
"Mutation effort to make the harmful intention disguised was DETECTED"
in message
)
assert "Mutation effort to make the harmful intention disguised was DETECTED" in message
# IPI should not be in message since it's False
assert "Indirect Prompt Injection was DETECTED" not in message
@ -450,10 +542,7 @@ def test_format_violation_message() -> None:
assert "Gray Swan Cygnal Guardrail" in message
assert "the model response has a violation score of 0.85" in message
assert "violating the rule(s): 1, 3, 5" in message
assert (
"Mutation effort to make the harmful intention disguised was DETECTED"
in message
)
assert "Mutation effort to make the harmful intention disguised was DETECTED" in message
def test_prepare_payload_includes_litellm_metadata(

View file

@ -1,7 +1,7 @@
{
"@typescript-eslint/no-explicit-any": 1988,
"@typescript-eslint/no-explicit-any": 1982,
"complexity": 127,
"local/no-large-inline-object-arg": 509,
"local/no-large-inline-object-arg": 508,
"local/no-long-condition-chain": 233,
"max-depth": 59,
"no-console": 15

View file

@ -1650,7 +1650,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/components/guardrails/tool_permission/ToolPermissionRulesEditor.tsx": {
@ -2491,11 +2491,6 @@
"count": 4
}
},
"src/components/view_logs/columns.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/view_logs/index.tsx": {
"no-restricted-imports": {
"count": 1

View file

@ -19,6 +19,7 @@ import {
SortState,
TableHeaderSortDropdown,
} from "@/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal";
import { AccessGroup } from "./types";
@ -143,21 +144,7 @@ export function AccessGroupsPage() {
header: () => <span>ID</span>,
enableSorting: false,
size: 170,
cell: ({ row }) => {
const record = row.original;
return (
<Tooltip title={record.id}>
<Text
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
style={{ fontSize: 14, padding: "1px 8px" }}
onClick={() => setSelectedGroupId(record.id)}
>
{record.id}
</Text>
</Tooltip>
);
},
cell: ({ row }) => <IdCell value={row.original.id} onClick={setSelectedGroupId} />,
},
{
id: "name",
@ -211,7 +198,7 @@ export function AccessGroupsPage() {
header: () => <span>Created</span>,
enableSorting: true,
sortingFn: "datetime",
cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString(),
cell: ({ getValue }) => <DateCell value={getValue() as string} precision="date" />,
meta: { responsive: ["lg"] },
},
{
@ -219,7 +206,7 @@ export function AccessGroupsPage() {
accessorKey: "updatedAt",
header: () => <span>Updated</span>,
enableSorting: false,
cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString(),
cell: ({ getValue }) => <DateCell value={getValue() as string} precision="date" />,
meta: { responsive: ["xl"] },
},
...(canModify

View file

@ -25,6 +25,7 @@ import DeleteResourceModal from "@/components/common_components/DeleteResourceMo
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { MoneyCell } from "@/components/shared/table_cells";
import BudgetModal from "./budget_modal";
import EditBudgetModal from "./edit_budget_modal";
import { CREATE_END_USER_CURL_COMMAND, CHAT_COMPLETIONS_CURL_COMMAND, OPENAI_SDK_PYTHON_CODE } from "./constants";
@ -127,7 +128,9 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
.map((value: budgetItem) => (
<TableRow key={value.budget_id}>
<TableCell>{value.budget_id}</TableCell>
<TableCell>{value.max_budget ? value.max_budget : "n/a"}</TableCell>
<TableCell>
<MoneyCell value={value.max_budget} decimals={2} showZero emptyText="Unlimited" />
</TableCell>
<TableCell>{value.tpm_limit ? value.tpm_limit : "n/a"}</TableCell>
<TableCell>{value.rpm_limit ? value.rpm_limit : "n/a"}</TableCell>
{canModify && (

View file

@ -0,0 +1,16 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getLicenseInfo, LicenseInfo } from "@/components/networking";
import { createQueryKeys } from "../common/queryKeysFactory";
const licenseInfoKeys = createQueryKeys("licenseInfo");
export const useLicenseInfo = (accessToken: string | null | undefined): UseQueryResult<LicenseInfo | null> => {
const options = {
queryKey: licenseInfoKeys.detail("license"),
queryFn: () => getLicenseInfo(accessToken!),
enabled: Boolean(accessToken),
staleTime: 5 * 60 * 1000,
retry: false,
};
return useQuery<LicenseInfo | null>(options);
};

View file

@ -21,6 +21,10 @@ vi.mock("@/components/DebugWarningBanner", () => ({
DebugWarningBanner: () => null,
}));
vi.mock("@/components/LicenseExpiryBanner", () => ({
LicenseExpiryBanner: () => null,
}));
vi.mock("@/contexts/ThemeContext", () => ({
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

View file

@ -8,6 +8,7 @@ import { useAuth } from "@/contexts/AuthContext";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner";
import { MIGRATED_PAGES, migratedHref, legacyPageHref, legacyKeyForPathname } from "@/utils/migratedPages";
import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext";
import { createApiClient } from "@/lib/http/client";
@ -116,6 +117,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
onToggleSidebar={() => setSidebarCollapsed((v) => !v)}
/>
<DebugWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<div className="flex flex-1">
{mode !== "ai-gateway" ? (
<div className="flex-1 flex">

View file

@ -2,7 +2,7 @@
import React, { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Card, Drawer, Empty, Input, Space, Table, Tooltip, Typography, message } from "antd";
import { Button, Card, Drawer, Empty, Input, Space, Table, Typography, message } from "antd";
import type { ColumnsType } from "antd/es/table";
import {
DeleteOutlined,
@ -13,6 +13,7 @@ import {
SearchOutlined,
} from "@ant-design/icons";
import { MemoryRow, createMemory, deleteMemory, fetchMemoryList, updateMemory } from "@/components/networking";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import { MemoryEditModal } from "./MemoryEditModal";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
@ -191,34 +192,13 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
}
};
const renderIdPill = (id: string | null | undefined, onClick?: () => void) => {
if (!id) return <Text type="secondary">-</Text>;
const short = id.length > 10 ? `${id.slice(0, 7)}...` : id;
const pillClass =
"font-mono text-blue-600 bg-blue-50 text-xs font-medium px-2 py-0.5 rounded-md border border-blue-200 inline-block max-w-[15ch] truncate whitespace-nowrap";
return (
<Tooltip title={id}>
{onClick ? (
<button
onClick={onClick}
className={`${pillClass} hover:bg-blue-100 cursor-pointer transition-colors text-left`}
>
{short}
</button>
) : (
<span className={pillClass}>{short}</span>
)}
</Tooltip>
);
};
const columns: ColumnsType<MemoryRow> = [
{
title: "ID",
dataIndex: "memory_id",
key: "memory_id",
width: 140,
render: (_: unknown, r: MemoryRow) => renderIdPill(r.memory_id, () => setDetailRow(r)),
render: (_: unknown, r: MemoryRow) => <IdCell value={r.memory_id} onClick={() => setDetailRow(r)} />,
},
{
title: "Name",
@ -246,21 +226,21 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
dataIndex: "user_id",
key: "user_id",
width: 160,
render: (uid?: string | null) => renderIdPill(uid),
render: (uid?: string | null) => <IdCell value={uid} />,
},
{
title: "Team ID",
dataIndex: "team_id",
key: "team_id",
width: 160,
render: (tid?: string | null) => renderIdPill(tid),
render: (tid?: string | null) => <IdCell value={tid} />,
},
{
title: "Updated",
dataIndex: "updated_at",
key: "updated_at",
width: 180,
render: (ts?: string) => <Text type="secondary">{formatTimestamp(ts)}</Text>,
render: (ts?: string) => <DateCell value={ts} />,
// No sorter — backend already returns rows in `updated_at DESC` order,
// and a client-side sorter on a paginated view would only affect the
// current page.

View file

@ -3,6 +3,7 @@ import { Empty, Table, Tooltip } from "antd";
import type { ColumnsType } from "antd/es/table";
import type { SpinProps } from "antd";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import { DateCell } from "@/components/shared/table_cells";
interface ProjectKeysTableProps {
keys: KeyResponse[];
@ -33,13 +34,13 @@ const columns: ColumnsType<KeyResponse> = [
title: "Created",
dataIndex: "created_at",
key: "created_at",
render: (date: string) => (date ? new Date(date).toLocaleDateString() : "—"),
render: (date: string) => <DateCell value={date} precision="date" />,
},
{
title: "Last Active",
dataIndex: "last_active",
key: "last_active",
render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : "Never"),
render: (date: string | null) => <DateCell value={date} precision="date" fallback="Never" />,
},
];

View file

@ -1,5 +1,6 @@
import { useProjects, ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
import {
Button,
@ -72,18 +73,7 @@ export function ProjectsPage() {
dataIndex: "project_id",
key: "project_id",
width: 170,
render: (id: string) => (
<Tooltip title={id}>
<Text
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
style={{ fontSize: 14, padding: "1px 8px" }}
onClick={() => setSelectedProjectId(id)}
>
{id}
</Text>
</Tooltip>
),
render: (id: string) => <IdCell value={id} onClick={setSelectedProjectId} />,
},
{
title: "Name",
@ -137,14 +127,14 @@ export function ProjectsPage() {
key: "created_at",
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
responsive: ["lg"],
render: (date: string) => new Date(date).toLocaleDateString(),
render: (date: string) => <DateCell value={date} precision="date" />,
},
{
title: "Updated",
dataIndex: "updated_at",
key: "updated_at",
responsive: ["xl"],
render: (date: string) => new Date(date).toLocaleDateString(),
render: (date: string) => <DateCell value={date} precision="date" />,
},
];

View file

@ -2,8 +2,8 @@ import React, { useState, useEffect } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Button } from "@tremor/react";
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, TrashIcon } from "@heroicons/react/outline";
import { Tooltip } from "antd";
import { CopyOutlined } from "@ant-design/icons";
import { PromptSpec, modelHubCall } from "@/components/networking";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import {
ColumnDef,
flexRender,
@ -62,48 +62,11 @@ const PromptTable: React.FC<PromptTableProps> = ({
fetchModelHubData();
}, [accessToken]);
// Format date helper function
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleString();
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const columns: ColumnDef<PromptSpec>[] = [
{
header: "Prompt ID",
accessorKey: "prompt_id",
cell: (info: any) => {
const fullId = String(info.getValue() || "");
const displayId = fullId.length > 25 ? `${fullId.slice(0, 25)}...` : fullId;
return (
<div className="flex items-center gap-2">
<Tooltip title={fullId}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate min-w-[220px] justify-start"
onClick={() => info.getValue() && onPromptClick?.(info.getValue())}
>
{displayId}
</Button>
</Tooltip>
<Tooltip title="Copy prompt ID">
<CopyOutlined
onClick={(e) => {
e.stopPropagation();
copyToClipboard(fullId);
}}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
);
},
cell: (info: any) => <IdCell value={info.getValue()} onClick={onPromptClick} copyable />,
},
{
header: "Model",
@ -162,26 +125,12 @@ const PromptTable: React.FC<PromptTableProps> = ({
{
header: "Created At",
accessorKey: "created_at",
cell: ({ row }) => {
const prompt = row.original;
return (
<Tooltip title={prompt.created_at}>
<span className="text-xs">{formatDate(prompt.created_at)}</span>
</Tooltip>
);
},
cell: ({ row }) => <DateCell value={row.original.created_at} />,
},
{
header: "Updated At",
accessorKey: "updated_at",
cell: ({ row }) => {
const prompt = row.original;
return (
<Tooltip title={prompt.updated_at}>
<span className="text-xs">{formatDate(prompt.updated_at)}</span>
</Tooltip>
);
},
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
},
{
header: "Environment",

View file

@ -1,6 +1,7 @@
import { Tag } from "antd";
import { ColumnsType } from "antd/es/table";
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import { SearchTool } from "./types";
export const searchToolColumns = (
@ -20,14 +21,7 @@ export const searchToolColumns = (
return <span className="text-xs">-</span>;
}
return (
<button
onClick={() => onView(tool.search_tool_id!)}
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left cursor-pointer max-w-40"
>
<span className="truncate block">{tool.search_tool_id}</span>
</button>
);
return <IdCell value={tool.search_tool_id} onClick={onView} />;
},
},
{
@ -52,7 +46,7 @@ export const searchToolColumns = (
dataIndex: "created_at",
key: "created_at",
render: (_, tool) => {
return <span className="text-xs">{tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}</span>;
return <DateCell value={tool.created_at} precision="date" />;
},
},
{
@ -60,7 +54,7 @@ export const searchToolColumns = (
dataIndex: "updated_at",
key: "updated_at",
render: (_, tool) => {
return <span className="text-xs">{tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}</span>;
return <DateCell value={tool.updated_at} precision="date" />;
},
},
{

View file

@ -91,7 +91,7 @@ describe("BulkEditUserModal", () => {
it("should display budget information in table", () => {
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
expect(screen.getByText("$50")).toBeInTheDocument();
expect(screen.getByText("$50.00")).toBeInTheDocument();
expect(screen.getByText("Unlimited")).toBeInTheDocument();
});

View file

@ -4,6 +4,7 @@ import { userBulkUpdateUserCall, teamBulkMemberAddCall, Member } from "@/compone
import { UserEditView } from "./user_edit_view";
import NotificationsManager from "@/components/molecules/notifications_manager";
import MessageManager from "@/components/molecules/message_manager";
import { MoneyCell } from "@/components/shared/table_cells";
const { Text, Title } = Typography;
@ -270,7 +271,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
key: "max_budget",
width: "20%",
render: (budget: number | null) => (
<Text style={{ fontSize: "12px" }}>{budget !== null ? `$${budget}` : "Unlimited"}</Text>
<MoneyCell value={budget} decimals={2} emptyText="Unlimited" showZero />
),
},
]}

View file

@ -158,7 +158,8 @@ describe("ViewUserDashboard", () => {
expect(
screen.getByText("Are you sure you want to delete this user? This action cannot be undone."),
).toBeInTheDocument();
expect(screen.getByText("user-1")).toBeInTheDocument();
const userIdInstances = screen.getAllByText("user-1");
expect(userIdInstances.length).toBeGreaterThan(0);
const emailInstances = screen.getAllByText("test@example.com");
expect(emailInstances.length).toBeGreaterThan(0);
});

View file

@ -3,8 +3,7 @@ import { Badge, Grid, Icon } from "@tremor/react";
import { Tooltip, Checkbox, Tag } from "antd";
import { UserInfo } from "@/components/networking";
import { PencilAltIcon, TrashIcon, InformationCircleIcon, RefreshIcon } from "@heroicons/react/outline";
import { CopyOutlined } from "@ant-design/icons";
import { formatNumberWithCommas, copyToClipboard } from "@/utils/dataUtils";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
interface SelectionOptions {
selectedUsers: UserInfo[];
@ -29,24 +28,7 @@ export const columns = (
header: "User ID",
accessorKey: "user_id",
enableSorting: true,
cell: ({ row }) => (
<div className="flex items-center space-x-2">
<Tooltip title={row.original.user_id}>
<span className="text-xs">{row.original.user_id ? `${row.original.user_id.slice(0, 7)}...` : "-"}</span>
</Tooltip>
{row.original.user_id && (
<Tooltip title="Copy User ID">
<CopyOutlined
onClick={(e) => {
e.stopPropagation();
copyToClipboard(row.original.user_id, "User ID copied to clipboard");
}}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
)}
</div>
),
cell: ({ row }) => <IdCell value={row.original.user_id} variant="plain" copyable />,
},
{
header: "Email",
@ -93,17 +75,13 @@ export const columns = (
header: "Spend (USD)",
accessorKey: "spend",
enableSorting: true,
cell: ({ row }) => (
<span className="text-xs">{row.original.spend ? formatNumberWithCommas(row.original.spend, 4) : "-"}</span>
),
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
},
{
header: "Budget (USD)",
accessorKey: "max_budget",
enableSorting: false,
cell: ({ row }) => (
<span className="text-xs">{row.original.max_budget !== null ? row.original.max_budget : "Unlimited"}</span>
),
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={2} emptyText="Unlimited" showZero />,
},
{
header: () => (
@ -142,21 +120,13 @@ export const columns = (
header: "Created At",
accessorKey: "created_at",
enableSorting: true,
cell: ({ row }) => (
<span className="text-xs">
{row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "-"}
</span>
),
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
header: "Updated At",
accessorKey: "updated_at",
enableSorting: false,
cell: ({ row }) => (
<span className="text-xs">
{row.original.updated_at ? new Date(row.original.updated_at).toLocaleDateString() : "-"}
</span>
),
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
},
{
id: "actions",

View file

@ -2,6 +2,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { Button, Badge, Text } from "@tremor/react";
import { Tooltip, Tag } from "antd";
import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { StatusBadge } from "@/components/shared/table_cells";
export interface AgentHubData {
agent_id?: string;
@ -194,17 +195,9 @@ export const getAgentHubTableColumns = (
return publicA - publicB;
},
cell: ({ row }) => {
const agent = row.original;
const isPublic = row.original.is_public === true;
return agent.is_public === true ? (
<Badge color="green" size="xs">
Yes
</Badge>
) : (
<Badge color="gray" size="xs">
No
</Badge>
);
return <StatusBadge tone={isPublic ? "success" : "neutral"} label={isPublic ? "Yes" : "No"} />;
},
meta: {
className: "hidden md:table-cell",

View file

@ -1,5 +1,5 @@
"use client";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
import {
ColumnDef,
@ -59,14 +59,7 @@ export function DeletedKeysTable({
header: "Key ID",
size: 150,
maxSize: 250,
cell: (info) => {
const value = info.getValue() as string;
return (
<Tooltip title={value}>
<span className="font-mono text-blue-500 text-xs truncate block max-w-[250px]">{value || "-"}</span>
</Tooltip>
);
},
cell: (info) => <IdCell value={info.getValue() as string} variant="plain" />,
},
{
id: "key_alias",
@ -100,9 +93,7 @@ export function DeletedKeysTable({
header: "Spend (USD)",
size: 100,
maxSize: 140,
cell: (info) => (
<span className="block max-w-[140px]">{formatNumberWithCommas(info.getValue() as number, 4)}</span>
),
cell: (info) => <MoneyCell value={info.getValue() as number} decimals={4} />,
},
{
id: "max_budget",
@ -110,14 +101,9 @@ export function DeletedKeysTable({
header: "Budget (USD)",
size: 110,
maxSize: 150,
cell: (info) => {
const maxBudget = info.getValue() as number | null;
return (
<span className="block max-w-[150px]">
{maxBudget === null ? "Unlimited" : `$${formatNumberWithCommas(maxBudget)}`}
</span>
);
},
cell: (info) => (
<MoneyCell value={info.getValue() as number | null} decimals={0} emptyText="Unlimited" showZero />
),
},
{
id: "user_email",
@ -140,14 +126,7 @@ export function DeletedKeysTable({
header: "User ID",
size: 120,
maxSize: 200,
cell: (info) => {
const userId = info.getValue() as string | null;
return (
<Tooltip title={userId || undefined}>
<span className="truncate block max-w-[200px]">{userId || "-"}</span>
</Tooltip>
);
},
cell: (info) => <IdCell value={info.getValue() as string | null} variant="plain" />,
},
{
id: "created_at",
@ -155,12 +134,7 @@ export function DeletedKeysTable({
header: "Created At",
size: 120,
maxSize: 140,
cell: (info) => {
const value = info.getValue();
return (
<span className="block max-w-[140px]">{value ? new Date(value as string).toLocaleDateString() : "-"}</span>
);
},
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
},
{
id: "created_by",
@ -183,10 +157,9 @@ export function DeletedKeysTable({
header: "Deleted At",
size: 120,
maxSize: 140,
cell: (info) => {
const value = (info.row.original as any).deleted_at as string | null | undefined;
return <span className="block max-w-[140px]">{value ? new Date(value).toLocaleDateString() : "-"}</span>;
},
cell: (info) => (
<DateCell value={(info.row.original as any).deleted_at as string | null | undefined} precision="date" />
),
},
{
id: "deleted_by",

View file

@ -1,5 +1,5 @@
"use client";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
import {
ColumnDef,
@ -51,14 +51,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
header: "Team ID",
size: 150,
maxSize: 250,
cell: (info) => {
const value = info.getValue() as string;
return (
<Tooltip title={value}>
<span className="font-mono text-blue-500 text-xs truncate block max-w-[250px]">{value || "-"}</span>
</Tooltip>
);
},
cell: (info) => <IdCell value={info.getValue() as string} />,
},
{
id: "created_at",
@ -66,12 +59,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
header: "Created",
size: 120,
maxSize: 140,
cell: (info) => {
const value = info.getValue();
return (
<span className="block max-w-[140px]">{value ? new Date(value as string).toLocaleDateString() : "-"}</span>
);
},
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
},
{
id: "spend",
@ -79,12 +67,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
header: "Spend (USD)",
size: 100,
maxSize: 140,
cell: (info) => {
const spend = (info.row.original as any).spend as number | undefined;
return (
<span className="block max-w-[140px]">{spend !== undefined ? formatNumberWithCommas(spend, 4) : "-"}</span>
);
},
cell: (info) => <MoneyCell value={info.getValue() as number | undefined} decimals={4} />,
},
{
id: "max_budget",
@ -92,14 +75,9 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
header: "Budget (USD)",
size: 110,
maxSize: 150,
cell: (info) => {
const maxBudget = info.getValue() as number | null;
return (
<span className="block max-w-[150px]">
{maxBudget === null || maxBudget === undefined ? "No limit" : `$${formatNumberWithCommas(maxBudget)}`}
</span>
);
},
cell: (info) => (
<MoneyCell value={info.getValue() as number | null} decimals={0} emptyText="Unlimited" showZero />
),
},
{
id: "models",
@ -148,14 +126,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
header: "Organization",
size: 150,
maxSize: 200,
cell: (info) => {
const value = info.getValue() as string;
return (
<Tooltip title={value || undefined}>
<span className="truncate block max-w-[200px]">{value || "-"}</span>
</Tooltip>
);
},
cell: (info) => <IdCell value={info.getValue() as string} variant="plain" />,
},
{
id: "deleted_at",
@ -163,10 +134,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
header: "Deleted At",
size: 120,
maxSize: 140,
cell: (info) => {
const value = (info.row.original as any).deleted_at as string | null | undefined;
return <span className="block max-w-[140px]">{value ? new Date(value).toLocaleDateString() : "-"}</span>;
},
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
},
{
id: "deleted_by",

View file

@ -0,0 +1,90 @@
import React from "react";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { LicenseExpiryBannerView } from "./LicenseExpiryBanner";
import { LicenseInfo } from "./networking";
const daysFromNow = (n: number): string => {
const date = new Date();
date.setUTCDate(date.getUTCDate() + n);
return date.toISOString().slice(0, 10);
};
const licenseWith = (expiration_date: string | null): LicenseInfo => ({
has_license: expiration_date !== null,
license_type: expiration_date !== null ? "enterprise" : "community",
expiration_date,
allowed_features: [],
limits: { max_users: null, max_teams: null },
});
describe("LicenseExpiryBannerView", () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
sessionStorage.clear();
});
it("renders nothing when there is no license info", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={null} />);
expect(container).toBeEmptyDOMElement();
});
it("renders nothing when expiration_date is null (community or remote-validated)", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(null)} />);
expect(container).toBeEmptyDOMElement();
});
it("renders nothing when expiry is more than 30 days out", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(40))} />);
expect(container).toBeEmptyDOMElement();
});
it("shows a dismissible amber warning within 30 days", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(20))} />);
expect(screen.getByText(/expires in 20 days/)).toBeInTheDocument();
expect(container.querySelector(".ant-alert-warning")).toBeInTheDocument();
expect(screen.queryByRole("button")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "sales@berri.ai" })).toHaveAttribute("href", "mailto:sales@berri.ai");
});
it("shows a non-dismissible red critical alert within 7 days", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(5))} />);
expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument();
expect(container.querySelector(".ant-alert-error")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("says 'expires today' on the expiration day", () => {
render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(0))} />);
expect(screen.getByText(/expires today/)).toBeInTheDocument();
});
it("shows a non-dismissible red expired alert stating features are disabled", () => {
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(-3))} />);
expect(screen.getByText(/expired on/)).toBeInTheDocument();
expect(screen.getByText(/features are now disabled/i)).toBeInTheDocument();
expect(container.querySelector(".ant-alert-error")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("hides the warning after dismissal and stays hidden within the session", () => {
const expiration = daysFromNow(20);
const { unmount } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
fireEvent.click(screen.getByRole("button"));
expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument();
unmount();
render(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument();
});
it("still shows a critical alert even when its date was previously dismissed", () => {
const expiration = daysFromNow(5);
sessionStorage.setItem(`litellm:licenseExpiryBannerDismissed:${expiration}`, "true");
render(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,95 @@
"use client";
import React, { useState } from "react";
import { Alert } from "antd";
import { LicenseInfo } from "@/components/networking";
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
import { formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "@/utils/licenseUtils";
const DISMISS_KEY_PREFIX = "litellm:licenseExpiryBannerDismissed:";
const SALES_EMAIL = "sales@berri.ai";
const salesLink = <a href={`mailto:${SALES_EMAIL}`}>{SALES_EMAIL}</a>;
interface LicenseExpiryBannerProps {
accessToken: string | null;
}
interface LicenseExpiryBannerViewProps {
licenseInfo: LicenseInfo | null;
}
const describeCountdown = (days: number): string => {
if (days <= 0) {
return "expires today";
}
if (days === 1) {
return "expires in 1 day";
}
return `expires in ${days} days`;
};
const expiryDescription = (tier: "warning" | "critical" | "expired"): React.ReactNode => {
if (tier === "expired") {
return <>Enterprise features are now disabled. Reach out to {salesLink} to restore access</>;
}
if (tier === "critical") {
return <>Renew now to avoid losing enterprise features. Reach out to {salesLink}</>;
}
return <>Renew before it lapses to keep enterprise features. Reach out to {salesLink}</>;
};
export const LicenseExpiryBannerView: React.FC<LicenseExpiryBannerViewProps> = ({ licenseInfo }) => {
const [locallyDismissed, setLocallyDismissed] = useState(false);
const expirationDate = licenseInfo?.expiration_date ?? null;
const tier = getLicenseExpiryTier(expirationDate);
const days = getDaysUntilExpiration(expirationDate);
if (expirationDate === null || tier === "none" || days === null) {
return null;
}
const isDismissible = tier === "warning";
const dismissKey = `${DISMISS_KEY_PREFIX}${expirationDate}`;
const previouslyDismissed =
isDismissible && typeof window !== "undefined" ? sessionStorage.getItem(dismissKey) === "true" : false;
if (isDismissible && (locallyDismissed || previouslyDismissed)) {
return null;
}
const formattedDate = formatExpiryDate(expirationDate);
const message =
tier === "expired"
? `Your LiteLLM Enterprise license expired on ${formattedDate}`
: `Your LiteLLM Enterprise license ${describeCountdown(days)} (${formattedDate})`;
const description = expiryDescription(tier);
const handleClose = () => {
if (typeof window !== "undefined") {
sessionStorage.setItem(dismissKey, "true");
}
setLocallyDismissed(true);
};
return (
<Alert
message={message}
description={description}
type={tier === "warning" ? "warning" : "error"}
showIcon
banner
closable={isDismissible}
onClose={handleClose}
style={{ marginBottom: 0, borderRadius: 0 }}
/>
);
};
export const LicenseExpiryBanner: React.FC<LicenseExpiryBannerProps> = ({ accessToken }) => {
const { data } = useLicenseInfo(accessToken);
return <LicenseExpiryBannerView licenseInfo={data ?? null} />;
};

View file

@ -31,6 +31,7 @@ import type { SorterResult } from "antd/es/table/interface";
import { KeyIcon, LayersIcon, SearchIcon, UsersIcon } from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import OrganizationDropdown from "./common_components/OrganizationDropdown";
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { teamListCall as v2TeamListCall, type TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams";
@ -670,18 +671,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
key: "team_id",
width: 170,
ellipsis: true,
render: (id: string, record: Team) => (
<Tooltip title={id}>
<Text
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
style={{ fontSize: 14, padding: "1px 8px" }}
onClick={() => setSelectedTeamId(record.team_id)}
data-testid="team-id-cell"
>
{id}
</Text>
</Tooltip>
render: (id: string) => (
<IdCell value={id} onClick={(teamId) => setSelectedTeamId(teamId)} dataTestId="team-id-cell" />
),
},
{
@ -797,13 +788,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
width: 130,
ellipsis: true,
sorter: true,
render: (date: string | undefined) => (
<Text type="secondary" style={{ fontSize: 13 }}>
{date
? new Date(date).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
: "—"}
</Text>
),
render: (date: string | undefined) => <DateCell value={date} precision="date" />,
},
{
title: "Actions",

View file

@ -3,6 +3,7 @@ import type { TableProps } from "antd";
import { Table } from "antd";
import Title from "antd/es/typography/Title";
import React from "react";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
import TableIconActionButton from "../../../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { AlertingObject } from "./types";
@ -61,17 +62,8 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
// and server-fetched rows both render correctly.
const mode = record.type || record.mode || "success";
const label = CALLBACK_MODES.find((m) => m.value === mode)?.label || mode;
const badgeClass =
mode === "success"
? "bg-green-100 text-green-800"
: mode === "failure"
? "bg-red-100 text-red-800"
: "bg-blue-100 text-blue-800";
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${badgeClass}`}>
{label}
</span>
);
const tone: StatusTone = mode === "success" ? "success" : mode === "failure" ? "error" : "info";
return <StatusBadge tone={tone} label={label} />;
},
width: 240,
},

View file

@ -3,7 +3,7 @@
import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
import { Button, Switch, Tooltip } from "antd";
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
import { TimeCell } from "./view_logs/time_cell";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import type { SortState } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
import { TableHeaderSortDropdown } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
import FilterComponent, { FilterOption } from "./molecules/filter";
@ -461,7 +461,7 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
paginated.map((tool) => (
<TableRow key={tool.tool_id} id={`tool-row-${tool.tool_id}`} className="h-8 hover:bg-gray-50">
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
<TimeCell utcTime={tool.created_at ?? ""} />
<DateCell value={tool.created_at} />
</TableCell>
<TableCell className="py-0.5 max-h-8 overflow-hidden">
<button
@ -498,16 +498,10 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
</div>
</TableCell>
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
<Tooltip title={tool.team_id ?? "-"}>
<span className="max-w-[15ch] truncate block">{tool.team_id ?? "-"}</span>
</Tooltip>
<IdCell value={tool.team_id} variant="plain" />
</TableCell>
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
<Tooltip title={tool.key_hash ?? "-"}>
<span className="font-mono max-w-[15ch] truncate block text-blue-600">
{tool.key_hash ?? "-"}
</span>
</Tooltip>
<IdCell value={tool.key_hash} />
</TableCell>
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
<Tooltip title={tool.key_alias ?? "-"}>

View file

@ -2,6 +2,7 @@ import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import UsageIndicator from "./UsageIndicator";
vi.mock("./networking", () => ({
@ -17,6 +18,11 @@ import { getRemainingUsers } from "./networking";
const mockGetRemainingUsers = vi.mocked(getRemainingUsers);
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const DEFAULT_USAGE_DATA = {
total_users: 100,
total_users_used: 1,
@ -33,7 +39,7 @@ describe("UsageIndicator", () => {
});
it("should render when given access token and usage data loads", async () => {
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -41,7 +47,7 @@ describe("UsageIndicator", () => {
});
it("should not show Near limit when users usage is below 80% (1/100 -> 1%)", async () => {
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -58,7 +64,7 @@ describe("UsageIndicator", () => {
total_users_remaining: null,
});
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await waitFor(() => {
expect(screen.queryByText("Usage")).not.toBeInTheDocument();
@ -76,7 +82,7 @@ describe("UsageIndicator", () => {
total_teams_remaining: 1,
});
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -94,7 +100,7 @@ describe("UsageIndicator", () => {
total_teams_remaining: null,
});
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -112,7 +118,7 @@ describe("UsageIndicator", () => {
total_teams_remaining: -2,
});
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -121,7 +127,7 @@ describe("UsageIndicator", () => {
});
it("should render nothing when accessToken is null", () => {
render(<UsageIndicator accessToken={null} width={220} />);
renderWithClient(<UsageIndicator accessToken={null} width={220} />);
expect(mockGetRemainingUsers).not.toHaveBeenCalled();
expect(screen.queryByText("Usage")).not.toBeInTheDocument();
@ -131,7 +137,7 @@ describe("UsageIndicator", () => {
const { useDisableUsageIndicator } = await import("@/app/(dashboard)/hooks/useDisableUsageIndicator");
(useDisableUsageIndicator as ReturnType<typeof vi.fn>).mockReturnValue(true);
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await waitFor(() => {
expect(screen.queryByText("Usage")).not.toBeInTheDocument();
@ -143,7 +149,7 @@ describe("UsageIndicator", () => {
it("should show Loading while fetching", () => {
mockGetRemainingUsers.mockImplementation(() => new Promise(() => {}));
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
@ -152,7 +158,7 @@ describe("UsageIndicator", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockGetRemainingUsers.mockRejectedValue(new Error("Network error"));
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
expect(await screen.findByText("Failed to load usage data")).toBeInTheDocument();
@ -161,7 +167,7 @@ describe("UsageIndicator", () => {
it("should minimize when user clicks minimize button", async () => {
const user = userEvent.setup();
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");
@ -174,7 +180,7 @@ describe("UsageIndicator", () => {
it("should restore from minimized when user clicks restore button", async () => {
const user = userEvent.setup();
render(<UsageIndicator accessToken="token" width={220} />);
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
await screen.findByText("Usage");

View file

@ -12,9 +12,11 @@ import {
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { getRemainingUsers, getLicenseInfo, LicenseInfo } from "./networking";
import { getRemainingUsers } from "./networking";
import { cn } from "@/lib/cva.config";
import { getDaysUntilExpiration } from "@/utils/licenseUtils";
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
interface UsageIndicatorProps {
accessToken: string | null;
@ -30,17 +32,6 @@ interface UsageData {
total_teams_remaining: number | null;
}
// Calculate days until expiration
const getDaysUntilExpiration = (expirationDate: string | null): number | null => {
if (!expirationDate) return null;
const expDate = new Date(expirationDate + "T00:00:00Z"); // Force UTC midnight
const now = new Date();
now.setHours(0, 0, 0, 0); // Normalize to local midnight
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
// Format expiration for display
const formatExpirationDisplay = (daysRemaining: number | null): string => {
if (daysRemaining === null) return "No expiration";
@ -58,10 +49,11 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
const [isExpanded, setIsExpanded] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [data, setData] = useState<UsageData | null>(null);
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const licenseInfo = useLicenseInfo(accessToken).data ?? null;
useEffect(() => {
const fetchData = async () => {
if (!accessToken) return;
@ -70,12 +62,8 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
setError(null);
try {
const [usageResult, licenseResult] = await Promise.all([
getRemainingUsers(accessToken),
getLicenseInfo(accessToken).catch(() => null), // Don't fail if license endpoint unavailable
]);
const usageResult = await getRemainingUsers(accessToken);
setData(usageResult);
setLicenseInfo(licenseResult);
} catch (err) {
console.error("Failed to fetch usage data:", err);
setError("Failed to load usage data");

View file

@ -31,12 +31,6 @@ vi.mock("antd", async () => {
return { Table, Progress };
});
vi.mock("@/utils/dataUtils", () => ({
formatNumberWithCommas: (value: number, decimals?: number) => {
return value.toFixed(decimals || 0);
},
}));
describe("EndpointUsageTable", () => {
it("should render", () => {
const mockEndpointData = {

View file

@ -1,7 +1,7 @@
import React from "react";
import { Table, Progress } from "antd";
import type { ColumnsType } from "antd/es/table";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { MoneyCell } from "@/components/shared/table_cells";
import { MetricWithMetadata } from "../../../types";
interface EndpointUsageTableProps {
@ -112,7 +112,7 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
title: "Spend",
dataIndex: "spend",
key: "spend",
render: (value: number) => `$${formatNumberWithCommas(value, 2)}`,
render: (value: number) => <MoneyCell value={value} decimals={2} />,
},
];

View file

@ -1,4 +1,5 @@
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import {
BarChart,
@ -665,7 +666,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
.map((entity) => (
<TableRow key={entity.metadata.id}>
<TableCell>{entity.metadata.alias}</TableCell>
<TableCell>${formatNumberWithCommas(entity.metrics.spend, 4)}</TableCell>
<TableCell>
<MoneyCell value={entity.metrics.spend} decimals={4} />
</TableCell>
<TableCell className="text-green-600">
{entity.metrics.successful_requests.toLocaleString()}
</TableCell>
@ -777,7 +780,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
<span>{provider.provider}</span>
</div>
</TableCell>
<TableCell>${formatNumberWithCommas(provider.spend, 2)}</TableCell>
<TableCell>
<MoneyCell value={provider.spend} decimals={2} />
</TableCell>
<TableCell className="text-green-600">
{provider.successful_requests.toLocaleString()}
</TableCell>

View file

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import SpendByProvider from "./SpendByProvider";
@ -200,6 +200,14 @@ describe("SpendByProvider", () => {
expect(screen.getByText("1,234,567")).toBeInTheDocument();
});
it("should render zero spend as a dash when Show Zero Spend is on", () => {
render(<SpendByProvider loading={false} isDateChanging={false} providerSpend={mockProviderSpend} />);
fireEvent.click(screen.getAllByRole("switch")[0]);
expect(screen.getAllByText("google").length).toBeGreaterThan(0);
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
});
it("should filter data correctly when both toggles are off", () => {
render(<SpendByProvider loading={false} isDateChanging={false} providerSpend={mockProviderSpend} />);
expect(screen.getAllByText("openai").length).toBeGreaterThan(0);

View file

@ -1,3 +1,4 @@
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
import {
@ -109,7 +110,9 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
<span>{provider.provider}</span>
</div>
</TableCell>
<TableCell>${formatNumberWithCommas(provider.spend, 2)}</TableCell>
<TableCell>
<MoneyCell value={provider.spend} decimals={2} />
</TableCell>
<TableCell className="text-green-600">{provider.successful_requests.toLocaleString()}</TableCell>
<TableCell className="text-red-600">{provider.failed_requests.toLocaleString()}</TableCell>
<TableCell>{provider.tokens.toLocaleString()}</TableCell>

View file

@ -171,7 +171,9 @@ describe("TopKeyView", () => {
]}
/>,
);
expect(screen.getByText(/sk-1234\.\.\./)).toBeInTheDocument();
const keyId = screen.getByText("sk-1234567890abcdef");
expect(keyId).toBeInTheDocument();
expect(keyId).toHaveClass("truncate");
});
it("should display dash for missing key alias", () => {
@ -206,7 +208,7 @@ describe("TopKeyView", () => {
expect(screen.getByText("$123.46")).toBeInTheDocument();
});
it("should display less than 0.01 spend as <$0.01", () => {
it("should display sub-cent spend as < $0.01", () => {
render(
<TopKeyView
{...baseProps}
@ -214,15 +216,15 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
spend: 0.005,
spend: 0.004,
},
]}
/>,
);
expect(screen.getByText("<$0.01")).toBeInTheDocument();
expect(screen.getByText("< $0.01")).toBeInTheDocument();
});
it("should display zero spend correctly", () => {
it("should display zero spend as a dash", () => {
render(
<TopKeyView
{...baseProps}
@ -235,7 +237,8 @@ describe("TopKeyView", () => {
]}
/>,
);
expect(screen.getByText("$0.00")).toBeInTheDocument();
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
});
it("should display dash for empty tags", () => {
@ -376,7 +379,7 @@ describe("TopKeyView", () => {
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
const keyIdButton = screen.getByText("key-123").closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}
@ -410,7 +413,7 @@ describe("TopKeyView", () => {
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
const keyIdButton = screen.getByText("key-123").closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}
@ -447,7 +450,7 @@ describe("TopKeyView", () => {
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
const keyIdButton = screen.getByText("key-123").closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}
@ -483,7 +486,7 @@ describe("TopKeyView", () => {
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
const keyIdButton = screen.getByText("key-123").closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}
@ -522,7 +525,7 @@ describe("TopKeyView", () => {
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
const keyIdButton = screen.getByText("key-123").closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}
@ -552,7 +555,7 @@ describe("TopKeyView", () => {
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
const keyIdButton = screen.getByText("key-123").closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}

View file

@ -1,6 +1,7 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { IdCell, MoneyCell } from "@/components/shared/table_cells";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline";
import { BarChart, Button } from "@tremor/react";
import { BarChart } from "@tremor/react";
import { Segmented, Tooltip } from "antd";
import React, { useState } from "react";
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
@ -83,20 +84,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
{
header: "Key ID",
accessorKey: "api_key",
cell: (info: any) => (
<div className="overflow-hidden">
<Tooltip title={info.getValue() as string}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
onClick={() => handleKeyClick(info.row.original)}
>
{info.getValue() ? `${(info.getValue() as string).slice(0, 7)}...` : "-"}
</Button>
</Tooltip>
</div>
),
cell: (info: any) => <IdCell value={info.getValue()} onClick={() => handleKeyClick(info.row.original)} />,
},
{
header: "Key Alias",
@ -165,10 +153,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
header: "Spend (USD)",
accessorKey: "spend",
meta: { numeric: true },
cell: (info: any) => {
const value = info.getValue();
return value > 0 && value < 0.01 ? "<$0.01" : `$${formatNumberWithCommas(value, 2)}`;
},
cell: (info: any) => <MoneyCell value={info.getValue()} decimals={2} />,
};
const columns = showTags ? [...baseColumns, tagsColumn, spendColumn] : [...baseColumns, spendColumn];

View file

@ -175,7 +175,7 @@ describe("TopModelView", () => {
setTopModelsLimit={mockSetTopModelsLimit}
/>,
);
expect(screen.getByText("$0.00")).toBeInTheDocument();
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.getAllByText("0").length).toBeGreaterThan(0);
});

View file

@ -1,6 +1,7 @@
import { BarChart } from "@tremor/react";
import { Segmented } from "antd";
import { useState } from "react";
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
import { DataTable } from "../../../view_logs/table";
@ -31,10 +32,7 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi
header: "Spend (USD)",
accessorKey: "spend",
meta: { numeric: true },
cell: (info: any) => {
const value = info.getValue();
return `$${formatNumberWithCommas(value, 2)}`;
},
cell: (info: any) => <MoneyCell value={info.getValue()} decimals={2} />,
},
{
header: "Successful",

View file

@ -158,7 +158,7 @@ describe("KeyModelUsageView", () => {
},
];
render(<KeyModelUsageView topModels={modelsWithZeros} />);
expect(screen.getByText("$0.00")).toBeInTheDocument();
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.getAllByText("0").length).toBeGreaterThan(0);
});

View file

@ -1,3 +1,4 @@
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { BarChart, Card, Title } from "@tremor/react";
import { Table } from "antd";
@ -24,7 +25,7 @@ const columns: ColumnsType<TopModelData> = [
title: "Spend (USD)",
dataIndex: "spend",
key: "spend",
render: (value) => `$${formatNumberWithCommas(value, 2)}`,
render: (value) => <MoneyCell value={value} decimals={2} />,
},
{
title: "Successful",

View file

@ -1,4 +1,5 @@
import { act, screen, waitFor, within, fireEvent } from "@testing-library/react";
import { screen, waitFor, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, it, expect, beforeEach, describe, MockedFunction } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { VirtualKeysTable } from "./VirtualKeysTable";
@ -175,7 +176,7 @@ it("should display key information correctly", async () => {
await waitFor(() => {
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
expect(screen.getByText("Test Team")).toBeInTheDocument();
expect(screen.getByText("5.5000")).toBeInTheDocument();
expect(screen.getByText("$5.5000")).toBeInTheDocument();
});
});
@ -477,9 +478,8 @@ describe("Status column reflects key.blocked / scim_blocked metadata", () => {
const tag = await screen.findByTestId(`key-status-${mockKey.token_id}`);
expect(tag).toHaveTextContent("Blocked");
act(() => {
fireEvent.mouseEnter(tag);
});
const user = userEvent.setup();
await user.hover(tag);
await waitFor(() => {
expect(screen.getByText(/Blocked by SCIM/i)).toBeInTheDocument();
});

View file

@ -13,20 +13,10 @@ import {
SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
Badge,
Button,
Icon,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Text,
} from "@tremor/react";
import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react";
import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons";
import { Button as AntButton, Popover, Skeleton, Tag, Tooltip, Typography } from "antd";
import { Button as AntButton, Popover, Skeleton, Typography } from "antd";
import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
import React, { useDeferredValue, useMemo, useState } from "react";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect";
@ -145,23 +135,7 @@ export function VirtualKeysTable() {
header: "Key ID",
size: 100,
enableSorting: true,
cell: (info) => {
const value = info.getValue() as string;
const width = info.cell.column.getSize();
return (
<Tooltip title={value}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate block"
style={{ maxWidth: width, overflow: "hidden" }}
onClick={() => setSelectedKey(info.row.original)}
>
{value ?? "-"}
</Button>
</Tooltip>
);
},
cell: (info) => <IdCell value={info.getValue() as string} onClick={() => setSelectedKey(info.row.original)} />,
},
{
id: "key_alias",
@ -187,22 +161,14 @@ export function VirtualKeysTable() {
cell: ({ row }) => {
const key = row.original;
if (key.blocked !== true) {
return (
<Tag color="green" data-testid={`key-status-${key.token_id}`}>
Active
</Tag>
);
return <StatusBadge tone="success" label="Active" dataTestId={`key-status-${key.token_id}`} />;
}
const isScimBlocked = (key.metadata as Record<string, unknown> | null | undefined)?.scim_blocked === true;
const reason = isScimBlocked
? "Blocked by SCIM (external identity provider deactivated or deleted the owning user)."
: "Blocked. Requests using this key will be rejected with 401.";
return (
<Tooltip title={reason}>
<Tag color="red" data-testid={`key-status-${key.token_id}`}>
Blocked
</Tag>
</Tooltip>
<StatusBadge tone="error" label="Blocked" tooltip={reason} dataTestId={`key-status-${key.token_id}`} />
);
},
},
@ -323,10 +289,7 @@ export function VirtualKeysTable() {
header: "Created At",
size: 120,
enableSorting: true,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "-";
},
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
},
{
id: "created_by",
@ -394,10 +357,7 @@ export function VirtualKeysTable() {
header: "Updated At",
size: 120,
enableSorting: true,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "Never";
},
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
},
{
id: "last_active",
@ -415,16 +375,7 @@ export function VirtualKeysTable() {
),
size: 130,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
if (!value) return "Unknown";
const date = new Date(value as string);
return (
<Tooltip title={date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "long" })}>
<span>{date.toLocaleDateString()}</span>
</Tooltip>
);
},
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Unknown" />,
},
{
id: "expires",
@ -432,10 +383,7 @@ export function VirtualKeysTable() {
header: "Expires",
size: 120,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "Never";
},
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
},
{
id: "spend",
@ -443,7 +391,7 @@ export function VirtualKeysTable() {
header: "Spend (USD)",
size: 100,
enableSorting: true,
cell: (info) => formatNumberWithCommas(info.getValue() as number, 4),
cell: (info) => <MoneyCell value={info.getValue() as number} decimals={4} />,
},
{
id: "max_budget",
@ -470,10 +418,7 @@ export function VirtualKeysTable() {
header: "Budget Reset",
size: 130,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleString() : "Never";
},
cell: (info) => <DateCell value={info.getValue() as string | null} fallback="Never" />,
},
{
id: "models",

View file

@ -20,7 +20,7 @@ import AgentInfoView from "./agents/agent_info";
import NotificationsManager from "./molecules/notifications_manager";
import { Agent } from "./agents/types";
import { Team } from "./key_team_helpers/key_list";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
interface AgentsPanelProps {
@ -193,19 +193,10 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
<Text>{agent.agent_name}</Text>
</TableCell>
<TableCell>
<Tooltip title={agent.agent_id}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
onClick={() => setSelectedAgentId(agent.agent_id)}
>
{agent.agent_id.slice(0, 7)}...
</Button>
</Tooltip>
<IdCell value={agent.agent_id} onClick={(id) => setSelectedAgentId(id)} />
</TableCell>
<TableCell>
<Text>{formatNumberWithCommas(agent.spend, 4)}</Text>
<MoneyCell value={agent.spend} decimals={4} />
</TableCell>
<TableCell>
<Badge size="xs" color="blue">
@ -213,13 +204,13 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
</Badge>
</TableCell>
<TableCell>
<Text>{agent.created_at ? new Date(agent.created_at).toLocaleDateString() : "N/A"}</Text>
<DateCell value={agent.created_at} precision="date" />
</TableCell>
<TableCell>
{(agent.keys?.length ?? 0) > 0 ? (
<Badge color="green">Active</Badge>
<StatusBadge tone="success" label="Active" />
) : (
<Badge color="yellow">Needs Setup</Badge>
<StatusBadge tone="warning" label="Needs Setup" />
)}
</TableCell>
{isAdmin && (

View file

@ -11,6 +11,7 @@ import {
import { Badge, Button, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react";
import { Tooltip } from "antd";
import React, { useState } from "react";
import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells";
import NotificationsManager from "../molecules/notifications_manager";
import { getCategoryBadgeColor } from "./helpers";
import { Plugin } from "./types";
@ -34,12 +35,6 @@ const PluginTable: React.FC<PluginTableProps> = ({
}) => {
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleString();
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
NotificationsManager.success("Copied to clipboard!");
@ -51,19 +46,9 @@ const PluginTable: React.FC<PluginTableProps> = ({
accessorKey: "name",
cell: ({ row }) => {
const plugin = row.original;
const name = plugin.name || "";
return (
<div className="flex items-center gap-2">
<Tooltip title={name}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate min-w-[150px] justify-start"
onClick={() => onPluginClick(plugin.id)}
>
{name}
</Button>
</Tooltip>
<IdCell value={plugin.name} onClick={() => onPluginClick(plugin.id)} />
<Tooltip title="Copy Plugin ID">
<CopyOutlined
onClick={(e) => {
@ -122,24 +107,13 @@ const PluginTable: React.FC<PluginTableProps> = ({
accessorKey: "enabled",
cell: ({ row }) => {
const plugin = row.original;
return (
<Badge color={plugin.enabled ? "green" : "gray"} className="text-xs font-normal" size="xs">
{plugin.enabled ? "Yes" : "No"}
</Badge>
);
return <StatusBadge tone={plugin.enabled ? "success" : "neutral"} label={plugin.enabled ? "Yes" : "No"} />;
},
},
{
header: "Created At",
accessorKey: "created_at",
cell: ({ row }) => {
const plugin = row.original;
return (
<Tooltip title={plugin.created_at}>
<span className="text-xs">{formatDate(plugin.created_at)}</span>
</Tooltip>
);
},
cell: ({ row }) => <DateCell value={row.original.created_at} />,
},
...(isAdmin
? [

View file

@ -4,7 +4,6 @@ import {
Table,
TableHead,
TableRow,
Badge,
TableHeaderCell,
TableCell,
TableBody,
@ -16,7 +15,8 @@ import {
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "./networking";
import { InputNumber } from "antd";
import { TrashIcon, CheckCircleIcon } from "@heroicons/react/outline";
import { TrashIcon } from "@heroicons/react/outline";
import { StatusBadge } from "@/components/shared/table_cells";
import RouterSettings from "./router_settings";
import Fallbacks from "./Settings/RouterSettings/Fallbacks/Fallbacks";
@ -173,13 +173,11 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
</TableCell>
<TableCell>
{value.stored_in_db == true ? (
<Badge icon={CheckCircleIcon} className="text-white">
In DB
</Badge>
<StatusBadge tone="success" label="In DB" />
) : value.stored_in_db == false ? (
<Badge className="text-gray bg-white outline-solid">In Config</Badge>
<StatusBadge tone="neutral" label="In Config" />
) : (
<Badge className="text-gray bg-white outline-solid">Not Set</Badge>
<StatusBadge tone="neutral" label="Not Set" />
)}
</TableCell>
<TableCell>

View file

@ -1,8 +1,8 @@
import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon, Button } from "@tremor/react";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon } from "@tremor/react";
import { TrashIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
import { Tooltip } from "antd";
import { Badge } from "@tremor/react";
import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells";
import {
ColumnDef,
flexRender,
@ -43,13 +43,6 @@ const GuardrailTable: React.FC<GuardrailTableProps> = ({
const [editModalVisible, setEditModalVisible] = useState(false);
const [selectedGuardrail, setSelectedGuardrail] = useState<Guardrail | null>(null);
// Format date helper function
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleString();
};
const handleEditClick = (guardrail: Guardrail) => {
setSelectedGuardrail(guardrail);
setEditModalVisible(true);
@ -65,18 +58,7 @@ const GuardrailTable: React.FC<GuardrailTableProps> = ({
{
header: "Guardrail ID",
accessorKey: "guardrail_id",
cell: (info: any) => (
<Tooltip title={String(info.getValue() || "")}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
onClick={() => info.getValue() && onGuardrailClick(info.getValue())}
>
{info.getValue() ? `${String(info.getValue()).slice(0, 7)}...` : ""}
</Button>
</Tooltip>
),
cell: (info: any) => <IdCell value={info.getValue()} onClick={onGuardrailClick} />,
},
{
header: "Name",
@ -126,41 +108,21 @@ const GuardrailTable: React.FC<GuardrailTableProps> = ({
header: "Default On",
accessorKey: "litellm_params.default_on",
cell: ({ row }) => {
const guardrail = row.original;
const isDefaultOn = !!row.original.litellm_params?.default_on;
return (
<Badge
color={guardrail.litellm_params?.default_on ? "green" : "gray"}
className="text-xs font-normal"
size="xs"
>
{guardrail.litellm_params?.default_on ? "Default On" : "Default Off"}
</Badge>
<StatusBadge tone={isDefaultOn ? "success" : "neutral"} label={isDefaultOn ? "Default On" : "Default Off"} />
);
},
},
{
header: "Created At",
accessorKey: "created_at",
cell: ({ row }) => {
const guardrail = row.original;
return (
<Tooltip title={guardrail.created_at}>
<span className="text-xs">{formatDate(guardrail.created_at)}</span>
</Tooltip>
);
},
cell: ({ row }) => <DateCell value={row.original.created_at} />,
},
{
header: "Updated At",
accessorKey: "updated_at",
cell: ({ row }) => {
const guardrail = row.original;
return (
<Tooltip title={guardrail.updated_at}>
<span className="text-xs">{formatDate(guardrail.updated_at)}</span>
</Tooltip>
);
},
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
},
{
id: "actions",

View file

@ -2,6 +2,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { Button, Badge, Text } from "@tremor/react";
import { Tooltip, Tag } from "antd";
import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
export interface MCPServerData {
server_id: string;
@ -124,21 +125,17 @@ export const mcpHubColumns = (
cell: ({ row }) => {
const server = row.original;
const statusColors: Record<string, string> = {
active: "green",
inactive: "red",
unknown: "gray",
healthy: "green",
unhealthy: "red",
const statusTones: Record<string, StatusTone> = {
active: "success",
inactive: "error",
unknown: "neutral",
healthy: "success",
unhealthy: "error",
};
const color = statusColors[server.status] || "gray";
const tone = statusTones[server.status] || "neutral";
return (
<Badge color={color} size="sm">
{server.status || "unknown"}
</Badge>
);
return <StatusBadge tone={tone} label={server.status || "unknown"} />;
},
},
{

View file

@ -6,6 +6,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useQueryClient } from "@tanstack/react-query";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import { DataTable } from "../view_logs/table";
import { createMCPToolset, updateMCPToolset, deleteMCPToolset, listMCPTools, getProxyBaseUrl } from "../networking";
import { MCPToolset, MCPToolsetTool } from "./types";
@ -302,11 +303,7 @@ function toolsetColumns(
{
header: "Toolset ID",
accessorKey: "toolset_id",
cell: ({ row }) => (
<span className="font-mono text-xs bg-gray-100 px-2 py-0.5 rounded-sm text-gray-600">
{row.original.toolset_id.slice(0, 8)}…
</span>
),
cell: ({ row }) => <IdCell value={row.original.toolset_id} />,
},
{
header: "Name",
@ -359,11 +356,7 @@ function toolsetColumns(
{
header: "Created",
accessorKey: "created_at",
cell: ({ row }) => (
<span className="text-xs text-gray-500">
{row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "—"}
</span>
),
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
...(isAdmin
? [

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import { Title, Text, Button, Badge } from "@tremor/react";
import { Title, Text, Button } from "@tremor/react";
import { Modal } from "antd";
import { Button as AntdButton } from "antd";
import { ModelDataTable } from "./table";
@ -468,21 +468,6 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
onPageChange?.(page);
};
const getStatusBadge = (status: string) => {
switch (status) {
case "healthy":
return <Badge color="emerald">healthy</Badge>;
case "unhealthy":
return <Badge color="red">unhealthy</Badge>;
case "checking":
return <Badge color="blue">checking</Badge>;
case "none":
return <Badge color="gray">none</Badge>;
default:
return <Badge color="gray">unknown</Badge>;
}
};
const showErrorModal = (modelName: string, cleanedError: string, fullError: string) => {
setSelectedErrorDetails({
modelName,
@ -612,7 +597,6 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
handleModelSelection,
handleSelectAll,
runIndividualHealthCheck,
getStatusBadge,
getDisplayModelName,
showErrorModal,
showSuccessModal,

View file

@ -3,6 +3,7 @@ import { Tooltip, Checkbox } from "antd";
import { Text } from "@tremor/react";
import { InformationCircleIcon, PlayIcon, RefreshIcon } from "@heroicons/react/outline";
import { Team } from "@/components/key_team_helpers/key_list";
import { IdCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
interface HealthCheckData {
model_name: string;
@ -21,6 +22,18 @@ interface HealthCheckData {
health_full_error?: string;
}
const HEALTH_STATUS_TONES: Record<string, StatusTone> = {
healthy: "success",
unhealthy: "error",
checking: "info",
none: "neutral",
};
const healthStatusBadge = (status: string): JSX.Element => {
const tone = HEALTH_STATUS_TONES[status];
return tone ? <StatusBadge tone={tone} label={status} /> : <StatusBadge tone="neutral" label="unknown" />;
};
interface HealthStatus {
status: string;
lastCheck: string;
@ -38,7 +51,6 @@ export const healthCheckColumns = (
handleModelSelection: (modelId: string, checked: boolean) => void,
handleSelectAll: (checked: boolean) => void,
runIndividualHealthCheck: (modelId: string) => void,
getStatusBadge: (status: string) => JSX.Element,
getDisplayModelName: (model: any) => string,
showErrorModal?: (modelName: string, cleanedError: string, fullError: string) => void,
showSuccessModal?: (modelName: string, response: any) => void,
@ -72,14 +84,7 @@ export const healthCheckColumns = (
onChange={(e) => handleModelSelection(modelId, e.target.checked)}
onClick={(e) => e.stopPropagation()}
/>
<Tooltip title={model.model_info.id}>
<div
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
onClick={() => setSelectedModelId && setSelectedModelId(model.model_info.id)}
>
{model.model_info.id}
</div>
</Tooltip>
<IdCell value={model.model_info.id} onClick={setSelectedModelId} />
</div>
);
},
@ -175,7 +180,7 @@ export const healthCheckColumns = (
return (
<div className="flex items-center space-x-2">
{getStatusBadge(healthStatus.status)}
{healthStatusBadge(healthStatus.status)}
{hasSuccessResponse && showSuccessModal && (
<Tooltip title="View response details" placement="top">
<button

View file

@ -2,6 +2,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { Button, Badge, Text } from "@tremor/react";
import { Tooltip, Tag } from "antd";
import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { StatusBadge } from "@/components/shared/table_cells";
interface ModelHubData {
model_group: string;
@ -212,13 +213,9 @@ export const modelHubColumns = (
const model = row.original;
return model.is_public_model_group === true ? (
<Badge color="green" size="xs">
Yes
</Badge>
<StatusBadge tone="success" label="Yes" />
) : (
<Badge color="gray" size="xs">
No
</Badge>
<StatusBadge tone="neutral" label="No" />
);
},
meta: {

View file

@ -451,6 +451,64 @@ describe("columns", () => {
expect(costCells.length).toBeGreaterThan(0);
});
it("should call setSelectedModelId without triggering the row click when the model ID pill is clicked", async () => {
const user = userEvent.setup();
const setSelectedModelId = vi.fn();
const cols = columns(
defaultProps.userRole,
defaultProps.userID,
defaultProps.premiumUser,
setSelectedModelId,
defaultProps.setSelectedTeamId,
defaultProps.getDisplayModelName,
defaultProps.handleEditClick,
defaultProps.handleRefreshClick,
defaultProps.expandedRows,
defaultProps.setExpandedRows,
);
const model = createMockModel();
const rowClick = vi.fn();
render(
<div onClick={rowClick}>
<TestTable data={[model]} columns={cols} />
</div>,
);
await user.click(screen.getByText("test-model-id"));
expect(setSelectedModelId).toHaveBeenCalledWith("test-model-id");
expect(rowClick).not.toHaveBeenCalled();
});
it("should call setSelectedTeamId without triggering the row click when the team ID pill is clicked", async () => {
const user = userEvent.setup();
const setSelectedTeamId = vi.fn();
const cols = columns(
defaultProps.userRole,
defaultProps.userID,
defaultProps.premiumUser,
defaultProps.setSelectedModelId,
setSelectedTeamId,
defaultProps.getDisplayModelName,
defaultProps.handleEditClick,
defaultProps.handleRefreshClick,
defaultProps.expandedRows,
defaultProps.setExpandedRows,
);
const model = createMockModel();
const rowClick = vi.fn();
render(
<div onClick={rowClick}>
<TestTable data={[model]} columns={cols} />
</div>,
);
await user.click(screen.getByText("test-team-id"));
expect(setSelectedTeamId).toHaveBeenCalledWith("test-team-id");
expect(rowClick).not.toHaveBeenCalled();
});
it("should display '-' when team ID is missing", () => {
const cols = columns(
defaultProps.userRole,

View file

@ -1,8 +1,9 @@
import { EditOutlined, InfoCircleOutlined, SyncOutlined } from "@ant-design/icons";
import { TrashIcon } from "@heroicons/react/outline";
import { ColumnDef } from "@tanstack/react-table";
import { Badge, Button, Icon } from "@tremor/react";
import { Badge, Icon } from "@tremor/react";
import { Divider, Flex, Popover, Space, Switch, Tooltip, Typography } from "antd";
import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells";
import { ModelData } from "../../model_dashboard/types";
import { ProviderLogo } from "./ProviderLogo";
@ -65,19 +66,9 @@ export const columns = (
cell: ({ row }) => {
const model = row.original;
return (
<Tooltip title={model.model_info.id}>
<Text
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer w-full block"
style={{ fontSize: 14, padding: "1px 8px" }}
onClick={(e) => {
e.stopPropagation();
setSelectedModelId(model.model_info.id);
}}
>
{model.model_info.id}
</Text>
</Tooltip>
<div onClick={(e) => e.stopPropagation()}>
<IdCell value={model.model_info.id} onClick={setSelectedModelId} />
</div>
);
},
},
@ -235,11 +226,7 @@ export const columns = (
minSize: 80,
cell: ({ row }) => {
const model = row.original;
return (
<span className="text-xs">
{model.model_info.updated_at ? new Date(model.model_info.updated_at).toLocaleDateString() : "-"}
</span>
);
return <DateCell value={model.model_info.updated_at} precision="date" />;
},
},
{
@ -282,20 +269,8 @@ export const columns = (
cell: ({ row }) => {
const model = row.original;
return model.model_info.team_id ? (
<div className="overflow-hidden w-full">
<Tooltip title={model.model_info.team_id}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate w-full"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setSelectedTeamId(model.model_info.team_id);
}}
>
{model.model_info.team_id.slice(0, 7)}...
</Button>
</Tooltip>
<div onClick={(e) => e.stopPropagation()}>
<IdCell value={model.model_info.team_id} onClick={setSelectedTeamId} />
</div>
) : (
"-"
@ -370,15 +345,10 @@ export const columns = (
minSize: 80,
cell: ({ row }) => {
const model = row.original;
return (
<div
className={`
inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
${model.model_info.db_model ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-600"}
`}
>
{model.model_info.db_model ? "DB Model" : "Config Model"}
</div>
return model.model_info.db_model ? (
<StatusBadge tone="info" label="DB Model" />
) : (
<StatusBadge tone="neutral" label="Config Model" />
);
},
},

View file

@ -1,6 +1,7 @@
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { organizationKeys, useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useQueryClient } from "@tanstack/react-query";
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { createTeamAliasMap } from "@/utils/teamUtils";
import { ArrowLeftIcon } from "@heroicons/react/outline";
@ -196,7 +197,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
render: (_: unknown, record: Member) => {
const orgMember =
record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined;
return <Typography.Text>${formatNumberWithCommas(orgMember?.spend ?? 0, 4)}</Typography.Text>;
return <MoneyCell value={orgMember?.spend} decimals={4} />;
},
},
{

View file

@ -27,7 +27,7 @@ import {
import { Form, Input, Modal, Select as Select2, Tooltip } from "antd";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { formatNumberWithCommas } from "../utils/dataUtils";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
@ -263,30 +263,22 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
.map((org: Organization) => (
<TableRow key={org.organization_id}>
<TableCell>
<div className="overflow-hidden">
<Tooltip title={org.organization_id}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
onClick={() => setSelectedOrgId(org.organization_id)}
>
{org.organization_id?.slice(0, 7)}
...
</Button>
</Tooltip>
</div>
<IdCell value={org.organization_id} onClick={setSelectedOrgId} />
</TableCell>
<TableCell>{org.organization_alias}</TableCell>
<TableCell>
{org.created_at ? new Date(org.created_at).toLocaleDateString() : "N/A"}
<DateCell value={org.created_at} precision="date" />
</TableCell>
<TableCell>{formatNumberWithCommas(org.spend, 4)}</TableCell>
<TableCell>
{org.litellm_budget_table?.max_budget !== null &&
org.litellm_budget_table?.max_budget !== undefined
? org.litellm_budget_table?.max_budget
: "No limit"}
<MoneyCell value={org.spend} decimals={4} />
</TableCell>
<TableCell>
<MoneyCell
value={org.litellm_budget_table?.max_budget}
decimals={2}
emptyText="Unlimited"
showZero
/>
</TableCell>
<TableCell
style={{

View file

@ -7,6 +7,7 @@ import AddPassThroughEndpoint from "./add_pass_through";
import PassThroughInfoView from "./pass_through_info";
import { DataTable } from "./view_logs/table";
import { ColumnDef } from "@tanstack/react-table";
import { IdCell, StatusBadge } from "@/components/shared/table_cells";
import { Eye, EyeOff } from "lucide-react";
import NotificationsManager from "./molecules/notifications_manager";
@ -136,16 +137,7 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
{
header: "ID",
accessorKey: "id",
cell: (info: any) => (
<Tooltip title={info.row.original.id}>
<div
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
onClick={() => info.row.original.id && setSelectedEndpointId(info.row.original.id)}
>
{info.row.original.id}
</div>
</Tooltip>
),
cell: (info: any) => <IdCell value={info.row.original.id} onClick={setSelectedEndpointId} />,
},
{
header: "Path",
@ -192,7 +184,9 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
</div>
),
accessorKey: "auth",
cell: (info: any) => <Badge color={info.getValue() ? "green" : "gray"}>{info.getValue() ? "Yes" : "No"}</Badge>,
cell: (info: any) => (
<StatusBadge tone={info.getValue() ? "success" : "neutral"} label={info.getValue() ? "Yes" : "No"} />
),
},
{
header: "Headers",

View file

@ -140,10 +140,13 @@ describe("AttachmentTable", () => {
expect(screen.queryByRole("button", { name: /TrashIcon/i })).not.toBeInTheDocument();
});
it("should show a truncated attachment ID in the table", () => {
it("should show the attachment ID as truncated plain mono text", () => {
const attachment = makeAttachment({ attachment_id: "att-abcdef1234567" });
renderWithProviders(<AttachmentTable {...defaultProps} attachments={[attachment]} />);
expect(screen.getByText("att-abc...")).toBeInTheDocument();
const idElement = screen.getByText("att-abcdef1234567");
expect(idElement.className).toContain("font-mono");
expect(idElement.className).toContain("truncate");
expect(idElement.className).not.toContain("bg-blue-50");
});
it("should render model tags when the attachment has models", () => {

View file

@ -10,6 +10,7 @@ import {
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import { PolicyAttachment } from "./types";
import ImpactPopover from "./impact_popover";
@ -30,24 +31,11 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
}) => {
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
// Format date helper function
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleString();
};
const columns: ColumnDef<PolicyAttachment>[] = [
{
header: "Attachment ID",
accessorKey: "attachment_id",
cell: (info: any) => (
<Tooltip title={String(info.getValue() || "")}>
<span className="font-mono text-xs text-gray-600">
{info.getValue() ? `${String(info.getValue()).slice(0, 7)}...` : ""}
</span>
</Tooltip>
),
cell: (info: any) => <IdCell value={info.getValue()} variant="plain" />,
},
{
header: "Policy",
@ -183,14 +171,7 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
{
header: "Created At",
accessorKey: "created_at",
cell: ({ row }) => {
const attachment = row.original;
return (
<Tooltip title={attachment.created_at}>
<span className="text-xs">{formatDate(attachment.created_at)}</span>
</Tooltip>
);
},
cell: ({ row }) => <DateCell value={row.original.created_at} />,
},
{
id: "actions",

View file

@ -10,6 +10,7 @@ import {
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { DateCell } from "@/components/shared/table_cells";
import { Policy } from "./types";
/** One row per policy name; primaryPolicy is used for display and for Edit (FlowBuilder loads all versions) */
@ -59,12 +60,6 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
const rows = useMemo(() => groupPoliciesByName(policies), [policies]);
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleString();
};
const columns: ColumnDef<PolicyRow>[] = [
{
header: "Name",
@ -199,14 +194,7 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
header: "Created At",
id: "created_at",
accessorFn: (row) => row.primaryPolicy.created_at ?? "",
cell: ({ row }) => {
const policy = row.original.primaryPolicy;
return (
<Tooltip title={policy.created_at}>
<span className="text-xs">{formatDate(policy.created_at)}</span>
</Tooltip>
);
},
cell: ({ row }) => <DateCell value={row.original.primaryPolicy.created_at} />,
},
{
id: "actions",

View file

@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface CellTooltipProps {
content: React.ReactNode;
trigger: React.ReactElement;
}
export function CellTooltip({ content, trigger }: CellTooltipProps) {
return (
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger render={trigger} />
<TooltipContent>{content}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DateCell, formatCellDate, formatFullTimestamp } from "./date_cell";
const localIso = new Date(2026, 6, 7, 9, 50, 13).toISOString();
describe("formatCellDate", () => {
it("formats datetime precision as 'MMM D, HH:mm:ss' without a year", () => {
expect(formatCellDate(new Date(2026, 6, 7, 9, 50, 13), "datetime")).toBe("Jul 7, 09:50:13");
});
it("zero-pads hours, minutes and seconds", () => {
expect(formatCellDate(new Date(2026, 0, 2, 1, 2, 3), "datetime")).toBe("Jan 2, 01:02:03");
});
it("formats date precision as 'MMM D, YYYY' with no time", () => {
expect(formatCellDate(new Date(2026, 11, 31, 23, 59, 59), "date")).toBe("Dec 31, 2026");
});
});
describe("formatFullTimestamp", () => {
it("includes year, 24h time and the IANA timezone", () => {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
expect(formatFullTimestamp(new Date(2026, 6, 7, 9, 50, 13))).toBe(`Jul 7, 2026, 09:50:13 (${timeZone})`);
});
});
describe("DateCell", () => {
it("renders the datetime format by default", () => {
render(<DateCell value={localIso} />);
expect(screen.getByText("Jul 7, 09:50:13")).toBeInTheDocument();
});
it("renders date-only when precision is 'date'", () => {
render(<DateCell value={localIso} precision="date" />);
expect(screen.getByText("Jul 7, 2026")).toBeInTheDocument();
});
it("renders '-' for null and undefined", () => {
const { rerender } = render(<DateCell value={null} />);
expect(screen.getByText("-")).toBeInTheDocument();
rerender(<DateCell value={undefined} />);
expect(screen.getByText("-")).toBeInTheDocument();
});
it("renders the custom fallback for empty values", () => {
render(<DateCell value="" fallback="Never" />);
expect(screen.getByText("Never")).toBeInTheDocument();
});
it("renders the fallback instead of 'Invalid Date' for unparseable input", () => {
render(<DateCell value="not-a-date" fallback="Unknown" />);
expect(screen.getByText("Unknown")).toBeInTheDocument();
expect(screen.queryByText(/Invalid/)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,41 @@
"use client";
import { CellTooltip } from "./cell_tooltip";
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
export type DatePrecision = "datetime" | "date";
interface DateCellProps {
value: string | null | undefined;
precision?: DatePrecision;
fallback?: string;
}
const pad = (n: number): string => String(n).padStart(2, "0");
export const formatCellDate = (date: Date, precision: DatePrecision): string =>
precision === "date"
? `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`
: `${MONTHS[date.getMonth()]} ${date.getDate()}, ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
export const formatFullTimestamp = (date: Date): string => {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const day = `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
return `${day}, ${time} (${timeZone})`;
};
export function DateCell({ value, precision = "datetime", fallback = "-" }: DateCellProps) {
const date = value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) {
return <span className="text-muted-foreground">{fallback}</span>;
}
return (
<CellTooltip
content={formatFullTimestamp(date)}
trigger={<span className="whitespace-nowrap">{formatCellDate(date, precision)}</span>}
/>
);
}

View file

@ -0,0 +1,78 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { IdCell } from "./id_cell";
const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() }));
vi.mock("@/utils/dataUtils", async (importOriginal) => ({
...(await importOriginal<typeof import("@/utils/dataUtils")>()),
copyToClipboard: copyToClipboardMock,
}));
describe("IdCell", () => {
it("renders '-' for empty values", () => {
render(<IdCell value={null} />);
expect(screen.getByText("-")).toBeInTheDocument();
});
it("renders the custom fallback for empty values", () => {
render(<IdCell value="" fallback="—" />);
expect(screen.getByText("—")).toBeInTheDocument();
});
it("renders the full id as a non-interactive pill by default", () => {
render(<IdCell value="sk-1234567890abcdef" />);
const el = screen.getByText("sk-1234567890abcdef");
expect(el.tagName).toBe("SPAN");
expect(el.className).toContain("bg-blue-50");
expect(el.className).toContain("font-mono");
expect(el.className).toContain("max-w-[15ch]");
expect(el.className).toContain("truncate");
});
it("renders plain mono text without pill styling for the plain variant", () => {
render(<IdCell value="req-123" variant="plain" />);
const el = screen.getByText("req-123");
expect(el.className).toContain("font-mono");
expect(el.className).not.toContain("bg-blue-50");
});
it("does not truncate when truncate is false", () => {
render(<IdCell value="audit-object-id" truncate={false} />);
expect(screen.getByText("audit-object-id").className).not.toContain("truncate");
});
it("becomes a button that fires onClick with the id value", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<IdCell value="team-42" onClick={onClick} />);
await user.click(screen.getByRole("button", { name: "team-42" }));
expect(onClick).toHaveBeenCalledWith("team-42");
});
it("stays non-interactive when disabled, even with onClick", () => {
const onClick = vi.fn();
render(<IdCell value="tag-1" onClick={onClick} disabled />);
expect(screen.queryByRole("button", { name: "tag-1" })).not.toBeInTheDocument();
});
it("copies the id via the trailing copy button without triggering row clicks", async () => {
const user = userEvent.setup();
const rowClick = vi.fn();
render(
<div onClick={rowClick}>
<IdCell value="key-hash-9" copyable />
</div>,
);
await user.click(screen.getByRole("button", { name: "Copy ID" }));
expect(copyToClipboardMock).toHaveBeenCalledWith("key-hash-9");
expect(rowClick).not.toHaveBeenCalled();
});
it("passes dataTestId through to the id element", () => {
render(<IdCell value="k-1" dataTestId="key-id-cell" />);
expect(screen.getByTestId("key-id-cell")).toHaveTextContent("k-1");
});
});

View file

@ -0,0 +1,94 @@
"use client";
import { Copy } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
import { CellTooltip } from "./cell_tooltip";
export type IdCellVariant = "pill" | "plain";
interface IdCellProps {
value: string | null | undefined;
variant?: IdCellVariant;
onClick?: (value: string) => void;
copyable?: boolean;
truncate?: boolean;
fallback?: string;
tooltip?: React.ReactNode;
disabled?: boolean;
dataTestId?: string;
className?: string;
}
const VARIANT_CLASS: Record<IdCellVariant, { base: string; clickable: string }> = {
pill: {
base: "font-mono text-xs font-normal px-2 py-0.5 rounded-md text-left bg-blue-50 text-blue-500",
clickable: "hover:bg-blue-100 cursor-pointer",
},
plain: {
base: "font-mono text-xs text-left",
clickable: "hover:text-blue-600 cursor-pointer",
},
};
export function IdCell({
value,
variant = "pill",
onClick,
copyable = false,
truncate = true,
fallback = "-",
tooltip,
disabled = false,
dataTestId,
className,
}: IdCellProps) {
if (!value) {
return <span className="text-muted-foreground">{fallback}</span>;
}
const clickable = !!onClick && !disabled;
const classes = cn(
VARIANT_CLASS[variant].base,
clickable && VARIANT_CLASS[variant].clickable,
truncate && "block max-w-[15ch] truncate",
disabled && "opacity-50",
className,
);
const idElement = clickable ? (
<button type="button" className={classes} data-testid={dataTestId} onClick={() => onClick(value)}>
{value}
</button>
) : (
<span className={classes} data-testid={dataTestId}>
{value}
</span>
);
const withTooltip = <CellTooltip content={tooltip ?? value} trigger={idElement} />;
if (!copyable) {
return withTooltip;
}
return (
<span className="inline-flex max-w-full items-center gap-1">
{withTooltip}
<button
type="button"
aria-label="Copy ID"
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
void copyToClipboard(value);
}}
>
<Copy className="size-3" />
</button>
</span>
);
}

View file

@ -0,0 +1,5 @@
export { CellTooltip } from "./cell_tooltip";
export { DateCell, formatCellDate, formatFullTimestamp, type DatePrecision } from "./date_cell";
export { IdCell, type IdCellVariant } from "./id_cell";
export { MoneyCell } from "./money_cell";
export { StatusBadge, type StatusTone } from "./status_badge";

View file

@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MoneyCell } from "./money_cell";
describe("MoneyCell", () => {
it("renders '-' for null and undefined", () => {
const { rerender } = render(<MoneyCell value={null} />);
expect(screen.getByText("-")).toBeInTheDocument();
rerender(<MoneyCell value={undefined} />);
expect(screen.getByText("-")).toBeInTheDocument();
});
it("renders the custom emptyText for null budgets", () => {
render(<MoneyCell value={null} emptyText="Unlimited" />);
expect(screen.getByText("Unlimited")).toBeInTheDocument();
});
it("renders '-' for zero by default", () => {
render(<MoneyCell value={0} />);
expect(screen.getByText("-")).toBeInTheDocument();
});
it("renders a formatted zero when showZero is set, never the emptyText", () => {
render(<MoneyCell value={0} showZero emptyText="Unlimited" decimals={2} />);
expect(screen.getByText("$0.00")).toBeInTheDocument();
expect(screen.queryByText("Unlimited")).not.toBeInTheDocument();
});
it("formats amounts with commas, a dollar sign and the given decimals", () => {
render(<MoneyCell value={1234.5678} decimals={2} />);
expect(screen.getByText("$1,234.57")).toBeInTheDocument();
});
it("defaults to 4 decimals", () => {
render(<MoneyCell value={42} />);
expect(screen.getByText("$42.0000")).toBeInTheDocument();
});
it("renders the sub-threshold form for amounts that round to zero", () => {
render(<MoneyCell value={0.0000001} decimals={6} />);
expect(screen.getByText("< $0.000001")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,23 @@
"use client";
import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils";
interface MoneyCellProps {
value: number | null | undefined;
decimals?: number;
emptyText?: string;
showZero?: boolean;
}
export function MoneyCell({ value, decimals = 4, emptyText = "-", showZero = false }: MoneyCellProps) {
if (value === null || value === undefined || Number.isNaN(value)) {
return <span className="text-muted-foreground">{emptyText}</span>;
}
if (value === 0) {
if (!showZero) {
return <span className="text-muted-foreground">-</span>;
}
return <span className="whitespace-nowrap">{`$${formatNumberWithCommas(0, decimals, false, true)}`}</span>;
}
return <span className="whitespace-nowrap">{getSpendString(value, decimals)}</span>;
}

View file

@ -0,0 +1,42 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { StatusBadge, type StatusTone } from "./status_badge";
describe("StatusBadge", () => {
const toneClasses: Record<StatusTone, string[]> = {
success: ["border-green-200", "bg-green-50", "text-green-600"],
error: ["border-red-200", "bg-red-50", "text-red-600"],
warning: ["border-amber-200", "bg-amber-50", "text-amber-600"],
neutral: ["border-gray-200", "bg-gray-50", "text-gray-600"],
info: ["border-blue-200", "bg-blue-50", "text-blue-600"],
};
(Object.entries(toneClasses) as [StatusTone, string[]][]).forEach(([tone, classes]) => {
it(`renders a tinted pill (${classes.join(" ")}) for the ${tone} tone`, () => {
render(<StatusBadge tone={tone} label={tone} />);
const badge = screen.getByText(tone);
classes.forEach((cls) => expect(badge.className).toContain(cls));
});
});
it("renders the label text inside an outline badge with no status dot", () => {
render(<StatusBadge tone="success" label="Active" />);
const badge = screen.getByText("Active");
expect(badge.dataset.variant).toBe("outline");
expect(badge.querySelector("[aria-hidden]")).toBeNull();
});
it("passes dataTestId through", () => {
render(<StatusBadge tone="error" label="Blocked" dataTestId="key-status" />);
expect(screen.getByTestId("key-status")).toHaveTextContent("Blocked");
});
it("opens the tooltip on hover, which requires Badge to forward its ref to the trigger", async () => {
const user = userEvent.setup();
render(<StatusBadge tone="error" label="Blocked" tooltip="This key was blocked by SCIM" />);
await user.hover(screen.getByText("Blocked"));
expect(await screen.findByText("This key was blocked by SCIM")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,38 @@
"use client";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/cva.config";
import { CellTooltip } from "./cell_tooltip";
export type StatusTone = "success" | "error" | "warning" | "neutral" | "info";
const TONE_CLASS: Record<StatusTone, string> = {
success: "border-green-200 bg-green-50 text-green-600",
error: "border-red-200 bg-red-50 text-red-600",
warning: "border-amber-200 bg-amber-50 text-amber-600",
neutral: "border-gray-200 bg-gray-50 text-gray-600",
info: "border-blue-200 bg-blue-50 text-blue-600",
};
interface StatusBadgeProps {
tone: StatusTone;
label: string;
tooltip?: React.ReactNode;
dataTestId?: string;
}
export function StatusBadge({ tone, label, tooltip, dataTestId }: StatusBadgeProps) {
const badge = (
<Badge variant="outline" data-testid={dataTestId} className={cn("whitespace-nowrap font-normal", TONE_CLASS[tone])}>
{label}
</Badge>
);
if (!tooltip) {
return badge;
}
return <CellTooltip content={tooltip} trigger={badge} />;
}

View file

@ -3,6 +3,7 @@ import { Badge, Text } from "@tremor/react";
import { Tooltip } from "antd";
import { CopyOutlined, LinkOutlined } from "@ant-design/icons";
import { Plugin } from "./claude_code_plugins/types";
import { StatusBadge } from "@/components/shared/table_cells";
export const skillHubColumns = (
showModal: (skill: Plugin) => void,
@ -104,9 +105,10 @@ export const skillHubColumns = (
accessorKey: "enabled",
enableSorting: true,
cell: ({ row }) => (
<Badge color={row.original.enabled ? "green" : "gray"} size="xs">
{row.original.enabled ? "Public" : "Draft"}
</Badge>
<StatusBadge
tone={row.original.enabled ? "success" : "neutral"}
label={row.original.enabled ? "Public" : "Draft"}
/>
),
},
];

View file

@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { formatCellDate } from "@/components/shared/table_cells";
import TagTable from "./TagTable";
import { Tag } from "./types";
@ -75,14 +76,21 @@ describe("TagTable", () => {
it("should display formatted created date", () => {
render(<TagTable {...defaultProps} data={[mockTag]} />);
const formattedDate = new Date(mockTag.created_at).toLocaleDateString();
const formattedDate = formatCellDate(new Date(mockTag.created_at), "date");
expect(screen.getByText(formattedDate)).toBeInTheDocument();
});
it("should disable tag name button for dynamic spend tags", () => {
it("should call onSelectTag when tag name is clicked", () => {
render(<TagTable {...defaultProps} data={[mockTag]} />);
fireEvent.click(screen.getByRole("button", { name: "test-tag" }));
expect(mockOnSelectTag).toHaveBeenCalledWith("test-tag");
});
it("should render tag name as non-clickable for dynamic spend tags", () => {
render(<TagTable {...defaultProps} data={[mockDynamicSpendTag]} />);
const tagButton = screen.getByRole("button", { name: "dynamic-spend-tag" });
expect(tagButton).toBeDisabled();
expect(screen.queryByRole("button", { name: "dynamic-spend-tag" })).not.toBeInTheDocument();
fireEvent.click(screen.getByText("dynamic-spend-tag"));
expect(mockOnSelectTag).not.toHaveBeenCalled();
});
it("should disable edit icon for dynamic spend tags", () => {

View file

@ -7,20 +7,10 @@ import {
SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
Badge,
Button,
Icon,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Text,
} from "@tremor/react";
import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react";
import { Tooltip } from "antd";
import React from "react";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import { Tag } from "./types";
interface TagTableProps {
@ -45,21 +35,15 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
return (
<div className="overflow-hidden">
<Tooltip
title={
<IdCell
value={tag.name}
truncate={false}
onClick={onSelectTag}
disabled={isDynamicSpendTag}
tooltip={
isDynamicSpendTag ? "You cannot view the information of a dynamically generated spend tag" : tag.name
}
>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5"
onClick={() => onSelectTag(tag.name)}
disabled={isDynamicSpendTag}
>
{tag.name}
</Button>
</Tooltip>
/>
</div>
);
},
@ -104,10 +88,7 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
header: "Created",
accessorKey: "created_at",
sortingFn: "datetime",
cell: ({ row }) => {
const tag = row.original;
return <span className="text-xs">{new Date(tag.created_at).toLocaleDateString()}</span>;
},
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
id: "actions",

View file

@ -27,6 +27,8 @@ const mockSetSelectedEditMember = vi.fn();
const mockSetIsEditMemberModalVisible = vi.fn();
const mockSetIsAddMemberModalVisible = vi.fn();
const budgetResetIso = new Date(2026, 6, 15, 12, 0, 0).toISOString();
const createMockTeamData = (overrides: Partial<TeamData> = {}): TeamData => ({
team_id: "team-123",
team_info: {
@ -78,6 +80,7 @@ const createMockTeamData = (overrides: Partial<TeamData> = {}): TeamData => ({
rpm_limit: 100,
model_max_budget: null,
budget_duration: null,
budget_reset_at: budgetResetIso,
},
},
],
@ -202,7 +205,7 @@ describe("TeamMembersComponent", () => {
/>,
);
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.getAllByText("-").length).toBeGreaterThanOrEqual(1);
});
it("should display Default Proxy Admin tag for default_user_id", () => {
@ -243,12 +246,12 @@ describe("TeamMembersComponent", () => {
/>,
);
expect(screen.getByText(/\$100\.5/)).toBeInTheDocument();
expect(screen.getByText("$100.5000")).toBeInTheDocument();
expect(screen.getByText(/100 RPM/)).toBeInTheDocument();
expect(screen.getByText(/10000 TPM/)).toBeInTheDocument();
});
it("should display No Limit for budget when member has no budget", () => {
it("should display the budget reset date for member with a budget reset", () => {
renderWithProviders(
<TeamMembersComponent
teamData={createMockTeamData()}
@ -260,7 +263,23 @@ describe("TeamMembersComponent", () => {
/>,
);
expect(screen.getByText("No Limit")).toBeInTheDocument();
expect(screen.getByText("Jul 15, 2026")).toBeInTheDocument();
});
it("should display formatted budget and Unlimited for member with no budget", () => {
renderWithProviders(
<TeamMembersComponent
teamData={createMockTeamData()}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
/>,
);
expect(screen.getByText("$1,000.0000")).toBeInTheDocument();
expect(screen.getByText("Unlimited")).toBeInTheDocument();
});
it("should display No Limits for rate limits when member has no limits", () => {

View file

@ -1,7 +1,7 @@
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Member } from "@/components/networking";
import { formatBudgetReset } from "@/utils/budgetUtils";
import { DateCell, MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { InfoCircleOutlined } from "@ant-design/icons";
@ -58,14 +58,10 @@ export default function TeamMemberTab({
return membership?.total_spend ?? 0;
};
const getUserBudget = (userId: string | null): string | null => {
const getUserBudget = (userId: string | null): number | null => {
if (!userId) return null;
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
const maxBudget = membership?.litellm_budget_table?.max_budget;
if (maxBudget === null || maxBudget === undefined) {
return null;
}
return formatNumber(maxBudget);
return membership?.litellm_budget_table?.max_budget ?? null;
};
// Helper function to get rate limits for a user
@ -98,7 +94,7 @@ export default function TeamMemberTab({
const getUserBudgetReset = (userId: string | null): string | null => {
if (!userId) return null;
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
return formatBudgetReset(membership?.litellm_budget_table?.budget_reset_at);
return membership?.litellm_budget_table?.budget_reset_at ?? null;
};
const extraColumns: ColumnsType<Member> = [
@ -146,7 +142,7 @@ export default function TeamMemberTab({
),
key: "spend",
render: (_: unknown, record: Member) => (
<Typography.Text>${formatNumberWithCommas(getUserCurrentCycleSpend(record.user_id), 4)}</Typography.Text>
<MoneyCell value={getUserCurrentCycleSpend(record.user_id)} decimals={4} />
),
},
{
@ -159,31 +155,19 @@ export default function TeamMemberTab({
</Space>
),
key: "total_spend",
render: (_: unknown, record: Member) => (
<Typography.Text>${formatNumberWithCommas(getUserTotalSpend(record.user_id), 4)}</Typography.Text>
),
render: (_: unknown, record: Member) => <MoneyCell value={getUserTotalSpend(record.user_id)} decimals={4} />,
},
{
title: "Team Member Budget (USD)",
key: "budget",
render: (_: unknown, record: Member) => {
const budget = getUserBudget(record.user_id);
return (
<Typography.Text>{budget ? `$${formatNumberWithCommas(Number(budget), 4)}` : "No Limit"}</Typography.Text>
);
},
render: (_: unknown, record: Member) => (
<MoneyCell value={getUserBudget(record.user_id)} decimals={4} emptyText="Unlimited" showZero />
),
},
{
title: "Budget Reset",
key: "budget_reset",
render: (_: unknown, record: Member) => {
const reset = getUserBudgetReset(record.user_id);
return reset ? (
<Typography.Text>{reset}</Typography.Text>
) : (
<Typography.Text type="secondary">—</Typography.Text>
);
},
render: (_: unknown, record: Member) => <DateCell value={getUserBudgetReset(record.user_id)} precision="date" />,
},
{
title: (

View file

@ -2,7 +2,7 @@
"use client";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
import {
ColumnDef,
@ -12,18 +12,7 @@ import {
SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
Badge,
Button,
Icon,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Text,
} from "@tremor/react";
import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Popover, Skeleton, Tooltip, Typography } from "antd";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
@ -214,23 +203,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
header: "Key ID",
size: 100,
enableSorting: true,
cell: (info) => {
const value = info.getValue() as string;
const width = info.cell.column.getSize();
return (
<Tooltip title={value}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate block"
style={{ maxWidth: width, overflow: "hidden" }}
onClick={() => setSelectedKey(info.row.original)}
>
{value ?? "-"}
</Button>
</Tooltip>
);
},
cell: (info) => (
<IdCell value={info.getValue() as string | null} onClick={() => setSelectedKey(info.row.original)} />
),
},
{
id: "key_alias",
@ -310,10 +285,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
header: "Created At",
size: 120,
enableSorting: true,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "-";
},
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
},
{
id: "created_by",
@ -380,10 +352,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
header: "Updated At",
size: 120,
enableSorting: true,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "Never";
},
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
},
{
id: "last_active",
@ -401,16 +370,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
),
size: 130,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
if (!value) return "Unknown";
const date = new Date(value as string);
return (
<Tooltip title={date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "long" })}>
<span>{date.toLocaleDateString()}</span>
</Tooltip>
);
},
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Unknown" />,
},
{
id: "expires",
@ -418,10 +378,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
header: "Expires",
size: 120,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "Never";
},
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
},
{
id: "spend",
@ -429,7 +386,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
header: "Spend (USD)",
size: 100,
enableSorting: true,
cell: (info) => formatNumberWithCommas(info.getValue() as number, 4),
cell: (info) => <MoneyCell value={info.getValue() as number | null} decimals={4} />,
},
{
id: "max_budget",
@ -437,11 +394,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
header: "Budget (USD)",
size: 110,
enableSorting: true,
cell: (info) => {
const maxBudget = info.getValue() as number | null;
if (maxBudget === null) return "Unlimited";
return `$${formatNumberWithCommas(maxBudget)}`;
},
cell: (info) => (
<MoneyCell value={info.getValue() as number | null} decimals={0} emptyText="Unlimited" showZero />
),
},
{
id: "budget_reset_at",
@ -449,10 +404,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
header: "Budget Reset",
size: 130,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleString() : "Never";
},
cell: (info) => <DateCell value={info.getValue() as string | null} fallback="Never" />,
},
{
id: "models",

View file

@ -21,14 +21,18 @@ const badgeVariants = cva({
},
});
function Badge({
className,
variant = "default",
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return (
<span data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
const Badge = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span"> & VariantProps<typeof badgeVariants>
>(({ className, variant = "default", ...props }, ref) => (
<span
ref={ref}
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
));
Badge.displayName = "Badge";
export { Badge, badgeVariants };

View file

@ -50,6 +50,7 @@ import {
getProxyUISettings,
} from "./networking";
import TopKeyView from "./UsagePage/components/EntityUsage/TopKeyView";
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "@/utils/dataUtils";
interface UsagePageProps {
@ -644,9 +645,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
<TableRow key={provider.provider}>
<TableCell>{provider.provider}</TableCell>
<TableCell>
{parseFloat(provider.spend.toFixed(2)) < 0.00001
? "less than 0.00"
: formatNumberWithCommas(provider.spend, 2)}
<MoneyCell value={provider.spend} decimals={2} />
</TableCell>
</TableRow>
))}
@ -819,7 +818,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
{topUsers?.map((user: any, index: number) => (
<TableRow key={index}>
<TableCell>{user.end_user}</TableCell>
<TableCell>{formatNumberWithCommas(user.total_spend, 2)}</TableCell>
<TableCell>
<MoneyCell value={user.total_spend} decimals={2} />
</TableCell>
<TableCell>{user.total_count}</TableCell>
</TableRow>
))}

View file

@ -1,7 +1,8 @@
import React from "react";
import { Table, Badge, Tooltip } from "antd";
import { Table, Tooltip } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { EyeOutlined, CopyOutlined, DeleteOutlined } from "@ant-design/icons";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
import { DocumentUpload } from "./types";
interface DocumentsTableProps {
@ -16,15 +17,15 @@ const DocumentsTable: React.FC<DocumentsTableProps> = ({ documents, onRemove })
};
const getStatusBadge = (status: DocumentUpload["status"]) => {
const statusConfig = {
uploading: { color: "blue", text: "Uploading" },
done: { color: "green", text: "Ready" },
error: { color: "red", text: "Error" },
removed: { color: "default", text: "Removed" },
const statusConfig: Record<DocumentUpload["status"], { tone: StatusTone; label: string }> = {
uploading: { tone: "info", label: "Uploading" },
done: { tone: "success", label: "Ready" },
error: { tone: "error", label: "Error" },
removed: { tone: "neutral", label: "Removed" },
};
const config = statusConfig[status];
return <Badge color={config.color} text={config.text} />;
const config: { tone: StatusTone; label: string } = statusConfig[status] ?? { tone: "neutral", label: status };
return <StatusBadge tone={config.tone} label={config.label} />;
};
const formatFileSize = (bytes?: number) => {

View file

@ -154,9 +154,8 @@ describe("VectorStoreTable", () => {
it("should truncate long vector store IDs", () => {
renderComponent();
// Check that the truncated text is rendered (first 15 chars + ...)
const truncatedText = "very-long-vecto...";
expect(screen.getByText(truncatedText)).toBeInTheDocument();
const idButton = screen.getByText("very-long-vector-store-id-that-should-be-truncated");
expect(idButton).toHaveClass("truncate", "max-w-[15ch]");
});
it("should make vector store ID clickable", async () => {
@ -245,13 +244,13 @@ describe("VectorStoreTable", () => {
describe("Date Columns", () => {
it("should render created at dates", () => {
renderComponent();
const dateElements = screen.getAllByText(/1\/\d+\/2024/);
const dateElements = screen.getAllByText(/Jan \d+, 2024/);
expect(dateElements.length).toBe(6); // 3 created_at + 3 updated_at dates
});
it("should render updated at dates", () => {
renderComponent();
const dateElements = screen.getAllByText(/1\/\d+\/2024/);
const dateElements = screen.getAllByText(/Jan \d+, 2024/);
expect(dateElements.length).toBe(6); // 3 created_at + 3 updated_at dates
});
});

View file

@ -10,6 +10,7 @@ import {
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react";
import { Tooltip } from "antd";
import React from "react";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { getProviderLogoAndName } from "../provider_info_helpers";
import { VectorStore } from "./types";
@ -28,19 +29,7 @@ const VectorStoreTable: React.FC<VectorStoreTableProps> = ({ data, onView, onEdi
{
header: "Vector Store ID",
accessorKey: "vector_store_id",
cell: ({ row }) => {
const vectorStore = row.original;
return (
<button
onClick={() => onView(vectorStore.vector_store_id)}
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
>
{vectorStore.vector_store_id.length > 15
? `${vectorStore.vector_store_id.slice(0, 15)}...`
: vectorStore.vector_store_id}
</button>
);
},
cell: ({ row }) => <IdCell value={row.original.vector_store_id} onClick={onView} />,
},
{
header: "Name",
@ -109,19 +98,13 @@ const VectorStoreTable: React.FC<VectorStoreTableProps> = ({ data, onView, onEdi
header: "Created At",
accessorKey: "created_at",
sortingFn: "datetime",
cell: ({ row }) => {
const vectorStore = row.original;
return <span className="text-xs">{new Date(vectorStore.created_at).toLocaleDateString()}</span>;
},
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
header: "Updated At",
accessorKey: "updated_at",
sortingFn: "datetime",
cell: ({ row }) => {
const vectorStore = row.original;
return <span className="text-xs">{new Date(vectorStore.updated_at).toLocaleDateString()}</span>;
},
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
},
{
id: "actions",

View file

@ -4,7 +4,7 @@ import { Table, Tag, Input, Select, Button, Pagination, Spin } from "antd";
import { ReloadOutlined, LoadingOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import { resolveLogoSrc } from "@/lib/assetPaths";
import moment from "moment";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import { uiAuditLogsCall } from "../networking";
import { AuditLogEntry } from "./columns";
import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer";
@ -95,11 +95,7 @@ export default function AuditLogs({ userID, userRole, token, accessToken, isActi
dataIndex: "updated_at",
key: "updated_at",
width: 200,
render: (val: string) => (
<span className="font-mono text-xs whitespace-nowrap">
{moment.utc(val).local().format("MMM D, YYYY HH:mm:ss")}
</span>
),
render: (val: string) => <DateCell value={val} />,
},
{
title: "Action",
@ -123,7 +119,7 @@ export default function AuditLogs({ userID, userRole, token, accessToken, isActi
title: "Object ID",
dataIndex: "object_id",
key: "object_id",
render: (val: string) => <span className="font-mono text-xs">{val}</span>,
render: (val: string) => <IdCell value={val} variant="plain" truncate={false} />,
},
{
title: "Changed By",
@ -137,7 +133,7 @@ export default function AuditLogs({ userID, userRole, token, accessToken, isActi
dataIndex: "changed_by_api_key",
key: "changed_by_api_key",
width: 140,
render: (val: string) => (val ? <span className="font-mono text-xs">{val.slice(0, 12)}…</span> : "—"),
render: (val: string) => <IdCell value={val} variant="plain" />,
},
];

View file

@ -0,0 +1,56 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { createColumns, type LogEntry } from "./columns";
import { DataTable } from "./table";
const logEntry = (overrides: Partial<LogEntry>): LogEntry => ({
request_id: "req-1",
api_key: "key-1",
team_id: "team-1",
model: "gpt-4o",
model_id: "model-1",
call_type: "acompletion",
spend: 0,
total_tokens: 10,
prompt_tokens: 5,
completion_tokens: 5,
startTime: "2026-07-07T09:50:13Z",
endTime: "2026-07-07T09:50:14Z",
cache_hit: "false",
messages: [],
response: {},
...overrides,
});
describe("Cost column", () => {
it("renders '-' for zero spend with no tooltip, so hovering never shows a contradictory $0", async () => {
const user = userEvent.setup();
render(
<DataTable
data={[logEntry({ request_id: "req-zero", spend: 0 })]}
columns={createColumns()}
getRowId={(r) => r.request_id}
/>,
);
for (const dash of screen.getAllByText("-")) {
await user.hover(dash);
}
expect(screen.queryByText("$0")).not.toBeInTheDocument();
});
it("shows the full-precision raw value in the tooltip for a real spend", async () => {
const user = userEvent.setup();
render(
<DataTable
data={[logEntry({ request_id: "req-spend", spend: 0.00012345678 })]}
columns={createColumns()}
getRowId={(r) => r.request_id}
/>,
);
const formatted = screen.getByText("$0.000123");
await user.hover(formatted);
expect(await screen.findByText("$0.00012345678")).toBeInTheDocument();
});
});

View file

@ -1,11 +1,10 @@
import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
import { getSpendString } from "@/utils/dataUtils";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge, Button } from "@tremor/react";
import { Tooltip } from "antd";
import React, { useState } from "react";
import React from "react";
import { getProviderLogoAndName } from "../provider_info_helpers";
import { TableHeaderSortDropdown } from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
import { TimeCell } from "./time_cell";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
import { AgentBadge, AgentIcon, LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges";
@ -120,7 +119,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
: "Time",
accessorKey: "startTime",
size: 200,
cell: (info: any) => <TimeCell utcTime={info.getValue()} />,
cell: (info: any) => <DateCell value={info.getValue()} />,
},
{
header: "Type",
@ -174,48 +173,20 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
cell: (info: any) => {
const status = info.getValue() || "Success";
const isSuccess = status.toLowerCase() !== "failure";
return (
<span
className={`px-2 py-1 rounded-md text-xs font-medium inline-block text-center w-16 ${
isSuccess ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
}`}
>
{isSuccess ? "Success" : "Failure"}
</span>
);
return <StatusBadge tone={isSuccess ? "success" : "error"} label={isSuccess ? "Success" : "Failure"} />;
},
},
{
header: "Session ID",
accessorKey: "session_id",
size: 120,
cell: (info: any) => {
const value = String(info.getValue() || "");
const onSessionClick = info.row.original.onSessionClick;
return (
<Tooltip title={String(info.getValue() || "")}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal text-xs max-w-[15ch] truncate block"
onClick={() => onSessionClick?.(value)}
>
{String(info.getValue() || "")}
</Button>
</Tooltip>
);
},
cell: (info: any) => <IdCell value={info.getValue()} onClick={info.row.original.onSessionClick} />,
},
{
header: "Request ID",
accessorKey: "request_id",
cell: (info: any) => (
<Tooltip title={String(info.getValue() || "")}>
<span className="font-mono text-xs max-w-[15ch] truncate block">{String(info.getValue() || "")}</span>
</Tooltip>
),
cell: (info: any) => <IdCell value={info.getValue()} variant="plain" />,
},
{
header: sortProps
@ -236,11 +207,14 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
const row = info.row.original;
const mcpCount = row.mcp_tool_call_count || 0;
const mcpSpend = row.mcp_tool_call_spend || 0;
const spend = info.getValue();
return (
<div className="flex flex-col items-end">
<Tooltip title={`$${String(info.getValue() || 0)}`}>
<span>{getSpendString(info.getValue() || 0)}</span>
<Tooltip title={spend ? `$${String(spend)}` : undefined}>
<span>
<MoneyCell value={spend} decimals={6} />
</span>
</Tooltip>
{mcpCount > 0 && mcpSpend > 0 && (
<span className="text-[10px] text-amber-600">
@ -320,21 +294,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
header: "Key Hash",
accessorKey: "metadata.user_api_key",
size: 110,
cell: (info: any) => {
const value = String(info.getValue() || "-");
const onKeyHashClick = info.row.original.onKeyHashClick;
return (
<Tooltip title={value}>
<span
className="font-mono max-w-[15ch] truncate block cursor-pointer hover:text-blue-600"
onClick={() => onKeyHashClick?.(value)}
>
{value}
</span>
</Tooltip>
);
},
cell: (info: any) => <IdCell value={info.getValue()} variant="plain" onClick={info.row.original.onKeyHashClick} />,
},
{
header: "Key Alias",
@ -589,141 +549,3 @@ export type AuditLogEntry = {
before_value: Record<string, any>;
updated_values: Record<string, any>;
};
const getActionBadge = (action: string) => {
return (
<Badge color="gray" className="flex items-center gap-1">
<span className="whitespace-nowrap text-xs">{action}</span>
</Badge>
);
};
export const auditLogColumns: ColumnDef<AuditLogEntry>[] = [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
const ExpanderCell = () => {
const [localExpanded, setLocalExpanded] = React.useState(row.getIsExpanded());
const toggleHandler = React.useCallback(() => {
setLocalExpanded((prev) => !prev);
row.getToggleExpandedHandler()();
}, [row]);
return row.getCanExpand() ? (
<button
onClick={toggleHandler}
style={{ cursor: "pointer" }}
aria-label={localExpanded ? "Collapse row" : "Expand row"}
className="w-6 h-6 flex items-center justify-center focus:outline-hidden"
>
<svg
className={`w-4 h-4 transform transition-transform ${localExpanded ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
) : (
<span className="w-6 h-6 flex items-center justify-center">●</span>
);
};
return <ExpanderCell />;
},
},
{
header: "Timestamp",
accessorKey: "updated_at",
cell: (info: any) => <TimeCell utcTime={info.getValue()} />,
},
{
header: "Table Name",
accessorKey: "table_name",
cell: (info: any) => {
const tableName = info.getValue();
let displayValue = tableName;
switch (tableName) {
case "LiteLLM_VerificationToken":
displayValue = "Keys";
break;
case "LiteLLM_TeamTable":
displayValue = "Teams";
break;
case "LiteLLM_OrganizationTable":
displayValue = "Organizations";
break;
case "LiteLLM_UserTable":
displayValue = "Users";
break;
case "LiteLLM_ProxyModelTable":
displayValue = "Models";
break;
default:
displayValue = tableName;
}
return <span>{displayValue}</span>;
},
},
{
header: "Action",
accessorKey: "action",
cell: (info: any) => <span>{getActionBadge(info.getValue())}</span>,
},
{
header: "Changed By",
accessorKey: "changed_by",
cell: (info: any) => {
const changedBy = info.row.original.changed_by;
const apiKey = info.row.original.changed_by_api_key;
return (
<div className="space-y-1">
<div className="font-medium">{changedBy}</div>
{apiKey && ( // Only show API key if it exists
<Tooltip title={apiKey}>
<div className="text-xs text-muted-foreground max-w-[15ch] truncate">
{" "}
{/* Apply max-width and truncate */}
{apiKey}
</div>
</Tooltip>
)}
</div>
);
},
},
{
header: "Affected Item ID",
accessorKey: "object_id",
cell: (props) => {
const ObjectIdDisplay = () => {
const objectId = props.getValue();
const [copied, setCopied] = useState(false);
if (!objectId) return <>-</>;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(String(objectId));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (err) {
console.error("Failed to copy object ID: ", err);
}
};
return (
<Tooltip title={copied ? "Copied!" : String(objectId)}>
<span className="max-w-[20ch] truncate block cursor-pointer hover:text-blue-600" onClick={handleCopy}>
{String(objectId)}
</span>
</Tooltip>
);
};
return <ObjectIdDisplay />;
},
},
];

View file

@ -1,36 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TimeCell, getTimeZone } from "./time_cell";
describe("TimeCell", () => {
it("should render a formatted time string", () => {
render(<TimeCell utcTime="2025-06-15T14:30:00Z" />);
// The global toLocaleString mock in setupTests returns "YYYY-MM-DD HH:MM:SS"
expect(screen.getByText(/2025/)).toBeInTheDocument();
});
it("should render 'Error converting time' for invalid dates", () => {
// toLocaleString on an Invalid Date returns "Invalid Date", not throwing,
// but the component catches exceptions. Force an error by passing something
// that causes Date constructor to produce NaN.
render(<TimeCell utcTime="not-a-date" />);
// The mock returns "NaN-NaN-NaN NaN:NaN:NaN" for invalid dates
// The component has a try/catch that returns "Error converting time" on exception
const el = screen.getByText(/NaN|Error/);
expect(el).toBeInTheDocument();
});
it("should render with monospace font", () => {
render(<TimeCell utcTime="2025-06-15T14:30:00Z" />);
const span = screen.getByText(/2025/);
expect(span).toHaveStyle({ fontFamily: "monospace" });
});
});
describe("getTimeZone", () => {
it("should return a non-empty timezone string", () => {
const tz = getTimeZone();
expect(typeof tz).toBe("string");
expect(tz.length).toBeGreaterThan(0);
});
});

View file

@ -1,42 +0,0 @@
import * as React from "react";
interface TimeCellProps {
utcTime: string;
}
const getLocalTime = (utcTime: string): string => {
try {
const date = new Date(utcTime);
return date
.toLocaleString("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
})
.replace(",", "");
} catch (e) {
return "Error converting time";
}
};
export const TimeCell: React.FC<TimeCellProps> = ({ utcTime }) => {
return (
<span
style={{
fontFamily: "monospace",
width: "180px",
display: "inline-block",
}}
>
{getLocalTime(utcTime)}
</span>
);
};
export const getTimeZone = (): string => {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
};

View file

@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { type LicenseExpiryTier, formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "./licenseUtils";
const NOW = new Date("2026-07-08T00:00:00Z");
describe("getDaysUntilExpiration", () => {
it("returns null for a null expiration", () => {
expect(getDaysUntilExpiration(null, NOW)).toBeNull();
});
it("returns null for an unparseable date", () => {
expect(getDaysUntilExpiration("not-a-date", NOW)).toBeNull();
});
it("returns 0 for an expiration on the current UTC day", () => {
expect(getDaysUntilExpiration("2026-07-08", NOW)).toBe(0);
});
it("returns a positive count for future dates", () => {
expect(getDaysUntilExpiration("2026-07-15", NOW)).toBe(7);
expect(getDaysUntilExpiration("2026-08-07", NOW)).toBe(30);
});
it("returns a negative count for a past date", () => {
expect(getDaysUntilExpiration("2026-07-07", NOW)).toBe(-1);
});
it("is timezone-independent within a UTC day", () => {
expect(getDaysUntilExpiration("2026-08-07", new Date("2026-07-08T00:00:01Z"))).toBe(30);
expect(getDaysUntilExpiration("2026-08-07", new Date("2026-07-08T23:59:59Z"))).toBe(30);
});
});
describe("getLicenseExpiryTier", () => {
const cases: Array<[string | null, LicenseExpiryTier]> = [
[null, "none"],
["not-a-date", "none"],
["2026-08-08", "none"],
["2026-08-07", "warning"],
["2026-07-16", "warning"],
["2026-07-15", "critical"],
["2026-07-09", "critical"],
["2026-07-08", "critical"],
["2026-07-07", "expired"],
["2026-01-01", "expired"],
];
it.each(cases)("classifies %s as %s", (date, expected) => {
expect(getLicenseExpiryTier(date, NOW)).toBe(expected);
});
});
describe("formatExpiryDate", () => {
it("formats an ISO date as a human-readable UTC date", () => {
expect(formatExpiryDate("2026-07-31")).toBe("Jul 31, 2026");
});
it("returns the input unchanged when unparseable", () => {
expect(formatExpiryDate("bogus")).toBe("bogus");
});
});

View file

@ -0,0 +1,51 @@
export type LicenseExpiryTier = "none" | "warning" | "critical" | "expired";
export const LICENSE_EXPIRY_WARNING_DAYS = 30;
export const LICENSE_EXPIRY_CRITICAL_DAYS = 7;
export const getDaysUntilExpiration = (expirationDate: string | null, now: Date = new Date()): number | null => {
if (!expirationDate) {
return null;
}
const expiration = new Date(`${expirationDate}T00:00:00Z`);
if (Number.isNaN(expiration.getTime())) {
return null;
}
const nowUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const diffMs = expiration.getTime() - nowUtcMidnight;
return Math.ceil(diffMs / (1000 * 60 * 60 * 24));
};
export const getLicenseExpiryTier = (expirationDate: string | null, now: Date = new Date()): LicenseExpiryTier => {
const days = getDaysUntilExpiration(expirationDate, now);
if (days === null) {
return "none";
}
if (days < 0) {
return "expired";
}
if (days <= LICENSE_EXPIRY_CRITICAL_DAYS) {
return "critical";
}
if (days <= LICENSE_EXPIRY_WARNING_DAYS) {
return "warning";
}
return "none";
};
const EXPIRY_DATE_FORMAT: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
};
export const formatExpiryDate = (expirationDate: string): string => {
const date = new Date(`${expirationDate}T00:00:00Z`);
if (Number.isNaN(date.getTime())) {
return expirationDate;
}
return date.toLocaleDateString("en-US", EXPIRY_DATE_FORMAT);
};