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
This commit is contained in:
155
components/dashboard/token-leaderboard.tsx
Normal file
155
components/dashboard/token-leaderboard.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user