Add dashboard token usage history (#240)

This commit is contained in:
Lingxuan Zuo
2026-03-02 13:20:33 +08:00
committed by GitHub
Unverified
parent 0bc4b7cbc2
commit 62108bdc23
10 changed files with 718 additions and 6 deletions

View File

@@ -12,6 +12,9 @@ import {
Settings,
Plus,
Terminal,
Coins,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -24,6 +27,23 @@ import { useSettingsStore } from '@/stores/settings';
import { StatusBadge } from '@/components/common/StatusBadge';
import { useTranslation } from 'react-i18next';
type UsageHistoryEntry = {
timestamp: string;
sessionId: string;
agentId: string;
model?: string;
provider?: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
totalTokens: number;
costUsd?: number;
};
type UsageWindow = '7d' | '30d' | 'all';
type UsageGroupBy = 'model' | 'day';
export function Dashboard() {
const { t } = useTranslation('dashboard');
const gatewayStatus = useGatewayStore((state) => state.status);
@@ -33,18 +53,38 @@ export function Dashboard() {
const isGatewayRunning = gatewayStatus.state === 'running';
const [uptime, setUptime] = useState(0);
const [usageHistory, setUsageHistory] = useState<UsageHistoryEntry[]>([]);
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model');
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
const [usagePage, setUsagePage] = useState(1);
// Fetch data only when gateway is running
useEffect(() => {
if (isGatewayRunning) {
fetchChannels();
fetchSkills();
window.electron.ipcRenderer.invoke('usage:recentTokenHistory', 60)
.then((entries) => {
setUsageHistory(Array.isArray(entries) ? entries as typeof usageHistory : []);
setUsagePage(1);
})
.catch(() => {
setUsageHistory([]);
});
}
}, [fetchChannels, fetchSkills, isGatewayRunning]);
// Calculate statistics safely
const connectedChannels = Array.isArray(channels) ? channels.filter((c) => c.status === 'connected').length : 0;
const enabledSkills = Array.isArray(skills) ? skills.filter((s) => s.enabled).length : 0;
const visibleUsageHistory = isGatewayRunning ? usageHistory : [];
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy);
const usagePageSize = 5;
const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize));
const safeUsagePage = Math.min(usagePage, usageTotalPages);
const pagedUsageHistory = filteredUsageHistory.slice((safeUsagePage - 1) * usagePageSize, safeUsagePage * usagePageSize);
const usageLoading = isGatewayRunning && visibleUsageHistory.length === 0;
// Update uptime periodically
useEffect(() => {
@@ -274,6 +314,166 @@ export function Dashboard() {
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('recentTokenHistory.title')}</CardTitle>
<CardDescription>{t('recentTokenHistory.description')}</CardDescription>
</CardHeader>
<CardContent>
{usageLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('recentTokenHistory.loading')}</div>
) : visibleUsageHistory.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('recentTokenHistory.empty')}</p>
</div>
) : filteredUsageHistory.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('recentTokenHistory.emptyForWindow')}</p>
</div>
) : (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="flex rounded-lg border p-1">
<Button
variant={usageGroupBy === 'model' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setUsageGroupBy('model');
setUsagePage(1);
}}
>
{t('recentTokenHistory.groupByModel')}
</Button>
<Button
variant={usageGroupBy === 'day' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setUsageGroupBy('day');
setUsagePage(1);
}}
>
{t('recentTokenHistory.groupByTime')}
</Button>
</div>
<div className="flex rounded-lg border p-1">
<Button
variant={usageWindow === '7d' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setUsageWindow('7d');
setUsagePage(1);
}}
>
{t('recentTokenHistory.last7Days')}
</Button>
<Button
variant={usageWindow === '30d' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setUsageWindow('30d');
setUsagePage(1);
}}
>
{t('recentTokenHistory.last30Days')}
</Button>
<Button
variant={usageWindow === 'all' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setUsageWindow('all');
setUsagePage(1);
}}
>
{t('recentTokenHistory.allTime')}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('recentTokenHistory.showingLast', { count: filteredUsageHistory.length })}
</p>
</div>
<UsageBarChart
groups={usageGroups}
emptyLabel={t('recentTokenHistory.empty')}
totalLabel={t('recentTokenHistory.totalTokens')}
inputLabel={t('recentTokenHistory.inputShort')}
outputLabel={t('recentTokenHistory.outputShort')}
cacheLabel={t('recentTokenHistory.cacheShort')}
/>
<div className="space-y-3">
{pagedUsageHistory.map((entry) => (
<div
key={`${entry.sessionId}-${entry.timestamp}`}
className="rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium truncate">
{entry.model || t('recentTokenHistory.unknownModel')}
</p>
<p className="text-xs text-muted-foreground truncate">
{[entry.provider, entry.agentId, entry.sessionId].filter(Boolean).join(' • ')}
</p>
</div>
<div className="text-right shrink-0">
<p className="font-semibold">{formatTokenCount(entry.totalTokens)}</p>
<p className="text-xs text-muted-foreground">
{formatUsageTimestamp(entry.timestamp)}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{t('recentTokenHistory.input', { value: formatTokenCount(entry.inputTokens) })}</span>
<span>{t('recentTokenHistory.output', { value: formatTokenCount(entry.outputTokens) })}</span>
{entry.cacheReadTokens > 0 && (
<span>{t('recentTokenHistory.cacheRead', { value: formatTokenCount(entry.cacheReadTokens) })}</span>
)}
{entry.cacheWriteTokens > 0 && (
<span>{t('recentTokenHistory.cacheWrite', { value: formatTokenCount(entry.cacheWriteTokens) })}</span>
)}
{typeof entry.costUsd === 'number' && Number.isFinite(entry.costUsd) && (
<span>{t('recentTokenHistory.cost', { amount: entry.costUsd.toFixed(4) })}</span>
)}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between gap-3 border-t pt-3">
<p className="text-xs text-muted-foreground">
{t('recentTokenHistory.page', { current: safeUsagePage, total: usageTotalPages })}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setUsagePage((page) => Math.max(1, page - 1))}
disabled={safeUsagePage <= 1}
>
<ChevronLeft className="h-4 w-4 mr-1" />
{t('recentTokenHistory.prev')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setUsagePage((page) => Math.min(usageTotalPages, page + 1))}
disabled={safeUsagePage >= usageTotalPages}
>
{t('recentTokenHistory.next')}
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -295,4 +495,183 @@ function formatUptime(seconds: number): string {
}
}
function formatTokenCount(value: number): string {
return Intl.NumberFormat().format(value);
}
function formatUsageTimestamp(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function groupUsageHistory(
entries: UsageHistoryEntry[],
groupBy: UsageGroupBy,
): Array<{
label: string;
totalTokens: number;
inputTokens: number;
outputTokens: number;
cacheTokens: number;
sortKey: number | string;
}> {
const grouped = new Map<string, {
label: string;
totalTokens: number;
inputTokens: number;
outputTokens: number;
cacheTokens: number;
sortKey: number | string;
}>();
for (const entry of entries) {
const label = groupBy === 'model'
? (entry.model || 'Unknown')
: formatUsageDay(entry.timestamp);
const current = grouped.get(label) ?? {
label,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
sortKey: groupBy === 'day' ? getUsageDaySortKey(entry.timestamp) : label.toLowerCase(),
};
current.totalTokens += entry.totalTokens;
current.inputTokens += entry.inputTokens;
current.outputTokens += entry.outputTokens;
current.cacheTokens += entry.cacheReadTokens + entry.cacheWriteTokens;
grouped.set(label, current);
}
return Array.from(grouped.values())
.sort((a, b) => {
if (groupBy === 'day') {
return Number(a.sortKey) - Number(b.sortKey);
}
return b.totalTokens - a.totalTokens;
})
.slice(0, 8);
}
function formatUsageDay(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
}).format(date);
}
function getUsageDaySortKey(timestamp: string): number {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return 0;
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function filterUsageHistoryByWindow(entries: UsageHistoryEntry[], window: UsageWindow): UsageHistoryEntry[] {
if (window === 'all') return entries;
const now = Date.now();
const days = window === '7d' ? 7 : 30;
const cutoff = now - days * 24 * 60 * 60 * 1000;
return entries.filter((entry) => {
const timestamp = Date.parse(entry.timestamp);
return Number.isFinite(timestamp) && timestamp >= cutoff;
});
}
function UsageBarChart({
groups,
emptyLabel,
totalLabel,
inputLabel,
outputLabel,
cacheLabel,
}: {
groups: Array<{
label: string;
totalTokens: number;
inputTokens: number;
outputTokens: number;
cacheTokens: number;
}>;
emptyLabel: string;
totalLabel: string;
inputLabel: string;
outputLabel: string;
cacheLabel: string;
}) {
if (groups.length === 0) {
return (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{emptyLabel}
</div>
);
}
const maxTokens = Math.max(...groups.map((group) => group.totalTokens), 1);
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-sky-500" />
{inputLabel}
</span>
<span className="inline-flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-violet-500" />
{outputLabel}
</span>
<span className="inline-flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
{cacheLabel}
</span>
</div>
{groups.map((group) => (
<div key={group.label} className="space-y-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate font-medium">{group.label}</span>
<span className="text-muted-foreground">
{totalLabel}: {formatTokenCount(group.totalTokens)}
</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-muted">
<div
className="flex h-full overflow-hidden rounded-full"
style={{ width: `${Math.max((group.totalTokens / maxTokens) * 100, 6)}%` }}
>
{group.inputTokens > 0 && (
<div
className="h-full bg-sky-500"
style={{ width: `${(group.inputTokens / group.totalTokens) * 100}%` }}
/>
)}
{group.outputTokens > 0 && (
<div
className="h-full bg-violet-500"
style={{ width: `${(group.outputTokens / group.totalTokens) * 100}%` }}
/>
)}
{group.cacheTokens > 0 && (
<div
className="h-full bg-amber-500"
style={{ width: `${(group.cacheTokens / group.totalTokens) * 100}%` }}
/>
)}
</div>
</div>
</div>
))}
</div>
);
}
export default Dashboard;