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:
2026-04-02 12:47:50 +08:00
commit b719b358f8
41 changed files with 3430 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
"use client";
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Cell,
} from "recharts";
import { formatTokens } from "@/lib/utils";
interface RankItem {
name: string;
total_tokens: number;
calls: number;
}
const BAR_COLORS = [
"#00e5ff", "#00bcd4", "#0097a7", "#7c4dff",
"#651fff", "#536dfe", "#448aff", "#40c4ff",
"#18ffff", "#84ffff",
];
export function RankingBar({
data,
title,
}: {
data: RankItem[];
title: string;
}) {
if (!data.length) return null;
const sliced = data.slice(0, 10);
return (
<div>
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">{title}</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={sliced}
layout="vertical"
margin={{ top: 0, right: 20, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(0,229,255,0.06)" horizontal={false} />
<XAxis type="number" tickFormatter={(v) => formatTokens(v)} tick={{ fontSize: 10, fill: "rgba(200,214,229,0.4)" }} stroke="rgba(0,229,255,0.1)" />
<YAxis type="category" dataKey="name" width={100} tick={{ fontSize: 11, fill: "rgba(200,214,229,0.6)" }} stroke="transparent" />
<Tooltip
contentStyle={{
background: "rgba(6,8,13,0.95)",
border: "1px solid rgba(0,229,255,0.2)",
borderRadius: "8px",
color: "#c8d6e5",
fontSize: "12px",
}}
formatter={(v) => [formatTokens(Number(v)), "Total Tokens"]}
/>
<Bar dataKey="total_tokens" radius={[0, 4, 4, 0]}>
{sliced.map((_, i) => (
<Cell key={i} fill={BAR_COLORS[i % BAR_COLORS.length]} fillOpacity={0.7} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
import { formatTokens } from "@/lib/utils";
import { useI18n } from "@/lib/i18n";
interface TrendPoint { date: string; calls: number; total_tokens: number; prompt_tokens: number; completion_tokens: number; }
export function TrendChart({ data, metric = "total_tokens" }: { data: TrendPoint[]; metric?: "total_tokens" | "calls" }) {
const { t, locale } = useI18n();
if (!data.length)
return <div className="flex h-64 items-center justify-center" style={{ color: "var(--text-muted)" }}>{t("common.noData")}</div>;
// 本地化日期格式
const formatDateLabel = (dateStr: string) => {
const d = new Date(dateStr);
if (locale === "zh") {
return `${d.getMonth() + 1}/${d.getDate()}`;
}
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
const tooltipStyle = {
background: "var(--surface)",
border: "1px solid var(--surface-border)",
borderRadius: "8px",
color: "var(--foreground)",
fontSize: "12px",
backdropFilter: "blur(16px)",
};
// Token 模式:输入和输出数量级差距大,用双 Y 轴
if (metric === "total_tokens") {
return (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data} margin={{ top: 5, right: 60, left: 10, bottom: 5 }}>
<defs>
<linearGradient id="gradCyan" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="var(--accent)" />
<stop offset="100%" stopColor="var(--accent-purple)" />
</linearGradient>
<linearGradient id="gradPurple" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="var(--accent-purple)" />
<stop offset="100%" stopColor="var(--accent-pink)" />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
<XAxis dataKey="date" tickFormatter={formatDateLabel} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
{/* 左 Y 轴:输入 */}
<YAxis
yAxisId="left"
tickFormatter={(v) => formatTokens(v)}
tick={{ fontSize: 10 }}
stroke="var(--chart-grid)"
label={{ value: t("th.input"), angle: -90, position: "insideLeft", style: { fontSize: 10, fill: "var(--text-muted)" } }}
/>
{/* 右 Y 轴:输出 */}
<YAxis
yAxisId="right"
orientation="right"
tickFormatter={(v) => formatTokens(v)}
tick={{ fontSize: 10 }}
stroke="var(--chart-grid)"
label={{ value: t("th.output"), angle: 90, position: "insideRight", style: { fontSize: 10, fill: "var(--text-muted)" } }}
/>
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={(label) => {
const d = new Date(String(label));
return locale === "zh"
? `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
: d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}}
formatter={(value, name) => [
formatTokens(Number(value)),
name === t("th.input") ? t("th.input") : t("th.output"),
]}
/>
<Legend />
<Line yAxisId="left" type="monotone" dataKey="prompt_tokens" name={t("th.input")} stroke="url(#gradCyan)" strokeWidth={2} dot={false} />
<Line yAxisId="right" type="monotone" dataKey="completion_tokens" name={t("th.output")} stroke="url(#gradPurple)" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}
// 调用量模式:单 Y 轴
return (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
<XAxis dataKey="date" tickFormatter={formatDateLabel} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
<YAxis tickFormatter={(v) => formatTokens(v)} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
<Tooltip
contentStyle={tooltipStyle}
labelFormatter={(label) => {
const d = new Date(String(label));
return locale === "zh"
? `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
: d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}}
formatter={(value, name) => [
formatTokens(Number(value)),
name === t("th.calls") ? t("th.calls") : String(name),
]}
/>
<Legend />
<Line type="monotone" dataKey="calls" name={t("th.calls")} stroke="var(--accent)" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}