Files
new-api-analytics/app/logs/page.tsx
shangzy b719b358f8 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
2026-04-02 12:47:50 +08:00

114 lines
6.6 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { motion } from "motion/react";
import { ScrollText, Search, ChevronLeft, ChevronRight, Zap } from "lucide-react";
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens, formatDate } from "@/lib/utils";
import { useI18n } from "@/lib/i18n";
interface LogEntry {
id: number; created_at: string; display_name: string;
real_model: string; channel_name: string; prompt_tokens: number;
completion_tokens: number; total_tokens: number;
use_time: number; is_stream: boolean;
}
export default function LogsPage() {
const { t } = useI18n();
const [range, setRange] = useState<TimeRange>("7d");
const [page, setPage] = useState(1);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({ username: "", model: "", token_name: "" });
const pageSize = 100;
const fetchData = useCallback(async () => {
setLoading(true);
const { start, end } = getTimeRange(range);
const res = await fetch(buildQuery("/api/logs", { start, end, page, page_size: pageSize, ...filters }));
const data = await res.json();
setLogs(data.logs); setTotal(data.total); setLoading(false);
}, [range, page, filters]);
useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => { setPage(1); }, [range, filters]);
const totalPages = Math.ceil(total / pageSize);
const headers = [t("th.time"), t("th.user"), t("th.realModel"), t("th.channel"), t("th.input"), t("th.output"), t("th.totalToken"), t("th.latency"), ""];
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">
<ScrollText className="h-5 w-5" style={{ color: "var(--accent)", opacity: 0.6 }} />
<h1 className="text-2xl font-bold gradient-text">{t("logs.title")}</h1>
</motion.div>
<TimeRangeSelector value={range} onChange={setRange} />
</div>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5" style={{ color: "var(--text-muted)" }} />
<input type="text" placeholder={t("logs.filterUser")} value={filters.username}
onChange={(e) => setFilters({ ...filters, username: e.target.value })}
className="input-glass rounded-lg pl-9 pr-3 py-2 text-sm w-36" />
</div>
<input type="text" placeholder={t("logs.filterModel")} value={filters.model}
onChange={(e) => setFilters({ ...filters, model: e.target.value })}
className="input-glass rounded-lg px-3 py-2 text-sm w-36" />
<input type="text" placeholder={t("logs.filterToken")} value={filters.token_name}
onChange={(e) => setFilters({ ...filters, token_name: e.target.value })}
className="input-glass rounded-lg px-3 py-2 text-sm w-36" />
<span className="text-xs font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>
{formatNumber(total)} {t("common.records")}
</span>
</motion.div>
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="glass overflow-auto">
<table className="w-full text-sm whitespace-nowrap">
<thead>
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
{headers.map((h, i) => (
<th key={i} className={`px-3 py-3 text-xs font-medium uppercase tracking-wider ${i >= 4 ? "text-right" : "text-left"}`} style={{ color: "var(--text-muted)" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={9} className="px-3 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr>
) : logs.map((log) => (
<tr key={log.id} className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }}>
<td className="px-3 py-2.5 text-xs font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>{formatDate(log.created_at)}</td>
<td className="px-3 py-2.5" style={{ color: "var(--text-secondary)" }}>{log.display_name}</td>
<td className="px-3 py-2.5 font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-accent)", opacity: 0.7 }}>{log.real_model}</td>
<td className="px-3 py-2.5" style={{ color: "var(--text-muted)" }}>{log.channel_name}</td>
<td className="px-3 py-2.5 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatNumber(log.prompt_tokens)}</td>
<td className="px-3 py-2.5 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatNumber(log.completion_tokens)}</td>
<td className="px-3 py-2.5 text-right tabular-nums font-medium font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-primary)" }}>{formatNumber(log.total_tokens)}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-xs" style={{ color: "var(--text-muted)" }}>{log.use_time}ms</td>
<td className="px-3 py-2.5 text-center">{log.is_stream ? <Zap className="inline h-3 w-3 text-yellow-500/60" /> : ""}</td>
</tr>
))}
</tbody>
</table>
</motion.div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3">
<button onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 1}
className="btn-accent rounded-lg px-3 py-1.5 text-xs disabled:opacity-20 flex items-center gap-1">
<ChevronLeft className="h-3 w-3" /> {t("common.prevPage")}
</button>
<span className="text-xs font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>{page} / {totalPages}</span>
<button onClick={() => setPage(Math.min(totalPages, page + 1))} disabled={page === totalPages}
className="btn-accent rounded-lg px-3 py-1.5 text-xs disabled:opacity-20 flex items-center gap-1">
{t("common.nextPage")} <ChevronRight className="h-3 w-3" />
</button>
</div>
)}
</div>
);
}