mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
refactor(ui): consolidate table cells onto a shared table_cells kit (#32393)
* feat(ui): add shared table_cells kit and convert logs columns DateCell, MoneyCell, IdCell and StatusBadge consolidate the duplicated per-table cell implementations behind one component each. The logs page columns are the reference conversion; the dead auditLogColumns export (superseded by audit_logs.tsx) is removed with it * refactor(ui): consolidate table cells onto the shared table_cells kit 106 cell sites across 44 table files converge onto DateCell, MoneyCell, IdCell and StatusBadge, replacing 8 date formats, 6 spend formats, 7 id truncation strategies and 6 status badge styles with one implementation each. Badge now forwards refs so Base UI tooltip triggers composed over it can attach (they previously never opened under React 18). TimeCell is deleted; its two consumers now render DateCell * fix(ui): suppress cost tooltip for zero spend and drop dead getStatusBadge param The logs Cost tooltip showed the raw $0 over a "-" cell for zero or null spend (pre-existing, surfaced by review); the tooltip now only renders when there is a real amount. healthCheckColumns no longer takes the unused getStatusBadge callback and its dead definition is removed * fix(ui): restyle StatusBadge as tinted pill matching the prior antd Tag look * fix(ui): keep StatusBadge fully rounded like the other kit pills
This commit is contained in:
parent
5b8bf5357a
commit
bd23c44cb1
75 changed files with 918 additions and 1139 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"@typescript-eslint/no-explicit-any": 1988,
|
||||
"@typescript-eslint/no-explicit-any": 1982,
|
||||
"complexity": 128,
|
||||
"local/no-large-inline-object-arg": 513,
|
||||
"local/no-large-inline-object-arg": 512,
|
||||
"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 && (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ?? "-"}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue