mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
refactor(ui): migrate usage to shadcn
Replaces antd and Tremor markup on the usage route with the installed shadcn base-vega primitives. Behaviour is unchanged: the route's characterisation tests were rewritten to role and text queries in the previous commit, proven green against the antd components, and pass through this commit unedited. Tremor tab panels stayed mounted once rendered, so every migrated TabsContent and the collapsible model sections in activity_metrics carry keepMounted to keep view-mode and expansion state alive across tab switches.
This commit is contained in:
parent
28032eb4c9
commit
b69177712e
16 changed files with 1454 additions and 1335 deletions
|
|
@ -1658,25 +1658,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
|
||||
}
|
||||
|
|
@ -1685,9 +1672,6 @@
|
|||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -1703,7 +1687,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
|
|
@ -1712,14 +1696,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
|
||||
|
|
@ -1982,29 +1958,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": {
|
||||
|
|
@ -2289,9 +2250,6 @@
|
|||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/add_model/AdaptiveRoutingConfig.tsx": {
|
||||
|
|
@ -2794,9 +2752,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": {
|
||||
|
|
@ -3171,9 +3126,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -3714,7 +3666,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,15 @@ 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} />
|
||||
{/* The failed share is the track showing through behind the successful share. */}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,8 @@ 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,
|
||||
});
|
||||
// The selector debounces its own input, so this holds the already-settled query.
|
||||
const [debouncedUserSearch, setDebouncedUserSearch] = useState("");
|
||||
|
||||
const {
|
||||
data: usersInfiniteData,
|
||||
|
|
@ -148,19 +136,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 +497,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={setDebouncedUserSearch}
|
||||
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 +899,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 +993,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,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,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,158 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
// Mirrors the antd Collapse this replaced: a panel's subtree is not built until it is first
|
||||
// opened, and it stays mounted afterwards so its view-mode state survives being collapsed.
|
||||
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 +321,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 +424,9 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
|
|||
metrics={modelMetrics[modelName]}
|
||||
hidePromptCachingMetrics={hidePromptCachingMetrics}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</ModelCollapsible>
|
||||
))}
|
||||
</Collapse>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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