mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
Add User Usage UI components and integration
Implement complete UI for admin user usage analytics: - UserUsage: Main component with state management and data fetching - UserUsageBarChart: Horizontal bar chart for top users (10/25/50) - UserUsageSummary: Five summary metric cards (total, spend, avg, power users, low users) - UserUsageFilters: Tag filters, min/max spend, sort controls - UserUsageTable: Paginated table with sortable columns - types.ts: TypeScript interfaces for all components Integration: - Added "User Usage" option to UsageViewSelect (admin-only) - Integrated UserUsage component into UsagePageView - Connects to /admin/users/daily/activity endpoint via adminUsersDailyActivityCall() Features: - Server-side pagination for 1K+ users - Tag-based filtering (User-Agent:claude-code, etc.) - Min/max spend thresholds - Sort by spend/requests/tokens - Success rate badges with color coding - Dynamic top N users selector - Responsive layout with Tremor and Ant Design
This commit is contained in:
parent
2659061235
commit
b89ad6d296
8 changed files with 929 additions and 1 deletions
|
|
@ -47,6 +47,7 @@ import AdvancedDatePicker from "../../shared/advanced_date_picker";
|
|||
import { ChartLoader } from "../../shared/chart_loader";
|
||||
import { Tag } from "../../tag_management/types";
|
||||
import UserAgentActivity from "../../user_agent_activity";
|
||||
import UserUsage from "./UserUsage/UserUsage";
|
||||
import ViewUserSpend from "../../view_user_spend";
|
||||
import { DailyData, KeyMetricWithMetadata, MetricWithMetadata } from "../types";
|
||||
import { valueFormatterSpend } from "../utils/value_formatters";
|
||||
|
|
@ -886,6 +887,8 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
{usageView === "user-agent-activity" && (
|
||||
<UserAgentActivity accessToken={accessToken} userRole={userRole} dateValue={dateValue} />
|
||||
)}
|
||||
{/* User Usage Panel */}
|
||||
{usageView === "user-usage" && <UserUsage accessToken={accessToken} dateValue={dateValue} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import {
|
|||
ShoppingCartOutlined,
|
||||
TagsOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Badge, Select } from "antd";
|
||||
import React from "react";
|
||||
export type UsageOption = "global" | "organization" | "team" | "customer" | "tag" | "agent" | "user-agent-activity";
|
||||
export type UsageOption = "global" | "organization" | "team" | "customer" | "tag" | "agent" | "user-agent-activity" | "user-usage";
|
||||
export interface UsageViewSelectProps {
|
||||
value: UsageOption;
|
||||
onChange: (value: UsageOption) => void;
|
||||
|
|
@ -86,6 +87,13 @@ const OPTIONS: OptionConfig[] = [
|
|||
icon: <LineChartOutlined style={{ fontSize: "16px" }} />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "user-usage",
|
||||
label: "User Usage",
|
||||
description: "View usage metrics per user with filtering",
|
||||
icon: <UserOutlined style={{ fontSize: "16px" }} />,
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
||||
value,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* User Usage Component
|
||||
*
|
||||
* Admin view for user usage analytics with filtering, sorting, and pagination.
|
||||
* Shows top users bar chart, summary cards, and paginated user table.
|
||||
*/
|
||||
|
||||
import { DateRangePickerValue } from "@tremor/react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { adminUsersDailyActivityCall } from "../../../networking";
|
||||
import { UserUsageBarChart } from "./UserUsageBarChart";
|
||||
import { UserUsageFilters } from "./UserUsageFilters";
|
||||
import { UserUsageSummary } from "./UserUsageSummary";
|
||||
import { UserUsageTable } from "./UserUsageTable";
|
||||
import {
|
||||
UserUsageFiltersState,
|
||||
UserUsageResponse,
|
||||
} from "./types";
|
||||
|
||||
interface UserUsageProps {
|
||||
accessToken: string | null;
|
||||
dateValue: DateRangePickerValue;
|
||||
}
|
||||
|
||||
const UserUsage: React.FC<UserUsageProps> = ({ accessToken, dateValue }) => {
|
||||
const [data, setData] = useState<UserUsageResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [topN, setTopN] = useState(10);
|
||||
|
||||
const [filters, setFilters] = useState<UserUsageFiltersState>({
|
||||
tagFilters: [],
|
||||
minSpend: null,
|
||||
maxSpend: null,
|
||||
sortBy: "spend",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!accessToken || !dateValue.from || !dateValue.to) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await adminUsersDailyActivityCall(
|
||||
accessToken,
|
||||
dateValue.from,
|
||||
dateValue.to,
|
||||
page,
|
||||
filters.tagFilters.length > 0 ? filters.tagFilters : null,
|
||||
filters.minSpend,
|
||||
filters.maxSpend,
|
||||
filters.sortBy,
|
||||
filters.sortOrder,
|
||||
topN
|
||||
);
|
||||
|
||||
setData(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching user usage data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
accessToken,
|
||||
dateValue.from,
|
||||
dateValue.to,
|
||||
page,
|
||||
pageSize,
|
||||
topN,
|
||||
filters,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleFiltersChange = (newFilters: Partial<UserUsageFiltersState>) => {
|
||||
setFilters((prev) => ({ ...prev, ...newFilters }));
|
||||
setPage(1); // Reset to first page when filters change
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setPage(1); // Reset to first page when page size changes
|
||||
};
|
||||
|
||||
const handleTopNChange = (newTopN: number) => {
|
||||
setTopN(newTopN);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<UserUsageFilters
|
||||
filters={filters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Top Users Bar Chart */}
|
||||
<UserUsageBarChart
|
||||
topUsers={data?.top_users || []}
|
||||
loading={loading}
|
||||
topN={topN}
|
||||
onTopNChange={handleTopNChange}
|
||||
sortBy={filters.sortBy}
|
||||
/>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<UserUsageSummary summary={data?.summary || null} loading={loading} />
|
||||
|
||||
{/* Paginated User Table */}
|
||||
<UserUsageTable
|
||||
users={data?.users || []}
|
||||
pagination={data?.pagination || null}
|
||||
loading={loading}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
sortBy={filters.sortBy}
|
||||
sortOrder={filters.sortOrder}
|
||||
onSortChange={(sortBy, sortOrder) =>
|
||||
handleFiltersChange({ sortBy, sortOrder })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserUsage;
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* User Usage Bar Chart Component
|
||||
*
|
||||
* Displays top N users in a horizontal bar chart
|
||||
*/
|
||||
|
||||
import { BarChart, Card, Title } from "@tremor/react";
|
||||
import { Segmented } from "antd";
|
||||
import React from "react";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { ChartLoader } from "../../../shared/chart_loader";
|
||||
import { UserMetrics } from "./types";
|
||||
|
||||
interface UserUsageBarChartProps {
|
||||
topUsers: UserMetrics[];
|
||||
loading: boolean;
|
||||
topN: number;
|
||||
onTopNChange: (topN: number) => void;
|
||||
sortBy: "spend" | "requests" | "tokens";
|
||||
}
|
||||
|
||||
const valueFormatters = {
|
||||
spend: (value: number) => `$${formatNumberWithCommas(value, 2)}`,
|
||||
requests: (value: number) => formatNumberWithCommas(value, 0),
|
||||
tokens: (value: number) => formatNumberWithCommas(value, 0),
|
||||
};
|
||||
|
||||
export const UserUsageBarChart: React.FC<UserUsageBarChartProps> = ({
|
||||
topUsers,
|
||||
loading,
|
||||
topN,
|
||||
onTopNChange,
|
||||
sortBy,
|
||||
}) => {
|
||||
const chartData = topUsers.map((user) => ({
|
||||
name: user.user_email || user.user_id,
|
||||
value: user[sortBy],
|
||||
}));
|
||||
|
||||
const valueFormatter = valueFormatters[sortBy];
|
||||
|
||||
const getTitle = () => {
|
||||
const sortLabel = sortBy === "spend" ? "Spend" : sortBy === "requests" ? "Requests" : "Tokens";
|
||||
return `Top ${topN} Users by ${sortLabel}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>{getTitle()}</Title>
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: "10", value: 10 },
|
||||
{ label: "25", value: 25 },
|
||||
{ label: "50", value: 50 },
|
||||
]}
|
||||
value={topN}
|
||||
onChange={(value) => onTopNChange(value as number)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={loading} />
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No user data available for the selected date range and filters.
|
||||
</div>
|
||||
) : (
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="name"
|
||||
categories={["value"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatter}
|
||||
layout="vertical"
|
||||
yAxisWidth={250}
|
||||
showLegend={false}
|
||||
style={{ height: Math.min(chartData.length * 52, 600) }}
|
||||
customTooltip={({ payload, active }) => {
|
||||
if (!active || !payload?.[0]) return null;
|
||||
const data = payload[0].payload;
|
||||
const user = topUsers.find(
|
||||
(u) => (u.user_email || u.user_id) === data.name
|
||||
);
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 shadow-lg rounded-lg border">
|
||||
<p className="font-bold">{user.user_email || user.user_id}</p>
|
||||
<p className="text-cyan-500">
|
||||
Spend: ${formatNumberWithCommas(user.spend, 2)}
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
Requests: {formatNumberWithCommas(user.requests, 0)}
|
||||
</p>
|
||||
<p className="text-green-600">
|
||||
Successful: {formatNumberWithCommas(user.successful_requests, 0)}
|
||||
</p>
|
||||
<p className="text-red-600">
|
||||
Failed: {formatNumberWithCommas(user.failed_requests, 0)}
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
Tokens: {formatNumberWithCommas(user.tokens, 0)}
|
||||
</p>
|
||||
<p className="text-gray-600">Days Active: {user.days_active}</p>
|
||||
{user.tags && user.tags.length > 0 && (
|
||||
<p className="text-gray-600 text-sm mt-2">
|
||||
Tags: {user.tags.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* User Usage Filters Component
|
||||
*
|
||||
* Filter controls for tag selection, spend thresholds, and sorting
|
||||
*/
|
||||
|
||||
import { Button, Card } from "@tremor/react";
|
||||
import { Input, Select } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { UserUsageFiltersState } from "./types";
|
||||
|
||||
interface UserUsageFiltersProps {
|
||||
filters: UserUsageFiltersState;
|
||||
onFiltersChange: (filters: Partial<UserUsageFiltersState>) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const COMMON_TAGS = [
|
||||
"User-Agent:claude-code",
|
||||
"User-Agent:claude-code-max",
|
||||
"User-Agent:cursor",
|
||||
"User-Agent:windsurf",
|
||||
];
|
||||
|
||||
export const UserUsageFilters: React.FC<UserUsageFiltersProps> = ({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
loading,
|
||||
}) => {
|
||||
const [localMinSpend, setLocalMinSpend] = useState<string>(
|
||||
filters.minSpend?.toString() || ""
|
||||
);
|
||||
const [localMaxSpend, setLocalMaxSpend] = useState<string>(
|
||||
filters.maxSpend?.toString() || ""
|
||||
);
|
||||
|
||||
const handleApplySpendFilters = () => {
|
||||
onFiltersChange({
|
||||
minSpend: localMinSpend ? parseFloat(localMinSpend) : null,
|
||||
maxSpend: localMaxSpend ? parseFloat(localMaxSpend) : null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setLocalMinSpend("");
|
||||
setLocalMaxSpend("");
|
||||
onFiltersChange({
|
||||
tagFilters: [],
|
||||
minSpend: null,
|
||||
maxSpend: null,
|
||||
sortBy: "spend",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Filters</h3>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
onClick={handleResetFilters}
|
||||
disabled={loading}
|
||||
>
|
||||
Reset All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Tag Filters */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
🏷️ User Agent Tags
|
||||
</label>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Select tags..."
|
||||
value={filters.tagFilters}
|
||||
onChange={(value) => onFiltersChange({ tagFilters: value })}
|
||||
disabled={loading}
|
||||
options={COMMON_TAGS.map((tag) => ({
|
||||
label: tag,
|
||||
value: tag,
|
||||
}))}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Min Spend */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
💰 Min Spend ($)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g., 200"
|
||||
value={localMinSpend}
|
||||
onChange={(e) => setLocalMinSpend(e.target.value)}
|
||||
onPressEnter={handleApplySpendFilters}
|
||||
disabled={loading}
|
||||
min={0}
|
||||
step={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Spend */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
💰 Max Spend ($)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g., 1000"
|
||||
value={localMaxSpend}
|
||||
onChange={(e) => setLocalMaxSpend(e.target.value)}
|
||||
onPressEnter={handleApplySpendFilters}
|
||||
disabled={loading}
|
||||
min={0}
|
||||
step={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort By */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
📊 Sort By
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
value={filters.sortBy}
|
||||
onChange={(value) => onFiltersChange({ sortBy: value })}
|
||||
disabled={loading}
|
||||
options={[
|
||||
{ label: "Spend", value: "spend" },
|
||||
{ label: "Requests", value: "requests" },
|
||||
{ label: "Tokens", value: "tokens" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 100 }}
|
||||
value={filters.sortOrder}
|
||||
onChange={(value) => onFiltersChange({ sortOrder: value })}
|
||||
disabled={loading}
|
||||
options={[
|
||||
{ label: "↓ Desc", value: "desc" },
|
||||
{ label: "↑ Asc", value: "asc" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apply Button for Spend Filters */}
|
||||
{(localMinSpend !== (filters.minSpend?.toString() || "") ||
|
||||
localMaxSpend !== (filters.maxSpend?.toString() || "")) && (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleApplySpendFilters} disabled={loading}>
|
||||
Apply Spend Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Filters Summary */}
|
||||
{(filters.tagFilters.length > 0 ||
|
||||
filters.minSpend !== null ||
|
||||
filters.maxSpend !== null) && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<strong>Active filters:</strong>{" "}
|
||||
{filters.tagFilters.length > 0 && (
|
||||
<span>Tags: {filters.tagFilters.length} selected; </span>
|
||||
)}
|
||||
{filters.minSpend !== null && (
|
||||
<span>Min spend: ${filters.minSpend}; </span>
|
||||
)}
|
||||
{filters.maxSpend !== null && (
|
||||
<span>Max spend: ${filters.maxSpend}; </span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* User Usage Summary Component
|
||||
*
|
||||
* Displays summary cards with total users, spend, requests, etc.
|
||||
*/
|
||||
|
||||
import { Card, Col, Grid, Text, Title } from "@tremor/react";
|
||||
import React from "react";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { UserUsageSummary as UserUsageSummaryType } from "./types";
|
||||
|
||||
interface UserUsageSummaryProps {
|
||||
summary: UserUsageSummaryType | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const UserUsageSummary: React.FC<UserUsageSummaryProps> = ({
|
||||
summary,
|
||||
loading,
|
||||
}) => {
|
||||
if (loading || !summary) {
|
||||
return (
|
||||
<Grid numItems={5} className="gap-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-24 mb-2"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-16"></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid numItems={5} className="gap-4">
|
||||
{/* Total Users */}
|
||||
<Card>
|
||||
<Title>Total Users</Title>
|
||||
<Text className="text-2xl font-bold mt-2">
|
||||
{formatNumberWithCommas(summary.total_users, 0)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* Total Spend */}
|
||||
<Card>
|
||||
<Title>Total Spend</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-cyan-600">
|
||||
${formatNumberWithCommas(summary.total_spend, 2)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* Average per User */}
|
||||
<Card>
|
||||
<Title>Avg per User</Title>
|
||||
<Text className="text-2xl font-bold mt-2">
|
||||
${formatNumberWithCommas(summary.avg_spend_per_user, 2)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* Power Users */}
|
||||
<Card>
|
||||
<Title>Power Users (>$200)</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-green-600">
|
||||
{formatNumberWithCommas(summary.power_users_count, 0)}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-500 mt-1">
|
||||
{summary.total_users > 0
|
||||
? `${((summary.power_users_count / summary.total_users) * 100).toFixed(1)}% of total`
|
||||
: "0%"}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* Low Users */}
|
||||
<Card>
|
||||
<Title>Low Users (<$10)</Title>
|
||||
<Text className="text-2xl font-bold mt-2 text-orange-600">
|
||||
{formatNumberWithCommas(summary.low_users_count, 0)}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-500 mt-1">
|
||||
{summary.total_users > 0
|
||||
? `${((summary.low_users_count / summary.total_users) * 100).toFixed(1)}% of total`
|
||||
: "0%"}
|
||||
</Text>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
/**
|
||||
* User Usage Table Component
|
||||
*
|
||||
* Displays paginated user list with sorting and pagination controls
|
||||
*/
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Title,
|
||||
} from "@tremor/react";
|
||||
import { Select } from "antd";
|
||||
import React from "react";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { UserMetrics, UserUsagePagination } from "./types";
|
||||
|
||||
interface UserUsageTableProps {
|
||||
users: UserMetrics[];
|
||||
pagination: UserUsagePagination | null;
|
||||
loading: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
sortBy: "spend" | "requests" | "tokens";
|
||||
sortOrder: "asc" | "desc";
|
||||
onSortChange: (
|
||||
sortBy: "spend" | "requests" | "tokens",
|
||||
sortOrder: "asc" | "desc"
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const UserUsageTable: React.FC<UserUsageTableProps> = ({
|
||||
users,
|
||||
pagination,
|
||||
loading,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
}) => {
|
||||
const handleSortClick = (column: "spend" | "requests" | "tokens") => {
|
||||
if (sortBy === column) {
|
||||
// Toggle sort order
|
||||
onSortChange(column, sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
// Change sort column (default to desc)
|
||||
onSortChange(column, "desc");
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (column: "spend" | "requests" | "tokens") => {
|
||||
if (sortBy !== column) return "⇅";
|
||||
return sortOrder === "asc" ? "↑" : "↓";
|
||||
};
|
||||
|
||||
const renderPagination = () => {
|
||||
if (!pagination) return null;
|
||||
|
||||
const { page: currentPage, total_pages, total_count } = pagination;
|
||||
const startItem = (currentPage - 1) * pageSize + 1;
|
||||
const endItem = Math.min(currentPage * pageSize, total_count);
|
||||
|
||||
const pages = [];
|
||||
const maxPagesToShow = 7;
|
||||
const halfRange = Math.floor(maxPagesToShow / 2);
|
||||
|
||||
let startPage = Math.max(1, currentPage - halfRange);
|
||||
let endPage = Math.min(total_pages, currentPage + halfRange);
|
||||
|
||||
// Adjust if we're near the start or end
|
||||
if (currentPage <= halfRange) {
|
||||
endPage = Math.min(total_pages, maxPagesToShow);
|
||||
} else if (currentPage + halfRange >= total_pages) {
|
||||
startPage = Math.max(1, total_pages - maxPagesToShow + 1);
|
||||
}
|
||||
|
||||
// Add first page if not visible
|
||||
if (startPage > 1) {
|
||||
pages.push(
|
||||
<button
|
||||
key={1}
|
||||
onClick={() => onPageChange(1)}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50"
|
||||
>
|
||||
1
|
||||
</button>
|
||||
);
|
||||
if (startPage > 2) {
|
||||
pages.push(
|
||||
<span key="ellipsis1" className="px-2">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add page numbers
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onPageChange(i)}
|
||||
className={`px-3 py-1 border rounded ${
|
||||
i === currentPage
|
||||
? "bg-cyan-500 text-white"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Add last page if not visible
|
||||
if (endPage < total_pages) {
|
||||
if (endPage < total_pages - 1) {
|
||||
pages.push(
|
||||
<span key="ellipsis2" className="px-2">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
pages.push(
|
||||
<button
|
||||
key={total_pages}
|
||||
onClick={() => onPageChange(total_pages)}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50"
|
||||
>
|
||||
{total_pages}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {startItem}-{endItem} of {total_count} users
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Page Size Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Items per page:</span>
|
||||
<Select
|
||||
value={pageSize}
|
||||
onChange={onPageSizeChange}
|
||||
style={{ width: 80 }}
|
||||
options={[
|
||||
{ label: "25", value: 25 },
|
||||
{ label: "50", value: 50 },
|
||||
{ label: "100", value: 100 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pagination Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
|
||||
<div className="flex gap-1">{pages}</div>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === total_pages}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>👥 All Users</Title>
|
||||
{pagination && (
|
||||
<span className="text-sm text-gray-600">
|
||||
{pagination.total_count} total users
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-cyan-500 mx-auto"></div>
|
||||
<p className="text-gray-500 mt-4">Loading users...</p>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No users found for the selected filters.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>User Email</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<button
|
||||
onClick={() => handleSortClick("spend")}
|
||||
className="flex items-center gap-1 hover:text-cyan-600"
|
||||
>
|
||||
Spend {getSortIcon("spend")}
|
||||
</button>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<button
|
||||
onClick={() => handleSortClick("requests")}
|
||||
className="flex items-center gap-1 hover:text-cyan-600"
|
||||
>
|
||||
Requests {getSortIcon("requests")}
|
||||
</button>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<button
|
||||
onClick={() => handleSortClick("tokens")}
|
||||
className="flex items-center gap-1 hover:text-cyan-600"
|
||||
>
|
||||
Tokens {getSortIcon("tokens")}
|
||||
</button>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Success Rate</TableHeaderCell>
|
||||
<TableHeaderCell>Days Active</TableHeaderCell>
|
||||
<TableHeaderCell>Tags</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((user) => {
|
||||
const successRate =
|
||||
user.requests > 0
|
||||
? (user.successful_requests / user.requests) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TableRow key={user.user_id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{user.user_email || user.user_id}
|
||||
</div>
|
||||
{user.user_email && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{user.user_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium text-cyan-600">
|
||||
${formatNumberWithCommas(user.spend, 2)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatNumberWithCommas(user.requests, 0)}
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="text-green-600">
|
||||
{formatNumberWithCommas(user.successful_requests, 0)}
|
||||
</span>
|
||||
{" / "}
|
||||
<span className="text-red-600">
|
||||
{formatNumberWithCommas(user.failed_requests, 0)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatNumberWithCommas(user.tokens, 0)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
color={
|
||||
successRate >= 99
|
||||
? "green"
|
||||
: successRate >= 95
|
||||
? "yellow"
|
||||
: "red"
|
||||
}
|
||||
>
|
||||
{successRate.toFixed(1)}%
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{user.days_active}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.tags && user.tags.length > 0 ? (
|
||||
user.tags.slice(0, 2).map((tag, idx) => (
|
||||
<Badge key={idx} size="xs">
|
||||
{tag.replace("User-Agent:", "")}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">-</span>
|
||||
)}
|
||||
{user.tags && user.tags.length > 2 && (
|
||||
<Badge size="xs" color="gray">
|
||||
+{user.tags.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{renderPagination()}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* TypeScript types for Admin User Usage components
|
||||
*/
|
||||
|
||||
export interface UserUsageSummary {
|
||||
total_users: number;
|
||||
total_spend: number;
|
||||
total_requests: number;
|
||||
total_successful_requests: number;
|
||||
total_failed_requests: number;
|
||||
total_tokens: number;
|
||||
avg_spend_per_user: number;
|
||||
power_users_count: number;
|
||||
low_users_count: number;
|
||||
}
|
||||
|
||||
export interface UserMetrics {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
spend: number;
|
||||
requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
tokens: number;
|
||||
days_active: number;
|
||||
first_request_date: string;
|
||||
last_request_date: string;
|
||||
tags: string[];
|
||||
models_used: string[];
|
||||
}
|
||||
|
||||
export interface UserUsagePagination {
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_count: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface UserUsageResponse {
|
||||
summary: UserUsageSummary;
|
||||
top_users: UserMetrics[];
|
||||
users: UserMetrics[];
|
||||
pagination: UserUsagePagination;
|
||||
}
|
||||
|
||||
export interface UserUsageFiltersState {
|
||||
tagFilters: string[];
|
||||
minSpend: number | null;
|
||||
maxSpend: number | null;
|
||||
sortBy: "spend" | "requests" | "tokens";
|
||||
sortOrder: "asc" | "desc";
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue