feat: add output/input ratio column to aggregation page

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.
This commit is contained in:
2026-04-07 15:09:23 +08:00
parent 9bb36432ba
commit 8b91aa3e97
4 changed files with 195 additions and 30 deletions

View File

@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState, useCallback } from "react";
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 } from "lucide-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";
@@ -13,7 +14,66 @@ interface AggItem {
prompt_tokens: number; completion_tokens: number; total_tokens: number;
}
type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens";
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();
@@ -34,7 +94,11 @@ export default function AggregationPage() {
useEffect(() => { fetchData(); }, [fetchData]);
const sorted = [...data].sort((a, b) => {
const diff = (a[sortKey] as number) - (b[sortKey] as number);
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;
});
@@ -104,14 +168,23 @@ export default function AggregationPage() {
<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={7} className="px-4 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr>
<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>
@@ -120,6 +193,7 @@ export default function AggregationPage() {
<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)" }}>