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:
140
app/aggregation/page.tsx
Normal file
140
app/aggregation/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||
import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
interface AggItem {
|
||||
rank: number; name: string; calls: number;
|
||||
prompt_tokens: number; completion_tokens: number; total_tokens: number;
|
||||
}
|
||||
|
||||
type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens";
|
||||
|
||||
export default function AggregationPage() {
|
||||
const { t } = useI18n();
|
||||
const [range, setRange] = useState<TimeRange>("30d");
|
||||
const [data, setData] = useState<AggItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortKey, setSortKey] = useState<SortKey>("total_tokens");
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { start, end } = getTimeRange(range);
|
||||
const res = await fetch(buildQuery("/api/aggregation", { start, end }));
|
||||
setData(await res.json());
|
||||
setLoading(false);
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const sorted = [...data].sort((a, b) => {
|
||||
const diff = (a[sortKey] as number) - (b[sortKey] as number);
|
||||
return sortAsc ? diff : -diff;
|
||||
});
|
||||
|
||||
const totals = data.reduce(
|
||||
(acc, d) => ({ calls: acc.calls + d.calls, tokens: acc.tokens + d.total_tokens }),
|
||||
{ calls: 0, tokens: 0 }
|
||||
);
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortAsc(!sortAsc);
|
||||
else { setSortKey(key); setSortAsc(false); }
|
||||
}
|
||||
|
||||
function SortIcon({ col }: { col: SortKey }) {
|
||||
if (sortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.5 }} />;
|
||||
return sortAsc
|
||||
? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} />
|
||||
: <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />;
|
||||
}
|
||||
|
||||
const sortHeaders: { key: SortKey; label: string }[] = [
|
||||
{ key: "calls", label: t("th.calls") },
|
||||
{ key: "prompt_tokens", label: t("th.input") },
|
||||
{ key: "completion_tokens", label: t("th.output") },
|
||||
{ key: "total_tokens", label: 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">
|
||||
<Users className="h-5 w-5" style={{ color: "var(--accent)", opacity: 0.6 }} />
|
||||
<h1 className="text-2xl font-bold gradient-text">{t("agg.title")}</h1>
|
||||
</motion.div>
|
||||
<TimeRangeSelector value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
{!loading && data.length > 0 && (
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="glass p-4 flex items-center gap-8 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3.5 w-3.5" style={{ color: "var(--accent)", opacity: 0.5 }} />
|
||||
<span style={{ color: "var(--text-muted)" }}>{t("agg.userCount")}</span>
|
||||
<span className="font-semibold font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{data.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="h-3.5 w-3.5" style={{ color: "var(--accent)", opacity: 0.5 }} />
|
||||
<span style={{ color: "var(--text-muted)" }}>{t("agg.totalCalls")}</span>
|
||||
<span className="font-semibold font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatNumber(totals.calls)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-3.5 w-3.5" style={{ color: "var(--accent)", opacity: 0.5 }} />
|
||||
<span style={{ color: "var(--text-muted)" }}>{t("agg.totalToken")}</span>
|
||||
<span className="font-semibold font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(totals.tokens)}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="glass overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>#</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.user")}</th>
|
||||
{sortHeaders.map(h => (
|
||||
<th key={h.key} className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider cursor-pointer select-none transition-colors"
|
||||
style={{ color: "var(--text-muted)" }} onClick={() => handleSort(h.key)}>
|
||||
<span className="inline-flex items-center gap-1">{h.label} <SortIcon col={h.key} /></span>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("common.share")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="px-4 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr>
|
||||
) : sorted.map((item, i) => {
|
||||
const pct = totals.tokens > 0 ? (item.total_tokens / totals.tokens * 100) : 0;
|
||||
return (
|
||||
<tr key={item.name} className="row-glow transition-colors group" style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||
<td className="px-4 py-3 font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{i + 1}</td>
|
||||
<td className="px-4 py-3 font-medium transition-colors" style={{ color: "var(--text-accent)" }}>{item.name}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" 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>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="w-16 h-1.5 rounded-full overflow-hidden" style={{ background: "var(--progress-bg)" }}>
|
||||
<motion.div className="h-full rounded-full bg-gradient-to-r from-[var(--accent)] to-[var(--accent-purple)]"
|
||||
initial={{ width: 0 }} animate={{ width: `${Math.min(pct, 100)}%` }}
|
||||
transition={{ duration: 0.8, delay: i * 0.02 }} />
|
||||
</div>
|
||||
<span className="text-xs font-[family-name:var(--font-geist-mono)] w-10 text-right" style={{ color: "var(--text-muted)" }}>{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user