mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_dedup_mcp_oauth_hooks
# Conflicts: # ui/litellm-dashboard/eslint-metrics.json
This commit is contained in:
commit
e1dd4f593d
86 changed files with 1420 additions and 1205 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
SortState,
|
||||
TableHeaderSortDropdown,
|
||||
} from "@/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
|
||||
import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal";
|
||||
import { AccessGroup } from "./types";
|
||||
|
|
@ -143,21 +144,7 @@ export function AccessGroupsPage() {
|
|||
header: () => <span>ID</span>,
|
||||
enableSorting: false,
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<Tooltip title={record.id}>
|
||||
<Text
|
||||
ellipsis
|
||||
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
|
||||
style={{ fontSize: 14, padding: "1px 8px" }}
|
||||
onClick={() => setSelectedGroupId(record.id)}
|
||||
>
|
||||
{record.id}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <IdCell value={row.original.id} onClick={setSelectedGroupId} />,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
|
|
@ -211,7 +198,7 @@ export function AccessGroupsPage() {
|
|||
header: () => <span>Created</span>,
|
||||
enableSorting: true,
|
||||
sortingFn: "datetime",
|
||||
cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString(),
|
||||
cell: ({ getValue }) => <DateCell value={getValue() as string} precision="date" />,
|
||||
meta: { responsive: ["lg"] },
|
||||
},
|
||||
{
|
||||
|
|
@ -219,7 +206,7 @@ export function AccessGroupsPage() {
|
|||
accessorKey: "updatedAt",
|
||||
header: () => <span>Updated</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString(),
|
||||
cell: ({ getValue }) => <DateCell value={getValue() as string} precision="date" />,
|
||||
meta: { responsive: ["xl"] },
|
||||
},
|
||||
...(canModify
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import DeleteResourceModal from "@/components/common_components/DeleteResourceMo
|
|||
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import BudgetModal from "./budget_modal";
|
||||
import EditBudgetModal from "./edit_budget_modal";
|
||||
import { CREATE_END_USER_CURL_COMMAND, CHAT_COMPLETIONS_CURL_COMMAND, OPENAI_SDK_PYTHON_CODE } from "./constants";
|
||||
|
|
@ -127,7 +128,9 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
.map((value: budgetItem) => (
|
||||
<TableRow key={value.budget_id}>
|
||||
<TableCell>{value.budget_id}</TableCell>
|
||||
<TableCell>{value.max_budget ? value.max_budget : "n/a"}</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell value={value.max_budget} decimals={2} showZero emptyText="Unlimited" />
|
||||
</TableCell>
|
||||
<TableCell>{value.tpm_limit ? value.tpm_limit : "n/a"}</TableCell>
|
||||
<TableCell>{value.rpm_limit ? value.rpm_limit : "n/a"}</TableCell>
|
||||
{canModify && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { getLicenseInfo, LicenseInfo } from "@/components/networking";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
|
||||
const licenseInfoKeys = createQueryKeys("licenseInfo");
|
||||
|
||||
export const useLicenseInfo = (accessToken: string | null | undefined): UseQueryResult<LicenseInfo | null> => {
|
||||
const options = {
|
||||
queryKey: licenseInfoKeys.detail("license"),
|
||||
queryFn: () => getLicenseInfo(accessToken!),
|
||||
enabled: Boolean(accessToken),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
};
|
||||
return useQuery<LicenseInfo | null>(options);
|
||||
};
|
||||
|
|
@ -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}</>,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
|
||||
import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner";
|
||||
import { MIGRATED_PAGES, migratedHref, legacyPageHref, legacyKeyForPathname } from "@/utils/migratedPages";
|
||||
import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext";
|
||||
import { createApiClient } from "@/lib/http/client";
|
||||
|
|
@ -116,6 +117,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||
onToggleSidebar={() => setSidebarCollapsed((v) => !v)}
|
||||
/>
|
||||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
<div className="flex flex-1">
|
||||
{mode !== "ai-gateway" ? (
|
||||
<div className="flex-1 flex">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Card, Drawer, Empty, Input, Space, Table, Tooltip, Typography, message } from "antd";
|
||||
import { Button, Card, Drawer, Empty, Input, Space, Table, Typography, message } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { MemoryRow, createMemory, deleteMemory, fetchMemoryList, updateMemory } from "@/components/networking";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import { MemoryEditModal } from "./MemoryEditModal";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
|
||||
|
|
@ -191,34 +192,13 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const renderIdPill = (id: string | null | undefined, onClick?: () => void) => {
|
||||
if (!id) return <Text type="secondary">-</Text>;
|
||||
const short = id.length > 10 ? `${id.slice(0, 7)}...` : id;
|
||||
const pillClass =
|
||||
"font-mono text-blue-600 bg-blue-50 text-xs font-medium px-2 py-0.5 rounded-md border border-blue-200 inline-block max-w-[15ch] truncate whitespace-nowrap";
|
||||
return (
|
||||
<Tooltip title={id}>
|
||||
{onClick ? (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`${pillClass} hover:bg-blue-100 cursor-pointer transition-colors text-left`}
|
||||
>
|
||||
{short}
|
||||
</button>
|
||||
) : (
|
||||
<span className={pillClass}>{short}</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<MemoryRow> = [
|
||||
{
|
||||
title: "ID",
|
||||
dataIndex: "memory_id",
|
||||
key: "memory_id",
|
||||
width: 140,
|
||||
render: (_: unknown, r: MemoryRow) => renderIdPill(r.memory_id, () => setDetailRow(r)),
|
||||
render: (_: unknown, r: MemoryRow) => <IdCell value={r.memory_id} onClick={() => setDetailRow(r)} />,
|
||||
},
|
||||
{
|
||||
title: "Name",
|
||||
|
|
@ -246,21 +226,21 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
dataIndex: "user_id",
|
||||
key: "user_id",
|
||||
width: 160,
|
||||
render: (uid?: string | null) => renderIdPill(uid),
|
||||
render: (uid?: string | null) => <IdCell value={uid} />,
|
||||
},
|
||||
{
|
||||
title: "Team ID",
|
||||
dataIndex: "team_id",
|
||||
key: "team_id",
|
||||
width: 160,
|
||||
render: (tid?: string | null) => renderIdPill(tid),
|
||||
render: (tid?: string | null) => <IdCell value={tid} />,
|
||||
},
|
||||
{
|
||||
title: "Updated",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
width: 180,
|
||||
render: (ts?: string) => <Text type="secondary">{formatTimestamp(ts)}</Text>,
|
||||
render: (ts?: string) => <DateCell value={ts} />,
|
||||
// No sorter — backend already returns rows in `updated_at DESC` order,
|
||||
// and a client-side sorter on a paginated view would only affect the
|
||||
// current page.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Empty, Table, Tooltip } from "antd";
|
|||
import type { ColumnsType } from "antd/es/table";
|
||||
import type { SpinProps } from "antd";
|
||||
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
|
||||
import { DateCell } from "@/components/shared/table_cells";
|
||||
|
||||
interface ProjectKeysTableProps {
|
||||
keys: KeyResponse[];
|
||||
|
|
@ -33,13 +34,13 @@ const columns: ColumnsType<KeyResponse> = [
|
|||
title: "Created",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (date: string) => (date ? new Date(date).toLocaleDateString() : "—"),
|
||||
render: (date: string) => <DateCell value={date} precision="date" />,
|
||||
},
|
||||
{
|
||||
title: "Last Active",
|
||||
dataIndex: "last_active",
|
||||
key: "last_active",
|
||||
render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : "Never"),
|
||||
render: (date: string | null) => <DateCell value={date} precision="date" fallback="Never" />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useProjects, ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -72,18 +73,7 @@ export function ProjectsPage() {
|
|||
dataIndex: "project_id",
|
||||
key: "project_id",
|
||||
width: 170,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<Text
|
||||
ellipsis
|
||||
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
|
||||
style={{ fontSize: 14, padding: "1px 8px" }}
|
||||
onClick={() => setSelectedProjectId(id)}
|
||||
>
|
||||
{id}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
render: (id: string) => <IdCell value={id} onClick={setSelectedProjectId} />,
|
||||
},
|
||||
{
|
||||
title: "Name",
|
||||
|
|
@ -137,14 +127,14 @@ export function ProjectsPage() {
|
|||
key: "created_at",
|
||||
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||
responsive: ["lg"],
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
render: (date: string) => <DateCell value={date} precision="date" />,
|
||||
},
|
||||
{
|
||||
title: "Updated",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
responsive: ["xl"],
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
render: (date: string) => <DateCell value={date} precision="date" />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import React, { useState, useEffect } from "react";
|
|||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Button } from "@tremor/react";
|
||||
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { Tooltip } from "antd";
|
||||
import { CopyOutlined } from "@ant-design/icons";
|
||||
import { PromptSpec, modelHubCall } from "@/components/networking";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
|
|
@ -62,48 +62,11 @@ const PromptTable: React.FC<PromptTableProps> = ({
|
|||
fetchModelHubData();
|
||||
}, [accessToken]);
|
||||
|
||||
// Format date helper function
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<PromptSpec>[] = [
|
||||
{
|
||||
header: "Prompt ID",
|
||||
accessorKey: "prompt_id",
|
||||
cell: (info: any) => {
|
||||
const fullId = String(info.getValue() || "");
|
||||
const displayId = fullId.length > 25 ? `${fullId.slice(0, 25)}...` : fullId;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip title={fullId}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate min-w-[220px] justify-start"
|
||||
onClick={() => info.getValue() && onPromptClick?.(info.getValue())}
|
||||
>
|
||||
{displayId}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Copy prompt ID">
|
||||
<CopyOutlined
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(fullId);
|
||||
}}
|
||||
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: (info: any) => <IdCell value={info.getValue()} onClick={onPromptClick} copyable />,
|
||||
},
|
||||
{
|
||||
header: "Model",
|
||||
|
|
@ -162,26 +125,12 @@ const PromptTable: React.FC<PromptTableProps> = ({
|
|||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
cell: ({ row }) => {
|
||||
const prompt = row.original;
|
||||
return (
|
||||
<Tooltip title={prompt.created_at}>
|
||||
<span className="text-xs">{formatDate(prompt.created_at)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
},
|
||||
{
|
||||
header: "Updated At",
|
||||
accessorKey: "updated_at",
|
||||
cell: ({ row }) => {
|
||||
const prompt = row.original;
|
||||
return (
|
||||
<Tooltip title={prompt.updated_at}>
|
||||
<span className="text-xs">{formatDate(prompt.updated_at)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
|
||||
},
|
||||
{
|
||||
header: "Environment",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Tag } from "antd";
|
||||
import { ColumnsType } from "antd/es/table";
|
||||
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import { SearchTool } from "./types";
|
||||
|
||||
export const searchToolColumns = (
|
||||
|
|
@ -20,14 +21,7 @@ export const searchToolColumns = (
|
|||
return <span className="text-xs">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onView(tool.search_tool_id!)}
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left cursor-pointer max-w-40"
|
||||
>
|
||||
<span className="truncate block">{tool.search_tool_id}</span>
|
||||
</button>
|
||||
);
|
||||
return <IdCell value={tool.search_tool_id} onClick={onView} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -52,7 +46,7 @@ export const searchToolColumns = (
|
|||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (_, tool) => {
|
||||
return <span className="text-xs">{tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}</span>;
|
||||
return <DateCell value={tool.created_at} precision="date" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -60,7 +54,7 @@ export const searchToolColumns = (
|
|||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
render: (_, tool) => {
|
||||
return <span className="text-xs">{tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}</span>;
|
||||
return <DateCell value={tool.updated_at} precision="date" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ describe("BulkEditUserModal", () => {
|
|||
it("should display budget information in table", () => {
|
||||
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("$50")).toBeInTheDocument();
|
||||
expect(screen.getByText("$50.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unlimited")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { userBulkUpdateUserCall, teamBulkMemberAddCall, Member } from "@/compone
|
|||
import { UserEditView } from "./user_edit_view";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
|
|
@ -270,7 +271,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
|
|||
key: "max_budget",
|
||||
width: "20%",
|
||||
render: (budget: number | null) => (
|
||||
<Text style={{ fontSize: "12px" }}>{budget !== null ? `$${budget}` : "Unlimited"}</Text>
|
||||
<MoneyCell value={budget} decimals={2} emptyText="Unlimited" showZero />
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { Badge, Grid, Icon } from "@tremor/react";
|
|||
import { Tooltip, Checkbox, Tag } from "antd";
|
||||
import { UserInfo } from "@/components/networking";
|
||||
import { PencilAltIcon, TrashIcon, InformationCircleIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import { CopyOutlined } from "@ant-design/icons";
|
||||
import { formatNumberWithCommas, copyToClipboard } from "@/utils/dataUtils";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
|
||||
interface SelectionOptions {
|
||||
selectedUsers: UserInfo[];
|
||||
|
|
@ -29,24 +28,7 @@ export const columns = (
|
|||
header: "User ID",
|
||||
accessorKey: "user_id",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tooltip title={row.original.user_id}>
|
||||
<span className="text-xs">{row.original.user_id ? `${row.original.user_id.slice(0, 7)}...` : "-"}</span>
|
||||
</Tooltip>
|
||||
{row.original.user_id && (
|
||||
<Tooltip title="Copy User ID">
|
||||
<CopyOutlined
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(row.original.user_id, "User ID copied to clipboard");
|
||||
}}
|
||||
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => <IdCell value={row.original.user_id} variant="plain" copyable />,
|
||||
},
|
||||
{
|
||||
header: "Email",
|
||||
|
|
@ -93,17 +75,13 @@ export const columns = (
|
|||
header: "Spend (USD)",
|
||||
accessorKey: "spend",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.spend ? formatNumberWithCommas(row.original.spend, 4) : "-"}</span>
|
||||
),
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
header: "Budget (USD)",
|
||||
accessorKey: "max_budget",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.max_budget !== null ? row.original.max_budget : "Unlimited"}</span>
|
||||
),
|
||||
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={2} emptyText="Unlimited" showZero />,
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
|
|
@ -142,21 +120,13 @@ export const columns = (
|
|||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">
|
||||
{row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
header: "Updated At",
|
||||
accessorKey: "updated_at",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">
|
||||
{row.original.updated_at ? new Date(row.original.updated_at).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ColumnDef } from "@tanstack/react-table";
|
|||
import { Button, Badge, Text } from "@tremor/react";
|
||||
import { Tooltip, Tag } from "antd";
|
||||
import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { StatusBadge } from "@/components/shared/table_cells";
|
||||
|
||||
export interface AgentHubData {
|
||||
agent_id?: string;
|
||||
|
|
@ -194,17 +195,9 @@ export const getAgentHubTableColumns = (
|
|||
return publicA - publicB;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const agent = row.original;
|
||||
const isPublic = row.original.is_public === true;
|
||||
|
||||
return agent.is_public === true ? (
|
||||
<Badge color="green" size="xs">
|
||||
Yes
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="gray" size="xs">
|
||||
No
|
||||
</Badge>
|
||||
);
|
||||
return <StatusBadge tone={isPublic ? "success" : "neutral"} label={isPublic ? "Yes" : "No"} />;
|
||||
},
|
||||
meta: {
|
||||
className: "hidden md:table-cell",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
ColumnDef,
|
||||
|
|
@ -59,14 +59,7 @@ export function DeletedKeysTable({
|
|||
header: "Key ID",
|
||||
size: 150,
|
||||
maxSize: 250,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span className="font-mono text-blue-500 text-xs truncate block max-w-[250px]">{value || "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: (info) => <IdCell value={info.getValue() as string} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "key_alias",
|
||||
|
|
@ -100,9 +93,7 @@ export function DeletedKeysTable({
|
|||
header: "Spend (USD)",
|
||||
size: 100,
|
||||
maxSize: 140,
|
||||
cell: (info) => (
|
||||
<span className="block max-w-[140px]">{formatNumberWithCommas(info.getValue() as number, 4)}</span>
|
||||
),
|
||||
cell: (info) => <MoneyCell value={info.getValue() as number} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
|
|
@ -110,14 +101,9 @@ export function DeletedKeysTable({
|
|||
header: "Budget (USD)",
|
||||
size: 110,
|
||||
maxSize: 150,
|
||||
cell: (info) => {
|
||||
const maxBudget = info.getValue() as number | null;
|
||||
return (
|
||||
<span className="block max-w-[150px]">
|
||||
{maxBudget === null ? "Unlimited" : `$${formatNumberWithCommas(maxBudget)}`}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
cell: (info) => (
|
||||
<MoneyCell value={info.getValue() as number | null} decimals={0} emptyText="Unlimited" showZero />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
|
|
@ -140,14 +126,7 @@ export function DeletedKeysTable({
|
|||
header: "User ID",
|
||||
size: 120,
|
||||
maxSize: 200,
|
||||
cell: (info) => {
|
||||
const userId = info.getValue() as string | null;
|
||||
return (
|
||||
<Tooltip title={userId || undefined}>
|
||||
<span className="truncate block max-w-[200px]">{userId || "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: (info) => <IdCell value={info.getValue() as string | null} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
|
|
@ -155,12 +134,7 @@ export function DeletedKeysTable({
|
|||
header: "Created At",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return (
|
||||
<span className="block max-w-[140px]">{value ? new Date(value as string).toLocaleDateString() : "-"}</span>
|
||||
);
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "created_by",
|
||||
|
|
@ -183,10 +157,9 @@ export function DeletedKeysTable({
|
|||
header: "Deleted At",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => {
|
||||
const value = (info.row.original as any).deleted_at as string | null | undefined;
|
||||
return <span className="block max-w-[140px]">{value ? new Date(value).toLocaleDateString() : "-"}</span>;
|
||||
},
|
||||
cell: (info) => (
|
||||
<DateCell value={(info.row.original as any).deleted_at as string | null | undefined} precision="date" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "deleted_by",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
ColumnDef,
|
||||
|
|
@ -51,14 +51,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
|
|||
header: "Team ID",
|
||||
size: 150,
|
||||
maxSize: 250,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span className="font-mono text-blue-500 text-xs truncate block max-w-[250px]">{value || "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: (info) => <IdCell value={info.getValue() as string} />,
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
|
|
@ -66,12 +59,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
|
|||
header: "Created",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return (
|
||||
<span className="block max-w-[140px]">{value ? new Date(value as string).toLocaleDateString() : "-"}</span>
|
||||
);
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
|
|
@ -79,12 +67,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
|
|||
header: "Spend (USD)",
|
||||
size: 100,
|
||||
maxSize: 140,
|
||||
cell: (info) => {
|
||||
const spend = (info.row.original as any).spend as number | undefined;
|
||||
return (
|
||||
<span className="block max-w-[140px]">{spend !== undefined ? formatNumberWithCommas(spend, 4) : "-"}</span>
|
||||
);
|
||||
},
|
||||
cell: (info) => <MoneyCell value={info.getValue() as number | undefined} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
|
|
@ -92,14 +75,9 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
|
|||
header: "Budget (USD)",
|
||||
size: 110,
|
||||
maxSize: 150,
|
||||
cell: (info) => {
|
||||
const maxBudget = info.getValue() as number | null;
|
||||
return (
|
||||
<span className="block max-w-[150px]">
|
||||
{maxBudget === null || maxBudget === undefined ? "No limit" : `$${formatNumberWithCommas(maxBudget)}`}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
cell: (info) => (
|
||||
<MoneyCell value={info.getValue() as number | null} decimals={0} emptyText="Unlimited" showZero />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
|
|
@ -148,14 +126,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
|
|||
header: "Organization",
|
||||
size: 150,
|
||||
maxSize: 200,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value || undefined}>
|
||||
<span className="truncate block max-w-[200px]">{value || "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: (info) => <IdCell value={info.getValue() as string} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "deleted_at",
|
||||
|
|
@ -163,10 +134,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams
|
|||
header: "Deleted At",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => {
|
||||
const value = (info.row.original as any).deleted_at as string | null | undefined;
|
||||
return <span className="block max-w-[140px]">{value ? new Date(value).toLocaleDateString() : "-"}</span>;
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "deleted_by",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { LicenseExpiryBannerView } from "./LicenseExpiryBanner";
|
||||
import { LicenseInfo } from "./networking";
|
||||
|
||||
const daysFromNow = (n: number): string => {
|
||||
const date = new Date();
|
||||
date.setUTCDate(date.getUTCDate() + n);
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const licenseWith = (expiration_date: string | null): LicenseInfo => ({
|
||||
has_license: expiration_date !== null,
|
||||
license_type: expiration_date !== null ? "enterprise" : "community",
|
||||
expiration_date,
|
||||
allowed_features: [],
|
||||
limits: { max_users: null, max_teams: null },
|
||||
});
|
||||
|
||||
describe("LicenseExpiryBannerView", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("renders nothing when there is no license info", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when expiration_date is null (community or remote-validated)", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(null)} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when expiry is more than 30 days out", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(40))} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows a dismissible amber warning within 30 days", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(20))} />);
|
||||
expect(screen.getByText(/expires in 20 days/)).toBeInTheDocument();
|
||||
expect(container.querySelector(".ant-alert-warning")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "sales@berri.ai" })).toHaveAttribute("href", "mailto:sales@berri.ai");
|
||||
});
|
||||
|
||||
it("shows a non-dismissible red critical alert within 7 days", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(5))} />);
|
||||
expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument();
|
||||
expect(container.querySelector(".ant-alert-error")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says 'expires today' on the expiration day", () => {
|
||||
render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(0))} />);
|
||||
expect(screen.getByText(/expires today/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a non-dismissible red expired alert stating features are disabled", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(-3))} />);
|
||||
expect(screen.getByText(/expired on/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/features are now disabled/i)).toBeInTheDocument();
|
||||
expect(container.querySelector(".ant-alert-error")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the warning after dismissal and stays hidden within the session", () => {
|
||||
const expiration = daysFromNow(20);
|
||||
const { unmount } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
render(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
|
||||
expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still shows a critical alert even when its date was previously dismissed", () => {
|
||||
const expiration = daysFromNow(5);
|
||||
sessionStorage.setItem(`litellm:licenseExpiryBannerDismissed:${expiration}`, "true");
|
||||
render(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
|
||||
expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
95
ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx
Normal file
95
ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Alert } from "antd";
|
||||
import { LicenseInfo } from "@/components/networking";
|
||||
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
|
||||
import { formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "@/utils/licenseUtils";
|
||||
|
||||
const DISMISS_KEY_PREFIX = "litellm:licenseExpiryBannerDismissed:";
|
||||
const SALES_EMAIL = "sales@berri.ai";
|
||||
|
||||
const salesLink = <a href={`mailto:${SALES_EMAIL}`}>{SALES_EMAIL}</a>;
|
||||
|
||||
interface LicenseExpiryBannerProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
interface LicenseExpiryBannerViewProps {
|
||||
licenseInfo: LicenseInfo | null;
|
||||
}
|
||||
|
||||
const describeCountdown = (days: number): string => {
|
||||
if (days <= 0) {
|
||||
return "expires today";
|
||||
}
|
||||
if (days === 1) {
|
||||
return "expires in 1 day";
|
||||
}
|
||||
return `expires in ${days} days`;
|
||||
};
|
||||
|
||||
const expiryDescription = (tier: "warning" | "critical" | "expired"): React.ReactNode => {
|
||||
if (tier === "expired") {
|
||||
return <>Enterprise features are now disabled. Reach out to {salesLink} to restore access</>;
|
||||
}
|
||||
if (tier === "critical") {
|
||||
return <>Renew now to avoid losing enterprise features. Reach out to {salesLink}</>;
|
||||
}
|
||||
return <>Renew before it lapses to keep enterprise features. Reach out to {salesLink}</>;
|
||||
};
|
||||
|
||||
export const LicenseExpiryBannerView: React.FC<LicenseExpiryBannerViewProps> = ({ licenseInfo }) => {
|
||||
const [locallyDismissed, setLocallyDismissed] = useState(false);
|
||||
|
||||
const expirationDate = licenseInfo?.expiration_date ?? null;
|
||||
const tier = getLicenseExpiryTier(expirationDate);
|
||||
const days = getDaysUntilExpiration(expirationDate);
|
||||
|
||||
if (expirationDate === null || tier === "none" || days === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDismissible = tier === "warning";
|
||||
const dismissKey = `${DISMISS_KEY_PREFIX}${expirationDate}`;
|
||||
const previouslyDismissed =
|
||||
isDismissible && typeof window !== "undefined" ? sessionStorage.getItem(dismissKey) === "true" : false;
|
||||
|
||||
if (isDismissible && (locallyDismissed || previouslyDismissed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedDate = formatExpiryDate(expirationDate);
|
||||
|
||||
const message =
|
||||
tier === "expired"
|
||||
? `Your LiteLLM Enterprise license expired on ${formattedDate}`
|
||||
: `Your LiteLLM Enterprise license ${describeCountdown(days)} (${formattedDate})`;
|
||||
|
||||
const description = expiryDescription(tier);
|
||||
|
||||
const handleClose = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem(dismissKey, "true");
|
||||
}
|
||||
setLocallyDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert
|
||||
message={message}
|
||||
description={description}
|
||||
type={tier === "warning" ? "warning" : "error"}
|
||||
showIcon
|
||||
banner
|
||||
closable={isDismissible}
|
||||
onClose={handleClose}
|
||||
style={{ marginBottom: 0, borderRadius: 0 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const LicenseExpiryBanner: React.FC<LicenseExpiryBannerProps> = ({ accessToken }) => {
|
||||
const { data } = useLicenseInfo(accessToken);
|
||||
return <LicenseExpiryBannerView licenseInfo={data ?? null} />;
|
||||
};
|
||||
|
|
@ -31,6 +31,7 @@ import type { SorterResult } from "antd/es/table/interface";
|
|||
import { KeyIcon, LayersIcon, SearchIcon, UsersIcon } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import OrganizationDropdown from "./common_components/OrganizationDropdown";
|
||||
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import { teamListCall as v2TeamListCall, type TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
|
@ -670,18 +671,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
key: "team_id",
|
||||
width: 170,
|
||||
ellipsis: true,
|
||||
render: (id: string, record: Team) => (
|
||||
<Tooltip title={id}>
|
||||
<Text
|
||||
ellipsis
|
||||
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
|
||||
style={{ fontSize: 14, padding: "1px 8px" }}
|
||||
onClick={() => setSelectedTeamId(record.team_id)}
|
||||
data-testid="team-id-cell"
|
||||
>
|
||||
{id}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
render: (id: string) => (
|
||||
<IdCell value={id} onClick={(teamId) => setSelectedTeamId(teamId)} dataTestId="team-id-cell" />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -797,13 +788,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
width: 130,
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
render: (date: string | undefined) => (
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{date
|
||||
? new Date(date).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
|
||||
: "—"}
|
||||
</Text>
|
||||
),
|
||||
render: (date: string | undefined) => <DateCell value={date} precision="date" />,
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { TableProps } from "antd";
|
|||
import { Table } from "antd";
|
||||
import Title from "antd/es/typography/Title";
|
||||
import React from "react";
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import TableIconActionButton from "../../../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import { AlertingObject } from "./types";
|
||||
|
||||
|
|
@ -61,17 +62,8 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
|
|||
// and server-fetched rows both render correctly.
|
||||
const mode = record.type || record.mode || "success";
|
||||
const label = CALLBACK_MODES.find((m) => m.value === mode)?.label || mode;
|
||||
const badgeClass =
|
||||
mode === "success"
|
||||
? "bg-green-100 text-green-800"
|
||||
: mode === "failure"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-blue-100 text-blue-800";
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${badgeClass}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
const tone: StatusTone = mode === "success" ? "success" : mode === "failure" ? "error" : "info";
|
||||
return <StatusBadge tone={tone} label={label} />;
|
||||
},
|
||||
width: 240,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Switch, Tooltip } from "antd";
|
||||
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
|
||||
import { TimeCell } from "./view_logs/time_cell";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import type { SortState } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
import { TableHeaderSortDropdown } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
import FilterComponent, { FilterOption } from "./molecules/filter";
|
||||
|
|
@ -461,7 +461,7 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
|
|||
paginated.map((tool) => (
|
||||
<TableRow key={tool.tool_id} id={`tool-row-${tool.tool_id}`} className="h-8 hover:bg-gray-50">
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<TimeCell utcTime={tool.created_at ?? ""} />
|
||||
<DateCell value={tool.created_at} />
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden">
|
||||
<button
|
||||
|
|
@ -498,16 +498,10 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<Tooltip title={tool.team_id ?? "-"}>
|
||||
<span className="max-w-[15ch] truncate block">{tool.team_id ?? "-"}</span>
|
||||
</Tooltip>
|
||||
<IdCell value={tool.team_id} variant="plain" />
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<Tooltip title={tool.key_hash ?? "-"}>
|
||||
<span className="font-mono max-w-[15ch] truncate block text-blue-600">
|
||||
{tool.key_hash ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<IdCell value={tool.key_hash} />
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<Tooltip title={tool.key_alias ?? "-"}>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import UsageIndicator from "./UsageIndicator";
|
||||
|
||||
vi.mock("./networking", () => ({
|
||||
|
|
@ -17,6 +18,11 @@ import { getRemainingUsers } from "./networking";
|
|||
|
||||
const mockGetRemainingUsers = vi.mocked(getRemainingUsers);
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
const DEFAULT_USAGE_DATA = {
|
||||
total_users: 100,
|
||||
total_users_used: 1,
|
||||
|
|
@ -33,7 +39,7 @@ describe("UsageIndicator", () => {
|
|||
});
|
||||
|
||||
it("should render when given access token and usage data loads", async () => {
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
@ -41,7 +47,7 @@ describe("UsageIndicator", () => {
|
|||
});
|
||||
|
||||
it("should not show Near limit when users usage is below 80% (1/100 -> 1%)", async () => {
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
@ -58,7 +64,7 @@ describe("UsageIndicator", () => {
|
|||
total_users_remaining: null,
|
||||
});
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Usage")).not.toBeInTheDocument();
|
||||
|
|
@ -76,7 +82,7 @@ describe("UsageIndicator", () => {
|
|||
total_teams_remaining: 1,
|
||||
});
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
@ -94,7 +100,7 @@ describe("UsageIndicator", () => {
|
|||
total_teams_remaining: null,
|
||||
});
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
@ -112,7 +118,7 @@ describe("UsageIndicator", () => {
|
|||
total_teams_remaining: -2,
|
||||
});
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
@ -121,7 +127,7 @@ describe("UsageIndicator", () => {
|
|||
});
|
||||
|
||||
it("should render nothing when accessToken is null", () => {
|
||||
render(<UsageIndicator accessToken={null} width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken={null} width={220} />);
|
||||
|
||||
expect(mockGetRemainingUsers).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("Usage")).not.toBeInTheDocument();
|
||||
|
|
@ -131,7 +137,7 @@ describe("UsageIndicator", () => {
|
|||
const { useDisableUsageIndicator } = await import("@/app/(dashboard)/hooks/useDisableUsageIndicator");
|
||||
(useDisableUsageIndicator as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Usage")).not.toBeInTheDocument();
|
||||
|
|
@ -143,7 +149,7 @@ describe("UsageIndicator", () => {
|
|||
it("should show Loading while fetching", () => {
|
||||
mockGetRemainingUsers.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -152,7 +158,7 @@ describe("UsageIndicator", () => {
|
|||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockGetRemainingUsers.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
expect(await screen.findByText("Failed to load usage data")).toBeInTheDocument();
|
||||
|
||||
|
|
@ -161,7 +167,7 @@ describe("UsageIndicator", () => {
|
|||
|
||||
it("should minimize when user clicks minimize button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
@ -174,7 +180,7 @@ describe("UsageIndicator", () => {
|
|||
|
||||
it("should restore from minimized when user clicks restore button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UsageIndicator accessToken="token" width={220} />);
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
await screen.findByText("Usage");
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ import {
|
|||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getRemainingUsers, getLicenseInfo, LicenseInfo } from "./networking";
|
||||
import { getRemainingUsers } from "./networking";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { getDaysUntilExpiration } from "@/utils/licenseUtils";
|
||||
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
|
||||
|
||||
interface UsageIndicatorProps {
|
||||
accessToken: string | null;
|
||||
|
|
@ -30,17 +32,6 @@ interface UsageData {
|
|||
total_teams_remaining: number | null;
|
||||
}
|
||||
|
||||
// Calculate days until expiration
|
||||
const getDaysUntilExpiration = (expirationDate: string | null): number | null => {
|
||||
if (!expirationDate) return null;
|
||||
const expDate = new Date(expirationDate + "T00:00:00Z"); // Force UTC midnight
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0); // Normalize to local midnight
|
||||
const diffTime = expDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays;
|
||||
};
|
||||
|
||||
// Format expiration for display
|
||||
const formatExpirationDisplay = (daysRemaining: number | null): string => {
|
||||
if (daysRemaining === null) return "No expiration";
|
||||
|
|
@ -58,10 +49,11 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
|
|||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [data, setData] = useState<UsageData | null>(null);
|
||||
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const licenseInfo = useLicenseInfo(accessToken).data ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!accessToken) return;
|
||||
|
|
@ -70,12 +62,8 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const [usageResult, licenseResult] = await Promise.all([
|
||||
getRemainingUsers(accessToken),
|
||||
getLicenseInfo(accessToken).catch(() => null), // Don't fail if license endpoint unavailable
|
||||
]);
|
||||
const usageResult = await getRemainingUsers(accessToken);
|
||||
setData(usageResult);
|
||||
setLicenseInfo(licenseResult);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch usage data:", err);
|
||||
setError("Failed to load usage data");
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
import { Table, Progress } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { MetricWithMetadata } from "../../../types";
|
||||
|
||||
interface EndpointUsageTableProps {
|
||||
|
|
@ -112,7 +112,7 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
|
|||
title: "Spend",
|
||||
dataIndex: "spend",
|
||||
key: "spend",
|
||||
render: (value: number) => `$${formatNumberWithCommas(value, 2)}`,
|
||||
render: (value: number) => <MoneyCell value={value} decimals={2} />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import {
|
||||
BarChart,
|
||||
|
|
@ -665,7 +666,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
|
|||
.map((entity) => (
|
||||
<TableRow key={entity.metadata.id}>
|
||||
<TableCell>{entity.metadata.alias}</TableCell>
|
||||
<TableCell>${formatNumberWithCommas(entity.metrics.spend, 4)}</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell value={entity.metrics.spend} decimals={4} />
|
||||
</TableCell>
|
||||
<TableCell className="text-green-600">
|
||||
{entity.metrics.successful_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
|
|
@ -777,7 +780,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
|
|||
<span>{provider.provider}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>${formatNumberWithCommas(provider.spend, 2)}</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell value={provider.spend} decimals={2} />
|
||||
</TableCell>
|
||||
<TableCell className="text-green-600">
|
||||
{provider.successful_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import SpendByProvider from "./SpendByProvider";
|
||||
|
||||
|
|
@ -200,6 +200,14 @@ describe("SpendByProvider", () => {
|
|||
expect(screen.getByText("1,234,567")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render zero spend as a dash when Show Zero Spend is on", () => {
|
||||
render(<SpendByProvider loading={false} isDateChanging={false} providerSpend={mockProviderSpend} />);
|
||||
fireEvent.click(screen.getAllByRole("switch")[0]);
|
||||
expect(screen.getAllByText("google").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should filter data correctly when both toggles are off", () => {
|
||||
render(<SpendByProvider loading={false} isDateChanging={false} providerSpend={mockProviderSpend} />);
|
||||
expect(screen.getAllByText("openai").length).toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
|
|
@ -109,7 +110,9 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
|
|||
<span>{provider.provider}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>${formatNumberWithCommas(provider.spend, 2)}</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell value={provider.spend} decimals={2} />
|
||||
</TableCell>
|
||||
<TableCell className="text-green-600">{provider.successful_requests.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-red-600">{provider.failed_requests.toLocaleString()}</TableCell>
|
||||
<TableCell>{provider.tokens.toLocaleString()}</TableCell>
|
||||
|
|
|
|||
|
|
@ -171,7 +171,9 @@ describe("TopKeyView", () => {
|
|||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/sk-1234\.\.\./)).toBeInTheDocument();
|
||||
const keyId = screen.getByText("sk-1234567890abcdef");
|
||||
expect(keyId).toBeInTheDocument();
|
||||
expect(keyId).toHaveClass("truncate");
|
||||
});
|
||||
|
||||
it("should display dash for missing key alias", () => {
|
||||
|
|
@ -206,7 +208,7 @@ describe("TopKeyView", () => {
|
|||
expect(screen.getByText("$123.46")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display less than 0.01 spend as <$0.01", () => {
|
||||
it("should display sub-cent spend as < $0.01", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
|
|
@ -214,15 +216,15 @@ describe("TopKeyView", () => {
|
|||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 0.005,
|
||||
spend: 0.004,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("<$0.01")).toBeInTheDocument();
|
||||
expect(screen.getByText("< $0.01")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display zero spend correctly", () => {
|
||||
it("should display zero spend as a dash", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
|
|
@ -235,7 +237,8 @@ describe("TopKeyView", () => {
|
|||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("$0.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display dash for empty tags", () => {
|
||||
|
|
@ -376,7 +379,7 @@ describe("TopKeyView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
|
||||
const keyIdButton = screen.getByText("key-123").closest("button");
|
||||
if (keyIdButton) {
|
||||
await user.click(keyIdButton);
|
||||
}
|
||||
|
|
@ -410,7 +413,7 @@ describe("TopKeyView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
|
||||
const keyIdButton = screen.getByText("key-123").closest("button");
|
||||
if (keyIdButton) {
|
||||
await user.click(keyIdButton);
|
||||
}
|
||||
|
|
@ -447,7 +450,7 @@ describe("TopKeyView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
|
||||
const keyIdButton = screen.getByText("key-123").closest("button");
|
||||
if (keyIdButton) {
|
||||
await user.click(keyIdButton);
|
||||
}
|
||||
|
|
@ -483,7 +486,7 @@ describe("TopKeyView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
|
||||
const keyIdButton = screen.getByText("key-123").closest("button");
|
||||
if (keyIdButton) {
|
||||
await user.click(keyIdButton);
|
||||
}
|
||||
|
|
@ -522,7 +525,7 @@ describe("TopKeyView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
|
||||
const keyIdButton = screen.getByText("key-123").closest("button");
|
||||
if (keyIdButton) {
|
||||
await user.click(keyIdButton);
|
||||
}
|
||||
|
|
@ -552,7 +555,7 @@ describe("TopKeyView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
|
||||
const keyIdButton = screen.getByText("key-123").closest("button");
|
||||
if (keyIdButton) {
|
||||
await user.click(keyIdButton);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline";
|
||||
import { BarChart, Button } from "@tremor/react";
|
||||
import { BarChart } from "@tremor/react";
|
||||
import { Segmented, Tooltip } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
|
||||
|
|
@ -83,20 +84,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
{
|
||||
header: "Key ID",
|
||||
accessorKey: "api_key",
|
||||
cell: (info: any) => (
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip title={info.getValue() as string}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||
onClick={() => handleKeyClick(info.row.original)}
|
||||
>
|
||||
{info.getValue() ? `${(info.getValue() as string).slice(0, 7)}...` : "-"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
cell: (info: any) => <IdCell value={info.getValue()} onClick={() => handleKeyClick(info.row.original)} />,
|
||||
},
|
||||
{
|
||||
header: "Key Alias",
|
||||
|
|
@ -165,10 +153,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
header: "Spend (USD)",
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: (info: any) => {
|
||||
const value = info.getValue();
|
||||
return value > 0 && value < 0.01 ? "<$0.01" : `$${formatNumberWithCommas(value, 2)}`;
|
||||
},
|
||||
cell: (info: any) => <MoneyCell value={info.getValue()} decimals={2} />,
|
||||
};
|
||||
|
||||
const columns = showTags ? [...baseColumns, tagsColumn, spendColumn] : [...baseColumns, spendColumn];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { BarChart } from "@tremor/react";
|
||||
import { Segmented } from "antd";
|
||||
import { useState } from "react";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
|
||||
import { DataTable } from "../../../view_logs/table";
|
||||
|
||||
|
|
@ -31,10 +32,7 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi
|
|||
header: "Spend (USD)",
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: (info: any) => {
|
||||
const value = info.getValue();
|
||||
return `$${formatNumberWithCommas(value, 2)}`;
|
||||
},
|
||||
cell: (info: any) => <MoneyCell value={info.getValue()} decimals={2} />,
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ describe("KeyModelUsageView", () => {
|
|||
},
|
||||
];
|
||||
render(<KeyModelUsageView topModels={modelsWithZeros} />);
|
||||
expect(screen.getByText("$0.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("0").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { BarChart, Card, Title } from "@tremor/react";
|
||||
import { Table } from "antd";
|
||||
|
|
@ -24,7 +25,7 @@ const columns: ColumnsType<TopModelData> = [
|
|||
title: "Spend (USD)",
|
||||
dataIndex: "spend",
|
||||
key: "spend",
|
||||
render: (value) => `$${formatNumberWithCommas(value, 2)}`,
|
||||
render: (value) => <MoneyCell value={value} decimals={2} />,
|
||||
},
|
||||
{
|
||||
title: "Successful",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,20 +13,10 @@ import {
|
|||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Icon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react";
|
||||
import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { Button as AntButton, Popover, Skeleton, Tag, Tooltip, Typography } from "antd";
|
||||
import { Button as AntButton, Popover, Skeleton, Typography } from "antd";
|
||||
import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import React, { useDeferredValue, useMemo, useState } from "react";
|
||||
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect";
|
||||
|
|
@ -145,23 +135,7 @@ export function VirtualKeysTable() {
|
|||
header: "Key ID",
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate block"
|
||||
style={{ maxWidth: width, overflow: "hidden" }}
|
||||
onClick={() => setSelectedKey(info.row.original)}
|
||||
>
|
||||
{value ?? "-"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: (info) => <IdCell value={info.getValue() as string} onClick={() => setSelectedKey(info.row.original)} />,
|
||||
},
|
||||
{
|
||||
id: "key_alias",
|
||||
|
|
@ -187,22 +161,14 @@ export function VirtualKeysTable() {
|
|||
cell: ({ row }) => {
|
||||
const key = row.original;
|
||||
if (key.blocked !== true) {
|
||||
return (
|
||||
<Tag color="green" data-testid={`key-status-${key.token_id}`}>
|
||||
Active
|
||||
</Tag>
|
||||
);
|
||||
return <StatusBadge tone="success" label="Active" dataTestId={`key-status-${key.token_id}`} />;
|
||||
}
|
||||
const isScimBlocked = (key.metadata as Record<string, unknown> | null | undefined)?.scim_blocked === true;
|
||||
const reason = isScimBlocked
|
||||
? "Blocked by SCIM (external identity provider deactivated or deleted the owning user)."
|
||||
: "Blocked. Requests using this key will be rejected with 401.";
|
||||
return (
|
||||
<Tooltip title={reason}>
|
||||
<Tag color="red" data-testid={`key-status-${key.token_id}`}>
|
||||
Blocked
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<StatusBadge tone="error" label="Blocked" tooltip={reason} dataTestId={`key-status-${key.token_id}`} />
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -323,10 +289,7 @@ export function VirtualKeysTable() {
|
|||
header: "Created At",
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleDateString() : "-";
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "created_by",
|
||||
|
|
@ -394,10 +357,7 @@ export function VirtualKeysTable() {
|
|||
header: "Updated At",
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleDateString() : "Never";
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "last_active",
|
||||
|
|
@ -415,16 +375,7 @@ export function VirtualKeysTable() {
|
|||
),
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
if (!value) return "Unknown";
|
||||
const date = new Date(value as string);
|
||||
return (
|
||||
<Tooltip title={date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "long" })}>
|
||||
<span>{date.toLocaleDateString()}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Unknown" />,
|
||||
},
|
||||
{
|
||||
id: "expires",
|
||||
|
|
@ -432,10 +383,7 @@ export function VirtualKeysTable() {
|
|||
header: "Expires",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleDateString() : "Never";
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
|
|
@ -443,7 +391,7 @@ export function VirtualKeysTable() {
|
|||
header: "Spend (USD)",
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
cell: (info) => formatNumberWithCommas(info.getValue() as number, 4),
|
||||
cell: (info) => <MoneyCell value={info.getValue() as number} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
|
|
@ -470,10 +418,7 @@ export function VirtualKeysTable() {
|
|||
header: "Budget Reset",
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleString() : "Never";
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import AgentInfoView from "./agents/agent_info";
|
|||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { Agent } from "./agents/types";
|
||||
import { Team } from "./key_team_helpers/key_list";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
|
||||
interface AgentsPanelProps {
|
||||
|
|
@ -193,19 +193,10 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
|
|||
<Text>{agent.agent_name}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={agent.agent_id}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||
onClick={() => setSelectedAgentId(agent.agent_id)}
|
||||
>
|
||||
{agent.agent_id.slice(0, 7)}...
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<IdCell value={agent.agent_id} onClick={(id) => setSelectedAgentId(id)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text>{formatNumberWithCommas(agent.spend, 4)}</Text>
|
||||
<MoneyCell value={agent.spend} decimals={4} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge size="xs" color="blue">
|
||||
|
|
@ -213,13 +204,13 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
|
|||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text>{agent.created_at ? new Date(agent.created_at).toLocaleDateString() : "N/A"}</Text>
|
||||
<DateCell value={agent.created_at} precision="date" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(agent.keys?.length ?? 0) > 0 ? (
|
||||
<Badge color="green">Active</Badge>
|
||||
<StatusBadge tone="success" label="Active" />
|
||||
) : (
|
||||
<Badge color="yellow">Needs Setup</Badge>
|
||||
<StatusBadge tone="warning" label="Needs Setup" />
|
||||
)}
|
||||
</TableCell>
|
||||
{isAdmin && (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { Badge, Button, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { getCategoryBadgeColor } from "./helpers";
|
||||
import { Plugin } from "./types";
|
||||
|
|
@ -34,12 +35,6 @@ const PluginTable: React.FC<PluginTableProps> = ({
|
|||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
NotificationsManager.success("Copied to clipboard!");
|
||||
|
|
@ -51,19 +46,9 @@ const PluginTable: React.FC<PluginTableProps> = ({
|
|||
accessorKey: "name",
|
||||
cell: ({ row }) => {
|
||||
const plugin = row.original;
|
||||
const name = plugin.name || "";
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip title={name}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate min-w-[150px] justify-start"
|
||||
onClick={() => onPluginClick(plugin.id)}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<IdCell value={plugin.name} onClick={() => onPluginClick(plugin.id)} />
|
||||
<Tooltip title="Copy Plugin ID">
|
||||
<CopyOutlined
|
||||
onClick={(e) => {
|
||||
|
|
@ -122,24 +107,13 @@ const PluginTable: React.FC<PluginTableProps> = ({
|
|||
accessorKey: "enabled",
|
||||
cell: ({ row }) => {
|
||||
const plugin = row.original;
|
||||
return (
|
||||
<Badge color={plugin.enabled ? "green" : "gray"} className="text-xs font-normal" size="xs">
|
||||
{plugin.enabled ? "Yes" : "No"}
|
||||
</Badge>
|
||||
);
|
||||
return <StatusBadge tone={plugin.enabled ? "success" : "neutral"} label={plugin.enabled ? "Yes" : "No"} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
cell: ({ row }) => {
|
||||
const plugin = row.original;
|
||||
return (
|
||||
<Tooltip title={plugin.created_at}>
|
||||
<span className="text-xs">{formatDate(plugin.created_at)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Badge,
|
||||
TableHeaderCell,
|
||||
TableCell,
|
||||
TableBody,
|
||||
|
|
@ -16,7 +15,8 @@ import {
|
|||
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
|
||||
import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "./networking";
|
||||
import { InputNumber } from "antd";
|
||||
import { TrashIcon, CheckCircleIcon } from "@heroicons/react/outline";
|
||||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
import { StatusBadge } from "@/components/shared/table_cells";
|
||||
|
||||
import RouterSettings from "./router_settings";
|
||||
import Fallbacks from "./Settings/RouterSettings/Fallbacks/Fallbacks";
|
||||
|
|
@ -173,13 +173,11 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
{value.stored_in_db == true ? (
|
||||
<Badge icon={CheckCircleIcon} className="text-white">
|
||||
In DB
|
||||
</Badge>
|
||||
<StatusBadge tone="success" label="In DB" />
|
||||
) : value.stored_in_db == false ? (
|
||||
<Badge className="text-gray bg-white outline-solid">In Config</Badge>
|
||||
<StatusBadge tone="neutral" label="In Config" />
|
||||
) : (
|
||||
<Badge className="text-gray bg-white outline-solid">Not Set</Badge>
|
||||
<StatusBadge tone="neutral" label="Not Set" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon, Button } from "@tremor/react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon } from "@tremor/react";
|
||||
import { TrashIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
|
||||
import { Tooltip } from "antd";
|
||||
import { Badge } from "@tremor/react";
|
||||
import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
|
|
@ -43,13 +43,6 @@ const GuardrailTable: React.FC<GuardrailTableProps> = ({
|
|||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [selectedGuardrail, setSelectedGuardrail] = useState<Guardrail | null>(null);
|
||||
|
||||
// Format date helper function
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const handleEditClick = (guardrail: Guardrail) => {
|
||||
setSelectedGuardrail(guardrail);
|
||||
setEditModalVisible(true);
|
||||
|
|
@ -65,18 +58,7 @@ const GuardrailTable: React.FC<GuardrailTableProps> = ({
|
|||
{
|
||||
header: "Guardrail ID",
|
||||
accessorKey: "guardrail_id",
|
||||
cell: (info: any) => (
|
||||
<Tooltip title={String(info.getValue() || "")}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||
onClick={() => info.getValue() && onGuardrailClick(info.getValue())}
|
||||
>
|
||||
{info.getValue() ? `${String(info.getValue()).slice(0, 7)}...` : ""}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
cell: (info: any) => <IdCell value={info.getValue()} onClick={onGuardrailClick} />,
|
||||
},
|
||||
{
|
||||
header: "Name",
|
||||
|
|
@ -126,41 +108,21 @@ const GuardrailTable: React.FC<GuardrailTableProps> = ({
|
|||
header: "Default On",
|
||||
accessorKey: "litellm_params.default_on",
|
||||
cell: ({ row }) => {
|
||||
const guardrail = row.original;
|
||||
const isDefaultOn = !!row.original.litellm_params?.default_on;
|
||||
return (
|
||||
<Badge
|
||||
color={guardrail.litellm_params?.default_on ? "green" : "gray"}
|
||||
className="text-xs font-normal"
|
||||
size="xs"
|
||||
>
|
||||
{guardrail.litellm_params?.default_on ? "Default On" : "Default Off"}
|
||||
</Badge>
|
||||
<StatusBadge tone={isDefaultOn ? "success" : "neutral"} label={isDefaultOn ? "Default On" : "Default Off"} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
cell: ({ row }) => {
|
||||
const guardrail = row.original;
|
||||
return (
|
||||
<Tooltip title={guardrail.created_at}>
|
||||
<span className="text-xs">{formatDate(guardrail.created_at)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
},
|
||||
{
|
||||
header: "Updated At",
|
||||
accessorKey: "updated_at",
|
||||
cell: ({ row }) => {
|
||||
const guardrail = row.original;
|
||||
return (
|
||||
<Tooltip title={guardrail.updated_at}>
|
||||
<span className="text-xs">{formatDate(guardrail.updated_at)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ColumnDef } from "@tanstack/react-table";
|
|||
import { Button, Badge, Text } from "@tremor/react";
|
||||
import { Tooltip, Tag } from "antd";
|
||||
import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
|
||||
export interface MCPServerData {
|
||||
server_id: string;
|
||||
|
|
@ -124,21 +125,17 @@ export const mcpHubColumns = (
|
|||
cell: ({ row }) => {
|
||||
const server = row.original;
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
active: "green",
|
||||
inactive: "red",
|
||||
unknown: "gray",
|
||||
healthy: "green",
|
||||
unhealthy: "red",
|
||||
const statusTones: Record<string, StatusTone> = {
|
||||
active: "success",
|
||||
inactive: "error",
|
||||
unknown: "neutral",
|
||||
healthy: "success",
|
||||
unhealthy: "error",
|
||||
};
|
||||
|
||||
const color = statusColors[server.status] || "gray";
|
||||
const tone = statusTones[server.status] || "neutral";
|
||||
|
||||
return (
|
||||
<Badge color={color} size="sm">
|
||||
{server.status || "unknown"}
|
||||
</Badge>
|
||||
);
|
||||
return <StatusBadge tone={tone} label={server.status || "unknown"} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ColumnDef } from "@tanstack/react-table";
|
|||
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import { DataTable } from "../view_logs/table";
|
||||
import { createMCPToolset, updateMCPToolset, deleteMCPToolset, listMCPTools, getProxyBaseUrl } from "../networking";
|
||||
import { MCPToolset, MCPToolsetTool } from "./types";
|
||||
|
|
@ -302,11 +303,7 @@ function toolsetColumns(
|
|||
{
|
||||
header: "Toolset ID",
|
||||
accessorKey: "toolset_id",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs bg-gray-100 px-2 py-0.5 rounded-sm text-gray-600">
|
||||
{row.original.toolset_id.slice(0, 8)}…
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => <IdCell value={row.original.toolset_id} />,
|
||||
},
|
||||
{
|
||||
header: "Name",
|
||||
|
|
@ -359,11 +356,7 @@ function toolsetColumns(
|
|||
{
|
||||
header: "Created",
|
||||
accessorKey: "created_at",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-gray-500">
|
||||
{row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "—"}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Title, Text, Button, Badge } from "@tremor/react";
|
||||
import { Title, Text, Button } from "@tremor/react";
|
||||
import { Modal } from "antd";
|
||||
import { Button as AntdButton } from "antd";
|
||||
import { ModelDataTable } from "./table";
|
||||
|
|
@ -468,21 +468,6 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
onPageChange?.(page);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
return <Badge color="emerald">healthy</Badge>;
|
||||
case "unhealthy":
|
||||
return <Badge color="red">unhealthy</Badge>;
|
||||
case "checking":
|
||||
return <Badge color="blue">checking</Badge>;
|
||||
case "none":
|
||||
return <Badge color="gray">none</Badge>;
|
||||
default:
|
||||
return <Badge color="gray">unknown</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const showErrorModal = (modelName: string, cleanedError: string, fullError: string) => {
|
||||
setSelectedErrorDetails({
|
||||
modelName,
|
||||
|
|
@ -612,7 +597,6 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
|||
handleModelSelection,
|
||||
handleSelectAll,
|
||||
runIndividualHealthCheck,
|
||||
getStatusBadge,
|
||||
getDisplayModelName,
|
||||
showErrorModal,
|
||||
showSuccessModal,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Tooltip, Checkbox } from "antd";
|
|||
import { Text } from "@tremor/react";
|
||||
import { InformationCircleIcon, PlayIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { IdCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
|
||||
interface HealthCheckData {
|
||||
model_name: string;
|
||||
|
|
@ -21,6 +22,18 @@ interface HealthCheckData {
|
|||
health_full_error?: string;
|
||||
}
|
||||
|
||||
const HEALTH_STATUS_TONES: Record<string, StatusTone> = {
|
||||
healthy: "success",
|
||||
unhealthy: "error",
|
||||
checking: "info",
|
||||
none: "neutral",
|
||||
};
|
||||
|
||||
const healthStatusBadge = (status: string): JSX.Element => {
|
||||
const tone = HEALTH_STATUS_TONES[status];
|
||||
return tone ? <StatusBadge tone={tone} label={status} /> : <StatusBadge tone="neutral" label="unknown" />;
|
||||
};
|
||||
|
||||
interface HealthStatus {
|
||||
status: string;
|
||||
lastCheck: string;
|
||||
|
|
@ -38,7 +51,6 @@ export const healthCheckColumns = (
|
|||
handleModelSelection: (modelId: string, checked: boolean) => void,
|
||||
handleSelectAll: (checked: boolean) => void,
|
||||
runIndividualHealthCheck: (modelId: string) => void,
|
||||
getStatusBadge: (status: string) => JSX.Element,
|
||||
getDisplayModelName: (model: any) => string,
|
||||
showErrorModal?: (modelName: string, cleanedError: string, fullError: string) => void,
|
||||
showSuccessModal?: (modelName: string, response: any) => void,
|
||||
|
|
@ -72,14 +84,7 @@ export const healthCheckColumns = (
|
|||
onChange={(e) => handleModelSelection(modelId, e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Tooltip title={model.model_info.id}>
|
||||
<div
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
|
||||
onClick={() => setSelectedModelId && setSelectedModelId(model.model_info.id)}
|
||||
>
|
||||
{model.model_info.id}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<IdCell value={model.model_info.id} onClick={setSelectedModelId} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -175,7 +180,7 @@ export const healthCheckColumns = (
|
|||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusBadge(healthStatus.status)}
|
||||
{healthStatusBadge(healthStatus.status)}
|
||||
{hasSuccessResponse && showSuccessModal && (
|
||||
<Tooltip title="View response details" placement="top">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ColumnDef } from "@tanstack/react-table";
|
|||
import { Button, Badge, Text } from "@tremor/react";
|
||||
import { Tooltip, Tag } from "antd";
|
||||
import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { StatusBadge } from "@/components/shared/table_cells";
|
||||
|
||||
interface ModelHubData {
|
||||
model_group: string;
|
||||
|
|
@ -212,13 +213,9 @@ export const modelHubColumns = (
|
|||
const model = row.original;
|
||||
|
||||
return model.is_public_model_group === true ? (
|
||||
<Badge color="green" size="xs">
|
||||
Yes
|
||||
</Badge>
|
||||
<StatusBadge tone="success" label="Yes" />
|
||||
) : (
|
||||
<Badge color="gray" size="xs">
|
||||
No
|
||||
</Badge>
|
||||
<StatusBadge tone="neutral" label="No" />
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
|
|
|
|||
|
|
@ -451,6 +451,64 @@ describe("columns", () => {
|
|||
expect(costCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should call setSelectedModelId without triggering the row click when the model ID pill is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const setSelectedModelId = vi.fn();
|
||||
const cols = columns(
|
||||
defaultProps.userRole,
|
||||
defaultProps.userID,
|
||||
defaultProps.premiumUser,
|
||||
setSelectedModelId,
|
||||
defaultProps.setSelectedTeamId,
|
||||
defaultProps.getDisplayModelName,
|
||||
defaultProps.handleEditClick,
|
||||
defaultProps.handleRefreshClick,
|
||||
defaultProps.expandedRows,
|
||||
defaultProps.setExpandedRows,
|
||||
);
|
||||
|
||||
const model = createMockModel();
|
||||
const rowClick = vi.fn();
|
||||
render(
|
||||
<div onClick={rowClick}>
|
||||
<TestTable data={[model]} columns={cols} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("test-model-id"));
|
||||
expect(setSelectedModelId).toHaveBeenCalledWith("test-model-id");
|
||||
expect(rowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call setSelectedTeamId without triggering the row click when the team ID pill is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const setSelectedTeamId = vi.fn();
|
||||
const cols = columns(
|
||||
defaultProps.userRole,
|
||||
defaultProps.userID,
|
||||
defaultProps.premiumUser,
|
||||
defaultProps.setSelectedModelId,
|
||||
setSelectedTeamId,
|
||||
defaultProps.getDisplayModelName,
|
||||
defaultProps.handleEditClick,
|
||||
defaultProps.handleRefreshClick,
|
||||
defaultProps.expandedRows,
|
||||
defaultProps.setExpandedRows,
|
||||
);
|
||||
|
||||
const model = createMockModel();
|
||||
const rowClick = vi.fn();
|
||||
render(
|
||||
<div onClick={rowClick}>
|
||||
<TestTable data={[model]} columns={cols} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("test-team-id"));
|
||||
expect(setSelectedTeamId).toHaveBeenCalledWith("test-team-id");
|
||||
expect(rowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display '-' when team ID is missing", () => {
|
||||
const cols = columns(
|
||||
defaultProps.userRole,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { EditOutlined, InfoCircleOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge, Button, Icon } from "@tremor/react";
|
||||
import { Badge, Icon } from "@tremor/react";
|
||||
import { Divider, Flex, Popover, Space, Switch, Tooltip, Typography } from "antd";
|
||||
import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { ModelData } from "../../model_dashboard/types";
|
||||
import { ProviderLogo } from "./ProviderLogo";
|
||||
|
||||
|
|
@ -65,19 +66,9 @@ export const columns = (
|
|||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return (
|
||||
<Tooltip title={model.model_info.id}>
|
||||
<Text
|
||||
ellipsis
|
||||
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer w-full block"
|
||||
style={{ fontSize: 14, padding: "1px 8px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedModelId(model.model_info.id);
|
||||
}}
|
||||
>
|
||||
{model.model_info.id}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<IdCell value={model.model_info.id} onClick={setSelectedModelId} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -235,11 +226,7 @@ export const columns = (
|
|||
minSize: 80,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{model.model_info.updated_at ? new Date(model.model_info.updated_at).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
);
|
||||
return <DateCell value={model.model_info.updated_at} precision="date" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -282,20 +269,8 @@ export const columns = (
|
|||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return model.model_info.team_id ? (
|
||||
<div className="overflow-hidden w-full">
|
||||
<Tooltip title={model.model_info.team_id}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate w-full"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTeamId(model.model_info.team_id);
|
||||
}}
|
||||
>
|
||||
{model.model_info.team_id.slice(0, 7)}...
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<IdCell value={model.model_info.team_id} onClick={setSelectedTeamId} />
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
|
|
@ -370,15 +345,10 @@ export const columns = (
|
|||
minSize: 80,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
|
||||
${model.model_info.db_model ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-600"}
|
||||
`}
|
||||
>
|
||||
{model.model_info.db_model ? "DB Model" : "Config Model"}
|
||||
</div>
|
||||
return model.model_info.db_model ? (
|
||||
<StatusBadge tone="info" label="DB Model" />
|
||||
) : (
|
||||
<StatusBadge tone="neutral" label="Config Model" />
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { organizationKeys, useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { createTeamAliasMap } from "@/utils/teamUtils";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/outline";
|
||||
|
|
@ -196,7 +197,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
render: (_: unknown, record: Member) => {
|
||||
const orgMember =
|
||||
record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined;
|
||||
return <Typography.Text>${formatNumberWithCommas(orgMember?.spend ?? 0, 4)}</Typography.Text>;
|
||||
return <MoneyCell value={orgMember?.spend} decimals={4} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
import { Form, Input, Modal, Select as Select2, Tooltip } from "antd";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import { formatNumberWithCommas } from "../utils/dataUtils";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import DeleteResourceModal from "./common_components/DeleteResourceModal";
|
||||
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
|
||||
|
|
@ -263,30 +263,22 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
|
|||
.map((org: Organization) => (
|
||||
<TableRow key={org.organization_id}>
|
||||
<TableCell>
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip title={org.organization_id}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||
onClick={() => setSelectedOrgId(org.organization_id)}
|
||||
>
|
||||
{org.organization_id?.slice(0, 7)}
|
||||
...
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<IdCell value={org.organization_id} onClick={setSelectedOrgId} />
|
||||
</TableCell>
|
||||
<TableCell>{org.organization_alias}</TableCell>
|
||||
<TableCell>
|
||||
{org.created_at ? new Date(org.created_at).toLocaleDateString() : "N/A"}
|
||||
<DateCell value={org.created_at} precision="date" />
|
||||
</TableCell>
|
||||
<TableCell>{formatNumberWithCommas(org.spend, 4)}</TableCell>
|
||||
<TableCell>
|
||||
{org.litellm_budget_table?.max_budget !== null &&
|
||||
org.litellm_budget_table?.max_budget !== undefined
|
||||
? org.litellm_budget_table?.max_budget
|
||||
: "No limit"}
|
||||
<MoneyCell value={org.spend} decimals={4} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell
|
||||
value={org.litellm_budget_table?.max_budget}
|
||||
decimals={2}
|
||||
emptyText="Unlimited"
|
||||
showZero
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import AddPassThroughEndpoint from "./add_pass_through";
|
|||
import PassThroughInfoView from "./pass_through_info";
|
||||
import { DataTable } from "./view_logs/table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { IdCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
|
||||
|
|
@ -136,16 +137,7 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
|
|||
{
|
||||
header: "ID",
|
||||
accessorKey: "id",
|
||||
cell: (info: any) => (
|
||||
<Tooltip title={info.row.original.id}>
|
||||
<div
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
|
||||
onClick={() => info.row.original.id && setSelectedEndpointId(info.row.original.id)}
|
||||
>
|
||||
{info.row.original.id}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
cell: (info: any) => <IdCell value={info.row.original.id} onClick={setSelectedEndpointId} />,
|
||||
},
|
||||
{
|
||||
header: "Path",
|
||||
|
|
@ -192,7 +184,9 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
|
|||
</div>
|
||||
),
|
||||
accessorKey: "auth",
|
||||
cell: (info: any) => <Badge color={info.getValue() ? "green" : "gray"}>{info.getValue() ? "Yes" : "No"}</Badge>,
|
||||
cell: (info: any) => (
|
||||
<StatusBadge tone={info.getValue() ? "success" : "neutral"} label={info.getValue() ? "Yes" : "No"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Headers",
|
||||
|
|
|
|||
|
|
@ -140,10 +140,13 @@ describe("AttachmentTable", () => {
|
|||
expect(screen.queryByRole("button", { name: /TrashIcon/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a truncated attachment ID in the table", () => {
|
||||
it("should show the attachment ID as truncated plain mono text", () => {
|
||||
const attachment = makeAttachment({ attachment_id: "att-abcdef1234567" });
|
||||
renderWithProviders(<AttachmentTable {...defaultProps} attachments={[attachment]} />);
|
||||
expect(screen.getByText("att-abc...")).toBeInTheDocument();
|
||||
const idElement = screen.getByText("att-abcdef1234567");
|
||||
expect(idElement.className).toContain("font-mono");
|
||||
expect(idElement.className).toContain("truncate");
|
||||
expect(idElement.className).not.toContain("bg-blue-50");
|
||||
});
|
||||
|
||||
it("should render model tags when the attachment has models", () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import { PolicyAttachment } from "./types";
|
||||
import ImpactPopover from "./impact_popover";
|
||||
|
||||
|
|
@ -30,24 +31,11 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
|||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
|
||||
|
||||
// Format date helper function
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const columns: ColumnDef<PolicyAttachment>[] = [
|
||||
{
|
||||
header: "Attachment ID",
|
||||
accessorKey: "attachment_id",
|
||||
cell: (info: any) => (
|
||||
<Tooltip title={String(info.getValue() || "")}>
|
||||
<span className="font-mono text-xs text-gray-600">
|
||||
{info.getValue() ? `${String(info.getValue()).slice(0, 7)}...` : ""}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
cell: (info: any) => <IdCell value={info.getValue()} variant="plain" />,
|
||||
},
|
||||
{
|
||||
header: "Policy",
|
||||
|
|
@ -183,14 +171,7 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
|||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
cell: ({ row }) => {
|
||||
const attachment = row.original;
|
||||
return (
|
||||
<Tooltip title={attachment.created_at}>
|
||||
<span className="text-xs">{formatDate(attachment.created_at)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { DateCell } from "@/components/shared/table_cells";
|
||||
import { Policy } from "./types";
|
||||
|
||||
/** One row per policy name; primaryPolicy is used for display and for Edit (FlowBuilder loads all versions) */
|
||||
|
|
@ -59,12 +60,6 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
|
|||
|
||||
const rows = useMemo(() => groupPoliciesByName(policies), [policies]);
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const columns: ColumnDef<PolicyRow>[] = [
|
||||
{
|
||||
header: "Name",
|
||||
|
|
@ -199,14 +194,7 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
|
|||
header: "Created At",
|
||||
id: "created_at",
|
||||
accessorFn: (row) => row.primaryPolicy.created_at ?? "",
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original.primaryPolicy;
|
||||
return (
|
||||
<Tooltip title={policy.created_at}>
|
||||
<span className="text-xs">{formatDate(policy.created_at)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <DateCell value={row.original.primaryPolicy.created_at} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface CellTooltipProps {
|
||||
content: React.ReactNode;
|
||||
trigger: React.ReactElement;
|
||||
}
|
||||
|
||||
export function CellTooltip({ content, trigger }: CellTooltipProps) {
|
||||
return (
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={trigger} />
|
||||
<TooltipContent>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DateCell, formatCellDate, formatFullTimestamp } from "./date_cell";
|
||||
|
||||
const localIso = new Date(2026, 6, 7, 9, 50, 13).toISOString();
|
||||
|
||||
describe("formatCellDate", () => {
|
||||
it("formats datetime precision as 'MMM D, HH:mm:ss' without a year", () => {
|
||||
expect(formatCellDate(new Date(2026, 6, 7, 9, 50, 13), "datetime")).toBe("Jul 7, 09:50:13");
|
||||
});
|
||||
|
||||
it("zero-pads hours, minutes and seconds", () => {
|
||||
expect(formatCellDate(new Date(2026, 0, 2, 1, 2, 3), "datetime")).toBe("Jan 2, 01:02:03");
|
||||
});
|
||||
|
||||
it("formats date precision as 'MMM D, YYYY' with no time", () => {
|
||||
expect(formatCellDate(new Date(2026, 11, 31, 23, 59, 59), "date")).toBe("Dec 31, 2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatFullTimestamp", () => {
|
||||
it("includes year, 24h time and the IANA timezone", () => {
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
expect(formatFullTimestamp(new Date(2026, 6, 7, 9, 50, 13))).toBe(`Jul 7, 2026, 09:50:13 (${timeZone})`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DateCell", () => {
|
||||
it("renders the datetime format by default", () => {
|
||||
render(<DateCell value={localIso} />);
|
||||
expect(screen.getByText("Jul 7, 09:50:13")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders date-only when precision is 'date'", () => {
|
||||
render(<DateCell value={localIso} precision="date" />);
|
||||
expect(screen.getByText("Jul 7, 2026")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders '-' for null and undefined", () => {
|
||||
const { rerender } = render(<DateCell value={null} />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
rerender(<DateCell value={undefined} />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the custom fallback for empty values", () => {
|
||||
render(<DateCell value="" fallback="Never" />);
|
||||
expect(screen.getByText("Never")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the fallback instead of 'Invalid Date' for unparseable input", () => {
|
||||
render(<DateCell value="not-a-date" fallback="Unknown" />);
|
||||
expect(screen.getByText("Unknown")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Invalid/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import { CellTooltip } from "./cell_tooltip";
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
|
||||
|
||||
export type DatePrecision = "datetime" | "date";
|
||||
|
||||
interface DateCellProps {
|
||||
value: string | null | undefined;
|
||||
precision?: DatePrecision;
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
|
||||
export const formatCellDate = (date: Date, precision: DatePrecision): string =>
|
||||
precision === "date"
|
||||
? `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`
|
||||
: `${MONTHS[date.getMonth()]} ${date.getDate()}, ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
|
||||
export const formatFullTimestamp = (date: Date): string => {
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const day = `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
|
||||
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
return `${day}, ${time} (${timeZone})`;
|
||||
};
|
||||
|
||||
export function DateCell({ value, precision = "datetime", fallback = "-" }: DateCellProps) {
|
||||
const date = value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) {
|
||||
return <span className="text-muted-foreground">{fallback}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CellTooltip
|
||||
content={formatFullTimestamp(date)}
|
||||
trigger={<span className="whitespace-nowrap">{formatCellDate(date, precision)}</span>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { IdCell } from "./id_cell";
|
||||
|
||||
const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/utils/dataUtils", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/utils/dataUtils")>()),
|
||||
copyToClipboard: copyToClipboardMock,
|
||||
}));
|
||||
|
||||
describe("IdCell", () => {
|
||||
it("renders '-' for empty values", () => {
|
||||
render(<IdCell value={null} />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the custom fallback for empty values", () => {
|
||||
render(<IdCell value="" fallback="—" />);
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the full id as a non-interactive pill by default", () => {
|
||||
render(<IdCell value="sk-1234567890abcdef" />);
|
||||
const el = screen.getByText("sk-1234567890abcdef");
|
||||
expect(el.tagName).toBe("SPAN");
|
||||
expect(el.className).toContain("bg-blue-50");
|
||||
expect(el.className).toContain("font-mono");
|
||||
expect(el.className).toContain("max-w-[15ch]");
|
||||
expect(el.className).toContain("truncate");
|
||||
});
|
||||
|
||||
it("renders plain mono text without pill styling for the plain variant", () => {
|
||||
render(<IdCell value="req-123" variant="plain" />);
|
||||
const el = screen.getByText("req-123");
|
||||
expect(el.className).toContain("font-mono");
|
||||
expect(el.className).not.toContain("bg-blue-50");
|
||||
});
|
||||
|
||||
it("does not truncate when truncate is false", () => {
|
||||
render(<IdCell value="audit-object-id" truncate={false} />);
|
||||
expect(screen.getByText("audit-object-id").className).not.toContain("truncate");
|
||||
});
|
||||
|
||||
it("becomes a button that fires onClick with the id value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(<IdCell value="team-42" onClick={onClick} />);
|
||||
await user.click(screen.getByRole("button", { name: "team-42" }));
|
||||
expect(onClick).toHaveBeenCalledWith("team-42");
|
||||
});
|
||||
|
||||
it("stays non-interactive when disabled, even with onClick", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<IdCell value="tag-1" onClick={onClick} disabled />);
|
||||
expect(screen.queryByRole("button", { name: "tag-1" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies the id via the trailing copy button without triggering row clicks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const rowClick = vi.fn();
|
||||
render(
|
||||
<div onClick={rowClick}>
|
||||
<IdCell value="key-hash-9" copyable />
|
||||
</div>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Copy ID" }));
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith("key-hash-9");
|
||||
expect(rowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes dataTestId through to the id element", () => {
|
||||
render(<IdCell value="k-1" dataTestId="key-id-cell" />);
|
||||
expect(screen.getByTestId("key-id-cell")).toHaveTextContent("k-1");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
import { Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
import { CellTooltip } from "./cell_tooltip";
|
||||
|
||||
export type IdCellVariant = "pill" | "plain";
|
||||
|
||||
interface IdCellProps {
|
||||
value: string | null | undefined;
|
||||
variant?: IdCellVariant;
|
||||
onClick?: (value: string) => void;
|
||||
copyable?: boolean;
|
||||
truncate?: boolean;
|
||||
fallback?: string;
|
||||
tooltip?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
dataTestId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VARIANT_CLASS: Record<IdCellVariant, { base: string; clickable: string }> = {
|
||||
pill: {
|
||||
base: "font-mono text-xs font-normal px-2 py-0.5 rounded-md text-left bg-blue-50 text-blue-500",
|
||||
clickable: "hover:bg-blue-100 cursor-pointer",
|
||||
},
|
||||
plain: {
|
||||
base: "font-mono text-xs text-left",
|
||||
clickable: "hover:text-blue-600 cursor-pointer",
|
||||
},
|
||||
};
|
||||
|
||||
export function IdCell({
|
||||
value,
|
||||
variant = "pill",
|
||||
onClick,
|
||||
copyable = false,
|
||||
truncate = true,
|
||||
fallback = "-",
|
||||
tooltip,
|
||||
disabled = false,
|
||||
dataTestId,
|
||||
className,
|
||||
}: IdCellProps) {
|
||||
if (!value) {
|
||||
return <span className="text-muted-foreground">{fallback}</span>;
|
||||
}
|
||||
|
||||
const clickable = !!onClick && !disabled;
|
||||
const classes = cn(
|
||||
VARIANT_CLASS[variant].base,
|
||||
clickable && VARIANT_CLASS[variant].clickable,
|
||||
truncate && "block max-w-[15ch] truncate",
|
||||
disabled && "opacity-50",
|
||||
className,
|
||||
);
|
||||
|
||||
const idElement = clickable ? (
|
||||
<button type="button" className={classes} data-testid={dataTestId} onClick={() => onClick(value)}>
|
||||
{value}
|
||||
</button>
|
||||
) : (
|
||||
<span className={classes} data-testid={dataTestId}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
|
||||
const withTooltip = <CellTooltip content={tooltip ?? value} trigger={idElement} />;
|
||||
|
||||
if (!copyable) {
|
||||
return withTooltip;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex max-w-full items-center gap-1">
|
||||
{withTooltip}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy ID"
|
||||
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void copyToClipboard(value);
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MoneyCell } from "./money_cell";
|
||||
|
||||
describe("MoneyCell", () => {
|
||||
it("renders '-' for null and undefined", () => {
|
||||
const { rerender } = render(<MoneyCell value={null} />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
rerender(<MoneyCell value={undefined} />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the custom emptyText for null budgets", () => {
|
||||
render(<MoneyCell value={null} emptyText="Unlimited" />);
|
||||
expect(screen.getByText("Unlimited")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders '-' for zero by default", () => {
|
||||
render(<MoneyCell value={0} />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a formatted zero when showZero is set, never the emptyText", () => {
|
||||
render(<MoneyCell value={0} showZero emptyText="Unlimited" decimals={2} />);
|
||||
expect(screen.getByText("$0.00")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Unlimited")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("formats amounts with commas, a dollar sign and the given decimals", () => {
|
||||
render(<MoneyCell value={1234.5678} decimals={2} />);
|
||||
expect(screen.getByText("$1,234.57")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to 4 decimals", () => {
|
||||
render(<MoneyCell value={42} />);
|
||||
expect(screen.getByText("$42.0000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the sub-threshold form for amounts that round to zero", () => {
|
||||
render(<MoneyCell value={0.0000001} decimals={6} />);
|
||||
expect(screen.getByText("< $0.000001")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils";
|
||||
|
||||
interface MoneyCellProps {
|
||||
value: number | null | undefined;
|
||||
decimals?: number;
|
||||
emptyText?: string;
|
||||
showZero?: boolean;
|
||||
}
|
||||
|
||||
export function MoneyCell({ value, decimals = 4, emptyText = "-", showZero = false }: MoneyCellProps) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return <span className="text-muted-foreground">{emptyText}</span>;
|
||||
}
|
||||
if (value === 0) {
|
||||
if (!showZero) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return <span className="whitespace-nowrap">{`$${formatNumberWithCommas(0, decimals, false, true)}`}</span>;
|
||||
}
|
||||
return <span className="whitespace-nowrap">{getSpendString(value, decimals)}</span>;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { StatusBadge, type StatusTone } from "./status_badge";
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
const toneClasses: Record<StatusTone, string[]> = {
|
||||
success: ["border-green-200", "bg-green-50", "text-green-600"],
|
||||
error: ["border-red-200", "bg-red-50", "text-red-600"],
|
||||
warning: ["border-amber-200", "bg-amber-50", "text-amber-600"],
|
||||
neutral: ["border-gray-200", "bg-gray-50", "text-gray-600"],
|
||||
info: ["border-blue-200", "bg-blue-50", "text-blue-600"],
|
||||
};
|
||||
|
||||
(Object.entries(toneClasses) as [StatusTone, string[]][]).forEach(([tone, classes]) => {
|
||||
it(`renders a tinted pill (${classes.join(" ")}) for the ${tone} tone`, () => {
|
||||
render(<StatusBadge tone={tone} label={tone} />);
|
||||
const badge = screen.getByText(tone);
|
||||
classes.forEach((cls) => expect(badge.className).toContain(cls));
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the label text inside an outline badge with no status dot", () => {
|
||||
render(<StatusBadge tone="success" label="Active" />);
|
||||
const badge = screen.getByText("Active");
|
||||
expect(badge.dataset.variant).toBe("outline");
|
||||
expect(badge.querySelector("[aria-hidden]")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes dataTestId through", () => {
|
||||
render(<StatusBadge tone="error" label="Blocked" dataTestId="key-status" />);
|
||||
expect(screen.getByTestId("key-status")).toHaveTextContent("Blocked");
|
||||
});
|
||||
|
||||
it("opens the tooltip on hover, which requires Badge to forward its ref to the trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StatusBadge tone="error" label="Blocked" tooltip="This key was blocked by SCIM" />);
|
||||
await user.hover(screen.getByText("Blocked"));
|
||||
expect(await screen.findByText("This key was blocked by SCIM")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { CellTooltip } from "./cell_tooltip";
|
||||
|
||||
export type StatusTone = "success" | "error" | "warning" | "neutral" | "info";
|
||||
|
||||
const TONE_CLASS: Record<StatusTone, string> = {
|
||||
success: "border-green-200 bg-green-50 text-green-600",
|
||||
error: "border-red-200 bg-red-50 text-red-600",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-600",
|
||||
neutral: "border-gray-200 bg-gray-50 text-gray-600",
|
||||
info: "border-blue-200 bg-blue-50 text-blue-600",
|
||||
};
|
||||
|
||||
interface StatusBadgeProps {
|
||||
tone: StatusTone;
|
||||
label: string;
|
||||
tooltip?: React.ReactNode;
|
||||
dataTestId?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ tone, label, tooltip, dataTestId }: StatusBadgeProps) {
|
||||
const badge = (
|
||||
<Badge variant="outline" data-testid={dataTestId} className={cn("whitespace-nowrap font-normal", TONE_CLASS[tone])}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return badge;
|
||||
}
|
||||
return <CellTooltip content={tooltip} trigger={badge} />;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { Badge, Text } from "@tremor/react";
|
|||
import { Tooltip } from "antd";
|
||||
import { CopyOutlined, LinkOutlined } from "@ant-design/icons";
|
||||
import { Plugin } from "./claude_code_plugins/types";
|
||||
import { StatusBadge } from "@/components/shared/table_cells";
|
||||
|
||||
export const skillHubColumns = (
|
||||
showModal: (skill: Plugin) => void,
|
||||
|
|
@ -104,9 +105,10 @@ export const skillHubColumns = (
|
|||
accessorKey: "enabled",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<Badge color={row.original.enabled ? "green" : "gray"} size="xs">
|
||||
{row.original.enabled ? "Public" : "Draft"}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
tone={row.original.enabled ? "success" : "neutral"}
|
||||
label={row.original.enabled ? "Public" : "Draft"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { formatCellDate } from "@/components/shared/table_cells";
|
||||
import TagTable from "./TagTable";
|
||||
import { Tag } from "./types";
|
||||
|
||||
|
|
@ -75,14 +76,21 @@ describe("TagTable", () => {
|
|||
|
||||
it("should display formatted created date", () => {
|
||||
render(<TagTable {...defaultProps} data={[mockTag]} />);
|
||||
const formattedDate = new Date(mockTag.created_at).toLocaleDateString();
|
||||
const formattedDate = formatCellDate(new Date(mockTag.created_at), "date");
|
||||
expect(screen.getByText(formattedDate)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable tag name button for dynamic spend tags", () => {
|
||||
it("should call onSelectTag when tag name is clicked", () => {
|
||||
render(<TagTable {...defaultProps} data={[mockTag]} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "test-tag" }));
|
||||
expect(mockOnSelectTag).toHaveBeenCalledWith("test-tag");
|
||||
});
|
||||
|
||||
it("should render tag name as non-clickable for dynamic spend tags", () => {
|
||||
render(<TagTable {...defaultProps} data={[mockDynamicSpendTag]} />);
|
||||
const tagButton = screen.getByRole("button", { name: "dynamic-spend-tag" });
|
||||
expect(tagButton).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: "dynamic-spend-tag" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("dynamic-spend-tag"));
|
||||
expect(mockOnSelectTag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should disable edit icon for dynamic spend tags", () => {
|
||||
|
|
|
|||
|
|
@ -7,20 +7,10 @@ import {
|
|||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Icon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import React from "react";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import { Tag } from "./types";
|
||||
|
||||
interface TagTableProps {
|
||||
|
|
@ -45,21 +35,15 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
|
|||
const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip
|
||||
title={
|
||||
<IdCell
|
||||
value={tag.name}
|
||||
truncate={false}
|
||||
onClick={onSelectTag}
|
||||
disabled={isDynamicSpendTag}
|
||||
tooltip={
|
||||
isDynamicSpendTag ? "You cannot view the information of a dynamically generated spend tag" : tag.name
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5"
|
||||
onClick={() => onSelectTag(tag.name)}
|
||||
disabled={isDynamicSpendTag}
|
||||
>
|
||||
{tag.name}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -104,10 +88,7 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
|
|||
header: "Created",
|
||||
accessorKey: "created_at",
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => {
|
||||
const tag = row.original;
|
||||
return <span className="text-xs">{new Date(tag.created_at).toLocaleDateString()}</span>;
|
||||
},
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ const mockSetSelectedEditMember = vi.fn();
|
|||
const mockSetIsEditMemberModalVisible = vi.fn();
|
||||
const mockSetIsAddMemberModalVisible = vi.fn();
|
||||
|
||||
const budgetResetIso = new Date(2026, 6, 15, 12, 0, 0).toISOString();
|
||||
|
||||
const createMockTeamData = (overrides: Partial<TeamData> = {}): TeamData => ({
|
||||
team_id: "team-123",
|
||||
team_info: {
|
||||
|
|
@ -78,6 +80,7 @@ const createMockTeamData = (overrides: Partial<TeamData> = {}): TeamData => ({
|
|||
rpm_limit: 100,
|
||||
model_max_budget: null,
|
||||
budget_duration: null,
|
||||
budget_reset_at: budgetResetIso,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -202,7 +205,7 @@ describe("TeamMembersComponent", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("-").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should display Default Proxy Admin tag for default_user_id", () => {
|
||||
|
|
@ -243,12 +246,12 @@ describe("TeamMembersComponent", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/\$100\.5/)).toBeInTheDocument();
|
||||
expect(screen.getByText("$100.5000")).toBeInTheDocument();
|
||||
expect(screen.getByText(/100 RPM/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10000 TPM/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display No Limit for budget when member has no budget", () => {
|
||||
it("should display the budget reset date for member with a budget reset", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
|
|
@ -260,7 +263,23 @@ describe("TeamMembersComponent", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No Limit")).toBeInTheDocument();
|
||||
expect(screen.getByText("Jul 15, 2026")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display formatted budget and Unlimited for member with no budget", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("$1,000.0000")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unlimited")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display No Limits for rate limits when member has no limits", () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { Member } from "@/components/networking";
|
||||
import { formatBudgetReset } from "@/utils/budgetUtils";
|
||||
import { DateCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
|
@ -58,14 +58,10 @@ export default function TeamMemberTab({
|
|||
return membership?.total_spend ?? 0;
|
||||
};
|
||||
|
||||
const getUserBudget = (userId: string | null): string | null => {
|
||||
const getUserBudget = (userId: string | null): number | null => {
|
||||
if (!userId) return null;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
const maxBudget = membership?.litellm_budget_table?.max_budget;
|
||||
if (maxBudget === null || maxBudget === undefined) {
|
||||
return null;
|
||||
}
|
||||
return formatNumber(maxBudget);
|
||||
return membership?.litellm_budget_table?.max_budget ?? null;
|
||||
};
|
||||
|
||||
// Helper function to get rate limits for a user
|
||||
|
|
@ -98,7 +94,7 @@ export default function TeamMemberTab({
|
|||
const getUserBudgetReset = (userId: string | null): string | null => {
|
||||
if (!userId) return null;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
return formatBudgetReset(membership?.litellm_budget_table?.budget_reset_at);
|
||||
return membership?.litellm_budget_table?.budget_reset_at ?? null;
|
||||
};
|
||||
|
||||
const extraColumns: ColumnsType<Member> = [
|
||||
|
|
@ -146,7 +142,7 @@ export default function TeamMemberTab({
|
|||
),
|
||||
key: "spend",
|
||||
render: (_: unknown, record: Member) => (
|
||||
<Typography.Text>${formatNumberWithCommas(getUserCurrentCycleSpend(record.user_id), 4)}</Typography.Text>
|
||||
<MoneyCell value={getUserCurrentCycleSpend(record.user_id)} decimals={4} />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -159,31 +155,19 @@ export default function TeamMemberTab({
|
|||
</Space>
|
||||
),
|
||||
key: "total_spend",
|
||||
render: (_: unknown, record: Member) => (
|
||||
<Typography.Text>${formatNumberWithCommas(getUserTotalSpend(record.user_id), 4)}</Typography.Text>
|
||||
),
|
||||
render: (_: unknown, record: Member) => <MoneyCell value={getUserTotalSpend(record.user_id)} decimals={4} />,
|
||||
},
|
||||
{
|
||||
title: "Team Member Budget (USD)",
|
||||
key: "budget",
|
||||
render: (_: unknown, record: Member) => {
|
||||
const budget = getUserBudget(record.user_id);
|
||||
return (
|
||||
<Typography.Text>{budget ? `$${formatNumberWithCommas(Number(budget), 4)}` : "No Limit"}</Typography.Text>
|
||||
);
|
||||
},
|
||||
render: (_: unknown, record: Member) => (
|
||||
<MoneyCell value={getUserBudget(record.user_id)} decimals={4} emptyText="Unlimited" showZero />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Budget Reset",
|
||||
key: "budget_reset",
|
||||
render: (_: unknown, record: Member) => {
|
||||
const reset = getUserBudgetReset(record.user_id);
|
||||
return reset ? (
|
||||
<Typography.Text>{reset}</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">—</Typography.Text>
|
||||
);
|
||||
},
|
||||
render: (_: unknown, record: Member) => <DateCell value={getUserBudgetReset(record.user_id)} precision="date" />,
|
||||
},
|
||||
{
|
||||
title: (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
"use client";
|
||||
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
ColumnDef,
|
||||
|
|
@ -12,18 +12,7 @@ import {
|
|||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Icon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Popover, Skeleton, Tooltip, Typography } from "antd";
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
|
|
@ -214,23 +203,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
header: "Key ID",
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate block"
|
||||
style={{ maxWidth: width, overflow: "hidden" }}
|
||||
onClick={() => setSelectedKey(info.row.original)}
|
||||
>
|
||||
{value ?? "-"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: (info) => (
|
||||
<IdCell value={info.getValue() as string | null} onClick={() => setSelectedKey(info.row.original)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "key_alias",
|
||||
|
|
@ -310,10 +285,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
header: "Created At",
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleDateString() : "-";
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "created_by",
|
||||
|
|
@ -380,10 +352,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
header: "Updated At",
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleDateString() : "Never";
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "last_active",
|
||||
|
|
@ -401,16 +370,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
),
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
if (!value) return "Unknown";
|
||||
const date = new Date(value as string);
|
||||
return (
|
||||
<Tooltip title={date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "long" })}>
|
||||
<span>{date.toLocaleDateString()}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Unknown" />,
|
||||
},
|
||||
{
|
||||
id: "expires",
|
||||
|
|
@ -418,10 +378,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
header: "Expires",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleDateString() : "Never";
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
|
|
@ -429,7 +386,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
header: "Spend (USD)",
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
cell: (info) => formatNumberWithCommas(info.getValue() as number, 4),
|
||||
cell: (info) => <MoneyCell value={info.getValue() as number | null} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
|
|
@ -437,11 +394,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
header: "Budget (USD)",
|
||||
size: 110,
|
||||
enableSorting: true,
|
||||
cell: (info) => {
|
||||
const maxBudget = info.getValue() as number | null;
|
||||
if (maxBudget === null) return "Unlimited";
|
||||
return `$${formatNumberWithCommas(maxBudget)}`;
|
||||
},
|
||||
cell: (info) => (
|
||||
<MoneyCell value={info.getValue() as number | null} decimals={0} emptyText="Unlimited" showZero />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "budget_reset_at",
|
||||
|
|
@ -449,10 +404,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
header: "Budget Reset",
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleString() : "Never";
|
||||
},
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
|
|
|
|||
|
|
@ -21,14 +21,18 @@ const badgeVariants = cva({
|
|||
},
|
||||
});
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return (
|
||||
<span data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
const Badge = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span"> & VariantProps<typeof badgeVariants>
|
||||
>(({ className, variant = "default", ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
getProxyUISettings,
|
||||
} from "./networking";
|
||||
import TopKeyView from "./UsagePage/components/EntityUsage/TopKeyView";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
interface UsagePageProps {
|
||||
|
|
@ -644,9 +645,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
<TableRow key={provider.provider}>
|
||||
<TableCell>{provider.provider}</TableCell>
|
||||
<TableCell>
|
||||
{parseFloat(provider.spend.toFixed(2)) < 0.00001
|
||||
? "less than 0.00"
|
||||
: formatNumberWithCommas(provider.spend, 2)}
|
||||
<MoneyCell value={provider.spend} decimals={2} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
@ -819,7 +818,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
{topUsers?.map((user: any, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{user.end_user}</TableCell>
|
||||
<TableCell>{formatNumberWithCommas(user.total_spend, 2)}</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell value={user.total_spend} decimals={2} />
|
||||
</TableCell>
|
||||
<TableCell>{user.total_count}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React from "react";
|
||||
import { Table, Badge, Tooltip } from "antd";
|
||||
import { Table, Tooltip } from "antd";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { EyeOutlined, CopyOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import { DocumentUpload } from "./types";
|
||||
|
||||
interface DocumentsTableProps {
|
||||
|
|
@ -16,15 +17,15 @@ const DocumentsTable: React.FC<DocumentsTableProps> = ({ documents, onRemove })
|
|||
};
|
||||
|
||||
const getStatusBadge = (status: DocumentUpload["status"]) => {
|
||||
const statusConfig = {
|
||||
uploading: { color: "blue", text: "Uploading" },
|
||||
done: { color: "green", text: "Ready" },
|
||||
error: { color: "red", text: "Error" },
|
||||
removed: { color: "default", text: "Removed" },
|
||||
const statusConfig: Record<DocumentUpload["status"], { tone: StatusTone; label: string }> = {
|
||||
uploading: { tone: "info", label: "Uploading" },
|
||||
done: { tone: "success", label: "Ready" },
|
||||
error: { tone: "error", label: "Error" },
|
||||
removed: { tone: "neutral", label: "Removed" },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
return <Badge color={config.color} text={config.text} />;
|
||||
const config: { tone: StatusTone; label: string } = statusConfig[status] ?? { tone: "neutral", label: status };
|
||||
return <StatusBadge tone={config.tone} label={config.label} />;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import React from "react";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import { getProviderLogoAndName } from "../provider_info_helpers";
|
||||
import { VectorStore } from "./types";
|
||||
|
|
@ -28,19 +29,7 @@ const VectorStoreTable: React.FC<VectorStoreTableProps> = ({ data, onView, onEdi
|
|||
{
|
||||
header: "Vector Store ID",
|
||||
accessorKey: "vector_store_id",
|
||||
cell: ({ row }) => {
|
||||
const vectorStore = row.original;
|
||||
return (
|
||||
<button
|
||||
onClick={() => onView(vectorStore.vector_store_id)}
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
|
||||
>
|
||||
{vectorStore.vector_store_id.length > 15
|
||||
? `${vectorStore.vector_store_id.slice(0, 15)}...`
|
||||
: vectorStore.vector_store_id}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <IdCell value={row.original.vector_store_id} onClick={onView} />,
|
||||
},
|
||||
{
|
||||
header: "Name",
|
||||
|
|
@ -109,19 +98,13 @@ const VectorStoreTable: React.FC<VectorStoreTableProps> = ({ data, onView, onEdi
|
|||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => {
|
||||
const vectorStore = row.original;
|
||||
return <span className="text-xs">{new Date(vectorStore.created_at).toLocaleDateString()}</span>;
|
||||
},
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
header: "Updated At",
|
||||
accessorKey: "updated_at",
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => {
|
||||
const vectorStore = row.original;
|
||||
return <span className="text-xs">{new Date(vectorStore.updated_at).toLocaleDateString()}</span>;
|
||||
},
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Table, Tag, Input, Select, Button, Pagination, Spin } from "antd";
|
|||
import { ReloadOutlined, LoadingOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
import moment from "moment";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import { uiAuditLogsCall } from "../networking";
|
||||
import { AuditLogEntry } from "./columns";
|
||||
import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer";
|
||||
|
|
@ -95,11 +95,7 @@ export default function AuditLogs({ userID, userRole, token, accessToken, isActi
|
|||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
width: 200,
|
||||
render: (val: string) => (
|
||||
<span className="font-mono text-xs whitespace-nowrap">
|
||||
{moment.utc(val).local().format("MMM D, YYYY HH:mm:ss")}
|
||||
</span>
|
||||
),
|
||||
render: (val: string) => <DateCell value={val} />,
|
||||
},
|
||||
{
|
||||
title: "Action",
|
||||
|
|
@ -123,7 +119,7 @@ export default function AuditLogs({ userID, userRole, token, accessToken, isActi
|
|||
title: "Object ID",
|
||||
dataIndex: "object_id",
|
||||
key: "object_id",
|
||||
render: (val: string) => <span className="font-mono text-xs">{val}</span>,
|
||||
render: (val: string) => <IdCell value={val} variant="plain" truncate={false} />,
|
||||
},
|
||||
{
|
||||
title: "Changed By",
|
||||
|
|
@ -137,7 +133,7 @@ export default function AuditLogs({ userID, userRole, token, accessToken, isActi
|
|||
dataIndex: "changed_by_api_key",
|
||||
key: "changed_by_api_key",
|
||||
width: 140,
|
||||
render: (val: string) => (val ? <span className="font-mono text-xs">{val.slice(0, 12)}…</span> : "—"),
|
||||
render: (val: string) => <IdCell value={val} variant="plain" />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createColumns, type LogEntry } from "./columns";
|
||||
import { DataTable } from "./table";
|
||||
|
||||
const logEntry = (overrides: Partial<LogEntry>): LogEntry => ({
|
||||
request_id: "req-1",
|
||||
api_key: "key-1",
|
||||
team_id: "team-1",
|
||||
model: "gpt-4o",
|
||||
model_id: "model-1",
|
||||
call_type: "acompletion",
|
||||
spend: 0,
|
||||
total_tokens: 10,
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 5,
|
||||
startTime: "2026-07-07T09:50:13Z",
|
||||
endTime: "2026-07-07T09:50:14Z",
|
||||
cache_hit: "false",
|
||||
messages: [],
|
||||
response: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("Cost column", () => {
|
||||
it("renders '-' for zero spend with no tooltip, so hovering never shows a contradictory $0", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTable
|
||||
data={[logEntry({ request_id: "req-zero", spend: 0 })]}
|
||||
columns={createColumns()}
|
||||
getRowId={(r) => r.request_id}
|
||||
/>,
|
||||
);
|
||||
for (const dash of screen.getAllByText("-")) {
|
||||
await user.hover(dash);
|
||||
}
|
||||
expect(screen.queryByText("$0")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the full-precision raw value in the tooltip for a real spend", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTable
|
||||
data={[logEntry({ request_id: "req-spend", spend: 0.00012345678 })]}
|
||||
columns={createColumns()}
|
||||
getRowId={(r) => r.request_id}
|
||||
/>,
|
||||
);
|
||||
const formatted = screen.getByText("$0.000123");
|
||||
await user.hover(formatted);
|
||||
expect(await screen.findByText("$0.00012345678")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { getSpendString } from "@/utils/dataUtils";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge, Button } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import { getProviderLogoAndName } from "../provider_info_helpers";
|
||||
import { TableHeaderSortDropdown } from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
import { TimeCell } from "./time_cell";
|
||||
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
|
||||
import { AgentBadge, AgentIcon, LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges";
|
||||
|
||||
|
|
@ -120,7 +119,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
|
|||
: "Time",
|
||||
accessorKey: "startTime",
|
||||
size: 200,
|
||||
cell: (info: any) => <TimeCell utcTime={info.getValue()} />,
|
||||
cell: (info: any) => <DateCell value={info.getValue()} />,
|
||||
},
|
||||
{
|
||||
header: "Type",
|
||||
|
|
@ -174,48 +173,20 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
|
|||
cell: (info: any) => {
|
||||
const status = info.getValue() || "Success";
|
||||
const isSuccess = status.toLowerCase() !== "failure";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium inline-block text-center w-16 ${
|
||||
isSuccess ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{isSuccess ? "Success" : "Failure"}
|
||||
</span>
|
||||
);
|
||||
return <StatusBadge tone={isSuccess ? "success" : "error"} label={isSuccess ? "Success" : "Failure"} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Session ID",
|
||||
accessorKey: "session_id",
|
||||
size: 120,
|
||||
cell: (info: any) => {
|
||||
const value = String(info.getValue() || "");
|
||||
const onSessionClick = info.row.original.onSessionClick;
|
||||
return (
|
||||
<Tooltip title={String(info.getValue() || "")}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal text-xs max-w-[15ch] truncate block"
|
||||
onClick={() => onSessionClick?.(value)}
|
||||
>
|
||||
{String(info.getValue() || "")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: (info: any) => <IdCell value={info.getValue()} onClick={info.row.original.onSessionClick} />,
|
||||
},
|
||||
|
||||
{
|
||||
header: "Request ID",
|
||||
accessorKey: "request_id",
|
||||
cell: (info: any) => (
|
||||
<Tooltip title={String(info.getValue() || "")}>
|
||||
<span className="font-mono text-xs max-w-[15ch] truncate block">{String(info.getValue() || "")}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
cell: (info: any) => <IdCell value={info.getValue()} variant="plain" />,
|
||||
},
|
||||
{
|
||||
header: sortProps
|
||||
|
|
@ -236,11 +207,14 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
|
|||
const row = info.row.original;
|
||||
const mcpCount = row.mcp_tool_call_count || 0;
|
||||
const mcpSpend = row.mcp_tool_call_spend || 0;
|
||||
const spend = info.getValue();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end">
|
||||
<Tooltip title={`$${String(info.getValue() || 0)}`}>
|
||||
<span>{getSpendString(info.getValue() || 0)}</span>
|
||||
<Tooltip title={spend ? `$${String(spend)}` : undefined}>
|
||||
<span>
|
||||
<MoneyCell value={spend} decimals={6} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{mcpCount > 0 && mcpSpend > 0 && (
|
||||
<span className="text-[10px] text-amber-600">
|
||||
|
|
@ -320,21 +294,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
|
|||
header: "Key Hash",
|
||||
accessorKey: "metadata.user_api_key",
|
||||
size: 110,
|
||||
cell: (info: any) => {
|
||||
const value = String(info.getValue() || "-");
|
||||
const onKeyHashClick = info.row.original.onKeyHashClick;
|
||||
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span
|
||||
className="font-mono max-w-[15ch] truncate block cursor-pointer hover:text-blue-600"
|
||||
onClick={() => onKeyHashClick?.(value)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: (info: any) => <IdCell value={info.getValue()} variant="plain" onClick={info.row.original.onKeyHashClick} />,
|
||||
},
|
||||
{
|
||||
header: "Key Alias",
|
||||
|
|
@ -589,141 +549,3 @@ export type AuditLogEntry = {
|
|||
before_value: Record<string, any>;
|
||||
updated_values: Record<string, any>;
|
||||
};
|
||||
|
||||
const getActionBadge = (action: string) => {
|
||||
return (
|
||||
<Badge color="gray" className="flex items-center gap-1">
|
||||
<span className="whitespace-nowrap text-xs">{action}</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const auditLogColumns: ColumnDef<AuditLogEntry>[] = [
|
||||
{
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const ExpanderCell = () => {
|
||||
const [localExpanded, setLocalExpanded] = React.useState(row.getIsExpanded());
|
||||
|
||||
const toggleHandler = React.useCallback(() => {
|
||||
setLocalExpanded((prev) => !prev);
|
||||
row.getToggleExpandedHandler()();
|
||||
}, [row]);
|
||||
|
||||
return row.getCanExpand() ? (
|
||||
<button
|
||||
onClick={toggleHandler}
|
||||
style={{ cursor: "pointer" }}
|
||||
aria-label={localExpanded ? "Collapse row" : "Expand row"}
|
||||
className="w-6 h-6 flex items-center justify-center focus:outline-hidden"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transform transition-transform ${localExpanded ? "rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-6 h-6 flex items-center justify-center">●</span>
|
||||
);
|
||||
};
|
||||
return <ExpanderCell />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Timestamp",
|
||||
accessorKey: "updated_at",
|
||||
cell: (info: any) => <TimeCell utcTime={info.getValue()} />,
|
||||
},
|
||||
{
|
||||
header: "Table Name",
|
||||
accessorKey: "table_name",
|
||||
cell: (info: any) => {
|
||||
const tableName = info.getValue();
|
||||
let displayValue = tableName;
|
||||
switch (tableName) {
|
||||
case "LiteLLM_VerificationToken":
|
||||
displayValue = "Keys";
|
||||
break;
|
||||
case "LiteLLM_TeamTable":
|
||||
displayValue = "Teams";
|
||||
break;
|
||||
case "LiteLLM_OrganizationTable":
|
||||
displayValue = "Organizations";
|
||||
break;
|
||||
case "LiteLLM_UserTable":
|
||||
displayValue = "Users";
|
||||
break;
|
||||
case "LiteLLM_ProxyModelTable":
|
||||
displayValue = "Models";
|
||||
break;
|
||||
default:
|
||||
displayValue = tableName;
|
||||
}
|
||||
return <span>{displayValue}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Action",
|
||||
accessorKey: "action",
|
||||
cell: (info: any) => <span>{getActionBadge(info.getValue())}</span>,
|
||||
},
|
||||
{
|
||||
header: "Changed By",
|
||||
accessorKey: "changed_by",
|
||||
cell: (info: any) => {
|
||||
const changedBy = info.row.original.changed_by;
|
||||
const apiKey = info.row.original.changed_by_api_key;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{changedBy}</div>
|
||||
{apiKey && ( // Only show API key if it exists
|
||||
<Tooltip title={apiKey}>
|
||||
<div className="text-xs text-muted-foreground max-w-[15ch] truncate">
|
||||
{" "}
|
||||
{/* Apply max-width and truncate */}
|
||||
{apiKey}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Affected Item ID",
|
||||
accessorKey: "object_id",
|
||||
cell: (props) => {
|
||||
const ObjectIdDisplay = () => {
|
||||
const objectId = props.getValue();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!objectId) return <>-</>;
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(objectId));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy object ID: ", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={copied ? "Copied!" : String(objectId)}>
|
||||
<span className="max-w-[20ch] truncate block cursor-pointer hover:text-blue-600" onClick={handleCopy}>
|
||||
{String(objectId)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
return <ObjectIdDisplay />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TimeCell, getTimeZone } from "./time_cell";
|
||||
|
||||
describe("TimeCell", () => {
|
||||
it("should render a formatted time string", () => {
|
||||
render(<TimeCell utcTime="2025-06-15T14:30:00Z" />);
|
||||
// The global toLocaleString mock in setupTests returns "YYYY-MM-DD HH:MM:SS"
|
||||
expect(screen.getByText(/2025/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render 'Error converting time' for invalid dates", () => {
|
||||
// toLocaleString on an Invalid Date returns "Invalid Date", not throwing,
|
||||
// but the component catches exceptions. Force an error by passing something
|
||||
// that causes Date constructor to produce NaN.
|
||||
render(<TimeCell utcTime="not-a-date" />);
|
||||
// The mock returns "NaN-NaN-NaN NaN:NaN:NaN" for invalid dates
|
||||
// The component has a try/catch that returns "Error converting time" on exception
|
||||
const el = screen.getByText(/NaN|Error/);
|
||||
expect(el).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with monospace font", () => {
|
||||
render(<TimeCell utcTime="2025-06-15T14:30:00Z" />);
|
||||
const span = screen.getByText(/2025/);
|
||||
expect(span).toHaveStyle({ fontFamily: "monospace" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimeZone", () => {
|
||||
it("should return a non-empty timezone string", () => {
|
||||
const tz = getTimeZone();
|
||||
expect(typeof tz).toBe("string");
|
||||
expect(tz.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
interface TimeCellProps {
|
||||
utcTime: string;
|
||||
}
|
||||
|
||||
const getLocalTime = (utcTime: string): string => {
|
||||
try {
|
||||
const date = new Date(utcTime);
|
||||
return date
|
||||
.toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.replace(",", "");
|
||||
} catch (e) {
|
||||
return "Error converting time";
|
||||
}
|
||||
};
|
||||
|
||||
export const TimeCell: React.FC<TimeCellProps> = ({ utcTime }) => {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
width: "180px",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
{getLocalTime(utcTime)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const getTimeZone = (): string => {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
};
|
||||
61
ui/litellm-dashboard/src/utils/licenseUtils.test.ts
Normal file
61
ui/litellm-dashboard/src/utils/licenseUtils.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { type LicenseExpiryTier, formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "./licenseUtils";
|
||||
|
||||
const NOW = new Date("2026-07-08T00:00:00Z");
|
||||
|
||||
describe("getDaysUntilExpiration", () => {
|
||||
it("returns null for a null expiration", () => {
|
||||
expect(getDaysUntilExpiration(null, NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an unparseable date", () => {
|
||||
expect(getDaysUntilExpiration("not-a-date", NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 0 for an expiration on the current UTC day", () => {
|
||||
expect(getDaysUntilExpiration("2026-07-08", NOW)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns a positive count for future dates", () => {
|
||||
expect(getDaysUntilExpiration("2026-07-15", NOW)).toBe(7);
|
||||
expect(getDaysUntilExpiration("2026-08-07", NOW)).toBe(30);
|
||||
});
|
||||
|
||||
it("returns a negative count for a past date", () => {
|
||||
expect(getDaysUntilExpiration("2026-07-07", NOW)).toBe(-1);
|
||||
});
|
||||
|
||||
it("is timezone-independent within a UTC day", () => {
|
||||
expect(getDaysUntilExpiration("2026-08-07", new Date("2026-07-08T00:00:01Z"))).toBe(30);
|
||||
expect(getDaysUntilExpiration("2026-08-07", new Date("2026-07-08T23:59:59Z"))).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLicenseExpiryTier", () => {
|
||||
const cases: Array<[string | null, LicenseExpiryTier]> = [
|
||||
[null, "none"],
|
||||
["not-a-date", "none"],
|
||||
["2026-08-08", "none"],
|
||||
["2026-08-07", "warning"],
|
||||
["2026-07-16", "warning"],
|
||||
["2026-07-15", "critical"],
|
||||
["2026-07-09", "critical"],
|
||||
["2026-07-08", "critical"],
|
||||
["2026-07-07", "expired"],
|
||||
["2026-01-01", "expired"],
|
||||
];
|
||||
|
||||
it.each(cases)("classifies %s as %s", (date, expected) => {
|
||||
expect(getLicenseExpiryTier(date, NOW)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatExpiryDate", () => {
|
||||
it("formats an ISO date as a human-readable UTC date", () => {
|
||||
expect(formatExpiryDate("2026-07-31")).toBe("Jul 31, 2026");
|
||||
});
|
||||
|
||||
it("returns the input unchanged when unparseable", () => {
|
||||
expect(formatExpiryDate("bogus")).toBe("bogus");
|
||||
});
|
||||
});
|
||||
51
ui/litellm-dashboard/src/utils/licenseUtils.ts
Normal file
51
ui/litellm-dashboard/src/utils/licenseUtils.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export type LicenseExpiryTier = "none" | "warning" | "critical" | "expired";
|
||||
|
||||
export const LICENSE_EXPIRY_WARNING_DAYS = 30;
|
||||
export const LICENSE_EXPIRY_CRITICAL_DAYS = 7;
|
||||
|
||||
export const getDaysUntilExpiration = (expirationDate: string | null, now: Date = new Date()): number | null => {
|
||||
if (!expirationDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiration = new Date(`${expirationDate}T00:00:00Z`);
|
||||
if (Number.isNaN(expiration.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nowUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const diffMs = expiration.getTime() - nowUtcMidnight;
|
||||
return Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
export const getLicenseExpiryTier = (expirationDate: string | null, now: Date = new Date()): LicenseExpiryTier => {
|
||||
const days = getDaysUntilExpiration(expirationDate, now);
|
||||
if (days === null) {
|
||||
return "none";
|
||||
}
|
||||
if (days < 0) {
|
||||
return "expired";
|
||||
}
|
||||
if (days <= LICENSE_EXPIRY_CRITICAL_DAYS) {
|
||||
return "critical";
|
||||
}
|
||||
if (days <= LICENSE_EXPIRY_WARNING_DAYS) {
|
||||
return "warning";
|
||||
}
|
||||
return "none";
|
||||
};
|
||||
|
||||
const EXPIRY_DATE_FORMAT: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
};
|
||||
|
||||
export const formatExpiryDate = (expirationDate: string): string => {
|
||||
const date = new Date(`${expirationDate}T00:00:00Z`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return expirationDate;
|
||||
}
|
||||
return date.toLocaleDateString("en-US", EXPIRY_DATE_FORMAT);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue