从 logs 表 other JSON 字段提取 cache_creation_tokens 和 cache_tokens, 在排名、日志、聚合、详情页分别展示,total_tokens 包含缓存部分。
117 lines
7.2 KiB
TypeScript
117 lines
7.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, startTransition } from "react";
|
|
import { motion } from "motion/react";
|
|
import { ScrollText, Search, ChevronLeft, ChevronRight, Zap } from "lucide-react";
|
|
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
|
import { buildQuery, formatNumber, formatDate } from "@/lib/utils";
|
|
import { useTimeRange } from "@/lib/time-range-context";
|
|
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; cache_creation_tokens: number; cache_read_tokens: number;
|
|
total_tokens: number; use_time: number; is_stream: boolean;
|
|
}
|
|
|
|
export default function LogsPage() {
|
|
const { t } = useI18n();
|
|
const { getEffectiveRange } = useTimeRange();
|
|
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 () => {
|
|
startTransition(() => setLoading(true));
|
|
const { start, end } = getEffectiveRange();
|
|
const res = await fetch(buildQuery("/api/logs", { start, end, page, page_size: pageSize, ...filters }));
|
|
const data = await res.json();
|
|
startTransition(() => { setLogs(data.logs); setTotal(data.total); setLoading(false); });
|
|
}, [page, filters, getEffectiveRange]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
useEffect(() => { startTransition(() => setPage(1)); }, [getEffectiveRange, 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.cacheCreation"), t("th.cacheRead"), 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 />
|
|
</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={11} 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.cache_creation_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.cache_read_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>
|
|
);
|
|
}
|