Files
new-api-analytics/app/detail/[...slug]/page.tsx

327 lines
19 KiB
TypeScript

"use client";
import { Fragment, useEffect, useState, useCallback, startTransition } from "react";
import { useParams } from "next/navigation";
import { motion } from "motion/react";
import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen, ArrowUpDown, ArrowDown, ArrowUp, ChevronRight, KeyRound } from "lucide-react";
import Link from "next/link";
import { StatsCard } from "@/components/StatsCard";
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { TrendChart } from "@/components/charts/TrendChart";
import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils";
import { sortDetailBreakdown, type DetailBreakdownItem, type DetailBreakdownSortKey } from "@/lib/detail-sort";
import { getPrimaryModelNames, getSharePercent, getTokenDisplayName, getTokenRowKey, shouldShowTokenTab, sortTokenBreakdown, type TokenBreakdownItem } from "@/lib/token-breakdown";
import { getDetailStats, type DetailStatKey } from "@/lib/detail-stats";
import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n";
interface DetailData {
calls: number; prompt_tokens: number; completion_tokens: number;
cache_creation_tokens: number; cache_read_tokens: number;
total_tokens: number; quota: number; display_name?: string;
models?: DetailBreakdownItem[];
users?: DetailBreakdownItem[];
tokens?: TokenBreakdownItem[];
channel_name?: string;
}
type BreakdownTab = "models" | "tokens";
export default function DetailPage() {
const { t } = useI18n();
const params = useParams();
const segments = Array.isArray(params.slug) ? params.slug : [];
const type = segments[0] || "";
const id = segments[1] || "";
const decodedId = decodeURIComponent(id);
const { getEffectiveRange } = useTimeRange();
const [data, setData] = useState<DetailData | null>(null);
const [trends, setTrends] = useState<{ date: string; calls: number; total_tokens: number; prompt_tokens: number; completion_tokens: number }[]>([]);
const [loading, setLoading] = useState(true);
const [breakdownSortKey, setBreakdownSortKey] = useState<DetailBreakdownSortKey>("total_tokens");
const [breakdownSortAsc, setBreakdownSortAsc] = useState(false);
const [breakdownTab, setBreakdownTab] = useState<BreakdownTab>("models");
const [expandedTokens, setExpandedTokens] = useState<Set<string>>(() => new Set());
const fetchData = useCallback(async () => {
startTransition(() => setLoading(true));
const { start, end } = getEffectiveRange();
const tp = { start, end };
const [detail, tr] = await Promise.all([
fetch(buildQuery(`/api/detail/${type}/${encodeURIComponent(decodedId)}`, tp)).then(r => r.json()),
fetch(buildQuery("/api/trends", { ...tp, granularity: "day",
...(type === "user" ? { username: decodedId } : {}),
...(type === "model" ? { model: decodedId } : {}),
...(type === "channel" ? { channel_id: decodedId } : {}),
})).then(r => r.json()),
]);
startTransition(() => { setData(detail); setTrends(tr); setLoading(false); });
}, [type, decodedId, getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]);
const title = type === "channel" ? (data?.channel_name || decodedId) : (data?.display_name || decodedId);
const typeLabel = { user: t("detail.user"), model: t("detail.model"), channel: t("detail.channel") }[type] || type;
const modelBreakdownItems = data?.models || data?.users || [];
const tokenBreakdownItems = data?.tokens || [];
const showTokenTab = shouldShowTokenTab(type);
const activeBreakdownTab = showTokenTab ? breakdownTab : "models";
const sortedBreakdownItems = sortDetailBreakdown(modelBreakdownItems, breakdownSortKey, breakdownSortAsc);
const sortedTokenItems = sortTokenBreakdown(tokenBreakdownItems, breakdownSortKey, breakdownSortAsc);
const breakdownLabel = data?.models ? t("detail.modelDist") : t("detail.userDist");
function handleBreakdownSort(key: DetailBreakdownSortKey) {
if (breakdownSortKey === key) setBreakdownSortAsc(!breakdownSortAsc);
else { setBreakdownSortKey(key); setBreakdownSortAsc(false); }
}
function toggleToken(tokenName: string) {
setExpandedTokens((current) => {
const next = new Set(current);
if (next.has(tokenName)) next.delete(tokenName);
else next.add(tokenName);
return next;
});
}
const renderBreakdownSortIcon = (col: DetailBreakdownSortKey) => {
if (breakdownSortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.4 }} />;
return breakdownSortAsc
? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} />
: <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />;
};
const breakdownColumns: { key: DetailBreakdownSortKey | null; label: string; align: "left" | "right" }[] = [
{ key: null, label: t("th.name"), align: "left" },
{ key: "calls", label: t("th.calls"), align: "right" },
{ key: "total_tokens", label: t("th.totalToken"), align: "right" },
{ key: "quota", label: t("th.cost"), align: "right" },
];
const statIcons: Record<DetailStatKey, typeof Hash> = {
calls: Hash,
total_tokens: Zap,
quota: DollarSign,
prompt_tokens: MessageSquare,
completion_tokens: MessageSquare,
cache_creation_tokens: DatabaseZap,
cache_read_tokens: BookOpen,
};
function renderEmptyBreakdown() {
return (
<div className="px-5 py-10 text-center text-sm" style={{ color: "var(--text-muted)" }}>
{t("common.noData")}
</div>
);
}
function renderTokenDistribution() {
if (sortedTokenItems.length === 0) return renderEmptyBreakdown();
return (
<table className="w-full text-sm mt-3">
<thead>
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.name")}</th>
{breakdownColumns.slice(1).map((col) => {
const key = col.key;
return (
<th key={col.label}
className={`px-5 py-3 text-xs font-medium uppercase tracking-wider ${col.align === "right" ? "text-right" : "text-left"} ${key ? "cursor-pointer select-none transition-colors hover:opacity-80" : ""}`}
style={{ color: key && breakdownSortKey === key ? "var(--text-accent)" : "var(--text-muted)" }}
onClick={key ? () => handleBreakdownSort(key) : undefined}
>
{key ? (
<span className="inline-flex items-center gap-1 justify-end">
{col.label} {renderBreakdownSortIcon(key)}
</span>
) : col.label}
</th>
);
})}
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("common.share")}</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("detail.primaryModels")}</th>
</tr>
</thead>
<tbody>
{sortedTokenItems.map((item) => {
const isExpanded = expandedTokens.has(item.name);
const tokenLabel = getTokenDisplayName(item.name, t("detail.unnamedToken"));
const userShare = getSharePercent(item.total_tokens, data?.total_tokens ?? 0);
return (
<Fragment key={getTokenRowKey(item.name)}>
<tr className="row-glow cursor-pointer transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }} onClick={() => toggleToken(item.name)}>
<td className="px-5 py-3" style={{ color: "var(--text-accent)", opacity: 0.85 }}>
<span className="inline-flex items-center gap-2">
<button
type="button"
aria-label={isExpanded ? t("detail.collapseToken") : t("detail.expandToken")}
className="inline-flex h-6 w-6 items-center justify-center rounded-md transition-colors"
style={{ color: "var(--text-muted)", background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}
onClick={(event) => { event.stopPropagation(); toggleToken(item.name); }}
>
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isExpanded ? "rotate-90" : ""}`} />
</button>
<KeyRound className="h-3.5 w-3.5" style={{ color: "var(--accent)", opacity: 0.55 }} />
<span>{tokenLabel}</span>
</span>
</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota / 500000)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{userShare.toFixed(1)}%</td>
<td className="px-5 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{getPrimaryModelNames(item.models) || t("common.noData")}</td>
</tr>
{isExpanded && (
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
<td colSpan={6} className="px-5 pb-4 pt-0">
<div className="ml-8 overflow-hidden rounded-lg" style={{ border: "1px solid var(--surface-border)", background: "var(--row-hover)" }}>
<table className="w-full text-xs">
<thead>
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
<th className="px-4 py-2 text-left font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("detail.model")}</th>
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.calls")}</th>
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.totalToken")}</th>
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.cost")}</th>
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("common.share")}</th>
</tr>
</thead>
<tbody>
{item.models.map((model) => {
const modelShare = getSharePercent(model.total_tokens, item.total_tokens);
return (
<tr key={`${item.name}:${model.name}`} style={{ borderBottom: "1px solid var(--surface-border)" }}>
<td className="px-4 py-2 font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-accent)", opacity: 0.75 }}>{model.name}</td>
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-secondary)" }}>{formatNumber(model.calls)}</td>
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(model.total_tokens)}</td>
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-secondary)" }}>{formatUSD(model.quota / 500000)}</td>
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>{modelShare.toFixed(1)}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
);
}
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 }}>
<Link href="/rankings" className="inline-flex items-center gap-1 text-xs transition-colors mb-2" style={{ color: "var(--text-muted)" }}>
<ArrowLeft className="h-3 w-3" /> {t("common.backToRankings")}
</Link>
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-wider px-2 py-0.5 rounded" style={{ color: "var(--text-muted)", background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}>{typeLabel}</span>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-primary)" }}>{title}</h1>
</div>
</motion.div>
<TimeRangeSelector />
</div>
{loading ? (
<div className="flex h-64 items-center justify-center"><div className="h-6 w-6 animate-spin rounded-full spinner" /></div>
) : data ? (
<>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{getDetailStats(data).map((stat, i) => (
<StatsCard
key={stat.key}
title={t(stat.labelKey)}
value={stat.value}
format={stat.format}
icon={statIcons[stat.key]}
delay={i * 0.05}
/>
))}
</div>
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="glass p-5">
<h2 className="mb-4 text-sm font-semibold" style={{ color: "var(--text-primary)" }}>{t("detail.trend")}</h2>
<TrendChart data={trends} />
</motion.div>
{(modelBreakdownItems.length > 0 || showTokenTab) && (
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="glass overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 pt-5">
<h2 className="text-xs font-medium uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>
{activeBreakdownTab === "tokens" ? t("detail.tokenDist") : breakdownLabel}
</h2>
{showTokenTab && (
<div className="flex gap-1 rounded-lg p-1" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
{([
["models", t("detail.modelDist")],
["tokens", t("detail.tokenDist")],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setBreakdownTab(value)}
className="rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
style={{
background: activeBreakdownTab === value ? "var(--btn-active-bg)" : "transparent",
color: activeBreakdownTab === value ? "var(--text-accent)" : "var(--text-muted)",
border: activeBreakdownTab === value ? "1px solid var(--surface-border)" : "1px solid transparent",
}}
>
{label}
</button>
))}
</div>
)}
</div>
{activeBreakdownTab === "tokens" ? (
renderTokenDistribution()
) : sortedBreakdownItems.length > 0 ? (
<table className="w-full text-sm mt-3">
<thead>
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
{breakdownColumns.map((col) => {
const key = col.key;
return (
<th key={col.label}
className={`px-5 py-3 text-xs font-medium uppercase tracking-wider ${col.align === "right" ? "text-right" : "text-left"} ${key ? "cursor-pointer select-none transition-colors hover:opacity-80" : ""}`}
style={{ color: key && breakdownSortKey === key ? "var(--text-accent)" : "var(--text-muted)" }}
onClick={key ? () => handleBreakdownSort(key) : undefined}
>
{key ? (
<span className={`inline-flex items-center gap-1 ${col.align === "right" ? "justify-end" : ""}`}>
{col.label} {renderBreakdownSortIcon(key)}
</span>
) : col.label}
</th>
);
})}
</tr>
</thead>
<tbody>
{sortedBreakdownItems.map((item) => (
<tr key={item.name} className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }}>
<td className="px-5 py-3" style={{ color: "var(--text-accent)", opacity: 0.8 }}>{item.name}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota / 500000)}</td>
</tr>
))}
</tbody>
</table>
) : renderEmptyBreakdown()}
</motion.div>
)}
</>
) : null}
</div>
);
}