From 2f42d70d78083691c31e486108f5e199e4c4fcfa Mon Sep 17 00:00:00 2001 From: Gemini Nexus Bot Date: Wed, 22 Apr 2026 16:23:45 -0300 Subject: [PATCH] feat(usage): refactor dashboard frontend with shadcn/ui components, skeleton states, and dark-theme charts --- .impeccable.md | 29 ++ src/components/ui/skeleton.tsx | 7 + src/components/usage/ChartCard.tsx | 67 +++ src/components/usage/CustomTooltip.tsx | 36 ++ src/components/usage/StatCard.tsx | 69 ++++ src/localization/i18n.ts | 20 + src/localization/vi.ts | 6 + src/routes/usage.tsx | 550 +++++++++++++++---------- 8 files changed, 555 insertions(+), 229 deletions(-) create mode 100644 .impeccable.md create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/usage/ChartCard.tsx create mode 100644 src/components/usage/CustomTooltip.tsx create mode 100644 src/components/usage/StatCard.tsx diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000..665c9c4 --- /dev/null +++ b/.impeccable.md @@ -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 diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..d0a6f3e --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from '@/lib/utils'; + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
; +} + +export { Skeleton }; diff --git a/src/components/usage/ChartCard.tsx b/src/components/usage/ChartCard.tsx new file mode 100644 index 0000000..24f281c --- /dev/null +++ b/src/components/usage/ChartCard.tsx @@ -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 = ({ + isLoading, + error, + isEmpty, + onRetry, + title, + emptyTitle, + emptyDescription, + errorMessage, + retryLabel, + children, +}) => { + return ( + + +
+

{title}

+
+
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+ +

{errorMessage}

+ +
+ ) : isEmpty ? ( +
+ +

{emptyTitle}

+

+ {emptyDescription} +

+
+ ) : ( + children + )} +
+
+
+ ); +}; diff --git a/src/components/usage/CustomTooltip.tsx b/src/components/usage/CustomTooltip.tsx new file mode 100644 index 0000000..dd70da3 --- /dev/null +++ b/src/components/usage/CustomTooltip.tsx @@ -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 = ({ active, payload, label }) => { + if (!active || !payload || payload.length === 0) { + return null; + } + + return ( +
+

{label}

+
+ {payload.map((entry, index) => ( +
+ + {entry.name}: + + {typeof entry.value === 'number' ? entry.value.toLocaleString() : entry.value} + +
+ ))} +
+
+ ); +}; diff --git a/src/components/usage/StatCard.tsx b/src/components/usage/StatCard.tsx new file mode 100644 index 0000000..fc744b7 --- /dev/null +++ b/src/components/usage/StatCard.tsx @@ -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 = ({ + label, + value, + icon: Icon, + isLoading, + className, + accent = 'slate', +}) => { + return ( + + +
+ + {label} + + {isLoading ? ( + + ) : ( + + {value.toLocaleString()} + + )} +
+
+ +
+
+
+ ); +}; diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts index 7250997..ca93070 100644 --- a/src/localization/i18n.ts +++ b/src/localization/i18n.ts @@ -511,6 +511,13 @@ i18n retry: 'Retry', allAccounts: 'All Accounts', 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: '重试', allAccounts: '所有账号', noData: '暂无数据', + emptyStateTitle: '暂无用量数据', + emptyStateDescription: '开始发起 API 请求后,此处将显示您的 Token 用量统计。', + promptLabel: 'Prompt', + completionLabel: 'Completion', + totalLabel: '总计', + requestsLabel: '请求数', }, }, }, @@ -1505,6 +1518,13 @@ i18n retry: 'Повторить', allAccounts: 'Все аккаунты', noData: 'Нет данных', + emptyStateTitle: 'Данных об использовании пока нет', + emptyStateDescription: + 'Начните отправлять API-запросы, чтобы увидеть статистику использования токенов.', + promptLabel: 'Prompt', + completionLabel: 'Completion', + totalLabel: 'Всего', + requestsLabel: 'Запросы', }, }, }, diff --git a/src/localization/vi.ts b/src/localization/vi.ts index db3987c..a3b6c74 100644 --- a/src/localization/vi.ts +++ b/src/localization/vi.ts @@ -490,6 +490,12 @@ const vi = { retry: 'Thử lại', allAccounts: 'Tất cả tài khoản', 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', }, }; diff --git a/src/routes/usage.tsx b/src/routes/usage.tsx index 71c4b50..00d5936 100644 --- a/src/routes/usage.tsx +++ b/src/routes/usage.tsx @@ -1,17 +1,7 @@ +import { useMemo, useState } from 'react'; 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 { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { useTranslation } from 'react-i18next'; -import { Loader2, TrendingUp, TrendingDown, Activity, Layers } from 'lucide-react'; -import { ipc } from '@/ipc/manager'; import { BarChart, Bar, @@ -25,34 +15,114 @@ import { Cell, Legend, } 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 { 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() { const { t } = useTranslation(); - const [range, setRange] = useState<'24h' | '7d' | '30d'>('7d'); + const [range, setRange] = useState('7d'); const [selectedAccountId, setSelectedAccountId] = useState(undefined); 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 { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily, - } = useQuery({ + } = useQuery({ queryKey: ['usage', 'day', range, selectedAccountId], queryFn: () => { const { start, end } = getTimeRange(range); @@ -68,7 +138,7 @@ function UsagePage() { isLoading: modelLoading, error: modelError, refetch: refetchModel, - } = useQuery({ + } = useQuery({ queryKey: ['usage', 'model', range, selectedAccountId], queryFn: () => { const { start, end } = getTimeRange(range); @@ -84,19 +154,19 @@ function UsagePage() { isLoading: hourlyLoading, error: hourlyError, refetch: refetchHourly, - } = useQuery({ + } = useQuery({ queryKey: ['usage', 'hour', range, selectedAccountId], queryFn: () => { const { start, end } = getTimeRange(range); return ipc.client.usage.getUsageByHour({ accountId: selectedAccountId, start, end }); }, + enabled: range === '24h', refetchInterval: 30000, refetchIntervalInBackground: false, staleTime: 60_000, }); - const isAllLoading = dailyLoading && modelLoading && hourlyLoading; - + /* -------------------------- Derived data -------------------------- */ const totals = useMemo(() => { const source = dailyData || []; return source.reduce( @@ -110,14 +180,23 @@ function UsagePage() { ); }, [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(() => { return (dailyData || []).map((d) => ({ - name: d.bucket, + name: formatBucketLabel(d.bucket, range), Prompt: d.promptTokens, Completion: d.completionTokens, - Total: d.totalTokens, })); - }, [dailyData]); + }, [dailyData, range]); const pieData = useMemo(() => { return (modelData || []).map((d) => ({ @@ -128,98 +207,38 @@ function UsagePage() { const hourlyChartData = useMemo(() => { return (hourlyData || []).map((d) => ({ - name: d.bucket, + name: formatBucketLabel(d.bucket, '24h'), Prompt: d.promptTokens, Completion: d.completionTokens, Total: d.totalTokens, })); }, [hourlyData]); - if (isAllLoading) { - return ( -
- -
- ); - } - - const dailyPlaceholder = dailyError ? ( - - -

{t('usage.loadFailed')}

- -
-
- ) : !dailyData ? ( - - -

{t('usage.noData')}

-
-
- ) : null; - - const modelPlaceholder = modelError ? ( - - -

{t('usage.loadFailed')}

- -
-
- ) : !modelData ? ( - - -

{t('usage.noData')}

-
-
- ) : null; - - const hourlyPlaceholder = hourlyError ? ( - - -

{t('usage.loadFailed')}

- -
-
- ) : !hourlyData ? ( - - -

{t('usage.noData')}

-
-
- ) : null; + const isDailyEmpty = !dailyLoading && !dailyError && (!dailyData || dailyData.length === 0); + const isModelEmpty = !modelLoading && !modelError && (!modelData || modelData.length === 0); + const isHourlyEmpty = !hourlyLoading && !hourlyError && (!hourlyData || hourlyData.length === 0); + /* ----------------------------- Render ----------------------------- */ return ( -
-
+
+ {/* Header */} +
-

{t('usage.title')}

-

{t('usage.description')}

+

{t('usage.title')}

+

{t('usage.description')}

-
+ +
+ {/* Account filter */} - setRange(v as '24h' | '7d' | '30d')}> - - {t('usage.range24h')} - {t('usage.range7d')} - {t('usage.range30d')} + + {/* Time range tabs */} + setRange(v as TimeRange)}> + + + {t('usage.range24h')} + + + {t('usage.range7d')} + + + {t('usage.range30d')} +
- {/* Summary Cards */} - {dailyPlaceholder || ( -
- - - {t('usage.totalTokens')} - - - -
{totals.totalTokens.toLocaleString()}
-
-
- - - - {t('usage.promptTokens')} - - - -
{totals.promptTokens.toLocaleString()}
-
-
- - - - {t('usage.completionTokens')} - - - -
{totals.completionTokens.toLocaleString()}
-
-
- - - - {t('usage.totalRequests')} - - - -
{totals.requests.toLocaleString()}
-
-
+
+ {/* Summary Stats */} +
+ + + +
- )} - {/* Charts */} -
- {dailyPlaceholder || ( - - - {t('usage.dailyUsage')} - - - - - - - - - - - - - - - + {/* Trend indicators row */} + {!dailyLoading && totals.totalTokens > 0 && ( +
+
+ + {t('usage.promptLabel')}: + {promptRatio}% +
+
+ + {t('usage.completionLabel')}: + {completionRatio}% +
+
+
+ {t('usage.requestsLabel')}:{' '} + + {totals.requests.toLocaleString()} + +
+
)} - {modelPlaceholder || ( - - - {t('usage.byModel')} - - - - - - {pieData.map((_, index) => ( - - ))} - - - - - - - + {/* Charts */} +
+ {/* Daily Usage Bar Chart */} + refetchDaily()} + emptyTitle={t('usage.emptyStateTitle')} + emptyDescription={t('usage.emptyStateDescription')} + errorMessage={t('usage.loadFailed')} + retryLabel={t('usage.retry')} + > + + + + + (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`)} + /> + } /> + + + + + + + + {/* Model Usage Donut Chart */} + refetchModel()} + emptyTitle={t('usage.emptyStateTitle')} + emptyDescription={t('usage.emptyStateDescription')} + errorMessage={t('usage.loadFailed')} + retryLabel={t('usage.retry')} + > + + + + {pieData.map((_, index) => ( + + ))} + + } /> + + + + +
+ + {/* Hourly Usage — 24h only */} + {range === '24h' && ( + refetchHourly()} + emptyTitle={t('usage.emptyStateTitle')} + emptyDescription={t('usage.emptyStateDescription')} + errorMessage={t('usage.loadFailed')} + retryLabel={t('usage.retry')} + > + + + + + (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`)} + /> + } /> + + + + + + + )}
- - {range === '24h' && ( - hourlyPlaceholder || ( - - - {t('usage.hourlyUsage')} - - - - - - - - - - - - - - - - - ) - )}
); }