feat: API analytics dashboard with i18n and theme support
Next.js full-stack analytics dashboard for new-api. - Direct PostgreSQL readonly queries on logs table - 5 pages: Dashboard, Rankings, Aggregation, Logs, Detail - Dark/Light/System theme with CSS variables - Chinese/English i18n (default Chinese) - Recharts with dual Y-axis for input/output tokens - Lucide icons + Motion animations - Docker + docker-compose with external sinobridge network, port 8019
This commit is contained in:
109
app/rankings/page.tsx
Normal file
109
app/rankings/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "motion/react";
|
||||
import { Trophy, Users, Cpu, Radio } from "lucide-react";
|
||||
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||
import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
type Tab = "user" | "model" | "channel";
|
||||
|
||||
interface RankItem {
|
||||
rank: number; name: string; id?: number; calls: number;
|
||||
prompt_tokens: number; completion_tokens: number; total_tokens: number;
|
||||
}
|
||||
|
||||
export default function RankingsPage() {
|
||||
const { t } = useI18n();
|
||||
const [range, setRange] = useState<TimeRange>("30d");
|
||||
const [tab, setTab] = useState<Tab>("user");
|
||||
const [data, setData] = useState<RankItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const tabConfig: Record<Tab, { label: string; icon: typeof Users }> = {
|
||||
user: { label: t("rank.user"), icon: Users },
|
||||
model: { label: t("rank.model"), icon: Cpu },
|
||||
channel: { label: t("rank.channel"), icon: Radio },
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { start, end } = getTimeRange(range);
|
||||
const res = await fetch(buildQuery("/api/rankings", { start, end, type: tab, limit: 100 }));
|
||||
setData(await res.json());
|
||||
setLoading(false);
|
||||
}, [range, tab]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
function detailHref(item: RankItem): string {
|
||||
if (tab === "channel") return `/detail/channel/${item.id}`;
|
||||
if (tab === "model") return `/detail/model/${encodeURIComponent(item.name)}`;
|
||||
return `/detail/user/${encodeURIComponent(item.name)}`;
|
||||
}
|
||||
|
||||
const headers = [t("th.rank"), t("th.name"), t("th.calls"), t("th.input"), t("th.output"), t("th.totalToken")];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="flex items-center gap-3">
|
||||
<Trophy className="h-5 w-5" style={{ color: "var(--accent)", opacity: 0.6 }} />
|
||||
<h1 className="text-2xl font-bold gradient-text">{t("rank.title")}</h1>
|
||||
</motion.div>
|
||||
<TimeRangeSelector value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 rounded-lg p-1 w-fit" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
|
||||
{(Object.keys(tabConfig) as Tab[]).map((key) => {
|
||||
const Icon = tabConfig[key].icon;
|
||||
return (
|
||||
<button key={key} onClick={() => setTab(key)}
|
||||
className="relative flex items-center gap-2 px-4 py-2 text-xs font-medium rounded-md transition-colors"
|
||||
style={{ color: tab === key ? "var(--text-accent)" : "var(--text-muted)" }}
|
||||
>
|
||||
{tab === key && (
|
||||
<motion.div layoutId="tab-bg" className="absolute inset-0 rounded-md"
|
||||
style={{ background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }} />
|
||||
)}
|
||||
<Icon className="relative z-10 h-3.5 w-3.5" />
|
||||
<span className="relative z-10">{tabConfig[key].label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} className="glass overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||
{headers.map((h, i) => (
|
||||
<th key={h} className={`px-4 py-3 text-xs font-medium uppercase tracking-wider ${i >= 2 ? "text-right" : "text-left"}`} style={{ color: "var(--text-muted)" }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr>
|
||||
) : data.map((item, i) => (
|
||||
<motion.tr key={item.rank} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: i * 0.02 }}
|
||||
className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)", borderBottomWidth: "1px", opacity: 0.01 }}>
|
||||
<td className="px-4 py-3 font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{item.rank}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={detailHref(item)} className="transition-colors" style={{ color: "var(--text-accent)" }}>{item.name}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.prompt_tokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.completion_tokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-medium font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user