feat(usage): add usage dashboard page with charts and navigation
This commit is contained in:
1 parent
1a1c0acd16
commit
a225791e71
3 files changed
+270
-3
No files matched your search
@@ -9,6 +9,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -40,6 +41,11 @@ export const MainLayout: React.FC = () => {
|
|||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
label: t('nav.accounts'),
|
label: t('nav.accounts'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/usage',
|
||||||
|
icon: BarChart3,
|
||||||
|
label: t('nav.usage'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/proxy',
|
to: '/proxy',
|
||||||
icon: Network,
|
icon: Network,
|
||||||
|
|||||||
+21
-3
@@ -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.
|
// 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 rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as UsageRouteImport } from './routes/usage'
|
||||||
import { Route as SettingsRouteImport } from './routes/settings'
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
import { Route as ProxyRouteImport } from './routes/proxy'
|
import { Route as ProxyRouteImport } from './routes/proxy'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
|
const UsageRoute = UsageRouteImport.update({
|
||||||
|
id: '/usage',
|
||||||
|
path: '/usage',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const SettingsRoute = SettingsRouteImport.update({
|
const SettingsRoute = SettingsRouteImport.update({
|
||||||
id: '/settings',
|
id: '/settings',
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
@@ -33,34 +39,45 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/proxy': typeof ProxyRoute
|
'/proxy': typeof ProxyRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
|
'/usage': typeof UsageRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/proxy': typeof ProxyRoute
|
'/proxy': typeof ProxyRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
|
'/usage': typeof UsageRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/proxy': typeof ProxyRoute
|
'/proxy': typeof ProxyRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
|
'/usage': typeof UsageRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/proxy' | '/settings'
|
fullPaths: '/' | '/proxy' | '/settings' | '/usage'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/proxy' | '/settings'
|
to: '/' | '/proxy' | '/settings' | '/usage'
|
||||||
id: '__root__' | '/' | '/proxy' | '/settings'
|
id: '__root__' | '/' | '/proxy' | '/settings' | '/usage'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
ProxyRoute: typeof ProxyRoute
|
ProxyRoute: typeof ProxyRoute
|
||||||
SettingsRoute: typeof SettingsRoute
|
SettingsRoute: typeof SettingsRoute
|
||||||
|
UsageRoute: typeof UsageRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/usage': {
|
||||||
|
id: '/usage'
|
||||||
|
path: '/usage'
|
||||||
|
fullPath: '/usage'
|
||||||
|
preLoaderRoute: typeof UsageRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/settings': {
|
'/settings': {
|
||||||
id: '/settings'
|
id: '/settings'
|
||||||
path: '/settings'
|
path: '/settings'
|
||||||
@@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
ProxyRoute: ProxyRoute,
|
ProxyRoute: ProxyRoute,
|
||||||
SettingsRoute: SettingsRoute,
|
SettingsRoute: SettingsRoute,
|
||||||
|
UsageRoute: UsageRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-6xl space-y-6 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">{t('usage.title')}</h2>
|
||||||
|
<p className="text-muted-foreground mt-1">{t('usage.description')}</p>
|
||||||
|
</div>
|
||||||
|
<Tabs value={range} onValueChange={(v) => setRange(v as '24h' | '7d' | '30d')}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="24h">{t('usage.range24h')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="7d">{t('usage.range7d')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="30d">{t('usage.range30d')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{t('usage.totalTokens')}</CardTitle>
|
||||||
|
<Activity className="text-muted-foreground h-4 w-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totals.totalTokens.toLocaleString()}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{t('usage.promptTokens')}</CardTitle>
|
||||||
|
<TrendingUp className="text-muted-foreground h-4 w-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totals.promptTokens.toLocaleString()}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{t('usage.completionTokens')}</CardTitle>
|
||||||
|
<TrendingDown className="text-muted-foreground h-4 w-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totals.completionTokens.toLocaleString()}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{t('usage.totalRequests')}</CardTitle>
|
||||||
|
<Layers className="text-muted-foreground h-4 w-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totals.requests.toLocaleString()}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('usage.dailyUsage')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="Prompt" fill="#3b82f6" />
|
||||||
|
<Bar dataKey="Completion" fill="#10b981" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('usage.byModel')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={100}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{pieData.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{range === '24h' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('usage.hourlyUsage')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={hourlyChartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="Total" fill="#8b5cf6" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/usage')({
|
||||||
|
component: UsagePage,
|
||||||
|
});
|
||||||
Reference in new issue
Block a user