mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
Merge pull request #17854 from BerriAI/litellm_ui_usage_select
[Feature] UI - Usage Page View Select
This commit is contained in:
commit
4f79a026ad
4 changed files with 738 additions and 440 deletions
|
|
@ -1,11 +1,11 @@
|
|||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
|
||||
import NewUsagePage from "./UsagePageView";
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Organization } from "../../networking";
|
||||
import * as networking from "../../networking";
|
||||
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import NewUsagePage from "./UsagePageView";
|
||||
|
||||
// Polyfill ResizeObserver for test environment
|
||||
beforeAll(() => {
|
||||
|
|
@ -69,6 +69,60 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
|||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
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";
|
||||
|
||||
return { Select, Alert };
|
||||
});
|
||||
|
||||
vi.mock("@ant-design/icons", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
function Icon() {
|
||||
return React.createElement("span");
|
||||
}
|
||||
|
||||
return {
|
||||
GlobalOutlined: Icon,
|
||||
BankOutlined: Icon,
|
||||
TeamOutlined: Icon,
|
||||
ShoppingCartOutlined: Icon,
|
||||
TagsOutlined: Icon,
|
||||
RobotOutlined: Icon,
|
||||
LineChartOutlined: Icon,
|
||||
BarChartOutlined: Icon,
|
||||
ClockCircleOutlined: Icon,
|
||||
CalendarOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
describe("NewUsage", () => {
|
||||
const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall);
|
||||
const mockTagListCall = vi.mocked(networking.tagListCall);
|
||||
|
|
@ -301,20 +355,20 @@ describe("NewUsage", () => {
|
|||
expect(screen.getByText("Top Virtual Keys")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should switch between tabs correctly", async () => {
|
||||
it("should switch between usage views correctly", async () => {
|
||||
render(<NewUsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Default tab should show Global Usage (for admin)
|
||||
// Default view should show Global Usage (for admin)
|
||||
expect(screen.getByText("Daily Spend")).toBeInTheDocument();
|
||||
|
||||
// Switch to Team Usage tab
|
||||
const teamUsageTab = screen.getByText("Team Usage");
|
||||
// Switch to Team Usage view
|
||||
const usageSelect = screen.getByRole("combobox");
|
||||
act(() => {
|
||||
fireEvent.click(teamUsageTab);
|
||||
fireEvent.change(usageSelect, { target: { value: "team" } });
|
||||
});
|
||||
|
||||
// Should render EntityUsage component
|
||||
|
|
@ -323,10 +377,9 @@ describe("NewUsage", () => {
|
|||
expect(entityUsageElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Switch to Tag Usage tab (admin only)
|
||||
const tagUsageTab = screen.getByText("Tag Usage");
|
||||
// Switch to Tag Usage view (admin only)
|
||||
act(() => {
|
||||
fireEvent.click(tagUsageTab);
|
||||
fireEvent.change(usageSelect, { target: { value: "tag" } });
|
||||
});
|
||||
|
||||
// Should still render EntityUsage component for tags
|
||||
|
|
@ -336,16 +389,16 @@ describe("NewUsage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should show organization usage banner and tab for admins", async () => {
|
||||
it("should show organization usage banner and view for admins", async () => {
|
||||
render(<NewUsagePage {...defaultProps} organizations={mockOrganizations} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const organizationTab = screen.getByText("Organization Usage");
|
||||
const usageSelect = screen.getByRole("combobox");
|
||||
act(() => {
|
||||
fireEvent.click(organizationTab);
|
||||
fireEvent.change(usageSelect, { target: { value: "organization" } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -355,7 +408,7 @@ describe("NewUsage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should show customer usage tab for admins", async () => {
|
||||
it("should show customer usage view for admins", async () => {
|
||||
mockUseCustomers.mockReturnValue({
|
||||
data: mockCustomers,
|
||||
isLoading: false,
|
||||
|
|
@ -368,9 +421,9 @@ describe("NewUsage", () => {
|
|||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const customerTab = screen.getByText("Customer Usage");
|
||||
const usageSelect = screen.getByRole("combobox");
|
||||
act(() => {
|
||||
fireEvent.click(customerTab);
|
||||
fireEvent.change(usageSelect, { target: { value: "customer" } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -379,7 +432,7 @@ describe("NewUsage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should show agent usage tab for admins", async () => {
|
||||
it("should show agent usage view for admins", async () => {
|
||||
mockUseAgents.mockReturnValue({
|
||||
data: { agents: mockAgents },
|
||||
isLoading: false,
|
||||
|
|
@ -392,9 +445,9 @@ describe("NewUsage", () => {
|
|||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const agentTab = screen.getByText("Agent Usage");
|
||||
const usageSelect = screen.getByRole("combobox");
|
||||
act(() => {
|
||||
fireEvent.click(agentTab);
|
||||
fireEvent.change(usageSelect, { target: { value: "agent" } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -30,13 +30,14 @@ import {
|
|||
import { Alert } from "antd";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { Button } from "@tremor/react";
|
||||
import { all_admin_roles } from "../../../utils/roles";
|
||||
import { ActivityMetrics, processActivityData } from "../../activity_metrics";
|
||||
import CloudZeroExportModal from "../../cloudzero_export_modal";
|
||||
import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
|
||||
import EntityUsageExportModal from "../../EntityUsageExport";
|
||||
import { Team } from "../../key_team_helpers/key_list";
|
||||
import { Organization, tagListCall, userDailyActivityAggregatedCall, userDailyActivityCall } from "../../networking";
|
||||
|
|
@ -44,13 +45,13 @@ import { getProviderLogoAndName } from "../../provider_info_helpers";
|
|||
import AdvancedDatePicker from "../../shared/advanced_date_picker";
|
||||
import { ChartLoader } from "../../shared/chart_loader";
|
||||
import { Tag } from "../../tag_management/types";
|
||||
import TopKeyView from "./EntityUsage/TopKeyView";
|
||||
import { DailyData, KeyMetricWithMetadata, MetricWithMetadata } from "../types";
|
||||
import { valueFormatterSpend } from "../utils/value_formatters";
|
||||
import UserAgentActivity from "../../user_agent_activity";
|
||||
import ViewUserSpend from "../../view_user_spend";
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { DailyData, KeyMetricWithMetadata, MetricWithMetadata } from "../types";
|
||||
import { valueFormatterSpend } from "../utils/value_formatters";
|
||||
import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
|
||||
import TopKeyView from "./EntityUsage/TopKeyView";
|
||||
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
|
||||
|
||||
interface UsagePageProps {
|
||||
teams: Team[];
|
||||
|
|
@ -86,7 +87,8 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
|
||||
const [showOrganizationBanner, setShowOrganizationBanner] = useState(true);
|
||||
const [showCustomerBanner, setShowCustomerBanner] = useState(true);
|
||||
|
||||
const [usageView, setUsageView] = useState<UsageOption>("global");
|
||||
const [showAgentBanner, setShowAgentBanner] = useState(true);
|
||||
const getAllTags = async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
|
|
@ -416,431 +418,433 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
{/* Global Date Picker and Tabs - Single Row */}
|
||||
<div className="flex items-end justify-between gap-6 mb-6">
|
||||
<div className="flex-1">
|
||||
<TabGroup>
|
||||
<div className="flex items-end justify-between gap-6 mb-6 w-full">
|
||||
<TabList variant="solid">
|
||||
{all_admin_roles.includes(userRole || "") ? <Tab>Global Usage</Tab> : <Tab>Your Usage</Tab>}
|
||||
{all_admin_roles.includes(userRole || "") ? (
|
||||
<Tab>Organization Usage</Tab>
|
||||
) : (
|
||||
<Tab>Your Organization Usage</Tab>
|
||||
)}
|
||||
<Tab>Team Usage</Tab>
|
||||
{all_admin_roles.includes(userRole || "") ? <Tab>Customer Usage</Tab> : <></>}
|
||||
{all_admin_roles.includes(userRole || "") ? <Tab>Tag Usage</Tab> : <></>}
|
||||
{all_admin_roles.includes(userRole || "") ? <Tab>Agent Usage</Tab> : <></>}
|
||||
{all_admin_roles.includes(userRole || "") ? <Tab>User Agent Activity</Tab> : <></>}
|
||||
</TabList>
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
|
||||
</div>
|
||||
<TabPanels>
|
||||
{/* Your Usage Panel */}
|
||||
<TabPanel>
|
||||
<TabGroup>
|
||||
<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>
|
||||
</TabList>
|
||||
<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"
|
||||
<div className="flex items-end justify-between gap-6 mb-4 w-full">
|
||||
<UsageViewSelect
|
||||
value={usageView}
|
||||
onChange={(value) => setUsageView(value)}
|
||||
isAdmin={all_admin_roles.includes(userRole || "")}
|
||||
/>
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
|
||||
</div>
|
||||
{/* Your Usage Panel */}
|
||||
{usageView === "global" && (
|
||||
<TabGroup>
|
||||
<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>
|
||||
</TabList>
|
||||
<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>
|
||||
)}
|
||||
>
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
<TabPanels>
|
||||
{/* Cost Panel */}
|
||||
<TabPanel>
|
||||
<Grid numItems={2} className="gap-2 w-full">
|
||||
{/* Total Spend Card */}
|
||||
<Col numColSpan={2}>
|
||||
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content mb-2 mt-2 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>
|
||||
|
||||
<ViewUserSpend userSpend={totalSpend} selectedTeam={null} userMaxBudget={null} />
|
||||
</Col>
|
||||
|
||||
<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>
|
||||
<Title>Successful Requests</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-green-600">
|
||||
{userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<Title>Failed Requests</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-red-600">
|
||||
{userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<Title>Total Tokens</Title>
|
||||
<Text className="text-2xl font-bold mt-2">
|
||||
{userSpendData.metadata?.total_tokens?.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>
|
||||
</Grid>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Daily Spend Chart */}
|
||||
<Col numColSpan={2}>
|
||||
<Card>
|
||||
<Title>Daily Spend</Title>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={[...userSpendData.results].sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||
)}
|
||||
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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
>
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
<TabPanels>
|
||||
{/* Cost Panel */}
|
||||
<TabPanel>
|
||||
<Grid numItems={2} className="gap-2 w-full">
|
||||
{/* Total Spend Card */}
|
||||
<Col numColSpan={2}>
|
||||
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content mb-2 mt-2 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>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
{/* Top API Keys */}
|
||||
<Col numColSpan={1}>
|
||||
<Card className="h-full">
|
||||
<Title>Top Virtual Keys</Title>
|
||||
<TopKeyView topKeys={getTopKeys()} teams={null} />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<ViewUserSpend userSpend={totalSpend} selectedTeam={null} userMaxBudget={null} />
|
||||
</Col>
|
||||
{/* Top Models */}
|
||||
<Col numColSpan={1}>
|
||||
<Card className="h-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}</Title>
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
modelViewType === "groups"
|
||||
? "bg-white shadow-sm text-gray-900"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
onClick={() => setModelViewType("groups")}
|
||||
>
|
||||
Public Model Name
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
modelViewType === "individual"
|
||||
? "bg-white shadow-sm text-gray-900"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
onClick={() => setModelViewType("individual")}
|
||||
>
|
||||
Litellm Model Name
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<BarChart
|
||||
className="mt-4 h-40"
|
||||
data={modelViewType === "groups" ? getTopModelGroups() : getTopModels()}
|
||||
index="key"
|
||||
categories={["spend"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatterSpend}
|
||||
layout="vertical"
|
||||
yAxisWidth={200}
|
||||
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.key}</p>
|
||||
<p className="text-cyan-500">Spend: ${formatNumberWithCommas(data.spend, 2)}</p>
|
||||
<p className="text-gray-600">Total Requests: {data.requests.toLocaleString()}</p>
|
||||
<p className="text-green-600">
|
||||
Successful: {data.successful_requests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-red-600">Failed: {data.failed_requests.toLocaleString()}</p>
|
||||
<p className="text-gray-600">Tokens: {data.tokens.toLocaleString()}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<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>
|
||||
<Title>Successful Requests</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-green-600">
|
||||
{userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<Title>Failed Requests</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-red-600">
|
||||
{userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<Title>Total Tokens</Title>
|
||||
<Text className="text-2xl font-bold mt-2">
|
||||
{userSpendData.metadata?.total_tokens?.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>
|
||||
</Grid>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Daily Spend Chart */}
|
||||
<Col numColSpan={2}>
|
||||
<Card>
|
||||
<Title>Daily Spend</Title>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={[...userSpendData.results].sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||
)}
|
||||
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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
{/* Top API Keys */}
|
||||
<Col numColSpan={1}>
|
||||
<Card className="h-full">
|
||||
<Title>Top Virtual Keys</Title>
|
||||
<TopKeyView topKeys={getTopKeys()} teams={null} />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Top Models */}
|
||||
<Col numColSpan={1}>
|
||||
<Card className="h-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>
|
||||
{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}
|
||||
</Title>
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
modelViewType === "groups"
|
||||
? "bg-white shadow-sm text-gray-900"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
onClick={() => setModelViewType("groups")}
|
||||
>
|
||||
Public Model Name
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
modelViewType === "individual"
|
||||
? "bg-white shadow-sm text-gray-900"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
onClick={() => setModelViewType("individual")}
|
||||
>
|
||||
Litellm Model Name
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<BarChart
|
||||
{/* Spend by Provider */}
|
||||
<Col numColSpan={2}>
|
||||
<Card className="h-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Spend by Provider</Title>
|
||||
</div>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<Grid numItems={2}>
|
||||
<Col numColSpan={1}>
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
data={modelViewType === "groups" ? getTopModelGroups() : getTopModels()}
|
||||
index="key"
|
||||
categories={["spend"]}
|
||||
data={getProviderSpend()}
|
||||
index="provider"
|
||||
category="spend"
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatterSpend}
|
||||
layout="vertical"
|
||||
yAxisWidth={200}
|
||||
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.key}</p>
|
||||
<p className="text-cyan-500">Spend: ${formatNumberWithCommas(data.spend, 2)}</p>
|
||||
<p className="text-gray-600">Total Requests: {data.requests.toLocaleString()}</p>
|
||||
<p className="text-green-600">
|
||||
Successful: {data.successful_requests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-red-600">Failed: {data.failed_requests.toLocaleString()}</p>
|
||||
<p className="text-gray-600">Tokens: {data.tokens.toLocaleString()}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Spend by Provider */}
|
||||
<Col numColSpan={2}>
|
||||
<Card className="h-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Spend by Provider</Title>
|
||||
</div>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<Grid numItems={2}>
|
||||
<Col numColSpan={1}>
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
data={getProviderSpend()}
|
||||
index="provider"
|
||||
category="spend"
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
colors={["cyan"]}
|
||||
/>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Provider</TableHeaderCell>
|
||||
<TableHeaderCell>Spend</TableHeaderCell>
|
||||
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
|
||||
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
|
||||
<TableHeaderCell>Tokens</TableHeaderCell>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Provider</TableHeaderCell>
|
||||
<TableHeaderCell>Spend</TableHeaderCell>
|
||||
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
|
||||
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
|
||||
<TableHeaderCell>Tokens</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{getProviderSpend()
|
||||
.filter((provider) => provider.spend > 0)
|
||||
.map((provider) => (
|
||||
<TableRow key={provider.provider}>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
{provider.provider && (
|
||||
<img
|
||||
src={getProviderLogoAndName(provider.provider).logo}
|
||||
alt={`${provider.provider} logo`}
|
||||
className="w-4 h-4"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
const fallbackDiv = document.createElement("div");
|
||||
fallbackDiv.className =
|
||||
"w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs";
|
||||
fallbackDiv.textContent = provider.provider?.charAt(0) || "-";
|
||||
parent.replaceChild(fallbackDiv, target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{provider.provider}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>${formatNumberWithCommas(provider.spend, 2)}</TableCell>
|
||||
<TableCell className="text-green-600">
|
||||
{provider.successful_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-red-600">
|
||||
{provider.failed_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>{provider.tokens.toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{getProviderSpend()
|
||||
.filter((provider) => provider.spend > 0)
|
||||
.map((provider) => (
|
||||
<TableRow key={provider.provider}>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
{provider.provider && (
|
||||
<img
|
||||
src={getProviderLogoAndName(provider.provider).logo}
|
||||
alt={`${provider.provider} logo`}
|
||||
className="w-4 h-4"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
const fallbackDiv = document.createElement("div");
|
||||
fallbackDiv.className =
|
||||
"w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs";
|
||||
fallbackDiv.textContent = provider.provider?.charAt(0) || "-";
|
||||
parent.replaceChild(fallbackDiv, target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{provider.provider}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>${formatNumberWithCommas(provider.spend, 2)}</TableCell>
|
||||
<TableCell className="text-green-600">
|
||||
{provider.successful_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-red-600">
|
||||
{provider.failed_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>{provider.tokens.toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Col>
|
||||
</Grid>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Col>
|
||||
</Grid>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Usage Metrics */}
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
{/* Usage Metrics */}
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Activity Panel */}
|
||||
<TabPanel>
|
||||
<ActivityMetrics modelMetrics={modelMetrics} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<ActivityMetrics modelMetrics={keyMetrics} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<ActivityMetrics modelMetrics={mcpServerMetrics} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</TabPanel>
|
||||
{/* Activity Panel */}
|
||||
<TabPanel>
|
||||
<ActivityMetrics modelMetrics={modelMetrics} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<ActivityMetrics modelMetrics={keyMetrics} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<ActivityMetrics modelMetrics={mcpServerMetrics} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
)}
|
||||
{/* Organization Usage Panel */}
|
||||
|
||||
{/* Organization Usage Panel */}
|
||||
<TabPanel>
|
||||
{showOrganizationBanner && (
|
||||
<Alert
|
||||
banner
|
||||
type="info"
|
||||
message="Organization usage is a new feature."
|
||||
description="Spend is tracked from feature launch and previous data isn't backfilled, so only future usage appears here."
|
||||
closable
|
||||
onClose={() => setShowOrganizationBanner(false)}
|
||||
className="mb-5"
|
||||
/>
|
||||
)}
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="organization"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
dateValue={dateValue}
|
||||
entityList={
|
||||
organizations?.map((organization) => ({
|
||||
label: organization.organization_alias,
|
||||
value: organization.organization_id,
|
||||
})) || null
|
||||
}
|
||||
premiumUser={premiumUser}
|
||||
{usageView === "organization" && (
|
||||
<>
|
||||
{showOrganizationBanner && (
|
||||
<Alert
|
||||
banner
|
||||
type="info"
|
||||
message="Organization usage is a new feature."
|
||||
description="Spend is tracked from feature launch and previous data isn't backfilled, so only future usage appears here."
|
||||
closable
|
||||
onClose={() => setShowOrganizationBanner(false)}
|
||||
className="mb-5"
|
||||
/>
|
||||
</TabPanel>
|
||||
)}
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="organization"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
dateValue={dateValue}
|
||||
entityList={
|
||||
organizations?.map((organization) => ({
|
||||
label: organization.organization_alias,
|
||||
value: organization.organization_id,
|
||||
})) || null
|
||||
}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Team Usage Panel */}
|
||||
<TabPanel>
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="team"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
teams?.map((team) => ({
|
||||
label: team.team_alias,
|
||||
value: team.team_id,
|
||||
})) || null
|
||||
}
|
||||
premiumUser={premiumUser}
|
||||
dateValue={dateValue}
|
||||
/>
|
||||
</TabPanel>
|
||||
{/* Team Usage Panel */}
|
||||
{usageView === "team" && (
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="team"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
teams?.map((team) => ({
|
||||
label: team.team_alias,
|
||||
value: team.team_id,
|
||||
})) || null
|
||||
}
|
||||
premiumUser={premiumUser}
|
||||
dateValue={dateValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Customer Usage Panel */}
|
||||
<TabPanel>
|
||||
{showCustomerBanner && (
|
||||
<Alert
|
||||
banner
|
||||
type="info"
|
||||
message="Customer usage is a new feature."
|
||||
description="Spend is tracked from feature launch and previous data isn't backfilled, so only future usage appears here."
|
||||
closable
|
||||
onClose={() => setShowCustomerBanner(false)}
|
||||
className="mb-5"
|
||||
/>
|
||||
)}
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="customer"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
customers?.map((customer) => ({
|
||||
label: customer.alias || customer.user_id,
|
||||
value: customer.user_id,
|
||||
})) || null
|
||||
}
|
||||
premiumUser={premiumUser}
|
||||
dateValue={dateValue}
|
||||
{/* Customer Usage Panel */}
|
||||
{usageView === "customer" && (
|
||||
<>
|
||||
{showCustomerBanner && (
|
||||
<Alert
|
||||
banner
|
||||
type="info"
|
||||
message="Customer usage is a new feature."
|
||||
description="Spend is tracked from feature launch and previous data isn't backfilled, so only future usage appears here."
|
||||
closable
|
||||
onClose={() => setShowCustomerBanner(false)}
|
||||
className="mb-5"
|
||||
/>
|
||||
</TabPanel>
|
||||
{/* Tag Usage Panel */}
|
||||
<TabPanel>
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="tag"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={allTags}
|
||||
premiumUser={premiumUser}
|
||||
dateValue={dateValue}
|
||||
)}
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="customer"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
customers?.map((customer) => ({
|
||||
label: customer.alias || customer.user_id,
|
||||
value: customer.user_id,
|
||||
})) || null
|
||||
}
|
||||
premiumUser={premiumUser}
|
||||
dateValue={dateValue}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Tag Usage Panel */}
|
||||
{usageView === "tag" && (
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="tag"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={allTags}
|
||||
premiumUser={premiumUser}
|
||||
dateValue={dateValue}
|
||||
/>
|
||||
)}
|
||||
{usageView === "agent" && (
|
||||
<>
|
||||
{showAgentBanner && (
|
||||
<Alert
|
||||
banner
|
||||
type="info"
|
||||
message="Agent usage (A2A) is a new feature."
|
||||
description="Spend is tracked from feature launch and previous data isn't backfilled, so only future usage appears here."
|
||||
closable
|
||||
onClose={() => setShowAgentBanner(false)}
|
||||
className="mb-5"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="agent"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
agentsResponse?.agents?.map((agent) => ({ label: agent.agent_name, value: agent.agent_id })) || null
|
||||
}
|
||||
premiumUser={premiumUser}
|
||||
dateValue={dateValue}
|
||||
/>
|
||||
</TabPanel>
|
||||
{/* User Agent Activity Panel */}
|
||||
<TabPanel>
|
||||
<UserAgentActivity accessToken={accessToken} userRole={userRole} dateValue={dateValue} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
)}
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="agent"
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
agentsResponse?.agents?.map((agent) => ({ label: agent.agent_name, value: agent.agent_id })) || null
|
||||
}
|
||||
premiumUser={premiumUser}
|
||||
dateValue={dateValue}
|
||||
/>{" "}
|
||||
</>
|
||||
)}
|
||||
{/* User Agent Activity Panel */}
|
||||
{usageView === "user-agent-activity" && (
|
||||
<UserAgentActivity accessToken={accessToken} userRole={userRole} dateValue={dateValue} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UsageViewSelect } from "./UsageViewSelect";
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
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";
|
||||
|
||||
return { Select };
|
||||
});
|
||||
|
||||
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,
|
||||
LineChartOutlined: Icon,
|
||||
BarChartOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
describe("UsageViewSelect", () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnChange.mockClear();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
render(<UsageViewSelect value="global" onChange={mockOnChange} isAdmin={false} />);
|
||||
|
||||
expect(screen.getByText("Usage View")).toBeInTheDocument();
|
||||
expect(screen.getByText("Select the usage data you want to view")).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onChange when value changes", () => {
|
||||
render(<UsageViewSelect value="global" onChange={mockOnChange} isAdmin={true} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
act(() => {
|
||||
fireEvent.change(select, { target: { value: "team" } });
|
||||
});
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith("team");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import {
|
||||
BankOutlined,
|
||||
BarChartOutlined,
|
||||
GlobalOutlined,
|
||||
LineChartOutlined,
|
||||
RobotOutlined,
|
||||
ShoppingCartOutlined,
|
||||
TagsOutlined,
|
||||
TeamOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Select } from "antd";
|
||||
import React from "react";
|
||||
export type UsageOption = "global" | "organization" | "team" | "customer" | "tag" | "agent" | "user-agent-activity";
|
||||
export interface UsageViewSelectProps {
|
||||
value: UsageOption;
|
||||
onChange: (value: UsageOption) => void;
|
||||
isAdmin: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
"data-id"?: string;
|
||||
}
|
||||
interface OptionConfig {
|
||||
value: UsageOption;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
adminOnly?: boolean;
|
||||
showForAdmin?: string;
|
||||
showForNonAdmin?: string;
|
||||
descriptionForAdmin?: string;
|
||||
descriptionForNonAdmin?: string;
|
||||
}
|
||||
const OPTIONS: OptionConfig[] = [
|
||||
{
|
||||
value: "global",
|
||||
label: "Global Usage",
|
||||
showForAdmin: "Global Usage",
|
||||
showForNonAdmin: "Your Usage",
|
||||
description: "View usage across all resources",
|
||||
descriptionForAdmin: "View usage across all resources and users",
|
||||
descriptionForNonAdmin: "View your personal usage statistics",
|
||||
icon: <GlobalOutlined style={{ fontSize: "16px" }} />,
|
||||
},
|
||||
{
|
||||
value: "organization",
|
||||
label: "Organization Usage",
|
||||
showForAdmin: "Organization Usage",
|
||||
showForNonAdmin: "Your Organization Usage",
|
||||
description: "View organization-level usage",
|
||||
descriptionForAdmin: "View usage across all organizations",
|
||||
descriptionForNonAdmin: "View your organization's usage statistics",
|
||||
icon: <BankOutlined style={{ fontSize: "16px" }} />,
|
||||
},
|
||||
{
|
||||
value: "team",
|
||||
label: "Team Usage",
|
||||
description: "View usage by team",
|
||||
icon: <TeamOutlined style={{ fontSize: "16px" }} />,
|
||||
},
|
||||
{
|
||||
value: "customer",
|
||||
label: "Customer Usage",
|
||||
description: "View usage by customer accounts",
|
||||
icon: <ShoppingCartOutlined style={{ fontSize: "16px" }} />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "tag",
|
||||
label: "Tag Usage",
|
||||
description: "View usage grouped by tags",
|
||||
icon: <TagsOutlined style={{ fontSize: "16px" }} />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "agent",
|
||||
label: "Agent Usage (A2A)",
|
||||
description: "View usage by AI agents",
|
||||
icon: <RobotOutlined style={{ fontSize: "16px" }} />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "user-agent-activity",
|
||||
label: "User Agent Activity",
|
||||
description: "View detailed user agent activity logs",
|
||||
icon: <LineChartOutlined style={{ fontSize: "16px" }} />,
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
isAdmin,
|
||||
title = "Usage View",
|
||||
description = "Select the usage data you want to view",
|
||||
"data-id": dataId,
|
||||
}) => {
|
||||
const getFilteredOptions = () => {
|
||||
return OPTIONS.filter((option) => {
|
||||
if (option.adminOnly && !isAdmin) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((option) => {
|
||||
let label = option.label;
|
||||
let desc = option.description;
|
||||
if (option.showForAdmin && option.showForNonAdmin) {
|
||||
label = isAdmin ? option.showForAdmin : option.showForNonAdmin;
|
||||
}
|
||||
if (option.descriptionForAdmin && option.descriptionForNonAdmin) {
|
||||
desc = isAdmin ? option.descriptionForAdmin : option.descriptionForNonAdmin;
|
||||
}
|
||||
return {
|
||||
value: option.value,
|
||||
label,
|
||||
description: desc,
|
||||
icon: option.icon,
|
||||
};
|
||||
});
|
||||
};
|
||||
const filteredOptions = getFilteredOptions();
|
||||
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="flex-shrink-0 flex items-center">
|
||||
<BarChartOutlined style={{ fontSize: "32px" }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-0.5 leading-tight">{title}</h3>
|
||||
<p className="text-xs text-gray-600 leading-tight">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-48 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-start gap-2 py-1">
|
||||
<div className="flex-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>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue