diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 590aeb3507e..ca6364b005e 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1660,7 +1660,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 } }, "src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx": { @@ -2270,11 +2270,6 @@ "count": 1 } }, - "src/components/UsagePage/components/KeyModelUsageView.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "src/components/UsagePage/utils/value_formatters.tsx": { "local/filename-pascal-case": { "count": 1 @@ -3928,14 +3923,6 @@ "count": 1 } }, - "src/components/view_logs/table.tsx": { - "local/filename-pascal-case": { - "count": 1 - }, - "no-nested-ternary": { - "count": 2 - } - }, "src/components/view_model/model_name_display.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.test.tsx index 63c606eb00e..62ca89b3b88 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.test.tsx @@ -1,36 +1,7 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen } from "@/../tests/test-utils"; +import { describe, expect, it } from "vitest"; import EndpointUsageTable from "./EndpointUsageTable"; -vi.mock("antd", async () => { - const React = await import("react"); - - function Table({ columns, dataSource }: any) { - return React.createElement( - "div", - { "data-testid": "antd-table" }, - columns?.map((col: any) => - React.createElement("div", { key: col.key, "data-testid": `column-${col.key}` }, col.title), - ), - dataSource?.map((row: any) => - React.createElement( - "div", - { key: row.key, "data-testid": `row-${row.key}` }, - React.createElement("div", null, row.endpoint), - ), - ), - ); - } - (Table as any).displayName = "Table"; - - function Progress({ percent }: any) { - return React.createElement("div", { "data-testid": "antd-progress", "data-percent": percent }); - } - (Progress as any).displayName = "Progress"; - - return { Table, Progress }; -}); - describe("EndpointUsageTable", () => { it("should render", () => { const mockEndpointData = { @@ -51,14 +22,18 @@ describe("EndpointUsageTable", () => { }, }; - render(); + renderWithProviders(); - expect(screen.getByTestId("antd-table")).toBeInTheDocument(); - expect(screen.getByTestId("column-endpoint")).toBeInTheDocument(); - expect(screen.getByTestId("column-requests")).toBeInTheDocument(); - expect(screen.getByTestId("column-api_requests")).toBeInTheDocument(); - expect(screen.getByTestId("column-successRate")).toBeInTheDocument(); - expect(screen.getByTestId("column-total_tokens")).toBeInTheDocument(); - expect(screen.getByTestId("column-spend")).toBeInTheDocument(); + expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([ + "Endpoint", + "Successful / Failed", + "Total Request", + "Success Rate", + "Total Tokens", + "Spend", + ]); + expect(screen.getByText("endpoint-1")).toBeInTheDocument(); + expect(screen.getByText("95.00%")).toBeInTheDocument(); + expect(screen.getByText("$100.50")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx index 60282b2fa1d..7b6bd57e758 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx @@ -1,6 +1,7 @@ import React from "react"; -import { Table, Progress } from "antd"; -import type { ColumnsType } from "antd/es/table"; +import { Progress } from "antd"; +import type { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; import { MetricWithMetadata } from "@/components/UsagePage/types"; @@ -36,17 +37,17 @@ const EndpointUsageTable: React.FC = ({ endpointData }) successRate: calculateSuccessRate(data.metrics.successful_requests, data.metrics.api_requests), })); - const columns: ColumnsType = [ + const columns: ColumnDef[] = [ { - title: "Endpoint", - dataIndex: "endpoint", - key: "endpoint", - render: (text: string) => {text}, + header: "Endpoint", + accessorKey: "endpoint", + cell: ({ row }) => {row.original.endpoint}, }, { - title: "Successful / Failed", - key: "requests", - render: (_: any, record: EndpointRow) => { + header: "Successful / Failed", + id: "requests", + cell: ({ row }) => { + const record = row.original; const successPercentage = record.api_requests > 0 ? (record.successful_requests / record.api_requests) * 100 : 0; const failurePercentage = record.api_requests > 0 ? (record.failed_requests / record.api_requests) * 100 : 0; @@ -76,16 +77,17 @@ const EndpointUsageTable: React.FC = ({ endpointData }) }, }, { - title: "Total Request", - dataIndex: "api_requests", - key: "api_requests", - render: (value: number) => value.toLocaleString(), + header: "Total Request", + accessorKey: "api_requests", + meta: { numeric: true }, + cell: ({ row }) => row.original.api_requests.toLocaleString(), }, { - title: "Success Rate", - dataIndex: "successRate", - key: "successRate", - render: (value: number) => { + header: "Success Rate", + accessorKey: "successRate", + meta: { numeric: true }, + cell: ({ row }) => { + const value = row.original.successRate; const successRateStr = value.toFixed(2); return ( = ({ endpointData }) }, }, { - title: "Total Tokens", - dataIndex: "total_tokens", - key: "total_tokens", - render: (value: number) => value.toLocaleString(), + header: "Total Tokens", + accessorKey: "total_tokens", + meta: { numeric: true }, + cell: ({ row }) => row.original.total_tokens.toLocaleString(), }, { - title: "Spend", - dataIndex: "spend", - key: "spend", - render: (value: number) => , + header: "Spend", + accessorKey: "spend", + meta: { numeric: true }, + cell: ({ row }) => , }, ]; - return ; + return ( + row.key} + noDataMessage="No endpoint usage data" + size="compact" + /> + ); }; export default EndpointUsageTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 956060fc244..3a22eb7fd10 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -1,11 +1,13 @@ import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { BarChart, DonutChart } from "@/components/shared/charts"; +import { DataTable } from "@/components/shared/DataTable"; import { getProviderSpend, getTopAgents, getTopAPIKeys, getTopModels, type ExtendedDailyData, + type ProviderSpendRow, } from "./entityUsageAggregations"; import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary"; import { MoneyCell } from "@/components/shared/table_cells"; @@ -20,12 +22,6 @@ import { Subtitle, Tab, TabGroup, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, TabList, TabPanel, TabPanels, @@ -33,6 +29,7 @@ import { Title, } from "@tremor/react"; import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons"; +import type { ColumnDef } from "@tanstack/react-table"; import { Alert, Button, Tooltip } from "antd"; import React, { type ReactNode, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; @@ -269,6 +266,80 @@ const EntityUsage: React.FC = ({ const capitalizedEntityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1); const showFlatCost = entityType === "team" && hasFlatCost(spendData.metadata); + const providerSpend = useMemo(() => getProviderSpend(spendData.results), [spendData.results]); + const entityBreakdownColumns = useMemo[]>( + () => [ + { + header: capitalizedEntityLabel, + accessorKey: "metadata.alias", + cell: ({ row }) => row.original.metadata.alias, + }, + { + header: "Spend", + accessorKey: "metrics.spend", + meta: { numeric: true }, + cell: ({ row }) => , + }, + { + header: "Successful", + accessorKey: "metrics.successful_requests", + meta: { numeric: true, className: "text-green-600" }, + cell: ({ row }) => row.original.metrics.successful_requests.toLocaleString(), + }, + { + header: "Failed", + accessorKey: "metrics.failed_requests", + meta: { numeric: true, className: "text-red-600" }, + cell: ({ row }) => row.original.metrics.failed_requests.toLocaleString(), + }, + { + header: "Tokens", + accessorKey: "metrics.total_tokens", + meta: { numeric: true }, + cell: ({ row }) => row.original.metrics.total_tokens.toLocaleString(), + }, + ], + [capitalizedEntityLabel], + ); + const providerSpendColumns = useMemo[]>( + () => [ + { + header: "Provider", + accessorKey: "provider", + cell: ({ row }) => ( +
+ {row.original.provider && } + {row.original.provider} +
+ ), + }, + { + header: "Spend", + accessorKey: "spend", + meta: { numeric: true }, + cell: ({ row }) => , + }, + { + header: "Successful", + accessorKey: "successful_requests", + meta: { numeric: true, className: "text-green-600" }, + cell: ({ row }) => row.original.successful_requests.toLocaleString(), + }, + { + header: "Failed", + accessorKey: "failed_requests", + meta: { numeric: true, className: "text-red-600" }, + cell: ({ row }) => row.original.failed_requests.toLocaleString(), + }, + { + header: "Tokens", + accessorKey: "tokens", + meta: { numeric: true }, + cell: ({ row }) => row.original.tokens.toLocaleString(), + }, + ], + [], + ); const chev = "text-gray-400 text-xs"; const expandIcon = showCostBreakdown ? : ; @@ -430,38 +501,14 @@ const EntityUsage: React.FC = ({ />
-
-
- - - {capitalizedEntityLabel} - Spend - Successful - Failed - Tokens - - - - {getEntityBreakdown() - .filter((entity) => entity.metrics.spend > 0) - .map((entity) => ( - - {entity.metadata.alias} - - - - - {entity.metrics.successful_requests.toLocaleString()} - - - {entity.metrics.failed_requests.toLocaleString()} - - {entity.metrics.total_tokens.toLocaleString()} - - ))} - -
- + entity.metrics.spend > 0)} + getRowId={(row) => row.metadata.id} + maxBodyHeight={208} + noDataMessage={`No ${entityType} spend data`} + size="compact" + /> @@ -519,7 +566,7 @@ const EntityUsage: React.FC = ({ `$${formatNumberWithCommas(value, 2)}`} @@ -530,37 +577,13 @@ const EntityUsage: React.FC = ({ /> - - - - Provider - Spend - Successful - Failed - Tokens - - - - {getProviderSpend(spendData.results).map((provider) => ( - - -
- {provider.provider && } - {provider.provider} -
-
- - - - - {provider.successful_requests.toLocaleString()} - - {provider.failed_requests.toLocaleString()} - {provider.tokens.toLocaleString()} -
- ))} -
-
+ row.provider} + noDataMessage="No provider usage data" + size="compact" + /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx index 846a0c6b13e..ac8ae0e67e1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx @@ -1,20 +1,10 @@ import { DonutChart } from "@/components/shared/charts"; +import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { InfoCircleOutlined } from "@ant-design/icons"; -import { - Card, - Col, - Grid, - Switch, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - Title, -} from "@tremor/react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Card, Col, Grid, Switch, Title } from "@tremor/react"; import { Tooltip } from "antd"; import React, { useState } from "react"; import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; @@ -35,6 +25,43 @@ interface SpendByProviderProps { providerSpend: ProviderSpendData[]; } +const columns: ColumnDef[] = [ + { + header: "Provider", + accessorKey: "provider", + cell: ({ row }) => ( +
+ {row.original.provider && } + {row.original.provider} +
+ ), + }, + { + header: "Spend", + accessorKey: "spend", + meta: { numeric: true }, + cell: ({ row }) => , + }, + { + header: "Successful", + accessorKey: "successful_requests", + meta: { numeric: true, className: "text-green-600" }, + cell: ({ row }) => row.original.successful_requests.toLocaleString(), + }, + { + header: "Failed", + accessorKey: "failed_requests", + meta: { numeric: true, className: "text-red-600" }, + cell: ({ row }) => row.original.failed_requests.toLocaleString(), + }, + { + header: "Tokens", + accessorKey: "tokens", + meta: { numeric: true }, + cell: ({ row }) => row.original.tokens.toLocaleString(), + }, +]; + const SpendByProvider: React.FC = ({ loading, isDateChanging, providerSpend }) => { const [includeZeroSpend, setIncludeZeroSpend] = useState(false); const [includeUnknown, setIncludeUnknown] = useState(false); @@ -94,35 +121,13 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi /> - - - - Provider - Spend - Successful - Failed - Tokens - - - - {filteredProviderSpend.map((provider) => ( - - -
- {provider.provider && } - {provider.provider} -
-
- - - - {provider.successful_requests.toLocaleString()} - {provider.failed_requests.toLocaleString()} - {provider.tokens.toLocaleString()} -
- ))} -
-
+ row.provider} + noDataMessage="No provider usage data" + size="compact" + /> )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx index 5b058e41a35..2e2880e8763 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx @@ -1,9 +1,9 @@ import { BarChart } from "@/components/shared/charts"; +import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; import { Segmented } from "antd"; import { useState } from "react"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { DataTable } from "@/components/view_logs/table"; type TopModel = { key: string; @@ -100,9 +100,7 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi /> ) : ( -
- -
+ )} ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts index fc72b66f974..a53b1d2827b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts @@ -6,6 +6,15 @@ export type ExtendedDailyData = DailyData & { export type ModelBreakdownKey = "models" | "model_groups"; +export interface ProviderSpendRow extends Record { + provider: string; + spend: number; + requests: number; + successful_requests: number; + failed_requests: number; + tokens: number; +} + export const getTopModels = ( results: ExtendedDailyData[], modelBreakdownKey: ModelBreakdownKey, @@ -136,8 +145,8 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number .slice(0, topKeysLimit); }; -export const getProviderSpend = (results: ExtendedDailyData[]) => { - const providerSpend: { [key: string]: any } = {}; +export const getProviderSpend = (results: ExtendedDailyData[]): ProviderSpendRow[] => { + const providerSpend: Record = {}; results.forEach((day) => { Object.entries(day.breakdown.providers || {}).forEach(([provider, metrics]) => { if (!providerSpend[provider]) { diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index 1542b313334..2993f549c65 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -1,5 +1,6 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { BarChart } from "@/components/shared/charts"; +import { DataTable } from "@/components/shared/DataTable"; import { IdCell, MoneyCell } from "@/components/shared/table_cells"; import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline"; import { Segmented, Tooltip } from "antd"; @@ -8,7 +9,6 @@ import { formatNumberWithCommas } from "../../../../utils/dataUtils"; import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info"; import { keyInfoV1Call } from "../../../networking"; import KeyInfoView from "../../../templates/key_info_view"; -import { DataTable } from "../../../view_logs/table"; import { TagUsage } from "../../types"; interface TopKeyViewProps { @@ -232,9 +232,7 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals /> ) : ( -
- -
+ )} {isModalOpen && selectedKey && keyData && ( diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx index ed78b7e3dba..1c131151761 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx @@ -1,9 +1,9 @@ import { BarChart } from "@/components/shared/charts"; +import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { Table } from "antd"; -import type { ColumnsType } from "antd/es/table"; +import type { ColumnDef } from "@tanstack/react-table"; import React, { useState } from "react"; import { TopModelData } from "../types"; @@ -12,39 +12,40 @@ interface KeyModelUsageViewProps { } const VISIBLE_ROWS = 5; -// antd Table with size="small" has a row height of ~39px -const ANTD_SMALL_TABLE_ROW_HEIGHT = 39; +const COMPACT_TABLE_HEADER_HEIGHT = 33; +const COMPACT_TABLE_ROW_HEIGHT = 32; -const columns: ColumnsType = [ +const columns: ColumnDef[] = [ { - title: "Model", - dataIndex: "model", - key: "model", - render: (value) => value || "-", + header: "Model", + accessorKey: "model", + cell: ({ row }) => row.original.model || "-", }, { - title: "Spend (USD)", - dataIndex: "spend", - key: "spend", - render: (value) => , + header: "Spend (USD)", + accessorKey: "spend", + meta: { numeric: true }, + cell: ({ row }) => , }, { - title: "Successful", - dataIndex: "successful_requests", - key: "successful_requests", - render: (value) => {value?.toLocaleString() || 0}, + header: "Successful", + accessorKey: "successful_requests", + meta: { numeric: true }, + cell: ({ row }) => ( + {row.original.successful_requests?.toLocaleString() || 0} + ), }, { - title: "Failed", - dataIndex: "failed_requests", - key: "failed_requests", - render: (value) => {value?.toLocaleString() || 0}, + header: "Failed", + accessorKey: "failed_requests", + meta: { numeric: true }, + cell: ({ row }) => {row.original.failed_requests?.toLocaleString() || 0}, }, { - title: "Tokens", - dataIndex: "tokens", - key: "tokens", - render: (value) => value?.toLocaleString() || 0, + header: "Tokens", + accessorKey: "tokens", + meta: { numeric: true }, + cell: ({ row }) => row.original.tokens?.toLocaleString() || 0, }, ]; @@ -93,13 +94,12 @@ const KeyModelUsageView: React.FC = ({ topModels }) => { /> ) : ( - VISIBLE_ROWS ? { y: VISIBLE_ROWS * ANTD_SMALL_TABLE_ROW_HEIGHT } : undefined} + data={topModels} + getRowId={(row) => row.model} + maxBodyHeight={COMPACT_TABLE_HEADER_HEIGHT + VISIBLE_ROWS * COMPACT_TABLE_ROW_HEIGHT} + size="compact" /> )} diff --git a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx index 21d66991bb5..cd37ea42a90 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx @@ -48,11 +48,14 @@ vi.mock("antd", () => { }; }); -vi.mock("@/utils/dataUtils", () => ({ - formatNumberWithCommas: (value: number, decimals?: number) => { - return value.toFixed(decimals || 0); - }, -})); +vi.mock("@/utils/dataUtils", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + formatNumberWithCommas: (value: number, decimals?: number) => value.toFixed(decimals || 0), + }; +}); vi.mock("@/utils/teamUtils", () => ({ resolveTeamAliasFromTeamID: (teamID: string, teams: any[]) => { diff --git a/ui/litellm-dashboard/src/components/per_user_usage.tsx b/ui/litellm-dashboard/src/components/per_user_usage.tsx index e542be8df8f..cef3fa5130b 100644 --- a/ui/litellm-dashboard/src/components/per_user_usage.tsx +++ b/ui/litellm-dashboard/src/components/per_user_usage.tsx @@ -1,22 +1,8 @@ import React, { useState, useEffect } from "react"; -import { - Title, - Subtitle, - Table, - TableHead, - TableRow, - TableHeaderCell, - TableBody, - TableCell, - Text, - Button, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, -} from "@tremor/react"; +import { Title, Subtitle, Text, Button, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; +import type { ColumnDef } from "@tanstack/react-table"; import { BarChart } from "@/components/shared/charts"; +import { DataTable } from "@/components/shared/DataTable"; import { perUserAnalyticsCall } from "./networking"; interface PerUserMetrics { @@ -89,6 +75,48 @@ const PerUserUsage: React.FC = ({ accessToken, selectedTags, } }; + const columns: ColumnDef[] = [ + { + header: "User ID", + accessorKey: "user_id", + cell: ({ row }) => {row.original.user_id}, + }, + { + header: "User Email", + accessorKey: "user_email", + cell: ({ row }) => row.original.user_email || "N/A", + }, + { + header: "User Agent", + accessorKey: "user_agent", + cell: ({ row }) => row.original.user_agent || "Unknown", + }, + { + header: "Success Generations", + accessorKey: "successful_requests", + meta: { numeric: true }, + cell: ({ row }) => formatAbbreviatedNumber(row.original.successful_requests), + }, + { + header: "Total Tokens", + accessorKey: "total_tokens", + meta: { numeric: true }, + cell: ({ row }) => formatAbbreviatedNumber(row.original.total_tokens), + }, + { + header: "Failed Requests", + accessorKey: "failed_requests", + meta: { numeric: true }, + cell: ({ row }) => formatAbbreviatedNumber(row.original.failed_requests), + }, + { + header: "Total Cost", + accessorKey: "spend", + meta: { numeric: true }, + cell: ({ row }) => `$${formatAbbreviatedNumber(row.original.spend, 4)}`, + }, + ]; + return (
Per User Usage @@ -103,46 +131,13 @@ const PerUserUsage: React.FC = ({ accessToken, selectedTags, {/* Tab 1: Existing User Details Table */} -
- - - User ID - User Email - User Agent - Success Generations - Total Tokens - Failed Requests - Total Cost - - - - {perUserData.results.slice(0, 10).map((item: PerUserMetrics, index: number) => ( - - - {item.user_id} - - - {item.user_email || "N/A"} - - - {item.user_agent || "Unknown"} - - - {formatAbbreviatedNumber(item.successful_requests)} - - - {formatAbbreviatedNumber(item.total_tokens)} - - - {formatAbbreviatedNumber(item.failed_requests)} - - - ${formatAbbreviatedNumber(item.spend, 4)} - - - ))} - -
+ row.user_id} + noDataMessage="No per-user usage data" + size="compact" + /> {perUserData.results.length > 10 && (
diff --git a/ui/litellm-dashboard/src/components/view_logs/table.test.tsx b/ui/litellm-dashboard/src/components/view_logs/table.test.tsx deleted file mode 100644 index 9a8469cfeba..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/table.test.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import type { ColumnDef } from "@tanstack/react-table"; -import { render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { DataTable } from "./table"; - -type Row = { request_id: string; a: string; b: string }; - -const data: Row[] = [{ request_id: "r1", a: "alpha", b: "beta" }]; - -const sizedColumns: ColumnDef[] = [ - { header: "A", accessorKey: "a", size: 120 }, - { header: "B", accessorKey: "b", size: 80 }, -]; - -const unsizedColumns: ColumnDef[] = [ - { header: "A", accessorKey: "a" }, - { header: "B", accessorKey: "b" }, -]; - -const expanderColumn: ColumnDef = { - id: "expander", - header: () => null, - cell: ({ row }) => - row.getCanExpand() ? ( - - ) : null, -}; - -describe("DataTable column sizing", () => { - it("min-widths the table to the column total and sizes every cell when columns declare sizes", () => { - render(); - - const table = screen.getByRole("table"); - expect(table.style.minWidth).toBe("200px"); - expect(table.style.width).toBe(""); - - const headers = screen.getAllByRole("columnheader"); - expect(headers.map((h) => h.style.width)).toEqual(["120px", "80px"]); - - const cells = screen.getAllByRole("cell"); - expect(cells.map((c) => c.style.width)).toEqual(["120px", "80px"]); - }); - - it("leaves cells unsized and keeps the fluid table when no column declares a size", () => { - render(); - - const table = screen.getByRole("table"); - expect(table.style.width).toBe(""); - expect(table.style.minWidth).toBe("400px"); - - for (const cell of [...screen.getAllByRole("columnheader"), ...screen.getAllByRole("cell")]) { - expect(cell.style.width).toBe(""); - } - }); -}); - -describe("DataTable states", () => { - it("shows the loading message instead of rows while loading", () => { - render(); - - expect(screen.getByText("Fetching things")).toBeInTheDocument(); - expect(screen.queryByText("alpha")).not.toBeInTheDocument(); - }); - - it("shows the no-data message when there are no rows", () => { - render(); - - expect(screen.getByText("Nothing here")).toBeInTheDocument(); - }); - - it("falls back to generic loading and empty defaults", () => { - const { rerender } = render(); - expect(screen.getByText("Loading...")).toBeInTheDocument(); - - rerender(); - expect(screen.getByText("No results")).toBeInTheDocument(); - }); - - it("suppresses the primitive's row hover on loading, empty, and expansion placeholder rows", async () => { - const user = userEvent.setup(); - const { rerender } = render(); - expect(screen.getByText("Loading...").closest("tr")).toHaveClass("hover:bg-transparent"); - - rerender(); - expect(screen.getByText("No results").closest("tr")).toHaveClass("hover:bg-transparent"); - - rerender( - true} - renderSubComponent={({ row }) =>
details for {row.original.request_id}
} - />, - ); - await user.click(screen.getByRole("button", { name: "expand r1" })); - expect(screen.getByText("details for r1").closest("tr")).toHaveClass("hover:bg-transparent"); - expect(screen.getByText("alpha").closest("tr")).not.toHaveClass("hover:bg-transparent"); - }); - - it("renders row data through plain TanStack column defs, including custom cell renderers", () => { - const columns: ColumnDef[] = [ - { header: "A", accessorKey: "a" }, - { header: "B", cell: ({ row }) => custom:{row.original.b} }, - ]; - render(); - - expect(screen.getByText("alpha")).toBeInTheDocument(); - expect(screen.getByText("custom:beta")).toBeInTheDocument(); - }); - - it("clips the table to the rounded wrapper so the header band cannot bleed past the corners", () => { - const { container } = render(); - - const wrapper = container.firstElementChild; - expect(wrapper).toHaveClass("rounded-lg", "overflow-hidden"); - }); - - it("right-aligns headers and cells with tabular figures for numeric meta columns", () => { - const columns: ColumnDef[] = [ - { header: "A", accessorKey: "a" }, - { header: "B", accessorKey: "b", meta: { numeric: true } }, - ]; - render(); - - const headers = screen.getAllByRole("columnheader"); - expect(headers[1].querySelector("div")).toHaveClass("justify-end"); - expect(headers[0].querySelector("div")).not.toHaveClass("justify-end"); - - const cells = screen.getAllByRole("cell"); - expect(cells[1]).toHaveClass("text-right", "tabular-nums"); - expect(cells[0]).not.toHaveClass("text-right"); - }); -}); - -describe("DataTable row interaction", () => { - it("fires onRowClick with the row's original data", async () => { - const user = userEvent.setup(); - const onRowClick = vi.fn(); - render(); - - await user.click(screen.getByText("alpha")); - - expect(onRowClick).toHaveBeenCalledExactlyOnceWith(data[0]); - }); -}); - -describe("DataTable expansion", () => { - const rows: Row[] = [ - { request_id: "r1", a: "alpha", b: "beta" }, - { request_id: "r2", a: "gamma", b: "delta" }, - ]; - - it("toggles the sub-component in a full-width cell (colspan path)", async () => { - const user = userEvent.setup(); - render( - true} - renderSubComponent={({ row }) =>
details for {row.original.request_id}
} - />, - ); - - expect(screen.queryByText("details for r1")).not.toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "expand r1" })); - const details = screen.getByText("details for r1"); - expect(details).toBeInTheDocument(); - expect(screen.queryByText("details for r2")).not.toBeInTheDocument(); - - const detailCell = details.closest("td"); - expect(detailCell).toHaveAttribute("colspan", "3"); - - await user.click(screen.getByRole("button", { name: "collapse r1" })); - expect(screen.queryByText("details for r1")).not.toBeInTheDocument(); - }); - - it("keeps expansion attached to the same row through data reorders when getRowId is injected", async () => { - const user = userEvent.setup(); - const { rerender } = render( - row.request_id} - getRowCanExpand={() => true} - renderSubComponent={({ row }) =>
details for {row.original.request_id}
} - />, - ); - - await user.click(screen.getByRole("button", { name: "expand r1" })); - expect(screen.getByText("details for r1")).toBeInTheDocument(); - - rerender( - row.request_id} - getRowCanExpand={() => true} - renderSubComponent={({ row }) =>
details for {row.original.request_id}
} - />, - ); - - expect(screen.getByText("details for r1")).toBeInTheDocument(); - expect(screen.queryByText("details for r2")).not.toBeInTheDocument(); - }); - - it("does not expand rows when getRowCanExpand is missing even if a renderer is provided", () => { - render( -
details for {row.original.request_id}
} - />, - ); - - expect(screen.queryByRole("button", { name: "expand r1" })).not.toBeInTheDocument(); - }); -}); - -describe("DataTable sorting", () => { - const rows: Row[] = [ - { request_id: "r1", a: "bravo", b: "2" }, - { request_id: "r2", a: "alpha", b: "1" }, - { request_id: "r3", a: "charlie", b: "3" }, - ]; - - const firstColumnValues = () => - screen - .getAllByRole("row") - .slice(1) - .map((row) => within(row).getAllByRole("cell")[0].textContent); - - it("leaves row order untouched when sorting is disabled", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText("A")); - - expect(firstColumnValues()).toEqual(["bravo", "alpha", "charlie"]); - }); - - it("sorts ascending then descending on header clicks when enabled", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText("A")); - expect(firstColumnValues()).toEqual(["alpha", "bravo", "charlie"]); - - await user.click(screen.getByText("A")); - expect(firstColumnValues()).toEqual(["charlie", "bravo", "alpha"]); - }); -}); diff --git a/ui/litellm-dashboard/src/components/view_logs/table.tsx b/ui/litellm-dashboard/src/components/view_logs/table.tsx deleted file mode 100644 index d522478e370..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/table.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { Fragment, useState } from "react"; -import { - ColumnDef, - RowData, - flexRender, - getCoreRowModel, - getExpandedRowModel, - Row, - useReactTable, - getSortedRowModel, - SortingState, -} from "@tanstack/react-table"; - -import { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from "@/components/ui/table"; - -declare module "@tanstack/react-table" { - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- declaration merging requires the type parameters to match the upstream ColumnMeta signature exactly (TS2428) - interface ColumnMeta { - numeric?: boolean; - } -} - -interface DataTableProps { - data: TData[]; - columns: ColumnDef[]; - getRowId?: (row: TData, index: number) => string; - onRowClick?: (row: TData) => void; - /** Renders inside a single colspan cell */ - renderSubComponent?: (props: { row: Row }) => React.ReactElement; - getRowCanExpand?: (row: Row) => boolean; - isLoading?: boolean; - loadingMessage?: string; - noDataMessage?: string; - /** Enable client-side column sorting (defaults to false to avoid conflicts with server-side sorting) */ - enableSorting?: boolean; -} - -export function DataTable({ - data = [], - columns, - getRowId, - onRowClick, - renderSubComponent, - getRowCanExpand, - isLoading = false, - loadingMessage = "Loading...", - noDataMessage = "No results", - enableSorting = false, -}: DataTableProps) { - const supportsExpansion = !!renderSubComponent && !!getRowCanExpand; - const hasExplicitColumnSizes = columns.some((column) => column.size !== undefined); - const [sorting, setSorting] = useState([]); - - const table = useReactTable({ - data, - columns, - ...(enableSorting && { - state: { - sorting, - }, - onSortingChange: setSorting, - enableSortingRemoval: false, - }), - ...(supportsExpansion && { getRowCanExpand }), - ...(getRowId && { getRowId }), - getCoreRowModel: getCoreRowModel(), - ...(enableSorting && { getSortedRowModel: getSortedRowModel() }), - ...(supportsExpansion && { getExpandedRowModel: getExpandedRowModel() }), - }); - - const tableClassName = hasExplicitColumnSizes ? "table-fixed" : "table-fixed w-full box-border"; - const tableStyle = hasExplicitColumnSizes ? { minWidth: table.getCenterTotalSize() } : { minWidth: "400px" }; - - return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const canSort = enableSorting && header.column.getCanSort(); - const isSorted = header.column.getIsSorted(); - const numeric = header.column.columnDef.meta?.numeric; - - return ( - - {header.isPlaceholder ? null : ( -
- {flexRender(header.column.columnDef.header, header.getContext())} - {canSort && ( - - {isSorted === "asc" ? "↑" : isSorted === "desc" ? "↓" : "⇅"} - - )} -
- )} -
- ); - })} -
- ))} -
- - {isLoading ? ( - - -
-

{loadingMessage}

-
-
-
- ) : table.getRowModel().rows.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - onRowClick?.(row.original)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - - {supportsExpansion && row.getIsExpanded() && renderSubComponent && ( - - -
{renderSubComponent({ row })}
-
-
- )} -
- )) - ) : ( - - -

{noDataMessage}

-
-
- )} -
-
-
- ); -}