style(design): apply Minimal Dark Mode design system\n\n- Switched to complete Dark Mode (OLED-friendly)\n- Established standard color palette matching modern technical aesthetics\n- Changed font to Plus Jakarta Sans for increased legibility\n- Enforced dark mode as default in App.tsx\n- Fixed several React cascading render (setState in useEffect) warnings
This commit is contained in:
1 parent
b80531f8a8
commit
bac7b5591e
738 files changed
+138513
-138123
No files matched your search
+20
-20
@@ -1,20 +1,20 @@
|
||||
import { MainLayout } from '@/layouts/MainLayout';
|
||||
import { createRootRoute } from '@tanstack/react-router';
|
||||
/* import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' */
|
||||
|
||||
/*
|
||||
* Uncomment the code in this file to enable the router devtools.
|
||||
*/
|
||||
|
||||
function Root() {
|
||||
return (
|
||||
<MainLayout>
|
||||
{/* Uncomment the following line to enable the router devtools */}
|
||||
{/* <TanStackRouterDevtools /> */}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root,
|
||||
});
|
||||
import { MainLayout } from '@/layouts/MainLayout';
|
||||
import { createRootRoute } from '@tanstack/react-router';
|
||||
/* import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' */
|
||||
|
||||
/*
|
||||
* Uncomment the code in this file to enable the router devtools.
|
||||
*/
|
||||
|
||||
function Root() {
|
||||
return (
|
||||
<MainLayout>
|
||||
{/* Uncomment the following line to enable the router devtools */}
|
||||
{/* <TanStackRouterDevtools /> */}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root,
|
||||
});
|
||||
+53
-53
@@ -1,53 +1,53 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { CloudAccountList } from '@/components/CloudAccountList';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl p-6">
|
||||
<CloudAccountList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HomePageErrorBoundary({ error, reset }: { error: unknown; reset: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const hasShownToastRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasShownToastRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('error.generic'),
|
||||
description: getLocalizedErrorMessage(error, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
hasShownToastRef.current = true;
|
||||
}, [error, t, toast]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl p-6">
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<div className="text-lg font-semibold">{t('error.generic')}</div>
|
||||
<div className="text-muted-foreground mt-2 text-sm">{t('action.retry')}</div>
|
||||
<Button className="mt-4" variant="outline" onClick={() => reset()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('action.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: HomePage,
|
||||
errorComponent: HomePageErrorBoundary,
|
||||
});
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { CloudAccountList } from '@/components/CloudAccountList';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl p-6">
|
||||
<CloudAccountList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HomePageErrorBoundary({ error, reset }: { error: unknown; reset: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const hasShownToastRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasShownToastRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('error.generic'),
|
||||
description: getLocalizedErrorMessage(error, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
hasShownToastRef.current = true;
|
||||
}, [error, t, toast]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl p-6">
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<div className="text-lg font-semibold">{t('error.generic')}</div>
|
||||
<div className="text-muted-foreground mt-2 text-sm">{t('action.retry')}</div>
|
||||
<Button className="mt-4" variant="outline" onClick={() => reset()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('action.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: HomePage,
|
||||
errorComponent: HomePageErrorBoundary,
|
||||
});
|
||||
+932
-932
File diff suppressed because it is too large.
Load diff
+394
-394
@@ -1,394 +1,394 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAppVersion, getPlatform } from '@/actions/app';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { setAppLanguage } from '@/actions/language';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Loader2, FolderOpen } from 'lucide-react';
|
||||
import { ModelVisibilitySettings } from '@/components/ModelVisibilitySettings';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ProxyConfig } from '@/types/config';
|
||||
import { openLogDirectory } from '@/actions/system';
|
||||
import { AccountProxySettings } from '@/components/AccountProxySettings';
|
||||
|
||||
function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { config, isLoading, saveConfig } = useAppConfig();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Local state for configuration editing
|
||||
const [proxyConfig, setProxyConfig] = useState<ProxyConfig | undefined>(undefined);
|
||||
|
||||
// Sync config to local state when loaded
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
// eslint-disable-next-line
|
||||
setProxyConfig(config.proxy);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const { data: appVersion } = useQuery({
|
||||
queryKey: ['app', 'version'],
|
||||
queryFn: getAppVersion,
|
||||
});
|
||||
|
||||
const { data: platform } = useQuery({
|
||||
queryKey: ['app', 'platform'],
|
||||
queryFn: getPlatform,
|
||||
});
|
||||
|
||||
const isAutoStartSupported =
|
||||
platform === 'win32' || platform === 'darwin' || platform === 'linux';
|
||||
const isMac = platform === 'darwin';
|
||||
|
||||
const handleLanguageChange = (value: string) => {
|
||||
setAppLanguage(value, i18n);
|
||||
};
|
||||
|
||||
// Helper to update proxyConfig and auto-save
|
||||
const updateProxyConfig = async (newProxyConfig: ProxyConfig) => {
|
||||
setProxyConfig(newProxyConfig);
|
||||
if (config) {
|
||||
await saveConfig({ ...config, proxy: newProxyConfig });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !proxyConfig) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl space-y-5 p-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">{t('settings.title')}</h2>
|
||||
<p className="text-muted-foreground mt-1">{t('settings.description')}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
|
||||
<TabsTrigger value="models">{t('settings.models')}</TabsTrigger>
|
||||
<TabsTrigger value="proxy">{t('settings.proxy_tab')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* --- GENERAL TAB --- */}
|
||||
<TabsContent value="general" className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.appearance.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.appearance.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dark-mode">{t('settings.darkMode')}</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('settings.darkModeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="dark-mode"
|
||||
checked={theme === 'dark'}
|
||||
onCheckedChange={(checked) => setTheme(checked ? 'dark' : 'light')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="language">{t('settings.language.title')}</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('settings.language.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={i18n.language}
|
||||
onValueChange={handleLanguageChange}
|
||||
key={i18n.language}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={t('settings.language.title')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">{t('settings.language.english')}</SelectItem>
|
||||
<SelectItem value="zh-CN">{t('settings.language.chinese')}</SelectItem>
|
||||
<SelectItem value="ru">{t('settings.language.russian')}</SelectItem>
|
||||
<SelectItem value="vi">{t('settings.language.vietnamese')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Settings Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.account.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.account.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Auto Refresh Quota */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.account.auto_refresh')}</Label>
|
||||
<p className="text-xs text-gray-500">{t('settings.account.auto_refresh_desc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.auto_refresh || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, auto_refresh: checked });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto Sync Account */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.account.auto_sync')}</Label>
|
||||
<p className="text-xs text-gray-500">{t('settings.account.auto_sync_desc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.auto_sync || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, auto_sync: checked });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isAutoStartSupported && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.startup.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.startup.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.startup.auto_startup')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.startup.auto_startup_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.auto_startup || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, auto_startup: checked });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isMac && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.startup.macos_hint')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.about.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.about.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="text-muted-foreground">{t('settings.version')}</div>
|
||||
<div className="font-medium">{appVersion || t('common.unknown')}</div>
|
||||
|
||||
<div className="text-muted-foreground">{t('settings.platform')}</div>
|
||||
<div className="font-medium capitalize">{platform || t('common.unknown')}</div>
|
||||
|
||||
<div className="text-muted-foreground">{t('settings.license')}</div>
|
||||
<div className="font-medium">CC BY-NC-SA 4.0</div>
|
||||
|
||||
<div className="text-muted-foreground">{t('action.openLogs')}</div>
|
||||
<button
|
||||
onClick={() => openLogDirectory()}
|
||||
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span>{t('settings.openLogDir')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Privacy & Error Reporting Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.privacy.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.privacy.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.privacy.error_reporting')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.privacy.error_reporting_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.error_reporting_enabled || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, error_reporting_enabled: checked });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{t('settings.privacy.restart_note')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notifications Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.notifications.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.notifications.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.notifications.quotaAlert')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.notifications.quotaAlertDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.quota_alert_enabled || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
try {
|
||||
await saveConfig({ ...config, quota_alert_enabled: checked });
|
||||
} catch {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: t('settings.notifications.saveFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.notifications.quotaThreshold')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.notifications.quotaThresholdDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config?.quota_alert_threshold ?? 20}
|
||||
onChange={async (e) => {
|
||||
const rawValue = e.target.value;
|
||||
const parsed = parseInt(rawValue, 10);
|
||||
if (isNaN(parsed) || parsed < 0 || parsed > 100) return;
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
await saveConfig({ ...config, quota_alert_threshold: parsed });
|
||||
} catch {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: t('settings.notifications.thresholdSaveFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-16 rounded-md border bg-transparent px-2 py-1 text-center text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* --- MODELS TAB --- */}
|
||||
<TabsContent value="models" className="space-y-5">
|
||||
<ModelVisibilitySettings />
|
||||
</TabsContent>
|
||||
|
||||
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
||||
<TabsContent value="proxy" className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.proxy.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.proxy.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="upstream-proxy-enabled">{t('settings.proxy.enable')}</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="upstream-proxy-enabled"
|
||||
checked={proxyConfig.upstream_proxy.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
upstream_proxy: { ...proxyConfig.upstream_proxy, enabled: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="upstream-proxy-url">{t('settings.proxy.url')}</Label>
|
||||
<Input
|
||||
id="upstream-proxy-url"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
value={proxyConfig.upstream_proxy.url}
|
||||
onChange={(e) =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
upstream_proxy: { ...proxyConfig.upstream_proxy, url: e.target.value },
|
||||
})
|
||||
}
|
||||
disabled={!proxyConfig.upstream_proxy.enabled}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AccountProxySettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
component: SettingsPage,
|
||||
});
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAppVersion, getPlatform } from '@/actions/app';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { setAppLanguage } from '@/actions/language';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Loader2, FolderOpen } from 'lucide-react';
|
||||
import { ModelVisibilitySettings } from '@/components/ModelVisibilitySettings';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ProxyConfig } from '@/types/config';
|
||||
import { openLogDirectory } from '@/actions/system';
|
||||
import { AccountProxySettings } from '@/components/AccountProxySettings';
|
||||
|
||||
function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { config, isLoading, saveConfig } = useAppConfig();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Local state for configuration editing
|
||||
const [proxyConfig, setProxyConfig] = useState<ProxyConfig | undefined>(undefined);
|
||||
|
||||
// Sync config to local state when loaded
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
// eslint-disable-next-line
|
||||
setProxyConfig(config.proxy);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const { data: appVersion } = useQuery({
|
||||
queryKey: ['app', 'version'],
|
||||
queryFn: getAppVersion,
|
||||
});
|
||||
|
||||
const { data: platform } = useQuery({
|
||||
queryKey: ['app', 'platform'],
|
||||
queryFn: getPlatform,
|
||||
});
|
||||
|
||||
const isAutoStartSupported =
|
||||
platform === 'win32' || platform === 'darwin' || platform === 'linux';
|
||||
const isMac = platform === 'darwin';
|
||||
|
||||
const handleLanguageChange = (value: string) => {
|
||||
setAppLanguage(value, i18n);
|
||||
};
|
||||
|
||||
// Helper to update proxyConfig and auto-save
|
||||
const updateProxyConfig = async (newProxyConfig: ProxyConfig) => {
|
||||
setProxyConfig(newProxyConfig);
|
||||
if (config) {
|
||||
await saveConfig({ ...config, proxy: newProxyConfig });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !proxyConfig) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl space-y-5 p-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">{t('settings.title')}</h2>
|
||||
<p className="text-muted-foreground mt-1">{t('settings.description')}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
|
||||
<TabsTrigger value="models">{t('settings.models')}</TabsTrigger>
|
||||
<TabsTrigger value="proxy">{t('settings.proxy_tab')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* --- GENERAL TAB --- */}
|
||||
<TabsContent value="general" className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.appearance.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.appearance.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dark-mode">{t('settings.darkMode')}</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('settings.darkModeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="dark-mode"
|
||||
checked={theme === 'dark'}
|
||||
onCheckedChange={(checked) => setTheme(checked ? 'dark' : 'light')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="language">{t('settings.language.title')}</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('settings.language.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={i18n.language}
|
||||
onValueChange={handleLanguageChange}
|
||||
key={i18n.language}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={t('settings.language.title')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">{t('settings.language.english')}</SelectItem>
|
||||
<SelectItem value="zh-CN">{t('settings.language.chinese')}</SelectItem>
|
||||
<SelectItem value="ru">{t('settings.language.russian')}</SelectItem>
|
||||
<SelectItem value="vi">{t('settings.language.vietnamese')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Settings Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.account.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.account.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Auto Refresh Quota */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.account.auto_refresh')}</Label>
|
||||
<p className="text-xs text-gray-500">{t('settings.account.auto_refresh_desc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.auto_refresh || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, auto_refresh: checked });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto Sync Account */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.account.auto_sync')}</Label>
|
||||
<p className="text-xs text-gray-500">{t('settings.account.auto_sync_desc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.auto_sync || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, auto_sync: checked });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isAutoStartSupported && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.startup.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.startup.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.startup.auto_startup')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.startup.auto_startup_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.auto_startup || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, auto_startup: checked });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isMac && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.startup.macos_hint')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.about.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.about.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="text-muted-foreground">{t('settings.version')}</div>
|
||||
<div className="font-medium">{appVersion || t('common.unknown')}</div>
|
||||
|
||||
<div className="text-muted-foreground">{t('settings.platform')}</div>
|
||||
<div className="font-medium capitalize">{platform || t('common.unknown')}</div>
|
||||
|
||||
<div className="text-muted-foreground">{t('settings.license')}</div>
|
||||
<div className="font-medium">CC BY-NC-SA 4.0</div>
|
||||
|
||||
<div className="text-muted-foreground">{t('action.openLogs')}</div>
|
||||
<button
|
||||
onClick={() => openLogDirectory()}
|
||||
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span>{t('settings.openLogDir')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Privacy & Error Reporting Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.privacy.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.privacy.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.privacy.error_reporting')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.privacy.error_reporting_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.error_reporting_enabled || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, error_reporting_enabled: checked });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{t('settings.privacy.restart_note')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notifications Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.notifications.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.notifications.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.notifications.quotaAlert')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.notifications.quotaAlertDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.quota_alert_enabled || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
try {
|
||||
await saveConfig({ ...config, quota_alert_enabled: checked });
|
||||
} catch {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: t('settings.notifications.saveFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.notifications.quotaThreshold')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.notifications.quotaThresholdDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config?.quota_alert_threshold ?? 20}
|
||||
onChange={async (e) => {
|
||||
const rawValue = e.target.value;
|
||||
const parsed = parseInt(rawValue, 10);
|
||||
if (isNaN(parsed) || parsed < 0 || parsed > 100) return;
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
await saveConfig({ ...config, quota_alert_threshold: parsed });
|
||||
} catch {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: t('settings.notifications.thresholdSaveFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-16 rounded-md border bg-transparent px-2 py-1 text-center text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* --- MODELS TAB --- */}
|
||||
<TabsContent value="models" className="space-y-5">
|
||||
<ModelVisibilitySettings />
|
||||
</TabsContent>
|
||||
|
||||
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
||||
<TabsContent value="proxy" className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.proxy.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.proxy.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="upstream-proxy-enabled">{t('settings.proxy.enable')}</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="upstream-proxy-enabled"
|
||||
checked={proxyConfig.upstream_proxy.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
upstream_proxy: { ...proxyConfig.upstream_proxy, enabled: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="upstream-proxy-url">{t('settings.proxy.url')}</Label>
|
||||
<Input
|
||||
id="upstream-proxy-url"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
value={proxyConfig.upstream_proxy.url}
|
||||
onChange={(e) =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
upstream_proxy: { ...proxyConfig.upstream_proxy, url: e.target.value },
|
||||
})
|
||||
}
|
||||
disabled={!proxyConfig.upstream_proxy.enabled}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AccountProxySettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
component: SettingsPage,
|
||||
});
|
||||
+497
-497
@@ -1,497 +1,497 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ResponsiveBar } from '@nivo/bar';
|
||||
import { ResponsivePie } from '@nivo/pie';
|
||||
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 {
|
||||
COLOR_PROMPT,
|
||||
COLOR_COMPLETION,
|
||||
COLOR_TOTAL,
|
||||
PIE_COLORS,
|
||||
BarTooltip,
|
||||
PieTooltip,
|
||||
} from '@/components/usage/NivoTooltip';
|
||||
import { nivoTheme } from '@/lib/nivo-theme';
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Layers,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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' });
|
||||
}
|
||||
|
||||
function barColor(bar: any): string {
|
||||
if (bar.id === 'Prompt') return COLOR_PROMPT;
|
||||
if (bar.id === 'Completion') return COLOR_COMPLETION;
|
||||
if (bar.id === 'Total') return COLOR_TOTAL;
|
||||
return 'var(--chart-3)';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function UsagePage() {
|
||||
const { t } = useTranslation();
|
||||
const [range, setRange] = useState<TimeRange>('7d');
|
||||
const ALL_ACCOUNTS = '__all__';
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
|
||||
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
||||
|
||||
const {
|
||||
data: dailyData,
|
||||
isLoading: dailyLoading,
|
||||
error: dailyError,
|
||||
refetch: refetchDaily,
|
||||
} = useQuery<UsageBucket[]>({
|
||||
queryKey: ['usage', 'day', range, selectedAccountId],
|
||||
queryFn: () => {
|
||||
const { start, end } = getTimeRange(range);
|
||||
return ipc.client.usage.getUsageByDay({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: modelData,
|
||||
isLoading: modelLoading,
|
||||
error: modelError,
|
||||
refetch: refetchModel,
|
||||
} = useQuery<ModelBucket[]>({
|
||||
queryKey: ['usage', 'model', range, selectedAccountId],
|
||||
queryFn: () => {
|
||||
const { start, end } = getTimeRange(range);
|
||||
return ipc.client.usage.getUsageByModel({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: hourlyData,
|
||||
isLoading: hourlyLoading,
|
||||
error: hourlyError,
|
||||
refetch: refetchHourly,
|
||||
} = useQuery<UsageBucket[]>({
|
||||
queryKey: ['usage', 'hour', range, selectedAccountId],
|
||||
queryFn: () => {
|
||||
const { start, end } = getTimeRange(range);
|
||||
return ipc.client.usage.getUsageByHour({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
|
||||
},
|
||||
enabled: range === '24h',
|
||||
refetchInterval: 30000,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
/* -------------------------- Derived data -------------------------- */
|
||||
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 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: formatBucketLabel(d.bucket, range),
|
||||
Prompt: d.promptTokens,
|
||||
Completion: d.completionTokens,
|
||||
}));
|
||||
}, [dailyData, range]);
|
||||
|
||||
const pieData = useMemo(() => {
|
||||
return (modelData || []).map((d) => ({
|
||||
id: d.model,
|
||||
value: d.totalTokens,
|
||||
}));
|
||||
}, [modelData]);
|
||||
|
||||
const hourlyChartData = useMemo(() => {
|
||||
return (hourlyData || []).map((d) => ({
|
||||
name: formatBucketLabel(d.bucket, '24h'),
|
||||
Prompt: d.promptTokens,
|
||||
Completion: d.completionTokens,
|
||||
Total: d.totalTokens,
|
||||
}));
|
||||
}, [hourlyData]);
|
||||
|
||||
const hourlyTickValues = useMemo(() => {
|
||||
return hourlyChartData.filter((_, i) => i % 3 === 0).map((d) => d.name);
|
||||
}, [hourlyChartData]);
|
||||
|
||||
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 (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
{/* 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>
|
||||
<h1 className="text-foreground text-xl font-bold tracking-tight">{t('usage.title')}</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">{t('usage.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Account filter */}
|
||||
<Select
|
||||
value={selectedAccountId}
|
||||
onValueChange={(value) => setSelectedAccountId(value)}
|
||||
disabled={accountsLoading}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[200px] text-xs">
|
||||
{accountsLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder={t('usage.allAccounts')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_ACCOUNTS}>{t('usage.allAccounts')}</SelectItem>
|
||||
{accounts?.map((acc) => (
|
||||
<SelectItem key={acc.id} value={acc.id}>
|
||||
{acc.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Time range tabs */}
|
||||
<Tabs value={range} onValueChange={(v) => setRange(v as TimeRange)}>
|
||||
<TabsList className="h-9">
|
||||
<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>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-5 px-6 py-5">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('usage.totalTokens')}
|
||||
value={totals.totalTokens}
|
||||
icon={Zap}
|
||||
isLoading={dailyLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('usage.promptTokens')}
|
||||
value={totals.promptTokens}
|
||||
icon={MessageSquare}
|
||||
isLoading={dailyLoading}
|
||||
accent="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('usage.completionTokens')}
|
||||
value={totals.completionTokens}
|
||||
icon={Activity}
|
||||
isLoading={dailyLoading}
|
||||
accent="green"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('usage.totalRequests')}
|
||||
value={totals.requests}
|
||||
icon={Layers}
|
||||
isLoading={dailyLoading}
|
||||
accent="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Trend indicators row */}
|
||||
{!dailyLoading && totals.totalTokens > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-blue-400" />
|
||||
<span className="text-muted-foreground">{t('usage.promptLabel')}:</span>
|
||||
<span className="text-foreground font-mono font-medium">{promptRatio}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<ArrowDownRight className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span className="text-muted-foreground">{t('usage.completionLabel')}:</span>
|
||||
<span className="text-foreground font-mono font-medium">{completionRatio}%</span>
|
||||
</div>
|
||||
<div className="bg-border/40 h-3 w-px" />
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('usage.requestsLabel')}:{' '}
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{totals.requests.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{/* Daily Usage Bar Chart */}
|
||||
<ChartCard
|
||||
title={t('usage.dailyUsage')}
|
||||
isLoading={dailyLoading}
|
||||
error={dailyError}
|
||||
isEmpty={isDailyEmpty}
|
||||
onRetry={() => refetchDaily()}
|
||||
emptyTitle={t('usage.emptyStateTitle')}
|
||||
emptyDescription={t('usage.emptyStateDescription')}
|
||||
errorMessage={t('usage.loadFailed')}
|
||||
retryLabel={t('usage.retry')}
|
||||
>
|
||||
<div className="h-[280px]">
|
||||
<ResponsiveBar
|
||||
data={chartData}
|
||||
keys={['Prompt', 'Completion']}
|
||||
indexBy="name"
|
||||
groupMode="grouped"
|
||||
theme={nivoTheme}
|
||||
colors={barColor}
|
||||
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
|
||||
padding={0.2}
|
||||
innerPadding={2}
|
||||
borderRadius={2}
|
||||
enableGridY={true}
|
||||
enableGridX={false}
|
||||
gridYValues={5}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
|
||||
}}
|
||||
tooltip={BarTooltip}
|
||||
legends={[
|
||||
{
|
||||
dataFrom: 'keys',
|
||||
anchor: 'bottom',
|
||||
direction: 'row',
|
||||
justify: false,
|
||||
translateX: 0,
|
||||
translateY: 30,
|
||||
itemsSpacing: 16,
|
||||
itemWidth: 80,
|
||||
itemHeight: 16,
|
||||
itemDirection: 'left-to-right',
|
||||
symbolSize: 8,
|
||||
symbolShape: 'circle',
|
||||
},
|
||||
]}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</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')}
|
||||
>
|
||||
<div className="h-[280px]">
|
||||
<ResponsivePie
|
||||
data={pieData}
|
||||
innerRadius={0.55}
|
||||
padAngle={2}
|
||||
colors={PIE_COLORS}
|
||||
borderWidth={0}
|
||||
theme={nivoTheme}
|
||||
margin={{ top: 10, right: 10, bottom: 40, left: 10 }}
|
||||
tooltip={PieTooltip}
|
||||
legends={[
|
||||
{
|
||||
anchor: 'bottom',
|
||||
direction: 'row',
|
||||
justify: false,
|
||||
translateX: 0,
|
||||
translateY: 30,
|
||||
itemsSpacing: 16,
|
||||
itemWidth: 80,
|
||||
itemHeight: 16,
|
||||
itemDirection: 'left-to-right',
|
||||
symbolSize: 8,
|
||||
symbolShape: 'circle',
|
||||
},
|
||||
]}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</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')}
|
||||
>
|
||||
<div className="h-[280px]">
|
||||
<ResponsiveBar
|
||||
data={hourlyChartData}
|
||||
keys={['Prompt', 'Completion', 'Total']}
|
||||
indexBy="name"
|
||||
groupMode="grouped"
|
||||
theme={nivoTheme}
|
||||
colors={barColor}
|
||||
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
|
||||
padding={0.2}
|
||||
innerPadding={2}
|
||||
borderRadius={2}
|
||||
enableGridY={true}
|
||||
enableGridX={false}
|
||||
gridYValues={5}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
tickValues: hourlyTickValues,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
|
||||
}}
|
||||
tooltip={BarTooltip}
|
||||
legends={[
|
||||
{
|
||||
dataFrom: 'keys',
|
||||
anchor: 'bottom',
|
||||
direction: 'row',
|
||||
justify: false,
|
||||
translateX: 0,
|
||||
translateY: 30,
|
||||
itemsSpacing: 16,
|
||||
itemWidth: 80,
|
||||
itemHeight: 16,
|
||||
itemDirection: 'left-to-right',
|
||||
symbolSize: 8,
|
||||
symbolShape: 'circle',
|
||||
},
|
||||
]}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</ChartCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/usage')({
|
||||
component: UsagePage,
|
||||
});
|
||||
import { useMemo, useState } from 'react';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ResponsiveBar } from '@nivo/bar';
|
||||
import { ResponsivePie } from '@nivo/pie';
|
||||
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 {
|
||||
COLOR_PROMPT,
|
||||
COLOR_COMPLETION,
|
||||
COLOR_TOTAL,
|
||||
PIE_COLORS,
|
||||
BarTooltip,
|
||||
PieTooltip,
|
||||
} from '@/components/usage/NivoTooltip';
|
||||
import { nivoTheme } from '@/lib/nivo-theme';
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Layers,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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' });
|
||||
}
|
||||
|
||||
function barColor(bar: any): string {
|
||||
if (bar.id === 'Prompt') return COLOR_PROMPT;
|
||||
if (bar.id === 'Completion') return COLOR_COMPLETION;
|
||||
if (bar.id === 'Total') return COLOR_TOTAL;
|
||||
return 'var(--chart-3)';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function UsagePage() {
|
||||
const { t } = useTranslation();
|
||||
const [range, setRange] = useState<TimeRange>('7d');
|
||||
const ALL_ACCOUNTS = '__all__';
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
|
||||
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
||||
|
||||
const {
|
||||
data: dailyData,
|
||||
isLoading: dailyLoading,
|
||||
error: dailyError,
|
||||
refetch: refetchDaily,
|
||||
} = useQuery<UsageBucket[]>({
|
||||
queryKey: ['usage', 'day', range, selectedAccountId],
|
||||
queryFn: () => {
|
||||
const { start, end } = getTimeRange(range);
|
||||
return ipc.client.usage.getUsageByDay({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: modelData,
|
||||
isLoading: modelLoading,
|
||||
error: modelError,
|
||||
refetch: refetchModel,
|
||||
} = useQuery<ModelBucket[]>({
|
||||
queryKey: ['usage', 'model', range, selectedAccountId],
|
||||
queryFn: () => {
|
||||
const { start, end } = getTimeRange(range);
|
||||
return ipc.client.usage.getUsageByModel({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: hourlyData,
|
||||
isLoading: hourlyLoading,
|
||||
error: hourlyError,
|
||||
refetch: refetchHourly,
|
||||
} = useQuery<UsageBucket[]>({
|
||||
queryKey: ['usage', 'hour', range, selectedAccountId],
|
||||
queryFn: () => {
|
||||
const { start, end } = getTimeRange(range);
|
||||
return ipc.client.usage.getUsageByHour({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
|
||||
},
|
||||
enabled: range === '24h',
|
||||
refetchInterval: 30000,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
/* -------------------------- Derived data -------------------------- */
|
||||
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 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: formatBucketLabel(d.bucket, range),
|
||||
Prompt: d.promptTokens,
|
||||
Completion: d.completionTokens,
|
||||
}));
|
||||
}, [dailyData, range]);
|
||||
|
||||
const pieData = useMemo(() => {
|
||||
return (modelData || []).map((d) => ({
|
||||
id: d.model,
|
||||
value: d.totalTokens,
|
||||
}));
|
||||
}, [modelData]);
|
||||
|
||||
const hourlyChartData = useMemo(() => {
|
||||
return (hourlyData || []).map((d) => ({
|
||||
name: formatBucketLabel(d.bucket, '24h'),
|
||||
Prompt: d.promptTokens,
|
||||
Completion: d.completionTokens,
|
||||
Total: d.totalTokens,
|
||||
}));
|
||||
}, [hourlyData]);
|
||||
|
||||
const hourlyTickValues = useMemo(() => {
|
||||
return hourlyChartData.filter((_, i) => i % 3 === 0).map((d) => d.name);
|
||||
}, [hourlyChartData]);
|
||||
|
||||
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 (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
{/* 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>
|
||||
<h1 className="text-foreground text-xl font-bold tracking-tight">{t('usage.title')}</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">{t('usage.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Account filter */}
|
||||
<Select
|
||||
value={selectedAccountId}
|
||||
onValueChange={(value) => setSelectedAccountId(value)}
|
||||
disabled={accountsLoading}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[200px] text-xs">
|
||||
{accountsLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder={t('usage.allAccounts')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_ACCOUNTS}>{t('usage.allAccounts')}</SelectItem>
|
||||
{accounts?.map((acc) => (
|
||||
<SelectItem key={acc.id} value={acc.id}>
|
||||
{acc.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Time range tabs */}
|
||||
<Tabs value={range} onValueChange={(v) => setRange(v as TimeRange)}>
|
||||
<TabsList className="h-9">
|
||||
<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>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-5 px-6 py-5">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('usage.totalTokens')}
|
||||
value={totals.totalTokens}
|
||||
icon={Zap}
|
||||
isLoading={dailyLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('usage.promptTokens')}
|
||||
value={totals.promptTokens}
|
||||
icon={MessageSquare}
|
||||
isLoading={dailyLoading}
|
||||
accent="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('usage.completionTokens')}
|
||||
value={totals.completionTokens}
|
||||
icon={Activity}
|
||||
isLoading={dailyLoading}
|
||||
accent="green"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('usage.totalRequests')}
|
||||
value={totals.requests}
|
||||
icon={Layers}
|
||||
isLoading={dailyLoading}
|
||||
accent="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Trend indicators row */}
|
||||
{!dailyLoading && totals.totalTokens > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-blue-400" />
|
||||
<span className="text-muted-foreground">{t('usage.promptLabel')}:</span>
|
||||
<span className="text-foreground font-mono font-medium">{promptRatio}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<ArrowDownRight className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span className="text-muted-foreground">{t('usage.completionLabel')}:</span>
|
||||
<span className="text-foreground font-mono font-medium">{completionRatio}%</span>
|
||||
</div>
|
||||
<div className="bg-border/40 h-3 w-px" />
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('usage.requestsLabel')}:{' '}
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{totals.requests.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{/* Daily Usage Bar Chart */}
|
||||
<ChartCard
|
||||
title={t('usage.dailyUsage')}
|
||||
isLoading={dailyLoading}
|
||||
error={dailyError}
|
||||
isEmpty={isDailyEmpty}
|
||||
onRetry={() => refetchDaily()}
|
||||
emptyTitle={t('usage.emptyStateTitle')}
|
||||
emptyDescription={t('usage.emptyStateDescription')}
|
||||
errorMessage={t('usage.loadFailed')}
|
||||
retryLabel={t('usage.retry')}
|
||||
>
|
||||
<div className="h-[280px]">
|
||||
<ResponsiveBar
|
||||
data={chartData}
|
||||
keys={['Prompt', 'Completion']}
|
||||
indexBy="name"
|
||||
groupMode="grouped"
|
||||
theme={nivoTheme}
|
||||
colors={barColor}
|
||||
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
|
||||
padding={0.2}
|
||||
innerPadding={2}
|
||||
borderRadius={2}
|
||||
enableGridY={true}
|
||||
enableGridX={false}
|
||||
gridYValues={5}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
|
||||
}}
|
||||
tooltip={BarTooltip}
|
||||
legends={[
|
||||
{
|
||||
dataFrom: 'keys',
|
||||
anchor: 'bottom',
|
||||
direction: 'row',
|
||||
justify: false,
|
||||
translateX: 0,
|
||||
translateY: 30,
|
||||
itemsSpacing: 16,
|
||||
itemWidth: 80,
|
||||
itemHeight: 16,
|
||||
itemDirection: 'left-to-right',
|
||||
symbolSize: 8,
|
||||
symbolShape: 'circle',
|
||||
},
|
||||
]}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</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')}
|
||||
>
|
||||
<div className="h-[280px]">
|
||||
<ResponsivePie
|
||||
data={pieData}
|
||||
innerRadius={0.55}
|
||||
padAngle={2}
|
||||
colors={PIE_COLORS}
|
||||
borderWidth={0}
|
||||
theme={nivoTheme}
|
||||
margin={{ top: 10, right: 10, bottom: 40, left: 10 }}
|
||||
tooltip={PieTooltip}
|
||||
legends={[
|
||||
{
|
||||
anchor: 'bottom',
|
||||
direction: 'row',
|
||||
justify: false,
|
||||
translateX: 0,
|
||||
translateY: 30,
|
||||
itemsSpacing: 16,
|
||||
itemWidth: 80,
|
||||
itemHeight: 16,
|
||||
itemDirection: 'left-to-right',
|
||||
symbolSize: 8,
|
||||
symbolShape: 'circle',
|
||||
},
|
||||
]}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</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')}
|
||||
>
|
||||
<div className="h-[280px]">
|
||||
<ResponsiveBar
|
||||
data={hourlyChartData}
|
||||
keys={['Prompt', 'Completion', 'Total']}
|
||||
indexBy="name"
|
||||
groupMode="grouped"
|
||||
theme={nivoTheme}
|
||||
colors={barColor}
|
||||
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
|
||||
padding={0.2}
|
||||
innerPadding={2}
|
||||
borderRadius={2}
|
||||
enableGridY={true}
|
||||
enableGridX={false}
|
||||
gridYValues={5}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
tickValues: hourlyTickValues,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
|
||||
}}
|
||||
tooltip={BarTooltip}
|
||||
legends={[
|
||||
{
|
||||
dataFrom: 'keys',
|
||||
anchor: 'bottom',
|
||||
direction: 'row',
|
||||
justify: false,
|
||||
translateX: 0,
|
||||
translateY: 30,
|
||||
itemsSpacing: 16,
|
||||
itemWidth: 80,
|
||||
itemHeight: 16,
|
||||
itemDirection: 'left-to-right',
|
||||
symbolSize: 8,
|
||||
symbolShape: 'circle',
|
||||
},
|
||||
]}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</ChartCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/usage')({
|
||||
component: UsagePage,
|
||||
});
|
||||
Reference in new issue
Block a user