diff --git a/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py b/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py index a14d2fc8608..9805b1a9117 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py +++ b/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py @@ -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 diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_grayswan.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_grayswan.py index f2e7447239f..53af7f36a5f 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_grayswan.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_grayswan.py @@ -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( diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index 7cf578ac1c7..0c6c9aa4f2d 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -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 diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 556add9951a..1fe32ad5a40 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx index c3dd1d54b32..dbbf4e35900 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx @@ -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: () => ID, enableSorting: false, size: 170, - cell: ({ row }) => { - const record = row.original; - return ( - - setSelectedGroupId(record.id)} - > - {record.id} - - - ); - }, + cell: ({ row }) => , }, { id: "name", @@ -211,7 +198,7 @@ export function AccessGroupsPage() { header: () => Created, enableSorting: true, sortingFn: "datetime", - cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString(), + cell: ({ getValue }) => , meta: { responsive: ["lg"] }, }, { @@ -219,7 +206,7 @@ export function AccessGroupsPage() { accessorKey: "updatedAt", header: () => Updated, enableSorting: false, - cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString(), + cell: ({ getValue }) => , meta: { responsive: ["xl"] }, }, ...(canModify diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx index 0e601645c20..af15a99f0b4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx @@ -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 = ({ accessToken }) => { .map((value: budgetItem) => ( {value.budget_id} - {value.max_budget ? value.max_budget : "n/a"} + + + {value.tpm_limit ? value.tpm_limit : "n/a"} {value.rpm_limit ? value.rpm_limit : "n/a"} {canModify && ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts new file mode 100644 index 00000000000..3ea0bd20e40 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts @@ -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 => { + const options = { + queryKey: licenseInfoKeys.detail("license"), + queryFn: () => getLicenseInfo(accessToken!), + enabled: Boolean(accessToken), + staleTime: 5 * 60 * 1000, + retry: false, + }; + return useQuery(options); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index af68d9f87e9..7573ddb5a0f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -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}, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 09951dc1923..c84209b80cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -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)} /> +
{mode !== "ai-gateway" ? (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx index 402de29e0c5..4ee784f4664 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx @@ -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 = ({ accessToken }) => { } }; - const renderIdPill = (id: string | null | undefined, onClick?: () => void) => { - if (!id) return -; - 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 ( - - {onClick ? ( - - ) : ( - {short} - )} - - ); - }; - const columns: ColumnsType = [ { title: "ID", dataIndex: "memory_id", key: "memory_id", width: 140, - render: (_: unknown, r: MemoryRow) => renderIdPill(r.memory_id, () => setDetailRow(r)), + render: (_: unknown, r: MemoryRow) => setDetailRow(r)} />, }, { title: "Name", @@ -246,21 +226,21 @@ export const MemoryView: React.FC = ({ accessToken }) => { dataIndex: "user_id", key: "user_id", width: 160, - render: (uid?: string | null) => renderIdPill(uid), + render: (uid?: string | null) => , }, { title: "Team ID", dataIndex: "team_id", key: "team_id", width: 160, - render: (tid?: string | null) => renderIdPill(tid), + render: (tid?: string | null) => , }, { title: "Updated", dataIndex: "updated_at", key: "updated_at", width: 180, - render: (ts?: string) => {formatTimestamp(ts)}, + render: (ts?: string) => , // 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. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.tsx index 7b891078d36..8269c843b98 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.tsx @@ -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 = [ title: "Created", dataIndex: "created_at", key: "created_at", - render: (date: string) => (date ? new Date(date).toLocaleDateString() : "—"), + render: (date: string) => , }, { title: "Last Active", dataIndex: "last_active", key: "last_active", - render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : "Never"), + render: (date: string | null) => , }, ]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.tsx index 8a3fc178914..be989229022 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.tsx @@ -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) => ( - - setSelectedProjectId(id)} - > - {id} - - - ), + render: (id: string) => , }, { 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) => , }, { title: "Updated", dataIndex: "updated_at", key: "updated_at", responsive: ["xl"], - render: (date: string) => new Date(date).toLocaleDateString(), + render: (date: string) => , }, ]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/components/prompt_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/components/prompt_table.tsx index 94b242ca4ea..51a03d19e83 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/components/prompt_table.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/components/prompt_table.tsx @@ -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 = ({ 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[] = [ { 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 ( -
- - - - - { - e.stopPropagation(); - copyToClipboard(fullId); - }} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" - /> - -
- ); - }, + cell: (info: any) => , }, { header: "Model", @@ -162,26 +125,12 @@ const PromptTable: React.FC = ({ { header: "Created At", accessorKey: "created_at", - cell: ({ row }) => { - const prompt = row.original; - return ( - - {formatDate(prompt.created_at)} - - ); - }, + cell: ({ row }) => , }, { header: "Updated At", accessorKey: "updated_at", - cell: ({ row }) => { - const prompt = row.original; - return ( - - {formatDate(prompt.updated_at)} - - ); - }, + cell: ({ row }) => , }, { header: "Environment", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolColumn.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolColumn.tsx index 198b3ea095f..3d9fdb2866e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolColumn.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolColumn.tsx @@ -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 -; } - return ( - - ); + return ; }, }, { @@ -52,7 +46,7 @@ export const searchToolColumns = ( dataIndex: "created_at", key: "created_at", render: (_, tool) => { - return {tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}; + return ; }, }, { @@ -60,7 +54,7 @@ export const searchToolColumns = ( dataIndex: "updated_at", key: "updated_at", render: (_, tool) => { - return {tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}; + return ; }, }, { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.test.tsx index f16f5325952..e49ac854e6d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.test.tsx @@ -91,7 +91,7 @@ describe("BulkEditUserModal", () => { it("should display budget information in table", () => { renderWithProviders(); - expect(screen.getByText("$50")).toBeInTheDocument(); + expect(screen.getByText("$50.00")).toBeInTheDocument(); expect(screen.getByText("Unlimited")).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx index 9bef2f3a937..7ea53352807 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx @@ -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 = ({ key: "max_budget", width: "20%", render: (budget: number | null) => ( - {budget !== null ? `$${budget}` : "Unlimited"} + ), }, ]} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 241cda3464e..996fd58efc1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -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); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx index deeaeeb25f4..fc680cb5b1b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx @@ -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 }) => ( -
- - {row.original.user_id ? `${row.original.user_id.slice(0, 7)}...` : "-"} - - {row.original.user_id && ( - - { - e.stopPropagation(); - copyToClipboard(row.original.user_id, "User ID copied to clipboard"); - }} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" - /> - - )} -
- ), + cell: ({ row }) => , }, { header: "Email", @@ -93,17 +75,13 @@ export const columns = ( header: "Spend (USD)", accessorKey: "spend", enableSorting: true, - cell: ({ row }) => ( - {row.original.spend ? formatNumberWithCommas(row.original.spend, 4) : "-"} - ), + cell: ({ row }) => , }, { header: "Budget (USD)", accessorKey: "max_budget", enableSorting: false, - cell: ({ row }) => ( - {row.original.max_budget !== null ? row.original.max_budget : "Unlimited"} - ), + cell: ({ row }) => , }, { header: () => ( @@ -142,21 +120,13 @@ export const columns = ( header: "Created At", accessorKey: "created_at", enableSorting: true, - cell: ({ row }) => ( - - {row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "-"} - - ), + cell: ({ row }) => , }, { header: "Updated At", accessorKey: "updated_at", enableSorting: false, - cell: ({ row }) => ( - - {row.original.updated_at ? new Date(row.original.updated_at).toLocaleDateString() : "-"} - - ), + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx index 09b1c147615..ae1a19ff95d 100644 --- a/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx @@ -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 ? ( - - Yes - - ) : ( - - No - - ); + return ; }, meta: { className: "hidden md:table-cell", diff --git a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx index bc52bbbe062..d4a120d0589 100644 --- a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx @@ -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 ( - - {value || "-"} - - ); - }, + cell: (info) => , }, { id: "key_alias", @@ -100,9 +93,7 @@ export function DeletedKeysTable({ header: "Spend (USD)", size: 100, maxSize: 140, - cell: (info) => ( - {formatNumberWithCommas(info.getValue() as number, 4)} - ), + cell: (info) => , }, { 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 ( - - {maxBudget === null ? "Unlimited" : `$${formatNumberWithCommas(maxBudget)}`} - - ); - }, + cell: (info) => ( + + ), }, { 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 ( - - {userId || "-"} - - ); - }, + cell: (info) => , }, { id: "created_at", @@ -155,12 +134,7 @@ export function DeletedKeysTable({ header: "Created At", size: 120, maxSize: 140, - cell: (info) => { - const value = info.getValue(); - return ( - {value ? new Date(value as string).toLocaleDateString() : "-"} - ); - }, + cell: (info) => , }, { 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 {value ? new Date(value).toLocaleDateString() : "-"}; - }, + cell: (info) => ( + + ), }, { id: "deleted_by", diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx index 57260f6bb2d..ddfd5cf73b6 100644 --- a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx @@ -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 ( - - {value || "-"} - - ); - }, + cell: (info) => , }, { 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 ( - {value ? new Date(value as string).toLocaleDateString() : "-"} - ); - }, + cell: (info) => , }, { 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 ( - {spend !== undefined ? formatNumberWithCommas(spend, 4) : "-"} - ); - }, + cell: (info) => , }, { 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 ( - - {maxBudget === null || maxBudget === undefined ? "No limit" : `$${formatNumberWithCommas(maxBudget)}`} - - ); - }, + cell: (info) => ( + + ), }, { 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 ( - - {value || "-"} - - ); - }, + cell: (info) => , }, { 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 {value ? new Date(value).toLocaleDateString() : "-"}; - }, + cell: (info) => , }, { id: "deleted_by", diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx new file mode 100644 index 00000000000..d6b419ace7c --- /dev/null +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx @@ -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(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when expiration_date is null (community or remote-validated)", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when expiry is more than 30 days out", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows a dismissible amber warning within 30 days", () => { + const { container } = render(); + 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(); + 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(); + expect(screen.getByText(/expires today/)).toBeInTheDocument(); + }); + + it("shows a non-dismissible red expired alert stating features are disabled", () => { + const { container } = render(); + 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(); + fireEvent.click(screen.getByRole("button")); + expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument(); + + unmount(); + render(); + 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(); + expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx new file mode 100644 index 00000000000..c3b20b5fac0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx @@ -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 = {SALES_EMAIL}; + +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 = ({ 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 ( + + ); +}; + +export const LicenseExpiryBanner: React.FC = ({ accessToken }) => { + const { data } = useLicenseInfo(accessToken); + return ; +}; diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index c2cb3d4948e..e83d1acf4e5 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -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 = ({ accessToken, userID, userRole, premiumUser key: "team_id", width: 170, ellipsis: true, - render: (id: string, record: Team) => ( - - setSelectedTeamId(record.team_id)} - data-testid="team-id-cell" - > - {id} - - + render: (id: string) => ( + setSelectedTeamId(teamId)} dataTestId="team-id-cell" /> ), }, { @@ -797,13 +788,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser width: 130, ellipsis: true, sorter: true, - render: (date: string | undefined) => ( - - {date - ? new Date(date).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) - : "—"} - - ), + render: (date: string | undefined) => , }, { title: "Actions", diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx index 70ec6599ca2..4f1889cbc99 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx @@ -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 = ({ // 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 ( - - {label} - - ); + const tone: StatusTone = mode === "success" ? "success" : mode === "failure" ? "error" : "info"; + return ; }, width: 240, }, diff --git a/ui/litellm-dashboard/src/components/ToolPolicies.tsx b/ui/litellm-dashboard/src/components/ToolPolicies.tsx index 4bd028f0c8f..4468334f813 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies.tsx @@ -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 = ({ accessToken, onSelec paginated.map((tool) => ( - +
- - {tool.team_id ?? "-"} - + - - - {tool.key_hash ?? "-"} - - + diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx index 8c7c15bc5a5..ad27fbcd74f 100644 --- a/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx @@ -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({ui}); +}; + 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(); + renderWithClient(); 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(); + renderWithClient(); await screen.findByText("Usage"); @@ -58,7 +64,7 @@ describe("UsageIndicator", () => { total_users_remaining: null, }); - render(); + renderWithClient(); await waitFor(() => { expect(screen.queryByText("Usage")).not.toBeInTheDocument(); @@ -76,7 +82,7 @@ describe("UsageIndicator", () => { total_teams_remaining: 1, }); - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -94,7 +100,7 @@ describe("UsageIndicator", () => { total_teams_remaining: null, }); - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -112,7 +118,7 @@ describe("UsageIndicator", () => { total_teams_remaining: -2, }); - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -121,7 +127,7 @@ describe("UsageIndicator", () => { }); it("should render nothing when accessToken is null", () => { - render(); + renderWithClient(); 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).mockReturnValue(true); - render(); + renderWithClient(); 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(); + renderWithClient(); 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(); + renderWithClient(); 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(); + renderWithClient(); 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(); + renderWithClient(); await screen.findByText("Usage"); diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.tsx index 030df228de0..6e7b5e9ec60 100644 --- a/ui/litellm-dashboard/src/components/UsageIndicator.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.tsx @@ -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(null); - const [licenseInfo, setLicenseInfo] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(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"); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.test.tsx index 793e4c6e3cf..63c606eb00e 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.test.tsx @@ -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 = { diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.tsx index f5cfb553370..88f92360ab5 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.tsx @@ -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 = ({ endpointData }) title: "Spend", dataIndex: "spend", key: "spend", - render: (value: number) => `$${formatNumberWithCommas(value, 2)}`, + render: (value: number) => , }, ]; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index df5b14a57d6..9dd72f44f67 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -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 = ({ accessToken, entityType, enti .map((entity) => ( {entity.metadata.alias} - ${formatNumberWithCommas(entity.metrics.spend, 4)} + + + {entity.metrics.successful_requests.toLocaleString()} @@ -777,7 +780,9 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti {provider.provider}
- ${formatNumberWithCommas(provider.spend, 2)} + + + {provider.successful_requests.toLocaleString()} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx index 0541a9c6925..7eea7653ef1 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx @@ -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(); + 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(); expect(screen.getAllByText("openai").length).toBeGreaterThan(0); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx index 58d673bb7ad..5cea84affb9 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx @@ -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 = ({ loading, isDateChangi {provider.provider} - ${formatNumberWithCommas(provider.spend, 2)} + + + {provider.successful_requests.toLocaleString()} {provider.failed_requests.toLocaleString()} {provider.tokens.toLocaleString()} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx index 126bf51bd36..766f027434f 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx @@ -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( { { 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( { ]} />, ); - 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); } diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index 40bc41b3e8c..2dcf98a1e5c 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -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 = ({ topKeys, teams, showTags = fals { header: "Key ID", accessorKey: "api_key", - cell: (info: any) => ( -
- - - -
- ), + cell: (info: any) => handleKeyClick(info.row.original)} />, }, { header: "Key Alias", @@ -165,10 +153,7 @@ const TopKeyView: React.FC = ({ 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) => , }; const columns = showTags ? [...baseColumns, tagsColumn, spendColumn] : [...baseColumns, spendColumn]; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx index f6014d025ae..bbf9379f5e1 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx @@ -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); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx index 7562ef06a03..8938767c02c 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx @@ -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) => , }, { header: "Successful", diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx index 61968294e18..322f00a501a 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx @@ -158,7 +158,7 @@ describe("KeyModelUsageView", () => { }, ]; render(); - expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.getByText("-")).toBeInTheDocument(); expect(screen.getAllByText("0").length).toBeGreaterThan(0); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx index ee1a49051da..ceb00e8a19f 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx @@ -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 = [ title: "Spend (USD)", dataIndex: "spend", key: "spend", - render: (value) => `$${formatNumberWithCommas(value, 2)}`, + render: (value) => , }, { title: "Successful", diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index ba616a03fd9..02f5d588149 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -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(); }); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index a803d78ed57..00f4304c8a9 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -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 ( - - - - ); - }, + cell: (info) => 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 ( - - Active - - ); + return ; } const isScimBlocked = (key.metadata as Record | 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 ( - - - Blocked - - + ); }, }, @@ -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) => , }, { 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) => , }, { 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 ( - - {date.toLocaleDateString()} - - ); - }, + cell: (info) => , }, { 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) => , }, { 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) => , }, { 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) => , }, { id: "models", diff --git a/ui/litellm-dashboard/src/components/agents.tsx b/ui/litellm-dashboard/src/components/agents.tsx index 0d8916942da..e3703f6c588 100644 --- a/ui/litellm-dashboard/src/components/agents.tsx +++ b/ui/litellm-dashboard/src/components/agents.tsx @@ -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 = ({ accessToken, userRole, teams {agent.agent_name} - - - + setSelectedAgentId(id)} /> - {formatNumberWithCommas(agent.spend, 4)} + @@ -213,13 +204,13 @@ const AgentsPanel: React.FC = ({ accessToken, userRole, teams - {agent.created_at ? new Date(agent.created_at).toLocaleDateString() : "N/A"} + {(agent.keys?.length ?? 0) > 0 ? ( - Active + ) : ( - Needs Setup + )} {isAdmin && ( diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/plugin_table.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/plugin_table.tsx index 1646161891f..0932e658899 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/plugin_table.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/plugin_table.tsx @@ -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 = ({ }) => { const [sorting, setSorting] = useState([{ 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 = ({ accessorKey: "name", cell: ({ row }) => { const plugin = row.original; - const name = plugin.name || ""; return (
- - - + onPluginClick(plugin.id)} /> { @@ -122,24 +107,13 @@ const PluginTable: React.FC = ({ accessorKey: "enabled", cell: ({ row }) => { const plugin = row.original; - return ( - - {plugin.enabled ? "Yes" : "No"} - - ); + return ; }, }, { header: "Created At", accessorKey: "created_at", - cell: ({ row }) => { - const plugin = row.original; - return ( - - {formatDate(plugin.created_at)} - - ); - }, + cell: ({ row }) => , }, ...(isAdmin ? [ diff --git a/ui/litellm-dashboard/src/components/general_settings.tsx b/ui/litellm-dashboard/src/components/general_settings.tsx index 5b8dec39505..038547c6e0e 100644 --- a/ui/litellm-dashboard/src/components/general_settings.tsx +++ b/ui/litellm-dashboard/src/components/general_settings.tsx @@ -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 = ({ accessToken, user {value.stored_in_db == true ? ( - - In DB - + ) : value.stored_in_db == false ? ( - In Config + ) : ( - Not Set + )} diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx index ecf6ce48fde..99f6b2793fd 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx @@ -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 = ({ const [editModalVisible, setEditModalVisible] = useState(false); const [selectedGuardrail, setSelectedGuardrail] = useState(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 = ({ { header: "Guardrail ID", accessorKey: "guardrail_id", - cell: (info: any) => ( - - - - ), + cell: (info: any) => , }, { header: "Name", @@ -126,41 +108,21 @@ const GuardrailTable: React.FC = ({ header: "Default On", accessorKey: "litellm_params.default_on", cell: ({ row }) => { - const guardrail = row.original; + const isDefaultOn = !!row.original.litellm_params?.default_on; return ( - - {guardrail.litellm_params?.default_on ? "Default On" : "Default Off"} - + ); }, }, { header: "Created At", accessorKey: "created_at", - cell: ({ row }) => { - const guardrail = row.original; - return ( - - {formatDate(guardrail.created_at)} - - ); - }, + cell: ({ row }) => , }, { header: "Updated At", accessorKey: "updated_at", - cell: ({ row }) => { - const guardrail = row.original; - return ( - - {formatDate(guardrail.updated_at)} - - ); - }, + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx index 7cf0d48a49f..1e25f87d262 100644 --- a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx @@ -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 = { - active: "green", - inactive: "red", - unknown: "gray", - healthy: "green", - unhealthy: "red", + const statusTones: Record = { + active: "success", + inactive: "error", + unknown: "neutral", + healthy: "success", + unhealthy: "error", }; - const color = statusColors[server.status] || "gray"; + const tone = statusTones[server.status] || "neutral"; - return ( - - {server.status || "unknown"} - - ); + return ; }, }, { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx index 546df9ebc4b..5e7e99ee8ec 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx @@ -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 }) => ( - - {row.original.toolset_id.slice(0, 8)}… - - ), + cell: ({ row }) => , }, { header: "Name", @@ -359,11 +356,7 @@ function toolsetColumns( { header: "Created", accessorKey: "created_at", - cell: ({ row }) => ( - - {row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "—"} - - ), + cell: ({ row }) => , }, ...(isAdmin ? [ diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx index a2194e23b6e..6497f2686cb 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx @@ -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 = ({ onPageChange?.(page); }; - const getStatusBadge = (status: string) => { - switch (status) { - case "healthy": - return healthy; - case "unhealthy": - return unhealthy; - case "checking": - return checking; - case "none": - return none; - default: - return unknown; - } - }; - const showErrorModal = (modelName: string, cleanedError: string, fullError: string) => { setSelectedErrorDetails({ modelName, @@ -612,7 +597,6 @@ const HealthCheckComponent: React.FC = ({ handleModelSelection, handleSelectAll, runIndividualHealthCheck, - getStatusBadge, getDisplayModelName, showErrorModal, showSuccessModal, diff --git a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx b/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx index 12cc984ef91..33c97236f97 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx @@ -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 = { + healthy: "success", + unhealthy: "error", + checking: "info", + none: "neutral", +}; + +const healthStatusBadge = (status: string): JSX.Element => { + const tone = HEALTH_STATUS_TONES[status]; + return tone ? : ; +}; + 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()} /> - -
setSelectedModelId && setSelectedModelId(model.model_info.id)} - > - {model.model_info.id} -
-
+
); }, @@ -175,7 +180,7 @@ export const healthCheckColumns = ( return (
- {getStatusBadge(healthStatus.status)} + {healthStatusBadge(healthStatus.status)} {hasSuccessResponse && showSuccessModal && ( - +
e.stopPropagation()}> +
) : ( "-" @@ -370,15 +345,10 @@ export const columns = ( minSize: 80, cell: ({ row }) => { const model = row.original; - return ( -
- {model.model_info.db_model ? "DB Model" : "Config Model"} -
+ return model.model_info.db_model ? ( + + ) : ( + ); }, }, diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index e1b8c6d5044..402cc33902f 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -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 = ({ render: (_: unknown, record: Member) => { const orgMember = record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined; - return ${formatNumberWithCommas(orgMember?.spend ?? 0, 4)}; + return ; }, }, { diff --git a/ui/litellm-dashboard/src/components/organizations.tsx b/ui/litellm-dashboard/src/components/organizations.tsx index 857a0a0c64c..edebc17087a 100644 --- a/ui/litellm-dashboard/src/components/organizations.tsx +++ b/ui/litellm-dashboard/src/components/organizations.tsx @@ -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 = ({ .map((org: Organization) => ( -
- - - -
+
{org.organization_alias} - {org.created_at ? new Date(org.created_at).toLocaleDateString() : "N/A"} + - {formatNumberWithCommas(org.spend, 4)} - {org.litellm_budget_table?.max_budget !== null && - org.litellm_budget_table?.max_budget !== undefined - ? org.litellm_budget_table?.max_budget - : "No limit"} + + + + = ({ { header: "ID", accessorKey: "id", - cell: (info: any) => ( - -
info.row.original.id && setSelectedEndpointId(info.row.original.id)} - > - {info.row.original.id} -
-
- ), + cell: (info: any) => , }, { header: "Path", @@ -192,7 +184,9 @@ const PassThroughSettings: React.FC = ({
), accessorKey: "auth", - cell: (info: any) => {info.getValue() ? "Yes" : "No"}, + cell: (info: any) => ( + + ), }, { header: "Headers", diff --git a/ui/litellm-dashboard/src/components/policies/attachment_table.test.tsx b/ui/litellm-dashboard/src/components/policies/attachment_table.test.tsx index 099aa97a433..0cfd4e41e0a 100644 --- a/ui/litellm-dashboard/src/components/policies/attachment_table.test.tsx +++ b/ui/litellm-dashboard/src/components/policies/attachment_table.test.tsx @@ -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(); - 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", () => { diff --git a/ui/litellm-dashboard/src/components/policies/attachment_table.tsx b/ui/litellm-dashboard/src/components/policies/attachment_table.tsx index d9de8378a8a..fa482552fd5 100644 --- a/ui/litellm-dashboard/src/components/policies/attachment_table.tsx +++ b/ui/litellm-dashboard/src/components/policies/attachment_table.tsx @@ -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 = ({ }) => { const [sorting, setSorting] = useState([{ 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[] = [ { header: "Attachment ID", accessorKey: "attachment_id", - cell: (info: any) => ( - - - {info.getValue() ? `${String(info.getValue()).slice(0, 7)}...` : ""} - - - ), + cell: (info: any) => , }, { header: "Policy", @@ -183,14 +171,7 @@ const AttachmentTable: React.FC = ({ { header: "Created At", accessorKey: "created_at", - cell: ({ row }) => { - const attachment = row.original; - return ( - - {formatDate(attachment.created_at)} - - ); - }, + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/policies/policy_table.tsx b/ui/litellm-dashboard/src/components/policies/policy_table.tsx index eafec442474..1716f5aea84 100644 --- a/ui/litellm-dashboard/src/components/policies/policy_table.tsx +++ b/ui/litellm-dashboard/src/components/policies/policy_table.tsx @@ -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 = ({ const rows = useMemo(() => groupPoliciesByName(policies), [policies]); - const formatDate = (dateString?: string) => { - if (!dateString) return "-"; - const date = new Date(dateString); - return date.toLocaleString(); - }; - const columns: ColumnDef[] = [ { header: "Name", @@ -199,14 +194,7 @@ const PolicyTable: React.FC = ({ header: "Created At", id: "created_at", accessorFn: (row) => row.primaryPolicy.created_at ?? "", - cell: ({ row }) => { - const policy = row.original.primaryPolicy; - return ( - - {formatDate(policy.created_at)} - - ); - }, + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/cell_tooltip.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/cell_tooltip.tsx new file mode 100644 index 00000000000..c6e10590e8f --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/cell_tooltip.tsx @@ -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 ( + + + + {content} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx new file mode 100644 index 00000000000..719047e0cfe --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx @@ -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(); + expect(screen.getByText("Jul 7, 09:50:13")).toBeInTheDocument(); + }); + + it("renders date-only when precision is 'date'", () => { + render(); + expect(screen.getByText("Jul 7, 2026")).toBeInTheDocument(); + }); + + it("renders '-' for null and undefined", () => { + const { rerender } = render(); + expect(screen.getByText("-")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("renders the custom fallback for empty values", () => { + render(); + expect(screen.getByText("Never")).toBeInTheDocument(); + }); + + it("renders the fallback instead of 'Invalid Date' for unparseable input", () => { + render(); + expect(screen.getByText("Unknown")).toBeInTheDocument(); + expect(screen.queryByText(/Invalid/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx new file mode 100644 index 00000000000..ee4c01bb237 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx @@ -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 {fallback}; + } + + return ( + {formatCellDate(date, precision)}} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx new file mode 100644 index 00000000000..1a87f17d50b --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx @@ -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()), + copyToClipboard: copyToClipboardMock, +})); + +describe("IdCell", () => { + it("renders '-' for empty values", () => { + render(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("renders the custom fallback for empty values", () => { + render(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("renders the full id as a non-interactive pill by default", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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( +
+ +
, + ); + 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(); + expect(screen.getByTestId("key-id-cell")).toHaveTextContent("k-1"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx new file mode 100644 index 00000000000..6fbd2e2f9ed --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx @@ -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 = { + 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 {fallback}; + } + + 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 ? ( + + ) : ( + + {value} + + ); + + const withTooltip = ; + + if (!copyable) { + return withTooltip; + } + + return ( + + {withTooltip} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/index.ts b/ui/litellm-dashboard/src/components/shared/table_cells/index.ts new file mode 100644 index 00000000000..e189413d43d --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/index.ts @@ -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"; diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.test.tsx new file mode 100644 index 00000000000..473785e31c4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.test.tsx @@ -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(); + expect(screen.getByText("-")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("renders the custom emptyText for null budgets", () => { + render(); + expect(screen.getByText("Unlimited")).toBeInTheDocument(); + }); + + it("renders '-' for zero by default", () => { + render(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("renders a formatted zero when showZero is set, never the emptyText", () => { + render(); + 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(); + expect(screen.getByText("$1,234.57")).toBeInTheDocument(); + }); + + it("defaults to 4 decimals", () => { + render(); + expect(screen.getByText("$42.0000")).toBeInTheDocument(); + }); + + it("renders the sub-threshold form for amounts that round to zero", () => { + render(); + expect(screen.getByText("< $0.000001")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.tsx new file mode 100644 index 00000000000..9d3c747b20e --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.tsx @@ -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 {emptyText}; + } + if (value === 0) { + if (!showZero) { + return -; + } + return {`$${formatNumberWithCommas(0, decimals, false, true)}`}; + } + return {getSpendString(value, decimals)}; +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.test.tsx new file mode 100644 index 00000000000..724a362c21f --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.test.tsx @@ -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 = { + 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(); + 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(); + const badge = screen.getByText("Active"); + expect(badge.dataset.variant).toBe("outline"); + expect(badge.querySelector("[aria-hidden]")).toBeNull(); + }); + + it("passes dataTestId through", () => { + render(); + 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(); + await user.hover(screen.getByText("Blocked")); + expect(await screen.findByText("This key was blocked by SCIM")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx new file mode 100644 index 00000000000..f10f817650a --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx @@ -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 = { + 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 = ( + + {label} + + ); + + if (!tooltip) { + return badge; + } + return ; +} diff --git a/ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx index 8a8d7ae7042..8fc9adc75a2 100644 --- a/ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx +++ b/ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx @@ -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 }) => ( - - {row.original.enabled ? "Public" : "Draft"} - + ), }, ]; diff --git a/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx b/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx index a56721787d5..057f30ccee5 100644 --- a/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx @@ -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(); - 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(); + 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(); - 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", () => { diff --git a/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx b/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx index ce28ac6e6f2..e34653cc702 100644 --- a/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx @@ -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 = ({ data, onEdit, onDelete, onSelectTag const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION; return (
- - - + />
); }, @@ -104,10 +88,7 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag header: "Created", accessorKey: "created_at", sortingFn: "datetime", - cell: ({ row }) => { - const tag = row.original; - return {new Date(tag.created_at).toLocaleDateString()}; - }, + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx index ac0ae16a44f..a07c57eaa30 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx @@ -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 => ({ team_id: "team-123", team_info: { @@ -78,6 +80,7 @@ const createMockTeamData = (overrides: Partial = {}): 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( { />, ); - 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( + , + ); + + 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", () => { diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index e2f108dcbf5..b884490efc0 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -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 = [ @@ -146,7 +142,7 @@ export default function TeamMemberTab({ ), key: "spend", render: (_: unknown, record: Member) => ( - ${formatNumberWithCommas(getUserCurrentCycleSpend(record.user_id), 4)} + ), }, { @@ -159,31 +155,19 @@ export default function TeamMemberTab({ ), key: "total_spend", - render: (_: unknown, record: Member) => ( - ${formatNumberWithCommas(getUserTotalSpend(record.user_id), 4)} - ), + render: (_: unknown, record: Member) => , }, { title: "Team Member Budget (USD)", key: "budget", - render: (_: unknown, record: Member) => { - const budget = getUserBudget(record.user_id); - return ( - {budget ? `$${formatNumberWithCommas(Number(budget), 4)}` : "No Limit"} - ); - }, + render: (_: unknown, record: Member) => ( + + ), }, { title: "Budget Reset", key: "budget_reset", - render: (_: unknown, record: Member) => { - const reset = getUserBudgetReset(record.user_id); - return reset ? ( - {reset} - ) : ( - — - ); - }, + render: (_: unknown, record: Member) => , }, { title: ( diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index e51c124d618..b7128e642a5 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -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 ( - - - - ); - }, + cell: (info) => ( + 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) => , }, { 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) => , }, { 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 ( - - {date.toLocaleDateString()} - - ); - }, + cell: (info) => , }, { 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) => , }, { 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) => , }, { 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) => ( + + ), }, { 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) => , }, { id: "models", diff --git a/ui/litellm-dashboard/src/components/ui/badge.tsx b/ui/litellm-dashboard/src/components/ui/badge.tsx index 87b536cc37e..2e1ebffa109 100644 --- a/ui/litellm-dashboard/src/components/ui/badge.tsx +++ b/ui/litellm-dashboard/src/components/ui/badge.tsx @@ -21,14 +21,18 @@ const badgeVariants = cva({ }, }); -function Badge({ - className, - variant = "default", - ...props -}: React.ComponentProps<"span"> & VariantProps) { - return ( - - ); -} +const Badge = React.forwardRef< + HTMLSpanElement, + React.ComponentPropsWithoutRef<"span"> & VariantProps +>(({ className, variant = "default", ...props }, ref) => ( + +)); +Badge.displayName = "Badge"; export { Badge, badgeVariants }; diff --git a/ui/litellm-dashboard/src/components/usage.tsx b/ui/litellm-dashboard/src/components/usage.tsx index 04fcbddbd37..91c12fd1fa2 100644 --- a/ui/litellm-dashboard/src/components/usage.tsx +++ b/ui/litellm-dashboard/src/components/usage.tsx @@ -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 = ({ accessToken, token, userRole, use {provider.provider} - {parseFloat(provider.spend.toFixed(2)) < 0.00001 - ? "less than 0.00" - : formatNumberWithCommas(provider.spend, 2)} + ))} @@ -819,7 +818,9 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use {topUsers?.map((user: any, index: number) => ( {user.end_user} - {formatNumberWithCommas(user.total_spend, 2)} + + + {user.total_count} ))} diff --git a/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx b/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx index c8288172d5b..eaa864ab3dc 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx @@ -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 = ({ 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 = { + 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 ; + const config: { tone: StatusTone; label: string } = statusConfig[status] ?? { tone: "neutral", label: status }; + return ; }; const formatFileSize = (bytes?: number) => { diff --git a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx index ac790122507..54b05bc73b5 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx @@ -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 }); }); diff --git a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx index 180c2485ab5..c2e47cebe69 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx @@ -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 = ({ data, onView, onEdi { header: "Vector Store ID", accessorKey: "vector_store_id", - cell: ({ row }) => { - const vectorStore = row.original; - return ( - - ); - }, + cell: ({ row }) => , }, { header: "Name", @@ -109,19 +98,13 @@ const VectorStoreTable: React.FC = ({ data, onView, onEdi header: "Created At", accessorKey: "created_at", sortingFn: "datetime", - cell: ({ row }) => { - const vectorStore = row.original; - return {new Date(vectorStore.created_at).toLocaleDateString()}; - }, + cell: ({ row }) => , }, { header: "Updated At", accessorKey: "updated_at", sortingFn: "datetime", - cell: ({ row }) => { - const vectorStore = row.original; - return {new Date(vectorStore.updated_at).toLocaleDateString()}; - }, + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx index 28b7afb6298..d811d3b9402 100644 --- a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx @@ -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) => ( - - {moment.utc(val).local().format("MMM D, YYYY HH:mm:ss")} - - ), + render: (val: string) => , }, { 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) => {val}, + render: (val: string) => , }, { 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 ? {val.slice(0, 12)}… : "—"), + render: (val: string) => , }, ]; diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx new file mode 100644 index 00000000000..afdd17813f3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx @@ -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 => ({ + 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( + 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( + r.request_id} + />, + ); + const formatted = screen.getByText("$0.000123"); + await user.hover(formatted); + expect(await screen.findByText("$0.00012345678")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 7452992ed59..1d0f3f33d08 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -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[] : "Time", accessorKey: "startTime", size: 200, - cell: (info: any) => , + cell: (info: any) => , }, { header: "Type", @@ -174,48 +173,20 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] cell: (info: any) => { const status = info.getValue() || "Success"; const isSuccess = status.toLowerCase() !== "failure"; - - return ( - - {isSuccess ? "Success" : "Failure"} - - ); + return ; }, }, { header: "Session ID", accessorKey: "session_id", size: 120, - cell: (info: any) => { - const value = String(info.getValue() || ""); - const onSessionClick = info.row.original.onSessionClick; - return ( - - - - ); - }, + cell: (info: any) => , }, { header: "Request ID", accessorKey: "request_id", - cell: (info: any) => ( - - {String(info.getValue() || "")} - - ), + cell: (info: any) => , }, { header: sortProps @@ -236,11 +207,14 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] 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 (
- - {getSpendString(info.getValue() || 0)} + + + + {mcpCount > 0 && mcpSpend > 0 && ( @@ -320,21 +294,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] 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 ( - - onKeyHashClick?.(value)} - > - {value} - - - ); - }, + cell: (info: any) => , }, { header: "Key Alias", @@ -589,141 +549,3 @@ export type AuditLogEntry = { before_value: Record; updated_values: Record; }; - -const getActionBadge = (action: string) => { - return ( - - {action} - - ); -}; - -export const auditLogColumns: ColumnDef[] = [ - { - 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() ? ( - - ) : ( - ● - ); - }; - return ; - }, - }, - { - header: "Timestamp", - accessorKey: "updated_at", - cell: (info: any) => , - }, - { - 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 {displayValue}; - }, - }, - { - header: "Action", - accessorKey: "action", - cell: (info: any) => {getActionBadge(info.getValue())}, - }, - { - 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 ( -
-
{changedBy}
- {apiKey && ( // Only show API key if it exists - -
- {" "} - {/* Apply max-width and truncate */} - {apiKey} -
-
- )} -
- ); - }, - }, - { - 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 ( - - - {String(objectId)} - - - ); - }; - return ; - }, - }, -]; diff --git a/ui/litellm-dashboard/src/components/view_logs/time_cell.test.tsx b/ui/litellm-dashboard/src/components/view_logs/time_cell.test.tsx deleted file mode 100644 index 95a8b43b2c1..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/time_cell.test.tsx +++ /dev/null @@ -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(); - // 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(); - // 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(); - 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); - }); -}); diff --git a/ui/litellm-dashboard/src/components/view_logs/time_cell.tsx b/ui/litellm-dashboard/src/components/view_logs/time_cell.tsx deleted file mode 100644 index 8addc702dc6..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/time_cell.tsx +++ /dev/null @@ -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 = ({ utcTime }) => { - return ( - - {getLocalTime(utcTime)} - - ); -}; - -export const getTimeZone = (): string => { - return Intl.DateTimeFormat().resolvedOptions().timeZone; -}; diff --git a/ui/litellm-dashboard/src/utils/licenseUtils.test.ts b/ui/litellm-dashboard/src/utils/licenseUtils.test.ts new file mode 100644 index 00000000000..717b8f0d90d --- /dev/null +++ b/ui/litellm-dashboard/src/utils/licenseUtils.test.ts @@ -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"); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/licenseUtils.ts b/ui/litellm-dashboard/src/utils/licenseUtils.ts new file mode 100644 index 00000000000..57acad85508 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/licenseUtils.ts @@ -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); +};