diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 5a7b02c..8dc40b8 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -9,6 +9,7 @@ import { ChevronLeft, ChevronRight, RefreshCw, + BarChart3, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; @@ -40,6 +41,11 @@ export const MainLayout: React.FC = () => { icon: LayoutDashboard, label: t('nav.accounts'), }, + { + to: '/usage', + icon: BarChart3, + label: t('nav.usage'), + }, { to: '/proxy', icon: Network, diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5a99cff..73b37fd 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,10 +9,16 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' import { Route as ProxyRouteImport } from './routes/proxy' import { Route as IndexRouteImport } from './routes/index' +const UsageRoute = UsageRouteImport.update({ + id: '/usage', + path: '/usage', + getParentRoute: () => rootRouteImport, +} as any) const SettingsRoute = SettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -33,34 +39,45 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/proxy': typeof ProxyRoute '/settings': typeof SettingsRoute + '/usage': typeof UsageRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/proxy': typeof ProxyRoute '/settings': typeof SettingsRoute + '/usage': typeof UsageRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/proxy': typeof ProxyRoute '/settings': typeof SettingsRoute + '/usage': typeof UsageRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/proxy' | '/settings' + fullPaths: '/' | '/proxy' | '/settings' | '/usage' fileRoutesByTo: FileRoutesByTo - to: '/' | '/proxy' | '/settings' - id: '__root__' | '/' | '/proxy' | '/settings' + to: '/' | '/proxy' | '/settings' | '/usage' + id: '__root__' | '/' | '/proxy' | '/settings' | '/usage' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute ProxyRoute: typeof ProxyRoute SettingsRoute: typeof SettingsRoute + UsageRoute: typeof UsageRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/usage': { + id: '/usage' + path: '/usage' + fullPath: '/usage' + preLoaderRoute: typeof UsageRouteImport + parentRoute: typeof rootRouteImport + } '/settings': { id: '/settings' path: '/settings' @@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ProxyRoute: ProxyRoute, SettingsRoute: SettingsRoute, + UsageRoute: UsageRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/usage.tsx b/src/routes/usage.tsx new file mode 100644 index 0000000..f1e3d12 --- /dev/null +++ b/src/routes/usage.tsx @@ -0,0 +1,243 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useTranslation } from 'react-i18next'; +import { Loader2, TrendingUp, TrendingDown, Activity, Layers } from 'lucide-react'; +import { ipc } from '@/ipc/manager'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + PieChart, + Pie, + Cell, + Legend, +} from 'recharts'; +import { useMemo, useState } from 'react'; + +const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4']; + +function UsagePage() { + const { t } = useTranslation(); + const [range, setRange] = useState<'24h' | '7d' | '30d'>('7d'); + + function getTimeRange(r: '24h' | '7d' | '30d') { + const now = Date.now(); + if (r === '24h') { + return { start: now - 24 * 60 * 60 * 1000, end: now }; + } + if (r === '7d') { + return { start: now - 7 * 24 * 60 * 60 * 1000, end: now }; + } + return { start: now - 30 * 24 * 60 * 60 * 1000, end: now }; + } + + const { data: dailyData, isLoading: dailyLoading } = useQuery({ + queryKey: ['usage', 'day', range], + queryFn: () => { + const { start, end } = getTimeRange(range); + return ipc.client.usage.getUsageByDay({ start, end }); + }, + }); + + const { data: modelData, isLoading: modelLoading } = useQuery({ + queryKey: ['usage', 'model', range], + queryFn: () => { + const { start, end } = getTimeRange(range); + return ipc.client.usage.getUsageByModel({ start, end }); + }, + }); + + const { data: hourlyData, isLoading: hourlyLoading } = useQuery({ + queryKey: ['usage', 'hour', range], + queryFn: () => { + const { start, end } = getTimeRange(range); + return ipc.client.usage.getUsageByHour({ start, end }); + }, + }); + + const isLoading = dailyLoading || modelLoading || hourlyLoading; + + const totals = useMemo(() => { + const source = dailyData || []; + return source.reduce( + (acc, row) => ({ + promptTokens: acc.promptTokens + row.promptTokens, + completionTokens: acc.completionTokens + row.completionTokens, + totalTokens: acc.totalTokens + row.totalTokens, + requests: acc.requests + row.requests, + }), + { promptTokens: 0, completionTokens: 0, totalTokens: 0, requests: 0 }, + ); + }, [dailyData]); + + const chartData = useMemo(() => { + return (dailyData || []).map((d) => ({ + name: d.bucket, + Prompt: d.promptTokens, + Completion: d.completionTokens, + Total: d.totalTokens, + })); + }, [dailyData]); + + const pieData = useMemo(() => { + return (modelData || []).map((d) => ({ + name: d.model, + value: d.totalTokens, + })); + }, [modelData]); + + const hourlyChartData = useMemo(() => { + return (hourlyData || []).map((d) => ({ + name: d.bucket, + Total: d.totalTokens, + })); + }, [hourlyData]); + + if (isLoading) { + return ( +
{t('usage.description')}
+