refactor(ui): migrate usage tables to shared DataTable (#36707)

* test(ui): decouple usage table test from antd

* refactor(ui): migrate usage tables to shared DataTable

* test(ui): preserve data utility exports in usage tests
This commit is contained in:
yuneng-jiang 2026-08-13 09:20:45 -07:00 committed by GitHub
parent 09889e1986
commit bfa34e2b10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 298 additions and 712 deletions

View file

@ -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

View file

@ -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(<EndpointUsageTable endpointData={mockEndpointData} />);
renderWithProviders(<EndpointUsageTable endpointData={mockEndpointData} />);
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();
});
});

View file

@ -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<EndpointUsageTableProps> = ({ endpointData })
successRate: calculateSuccessRate(data.metrics.successful_requests, data.metrics.api_requests),
}));
const columns: ColumnsType<EndpointRow> = [
const columns: ColumnDef<EndpointRow>[] = [
{
title: "Endpoint",
dataIndex: "endpoint",
key: "endpoint",
render: (text: string) => <span className="font-medium">{text}</span>,
header: "Endpoint",
accessorKey: "endpoint",
cell: ({ row }) => <span className="font-medium">{row.original.endpoint}</span>,
},
{
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<EndpointUsageTableProps> = ({ 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 (
<span
@ -103,20 +105,28 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ 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) => <MoneyCell value={value} decimals={2} />,
header: "Spend",
accessorKey: "spend",
meta: { numeric: true },
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
},
];
return <Table columns={columns} dataSource={dataSource} pagination={false} />;
return (
<DataTable
columns={columns}
data={dataSource}
getRowId={(row) => row.key}
noDataMessage="No endpoint usage data"
size="compact"
/>
);
};
export default EndpointUsageTable;

View file

@ -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<EntityUsageProps> = ({
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<ColumnDef<EntityMetricWithMetadata>[]>(
() => [
{
header: capitalizedEntityLabel,
accessorKey: "metadata.alias",
cell: ({ row }) => row.original.metadata.alias,
},
{
header: "Spend",
accessorKey: "metrics.spend",
meta: { numeric: true },
cell: ({ row }) => <MoneyCell value={row.original.metrics.spend} decimals={4} />,
},
{
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<ColumnDef<ProviderSpendRow>[]>(
() => [
{
header: "Provider",
accessorKey: "provider",
cell: ({ row }) => (
<div className="flex items-center space-x-2">
{row.original.provider && <Logo provider={row.original.provider} className="size-4" />}
<span>{row.original.provider}</span>
</div>
),
},
{
header: "Spend",
accessorKey: "spend",
meta: { numeric: true },
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
},
{
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 ? <DownOutlined className={chev} /> : <RightOutlined className={chev} />;
@ -430,38 +501,14 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
/>
</Col>
<Col numColSpan={1}>
<div className="h-52 overflow-y-auto">
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{capitalizedEntityLabel}</TableHeaderCell>
<TableHeaderCell>Spend</TableHeaderCell>
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
<TableHeaderCell>Tokens</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{getEntityBreakdown()
.filter((entity) => entity.metrics.spend > 0)
.map((entity) => (
<TableRow key={entity.metadata.id}>
<TableCell>{entity.metadata.alias}</TableCell>
<TableCell>
<MoneyCell value={entity.metrics.spend} decimals={4} />
</TableCell>
<TableCell className="text-green-600">
{entity.metrics.successful_requests.toLocaleString()}
</TableCell>
<TableCell className="text-red-600">
{entity.metrics.failed_requests.toLocaleString()}
</TableCell>
<TableCell>{entity.metrics.total_tokens.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DataTable
columns={entityBreakdownColumns}
data={getEntityBreakdown().filter((entity) => entity.metrics.spend > 0)}
getRowId={(row) => row.metadata.id}
maxBodyHeight={208}
noDataMessage={`No ${entityType} spend data`}
size="compact"
/>
</Col>
</Grid>
</div>
@ -519,7 +566,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
<Col numColSpan={1}>
<DonutChart
className="mt-4 h-40"
data={getProviderSpend(spendData.results)}
data={providerSpend}
index="provider"
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
@ -530,37 +577,13 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
/>
</Col>
<Col numColSpan={1}>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Provider</TableHeaderCell>
<TableHeaderCell>Spend</TableHeaderCell>
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
<TableHeaderCell>Tokens</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{getProviderSpend(spendData.results).map((provider) => (
<TableRow key={provider.provider}>
<TableCell>
<div className="flex items-center space-x-2">
{provider.provider && <Logo provider={provider.provider} className="w-4 h-4" />}
<span>{provider.provider}</span>
</div>
</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>
</TableRow>
))}
</TableBody>
</Table>
<DataTable
columns={providerSpendColumns}
data={providerSpend}
getRowId={(row) => row.provider}
noDataMessage="No provider usage data"
size="compact"
/>
</Col>
</Grid>
</div>

View file

@ -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<ProviderSpendData>[] = [
{
header: "Provider",
accessorKey: "provider",
cell: ({ row }) => (
<div className="flex items-center space-x-2">
{row.original.provider && <ProviderLogo provider={row.original.provider} className="size-4" />}
<span>{row.original.provider}</span>
</div>
),
},
{
header: "Spend",
accessorKey: "spend",
meta: { numeric: true },
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
},
{
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<SpendByProviderProps> = ({ loading, isDateChanging, providerSpend }) => {
const [includeZeroSpend, setIncludeZeroSpend] = useState(false);
const [includeUnknown, setIncludeUnknown] = useState(false);
@ -94,35 +121,13 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
/>
</Col>
<Col numColSpan={1}>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Provider</TableHeaderCell>
<TableHeaderCell>Spend</TableHeaderCell>
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
<TableHeaderCell>Tokens</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{filteredProviderSpend.map((provider) => (
<TableRow key={provider.provider}>
<TableCell>
<div className="flex items-center space-x-2">
{provider.provider && <ProviderLogo provider={provider.provider} className="w-4 h-4" />}
<span>{provider.provider}</span>
</div>
</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>
</TableRow>
))}
</TableBody>
</Table>
<DataTable
columns={columns}
data={filteredProviderSpend}
getRowId={(row) => row.provider}
noDataMessage="No provider usage data"
size="compact"
/>
</Col>
</Grid>
)}

View file

@ -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
/>
</div>
) : (
<div className="border rounded-lg overflow-hidden max-h-[600px] overflow-y-auto">
<DataTable columns={columns} data={processedTopModels} isLoading={false} />
</div>
<DataTable columns={columns} data={processedTopModels} isLoading={false} maxBodyHeight={600} size="compact" />
)}
</>
);

View file

@ -6,6 +6,15 @@ export type ExtendedDailyData = DailyData & {
export type ModelBreakdownKey = "models" | "model_groups";
export interface ProviderSpendRow extends Record<string, unknown> {
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<string, ProviderSpendRow> = {};
results.forEach((day) => {
Object.entries(day.breakdown.providers || {}).forEach(([provider, metrics]) => {
if (!providerSpend[provider]) {

View file

@ -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<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
/>
</div>
) : (
<div className="border rounded-lg overflow-hidden max-h-[600px] overflow-y-auto">
<DataTable columns={columns} data={topKeys} isLoading={false} />
</div>
<DataTable columns={columns} data={topKeys} isLoading={false} maxBodyHeight={600} size="compact" />
)}
{isModalOpen && selectedKey && keyData && (

View file

@ -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<TopModelData> = [
const columns: ColumnDef<TopModelData>[] = [
{
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) => <MoneyCell value={value} decimals={2} />,
header: "Spend (USD)",
accessorKey: "spend",
meta: { numeric: true },
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
},
{
title: "Successful",
dataIndex: "successful_requests",
key: "successful_requests",
render: (value) => <span className="text-green-600">{value?.toLocaleString() || 0}</span>,
header: "Successful",
accessorKey: "successful_requests",
meta: { numeric: true },
cell: ({ row }) => (
<span className="text-green-600">{row.original.successful_requests?.toLocaleString() || 0}</span>
),
},
{
title: "Failed",
dataIndex: "failed_requests",
key: "failed_requests",
render: (value) => <span className="text-red-600">{value?.toLocaleString() || 0}</span>,
header: "Failed",
accessorKey: "failed_requests",
meta: { numeric: true },
cell: ({ row }) => <span className="text-red-600">{row.original.failed_requests?.toLocaleString() || 0}</span>,
},
{
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<KeyModelUsageViewProps> = ({ topModels }) => {
/>
</div>
) : (
<Table
<DataTable
columns={columns}
dataSource={topModels}
rowKey="model"
size="small"
pagination={false}
scroll={topModels.length > 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"
/>
)}
</CardContent>

View file

@ -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<typeof import("@/utils/dataUtils")>();
return {
...actual,
formatNumberWithCommas: (value: number, decimals?: number) => value.toFixed(decimals || 0),
};
});
vi.mock("@/utils/teamUtils", () => ({
resolveTeamAliasFromTeamID: (teamID: string, teams: any[]) => {

View file

@ -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<PerUserUsageProps> = ({ accessToken, selectedTags,
}
};
const columns: ColumnDef<PerUserMetrics>[] = [
{
header: "User ID",
accessorKey: "user_id",
cell: ({ row }) => <span className="font-medium">{row.original.user_id}</span>,
},
{
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 (
<div className="mb-6">
<Title>Per User Usage</Title>
@ -103,46 +131,13 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
<TabPanels>
{/* Tab 1: Existing User Details Table */}
<TabPanel>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>User ID</TableHeaderCell>
<TableHeaderCell>User Email</TableHeaderCell>
<TableHeaderCell>User Agent</TableHeaderCell>
<TableHeaderCell className="text-right">Success Generations</TableHeaderCell>
<TableHeaderCell className="text-right">Total Tokens</TableHeaderCell>
<TableHeaderCell className="text-right">Failed Requests</TableHeaderCell>
<TableHeaderCell className="text-right">Total Cost</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{perUserData.results.slice(0, 10).map((item: PerUserMetrics, index: number) => (
<TableRow key={index}>
<TableCell>
<Text className="font-medium">{item.user_id}</Text>
</TableCell>
<TableCell>
<Text>{item.user_email || "N/A"}</Text>
</TableCell>
<TableCell>
<Text>{item.user_agent || "Unknown"}</Text>
</TableCell>
<TableCell className="text-right">
<Text>{formatAbbreviatedNumber(item.successful_requests)}</Text>
</TableCell>
<TableCell className="text-right">
<Text>{formatAbbreviatedNumber(item.total_tokens)}</Text>
</TableCell>
<TableCell className="text-right">
<Text>{formatAbbreviatedNumber(item.failed_requests)}</Text>
</TableCell>
<TableCell className="text-right">
<Text>${formatAbbreviatedNumber(item.spend, 4)}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<DataTable
columns={columns}
data={perUserData.results.slice(0, 10)}
getRowId={(row) => row.user_id}
noDataMessage="No per-user usage data"
size="compact"
/>
{perUserData.results.length > 10 && (
<div className="mt-4 flex justify-between items-center">

View file

@ -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<Row>[] = [
{ header: "A", accessorKey: "a", size: 120 },
{ header: "B", accessorKey: "b", size: 80 },
];
const unsizedColumns: ColumnDef<Row>[] = [
{ header: "A", accessorKey: "a" },
{ header: "B", accessorKey: "b" },
];
const expanderColumn: ColumnDef<Row> = {
id: "expander",
header: () => null,
cell: ({ row }) =>
row.getCanExpand() ? (
<button
onClick={row.getToggleExpandedHandler()}
aria-label={`${row.getIsExpanded() ? "collapse" : "expand"} ${row.original.request_id}`}
>
{row.getIsExpanded() ? "collapse" : "expand"}
</button>
) : null,
};
describe("DataTable column sizing", () => {
it("min-widths the table to the column total and sizes every cell when columns declare sizes", () => {
render(<DataTable data={data} columns={sizedColumns} />);
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(<DataTable data={data} columns={unsizedColumns} />);
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(<DataTable data={data} columns={unsizedColumns} isLoading loadingMessage="Fetching things" />);
expect(screen.getByText("Fetching things")).toBeInTheDocument();
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
});
it("shows the no-data message when there are no rows", () => {
render(<DataTable data={[]} columns={unsizedColumns} noDataMessage="Nothing here" />);
expect(screen.getByText("Nothing here")).toBeInTheDocument();
});
it("falls back to generic loading and empty defaults", () => {
const { rerender } = render(<DataTable data={data} columns={unsizedColumns} isLoading />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
rerender(<DataTable data={[]} columns={unsizedColumns} />);
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(<DataTable data={data} columns={unsizedColumns} isLoading />);
expect(screen.getByText("Loading...").closest("tr")).toHaveClass("hover:bg-transparent");
rerender(<DataTable data={[]} columns={unsizedColumns} />);
expect(screen.getByText("No results").closest("tr")).toHaveClass("hover:bg-transparent");
rerender(
<DataTable
data={data}
columns={[expanderColumn, ...unsizedColumns]}
getRowCanExpand={() => true}
renderSubComponent={({ row }) => <div>details for {row.original.request_id}</div>}
/>,
);
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<Row>[] = [
{ header: "A", accessorKey: "a" },
{ header: "B", cell: ({ row }) => <span>custom:{row.original.b}</span> },
];
render(<DataTable data={data} columns={columns} />);
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(<DataTable data={data} columns={unsizedColumns} />);
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<Row>[] = [
{ header: "A", accessorKey: "a" },
{ header: "B", accessorKey: "b", meta: { numeric: true } },
];
render(<DataTable data={data} columns={columns} />);
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(<DataTable data={data} columns={unsizedColumns} onRowClick={onRowClick} />);
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(
<DataTable
data={rows}
columns={[expanderColumn, ...unsizedColumns]}
getRowCanExpand={() => true}
renderSubComponent={({ row }) => <div>details for {row.original.request_id}</div>}
/>,
);
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(
<DataTable
data={rows}
columns={[expanderColumn, ...unsizedColumns]}
getRowId={(row) => row.request_id}
getRowCanExpand={() => true}
renderSubComponent={({ row }) => <div>details for {row.original.request_id}</div>}
/>,
);
await user.click(screen.getByRole("button", { name: "expand r1" }));
expect(screen.getByText("details for r1")).toBeInTheDocument();
rerender(
<DataTable
data={[...rows].reverse()}
columns={[expanderColumn, ...unsizedColumns]}
getRowId={(row) => row.request_id}
getRowCanExpand={() => true}
renderSubComponent={({ row }) => <div>details for {row.original.request_id}</div>}
/>,
);
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(
<DataTable
data={rows}
columns={[expanderColumn, ...unsizedColumns]}
renderSubComponent={({ row }) => <div>details for {row.original.request_id}</div>}
/>,
);
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(<DataTable data={rows} columns={unsizedColumns} />);
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(<DataTable data={rows} columns={unsizedColumns} enableSorting />);
await user.click(screen.getByText("A"));
expect(firstColumnValues()).toEqual(["alpha", "bravo", "charlie"]);
await user.click(screen.getByText("A"));
expect(firstColumnValues()).toEqual(["charlie", "bravo", "alpha"]);
});
});

View file

@ -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<TData extends RowData, TValue> {
numeric?: boolean;
}
}
interface DataTableProps<TData, TValue> {
data: TData[];
columns: ColumnDef<TData, TValue>[];
getRowId?: (row: TData, index: number) => string;
onRowClick?: (row: TData) => void;
/** Renders inside a single colspan cell */
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
getRowCanExpand?: (row: Row<TData>) => 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<TData, TValue>({
data = [],
columns,
getRowId,
onRowClick,
renderSubComponent,
getRowCanExpand,
isLoading = false,
loadingMessage = "Loading...",
noDataMessage = "No results",
enableSorting = false,
}: DataTableProps<TData, TValue>) {
const supportsExpansion = !!renderSubComponent && !!getRowCanExpand;
const hasExplicitColumnSizes = columns.some((column) => column.size !== undefined);
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable<TData>({
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 (
<div className="rounded-lg custom-border overflow-hidden w-full max-w-full box-border">
<Table className={tableClassName} style={tableStyle}>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
{headerGroup.headers.map((header) => {
const canSort = enableSorting && header.column.getCanSort();
const isSorted = header.column.getIsSorted();
const numeric = header.column.columnDef.meta?.numeric;
return (
<TableHead
key={header.id}
className={`py-1 h-8 text-xs font-medium text-muted-foreground first:pl-4 last:pr-4 ${
canSort ? "cursor-pointer select-none hover:bg-muted" : ""
}`}
style={hasExplicitColumnSizes ? { width: header.getSize() } : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
>
{header.isPlaceholder ? null : (
<div className={`flex items-center gap-1 ${numeric ? "justify-end" : ""}`}>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort && (
<span className="text-muted-foreground">
{isSorted === "asc" ? "↑" : isSorted === "desc" ? "↓" : "⇅"}
</span>
)}
</div>
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-muted-foreground">
<p>{loadingMessage}</p>
</div>
</TableCell>
</TableRow>
) : table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<Fragment key={row.id}>
<TableRow
className={`h-8 ${onRowClick ? "cursor-pointer" : ""}`}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap first:pl-4 last:pr-4 ${
cell.column.columnDef.meta?.numeric ? "text-right tabular-nums" : ""
}`}
style={hasExplicitColumnSizes ? { width: cell.column.getSize() } : undefined}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{supportsExpansion && row.getIsExpanded() && renderSubComponent && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={row.getVisibleCells().length} className="p-0">
<div className="w-full max-w-full overflow-hidden box-border">{renderSubComponent({ row })}</div>
</TableCell>
</TableRow>
)}
</Fragment>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="h-24 text-center align-middle">
<p className="text-sm text-muted-foreground">{noDataMessage}</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}