feat(usage): refactor dashboard frontend with shadcn/ui components, skeleton states, and dark-theme charts
This commit is contained in:
1 parent
425b0009b8
commit
2f42d70d78
8 files changed
+555
-229
No files matched your search
@@ -0,0 +1,29 @@
|
|||||||
|
## Design Context
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
Developers and power users who manage multiple AI accounts (Google Gemini, Claude) through a desktop Electron app. They use IDEs like Cursor, OpenCode, and Windsurf. They need to monitor token usage across accounts in real-time to avoid quota exhaustion.
|
||||||
|
|
||||||
|
### Brand Personality
|
||||||
|
|
||||||
|
- **Professional**: Clean, precise, no clutter
|
||||||
|
- **Technical**: Data-dense but readable, developer-friendly
|
||||||
|
- **Efficient**: Information at a glance, minimal clicks
|
||||||
|
- 3 words: "precise", "technical", "focused"
|
||||||
|
|
||||||
|
### Aesthetic Direction
|
||||||
|
|
||||||
|
- Dark mode primary (this is a developer tool used for long sessions)
|
||||||
|
- Deep navy/slate backgrounds, not pure black
|
||||||
|
- Sharp accent colors for data visualization (subtle blues, teals, amber for warnings)
|
||||||
|
- High information density without visual noise
|
||||||
|
- Monospace for numbers/data, clean sans-serif for UI chrome
|
||||||
|
- No gradients on text, no glassmorphism, no rounded-everything
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
1. Data first — charts and numbers are the heroes
|
||||||
|
2. Every pixel earns its place — no decorative elements
|
||||||
|
3. Density over whitespace — developers want to see everything
|
||||||
|
4. Motion only for state changes — no gratuitous animations
|
||||||
|
5. Consistent with shadcn/ui + Tailwind system already in place
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('bg-muted animate-pulse rounded-md', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { AlertTriangle, BarChart3, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ChartStatesProps {
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
isEmpty: boolean;
|
||||||
|
onRetry: () => void;
|
||||||
|
title: string;
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyDescription: string;
|
||||||
|
errorMessage: string;
|
||||||
|
retryLabel: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChartCard: React.FC<ChartStatesProps> = ({
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isEmpty,
|
||||||
|
onRetry,
|
||||||
|
title,
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
errorMessage,
|
||||||
|
retryLabel,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/60 bg-card/60 flex flex-col">
|
||||||
|
<CardContent className="flex flex-1 flex-col p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-foreground text-sm font-semibold tracking-tight">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-[280px] flex-col gap-3">
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
|
||||||
|
<AlertTriangle className="text-destructive h-5 w-5" />
|
||||||
|
<p className="text-muted-foreground text-sm">{errorMessage}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
|
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{retryLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : isEmpty ? (
|
||||||
|
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
|
||||||
|
<BarChart3 className="text-muted-foreground/50 h-6 w-6" />
|
||||||
|
<p className="text-foreground text-sm font-medium">{emptyTitle}</p>
|
||||||
|
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
|
||||||
|
{emptyDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TooltipPayloadItem {
|
||||||
|
name?: string;
|
||||||
|
value?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomTooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: TooltipPayloadItem[];
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomTooltip: React.FC<CustomTooltipProps> = ({ active, payload, label }) => {
|
||||||
|
if (!active || !payload || payload.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-popover border-border rounded-md border px-3 py-2 shadow-md">
|
||||||
|
<p className="text-foreground mb-1.5 text-xs font-medium">{label}</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||||
|
<span className="text-muted-foreground">{entry.name}:</span>
|
||||||
|
<span className="text-foreground font-mono font-medium">
|
||||||
|
{typeof entry.value === 'number' ? entry.value.toLocaleString() : entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
icon: LucideIcon;
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
accent?: 'blue' | 'green' | 'amber' | 'slate';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCENT_MAP = {
|
||||||
|
blue: 'text-blue-400',
|
||||||
|
green: 'text-emerald-400',
|
||||||
|
amber: 'text-amber-400',
|
||||||
|
slate: 'text-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BG_MAP = {
|
||||||
|
blue: 'bg-blue-500/5',
|
||||||
|
green: 'bg-emerald-500/5',
|
||||||
|
amber: 'bg-amber-500/5',
|
||||||
|
slate: 'bg-slate-500/5',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StatCard: React.FC<StatCardProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
isLoading,
|
||||||
|
className,
|
||||||
|
accent = 'slate',
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'border-border/60 bg-card/60 hover:bg-card backdrop-blur-none transition-colors',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-7 w-24" />
|
||||||
|
) : (
|
||||||
|
<span className="text-foreground font-mono text-2xl font-semibold tracking-tight">
|
||||||
|
{value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-9 shrink-0 items-center justify-center rounded-md',
|
||||||
|
BG_MAP[accent],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-4 w-4', ACCENT_MAP[accent])} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -511,6 +511,13 @@ i18n
|
|||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
allAccounts: 'All Accounts',
|
allAccounts: 'All Accounts',
|
||||||
noData: 'No data available',
|
noData: 'No data available',
|
||||||
|
emptyStateTitle: 'No usage data yet',
|
||||||
|
emptyStateDescription:
|
||||||
|
'Start making API requests to see your token usage statistics here.',
|
||||||
|
promptLabel: 'Prompt',
|
||||||
|
completionLabel: 'Completion',
|
||||||
|
totalLabel: 'Total',
|
||||||
|
requestsLabel: 'Requests',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1005,6 +1012,12 @@ i18n
|
|||||||
retry: '重试',
|
retry: '重试',
|
||||||
allAccounts: '所有账号',
|
allAccounts: '所有账号',
|
||||||
noData: '暂无数据',
|
noData: '暂无数据',
|
||||||
|
emptyStateTitle: '暂无用量数据',
|
||||||
|
emptyStateDescription: '开始发起 API 请求后,此处将显示您的 Token 用量统计。',
|
||||||
|
promptLabel: 'Prompt',
|
||||||
|
completionLabel: 'Completion',
|
||||||
|
totalLabel: '总计',
|
||||||
|
requestsLabel: '请求数',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1505,6 +1518,13 @@ i18n
|
|||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
allAccounts: 'Все аккаунты',
|
allAccounts: 'Все аккаунты',
|
||||||
noData: 'Нет данных',
|
noData: 'Нет данных',
|
||||||
|
emptyStateTitle: 'Данных об использовании пока нет',
|
||||||
|
emptyStateDescription:
|
||||||
|
'Начните отправлять API-запросы, чтобы увидеть статистику использования токенов.',
|
||||||
|
promptLabel: 'Prompt',
|
||||||
|
completionLabel: 'Completion',
|
||||||
|
totalLabel: 'Всего',
|
||||||
|
requestsLabel: 'Запросы',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -490,6 +490,12 @@ const vi = {
|
|||||||
retry: 'Thử lại',
|
retry: 'Thử lại',
|
||||||
allAccounts: 'Tất cả tài khoản',
|
allAccounts: 'Tất cả tài khoản',
|
||||||
noData: 'Không có dữ liệu',
|
noData: 'Không có dữ liệu',
|
||||||
|
emptyStateTitle: 'Chưa có dữ liệu sử dụng',
|
||||||
|
emptyStateDescription: 'Bắt đầu gửi yêu cầu API để xem thống kê sử dụng token tại đây.',
|
||||||
|
promptLabel: 'Prompt',
|
||||||
|
completionLabel: 'Completion',
|
||||||
|
totalLabel: 'Tổng',
|
||||||
|
requestsLabel: 'Yêu cầu',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+321
-229
@@ -1,17 +1,7 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Loader2, TrendingUp, TrendingDown, Activity, Layers } from 'lucide-react';
|
|
||||||
import { ipc } from '@/ipc/manager';
|
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
@@ -25,34 +15,114 @@ import {
|
|||||||
Cell,
|
Cell,
|
||||||
Legend,
|
Legend,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { useMemo, useState } from 'react';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { ipc } from '@/ipc/manager';
|
||||||
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
||||||
|
import { StatCard } from '@/components/usage/StatCard';
|
||||||
|
import { ChartCard } from '@/components/usage/ChartCard';
|
||||||
|
import { CustomTooltip } from '@/components/usage/CustomTooltip';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
Layers,
|
||||||
|
Loader2,
|
||||||
|
MessageSquare,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4'];
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Color tokens — muted, dark-background-friendly palette */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
const COLOR_PROMPT = '#5b9bd5';
|
||||||
|
const COLOR_COMPLETION = '#70ad47';
|
||||||
|
const COLOR_TOTAL = '#a5a5a5';
|
||||||
|
const PIE_COLORS = [
|
||||||
|
'#5b9bd5',
|
||||||
|
'#ed7d31',
|
||||||
|
'#70ad47',
|
||||||
|
'#a5a5a5',
|
||||||
|
'#ffc000',
|
||||||
|
'#4472c4',
|
||||||
|
'#91d1c2',
|
||||||
|
'#c55a5a',
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
type TimeRange = '24h' | '7d' | '30d';
|
||||||
|
|
||||||
|
interface UsageBucket {
|
||||||
|
bucket: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelBucket {
|
||||||
|
model: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
function getTimeRange(r: TimeRange): { start: number; end: number } {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBucketLabel(bucket: string, range: TimeRange): string {
|
||||||
|
if (range === '24h') {
|
||||||
|
// bucket is "YYYY-MM-DD HH:00"
|
||||||
|
const parts = bucket.split(' ');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return parts[1]; // "HH:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (range === '7d') {
|
||||||
|
// bucket is "YYYY-MM-DD"
|
||||||
|
const d = new Date(bucket + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
// 30d
|
||||||
|
const d = new Date(bucket + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
function UsagePage() {
|
function UsagePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [range, setRange] = useState<'24h' | '7d' | '30d'>('7d');
|
const [range, setRange] = useState<TimeRange>('7d');
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState<string | undefined>(undefined);
|
const [selectedAccountId, setSelectedAccountId] = useState<string | undefined>(undefined);
|
||||||
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
||||||
|
|
||||||
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 {
|
const {
|
||||||
data: dailyData,
|
data: dailyData,
|
||||||
isLoading: dailyLoading,
|
isLoading: dailyLoading,
|
||||||
error: dailyError,
|
error: dailyError,
|
||||||
refetch: refetchDaily,
|
refetch: refetchDaily,
|
||||||
} = useQuery({
|
} = useQuery<UsageBucket[]>({
|
||||||
queryKey: ['usage', 'day', range, selectedAccountId],
|
queryKey: ['usage', 'day', range, selectedAccountId],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { start, end } = getTimeRange(range);
|
const { start, end } = getTimeRange(range);
|
||||||
@@ -68,7 +138,7 @@ function UsagePage() {
|
|||||||
isLoading: modelLoading,
|
isLoading: modelLoading,
|
||||||
error: modelError,
|
error: modelError,
|
||||||
refetch: refetchModel,
|
refetch: refetchModel,
|
||||||
} = useQuery({
|
} = useQuery<ModelBucket[]>({
|
||||||
queryKey: ['usage', 'model', range, selectedAccountId],
|
queryKey: ['usage', 'model', range, selectedAccountId],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { start, end } = getTimeRange(range);
|
const { start, end } = getTimeRange(range);
|
||||||
@@ -84,19 +154,19 @@ function UsagePage() {
|
|||||||
isLoading: hourlyLoading,
|
isLoading: hourlyLoading,
|
||||||
error: hourlyError,
|
error: hourlyError,
|
||||||
refetch: refetchHourly,
|
refetch: refetchHourly,
|
||||||
} = useQuery({
|
} = useQuery<UsageBucket[]>({
|
||||||
queryKey: ['usage', 'hour', range, selectedAccountId],
|
queryKey: ['usage', 'hour', range, selectedAccountId],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { start, end } = getTimeRange(range);
|
const { start, end } = getTimeRange(range);
|
||||||
return ipc.client.usage.getUsageByHour({ accountId: selectedAccountId, start, end });
|
return ipc.client.usage.getUsageByHour({ accountId: selectedAccountId, start, end });
|
||||||
},
|
},
|
||||||
|
enabled: range === '24h',
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAllLoading = dailyLoading && modelLoading && hourlyLoading;
|
/* -------------------------- Derived data -------------------------- */
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
const source = dailyData || [];
|
const source = dailyData || [];
|
||||||
return source.reduce(
|
return source.reduce(
|
||||||
@@ -110,14 +180,23 @@ function UsagePage() {
|
|||||||
);
|
);
|
||||||
}, [dailyData]);
|
}, [dailyData]);
|
||||||
|
|
||||||
|
const promptRatio = useMemo(() => {
|
||||||
|
if (totals.totalTokens === 0) return 0;
|
||||||
|
return Math.round((totals.promptTokens / totals.totalTokens) * 100);
|
||||||
|
}, [totals]);
|
||||||
|
|
||||||
|
const completionRatio = useMemo(() => {
|
||||||
|
if (totals.totalTokens === 0) return 0;
|
||||||
|
return Math.round((totals.completionTokens / totals.totalTokens) * 100);
|
||||||
|
}, [totals]);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
return (dailyData || []).map((d) => ({
|
return (dailyData || []).map((d) => ({
|
||||||
name: d.bucket,
|
name: formatBucketLabel(d.bucket, range),
|
||||||
Prompt: d.promptTokens,
|
Prompt: d.promptTokens,
|
||||||
Completion: d.completionTokens,
|
Completion: d.completionTokens,
|
||||||
Total: d.totalTokens,
|
|
||||||
}));
|
}));
|
||||||
}, [dailyData]);
|
}, [dailyData, range]);
|
||||||
|
|
||||||
const pieData = useMemo(() => {
|
const pieData = useMemo(() => {
|
||||||
return (modelData || []).map((d) => ({
|
return (modelData || []).map((d) => ({
|
||||||
@@ -128,98 +207,38 @@ function UsagePage() {
|
|||||||
|
|
||||||
const hourlyChartData = useMemo(() => {
|
const hourlyChartData = useMemo(() => {
|
||||||
return (hourlyData || []).map((d) => ({
|
return (hourlyData || []).map((d) => ({
|
||||||
name: d.bucket,
|
name: formatBucketLabel(d.bucket, '24h'),
|
||||||
Prompt: d.promptTokens,
|
Prompt: d.promptTokens,
|
||||||
Completion: d.completionTokens,
|
Completion: d.completionTokens,
|
||||||
Total: d.totalTokens,
|
Total: d.totalTokens,
|
||||||
}));
|
}));
|
||||||
}, [hourlyData]);
|
}, [hourlyData]);
|
||||||
|
|
||||||
if (isAllLoading) {
|
const isDailyEmpty = !dailyLoading && !dailyError && (!dailyData || dailyData.length === 0);
|
||||||
return (
|
const isModelEmpty = !modelLoading && !modelError && (!modelData || modelData.length === 0);
|
||||||
<div className="flex h-full items-center justify-center">
|
const isHourlyEmpty = !hourlyLoading && !hourlyError && (!hourlyData || hourlyData.length === 0);
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyPlaceholder = dailyError ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center gap-2 py-12">
|
|
||||||
<p className="text-sm text-destructive">{t('usage.loadFailed')}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => refetchDaily()}
|
|
||||||
className="text-primary text-sm underline"
|
|
||||||
>
|
|
||||||
{t('usage.retry')}
|
|
||||||
</button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : !dailyData ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
|
||||||
<p className="text-muted-foreground text-sm">{t('usage.noData')}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const modelPlaceholder = modelError ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center gap-2 py-12">
|
|
||||||
<p className="text-sm text-destructive">{t('usage.loadFailed')}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => refetchModel()}
|
|
||||||
className="text-primary text-sm underline"
|
|
||||||
>
|
|
||||||
{t('usage.retry')}
|
|
||||||
</button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : !modelData ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
|
||||||
<p className="text-muted-foreground text-sm">{t('usage.noData')}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const hourlyPlaceholder = hourlyError ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center gap-2 py-12">
|
|
||||||
<p className="text-sm text-destructive">{t('usage.loadFailed')}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => refetchHourly()}
|
|
||||||
className="text-primary text-sm underline"
|
|
||||||
>
|
|
||||||
{t('usage.retry')}
|
|
||||||
</button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : !hourlyData ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
|
||||||
<p className="text-muted-foreground text-sm">{t('usage.noData')}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
|
/* ----------------------------- Render ----------------------------- */
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-6xl space-y-6 p-6">
|
<div className="flex h-full flex-col overflow-auto">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header */}
|
||||||
|
<div className="border-border/40 flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight">{t('usage.title')}</h2>
|
<h1 className="text-foreground text-xl font-bold tracking-tight">{t('usage.title')}</h1>
|
||||||
<p className="text-muted-foreground mt-1">{t('usage.description')}</p>
|
<p className="text-muted-foreground mt-0.5 text-sm">{t('usage.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Account filter */}
|
||||||
<Select
|
<Select
|
||||||
value={selectedAccountId || ''}
|
value={selectedAccountId || ''}
|
||||||
onValueChange={(value) => setSelectedAccountId(value || undefined)}
|
onValueChange={(value) => setSelectedAccountId(value || undefined)}
|
||||||
disabled={accountsLoading}
|
disabled={accountsLoading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[200px]">
|
<SelectTrigger className="h-9 w-[200px] text-xs">
|
||||||
{accountsLoading ? (
|
{accountsLoading ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
{t('common.loading')}
|
{t('common.loading')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -235,138 +254,211 @@ function UsagePage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Tabs value={range} onValueChange={(v) => setRange(v as '24h' | '7d' | '30d')}>
|
|
||||||
<TabsList>
|
{/* Time range tabs */}
|
||||||
<TabsTrigger value="24h">{t('usage.range24h')}</TabsTrigger>
|
<Tabs value={range} onValueChange={(v) => setRange(v as TimeRange)}>
|
||||||
<TabsTrigger value="7d">{t('usage.range7d')}</TabsTrigger>
|
<TabsList className="h-9">
|
||||||
<TabsTrigger value="30d">{t('usage.range30d')}</TabsTrigger>
|
<TabsTrigger value="24h" className="px-3 text-xs">
|
||||||
|
{t('usage.range24h')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="7d" className="px-3 text-xs">
|
||||||
|
{t('usage.range7d')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="30d" className="px-3 text-xs">
|
||||||
|
{t('usage.range30d')}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
<div className="flex-1 space-y-5 px-6 py-5">
|
||||||
{dailyPlaceholder || (
|
{/* Summary Stats */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card>
|
<StatCard
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
label={t('usage.totalTokens')}
|
||||||
<CardTitle className="text-sm font-medium">{t('usage.totalTokens')}</CardTitle>
|
value={totals.totalTokens}
|
||||||
<Activity className="text-muted-foreground h-4 w-4" />
|
icon={Zap}
|
||||||
</CardHeader>
|
isLoading={dailyLoading}
|
||||||
<CardContent>
|
accent="amber"
|
||||||
<div className="text-2xl font-bold">{totals.totalTokens.toLocaleString()}</div>
|
/>
|
||||||
</CardContent>
|
<StatCard
|
||||||
</Card>
|
label={t('usage.promptTokens')}
|
||||||
|
value={totals.promptTokens}
|
||||||
<Card>
|
icon={MessageSquare}
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
isLoading={dailyLoading}
|
||||||
<CardTitle className="text-sm font-medium">{t('usage.promptTokens')}</CardTitle>
|
accent="blue"
|
||||||
<TrendingUp className="text-muted-foreground h-4 w-4" />
|
/>
|
||||||
</CardHeader>
|
<StatCard
|
||||||
<CardContent>
|
label={t('usage.completionTokens')}
|
||||||
<div className="text-2xl font-bold">{totals.promptTokens.toLocaleString()}</div>
|
value={totals.completionTokens}
|
||||||
</CardContent>
|
icon={Activity}
|
||||||
</Card>
|
isLoading={dailyLoading}
|
||||||
|
accent="green"
|
||||||
<Card>
|
/>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<StatCard
|
||||||
<CardTitle className="text-sm font-medium">{t('usage.completionTokens')}</CardTitle>
|
label={t('usage.totalRequests')}
|
||||||
<TrendingDown className="text-muted-foreground h-4 w-4" />
|
value={totals.requests}
|
||||||
</CardHeader>
|
icon={Layers}
|
||||||
<CardContent>
|
isLoading={dailyLoading}
|
||||||
<div className="text-2xl font-bold">{totals.completionTokens.toLocaleString()}</div>
|
accent="slate"
|
||||||
</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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Trend indicators row */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
{!dailyLoading && totals.totalTokens > 0 && (
|
||||||
{dailyPlaceholder || (
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<Card>
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<CardHeader>
|
<ArrowUpRight className="h-3.5 w-3.5 text-blue-400" />
|
||||||
<CardTitle>{t('usage.dailyUsage')}</CardTitle>
|
<span className="text-muted-foreground">{t('usage.promptLabel')}:</span>
|
||||||
</CardHeader>
|
<span className="text-foreground font-mono font-medium">{promptRatio}%</span>
|
||||||
<CardContent>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<BarChart data={chartData}>
|
<ArrowDownRight className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<span className="text-muted-foreground">{t('usage.completionLabel')}:</span>
|
||||||
<XAxis dataKey="name" />
|
<span className="text-foreground font-mono font-medium">{completionRatio}%</span>
|
||||||
<YAxis />
|
</div>
|
||||||
<Tooltip />
|
<div className="bg-border/40 h-3 w-px" />
|
||||||
<Legend />
|
<div className="text-muted-foreground text-xs">
|
||||||
<Bar dataKey="Prompt" fill="#3b82f6" />
|
{t('usage.requestsLabel')}:{' '}
|
||||||
<Bar dataKey="Completion" fill="#10b981" />
|
<span className="text-foreground font-mono font-medium">
|
||||||
</BarChart>
|
{totals.requests.toLocaleString()}
|
||||||
</ResponsiveContainer>
|
</span>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{modelPlaceholder || (
|
{/* Charts */}
|
||||||
<Card>
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
<CardHeader>
|
{/* Daily Usage Bar Chart */}
|
||||||
<CardTitle>{t('usage.byModel')}</CardTitle>
|
<ChartCard
|
||||||
</CardHeader>
|
title={t('usage.dailyUsage')}
|
||||||
<CardContent>
|
isLoading={dailyLoading}
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
error={dailyError}
|
||||||
<PieChart>
|
isEmpty={isDailyEmpty}
|
||||||
<Pie
|
onRetry={() => refetchDaily()}
|
||||||
data={pieData}
|
emptyTitle={t('usage.emptyStateTitle')}
|
||||||
cx="50%"
|
emptyDescription={t('usage.emptyStateDescription')}
|
||||||
cy="50%"
|
errorMessage={t('usage.loadFailed')}
|
||||||
innerRadius={60}
|
retryLabel={t('usage.retry')}
|
||||||
outerRadius={100}
|
>
|
||||||
paddingAngle={5}
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
dataKey="value"
|
<BarChart data={chartData} barCategoryGap="20%">
|
||||||
label
|
<CartesianGrid
|
||||||
>
|
strokeDasharray="3 3"
|
||||||
{pieData.map((_, index) => (
|
stroke="rgba(255,255,255,0.06)"
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
vertical={false}
|
||||||
))}
|
/>
|
||||||
</Pie>
|
<XAxis
|
||||||
<Tooltip />
|
dataKey="name"
|
||||||
<Legend />
|
tick={{ fill: '#a1a1aa', fontSize: 11 }}
|
||||||
</PieChart>
|
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||||
</ResponsiveContainer>
|
tickLine={false}
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
<YAxis
|
||||||
|
tick={{ fill: '#a1a1aa', fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`)}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="Prompt" fill={COLOR_PROMPT} radius={[2, 2, 0, 0]} />
|
||||||
|
<Bar dataKey="Completion" fill={COLOR_COMPLETION} radius={[2, 2, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* Model Usage Donut Chart */}
|
||||||
|
<ChartCard
|
||||||
|
title={t('usage.byModel')}
|
||||||
|
isLoading={modelLoading}
|
||||||
|
error={modelError}
|
||||||
|
isEmpty={isModelEmpty}
|
||||||
|
onRetry={() => refetchModel()}
|
||||||
|
emptyTitle={t('usage.emptyStateTitle')}
|
||||||
|
emptyDescription={t('usage.emptyStateDescription')}
|
||||||
|
errorMessage={t('usage.loadFailed')}
|
||||||
|
retryLabel={t('usage.retry')}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={70}
|
||||||
|
outerRadius={100}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
{pieData.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hourly Usage — 24h only */}
|
||||||
|
{range === '24h' && (
|
||||||
|
<ChartCard
|
||||||
|
title={t('usage.hourlyUsage')}
|
||||||
|
isLoading={hourlyLoading}
|
||||||
|
error={hourlyError}
|
||||||
|
isEmpty={isHourlyEmpty}
|
||||||
|
onRetry={() => refetchHourly()}
|
||||||
|
emptyTitle={t('usage.emptyStateTitle')}
|
||||||
|
emptyDescription={t('usage.emptyStateDescription')}
|
||||||
|
errorMessage={t('usage.loadFailed')}
|
||||||
|
retryLabel={t('usage.retry')}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={hourlyChartData} barCategoryGap="20%">
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="rgba(255,255,255,0.06)"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fill: '#a1a1aa', fontSize: 11 }}
|
||||||
|
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||||
|
tickLine={false}
|
||||||
|
interval={2}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: '#a1a1aa', fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`)}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="Prompt" fill={COLOR_PROMPT} radius={[2, 2, 0, 0]} />
|
||||||
|
<Bar dataKey="Completion" fill={COLOR_COMPLETION} radius={[2, 2, 0, 0]} />
|
||||||
|
<Bar dataKey="Total" fill={COLOR_TOTAL} radius={[2, 2, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{range === '24h' && (
|
|
||||||
hourlyPlaceholder || (
|
|
||||||
<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 />
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="Prompt" fill="#3b82f6" />
|
|
||||||
<Bar dataKey="Completion" fill="#10b981" />
|
|
||||||
<Bar dataKey="Total" fill="#8b5cf6" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user