Add a sortable "Out/In Ratio" (completion_tokens / prompt_tokens) column with a portal-based tooltip explaining the metric. Fix hydration mismatch by switching to useSyncExternalStore for localStorage reads in TimeRangeProvider. Update CLAUDE.md with project documentation.
216 lines
10 KiB
TypeScript
216 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { motion } from "motion/react";
|
|
import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp, HelpCircle } from "lucide-react";
|
|
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
|
import { buildQuery, formatNumber, formatTokens } from "@/lib/utils";
|
|
import { useTimeRange } from "@/lib/time-range-context";
|
|
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" | "ratio";
|
|
|
|
function RatioTooltip({ text }: { text: string }) {
|
|
const [show, setShow] = useState(false);
|
|
const [pos, setPos] = useState({ x: 0, y: 0 });
|
|
const iconRef = useRef<HTMLSpanElement>(null);
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => { setMounted(true); }, []);
|
|
|
|
function handleEnter() {
|
|
if (iconRef.current) {
|
|
const rect = iconRef.current.getBoundingClientRect();
|
|
setPos({ x: rect.right, y: rect.top });
|
|
}
|
|
setShow(true);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<span
|
|
ref={iconRef}
|
|
className="cursor-help"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onMouseEnter={handleEnter}
|
|
onMouseLeave={() => setShow(false)}
|
|
>
|
|
<HelpCircle className="h-3 w-3" style={{ opacity: 0.5 }} />
|
|
</span>
|
|
{mounted && show && createPortal(
|
|
<div
|
|
className="fixed w-60 rounded-lg px-3.5 py-2.5 text-xs font-normal leading-relaxed z-[9999]"
|
|
style={{
|
|
top: pos.y - 8,
|
|
left: pos.x,
|
|
transform: "translate(-100%, -100%)",
|
|
background: "var(--background)",
|
|
border: "1px solid var(--surface-border)",
|
|
color: "var(--text-secondary)",
|
|
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
{text}
|
|
<span
|
|
className="absolute w-0 h-0"
|
|
style={{
|
|
bottom: "-6px",
|
|
right: "8px",
|
|
borderLeft: "6px solid transparent",
|
|
borderRight: "6px solid transparent",
|
|
borderTop: "6px solid var(--surface-border)",
|
|
}}
|
|
/>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function AggregationPage() {
|
|
const { t } = useI18n();
|
|
const { getEffectiveRange } = useTimeRange();
|
|
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 } = getEffectiveRange();
|
|
const res = await fetch(buildQuery("/api/aggregation", { start, end }));
|
|
setData(await res.json());
|
|
setLoading(false);
|
|
}, [getEffectiveRange]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
const sorted = [...data].sort((a, b) => {
|
|
const getVal = (item: AggItem) =>
|
|
sortKey === "ratio"
|
|
? (item.prompt_tokens > 0 ? item.completion_tokens / item.prompt_tokens : 0)
|
|
: (item[sortKey] as number);
|
|
const diff = getVal(a) - getVal(b);
|
|
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 />
|
|
</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 cursor-pointer select-none transition-colors"
|
|
style={{ color: "var(--text-muted)" }} onClick={() => handleSort("ratio")}>
|
|
<span className="inline-flex items-center gap-1">
|
|
{t("agg.ratio")}
|
|
<SortIcon col="ratio" />
|
|
<RatioTooltip text={t("agg.ratioTip")} />
|
|
</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={8} 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;
|
|
const ratio = item.prompt_tokens > 0 ? (item.completion_tokens / item.prompt_tokens) : 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 tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: ratio >= 1 ? "var(--accent)" : "var(--text-muted)" }}>{ratio.toFixed(2)}</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>
|
|
);
|
|
}
|