mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge pull request #36834 from BerriAI/litellm_/compassionate-jones-9c0d5c
refactor(ui): migrate usage to shadcn
This commit is contained in:
commit
ce3a56dce6
25 changed files with 1848 additions and 1775 deletions
|
|
@ -1603,25 +1603,12 @@
|
|||
"src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -1630,9 +1617,6 @@
|
|||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -1648,7 +1632,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
|
|
@ -1657,14 +1641,6 @@
|
|||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": {
|
||||
"react-hooks/refs": {
|
||||
"count": 1
|
||||
|
|
@ -1927,29 +1903,14 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/EntityUsageExport/EntityUsageExportModal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/EntityUsageExport/ExportFormatSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/EntityUsageExport/ExportSummary.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/EntityUsageExport/ExportTypeSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/EntityUsageExport/UsageExportHeader.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/EntityUsageExport/types.ts": {
|
||||
|
|
@ -2234,9 +2195,6 @@
|
|||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/add_model/AdaptiveRoutingConfig.tsx": {
|
||||
|
|
@ -2721,9 +2679,6 @@
|
|||
"src/components/common_components/team_multi_select.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/user_search_modal.tsx": {
|
||||
|
|
@ -3090,9 +3045,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -3625,7 +3577,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { Progress } from "antd";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { MetricWithMetadata } from "@/components/UsagePage/types";
|
||||
|
|
@ -53,19 +53,14 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
|
|||
const failurePercentage = record.api_requests > 0 ? (record.failed_requests / record.api_requests) * 100 : 0;
|
||||
const totalPercentage = successPercentage + failurePercentage;
|
||||
|
||||
const strokeColorConfig: Record<string, string> = {
|
||||
"0%": "#22c55e",
|
||||
};
|
||||
if (successPercentage > 0 && successPercentage < 100) {
|
||||
strokeColorConfig[`${successPercentage}%`] = "#22c55e";
|
||||
strokeColorConfig[`${successPercentage + 0.01}%`] = "#ef4444";
|
||||
}
|
||||
strokeColorConfig["100%"] = failurePercentage > 0 ? "#ef4444" : "#22c55e";
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-1 relative">
|
||||
<Progress percent={totalPercentage} size="small" strokeColor={strokeColorConfig} showInfo={false} />
|
||||
<Meter value={successPercentage} max={totalPercentage || 100} aria-label="Successful requests">
|
||||
<MeterTrack className={failurePercentage > 0 ? "bg-red-500" : undefined}>
|
||||
<MeterIndicator className="bg-green-500" />
|
||||
</MeterTrack>
|
||||
</Meter>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm min-w-[100px]">
|
||||
<span className="text-green-600 font-medium">{record.successful_requests.toLocaleString()}</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as networking from "@/components/networking";
|
||||
import EntityUsage from "./EntityUsage";
|
||||
|
|
@ -500,18 +500,33 @@ describe("EntityUsage", () => {
|
|||
expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const selectedPanels = (container: HTMLElement) =>
|
||||
Array.from(container.querySelectorAll("div.tremor-TabPanel-root")).filter(
|
||||
(panel) => panel.getAttribute("aria-selected") === "true",
|
||||
);
|
||||
// An inactive tab panel is marked aria-selected="false" by one tab library and hidden by the
|
||||
// other, so treat either as "not on screen" and the assertion holds whichever one is rendering.
|
||||
const isShowing = (element: HTMLElement): boolean => {
|
||||
for (let node: HTMLElement | null = element; node; node = node.parentElement) {
|
||||
if (node.hasAttribute("hidden")) return false;
|
||||
if (node.getAttribute("aria-selected") === "false") return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
it.each([
|
||||
const showingCount = (marker: string): number => screen.queryAllByText(marker).filter(isShowing).length;
|
||||
|
||||
const showingText = (text: string): HTMLElement => {
|
||||
const [element] = screen.getAllByText(text).filter(isShowing);
|
||||
expect(element).toBeDefined();
|
||||
return element;
|
||||
};
|
||||
|
||||
const NON_TEAM_PANELS: [string, string][] = [
|
||||
["Cost", "Tag Spend Overview"],
|
||||
["Model Activity", "metrics-source:model_groups"],
|
||||
["Key Activity", "metrics-source:api_keys"],
|
||||
["Endpoint Activity", "Endpoint Usage Panel"],
|
||||
])("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => {
|
||||
const { container } = render(<EntityUsage {...defaultProps} />);
|
||||
];
|
||||
|
||||
it.each(NON_TEAM_PANELS)("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => {
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -521,19 +536,23 @@ describe("EntityUsage", () => {
|
|||
fireEvent.click(screen.getByText(tabLabel));
|
||||
});
|
||||
|
||||
const selected = selectedPanels(container);
|
||||
expect(selected).toHaveLength(1);
|
||||
expect(selected[0].textContent).toContain(marker);
|
||||
expect(showingCount(marker)).toBeGreaterThan(0);
|
||||
for (const [otherLabel, otherMarker] of NON_TEAM_PANELS) {
|
||||
if (otherLabel === tabLabel) continue;
|
||||
expect(showingCount(otherMarker)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
const TEAM_PANELS: [string, string][] = [
|
||||
["Cost", "Team Spend Overview"],
|
||||
["Model Activity", "metrics-source:model_groups"],
|
||||
["Agent Activity", "metrics-source:entities"],
|
||||
["Key Activity", "metrics-source:api_keys"],
|
||||
["Endpoint Activity", "Endpoint Usage Panel"],
|
||||
])("shows only the %s panel for the team entity type", async (tabLabel, marker) => {
|
||||
const { container } = render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
];
|
||||
|
||||
it.each(TEAM_PANELS)("shows only the %s panel for the team entity type", async (tabLabel, marker) => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -543,9 +562,11 @@ describe("EntityUsage", () => {
|
|||
fireEvent.click(screen.getByText(tabLabel));
|
||||
});
|
||||
|
||||
const selected = selectedPanels(container);
|
||||
expect(selected).toHaveLength(1);
|
||||
expect(selected[0].textContent).toContain(marker);
|
||||
expect(showingCount(marker)).toBeGreaterThan(0);
|
||||
for (const [otherLabel, otherMarker] of TEAM_PANELS) {
|
||||
if (otherLabel === tabLabel) continue;
|
||||
expect(showingCount(otherMarker)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle empty data gracefully", async () => {
|
||||
|
|
@ -615,20 +636,19 @@ describe("EntityUsage", () => {
|
|||
fireEvent.click(screen.getByText("Model Activity"));
|
||||
});
|
||||
|
||||
const modelActivityPanel = () => selectedPanels(container)[0] as HTMLElement;
|
||||
expect(modelActivityPanel().textContent).toContain("metrics-source:model_groups");
|
||||
expect(showingCount("metrics-source:model_groups")).toBeGreaterThan(0);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(within(modelActivityPanel()).getByText("Litellm Model Name"));
|
||||
fireEvent.click(showingText("Litellm Model Name"));
|
||||
});
|
||||
|
||||
expect(modelActivityPanel().textContent).toContain("metrics-source:models");
|
||||
expect(showingCount("metrics-source:models")).toBeGreaterThan(0);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(within(modelActivityPanel()).getByText("Public Model Name"));
|
||||
fireEvent.click(showingText("Public Model Name"));
|
||||
});
|
||||
|
||||
expect(modelActivityPanel().textContent).toContain("metrics-source:model_groups");
|
||||
expect(showingCount("metrics-source:model_groups")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should display Top Agents title for agent entity type", async () => {
|
||||
|
|
|
|||
|
|
@ -14,23 +14,13 @@ import { MoneyCell } from "@/components/shared/table_cells";
|
|||
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { hasCapability, type Capability } from "@/utils/capabilities";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
DateRangePickerValue,
|
||||
Grid,
|
||||
Subtitle,
|
||||
Tab,
|
||||
TabGroup,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Text,
|
||||
Title,
|
||||
} from "@tremor/react";
|
||||
import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import type { DateRangePickerValue } from "@tremor/react";
|
||||
import { ChevronDown, ChevronRight, ExternalLink, Info, Loader2 } from "lucide-react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Alert, Button, Tooltip } from "antd";
|
||||
import { Alert, AlertDescription } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import React, { type ReactNode, useMemo, useState } from "react";
|
||||
import TeamMultiSelect from "@/components/common_components/team_multi_select";
|
||||
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
|
||||
|
|
@ -341,23 +331,29 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
[],
|
||||
);
|
||||
|
||||
const chev = "text-gray-400 text-xs";
|
||||
const expandIcon = showCostBreakdown ? <DownOutlined className={chev} /> : <RightOutlined className={chev} />;
|
||||
const infoIcon = <InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />;
|
||||
const chev = "size-3 text-gray-400";
|
||||
const expandIcon = showCostBreakdown ? <ChevronDown className={chev} /> : <ChevronRight className={chev} />;
|
||||
|
||||
const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => (
|
||||
<Card
|
||||
<ShadcnCard
|
||||
key={title}
|
||||
className={expandable ? "cursor-pointer hover:bg-gray-50 transition-colors" : undefined}
|
||||
onClick={expandable ? () => setShowCostBreakdown(!showCostBreakdown) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Title>{title}</Title>
|
||||
{tooltip ? <Tooltip title={tooltip}>{infoIcon}</Tooltip> : null}
|
||||
{expandable ? expandIcon : null}
|
||||
</div>
|
||||
<Text className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</Text>
|
||||
</Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-4 text-gray-400 hover:text-gray-600" />} />
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{expandable ? expandIcon : null}
|
||||
</div>
|
||||
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
);
|
||||
|
||||
const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : [];
|
||||
|
|
@ -366,18 +362,18 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models";
|
||||
|
||||
const costPanel = (
|
||||
<Grid numItems={2} className="gap-2 w-full">
|
||||
<Col numColSpan={2}>
|
||||
<Card>
|
||||
<Title>{capitalizedEntityLabel} Spend Overview</Title>
|
||||
<Grid numItems={5} className="gap-4 mt-4">
|
||||
{summaryTiles.map(renderSummaryTile)}
|
||||
</Grid>
|
||||
</Card>
|
||||
</Col>
|
||||
<div className="grid grid-cols-2 gap-2 w-full">
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">{capitalizedEntityLabel} Spend Overview</h3>
|
||||
<div className="grid grid-cols-5 gap-4 mt-4">{summaryTiles.map(renderSummaryTile)}</div>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
{/* Daily Spend Chart */}
|
||||
<Col numColSpan={2}>
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
|
||||
|
|
@ -451,15 +447,15 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
/>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</Col>
|
||||
</div>
|
||||
|
||||
{/* Entity Breakdown Section */}
|
||||
<Col numColSpan={2}>
|
||||
<Card>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Title>Spend Per {capitalizedEntityLabel}</Title>
|
||||
<Subtitle className="text-xs">Showing Top 5 by Spend</Subtitle>
|
||||
<h3 className="text-lg font-medium text-foreground">Spend Per {capitalizedEntityLabel}</h3>
|
||||
<p className="text-xs text-muted-foreground">Showing Top 5 by Spend</p>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<span>Get Started by Tracking cost per {capitalizedEntityLabel} </span>
|
||||
<a
|
||||
|
|
@ -470,8 +466,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<Grid numItems={2} className="gap-6">
|
||||
<Col numColSpan={1}>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<BarChart
|
||||
className="mt-4 h-52"
|
||||
data={getProcessedEntityBreakdownForChart()}
|
||||
|
|
@ -499,8 +495,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
</div>
|
||||
<div>
|
||||
<DataTable
|
||||
columns={entityBreakdownColumns}
|
||||
data={getEntityBreakdown().filter((entity) => entity.metrics.spend > 0)}
|
||||
|
|
@ -509,61 +505,69 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
noDataMessage={`No ${entityType} spend data`}
|
||||
size="compact"
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
{/* Top API Keys */}
|
||||
<Col numColSpan={1}>
|
||||
<Card>
|
||||
<Title>Top Virtual Keys</Title>
|
||||
<TopKeyView
|
||||
topKeys={getTopAPIKeys(spendData.results, topKeysLimit)}
|
||||
teams={null}
|
||||
showTags={entityType === "tag"}
|
||||
topKeysLimit={topKeysLimit}
|
||||
setTopKeysLimit={setTopKeysLimit}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<div>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys</h3>
|
||||
<TopKeyView
|
||||
topKeys={getTopAPIKeys(spendData.results, topKeysLimit)}
|
||||
teams={null}
|
||||
showTags={entityType === "tag"}
|
||||
topKeysLimit={topKeysLimit}
|
||||
setTopKeysLimit={setTopKeysLimit}
|
||||
/>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
{/* Top Models */}
|
||||
<Col numColSpan={1}>
|
||||
<Card>
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>{entityType === "agent" ? "Top Agents" : modelViewTitle}</Title>
|
||||
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
|
||||
</div>
|
||||
<TopModelView
|
||||
topModels={getTopModels(spendData.results, modelBreakdownKey, topModelsLimit)}
|
||||
topModelsLimit={topModelsLimit}
|
||||
setTopModelsLimit={setTopModelsLimit}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<div>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{entityType === "agent" ? "Top Agents" : modelViewTitle}
|
||||
</h3>
|
||||
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
|
||||
</div>
|
||||
<TopModelView
|
||||
topModels={getTopModels(spendData.results, modelBreakdownKey, topModelsLimit)}
|
||||
topModelsLimit={topModelsLimit}
|
||||
setTopModelsLimit={setTopModelsLimit}
|
||||
/>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
{showAgentBreakdown && (
|
||||
<Col numColSpan={2}>
|
||||
<Card>
|
||||
<Title>Top Agents Driving Spend</Title>
|
||||
<TopModelView
|
||||
topModels={getTopAgents(agentSpendData.results, topAgentsLimit)}
|
||||
topModelsLimit={topAgentsLimit}
|
||||
setTopModelsLimit={setTopAgentsLimit}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Top Agents Driving Spend</h3>
|
||||
<TopModelView
|
||||
topModels={getTopAgents(agentSpendData.results, topAgentsLimit)}
|
||||
topModelsLimit={topAgentsLimit}
|
||||
setTopModelsLimit={setTopAgentsLimit}
|
||||
/>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spend by Provider */}
|
||||
<Col numColSpan={2}>
|
||||
<Card>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Title>Provider Usage</Title>
|
||||
<Grid numItems={2}>
|
||||
<Col numColSpan={1}>
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent className="flex flex-col space-y-4">
|
||||
<h3 className="text-lg font-medium text-foreground">Provider Usage</h3>
|
||||
<div className="grid grid-cols-2">
|
||||
<div>
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
data={providerSpend}
|
||||
|
|
@ -575,8 +579,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
startAngle={90}
|
||||
endAngle={-270}
|
||||
/>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
</div>
|
||||
<div>
|
||||
<DataTable
|
||||
columns={providerSpendColumns}
|
||||
data={providerSpend}
|
||||
|
|
@ -584,12 +588,12 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
noDataMessage="No provider usage data"
|
||||
size="compact"
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const tabs: readonly { key: string; label: string; content: ReactNode }[] = [
|
||||
|
|
@ -620,80 +624,60 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
return (
|
||||
<div style={{ width: "100%" }} className="relative">
|
||||
{isFetchingMore && (
|
||||
<Alert
|
||||
banner
|
||||
type="warning"
|
||||
className="mb-2"
|
||||
message={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
<LoadingOutlined spin className="mr-2" />
|
||||
Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will
|
||||
update periodically as data loads. Moving off of this page will stop and reset this. To continue using
|
||||
the UI in the meantime,{" "}
|
||||
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
|
||||
open a new tab <ExportOutlined />
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<Button type="primary" danger onClick={cancel}>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Alert variant="warning" className="mb-2">
|
||||
<AlertDescription className="flex items-center justify-between text-inherit">
|
||||
<span>
|
||||
<Loader2 className="mr-2 inline size-4 animate-spin align-text-bottom" />
|
||||
Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will
|
||||
update periodically as data loads. Moving off of this page will stop and reset this. To continue using the
|
||||
UI in the meantime,{" "}
|
||||
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
|
||||
open a new tab <ExternalLink className="inline size-3.5 align-text-bottom" />
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<Button variant="destructive" onClick={cancel}>
|
||||
Stop
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{cancelled && (
|
||||
<Alert
|
||||
banner
|
||||
type="info"
|
||||
className="mb-2"
|
||||
message={
|
||||
<span>
|
||||
Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded)
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Alert variant="info" className="mb-2">
|
||||
<AlertDescription className="text-inherit">
|
||||
Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{agentIsFetchingMore && showAgentBreakdown && (
|
||||
<Alert
|
||||
banner
|
||||
type="warning"
|
||||
className="mb-2"
|
||||
message={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
<LoadingOutlined spin className="mr-2" />
|
||||
Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages.
|
||||
Charts will update periodically as data loads. Moving off of this page will stop and reset this. To
|
||||
continue using the UI in the meantime,{" "}
|
||||
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
|
||||
open a new tab <ExportOutlined />
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<Button type="primary" danger onClick={agentCancel}>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Alert variant="warning" className="mb-2">
|
||||
<AlertDescription className="flex items-center justify-between text-inherit">
|
||||
<span>
|
||||
<Loader2 className="mr-2 inline size-4 animate-spin align-text-bottom" />
|
||||
Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages.
|
||||
Charts will update periodically as data loads. Moving off of this page will stop and reset this. To
|
||||
continue using the UI in the meantime,{" "}
|
||||
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
|
||||
open a new tab <ExternalLink className="inline size-3.5 align-text-bottom" />
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<Button variant="destructive" onClick={agentCancel}>
|
||||
Stop
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{agentCancelled && showAgentBreakdown && (
|
||||
<Alert
|
||||
banner
|
||||
type="info"
|
||||
className="mb-2"
|
||||
message={
|
||||
<span>
|
||||
Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded)
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Alert variant="info" className="mb-2">
|
||||
<AlertDescription className="text-inherit">
|
||||
Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{entityType === "team" && (
|
||||
<div className="mb-4">
|
||||
<Text className="mb-2">Filter by team</Text>
|
||||
<p className="mb-2 text-sm text-foreground">Filter by team</p>
|
||||
<TeamMultiSelect value={selectedTags} onChange={setSelectedTags} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -710,18 +694,20 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
filterMode={entityType === "user" ? "single" : "multiple"}
|
||||
teams={teams || []}
|
||||
/>
|
||||
<TabGroup>
|
||||
<TabList variant="solid" className="mt-1">
|
||||
<Tabs defaultValue={tabs[0].key}>
|
||||
<TabsList className="mt-1">
|
||||
{tabs.map(({ key, label }) => (
|
||||
<Tab key={key}>{label}</Tab>
|
||||
<TabsTrigger key={key} value={key} className="flex-none px-3">
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{tabs.map(({ key, content }) => (
|
||||
<TabPanel key={key}>{content}</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</TabsList>
|
||||
{tabs.map(({ key, content }) => (
|
||||
<TabsContent key={key} value={key} keepMounted>
|
||||
{content}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ 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 { Info } from "lucide-react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Card, Col, Grid, Switch, Title } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import React, { useState } from "react";
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
|
|
@ -85,29 +86,30 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
|
|||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Spend by Provider</Title>
|
||||
<div className="flex items-center gap-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Spend by Provider</CardTitle>
|
||||
<CardAction className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-700">Show Zero Spend</label>
|
||||
<Switch checked={includeZeroSpend} onChange={setIncludeZeroSpend} />
|
||||
<Switch checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-sm text-gray-700">Show Unknown</label>
|
||||
<Tooltip title="Requests that failed to route to a provider">
|
||||
<InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-4 text-gray-400 hover:text-gray-600" />} />
|
||||
<TooltipContent>Requests that failed to route to a provider</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch checked={includeUnknown} onChange={setIncludeUnknown} />
|
||||
<Switch checked={includeUnknown} onCheckedChange={setIncludeUnknown} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<Grid numItems={2}>
|
||||
<Col numColSpan={1}>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2">
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
data={filteredProviderSpend}
|
||||
|
|
@ -119,8 +121,6 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
|
|||
startAngle={90}
|
||||
endAngle={-270}
|
||||
/>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredProviderSpend}
|
||||
|
|
@ -128,9 +128,9 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
|
|||
noDataMessage="No provider usage data"
|
||||
size="compact"
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ describe("TopModelView", () => {
|
|||
mockSetTopModelsLimit.mockClear();
|
||||
});
|
||||
|
||||
// Which element a control library gives its label to is its own business, so drive the
|
||||
// control by its visible text and judge the result by what the panel renders.
|
||||
const clickControl = async (user: ReturnType<typeof userEvent.setup>, label: string) => {
|
||||
await user.click(screen.getByText(label));
|
||||
};
|
||||
|
||||
const showsChart = (container: HTMLElement) => container.querySelector(".recharts-wrapper") !== null;
|
||||
|
||||
it("should render", () => {
|
||||
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
expect(screen.getByText("Table View")).toBeInTheDocument();
|
||||
|
|
@ -17,12 +25,12 @@ describe("TopModelView", () => {
|
|||
|
||||
it("should display table view button", () => {
|
||||
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Table View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display chart view button", () => {
|
||||
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Chart View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display all table column headers", () => {
|
||||
|
|
@ -60,27 +68,32 @@ describe("TopModelView", () => {
|
|||
expect(screen.getByText("50,000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const oneModel = [{ key: "gpt-4", spend: 150.5, successful_requests: 100, failed_requests: 5, tokens: 50000 }];
|
||||
|
||||
it("should switch to chart view when chart view button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
const { container } = render(
|
||||
<TopModelView topModels={oneModel} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />,
|
||||
);
|
||||
|
||||
const chartViewButton = screen.getByRole("button", { name: "Chart View" });
|
||||
await user.click(chartViewButton);
|
||||
expect(showsChart(container)).toBe(false);
|
||||
await clickControl(user, "Chart View");
|
||||
|
||||
expect(chartViewButton).toHaveClass("bg-blue-100");
|
||||
expect(showsChart(container)).toBe(true);
|
||||
expect(screen.queryByText("Spend (USD)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should switch to table view when table view button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
const { container } = render(
|
||||
<TopModelView topModels={oneModel} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />,
|
||||
);
|
||||
|
||||
const chartViewButton = screen.getByRole("button", { name: "Chart View" });
|
||||
const tableViewButton = screen.getByRole("button", { name: "Table View" });
|
||||
await clickControl(user, "Chart View");
|
||||
await clickControl(user, "Table View");
|
||||
|
||||
await user.click(chartViewButton);
|
||||
await user.click(tableViewButton);
|
||||
|
||||
expect(tableViewButton).toHaveClass("bg-blue-100");
|
||||
expect(showsChart(container)).toBe(false);
|
||||
expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders one cyan bar per model with model names on the axis in chart view", async () => {
|
||||
|
|
@ -108,7 +121,7 @@ describe("TopModelView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Chart View" }));
|
||||
await clickControl(user, "Chart View");
|
||||
|
||||
const bars = container.querySelectorAll("path.recharts-rectangle");
|
||||
expect(bars).toHaveLength(2);
|
||||
|
|
@ -118,19 +131,11 @@ describe("TopModelView", () => {
|
|||
expect(screen.getAllByText("claude-3").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should call setTopModelsLimit when limit is changed via Segmented control", async () => {
|
||||
it("should call setTopModelsLimit when the limit control is changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
|
||||
const limit10Radio = screen.getByRole("radio", { name: "10" });
|
||||
const limit10Label = limit10Radio.closest("label");
|
||||
if (limit10Label) {
|
||||
await user.click(limit10Label);
|
||||
} else {
|
||||
// Fallback: click the div with title="10"
|
||||
const limit10Div = screen.getByTitle("10");
|
||||
await user.click(limit10Div);
|
||||
}
|
||||
await clickControl(user, "10");
|
||||
|
||||
expect(mockSetTopModelsLimit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { BarChart } from "@/components/shared/charts";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { Segmented } from "antd";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useState } from "react";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
|
|
@ -19,6 +19,8 @@ interface TopModelViewProps {
|
|||
setTopModelsLimit: (limit: number) => void;
|
||||
}
|
||||
|
||||
export const TOP_MODEL_LIMITS = [5, 10, 25, 50];
|
||||
|
||||
export default function TopModelView({ topModels, topModelsLimit, setTopModelsLimit }: TopModelViewProps) {
|
||||
const [modelViewMode, setModelViewMode] = useState<"chart" | "table">("table");
|
||||
|
||||
|
|
@ -58,30 +60,25 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi
|
|||
return (
|
||||
<>
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: "5", value: 5 },
|
||||
{ label: "10", value: 10 },
|
||||
{ label: "25", value: 25 },
|
||||
{ label: "50", value: 50 },
|
||||
]}
|
||||
value={topModelsLimit}
|
||||
onChange={(value) => setTopModelsLimit(value as number)}
|
||||
/>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setModelViewMode("table")}
|
||||
className={`px-3 py-1 text-sm rounded-md ${modelViewMode === "table" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
Table View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModelViewMode("chart")}
|
||||
className={`px-3 py-1 text-sm rounded-md ${modelViewMode === "chart" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
Chart View
|
||||
</button>
|
||||
</div>
|
||||
<Tabs value={String(topModelsLimit)} onValueChange={(value: string) => setTopModelsLimit(Number(value))}>
|
||||
<TabsList aria-label="Number of models to show">
|
||||
{TOP_MODEL_LIMITS.map((limit) => (
|
||||
<TabsTrigger key={limit} value={String(limit)} className="flex-none px-3">
|
||||
{limit}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Tabs value={modelViewMode} onValueChange={(value: string) => setModelViewMode(value as "chart" | "table")}>
|
||||
<TabsList aria-label="Top model view mode">
|
||||
<TabsTrigger value="table" className="flex-none px-3">
|
||||
Table View
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chart" className="flex-none px-3">
|
||||
Chart View
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
{modelViewMode === "chart" ? (
|
||||
<div className="relative max-h-[600px] overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ describe("UsageAIChatPanel", () => {
|
|||
it("should render model selector", () => {
|
||||
renderWithProviders(<UsageAIChatPanel {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Select a model (optional, defaults to gpt-4o-mini)")).toBeInTheDocument();
|
||||
// One library paints the prompt as its own text node and the other leaves it on the input's
|
||||
// placeholder attribute, so either one means the user is being told what to pick.
|
||||
const prompt = "Select a model (optional, defaults to gpt-4o-mini)";
|
||||
expect(screen.queryAllByText(prompt).length + screen.queryAllByPlaceholderText(prompt).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should render empty state message when no conversation", () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Button, Select, Input, Spin } from "antd";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { modelHubCall, usageAiChatStream, UsageAiToolCallEvent } from "@/components/networking";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface ToolCallStep {
|
||||
tool_name: string;
|
||||
tool_label: string;
|
||||
|
|
@ -41,7 +49,7 @@ const ToolCallDisplay: React.FC<{ step: ToolCallStep }> = ({ step }) => {
|
|||
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-gray-100 border border-gray-200 text-xs">
|
||||
<span className="shrink-0 mt-0.5">
|
||||
{step.status === "running" ? (
|
||||
<Spin size="small" />
|
||||
<UiLoadingSpinner className="size-3.5" />
|
||||
) : step.status === "error" ? (
|
||||
<span className="text-red-500">✗</span>
|
||||
) : (
|
||||
|
|
@ -259,18 +267,29 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, acce
|
|||
|
||||
{/* Model selector */}
|
||||
<div className="px-5 py-3 border-b border-gray-100 shrink-0">
|
||||
<Select
|
||||
placeholder="Select a model (optional, defaults to gpt-4o-mini)"
|
||||
value={selectedModel}
|
||||
onChange={(value) => setSelectedModel(value)}
|
||||
loading={isLoadingModels}
|
||||
showSearch
|
||||
allowClear
|
||||
size="small"
|
||||
className="w-full"
|
||||
options={availableModels.map((m) => ({ label: m, value: m }))}
|
||||
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
<Combobox
|
||||
items={availableModels}
|
||||
value={selectedModel ?? null}
|
||||
onValueChange={(value: string | null) => setSelectedModel(value ?? undefined)}
|
||||
>
|
||||
<ComboboxInput
|
||||
className="w-full"
|
||||
placeholder="Select a model (optional, defaults to gpt-4o-mini)"
|
||||
aria-label="Select a model (optional, defaults to gpt-4o-mini)"
|
||||
aria-busy={isLoadingModels}
|
||||
showClear={selectedModel !== undefined}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{isLoadingModels ? "Loading models…" : "No models found"}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model: string) => (
|
||||
<ComboboxItem key={model} value={model}>
|
||||
{model}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
{/* Chat messages */}
|
||||
|
|
@ -329,7 +348,7 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, acce
|
|||
{/* Status / spinner */}
|
||||
{isLoading && !streamingContent && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500">
|
||||
<Spin size="small" />
|
||||
<UiLoadingSpinner className="size-3.5" />
|
||||
<span className="italic">{statusMessage || "Thinking..."}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -347,16 +366,17 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, acce
|
|||
{/* Input area */}
|
||||
<div className="px-4 py-3 border-t border-gray-200 bg-white shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<TextArea
|
||||
<Textarea
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about your usage..."
|
||||
autoSize={{ minRows: 1, maxRows: 3 }}
|
||||
className="flex-1"
|
||||
rows={1}
|
||||
className="flex-1 min-h-9 max-h-24"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button type="primary" onClick={handleSend} disabled={!inputText.trim() || isLoading} loading={isLoading}>
|
||||
<Button onClick={handleSend} disabled={!inputText.trim() || isLoading}>
|
||||
{isLoading && <UiLoadingSpinner className="size-4" />}
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import type { Organization } from "@/components/networking";
|
||||
|
|
@ -143,207 +144,6 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
|
|||
useInfiniteUsers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("antd", async (importOriginal) => {
|
||||
const React = await import("react");
|
||||
const actual = await importOriginal<typeof import("antd")>();
|
||||
|
||||
function Select(props: any) {
|
||||
const { value, onChange, options, ...rest } = props;
|
||||
return React.createElement(
|
||||
"select",
|
||||
{
|
||||
...rest,
|
||||
value,
|
||||
onChange: (e: any) => onChange?.(e.target.value),
|
||||
role: "combobox",
|
||||
},
|
||||
options?.map((opt: any) => React.createElement("option", { key: opt.value, value: opt.value }, opt.label)),
|
||||
);
|
||||
}
|
||||
(Select as any).displayName = "AntdSelect";
|
||||
|
||||
function Alert(props: any) {
|
||||
const { message, description, type, closable, onClose, ...rest } = props;
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ ...rest, "data-testid": "antd-alert", "data-type": type },
|
||||
message && React.createElement("div", null, message),
|
||||
description && React.createElement("div", null, description),
|
||||
closable && React.createElement("button", { onClick: onClose, "aria-label": "Close" }, "×"),
|
||||
);
|
||||
}
|
||||
(Alert as any).displayName = "AntdAlert";
|
||||
|
||||
function Badge(props: any) {
|
||||
const { count, color, children, ...rest } = props;
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ ...rest, "data-testid": "antd-badge", "data-color": color },
|
||||
count && React.createElement("span", { "data-testid": "antd-badge-count" }, count),
|
||||
children,
|
||||
);
|
||||
}
|
||||
(Badge as any).displayName = "AntdBadge";
|
||||
|
||||
function Table({ columns, dataSource, ...rest }: any) {
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ ...rest, "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}` },
|
||||
columns?.map((col: any) => {
|
||||
const value = col.render ? col.render(row[col.dataIndex], row) : row[col.dataIndex];
|
||||
return React.createElement("div", { key: col.key }, value);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
(Table as any).displayName = "Table";
|
||||
|
||||
function Segmented(props: any) {
|
||||
const { value, onChange, options, ...rest } = props;
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ ...rest, "data-testid": "antd-segmented" },
|
||||
options?.map((opt: any) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
key: opt.value,
|
||||
onClick: () => onChange?.(opt.value),
|
||||
"data-selected": value === opt.value,
|
||||
},
|
||||
opt.label,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
(Segmented as any).displayName = "AntdSegmented";
|
||||
|
||||
function Tooltip(props: any) {
|
||||
const { title, children, ...rest } = props;
|
||||
return React.createElement("div", { ...rest, "data-testid": "antd-tooltip", title }, children);
|
||||
}
|
||||
(Tooltip as any).displayName = "AntdTooltip";
|
||||
|
||||
return {
|
||||
...actual,
|
||||
Select,
|
||||
Alert,
|
||||
Badge,
|
||||
Table,
|
||||
Segmented,
|
||||
Tooltip,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@ant-design/icons", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
function Icon() {
|
||||
return React.createElement("span");
|
||||
}
|
||||
|
||||
function LoadingOutlined(props: any) {
|
||||
return React.createElement("span", { "data-testid": "loading-icon", ...props });
|
||||
}
|
||||
|
||||
return {
|
||||
GlobalOutlined: Icon,
|
||||
BankOutlined: Icon,
|
||||
TeamOutlined: Icon,
|
||||
ShoppingCartOutlined: Icon,
|
||||
TagsOutlined: Icon,
|
||||
RobotOutlined: Icon,
|
||||
LineChartOutlined: Icon,
|
||||
BarChartOutlined: Icon,
|
||||
ClockCircleOutlined: Icon,
|
||||
CalendarOutlined: Icon,
|
||||
InfoCircleOutlined: Icon,
|
||||
UserOutlined: Icon,
|
||||
DownOutlined: Icon,
|
||||
RightOutlined: Icon,
|
||||
ExportOutlined: Icon,
|
||||
LoadingOutlined,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Tremor components
|
||||
vi.mock("@tremor/react", async () => {
|
||||
const React = await import("react");
|
||||
const actual = await import("@tremor/react");
|
||||
|
||||
function TabGroup({ children }: any) {
|
||||
return React.createElement("div", { "data-testid": "tremor-tab-group" }, children);
|
||||
}
|
||||
|
||||
function TabList({ children }: any) {
|
||||
return React.createElement("div", { "data-testid": "tremor-tab-list" }, children);
|
||||
}
|
||||
|
||||
function Tab({ children, ...props }: any) {
|
||||
return React.createElement("button", { ...props, "data-testid": "tremor-tab" }, children);
|
||||
}
|
||||
|
||||
function TabPanels({ children }: any) {
|
||||
return React.createElement("div", { "data-testid": "tremor-tab-panels" }, children);
|
||||
}
|
||||
|
||||
function TabPanel({ children }: any) {
|
||||
return React.createElement("div", { "data-testid": "tremor-tab-panel" }, children);
|
||||
}
|
||||
|
||||
function Card({ children, ...props }: any) {
|
||||
return React.createElement("div", { ...props, "data-testid": "tremor-card" }, children);
|
||||
}
|
||||
|
||||
function Grid({ children, numItems, ...props }: any) {
|
||||
return React.createElement("div", { ...props, "data-testid": "tremor-grid" }, children);
|
||||
}
|
||||
|
||||
function Col({ children, numColSpan, ...props }: any) {
|
||||
return React.createElement("div", { ...props, "data-testid": "tremor-col" }, children);
|
||||
}
|
||||
|
||||
function Title({ children, ...props }: any) {
|
||||
return React.createElement("h2", { ...props, "data-testid": "tremor-title" }, children);
|
||||
}
|
||||
|
||||
function Text({ children, ...props }: any) {
|
||||
return React.createElement("p", { ...props, "data-testid": "tremor-text" }, children);
|
||||
}
|
||||
|
||||
function Button({ children, icon, onClick, ...props }: any) {
|
||||
return React.createElement(
|
||||
"button",
|
||||
{ ...props, onClick, "data-testid": "tremor-button" },
|
||||
icon && React.createElement("span", { "data-testid": "tremor-button-icon" }),
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...actual,
|
||||
TabGroup,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
Card,
|
||||
Grid,
|
||||
Col,
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
};
|
||||
});
|
||||
|
||||
describe("UsagePage", () => {
|
||||
const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall);
|
||||
const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall);
|
||||
|
|
@ -885,6 +685,26 @@ describe("UsagePage", () => {
|
|||
});
|
||||
|
||||
describe("admin user selector", () => {
|
||||
// Anchored on the field's own label, so it does not depend on which library draws the control.
|
||||
const userSelectCombobox = (): HTMLElement => {
|
||||
let node: HTMLElement | null = screen.getByText("Filter by user");
|
||||
while (node && !node.querySelector('[role="combobox"]')) {
|
||||
node = node.parentElement;
|
||||
}
|
||||
const combobox = node?.querySelector('[role="combobox"]') ?? null;
|
||||
expect(combobox).not.toBeNull();
|
||||
return combobox as HTMLElement;
|
||||
};
|
||||
|
||||
const openUserSelect = async () => {
|
||||
await userEvent.setup().click(userSelectCombobox());
|
||||
};
|
||||
|
||||
// One library paints the prompt as its own text node and the other leaves it on the input's
|
||||
// placeholder attribute, so either one means the user is being told what to type.
|
||||
const promptsWith = (text: string) =>
|
||||
screen.queryAllByText(text).length + screen.queryAllByPlaceholderText(text).length > 0;
|
||||
|
||||
it("should render user selector for admin users in global view", async () => {
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
|
|
@ -892,10 +712,8 @@ describe("UsagePage", () => {
|
|||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Admin should see the user selector select element with the placeholder attribute
|
||||
const userSelects = screen.getAllByRole("combobox");
|
||||
const userSelect = userSelects.find((el) => el.getAttribute("placeholder") === "Select user to filter...");
|
||||
expect(userSelect).toBeDefined();
|
||||
expect(userSelectCombobox()).toBeInTheDocument();
|
||||
expect(promptsWith("Select user to filter...")).toBe(true);
|
||||
});
|
||||
|
||||
it("should format user options with alias when available", async () => {
|
||||
|
|
@ -905,6 +723,8 @@ describe("UsagePage", () => {
|
|||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await openUserSelect();
|
||||
|
||||
// User with alias should show "alias (id)"
|
||||
expect(screen.getByText("Alice (user-001)")).toBeInTheDocument();
|
||||
// User without alias but with email should show "email (id)"
|
||||
|
|
@ -958,6 +778,8 @@ describe("UsagePage", () => {
|
|||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await openUserSelect();
|
||||
|
||||
// Duplicate user should appear only once
|
||||
const dupElements = screen.getAllByText("DupUser (user-dup)");
|
||||
expect(dupElements).toHaveLength(1);
|
||||
|
|
@ -1003,10 +825,9 @@ describe("UsagePage", () => {
|
|||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Non-admin should not see the user selector
|
||||
const userSelects = screen.getAllByRole("combobox");
|
||||
const userSelect = userSelects.find((el) => el.getAttribute("placeholder") === "Select user to filter...");
|
||||
expect(userSelect).toBeUndefined();
|
||||
// The admin case above proves this label is rendered when the selector exists, so its
|
||||
// absence here is a live assertion rather than a query that can never match.
|
||||
expect(screen.queryByText("Filter by user")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should always pass own userId for non-admin users", async () => {
|
||||
|
|
|
|||
|
|
@ -6,27 +6,17 @@
|
|||
* Works at 1m+ spend logs, by querying an aggregate table instead.
|
||||
*/
|
||||
|
||||
import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
DateRangePickerValue,
|
||||
Grid,
|
||||
Tab,
|
||||
TabGroup,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Text,
|
||||
Title,
|
||||
} from "@tremor/react";
|
||||
import { Alert, Button, Segmented, Select, Tooltip, Typography } from "antd";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react";
|
||||
import { ChevronDown, ChevronRight, Download, ExternalLink, Info, Loader2, Sparkles, X } from "lucide-react";
|
||||
import type { DateRangePickerValue } from "@tremor/react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { BarChart } from "@/components/shared/charts";
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
|
|
@ -68,6 +58,7 @@ import EndpointUsage from "./EndpointUsage/EndpointUsage";
|
|||
import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
|
||||
import ModelViewToggle, { ModelViewType } from "./ModelViewToggle";
|
||||
import SpendByProvider from "./EntityUsage/SpendByProvider";
|
||||
import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView";
|
||||
import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
|
||||
import UsageAIChatPanel from "./UsageAIChatPanel";
|
||||
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
|
||||
|
|
@ -113,11 +104,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage");
|
||||
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
|
||||
|
||||
// Debounced search for user selector
|
||||
const [userSearchInput, setUserSearchInput] = useState("");
|
||||
const [debouncedUserSearch, setDebouncedUserSearch] = useDebouncedState("", {
|
||||
wait: DEBOUNCE_WAIT_MS,
|
||||
});
|
||||
const [settledUserSearch, setSettledUserSearch] = useState("");
|
||||
|
||||
const {
|
||||
data: usersInfiniteData,
|
||||
|
|
@ -125,7 +112,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
hasNextPage: hasNextUsersPage,
|
||||
isFetchingNextPage: isFetchingNextUsersPage,
|
||||
isLoading: isLoadingUsers,
|
||||
} = useInfiniteUsers(50, debouncedUserSearch || undefined);
|
||||
} = useInfiniteUsers(50, settledUserSearch || undefined);
|
||||
|
||||
const userOptions = useMemo(() => {
|
||||
if (!usersInfiniteData?.pages) return [];
|
||||
|
|
@ -148,19 +135,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
return result;
|
||||
}, [usersInfiniteData]);
|
||||
|
||||
const handleUserSearchChange = (value: string) => {
|
||||
setUserSearchInput(value);
|
||||
setDebouncedUserSearch(value);
|
||||
};
|
||||
|
||||
const handleUserPopupScroll = (e: UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
|
||||
if (scrollRatio >= 0.8 && hasNextUsersPage && !isFetchingNextUsersPage) {
|
||||
fetchNextUsersPage();
|
||||
}
|
||||
};
|
||||
|
||||
// For admins: null means global view (all users), a string means filter by that user
|
||||
// For non-admins: always set to their own user ID
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(isAdmin ? null : userID || null);
|
||||
|
|
@ -522,352 +496,361 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
|
||||
</div>
|
||||
{paginatedResult.isFetchingMore && (
|
||||
<Alert
|
||||
banner
|
||||
type="warning"
|
||||
className="mb-2"
|
||||
message={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
<LoadingOutlined spin className="mr-2" />
|
||||
Currently fetching spend data: fetched {paginatedResult.progress.currentPage} /{" "}
|
||||
{paginatedResult.progress.totalPages} pages. Charts will update periodically as data loads. Moving
|
||||
off of this page will stop and reset this. To continue using the UI in the meantime,{" "}
|
||||
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
|
||||
open a new tab <ExportOutlined />
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<Button type="primary" danger onClick={paginatedResult.cancel}>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Alert variant="warning" className="mb-2">
|
||||
<AlertDescription className="flex items-center justify-between text-inherit">
|
||||
<span>
|
||||
<Loader2 className="mr-2 inline size-4 animate-spin align-text-bottom" />
|
||||
Currently fetching spend data: fetched {paginatedResult.progress.currentPage} /{" "}
|
||||
{paginatedResult.progress.totalPages} pages. Charts will update periodically as data loads. Moving off
|
||||
of this page will stop and reset this. To continue using the UI in the meantime,{" "}
|
||||
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
|
||||
open a new tab <ExternalLink className="inline size-3.5 align-text-bottom" />
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<Button variant="destructive" onClick={paginatedResult.cancel}>
|
||||
Stop
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{paginatedResult.cancelled && (
|
||||
<Alert
|
||||
banner
|
||||
type="info"
|
||||
className="mb-2"
|
||||
message={
|
||||
<span>
|
||||
Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages}{" "}
|
||||
pages loaded)
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Alert variant="info" className="mb-2">
|
||||
<AlertDescription className="text-inherit">
|
||||
Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages
|
||||
loaded)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Your Usage / Global Usage Panel */}
|
||||
{(usageView === "global" || usageView === "my-usage") && (
|
||||
<>
|
||||
{isAdmin && usageView === "global" && (
|
||||
<div className="mb-4">
|
||||
<Text className="mb-2">Filter by user</Text>
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Select user to filter..."
|
||||
value={selectedUserId}
|
||||
onChange={(value) => setSelectedUserId(value ?? null)}
|
||||
filterOption={false}
|
||||
onSearch={handleUserSearchChange}
|
||||
searchValue={userSearchInput}
|
||||
onPopupScroll={handleUserPopupScroll}
|
||||
loading={isLoadingUsers}
|
||||
notFoundContent={isLoadingUsers ? <LoadingOutlined spin /> : "No users found"}
|
||||
<p className="mb-2 text-sm text-foreground">Filter by user</p>
|
||||
<PaginatedSearchSelect
|
||||
options={userOptions}
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
{isFetchingNextUsersPage && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
value={selectedUserId ?? undefined}
|
||||
onValueChange={(value) => setSelectedUserId(value === "" ? null : value)}
|
||||
onSearchChange={setSettledUserSearch}
|
||||
onLoadMore={fetchNextUsersPage}
|
||||
hasNextPage={hasNextUsersPage}
|
||||
isLoading={isLoadingUsers}
|
||||
isFetchingNextPage={isFetchingNextUsersPage}
|
||||
placeholder="Select user to filter..."
|
||||
emptyText="No users found"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<TabGroup>
|
||||
<Tabs defaultValue="cost">
|
||||
<div className="flex justify-between items-center">
|
||||
<TabList variant="solid" className="mt-1">
|
||||
<Tab>Cost</Tab>
|
||||
<Tab>Model Activity</Tab>
|
||||
<Tab>Key Activity</Tab>
|
||||
<Tab>MCP Server Activity</Tab>
|
||||
<Tab>Endpoint Activity</Tab>
|
||||
</TabList>
|
||||
<TabsList className="mt-1">
|
||||
<TabsTrigger value="cost" className="flex-none px-3">
|
||||
Cost
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models" className="flex-none px-3">
|
||||
Model Activity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="keys" className="flex-none px-3">
|
||||
Key Activity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp" className="flex-none px-3">
|
||||
MCP Server Activity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="endpoints" className="flex-none px-3">
|
||||
Endpoint Activity
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setIsAiChatOpen(true)}
|
||||
icon={
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1l1.5 3.5L13 6l-3.5 1.5L8 11 6.5 7.5 3 6l3.5-1.5L8 1zm4 7l.75 1.75L14.5 10.5l-1.75.75L12 13l-.75-1.75L9.5 10.5l1.75-.75L12 8zM4 9l.75 1.75L6.5 11.5l-1.75.75L4 14l-.75-1.75L1.5 11.5l1.75-.75L4 9z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setIsAiChatOpen(true)}>
|
||||
<Sparkles />
|
||||
Ask AI
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsGlobalExportModalOpen(true)}
|
||||
icon={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setIsGlobalExportModalOpen(true)}>
|
||||
<Download />
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabPanels>
|
||||
{/* Cost Panel */}
|
||||
<TabPanel>
|
||||
<Grid numItems={2} className="gap-2 w-full">
|
||||
{/* Total Spend Card */}
|
||||
<Col numColSpan={2}>
|
||||
<div className="flex items-center gap-4 mt-2 mb-2">
|
||||
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content text-lg">
|
||||
Project Spend{" "}
|
||||
{dateValue.from && dateValue.to && (
|
||||
<>
|
||||
{dateValue.from.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year:
|
||||
dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined,
|
||||
})}
|
||||
{" - "}
|
||||
{dateValue.to.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
{/* Cost Panel */}
|
||||
<TabsContent value="cost" keepMounted>
|
||||
<div className="grid grid-cols-2 gap-2 w-full">
|
||||
{/* Total Spend Card */}
|
||||
<div className="col-span-2">
|
||||
<div className="flex items-center gap-4 mt-2 mb-2">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Project Spend{" "}
|
||||
{dateValue.from && dateValue.to && (
|
||||
<>
|
||||
{dateValue.from.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year:
|
||||
dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined,
|
||||
})}
|
||||
{" - "}
|
||||
{dateValue.to.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ViewUserSpend
|
||||
userSpend={totalSpend}
|
||||
selectedTeam={null}
|
||||
userMaxBudget={currentUser?.max_budget || null}
|
||||
/>
|
||||
</Col>
|
||||
<ViewUserSpend
|
||||
userSpend={totalSpend}
|
||||
selectedTeam={null}
|
||||
userMaxBudget={currentUser?.max_budget || null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Col numColSpan={2}>
|
||||
<Card>
|
||||
<Title>Usage Metrics</Title>
|
||||
<Grid numItems={5} className="gap-4 mt-4">
|
||||
<Card>
|
||||
<Title>Total Requests</Title>
|
||||
<Text className="text-2xl font-bold mt-2">
|
||||
{userSpendData.metadata?.total_api_requests?.toLocaleString() || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="flex items-center gap-2">
|
||||
<Title>Successful Requests</Title>
|
||||
{gatewayActivity && (
|
||||
<Tooltip title="Counted by the gateway when it answers a request, independent of spend logging. Deployment-wide, so it will not match the per-key or per-model breakdowns below.">
|
||||
<InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Usage Metrics</h3>
|
||||
<div className="grid grid-cols-5 gap-4 mt-4">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Total Requests</h3>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
{userSpendData.metadata?.total_api_requests?.toLocaleString() || 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-foreground">Successful Requests</h3>
|
||||
{gatewayActivity && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Info className="size-4 text-gray-400 hover:text-gray-600" />}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Counted by the gateway when it answers a request, independent of spend logging.
|
||||
Deployment-wide, so it will not match the per-key or per-model breakdowns below.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
TODO: drop the userSpendData fallback once every deployment
|
||||
is writing LiteLLM_DailyGatewayRequests. It covers two cases
|
||||
today: a non-admin (who may not read deployment-wide counts)
|
||||
and an admin on a proxy whose table is still backfilling.
|
||||
*/}
|
||||
<p className="text-2xl font-bold mt-2 text-green-600">
|
||||
{(
|
||||
gatewayActivity?.total_successful_requests ??
|
||||
userSpendData.metadata?.total_successful_requests
|
||||
)?.toLocaleString() || 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-foreground">Failed Requests</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Info className="size-4 text-gray-400 hover:text-gray-600" />}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{gatewayActivity
|
||||
? "Counted by the gateway when it answers a request, independent of spend logging. Deployment-wide, so it will not match the per-key or per-model breakdowns below."
|
||||
: "Includes requests that failed to route to a provider, tool usage failures, and other request errors where the provider cannot be determined."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
TODO: drop the userSpendData fallback once every deployment
|
||||
is writing LiteLLM_DailyGatewayRequests. It covers two cases
|
||||
today: a non-admin (who may not read deployment-wide counts)
|
||||
and an admin on a proxy whose table is still backfilling.
|
||||
*/}
|
||||
<Text className="text-2xl font-bold mt-2 text-green-600">
|
||||
{(
|
||||
gatewayActivity?.total_successful_requests ??
|
||||
userSpendData.metadata?.total_successful_requests
|
||||
)?.toLocaleString() || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="flex items-center gap-2">
|
||||
<Title>Failed Requests</Title>
|
||||
<Tooltip
|
||||
title={
|
||||
gatewayActivity
|
||||
? "Counted by the gateway when it answers a request, independent of spend logging. Deployment-wide, so it will not match the per-key or per-model breakdowns below."
|
||||
: "Includes requests that failed to route to a provider, tool usage failures, and other request errors where the provider cannot be determined."
|
||||
}
|
||||
>
|
||||
<InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Same source as Successful Requests: the two must agree, or the
|
||||
tile disagrees with the endpoint breakdown chart below it. */}
|
||||
<Text className="text-2xl font-bold mt-2 text-red-600">
|
||||
{(
|
||||
gatewayActivity?.total_failed_requests ??
|
||||
userSpendData.metadata?.total_failed_requests
|
||||
)?.toLocaleString() || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<Title>Average Cost per Request</Title>
|
||||
<Text className="text-2xl font-bold mt-2">
|
||||
$
|
||||
{formatNumberWithCommas(
|
||||
(totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1),
|
||||
4,
|
||||
)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card
|
||||
</div>
|
||||
{/* Same source as Successful Requests: the two must agree, or the
|
||||
tile disagrees with the endpoint breakdown chart below it. */}
|
||||
<p className="text-2xl font-bold mt-2 text-red-600">
|
||||
{(
|
||||
gatewayActivity?.total_failed_requests ??
|
||||
userSpendData.metadata?.total_failed_requests
|
||||
)?.toLocaleString() || 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Average Cost per Request</h3>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
$
|
||||
{formatNumberWithCommas(
|
||||
(totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1),
|
||||
4,
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard
|
||||
className="cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowTokenBreakdown(!showTokenBreakdown)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Title>Total Tokens</Title>
|
||||
{showTokenBreakdown ? (
|
||||
<DownOutlined className="text-gray-400 text-xs" />
|
||||
) : (
|
||||
<RightOutlined className="text-gray-400 text-xs" />
|
||||
)}
|
||||
</div>
|
||||
<Text className="text-2xl font-bold mt-2">
|
||||
{userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
</Grid>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-foreground">Total Tokens</h3>
|
||||
{showTokenBreakdown ? (
|
||||
<ChevronDown className="size-3 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
{userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
{showTokenBreakdown && (
|
||||
<Grid numItems={4} className="gap-4 mt-4">
|
||||
<Card>
|
||||
<Title>Input Tokens</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-blue-600">
|
||||
{(userSpendData.metadata?.total_prompt_tokens || 0).toLocaleString()}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<Title>Output Tokens</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-cyan-600">
|
||||
{userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<Title>Cache Read Tokens</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-green-600">
|
||||
{userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<Title>Cache Write Tokens</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-purple-600">
|
||||
{userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
</Grid>
|
||||
<div className="grid grid-cols-4 gap-4 mt-4">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Input Tokens</h3>
|
||||
<p className="text-2xl font-bold mt-2 text-blue-600">
|
||||
{(userSpendData.metadata?.total_prompt_tokens || 0).toLocaleString()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Output Tokens</h3>
|
||||
<p className="text-2xl font-bold mt-2 text-cyan-600">
|
||||
{userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Cache Read Tokens</h3>
|
||||
<p className="text-2xl font-bold mt-2 text-green-600">
|
||||
{userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Cache Write Tokens</h3>
|
||||
<p className="text-2xl font-bold mt-2 text-purple-600">
|
||||
{userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
{/* Daily Spend Chart */}
|
||||
<Col numColSpan={2}>
|
||||
<ShadcnCard>
|
||||
{/* Daily Spend Chart */}
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={sortedDailyResults}
|
||||
index="date"
|
||||
categories={["metrics.spend"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatterSpend}
|
||||
yAxisWidth={100}
|
||||
showLegend={false}
|
||||
customTooltip={({ payload, active }) => {
|
||||
if (!active || !payload?.[0]) return null;
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white p-4 shadow-lg rounded-lg border">
|
||||
<p className="font-bold">{data.date}</p>
|
||||
<p className="text-cyan-500">
|
||||
Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}
|
||||
</p>
|
||||
<p className="text-gray-600">Requests: {data.metrics.api_requests}</p>
|
||||
<p className="text-gray-600">Successful: {data.metrics.successful_requests}</p>
|
||||
<p className="text-gray-600">Failed: {data.metrics.failed_requests}</p>
|
||||
<p className="text-gray-600">Tokens: {data.metrics.total_tokens}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
{/* Gateway Requests by Endpoint (SGR) */}
|
||||
{gatewayActivity && gatewayActivity.by_route.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard data-testid="gateway-requests-by-endpoint">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
|
||||
<CardTitle className="text-base font-semibold">
|
||||
Gateway Requests by Endpoint
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Info className="ml-2 inline size-4 text-gray-400 hover:text-gray-600" />}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Counted by the gateway middleware as each request is answered. Covers LLM, MCP and A2A
|
||||
endpoints across the whole deployment.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={sortedDailyResults}
|
||||
index="date"
|
||||
categories={["metrics.spend"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatterSpend}
|
||||
yAxisWidth={100}
|
||||
showLegend={false}
|
||||
customTooltip={({ payload, active }) => {
|
||||
if (!active || !payload?.[0]) return null;
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white p-4 shadow-lg rounded-lg border">
|
||||
<p className="font-bold">{data.date}</p>
|
||||
<p className="text-cyan-500">
|
||||
Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}
|
||||
</p>
|
||||
<p className="text-gray-600">Requests: {data.metrics.api_requests}</p>
|
||||
<p className="text-gray-600">Successful: {data.metrics.successful_requests}</p>
|
||||
<p className="text-gray-600">Failed: {data.metrics.failed_requests}</p>
|
||||
<p className="text-gray-600">Tokens: {data.metrics.total_tokens}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<BarChart
|
||||
data={gatewayRequestsByRoute}
|
||||
index="route"
|
||||
categories={["successful_requests", "failed_requests"]}
|
||||
colors={["green", "red"]}
|
||||
stack={true}
|
||||
yAxisWidth={100}
|
||||
valueFormatter={(value: number) => value.toLocaleString()}
|
||||
/>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</Col>
|
||||
{/* Gateway Requests by Endpoint (SGR) */}
|
||||
{gatewayActivity && gatewayActivity.by_route.length > 0 && (
|
||||
<Col numColSpan={2}>
|
||||
<ShadcnCard data-testid="gateway-requests-by-endpoint">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">
|
||||
Gateway Requests by Endpoint
|
||||
<Tooltip title="Counted by the gateway middleware as each request is answered. Covers LLM, MCP and A2A endpoints across the whole deployment.">
|
||||
<InfoCircleOutlined className="ml-2 text-gray-400 hover:text-gray-600" />
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
data={gatewayRequestsByRoute}
|
||||
index="route"
|
||||
categories={["successful_requests", "failed_requests"]}
|
||||
colors={["green", "red"]}
|
||||
stack={true}
|
||||
yAxisWidth={100}
|
||||
valueFormatter={(value: number) => value.toLocaleString()}
|
||||
/>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</Col>
|
||||
)}
|
||||
{/* Top API Keys */}
|
||||
<Col numColSpan={1}>
|
||||
<Card className="h-full">
|
||||
<Title>Top Virtual Keys</Title>
|
||||
</div>
|
||||
)}
|
||||
{/* Top API Keys */}
|
||||
<div>
|
||||
<ShadcnCard className="h-full">
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys</h3>
|
||||
<TopKeyView
|
||||
topKeys={topKeys}
|
||||
teams={null}
|
||||
topKeysLimit={topKeysLimit}
|
||||
setTopKeysLimit={setTopKeysLimit}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
{/* Top Models */}
|
||||
<Col numColSpan={1}>
|
||||
<Card className="h-full">
|
||||
<Title>{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}</Title>
|
||||
{/* Top Models */}
|
||||
<div>
|
||||
<ShadcnCard className="h-full">
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}
|
||||
</h3>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: "5", value: 5 },
|
||||
{ label: "10", value: 10 },
|
||||
{ label: "25", value: 25 },
|
||||
{ label: "50", value: 50 },
|
||||
]}
|
||||
value={topModelsLimit}
|
||||
onChange={(value) => setTopModelsLimit(value as number)}
|
||||
/>
|
||||
<Tabs
|
||||
value={String(topModelsLimit)}
|
||||
onValueChange={(value: string) => setTopModelsLimit(Number(value))}
|
||||
>
|
||||
<TabsList>
|
||||
{TOP_MODEL_LIMITS.map((limit) => (
|
||||
<TabsTrigger key={limit} value={String(limit)} className="flex-none px-3">
|
||||
{limit}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
|
||||
</div>
|
||||
{loading ? (
|
||||
|
|
@ -915,40 +898,40 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
})()}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Spend by Provider */}
|
||||
<Col numColSpan={2}>
|
||||
<SpendByProvider
|
||||
loading={loading}
|
||||
isDateChanging={isDateChanging}
|
||||
providerSpend={providerSpend}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{/* Usage Metrics */}
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Activity Panel */}
|
||||
<TabPanel>
|
||||
<div className="flex justify-end mt-2 mb-4">
|
||||
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
<ActivityMetrics modelMetrics={modelMetrics} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<ActivityMetrics modelMetrics={keyMetrics} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<ActivityMetrics modelMetrics={mcpServerMetrics} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<EndpointUsage userSpendData={userSpendData} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
{/* Spend by Provider */}
|
||||
<div className="col-span-2">
|
||||
<SpendByProvider
|
||||
loading={loading}
|
||||
isDateChanging={isDateChanging}
|
||||
providerSpend={providerSpend}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage Metrics */}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Activity Panel */}
|
||||
<TabsContent value="models" keepMounted>
|
||||
<div className="flex justify-end mt-2 mb-4">
|
||||
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
|
||||
</div>
|
||||
<ActivityMetrics modelMetrics={modelMetrics} />
|
||||
</TabsContent>
|
||||
<TabsContent value="keys" keepMounted>
|
||||
<ActivityMetrics modelMetrics={keyMetrics} />
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp" keepMounted>
|
||||
<ActivityMetrics modelMetrics={mcpServerMetrics} />
|
||||
</TabsContent>
|
||||
<TabsContent value="endpoints" keepMounted>
|
||||
<EndpointUsage userSpendData={userSpendData} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
{/* Organization Usage Panel */}
|
||||
|
|
@ -1009,21 +992,24 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
{usageView === "tag" && (
|
||||
<>
|
||||
{showCredentialBanner && (
|
||||
<Alert
|
||||
banner
|
||||
type="info"
|
||||
message="Reusable credentials are automatically tracked as tags"
|
||||
description={
|
||||
<Typography.Text>
|
||||
When a reusable credential is used, it will appear as a tag prefixed with{" "}
|
||||
<Typography.Text code>Credential: </Typography.Text>
|
||||
in this view.
|
||||
</Typography.Text>
|
||||
}
|
||||
closable
|
||||
onClose={() => setShowCredentialBanner(false)}
|
||||
className="mb-5"
|
||||
/>
|
||||
<Alert variant="info" className="mb-5">
|
||||
<AlertTitle>Reusable credentials are automatically tracked as tags</AlertTitle>
|
||||
<AlertDescription className="text-inherit">
|
||||
When a reusable credential is used, it will appear as a tag prefixed with{" "}
|
||||
<code className="rounded bg-black/5 px-1 py-0.5 font-mono text-xs">Credential: </code>
|
||||
in this view.
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Close"
|
||||
onClick={() => setShowCredentialBanner(false)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
)}
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
|
|
|
|||
|
|
@ -1,86 +1,16 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UsageViewSelect } from "./UsageViewSelect";
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const React = await import("react");
|
||||
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
};
|
||||
|
||||
function Select(props: any) {
|
||||
const { value, onChange, options, optionRender, labelRender, ...rest } = props;
|
||||
|
||||
const optionElements = options?.map((opt: any) =>
|
||||
React.createElement("option", { key: opt.value, value: opt.value }, opt.label),
|
||||
);
|
||||
|
||||
const optionRenderOutputs = options
|
||||
?.map((opt: any) => {
|
||||
if (optionRender) {
|
||||
const rendered = optionRender({ value: opt.value, label: opt.label });
|
||||
return React.createElement(
|
||||
"div",
|
||||
{
|
||||
key: `option-render-${opt.value}`,
|
||||
"data-testid": `option-render-${opt.value}`,
|
||||
style: { display: "none" },
|
||||
},
|
||||
rendered,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement(
|
||||
"select",
|
||||
{
|
||||
...rest,
|
||||
value,
|
||||
onChange: (e: any) => onChange?.(e.target.value),
|
||||
role: "combobox",
|
||||
},
|
||||
optionElements,
|
||||
),
|
||||
...(optionRenderOutputs || []),
|
||||
);
|
||||
}
|
||||
(Select as any).displayName = "AntdSelect";
|
||||
|
||||
function Badge(props: any) {
|
||||
const { count, color, children, ...rest } = props;
|
||||
return React.createElement(
|
||||
"span",
|
||||
{ ...rest, "data-testid": "antd-badge", "data-color": color },
|
||||
count && React.createElement("span", { "data-testid": "antd-badge-count" }, count),
|
||||
children,
|
||||
);
|
||||
}
|
||||
(Badge as any).displayName = "AntdBadge";
|
||||
|
||||
return { Select, Badge };
|
||||
});
|
||||
|
||||
vi.mock("@ant-design/icons", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
function Icon(props: any) {
|
||||
return React.createElement("span", { "data-testid": "antd-icon" });
|
||||
}
|
||||
|
||||
return {
|
||||
GlobalOutlined: Icon,
|
||||
BankOutlined: Icon,
|
||||
TeamOutlined: Icon,
|
||||
ShoppingCartOutlined: Icon,
|
||||
TagsOutlined: Icon,
|
||||
RobotOutlined: Icon,
|
||||
UserOutlined: Icon,
|
||||
LineChartOutlined: Icon,
|
||||
BarChartOutlined: Icon,
|
||||
};
|
||||
});
|
||||
// The listbox is portalled outside the render container in both antd and Base UI, so an
|
||||
// option is "offered" when the label appears more times on the page than inside the trigger.
|
||||
const offers = (container: HTMLElement, label: string) =>
|
||||
screen.queryAllByText(label).length > within(container).queryAllByText(label).length;
|
||||
|
||||
describe("UsageViewSelect", () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
|
@ -89,53 +19,73 @@ describe("UsageViewSelect", () => {
|
|||
mockOnChange.mockClear();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
|
||||
it("should render", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
|
||||
|
||||
expect(screen.getByText("Usage View")).toBeInTheDocument();
|
||||
expect(screen.getByText("Select the usage data you want to view")).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Your Usage" })).toBeInTheDocument();
|
||||
|
||||
await openMenu(user);
|
||||
expect(offers(container, "Your Usage")).toBe(true);
|
||||
});
|
||||
|
||||
it("should call onChange when value changes", () => {
|
||||
it("should call onChange when value changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
act(() => {
|
||||
fireEvent.change(select, { target: { value: "team" } });
|
||||
});
|
||||
await openMenu(user);
|
||||
const matches = screen.getAllByText("Team Usage");
|
||||
await user.click(matches[matches.length - 1]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith("team");
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
expect(mockOnChange.mock.calls[0][0]).toBe("team");
|
||||
});
|
||||
|
||||
it("should show Tag Usage for non-admin users with tag usage permission", () => {
|
||||
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />);
|
||||
it("should show Tag Usage for non-admin users with tag usage permission", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("option", { name: "Tag Usage" })).toBeInTheDocument();
|
||||
await openMenu(user);
|
||||
expect(offers(container, "Tag Usage")).toBe(true);
|
||||
});
|
||||
|
||||
it("should hide Tag Usage for non-admin users without tag usage permission", () => {
|
||||
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
|
||||
it("should hide Tag Usage for non-admin users without tag usage permission", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
|
||||
|
||||
expect(screen.queryByRole("option", { name: "Tag Usage" })).not.toBeInTheDocument();
|
||||
await openMenu(user);
|
||||
expect(offers(container, "Tag Usage")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", (optionName) => {
|
||||
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
|
||||
|
||||
expect(screen.getByRole("option", { name: optionName })).toBeInTheDocument();
|
||||
await openMenu(user);
|
||||
expect(offers(container, optionName)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", (optionName) => {
|
||||
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />);
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("option", { name: optionName })).not.toBeInTheDocument();
|
||||
await openMenu(user);
|
||||
expect(offers(container, optionName)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", (optionName) => {
|
||||
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />);
|
||||
it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("option", { name: optionName })).toBeInTheDocument();
|
||||
await openMenu(user);
|
||||
expect(offers(container, optionName)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
import {
|
||||
BankOutlined,
|
||||
BarChartOutlined,
|
||||
GlobalOutlined,
|
||||
LineChartOutlined,
|
||||
RobotOutlined,
|
||||
ShoppingCartOutlined,
|
||||
TagsOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Badge, Select } from "antd";
|
||||
import { BarChart3, Bot, Building2, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { hasCapability, type Capability } from "@/utils/capabilities";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
export type UsageOption =
|
||||
|
|
@ -54,61 +45,61 @@ const OPTIONS: OptionConfig[] = [
|
|||
description: "View usage across all resources",
|
||||
descriptionForAdmin: "View usage across all resources",
|
||||
descriptionForNonAdmin: "View your usage",
|
||||
icon: <GlobalOutlined style={{ fontSize: "16px" }} />,
|
||||
icon: <Globe className="size-4" />,
|
||||
},
|
||||
{
|
||||
value: "my-usage",
|
||||
label: "Your Usage",
|
||||
description: "View your own usage",
|
||||
icon: <UserOutlined style={{ fontSize: "16px" }} />,
|
||||
icon: <User className="size-4" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "organization",
|
||||
label: "Organization Usage",
|
||||
description: "View usage across all organizations",
|
||||
icon: <BankOutlined style={{ fontSize: "16px" }} />,
|
||||
icon: <Building2 className="size-4" />,
|
||||
capability: "viewOrganizationUsage",
|
||||
},
|
||||
{
|
||||
value: "team",
|
||||
label: "Team Usage",
|
||||
description: "View usage by team",
|
||||
icon: <TeamOutlined style={{ fontSize: "16px" }} />,
|
||||
icon: <Users className="size-4" />,
|
||||
},
|
||||
{
|
||||
value: "customer",
|
||||
label: "Customer Usage",
|
||||
description: "View usage by customer accounts",
|
||||
icon: <ShoppingCartOutlined style={{ fontSize: "16px" }} />,
|
||||
icon: <ShoppingCart className="size-4" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "tag",
|
||||
label: "Tag Usage",
|
||||
description: "View usage grouped by tags",
|
||||
icon: <TagsOutlined style={{ fontSize: "16px" }} />,
|
||||
icon: <Tags className="size-4" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "agent",
|
||||
label: "Agent Usage (A2A)",
|
||||
description: "View usage by AI agents",
|
||||
icon: <RobotOutlined style={{ fontSize: "16px" }} />,
|
||||
icon: <Bot className="size-4" />,
|
||||
capability: "viewAgentUsage",
|
||||
},
|
||||
{
|
||||
value: "user",
|
||||
label: "User Usage",
|
||||
description: "View usage by individual users",
|
||||
icon: <UserOutlined style={{ fontSize: "16px" }} />,
|
||||
icon: <User className="size-4" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "user-agent-activity",
|
||||
label: "User Agent Activity",
|
||||
description: "View detailed user agent activity logs",
|
||||
icon: <LineChartOutlined style={{ fontSize: "16px" }} />,
|
||||
icon: <LineChart className="size-4" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
|
|
@ -153,12 +144,13 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
|||
});
|
||||
};
|
||||
const filteredOptions = getFilteredOptions();
|
||||
const selectedOption = filteredOptions.find((option) => option.value === value);
|
||||
return (
|
||||
<div className="w-full" data-id={dataId}>
|
||||
<div className="flex flex-wrap items-center justify-start gap-4">
|
||||
<div className="flex items-stretch gap-2 min-w-0">
|
||||
<div className="shrink-0 flex items-center">
|
||||
<BarChartOutlined style={{ fontSize: "32px" }} />
|
||||
<BarChart3 className="size-8" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-0.5 leading-tight">{title}</h3>
|
||||
|
|
@ -168,42 +160,35 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
|||
<div className="shrink-0">
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-54 sm:w-64 md:w-72"
|
||||
size="large"
|
||||
options={filteredOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
}))}
|
||||
optionRender={(option) => {
|
||||
const opt = filteredOptions.find((o) => o.value === option.value);
|
||||
if (!opt) return option.label;
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div className="shrink-0 mt-0.5">{opt.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900">{opt.label}</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">{opt.description}</div>
|
||||
</div>
|
||||
{opt.badgeText && (
|
||||
<div className="items-center">
|
||||
<Badge color="blue" count={opt.badgeText} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
onValueChange={(next: UsageOption | null) => {
|
||||
if (next) onChange(next);
|
||||
}}
|
||||
labelRender={(props) => {
|
||||
const opt = filteredOptions.find((o) => o.value === props.value);
|
||||
if (!opt) return props.label;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{opt.icon}</div>
|
||||
<span className="text-sm">{opt.label}</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="w-54 sm:w-64 md:w-72">
|
||||
<SelectValue>
|
||||
{selectedOption && (
|
||||
<span className="flex items-center gap-2">
|
||||
{selectedOption.icon}
|
||||
<span className="text-sm">{selectedOption.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span className="flex items-center gap-2 py-1">
|
||||
<span className="shrink-0 mt-0.5">{option.icon}</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium text-gray-900">{option.label}</span>
|
||||
<span className="block text-xs text-gray-600 mt-0.5">{option.description}</span>
|
||||
</span>
|
||||
{option.badgeText && <Badge>{option.badgeText}</Badge>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { createTeamAliasMap } from "@/utils/teamUtils";
|
||||
import { Button, Modal, Skeleton } from "antd";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import ExportFormatSelector from "./ExportFormatSelector";
|
||||
import ExportSummary from "./ExportSummary";
|
||||
|
|
@ -49,45 +52,51 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<span className="text-base font-semibold">{modalTitle}</span>}
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={480}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-5 py-2">
|
||||
{isLoadingTeams ? (
|
||||
<Skeleton active />
|
||||
) : (
|
||||
<>
|
||||
<ExportSummary dateRange={dateRange} selectedFilters={selectedFilters} />
|
||||
<ExportTypeSelector value={exportScope} onChange={setExportScope} entityType={entityType} />
|
||||
<ExportFormatSelector value={exportFormat} onChange={setExportFormat} />
|
||||
</>
|
||||
)}
|
||||
{isLoadingTeams ? (
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">{modalTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 py-2">
|
||||
{isLoadingTeams ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ExportSummary dateRange={dateRange} selectedFilters={selectedFilters} />
|
||||
<ExportTypeSelector value={exportScope} onChange={setExportScope} entityType={entityType} />
|
||||
<ExportFormatSelector value={exportFormat} onChange={setExportFormat} />
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
||||
<Skeleton.Button active />
|
||||
<Skeleton.Button active />
|
||||
{isLoadingTeams ? (
|
||||
<>
|
||||
<Skeleton className="h-9 w-20" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={isExporting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => handleExport()} disabled={isExporting}>
|
||||
{isExporting && <Loader2 className="animate-spin" />}
|
||||
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outlined" onClick={onClose} disabled={isExporting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleExport()}
|
||||
loading={isExporting || isLoadingTeams}
|
||||
disabled={isExporting || isLoadingTeams}
|
||||
type="primary"
|
||||
>
|
||||
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Select } from "antd";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { ExportFormat } from "./types";
|
||||
|
||||
interface ExportFormatSelectorProps {
|
||||
|
|
@ -7,25 +7,27 @@ interface ExportFormatSelectorProps {
|
|||
onChange: (value: ExportFormat) => void;
|
||||
}
|
||||
|
||||
const FORMAT_LABELS: Record<ExportFormat, string> = {
|
||||
csv: "CSV (Excel, Google Sheets)",
|
||||
json: "JSON (includes metadata)",
|
||||
};
|
||||
|
||||
const ExportFormatSelector: React.FC<ExportFormatSelectorProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-2">Format</label>
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
options={[
|
||||
{
|
||||
value: "csv",
|
||||
label: "CSV (Excel, Google Sheets)",
|
||||
},
|
||||
{
|
||||
value: "json",
|
||||
label: "JSON (includes metadata)",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Select value={value} onValueChange={(next: ExportFormat | null) => next && onChange(next)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{FORMAT_LABELS[value]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(FORMAT_LABELS) as ExportFormat[]).map((format) => (
|
||||
<SelectItem key={format} value={format}>
|
||||
{FORMAT_LABELS[format]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Radio } from "antd";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import type { ExportScope, EntityType } from "./types";
|
||||
|
||||
interface ExportTypeSelectorProps {
|
||||
|
|
@ -9,36 +9,41 @@ interface ExportTypeSelectorProps {
|
|||
}
|
||||
|
||||
const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange, entityType }) => {
|
||||
const scopes: { value: ExportScope; title: string; description: string }[] = [
|
||||
{
|
||||
value: "daily",
|
||||
title: `Day-by-day breakdown by ${entityType}`,
|
||||
description: `Daily metrics for each ${entityType}`,
|
||||
},
|
||||
{
|
||||
value: "daily_with_keys",
|
||||
title: `Day-by-day breakdown by ${entityType} and key`,
|
||||
description: `Daily metrics for each ${entityType}, split by API key`,
|
||||
},
|
||||
{
|
||||
value: "daily_with_models",
|
||||
title: `Day-by-day by ${entityType} and model`,
|
||||
description: "Daily metrics split by model",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-2">Export type</label>
|
||||
<Radio.Group value={value} onChange={(e) => onChange(e.target.value)} className="w-full">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<Radio value="daily" className="mt-0.5" />
|
||||
<RadioGroup value={value} onValueChange={(next) => onChange(next as ExportScope)} className="gap-2">
|
||||
{scopes.map((scope) => (
|
||||
<label
|
||||
key={scope.value}
|
||||
className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<RadioGroupItem value={scope.value} className="mt-0.5" />
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="font-medium text-sm">Day-by-day breakdown by {entityType}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">Daily metrics for each {entityType}</div>
|
||||
<div className="font-medium text-sm">{scope.title}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{scope.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<Radio value="daily_with_keys" className="mt-0.5" />
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="font-medium text-sm">Day-by-day breakdown by {entityType} and key</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">Daily metrics for each {entityType}, split by API key</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<Radio value="daily_with_models" className="mt-0.5" />
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="font-medium text-sm">Day-by-day by {entityType} and model</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">Daily metrics split by model</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
import type { DateRangePickerValue } from "@tremor/react";
|
||||
import { Button, Text } from "@tremor/react";
|
||||
import { Select } from "antd";
|
||||
import { Download } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxClear,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
} from "@/components/ui/combobox";
|
||||
import EntityUsageExportModal from "./EntityUsageExportModal";
|
||||
import type { EntitySpendData, EntityType } from "./types";
|
||||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
|
|
@ -40,13 +53,22 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
}) => {
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
|
||||
// Determine grid layout based on what's visible
|
||||
const getGridCols = () => {
|
||||
const hasFilters = showFilters && filterOptions.length > 0;
|
||||
const hasFilters = showFilters && filterOptions.length > 0;
|
||||
const optionValues = filterOptions.map((option) => option.value);
|
||||
const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value;
|
||||
|
||||
if (hasFilters) return "grid-cols-[1fr_auto]";
|
||||
return "grid-cols-[auto]";
|
||||
};
|
||||
const filterList = (
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No options found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(value: string) => (
|
||||
<ComboboxItem key={value} value={value}>
|
||||
{labelOf(value)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -56,42 +78,58 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
* align to the same baseline regardless of label heights. This removes
|
||||
* vertical drift when the right column has a label above the input.
|
||||
*/}
|
||||
<div className={`grid ${getGridCols()} items-end gap-4`}>
|
||||
{showFilters && filterOptions.length > 0 && (
|
||||
<div className={`grid ${hasFilters ? "grid-cols-[1fr_auto]" : "grid-cols-[auto]"} items-end gap-4`}>
|
||||
{hasFilters && (
|
||||
<div>
|
||||
{filterLabel && <Text className="mb-2">{filterLabel}</Text>}
|
||||
<Select
|
||||
mode={filterMode === "single" ? undefined : "multiple"}
|
||||
style={{ width: "100%" }}
|
||||
placeholder={filterPlaceholder}
|
||||
value={filterMode === "single" ? selectedFilters[0] ?? undefined : selectedFilters}
|
||||
onChange={(value: any) => {
|
||||
if (filterMode === "single") {
|
||||
onFiltersChange?.(value ? [value] : []);
|
||||
} else {
|
||||
onFiltersChange?.(value);
|
||||
}
|
||||
}}
|
||||
options={filterOptions}
|
||||
allowClear
|
||||
/>
|
||||
{filterLabel && <label className="text-sm font-medium text-gray-700 block mb-2">{filterLabel}</label>}
|
||||
{filterMode === "single" ? (
|
||||
<Combobox
|
||||
items={optionValues}
|
||||
value={selectedFilters[0] ?? null}
|
||||
onValueChange={(next: string | null) => onFiltersChange?.(next ? [next] : [])}
|
||||
itemToStringLabel={labelOf}
|
||||
>
|
||||
<ComboboxInput
|
||||
className="w-full"
|
||||
placeholder={filterPlaceholder}
|
||||
aria-label={filterPlaceholder}
|
||||
showClear={selectedFilters.length > 0}
|
||||
/>
|
||||
{filterList}
|
||||
</Combobox>
|
||||
) : (
|
||||
<Combobox
|
||||
multiple
|
||||
items={optionValues}
|
||||
value={selectedFilters}
|
||||
onValueChange={(next: string[]) => onFiltersChange?.(next)}
|
||||
>
|
||||
<ComboboxChips className="w-full">
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) =>
|
||||
selected.map((value) => (
|
||||
<ComboboxChip key={value} aria-label={labelOf(value)}>
|
||||
{labelOf(value)}
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput
|
||||
className="border-0 bg-transparent"
|
||||
placeholder={filterPlaceholder}
|
||||
aria-label={filterPlaceholder}
|
||||
/>
|
||||
{selectedFilters.length > 0 && <ComboboxClear aria-label={`Clear ${filterLabel ?? "filters"}`} />}
|
||||
</ComboboxChips>
|
||||
{filterList}
|
||||
</Combobox>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="justify-self-end">
|
||||
<Button
|
||||
onClick={() => setIsExportModalOpen(true)}
|
||||
icon={() => (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
>
|
||||
<Button onClick={() => setIsExportModalOpen(true)}>
|
||||
<Download />
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { ActivityMetrics, formatKeyLabel, processActivityData } from "./activity_metrics";
|
||||
|
|
@ -15,38 +15,12 @@ beforeAll(() => {
|
|||
}
|
||||
});
|
||||
|
||||
vi.mock("@tremor/react", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Grid: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Text: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
Title: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||
AreaChart: () => <div>AreaChart</div>,
|
||||
BarChart: () => <div>BarChart</div>,
|
||||
}));
|
||||
|
||||
vi.mock("antd", () => {
|
||||
const CollapseComponent = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
||||
const PanelComponent = ({ children, header }: { children: React.ReactNode; header: React.ReactNode }) => (
|
||||
<div>
|
||||
<div>{header}</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
PanelComponent.displayName = "Collapse.Panel";
|
||||
CollapseComponent.Panel = PanelComponent;
|
||||
const TableComponent = ({ dataSource, columns }: { dataSource?: unknown[]; columns?: { title: string }[] }) => (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{columns?.map((col, i) => <th key={i}>{col.title}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>{dataSource?.map((_, i) => <tr key={i} />)}</tbody>
|
||||
</table>
|
||||
);
|
||||
return {
|
||||
Collapse: CollapseComponent,
|
||||
Table: TableComponent,
|
||||
};
|
||||
});
|
||||
// Panel order is a contract; which element the label lands in is not, so compare document order.
|
||||
const precedes = (firstLabel: string, secondLabel: string): boolean => {
|
||||
const first = screen.getAllByText(firstLabel)[0];
|
||||
const second = screen.getAllByText(secondLabel)[0];
|
||||
return Boolean(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
};
|
||||
|
||||
vi.mock("@/utils/dataUtils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/utils/dataUtils")>();
|
||||
|
|
@ -256,10 +230,7 @@ describe("ActivityMetrics", () => {
|
|||
};
|
||||
|
||||
render(<ActivityMetrics modelMetrics={multipleModels} />);
|
||||
const headers = screen.getAllByRole("heading", { level: 2 });
|
||||
const gpt4Index = headers.findIndex((h) => h.textContent?.includes("GPT-4"));
|
||||
const gpt35Index = headers.findIndex((h) => h.textContent?.includes("GPT-3.5"));
|
||||
expect(gpt4Index).toBeLessThan(gpt35Index);
|
||||
expect(precedes("GPT-4", "GPT-3.5")).toBe(true);
|
||||
});
|
||||
|
||||
it("should display model summary cards with correct values", () => {
|
||||
|
|
@ -387,10 +358,27 @@ describe("ActivityMetrics", () => {
|
|||
};
|
||||
|
||||
render(<ActivityMetrics modelMetrics={modelsWithEmptyKey} />);
|
||||
const headings = screen.getAllByRole("heading", { level: 2 });
|
||||
const gpt4Index = headings.findIndex((h) => h.textContent?.includes("GPT-4"));
|
||||
const unknownIndex = headings.findIndex((h) => h.textContent?.includes("Unknown"));
|
||||
expect(gpt4Index).toBeLessThan(unknownIndex);
|
||||
expect(precedes("GPT-4", "Unknown")).toBe(true);
|
||||
});
|
||||
|
||||
// A model section owns view-mode state, so collapsing one must not throw its subtree away.
|
||||
it("keeps a model section mounted once it has been expanded", () => {
|
||||
const multipleModels: Record<string, ModelActivityData> = {
|
||||
"gpt-3.5": GPT_35_MODEL_DATA,
|
||||
"gpt-4": { ...mockModelMetrics["gpt-4"], total_spend: 100.5 },
|
||||
};
|
||||
|
||||
render(<ActivityMetrics modelMetrics={multipleModels} />);
|
||||
|
||||
// Only the highest-spend section is expanded initially, so only its body is mounted.
|
||||
const sectionsMounted = () => screen.getAllByText("Spend per day").length;
|
||||
expect(sectionsMounted()).toBe(1);
|
||||
|
||||
fireEvent.click(screen.getAllByText("GPT-3.5")[0]);
|
||||
expect(sectionsMounted()).toBe(2);
|
||||
|
||||
fireEvent.click(screen.getAllByText("GPT-3.5")[0]);
|
||||
expect(sectionsMounted()).toBe(2);
|
||||
});
|
||||
|
||||
it("should display average tokens per successful request", () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { AreaChart, BarChart, CustomLegend, CustomTooltip } from "@/components/shared/charts";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { resolveTeamAliasFromTeamID } from "@/utils/teamUtils";
|
||||
import { Card, Grid, Text, Title } from "@tremor/react";
|
||||
import { Collapse } from "antd";
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { Team } from "./key_team_helpers/key_list";
|
||||
import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView";
|
||||
import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopApiKeyData, TopModelData } from "./UsagePage/types";
|
||||
|
|
@ -26,50 +27,65 @@ const ModelSection = ({
|
|||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Summary Cards */}
|
||||
<Grid numItems={4} className="gap-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<Text>Total Requests</Text>
|
||||
<Title>{metrics.total_requests.toLocaleString()}</Title>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Requests</p>
|
||||
<h3 className="text-lg font-medium text-foreground">{metrics.total_requests.toLocaleString()}</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Total Successful Requests</Text>
|
||||
<Title>{metrics.total_successful_requests.toLocaleString()}</Title>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Successful Requests</p>
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{metrics.total_successful_requests.toLocaleString()}
|
||||
</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Total Tokens</Text>
|
||||
<Title>{metrics.total_tokens.toLocaleString()}</Title>
|
||||
<Text>{Math.round(metrics.total_tokens / metrics.total_successful_requests)} avg per successful request</Text>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Tokens</p>
|
||||
<h3 className="text-lg font-medium text-foreground">{metrics.total_tokens.toLocaleString()}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{Math.round(metrics.total_tokens / metrics.total_successful_requests)} avg per successful request
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Total Spend</Text>
|
||||
<Title>${formatNumberWithCommas(metrics.total_spend, 2)}</Title>
|
||||
<Text>
|
||||
${formatNumberWithCommas(metrics.total_spend / metrics.total_successful_requests, 3)} per successful request
|
||||
</Text>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Spend</p>
|
||||
<h3 className="text-lg font-medium text-foreground">${formatNumberWithCommas(metrics.total_spend, 2)}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
${formatNumberWithCommas(metrics.total_spend / metrics.total_successful_requests, 3)} per successful
|
||||
request
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
{metrics.top_api_keys && metrics.top_api_keys.length > 0 && (
|
||||
<Card className="mt-4">
|
||||
<Title>Top Virtual Keys by Spend</Title>
|
||||
<div className="mt-3">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{metrics.top_api_keys.map((keyData, index) => (
|
||||
<div key={keyData.api_key} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<Text className="font-medium">{keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`}</Text>
|
||||
{keyData.team_id && <Text className="text-xs text-gray-500">Team: {keyData.team_id}</Text>}
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
|
||||
<div className="mt-3">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{metrics.top_api_keys.map((keyData) => (
|
||||
<div key={keyData.api_key} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`}</p>
|
||||
{keyData.team_id && <p className="text-xs text-gray-500">Team: {keyData.team_id}</p>}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">${formatNumberWithCommas(keyData.spend, 2)}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Text className="font-medium">${formatNumberWithCommas(keyData.spend, 2)}</Text>
|
||||
<Text className="text-xs text-gray-500">
|
||||
{keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
|
@ -77,110 +93,156 @@ const ModelSection = ({
|
|||
|
||||
{/* Spend per day - Full width card */}
|
||||
<Card className="mt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Spend per day</Title>
|
||||
<CustomLegend categories={["metrics.spend"]} colors={["green"]} />
|
||||
</div>
|
||||
<BarChart
|
||||
className="mt-4"
|
||||
data={metrics.daily_data}
|
||||
index="date"
|
||||
categories={["metrics.spend"]}
|
||||
colors={["green"]}
|
||||
valueFormatter={(value: number) => `$${formatNumberWithCommas(value, 2, true)}`}
|
||||
yAxisWidth={72}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Charts */}
|
||||
<Grid numItems={2} className="gap-4 mt-4">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Total Tokens</Title>
|
||||
<CustomLegend
|
||||
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
|
||||
colors={["blue", "cyan", "indigo"]}
|
||||
/>
|
||||
</div>
|
||||
<AreaChart
|
||||
className="mt-4"
|
||||
data={metrics.daily_data}
|
||||
index="date"
|
||||
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
|
||||
colors={["blue", "cyan", "indigo"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Requests per day</Title>
|
||||
<CustomLegend categories={["metrics.api_requests"]} colors={["blue"]} />
|
||||
<h3 className="text-lg font-medium text-foreground">Spend per day</h3>
|
||||
<CustomLegend categories={["metrics.spend"]} colors={["green"]} />
|
||||
</div>
|
||||
<BarChart
|
||||
className="mt-4"
|
||||
data={metrics.daily_data}
|
||||
index="date"
|
||||
categories={["metrics.api_requests"]}
|
||||
colors={["blue"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
categories={["metrics.spend"]}
|
||||
colors={["green"]}
|
||||
valueFormatter={(value: number) => `$${formatNumberWithCommas(value, 2, true)}`}
|
||||
yAxisWidth={72}
|
||||
/>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<Card>
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Success vs Failed Requests</Title>
|
||||
<CustomLegend
|
||||
categories={["metrics.successful_requests", "metrics.failed_requests"]}
|
||||
colors={["green", "red"]}
|
||||
/>
|
||||
</div>
|
||||
<AreaChart
|
||||
className="mt-4"
|
||||
data={metrics.daily_data}
|
||||
index="date"
|
||||
categories={["metrics.successful_requests", "metrics.failed_requests"]}
|
||||
colors={["green", "red"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{!hidePromptCachingMetrics && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Prompt Caching Metrics</Title>
|
||||
<h3 className="text-lg font-medium text-foreground">Total Tokens</h3>
|
||||
<CustomLegend
|
||||
categories={["metrics.cache_read_input_tokens", "metrics.cache_creation_input_tokens"]}
|
||||
colors={["cyan", "purple"]}
|
||||
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
|
||||
colors={["blue", "cyan", "indigo"]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<Text>Cache Read: {metrics.total_cache_read_input_tokens?.toLocaleString() || 0} tokens</Text>
|
||||
<Text>Cache Creation: {metrics.total_cache_creation_input_tokens?.toLocaleString() || 0} tokens</Text>
|
||||
</div>
|
||||
<AreaChart
|
||||
className="mt-4"
|
||||
data={metrics.daily_data}
|
||||
index="date"
|
||||
categories={["metrics.cache_read_input_tokens", "metrics.cache_creation_input_tokens"]}
|
||||
colors={["cyan", "purple"]}
|
||||
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
|
||||
colors={["blue", "cyan", "indigo"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-foreground">Requests per day</h3>
|
||||
<CustomLegend categories={["metrics.api_requests"]} colors={["blue"]} />
|
||||
</div>
|
||||
<BarChart
|
||||
className="mt-4"
|
||||
data={metrics.daily_data}
|
||||
index="date"
|
||||
categories={["metrics.api_requests"]}
|
||||
colors={["blue"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-foreground">Success vs Failed Requests</h3>
|
||||
<CustomLegend
|
||||
categories={["metrics.successful_requests", "metrics.failed_requests"]}
|
||||
colors={["green", "red"]}
|
||||
/>
|
||||
</div>
|
||||
<AreaChart
|
||||
className="mt-4"
|
||||
data={metrics.daily_data}
|
||||
index="date"
|
||||
categories={["metrics.successful_requests", "metrics.failed_requests"]}
|
||||
colors={["green", "red"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!hidePromptCachingMetrics && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-foreground">Prompt Caching Metrics</h3>
|
||||
<CustomLegend
|
||||
categories={["metrics.cache_read_input_tokens", "metrics.cache_creation_input_tokens"]}
|
||||
colors={["cyan", "purple"]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<p className="text-sm">
|
||||
Cache Read: {metrics.total_cache_read_input_tokens?.toLocaleString() || 0} tokens
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Cache Creation: {metrics.total_cache_creation_input_tokens?.toLocaleString() || 0} tokens
|
||||
</p>
|
||||
</div>
|
||||
<AreaChart
|
||||
className="mt-4"
|
||||
data={metrics.daily_data}
|
||||
index="date"
|
||||
categories={["metrics.cache_read_input_tokens", "metrics.cache_creation_input_tokens"]}
|
||||
colors={["cyan", "purple"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ModelCollapsible = ({
|
||||
defaultOpen,
|
||||
header,
|
||||
children,
|
||||
}: {
|
||||
defaultOpen: boolean;
|
||||
header: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [everOpened, setEverOpened] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next) setEverOpened(true);
|
||||
}}
|
||||
className="border-b last:border-b-0"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 px-4 py-3 text-left">
|
||||
<ChevronDown className={`size-4 shrink-0 text-gray-400 transition-transform ${open ? "" : "-rotate-90"}`} />
|
||||
{header}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent keepMounted={everOpened} className="px-4 pb-4">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics, hidePromptCachingMetrics = false }) => {
|
||||
const modelNames = Object.keys(modelMetrics).sort((a, b) => {
|
||||
if (a === "") return 1;
|
||||
|
|
@ -257,78 +319,97 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
|
|||
<div className="space-y-8">
|
||||
{/* Global Summary */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<Title>Overall Usage</Title>
|
||||
<Grid numItems={4} className="gap-4 mb-4">
|
||||
<h3 className="text-lg font-medium text-foreground">Overall Usage</h3>
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
<Card>
|
||||
<Text>Total Requests</Text>
|
||||
<Title>{totalMetrics.total_requests.toLocaleString()}</Title>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Requests</p>
|
||||
<h3 className="text-lg font-medium text-foreground">{totalMetrics.total_requests.toLocaleString()}</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Total Successful Requests</Text>
|
||||
<Title>{totalMetrics.total_successful_requests.toLocaleString()}</Title>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Successful Requests</p>
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{totalMetrics.total_successful_requests.toLocaleString()}
|
||||
</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Total Tokens</Text>
|
||||
<Title>{totalMetrics.total_tokens.toLocaleString()}</Title>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Tokens</p>
|
||||
<h3 className="text-lg font-medium text-foreground">{totalMetrics.total_tokens.toLocaleString()}</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Total Spend</Text>
|
||||
<Title>${formatNumberWithCommas(totalMetrics.total_spend, 2)}</Title>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Spend</p>
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
${formatNumberWithCommas(totalMetrics.total_spend, 2)}
|
||||
</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<Grid numItems={2} className="gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Total Tokens Over Time</Title>
|
||||
<CustomLegend
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-foreground">Total Tokens Over Time</h3>
|
||||
<CustomLegend
|
||||
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
|
||||
colors={["blue", "cyan", "indigo"]}
|
||||
/>
|
||||
</div>
|
||||
<AreaChart
|
||||
className="mt-4"
|
||||
data={sortedDailyData}
|
||||
index="date"
|
||||
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
|
||||
colors={["blue", "cyan", "indigo"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
yAxisWidth={80}
|
||||
/>
|
||||
</div>
|
||||
<AreaChart
|
||||
className="mt-4"
|
||||
data={sortedDailyData}
|
||||
index="date"
|
||||
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
|
||||
colors={["blue", "cyan", "indigo"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
yAxisWidth={80}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Total Requests Over Time</Title>
|
||||
<CustomLegend
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-foreground">Total Requests Over Time</h3>
|
||||
<CustomLegend
|
||||
categories={["metrics.successful_requests", "metrics.failed_requests"]}
|
||||
colors={["emerald", "red"]}
|
||||
/>
|
||||
</div>
|
||||
<AreaChart
|
||||
className="mt-4"
|
||||
data={sortedDailyData}
|
||||
index="date"
|
||||
categories={["metrics.successful_requests", "metrics.failed_requests"]}
|
||||
colors={["emerald", "red"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
yAxisWidth={80}
|
||||
/>
|
||||
</div>
|
||||
<AreaChart
|
||||
className="mt-4"
|
||||
data={sortedDailyData}
|
||||
index="date"
|
||||
categories={["metrics.successful_requests", "metrics.failed_requests"]}
|
||||
colors={["emerald", "red"]}
|
||||
valueFormatter={valueFormatter}
|
||||
customTooltip={CustomTooltip}
|
||||
showLegend={false}
|
||||
yAxisWidth={80}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Individual Model Sections */}
|
||||
<Collapse defaultActiveKey={modelNames[0]}>
|
||||
<div className="rounded-lg border">
|
||||
{modelNames.map((modelName) => (
|
||||
<Collapse.Panel
|
||||
<ModelCollapsible
|
||||
key={modelName}
|
||||
defaultOpen={modelName === modelNames[0]}
|
||||
header={
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<Title>{modelMetrics[modelName].label || "Unknown Item"}</Title>
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{modelMetrics[modelName].label || "Unknown Item"}
|
||||
</h3>
|
||||
<div className="flex space-x-4 text-sm text-gray-500">
|
||||
<span>${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)}</span>
|
||||
<span>{modelMetrics[modelName].total_requests.toLocaleString()} requests</span>
|
||||
|
|
@ -341,9 +422,9 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
|
|||
metrics={modelMetrics[modelName]}
|
||||
hidePromptCachingMetrics={hidePromptCachingMetrics}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</ModelCollapsible>
|
||||
))}
|
||||
</Collapse>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import TeamMultiSelect from "./team_multi_select";
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useInfiniteTeams: vi.fn(),
|
||||
}));
|
||||
|
||||
const team = (id: string, alias: string) => ({ team_id: id, team_alias: alias });
|
||||
|
||||
const mockTeamsResult = (
|
||||
overrides: Partial<{
|
||||
pages: { teams: ReturnType<typeof team>[] }[];
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
}> = {},
|
||||
) => {
|
||||
const { pages = [{ teams: [team("team-1", "Alpha Team"), team("team-2", "Beta Team")] }], ...rest } = overrides;
|
||||
return {
|
||||
data: { pages },
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
describe("TeamMultiSelect", () => {
|
||||
const mockUseInfiniteTeams = vi.mocked(useInfiniteTeams);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseInfiniteTeams.mockReturnValue(mockTeamsResult() as never);
|
||||
});
|
||||
|
||||
const combobox = () => screen.getByRole("combobox");
|
||||
|
||||
// One library paints the prompt as its own text node and the other leaves it on the input's
|
||||
// placeholder attribute, so either one means the user is being told what to type.
|
||||
const promptsWith = (text: string) =>
|
||||
screen.queryAllByText(text).length + screen.queryAllByPlaceholderText(text).length > 0;
|
||||
|
||||
it("renders a search control with the given placeholder", () => {
|
||||
render(<TeamMultiSelect placeholder="Search teams by alias..." />);
|
||||
|
||||
expect(combobox()).toBeInTheDocument();
|
||||
expect(promptsWith("Search teams by alias...")).toBe(true);
|
||||
});
|
||||
|
||||
it("offers every loaded team by alias and id", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TeamMultiSelect />);
|
||||
|
||||
await user.click(combobox());
|
||||
|
||||
expect(screen.getByText("Alpha Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("(team-1)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Beta Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("(team-2)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deduplicates a team that appears on more than one page", async () => {
|
||||
mockUseInfiniteTeams.mockReturnValue(
|
||||
mockTeamsResult({
|
||||
pages: [
|
||||
{ teams: [team("team-1", "Alpha Team")] },
|
||||
{ teams: [team("team-1", "Alpha Team"), team("team-2", "Beta Team")] },
|
||||
],
|
||||
}) as never,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<TeamMultiSelect />);
|
||||
|
||||
await user.click(combobox());
|
||||
|
||||
expect(screen.getAllByText("Alpha Team")).toHaveLength(1);
|
||||
expect(screen.getByText("Beta Team")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports the picked team id to onChange", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<TeamMultiSelect onChange={onChange} />);
|
||||
|
||||
await user.click(combobox());
|
||||
const matches = screen.getAllByText("Beta Team");
|
||||
await user.click(matches[matches.length - 1]);
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
expect(onChange.mock.calls[0][0]).toEqual(["team-2"]);
|
||||
});
|
||||
|
||||
it("does not report a selection while disabled", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<TeamMultiSelect onChange={onChange} disabled />);
|
||||
|
||||
await user.click(combobox());
|
||||
|
||||
expect(screen.queryByText("Alpha Team")).not.toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells the user when there are no teams to pick", async () => {
|
||||
mockUseInfiniteTeams.mockReturnValue(mockTeamsResult({ pages: [{ teams: [] }] }) as never);
|
||||
const user = userEvent.setup();
|
||||
render(<TeamMultiSelect />);
|
||||
|
||||
await user.click(combobox());
|
||||
|
||||
// Substring match because one library appends an invisible word joiner for its live region.
|
||||
expect(screen.getByText(/No teams found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the page size and organization filter through to the teams query", () => {
|
||||
render(<TeamMultiSelect pageSize={25} organizationId="org-7" />);
|
||||
|
||||
expect(mockUseInfiniteTeams).toHaveBeenCalledWith(25, undefined, "org-7");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,22 @@
|
|||
import React, { useMemo, useState, type UIEvent } from "react";
|
||||
import { Select, Typography } from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxClear,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
} from "@/components/ui/combobox";
|
||||
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TeamMultiSelectProps {
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
|
|
@ -27,77 +36,82 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
|
|||
pageSize = 20,
|
||||
placeholder = "Search teams by alias...",
|
||||
}) => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
|
||||
wait: DEBOUNCE_WAIT_MS,
|
||||
});
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSetSearch = useDebouncedCallback(setSearch, { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams(
|
||||
pageSize,
|
||||
debouncedSearch || undefined,
|
||||
search || undefined,
|
||||
organizationId,
|
||||
);
|
||||
|
||||
const teams = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: Team[] = [];
|
||||
for (const page of data.pages) {
|
||||
for (const team of page.teams) {
|
||||
if (seen.has(team.team_id)) continue;
|
||||
seen.add(team.team_id);
|
||||
result.push(team);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [data]);
|
||||
const teamById = useMemo(
|
||||
() =>
|
||||
new Map<string, Team>(
|
||||
(data?.pages ?? []).flatMap((page) => page.teams).map((team) => [team.team_id, team] as const),
|
||||
),
|
||||
[data],
|
||||
);
|
||||
const teamIds = useMemo(() => Array.from(teamById.keys()), [teamById]);
|
||||
|
||||
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
const aliasOf = (teamId: string) => teamById.get(teamId)?.team_alias ?? teamId;
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
if (target.scrollHeight === 0) return;
|
||||
const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
|
||||
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (val: string) => {
|
||||
setSearchInput(val);
|
||||
setDebouncedSearch(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
placeholder={placeholder}
|
||||
<Combobox
|
||||
multiple
|
||||
items={teamIds}
|
||||
value={value}
|
||||
onChange={(val: string[]) => onChange?.(val)}
|
||||
onValueChange={(next: string[]) => onChange?.(next)}
|
||||
filter={null}
|
||||
onInputValueChange={debouncedSetSearch}
|
||||
disabled={disabled}
|
||||
allowClear
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
searchValue={searchInput}
|
||||
onPopupScroll={handlePopupScroll}
|
||||
loading={isLoading}
|
||||
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No teams found"}
|
||||
style={{ width: "100%" }}
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
{isFetchingNextPage && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{teams.map((team) => (
|
||||
<Select.Option key={team.team_id} value={team.team_id}>
|
||||
<span className="font-medium">{team.team_alias}</span> <Text type="secondary">({team.team_id})</Text>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<ComboboxChips className="w-full" aria-busy={isLoading}>
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) =>
|
||||
selected.map((teamId) => (
|
||||
<ComboboxChip key={teamId} aria-label={aliasOf(teamId)}>
|
||||
{aliasOf(teamId)}
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput
|
||||
className="border-0 bg-transparent"
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value.length > 0 && <ComboboxClear aria-label="Clear all teams" disabled={disabled} />}
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
{isLoading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : "No teams found"}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList onScroll={handleScroll}>
|
||||
{(teamId: string) => (
|
||||
<ComboboxItem key={teamId} value={teamId}>
|
||||
<span className="font-medium">{aliasOf(teamId)}</span>{" "}
|
||||
<span className="text-muted-foreground">({teamId})</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ const userRow = (userId: string, userAgent: string | null, successfulRequests: n
|
|||
spend: 1,
|
||||
});
|
||||
|
||||
// The distribution panel owns the only chart in this component, so resolving it by slot keeps
|
||||
// the assertions independent of how many wrappers the tab library puts around a panel.
|
||||
const distributionChart = (): HTMLElement => {
|
||||
const chart = document.querySelector('[data-slot="chart"]');
|
||||
expect(chart).not.toBeNull();
|
||||
return chart as HTMLElement;
|
||||
};
|
||||
|
||||
describe("PerUserUsage", () => {
|
||||
const mockPerUserAnalyticsCall = vi.mocked(networking.perUserAnalyticsCall);
|
||||
|
||||
|
|
@ -70,6 +78,22 @@ describe("PerUserUsage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps both tab panels mounted so switching tabs does not reset their state", async () => {
|
||||
render(<PerUserUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("u1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Still on the User Details tab: the distribution panel is mounted alongside it.
|
||||
expect(screen.getByText("User Usage Distribution")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Usage Distribution"));
|
||||
|
||||
// And the details panel survives the switch rather than unmounting.
|
||||
expect(screen.getByText("u1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the usage distribution as a stacked bar chart with the explicit palette and users formatter", async () => {
|
||||
render(<PerUserUsage {...defaultProps} />);
|
||||
|
||||
|
|
@ -79,26 +103,22 @@ describe("PerUserUsage", () => {
|
|||
|
||||
fireEvent.click(screen.getByText("Usage Distribution"));
|
||||
|
||||
const panel = screen.getByText("User Usage Distribution").closest("div")?.parentElement;
|
||||
expect(panel).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(panel!.querySelectorAll("path.recharts-rectangle")).toHaveLength(4);
|
||||
expect(distributionChart().querySelectorAll("path.recharts-rectangle")).toHaveLength(4);
|
||||
});
|
||||
|
||||
const chart = panel!.querySelector('[data-slot="chart"]');
|
||||
expect(chart).not.toBeNull();
|
||||
expect(chart!.querySelectorAll(".recharts-bar")).toHaveLength(2);
|
||||
const chart = distributionChart();
|
||||
expect(chart.querySelectorAll(".recharts-bar")).toHaveLength(2);
|
||||
|
||||
const rectangles = Array.from(chart!.querySelectorAll("path.recharts-rectangle"));
|
||||
const rectangles = Array.from(chart.querySelectorAll("path.recharts-rectangle"));
|
||||
const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill")));
|
||||
expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-green-500, #22c55e)"]));
|
||||
|
||||
const xPositions = new Set(rectangles.map((rect) => rect.getAttribute("d")?.match(/^M\s*([\d.]+)/)?.[1]));
|
||||
expect(xPositions.size).toBe(3);
|
||||
|
||||
expect(chart!.textContent).toContain("curl/8.0");
|
||||
expect(chart!.textContent).toContain("Unknown");
|
||||
expect(chart.textContent).toContain("curl/8.0");
|
||||
expect(chart.textContent).toContain("Unknown");
|
||||
for (const bucket of [
|
||||
"1-9 requests",
|
||||
"10-99 requests",
|
||||
|
|
@ -107,10 +127,10 @@ describe("PerUserUsage", () => {
|
|||
"10K-99.9K requests",
|
||||
"100K+ requests",
|
||||
]) {
|
||||
expect(chart!.textContent).toContain(bucket);
|
||||
expect(chart.textContent).toContain(bucket);
|
||||
}
|
||||
|
||||
const tickTexts = Array.from(chart!.querySelectorAll(".recharts-cartesian-axis-tick-value")).map(
|
||||
const tickTexts = Array.from(chart.querySelectorAll(".recharts-cartesian-axis-tick-value")).map(
|
||||
(tick) => tick.textContent ?? "",
|
||||
);
|
||||
expect(tickTexts.some((tick) => / users$/.test(tick))).toBe(true);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React, { useState, useEffect } from "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 { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { perUserAnalyticsCall } from "./networking";
|
||||
|
||||
interface PerUserMetrics {
|
||||
|
|
@ -119,131 +120,133 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
|
|||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<Title>Per User Usage</Title>
|
||||
<Subtitle>Individual developer usage metrics</Subtitle>
|
||||
<h3 className="text-lg font-medium text-foreground">Per User Usage</h3>
|
||||
<p className="text-sm text-muted-foreground">Individual developer usage metrics</p>
|
||||
|
||||
<TabGroup>
|
||||
<TabList className="mb-6">
|
||||
<Tab>User Details</Tab>
|
||||
<Tab>Usage Distribution</Tab>
|
||||
</TabList>
|
||||
<Tabs defaultValue="details">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="details" className="flex-none px-3">
|
||||
User Details
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="distribution" className="flex-none px-3">
|
||||
Usage Distribution
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Tab 1: Existing User Details Table */}
|
||||
<TabPanel>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={perUserData.results.slice(0, 10)}
|
||||
getRowId={(row) => row.user_id}
|
||||
noDataMessage="No per-user usage data"
|
||||
size="compact"
|
||||
/>
|
||||
{/* Tab 1: Existing User Details Table */}
|
||||
<TabsContent value="details" keepMounted>
|
||||
<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">
|
||||
<Text className="text-sm text-gray-500">Showing 10 of {perUserData.total_count} results</Text>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handlePrevPage} disabled={currentPage === 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage >= perUserData.total_pages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
{perUserData.results.length > 10 && (
|
||||
<div className="mt-4 flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500">Showing 10 of {perUserData.total_count} results</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handlePrevPage} disabled={currentPage === 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage >= perUserData.total_pages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Tab 2: Usage Distribution Histogram */}
|
||||
<TabPanel>
|
||||
<div className="mb-4">
|
||||
<Title className="text-lg">User Usage Distribution</Title>
|
||||
<Subtitle>Number of users by successful request frequency</Subtitle>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<BarChart
|
||||
data={(() => {
|
||||
// Get top user agents by frequency first
|
||||
const userAgentCounts = new Map<string, number>();
|
||||
perUserData.results.forEach((item: PerUserMetrics) => {
|
||||
const agent = item.user_agent || "Unknown";
|
||||
userAgentCounts.set(agent, (userAgentCounts.get(agent) || 0) + 1);
|
||||
});
|
||||
{/* Tab 2: Usage Distribution Histogram */}
|
||||
<TabsContent value="distribution" keepMounted>
|
||||
<div className="mb-4">
|
||||
<h4 className="text-lg font-medium text-foreground">User Usage Distribution</h4>
|
||||
<p className="text-sm text-muted-foreground">Number of users by successful request frequency</p>
|
||||
</div>
|
||||
|
||||
const topUserAgents = Array.from(userAgentCounts.entries())
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, MAX_USER_AGENTS)
|
||||
.map(([agent]) => agent);
|
||||
<BarChart
|
||||
data={(() => {
|
||||
// Get top user agents by frequency first
|
||||
const userAgentCounts = new Map<string, number>();
|
||||
perUserData.results.forEach((item: PerUserMetrics) => {
|
||||
const agent = item.user_agent || "Unknown";
|
||||
userAgentCounts.set(agent, (userAgentCounts.get(agent) || 0) + 1);
|
||||
});
|
||||
|
||||
// Categorize users by successful request count and user agent
|
||||
const categories = {
|
||||
"1-9 requests": { range: [1, 9], agents: {} as Record<string, number> },
|
||||
"10-99 requests": { range: [10, 99], agents: {} as Record<string, number> },
|
||||
"100-999 requests": { range: [100, 999], agents: {} as Record<string, number> },
|
||||
"1K-9.9K requests": { range: [1000, 9999], agents: {} as Record<string, number> },
|
||||
"10K-99.9K requests": { range: [10000, 99999], agents: {} as Record<string, number> },
|
||||
"100K+ requests": { range: [100000, Infinity], agents: {} as Record<string, number> },
|
||||
};
|
||||
const topUserAgents = Array.from(userAgentCounts.entries())
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, MAX_USER_AGENTS)
|
||||
.map(([agent]) => agent);
|
||||
|
||||
// Count users in each category by user agent (only for top user agents)
|
||||
perUserData.results.forEach((item: PerUserMetrics) => {
|
||||
const successCount = item.successful_requests;
|
||||
const userAgent = item.user_agent || "Unknown";
|
||||
// Categorize users by successful request count and user agent
|
||||
const categories = {
|
||||
"1-9 requests": { range: [1, 9], agents: {} as Record<string, number> },
|
||||
"10-99 requests": { range: [10, 99], agents: {} as Record<string, number> },
|
||||
"100-999 requests": { range: [100, 999], agents: {} as Record<string, number> },
|
||||
"1K-9.9K requests": { range: [1000, 9999], agents: {} as Record<string, number> },
|
||||
"10K-99.9K requests": { range: [10000, 99999], agents: {} as Record<string, number> },
|
||||
"100K+ requests": { range: [100000, Infinity], agents: {} as Record<string, number> },
|
||||
};
|
||||
|
||||
// Only process if this is one of the top user agents
|
||||
if (topUserAgents.includes(userAgent)) {
|
||||
Object.entries(categories).forEach(([categoryName, category]) => {
|
||||
if (successCount >= category.range[0] && successCount <= category.range[1]) {
|
||||
if (!category.agents[userAgent]) {
|
||||
category.agents[userAgent] = 0;
|
||||
}
|
||||
category.agents[userAgent]++;
|
||||
// Count users in each category by user agent (only for top user agents)
|
||||
perUserData.results.forEach((item: PerUserMetrics) => {
|
||||
const successCount = item.successful_requests;
|
||||
const userAgent = item.user_agent || "Unknown";
|
||||
|
||||
// Only process if this is one of the top user agents
|
||||
if (topUserAgents.includes(userAgent)) {
|
||||
Object.entries(categories).forEach(([categoryName, category]) => {
|
||||
if (successCount >= category.range[0] && successCount <= category.range[1]) {
|
||||
if (!category.agents[userAgent]) {
|
||||
category.agents[userAgent] = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to chart data format for stacked bar chart
|
||||
return Object.entries(categories).map(([categoryName, category]) => {
|
||||
const dataPoint: Record<string, any> = { category: categoryName };
|
||||
|
||||
// Add count for each top user agent
|
||||
topUserAgents.forEach((agent) => {
|
||||
dataPoint[agent] = category.agents[agent] || 0;
|
||||
category.agents[userAgent]++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return dataPoint;
|
||||
});
|
||||
})()}
|
||||
index="category"
|
||||
categories={(() => {
|
||||
// Count user agents by frequency and get top ones
|
||||
const userAgentCounts = new Map<string, number>();
|
||||
perUserData.results.forEach((item: PerUserMetrics) => {
|
||||
const agent = item.user_agent || "Unknown";
|
||||
userAgentCounts.set(agent, (userAgentCounts.get(agent) || 0) + 1);
|
||||
// Convert to chart data format for stacked bar chart
|
||||
return Object.entries(categories).map(([categoryName, category]) => {
|
||||
const dataPoint: Record<string, any> = { category: categoryName };
|
||||
|
||||
// Add count for each top user agent
|
||||
topUserAgents.forEach((agent) => {
|
||||
dataPoint[agent] = category.agents[agent] || 0;
|
||||
});
|
||||
|
||||
// Sort by frequency (most common first) and limit to top MAX_USER_AGENTS
|
||||
return Array.from(userAgentCounts.entries())
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, MAX_USER_AGENTS)
|
||||
.map(([agent]) => agent);
|
||||
})()}
|
||||
colors={["blue", "green", "orange", "red", "purple", "yellow", "pink", "indigo"]}
|
||||
valueFormatter={(value: number) => `${value} users`}
|
||||
yAxisWidth={80}
|
||||
showLegend={true}
|
||||
stack={true}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
return dataPoint;
|
||||
});
|
||||
})()}
|
||||
index="category"
|
||||
categories={(() => {
|
||||
// Count user agents by frequency and get top ones
|
||||
const userAgentCounts = new Map<string, number>();
|
||||
perUserData.results.forEach((item: PerUserMetrics) => {
|
||||
const agent = item.user_agent || "Unknown";
|
||||
userAgentCounts.set(agent, (userAgentCounts.get(agent) || 0) + 1);
|
||||
});
|
||||
|
||||
// Sort by frequency (most common first) and limit to top MAX_USER_AGENTS
|
||||
return Array.from(userAgentCounts.entries())
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, MAX_USER_AGENTS)
|
||||
.map(([agent]) => agent);
|
||||
})()}
|
||||
colors={["blue", "green", "orange", "red", "purple", "yellow", "pink", "indigo"]}
|
||||
valueFormatter={(value: number) => `${value} users`}
|
||||
yAxisWidth={80}
|
||||
showLegend={true}
|
||||
stack={true}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -177,40 +177,61 @@ describe("UserAgentActivity", () => {
|
|||
// Check that filter label is present
|
||||
expect(screen.getByText("Filter by User Agents")).toBeInTheDocument();
|
||||
|
||||
// The Ant Design Select component should be in the document with placeholder
|
||||
const selectElement = screen.getByText("All User Agents");
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
// One library paints the prompt as its own text node and the other leaves it on the input's
|
||||
// placeholder attribute, so either one means the user is being told what the filter does.
|
||||
const prompts =
|
||||
screen.queryAllByText("All User Agents").length + screen.queryAllByPlaceholderText("All User Agents").length;
|
||||
expect(prompts).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const getPanelForTitle = (title: string): HTMLElement => {
|
||||
// Assumes two wrapper divs between the Tremor <Title> and the panel root; update if Tremor's TabPanel depth changes.
|
||||
const panel = screen.getByText(title).closest("div")?.parentElement;
|
||||
expect(panel).not.toBeNull();
|
||||
return panel!;
|
||||
// Walks up from the panel's heading to the nearest ancestor that owns a chart, so the
|
||||
// assertions do not depend on how many wrappers the tab library puts around a panel.
|
||||
const chartForTitle = (title: string): HTMLElement => {
|
||||
let node: HTMLElement | null = screen.getByText(title);
|
||||
while (node && !node.querySelector('[data-slot="chart"]')) {
|
||||
node = node.parentElement;
|
||||
}
|
||||
const chart = node?.querySelector('[data-slot="chart"]') ?? null;
|
||||
expect(chart).not.toBeNull();
|
||||
return chart as HTMLElement;
|
||||
};
|
||||
|
||||
const expectStackedTwoCategoryChart = (panel: HTMLElement, firstBucketLabel: string) => {
|
||||
const chart = panel.querySelector('[data-slot="chart"]');
|
||||
expect(chart).not.toBeNull();
|
||||
expect(chart!.querySelectorAll(".recharts-bar")).toHaveLength(2);
|
||||
const expectStackedTwoCategoryChart = (chart: HTMLElement, firstBucketLabel: string) => {
|
||||
expect(chart.querySelectorAll(".recharts-bar")).toHaveLength(2);
|
||||
|
||||
const rectangles = Array.from(chart!.querySelectorAll("path.recharts-rectangle"));
|
||||
const rectangles = Array.from(chart.querySelectorAll("path.recharts-rectangle"));
|
||||
const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill")));
|
||||
expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"]));
|
||||
|
||||
const xPositions = new Set(rectangles.map((rect) => rect.getAttribute("d")?.match(/^M\s*([\d.]+)/)?.[1]));
|
||||
expect(xPositions.size).toBe(1);
|
||||
|
||||
expect(chart!.textContent).toContain("Chrome/1.0");
|
||||
expect(chart!.textContent).toContain("Firefox/2.0");
|
||||
expect(chart!.textContent).toContain(firstBucketLabel);
|
||||
expect(chart.textContent).toContain("Chrome/1.0");
|
||||
expect(chart.textContent).toContain("Firefox/2.0");
|
||||
expect(chart.textContent).toContain(firstBucketLabel);
|
||||
|
||||
const tickTexts = Array.from(chart!.querySelectorAll(".recharts-cartesian-axis-tick-value")).map(
|
||||
const tickTexts = Array.from(chart.querySelectorAll(".recharts-cartesian-axis-tick-value")).map(
|
||||
(tick) => tick.textContent ?? "",
|
||||
);
|
||||
expect(tickTexts.some((tick) => /^\d+K$/.test(tick))).toBe(true);
|
||||
};
|
||||
|
||||
it("keeps every tab panel mounted so switching tabs does not reset their state", async () => {
|
||||
render(<UserAgentActivity {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDauCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// No tab has been clicked: the inactive DAU/WAU/MAU panels are mounted alongside the active one.
|
||||
expect(screen.getByText("Daily Active Users - Last 7 Days")).toBeInTheDocument();
|
||||
expect(screen.getByText("Weekly Active Users - Last 7 Weeks")).toBeInTheDocument();
|
||||
expect(screen.getByText("Monthly Active Users - Last 7 Months")).toBeInTheDocument();
|
||||
|
||||
// And so is the second panel of the outer tab group.
|
||||
expect(screen.getByText("Per User Usage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the DAU chart stacked with default color cycle and abbreviated axis ticks", async () => {
|
||||
const firstBucketDate = new Date();
|
||||
firstBucketDate.setDate(firstBucketDate.getDate() - 6);
|
||||
|
|
@ -224,12 +245,16 @@ describe("UserAgentActivity", () => {
|
|||
|
||||
render(<UserAgentActivity {...defaultProps} />);
|
||||
|
||||
const panel = getPanelForTitle("Daily Active Users - Last 7 Days");
|
||||
await waitFor(() => {
|
||||
expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2);
|
||||
expect(
|
||||
chartForTitle("Daily Active Users - Last 7 Days").querySelectorAll("path.recharts-rectangle"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
expectStackedTwoCategoryChart(panel, firstBucketDate.toISOString().split("T")[0]);
|
||||
expectStackedTwoCategoryChart(
|
||||
chartForTitle("Daily Active Users - Last 7 Days"),
|
||||
firstBucketDate.toISOString().split("T")[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the WAU chart stacked with week buckets and abbreviated axis ticks", async () => {
|
||||
|
|
@ -242,12 +267,13 @@ describe("UserAgentActivity", () => {
|
|||
|
||||
render(<UserAgentActivity {...defaultProps} />);
|
||||
|
||||
const panel = getPanelForTitle("Weekly Active Users - Last 7 Weeks");
|
||||
await waitFor(() => {
|
||||
expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2);
|
||||
expect(
|
||||
chartForTitle("Weekly Active Users - Last 7 Weeks").querySelectorAll("path.recharts-rectangle"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
expectStackedTwoCategoryChart(panel, "Week 1");
|
||||
expectStackedTwoCategoryChart(chartForTitle("Weekly Active Users - Last 7 Weeks"), "Week 1");
|
||||
});
|
||||
|
||||
it("renders the MAU chart stacked with month buckets and abbreviated axis ticks", async () => {
|
||||
|
|
@ -260,11 +286,12 @@ describe("UserAgentActivity", () => {
|
|||
|
||||
render(<UserAgentActivity {...defaultProps} />);
|
||||
|
||||
const panel = getPanelForTitle("Monthly Active Users - Last 7 Months");
|
||||
await waitFor(() => {
|
||||
expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2);
|
||||
expect(
|
||||
chartForTitle("Monthly Active Users - Last 7 Months").querySelectorAll("path.recharts-rectangle"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
expectStackedTwoCategoryChart(panel, "Month 1");
|
||||
expectStackedTwoCategoryChart(chartForTitle("Monthly Active Users - Last 7 Months"), "Month 1");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Card, Title, Text, Grid, Metric, Subtitle, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
||||
import { Select, Tooltip } from "antd";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxClear,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { BarChart } from "@/components/shared/charts";
|
||||
import { userAgentSummaryCall, tagDauCall, tagWauCall, tagMauCall, tagDistinctCall } from "./networking";
|
||||
import PerUserUsage from "./per_user_usage";
|
||||
import { DateRangePickerValue } from "@tremor/react";
|
||||
import type { DateRangePickerValue } from "@tremor/react";
|
||||
import { ChartLoader } from "./shared/chart_loader";
|
||||
|
||||
// New interfaces for the updated API response
|
||||
|
|
@ -356,39 +369,53 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
|
|||
<div className="space-y-6 mt-6">
|
||||
{/* Summary Section Card */}
|
||||
<Card>
|
||||
<div className="space-y-6">
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<Title>Summary by User Agent</Title>
|
||||
<Subtitle>Performance metrics for different user agents</Subtitle>
|
||||
<h3 className="text-lg font-medium text-foreground">Summary by User Agent</h3>
|
||||
<p className="text-sm text-muted-foreground">Performance metrics for different user agents</p>
|
||||
</div>
|
||||
|
||||
{/* User Agent Filter */}
|
||||
<div className="w-96">
|
||||
<Text className="text-sm font-medium block mb-2">Filter by User Agents</Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="All User Agents"
|
||||
<label className="text-sm font-medium block mb-2">Filter by User Agents</label>
|
||||
<Combobox
|
||||
multiple
|
||||
items={availableTags}
|
||||
value={selectedTags}
|
||||
onChange={setSelectedTags}
|
||||
style={{ width: "100%" }}
|
||||
showSearch={true}
|
||||
allowClear={true}
|
||||
loading={tagsLoading}
|
||||
optionFilterProp="label"
|
||||
className="rounded-md"
|
||||
maxTagCount="responsive"
|
||||
onValueChange={(next: string[]) => setSelectedTags(next)}
|
||||
>
|
||||
{availableTags.map((tag) => {
|
||||
const userAgent = extractUserAgent(tag);
|
||||
const displayName = userAgent.length > 50 ? `${userAgent.substring(0, 50)}...` : userAgent;
|
||||
return (
|
||||
<Select.Option key={tag} value={tag} label={displayName} title={userAgent}>
|
||||
{displayName}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
<ComboboxChips className="w-full" aria-busy={tagsLoading}>
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) =>
|
||||
selected.map((tag) => (
|
||||
<ComboboxChip key={tag} aria-label={extractUserAgent(tag)}>
|
||||
{truncateUserAgent(extractUserAgent(tag))}
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput
|
||||
className="border-0 bg-transparent"
|
||||
placeholder="All User Agents"
|
||||
aria-label="All User Agents"
|
||||
/>
|
||||
{selectedTags.length > 0 && <ComboboxClear aria-label="Clear user agent filter" />}
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No user agents found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(tag: string) => {
|
||||
const userAgent = extractUserAgent(tag);
|
||||
return (
|
||||
<ComboboxItem key={tag} value={tag} title={userAgent}>
|
||||
{userAgent.length > 50 ? `${userAgent.substring(0, 50)}...` : userAgent}
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -398,151 +425,166 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
|
|||
{summaryLoading ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<Grid numItems={4} className="gap-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{(summaryData.results || []).slice(0, 4).map((tag, index) => {
|
||||
const userAgent = extractUserAgent(tag.tag);
|
||||
const displayName = truncateUserAgent(userAgent);
|
||||
return (
|
||||
<Card key={index}>
|
||||
<Tooltip title={userAgent} placement="top">
|
||||
<Title className="truncate">{displayName}</Title>
|
||||
</Tooltip>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<Text className="text-sm text-gray-600">Success Requests</Text>
|
||||
<Metric className="text-lg">{formatAbbreviatedNumber(tag.successful_requests)}</Metric>
|
||||
<CardContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<h4 className="truncate text-lg font-medium text-foreground">{displayName}</h4>}
|
||||
/>
|
||||
<TooltipContent side="top">{userAgent}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Success Requests</p>
|
||||
<p className="text-lg font-semibold">{formatAbbreviatedNumber(tag.successful_requests)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Tokens</p>
|
||||
<p className="text-lg font-semibold">{formatAbbreviatedNumber(tag.total_tokens)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Cost</p>
|
||||
<p className="text-lg font-semibold">${formatAbbreviatedNumber(tag.total_spend, 4)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-sm text-gray-600">Total Tokens</Text>
|
||||
<Metric className="text-lg">{formatAbbreviatedNumber(tag.total_tokens)}</Metric>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-sm text-gray-600">Total Cost</Text>
|
||||
<Metric className="text-lg">${formatAbbreviatedNumber(tag.total_spend, 4)}</Metric>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{/* Fill remaining slots if less than 4 agents */}
|
||||
{Array.from({ length: Math.max(0, 4 - (summaryData.results || []).length) }).map((_, index) => (
|
||||
<Card key={`empty-${index}`}>
|
||||
<Title>No Data</Title>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<Text className="text-sm text-gray-600">Success Requests</Text>
|
||||
<Metric className="text-lg">-</Metric>
|
||||
<CardContent>
|
||||
<h4 className="text-lg font-medium text-foreground">No Data</h4>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Success Requests</p>
|
||||
<p className="text-lg font-semibold">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Tokens</p>
|
||||
<p className="text-lg font-semibold">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Cost</p>
|
||||
<p className="text-lg font-semibold">-</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-sm text-gray-600">Total Tokens</Text>
|
||||
<Metric className="text-lg">-</Metric>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-sm text-gray-600">Total Cost</Text>
|
||||
<Metric className="text-lg">-</Metric>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main TabGroup for DAU/WAU/MAU vs Per User Usage */}
|
||||
{/* Main tabs for DAU/WAU/MAU vs Per User Usage */}
|
||||
<Card>
|
||||
<TabGroup>
|
||||
<TabList className="mb-6">
|
||||
<Tab>DAU/WAU/MAU</Tab>
|
||||
<Tab>Per User Usage (Last 30 Days)</Tab>
|
||||
</TabList>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="active-users">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="active-users" className="flex-none px-3">
|
||||
DAU/WAU/MAU
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="per-user" className="flex-none px-3">
|
||||
Per User Usage (Last 30 Days)
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabPanels>
|
||||
{/* DAU/WAU/MAU Tab Panel */}
|
||||
<TabPanel>
|
||||
<TabsContent value="active-users" keepMounted>
|
||||
<div className="mb-6">
|
||||
<Title>DAU, WAU & MAU per Agent</Title>
|
||||
<Subtitle>Active users across different time periods</Subtitle>
|
||||
<h3 className="text-lg font-medium text-foreground">DAU, WAU & MAU per Agent</h3>
|
||||
<p className="text-sm text-muted-foreground">Active users across different time periods</p>
|
||||
</div>
|
||||
|
||||
<TabGroup>
|
||||
<TabList className="mb-6">
|
||||
<Tab>DAU</Tab>
|
||||
<Tab>WAU</Tab>
|
||||
<Tab>MAU</Tab>
|
||||
</TabList>
|
||||
<Tabs defaultValue="dau">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="dau" className="flex-none px-3">
|
||||
DAU
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="wau" className="flex-none px-3">
|
||||
WAU
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mau" className="flex-none px-3">
|
||||
MAU
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<div className="mb-4">
|
||||
<Title className="text-lg">Daily Active Users - Last 7 Days</Title>
|
||||
</div>
|
||||
{dauLoading ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={dailyChartData}
|
||||
index="date"
|
||||
categories={allDauTags.map(extractUserAgent)}
|
||||
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
|
||||
yAxisWidth={60}
|
||||
showLegend={true}
|
||||
stack={true}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabsContent value="dau" keepMounted>
|
||||
<div className="mb-4">
|
||||
<h4 className="text-lg font-medium text-foreground">Daily Active Users - Last 7 Days</h4>
|
||||
</div>
|
||||
{dauLoading ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={dailyChartData}
|
||||
index="date"
|
||||
categories={allDauTags.map(extractUserAgent)}
|
||||
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
|
||||
yAxisWidth={60}
|
||||
showLegend={true}
|
||||
stack={true}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabPanel>
|
||||
<div className="mb-4">
|
||||
<Title className="text-lg">Weekly Active Users - Last 7 Weeks</Title>
|
||||
</div>
|
||||
{wauLoading ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={weeklyChartData}
|
||||
index="week"
|
||||
categories={allWauTags.map(extractUserAgent)}
|
||||
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
|
||||
yAxisWidth={60}
|
||||
showLegend={true}
|
||||
stack={true}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabsContent value="wau" keepMounted>
|
||||
<div className="mb-4">
|
||||
<h4 className="text-lg font-medium text-foreground">Weekly Active Users - Last 7 Weeks</h4>
|
||||
</div>
|
||||
{wauLoading ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={weeklyChartData}
|
||||
index="week"
|
||||
categories={allWauTags.map(extractUserAgent)}
|
||||
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
|
||||
yAxisWidth={60}
|
||||
showLegend={true}
|
||||
stack={true}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabPanel>
|
||||
<div className="mb-4">
|
||||
<Title className="text-lg">Monthly Active Users - Last 7 Months</Title>
|
||||
</div>
|
||||
{mauLoading ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={monthlyChartData}
|
||||
index="month"
|
||||
categories={allMauTags.map(extractUserAgent)}
|
||||
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
|
||||
yAxisWidth={60}
|
||||
showLegend={true}
|
||||
stack={true}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</TabPanel>
|
||||
<TabsContent value="mau" keepMounted>
|
||||
<div className="mb-4">
|
||||
<h4 className="text-lg font-medium text-foreground">Monthly Active Users - Last 7 Months</h4>
|
||||
</div>
|
||||
{mauLoading ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={monthlyChartData}
|
||||
index="month"
|
||||
categories={allMauTags.map(extractUserAgent)}
|
||||
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
|
||||
yAxisWidth={60}
|
||||
showLegend={true}
|
||||
stack={true}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
|
||||
{/* Per User Usage Tab Panel */}
|
||||
<TabPanel>
|
||||
<TabsContent value="per-user" keepMounted>
|
||||
<PerUserUsage
|
||||
accessToken={accessToken}
|
||||
selectedTags={selectedTags}
|
||||
formatAbbreviatedNumber={formatAbbreviatedNumber}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue