Files
openclaw-market/components/dashboard/token-leaderboard.tsx
richarjiang 36f10954cf feat: add token usage tracking and leaderboard
- Add token_usage table with composite unique index for claw_id + date
- Add API endpoints: POST /token, GET /token/leaderboard, GET /token/stats
- Add TokenLeaderboard component with daily/total period toggle
- Add CLI commands: `token` and `stats` for reporting and viewing usage
- Add Redis caching for leaderboard with 1-minute TTL
- Add shared utilities: authenticateRequest, getTodayDateString
- Use UPSERT pattern for atomic token updates
- Add i18n translations (en/zh) for new UI elements
2026-03-15 15:17:10 +08:00

156 lines
5.3 KiB
TypeScript

"use client";
import { useEffect, useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
interface LeaderboardEntry {
rank: number;
clawId: string;
clawName: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
interface LeaderboardData {
period: string;
date: string;
leaderboard: LeaderboardEntry[];
}
type Period = "daily" | "total";
function formatTokens(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toString();
}
export function TokenLeaderboard() {
const t = useTranslations("tokenLeaderboard");
const [period, setPeriod] = useState<Period>("daily");
const [data, setData] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch(`/api/v1/token/leaderboard?period=${period}`);
if (res.ok) {
const json: LeaderboardData = await res.json();
setData(json.leaderboard);
}
} catch {
// retry on interval
} finally {
setLoading(false);
}
};
setLoading(true);
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, [period]);
const maxTokens = useMemo(
() => Math.max(...data.map((d) => d.totalTokens), 1),
[data]
);
return (
<Card className="border-white/5">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle>{t("title")}</CardTitle>
<div className="flex rounded-md border border-white/10 bg-white/5 p-0.5">
<button
onClick={() => setPeriod("daily")}
className={cn(
"rounded px-2 py-0.5 text-xs font-medium transition-all",
period === "daily"
? "bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)]"
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
)}
>
{t("daily")}
</button>
<button
onClick={() => setPeriod("total")}
className={cn(
"rounded px-2 py-0.5 text-xs font-medium transition-all",
period === "total"
? "bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)]"
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
)}
>
{t("total")}
</button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2 p-4 pt-0">
{loading ? (
<div className="flex items-center justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-[var(--accent-cyan)] border-t-transparent" />
</div>
) : data.length === 0 ? (
<p className="py-4 text-center text-xs text-[var(--text-muted)]">{t("noData")}</p>
) : (
data.slice(0, 10).map((entry) => (
<div key={entry.clawId} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={cn(
"flex h-5 w-5 items-center justify-center rounded text-xs font-bold",
entry.rank === 1
? "bg-yellow-500/20 text-yellow-400"
: entry.rank === 2
? "bg-gray-400/20 text-gray-300"
: entry.rank === 3
? "bg-orange-500/20 text-orange-400"
: "bg-white/5 text-[var(--text-muted)]"
)}
>
{entry.rank}
</span>
<span className="max-w-[100px] truncate text-sm font-medium">
{entry.clawName}
</span>
</div>
<span className="font-mono text-xs text-[var(--accent-cyan)]">
{formatTokens(entry.totalTokens)}
</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-white/5">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-[var(--accent-cyan)] to-[var(--accent-purple)]"
initial={{ width: 0 }}
animate={{ width: `${(entry.totalTokens / maxTokens) * 100}%` }}
transition={{ duration: 0.5, ease: "easeOut" }}
/>
</div>
<div className="flex justify-end">
<span className="font-mono text-[10px] text-[var(--text-muted)]">
{t("inputOutput", {
input: formatTokens(entry.inputTokens),
output: formatTokens(entry.outputTokens),
})}
</span>
</div>
</div>
))
)}
</CardContent>
</Card>
);
}