diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 88385248b6e..569d13d250b 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -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 = ({ teams, organizations }) => { {usageView === "user-agent-activity" && ( )} + {/* User Usage Panel */} + {usageView === "user-usage" && } diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.tsx index 3d456de5a65..6e815620d2c 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.tsx @@ -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: , adminOnly: true, }, + { + value: "user-usage", + label: "User Usage", + description: "View usage metrics per user with filtering", + icon: , + adminOnly: true, + }, ]; export const UsageViewSelect: React.FC = ({ value, diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsage.tsx new file mode 100644 index 00000000000..94c4e56a531 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsage.tsx @@ -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 = ({ accessToken, dateValue }) => { + const [data, setData] = useState(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({ + 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) => { + 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 ( +
+ {/* Filters */} + + + {/* Top Users Bar Chart */} + + + {/* Summary Cards */} + + + {/* Paginated User Table */} + + handleFiltersChange({ sortBy, sortOrder }) + } + /> +
+ ); +}; + +export default UserUsage; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageBarChart.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageBarChart.tsx new file mode 100644 index 00000000000..a99cc96bb61 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageBarChart.tsx @@ -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 = ({ + 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 ( + +
+ {getTitle()} + onTopNChange(value as number)} + /> +
+ + {loading ? ( + + ) : chartData.length === 0 ? ( +
+ No user data available for the selected date range and filters. +
+ ) : ( + { + 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 ( +
+

{user.user_email || user.user_id}

+

+ Spend: ${formatNumberWithCommas(user.spend, 2)} +

+

+ Requests: {formatNumberWithCommas(user.requests, 0)} +

+

+ Successful: {formatNumberWithCommas(user.successful_requests, 0)} +

+

+ Failed: {formatNumberWithCommas(user.failed_requests, 0)} +

+

+ Tokens: {formatNumberWithCommas(user.tokens, 0)} +

+

Days Active: {user.days_active}

+ {user.tags && user.tags.length > 0 && ( +

+ Tags: {user.tags.join(", ")} +

+ )} +
+ ); + }} + /> + )} +
+ ); +}; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageFilters.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageFilters.tsx new file mode 100644 index 00000000000..1ee8f71bc19 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageFilters.tsx @@ -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) => 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 = ({ + filters, + onFiltersChange, + loading, +}) => { + const [localMinSpend, setLocalMinSpend] = useState( + filters.minSpend?.toString() || "" + ); + const [localMaxSpend, setLocalMaxSpend] = useState( + 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 ( + +
+
+

Filters

+ +
+ +
+ {/* Tag Filters */} +
+ + setLocalMinSpend(e.target.value)} + onPressEnter={handleApplySpendFilters} + disabled={loading} + min={0} + step={10} + /> +
+ + {/* Max Spend */} +
+ + setLocalMaxSpend(e.target.value)} + onPressEnter={handleApplySpendFilters} + disabled={loading} + min={0} + step={10} + /> +
+ + {/* Sort By */} +
+ +
+ onFiltersChange({ sortOrder: value })} + disabled={loading} + options={[ + { label: "↓ Desc", value: "desc" }, + { label: "↑ Asc", value: "asc" }, + ]} + /> +
+
+
+ + {/* Apply Button for Spend Filters */} + {(localMinSpend !== (filters.minSpend?.toString() || "") || + localMaxSpend !== (filters.maxSpend?.toString() || "")) && ( +
+ +
+ )} + + {/* Active Filters Summary */} + {(filters.tagFilters.length > 0 || + filters.minSpend !== null || + filters.maxSpend !== null) && ( +
+ Active filters:{" "} + {filters.tagFilters.length > 0 && ( + Tags: {filters.tagFilters.length} selected; + )} + {filters.minSpend !== null && ( + Min spend: ${filters.minSpend}; + )} + {filters.maxSpend !== null && ( + Max spend: ${filters.maxSpend}; + )} +
+ )} +
+
+ ); +}; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageSummary.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageSummary.tsx new file mode 100644 index 00000000000..b0d5f1e0da8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageSummary.tsx @@ -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 = ({ + summary, + loading, +}) => { + if (loading || !summary) { + return ( + + {[...Array(5)].map((_, i) => ( + +
+
+
+
+
+ ))} +
+ ); + } + + return ( + + {/* Total Users */} + + Total Users + + {formatNumberWithCommas(summary.total_users, 0)} + + + + {/* Total Spend */} + + Total Spend + + ${formatNumberWithCommas(summary.total_spend, 2)} + + + + {/* Average per User */} + + Avg per User + + ${formatNumberWithCommas(summary.avg_spend_per_user, 2)} + + + + {/* Power Users */} + + Power Users (>$200) + + {formatNumberWithCommas(summary.power_users_count, 0)} + + + {summary.total_users > 0 + ? `${((summary.power_users_count / summary.total_users) * 100).toFixed(1)}% of total` + : "0%"} + + + + {/* Low Users */} + + Low Users (<$10) + + {formatNumberWithCommas(summary.low_users_count, 0)} + + + {summary.total_users > 0 + ? `${((summary.low_users_count / summary.total_users) * 100).toFixed(1)}% of total` + : "0%"} + + + + ); +}; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageTable.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageTable.tsx new file mode 100644 index 00000000000..7c252560b0c --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UserUsage/UserUsageTable.tsx @@ -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 = ({ + 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( + + ); + if (startPage > 2) { + pages.push( + + ... + + ); + } + } + + // Add page numbers + for (let i = startPage; i <= endPage; i++) { + pages.push( + + ); + } + + // Add last page if not visible + if (endPage < total_pages) { + if (endPage < total_pages - 1) { + pages.push( + + ... + + ); + } + pages.push( + + ); + } + + return ( +
+
+ Showing {startItem}-{endItem} of {total_count} users +
+ +
+ {/* Page Size Selector */} +
+ Items per page: +