Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
435 lines
20 KiB
TypeScript
435 lines
20 KiB
TypeScript
import { Icon } from "./Icon";
|
||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||
import { useFetch } from "../hooks";
|
||
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n";
|
||
import { navigate } from "../router";
|
||
import { useStore } from "../store";
|
||
import type { User } from "../types";
|
||
import type { Aggregate } from "../types";
|
||
import { Heatmap, Legend, SERIES, StackedBars, type Series } from "./Charts";
|
||
import { BudgetPanel } from "./Budget";
|
||
|
||
type Row<K extends string> = Aggregate & Record<K, string>;
|
||
|
||
interface StatsData {
|
||
total: Aggregate;
|
||
by_day: Row<"day">[];
|
||
by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[];
|
||
by_month: Row<"month">[];
|
||
/** First recorded request (any range): the years to choose from. */
|
||
first_ts: number | null;
|
||
by_model: Row<"model">[];
|
||
by_provider: Row<"provider">[];
|
||
by_agent: Row<"agent">[];
|
||
by_user: Row<"user">[];
|
||
/** Last 14 days: slot = "YYYY-MM-DD HH" (local time). */
|
||
heatmap: (Aggregate & { slot: string })[];
|
||
top_sessions: (Aggregate & { session_id: string; title: string })[];
|
||
currency: string;
|
||
from: number;
|
||
to: number;
|
||
}
|
||
|
||
const RANGES: { key: string; days: number; label: Key }[] = [
|
||
{ key: "7", days: 7, label: "stats.range7" },
|
||
{ key: "30", days: 30, label: "stats.range30" },
|
||
{ key: "90", days: 90, label: "stats.range90" },
|
||
{ key: "365", days: 365, label: "stats.range365" },
|
||
{ key: "all", days: 0, label: "stats.rangeAll" },
|
||
];
|
||
|
||
type HeatMetric = "requests" | "tokens" | "cost";
|
||
|
||
/** All months (YYYY-MM, local time) from the month of `fromMs` to the month of `toMs`. */
|
||
function monthRange(fromMs: number, toMs: number): string[] {
|
||
const out: string[] = [];
|
||
const d = new Date(fromMs);
|
||
d.setDate(1);
|
||
d.setHours(0, 0, 0, 0);
|
||
for (; d.getTime() <= toMs && out.length < 600; d.setMonth(d.getMonth() + 1)) out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||
return out;
|
||
}
|
||
|
||
function dayKey(ms: number) {
|
||
const d = new Date(ms);
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
/** All days between from and to (local time), so empty days show as gaps. */
|
||
function dayRange(fromMs: number, toMs: number, firstDataDay?: string): string[] {
|
||
const start = new Date(firstDataDay && fromMs === 0 ? `${firstDataDay}T00:00:00` : fromMs);
|
||
start.setHours(0, 0, 0, 0);
|
||
const out: string[] = [];
|
||
for (let d = new Date(start); d.getTime() <= toMs && out.length < 800; d.setDate(d.getDate() + 1)) out.push(dayKey(d.getTime()));
|
||
return out;
|
||
}
|
||
|
||
const FOLD_KEY = "codecraft.statsFolded";
|
||
|
||
function foldedSet(): Set<string> {
|
||
try {
|
||
return new Set(JSON.parse(localStorage.getItem(FOLD_KEY) ?? "[]"));
|
||
} catch {
|
||
return new Set();
|
||
}
|
||
}
|
||
|
||
/** Panel with a title that folds the content; the state is remembered per device. */
|
||
function Fold({ id, title, children }: { id: string; title: React.ReactNode; children: React.ReactNode }) {
|
||
const [closed, setClosed] = useState(() => foldedSet().has(id));
|
||
const toggle = () => {
|
||
const next = !closed;
|
||
setClosed(next);
|
||
try {
|
||
const set = foldedSet();
|
||
if (next) set.add(id);
|
||
else set.delete(id);
|
||
localStorage.setItem(FOLD_KEY, JSON.stringify([...set]));
|
||
} catch {
|
||
/* storage unavailable */
|
||
}
|
||
};
|
||
return (
|
||
<div className={`panel fold${closed ? " closed" : ""}`}>
|
||
<h3>
|
||
<button className="fold-toggle" aria-expanded={!closed} onClick={toggle}>
|
||
<span className="fold-caret" aria-hidden="true" />
|
||
{title}
|
||
</button>
|
||
</h3>
|
||
{!closed && children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||
return (
|
||
<div className="kpi">
|
||
<div className="k-label">{label}</div>
|
||
<div className="k-value">{value}</div>
|
||
{sub && <div className="k-sub">{sub}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Effective cost per 1M tokens (input incl. cache + output). */
|
||
function perMillion(r: Aggregate, currency: string) {
|
||
const tokens = r.input_tokens + r.cache_read_tokens + r.cache_write_tokens + r.output_tokens;
|
||
return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–";
|
||
}
|
||
|
||
function AggTable({
|
||
rows,
|
||
keyName,
|
||
label,
|
||
currency,
|
||
link,
|
||
group,
|
||
}: {
|
||
rows: (Aggregate & Record<string, any>)[];
|
||
keyName: string;
|
||
label: string;
|
||
currency: string;
|
||
link?: (r: any) => void;
|
||
/** Group key of a row (e.g. the provider of a model): groups fold. */
|
||
group?: (r: any) => string;
|
||
}) {
|
||
const t = useT();
|
||
const [shut, setShut] = useState<Set<string>>(new Set());
|
||
const groups = group ? [...new Set(rows.map(group))] : [];
|
||
const row = (r: (typeof rows)[number]) => (
|
||
<tr key={r[keyName]}>
|
||
<td className={group ? "fold-indent" : undefined}>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td>
|
||
<td className="num">{fmtNumber(r.requests)}</td>
|
||
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
|
||
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
|
||
<td className="num">{fmtCompact(r.output_tokens)}</td>
|
||
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
|
||
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
|
||
<td className="num">{fmtNumber(r.prefill_tps)}</td>
|
||
<td className="num">{fmtCost(r.cost, currency)}</td>
|
||
<td className="num">{perMillion(r, currency)}</td>
|
||
</tr>
|
||
);
|
||
return (
|
||
<div style={{ overflowX: "auto" }}>
|
||
<table className="grid">
|
||
<thead>
|
||
<tr>
|
||
<th>{label}</th>
|
||
<th className="num">{t("stats.requests")}</th>
|
||
<th className="num">{t("stats.input")}</th>
|
||
<th className="num">{t("stats.cacheRead")}</th>
|
||
<th className="num">{t("stats.output")}</th>
|
||
<th className="num">{t("stats.ttft")}</th>
|
||
<th className="num">{t("stats.outputSpeed")}</th>
|
||
<th className="num">{t("stats.prefillSpeed")}</th>
|
||
<th className="num">{t("stats.cost")}</th>
|
||
<th className="num" title={t("stats.perMillionHint")}>{t("stats.perMillion")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{group
|
||
? groups.map((g) => {
|
||
const members = rows.filter((r) => group(r) === g);
|
||
const open = !shut.has(g);
|
||
return (
|
||
<Fragment key={g}>
|
||
<tr className="fold-group">
|
||
<td>
|
||
<button
|
||
className="fold-toggle"
|
||
aria-expanded={open}
|
||
onClick={() => setShut((x) => (x.has(g) ? new Set([...x].filter((k) => k !== g)) : new Set(x).add(g)))}
|
||
>
|
||
<span className="fold-caret" aria-hidden="true" />
|
||
{g} <span className="hint">({members.length})</span>
|
||
</button>
|
||
</td>
|
||
<td className="num">{fmtNumber(members.reduce((a, r) => a + r.requests, 0))}</td>
|
||
<td colSpan={6} />
|
||
<td className="num">{fmtCost(members.reduce((a, r) => a + r.cost, 0), currency)}</td>
|
||
<td />
|
||
</tr>
|
||
{open && members.map(row)}
|
||
</Fragment>
|
||
);
|
||
})
|
||
: rows.map(row)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const HEAT_DAYS = 14;
|
||
|
||
export function Stats({ onMenu }: { onMenu: () => void }) {
|
||
const t = useT();
|
||
const [range, setRange] = useState("30");
|
||
const [heat, setHeat] = useState<HeatMetric>("requests");
|
||
const [tableView, setTableView] = useState(false);
|
||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||
const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? [];
|
||
const [userFilter, setUserFilter] = useState("");
|
||
const tz = -new Date().getTimezoneOffset();
|
||
// Stable per range: rounded to the minute so the URL doesn't change on every render.
|
||
const now = Math.floor(Date.now() / 60000) * 60000 + 60000;
|
||
// "y2025": that calendar year (local time); otherwise a rolling window.
|
||
const year = /^y(\d{4})$/.test(range) ? Number(range.slice(1)) : 0;
|
||
const days = RANGES.find((r) => r.key === range)?.days ?? 0;
|
||
const from = year ? new Date(year, 0, 1).getTime() : days ? now - days * 86400000 : 0;
|
||
const to = year ? Math.min(now, new Date(year + 1, 0, 1).getTime()) : now;
|
||
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${to}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
|
||
/** Download of what is shown: all tables as XLSX, or one table as CSV. */
|
||
const exportAs = (what: string) => {
|
||
if (!what) return;
|
||
const q = `from=${from}&to=${to}&tz=${tz}&lang=${getLang()}${userFilter ? `&user=${userFilter}` : ""}`;
|
||
const a = document.createElement("a");
|
||
a.href = `/api/stats/export?${q}&format=${what === "xlsx" ? "xlsx" : "csv&table=" + what.slice(4)}`;
|
||
a.download = "";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
};
|
||
// Years since the first recorded request (the data survives indefinitely).
|
||
const [firstYear, setFirstYear] = useState(new Date().getFullYear());
|
||
useEffect(() => {
|
||
if (data?.first_ts) setFirstYear(new Date(data.first_ts).getFullYear());
|
||
}, [data?.first_ts]);
|
||
const years = Array.from({ length: new Date().getFullYear() - firstYear + 1 }, (_, i) => new Date().getFullYear() - i);
|
||
const spanDays = ((data?.to ?? now) - (data?.from || data?.first_ts || now)) / 86400000;
|
||
// Long ranges: one bar per month instead of one per day.
|
||
const monthly = spanDays > 120;
|
||
|
||
const view = useMemo(() => {
|
||
if (!data) return undefined;
|
||
const bucket = (day: string) => (monthly ? day.slice(0, 7) : day);
|
||
const cats = monthly ? monthRange(data.from || data.first_ts || data.to, data.to) : dayRange(data.from, data.to, data.by_day[0]?.day);
|
||
// Up to 7 models by cost get their own color; the rest fold into "Other".
|
||
const ranked = [...data.by_model].sort((a, b) => b.cost - a.cost || b.requests - a.requests);
|
||
const named = ranked.slice(0, ranked.length > 8 ? 7 : 8).map((m) => m.model);
|
||
const other = t("stats.other");
|
||
const modelSeries: Series[] = [...named, ...(ranked.length > 8 ? [other] : [])].map((m, i) => ({ key: m, label: m, color: SERIES[i] }));
|
||
const costByDay = cats.map(() => ({}) as Record<string, number>);
|
||
const idx = new Map(cats.map((c, i) => [c, i]));
|
||
for (const r of data.by_day_model) {
|
||
const i = idx.get(bucket(r.day));
|
||
if (i === undefined) continue;
|
||
const key = named.includes(r.model) ? r.model : other;
|
||
costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost;
|
||
}
|
||
const tokenSeries: Series[] = [
|
||
{ key: "input", label: t("stats.input"), color: SERIES[0] },
|
||
{ key: "cache", label: t("stats.cacheRead"), color: SERIES[1] },
|
||
{ key: "output", label: t("stats.output"), color: SERIES[2] },
|
||
];
|
||
const tokensByDay = cats.map((c): Record<string, number> => {
|
||
const d = monthly ? data.by_month.find((x) => x.month === c) : data.by_day.find((x) => x.day === c);
|
||
return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {};
|
||
});
|
||
// Rolling window: the last 14 days, today at the bottom.
|
||
const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
const heatDays = Array.from({ length: HEAT_DAYS }, (_, i) => {
|
||
const d = new Date();
|
||
d.setHours(12, 0, 0, 0);
|
||
d.setDate(d.getDate() - (HEAT_DAYS - 1 - i));
|
||
return iso(d);
|
||
});
|
||
const rowOf = new Map(heatDays.map((d, i) => [d, i]));
|
||
const cells = Array<number>(HEAT_DAYS * 24).fill(0);
|
||
for (const h of data.heatmap) {
|
||
const [day, hour] = h.slot.split(" ");
|
||
const row = rowOf.get(day);
|
||
if (row === undefined) continue;
|
||
cells[row * 24 + Number(hour)] =
|
||
heat === "requests" ? h.requests : heat === "cost" ? h.cost : h.input_tokens + h.cache_read_tokens + h.cache_write_tokens + h.output_tokens;
|
||
}
|
||
const wd = new Intl.DateTimeFormat(getLang(), { weekday: "short" });
|
||
const dm = new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "numeric" });
|
||
const full = new Intl.DateTimeFormat(getLang(), { weekday: "short", day: "numeric", month: "short" });
|
||
const heatRows = heatDays.map((d) => {
|
||
const date = new Date(`${d}T12:00:00`);
|
||
return { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) };
|
||
});
|
||
return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows };
|
||
}, [data, heat, t, monthly]);
|
||
|
||
const dayLabel = (d: string) =>
|
||
monthly
|
||
? new Intl.DateTimeFormat(getLang(), { month: "short", year: "2-digit" }).format(new Date(`${d}-01T00:00:00`))
|
||
: new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`));
|
||
const cur = data?.currency ?? "USD";
|
||
const tot = data?.total;
|
||
const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v));
|
||
|
||
return (
|
||
<>
|
||
<div className="topbar">
|
||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||
<Icon name="menu" />
|
||
</button>
|
||
<div className="grow">
|
||
<h1>{t("nav.stats")}</h1>
|
||
{data && (
|
||
<div className="path">
|
||
{data.from ? fmtDate(data.from, false) : t("stats.rangeAll")} – {fmtDate(data.to, false)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="mode-tabs" role="tablist">
|
||
{RANGES.map((r) => (
|
||
<button key={r.key} className={range === r.key ? "active" : ""} onClick={() => setRange(r.key)}>
|
||
{t(r.label)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{years.length > 1 && (
|
||
<select value={year ? range : ""} onChange={(e) => e.target.value && setRange(e.target.value)} aria-label={t("stats.year")} className={year ? "active" : ""}>
|
||
<option value="">{t("stats.year")}</option>
|
||
{years.map((y) => (
|
||
<option key={y} value={`y${y}`}>
|
||
{y}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
{isAdmin && (
|
||
<select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}>
|
||
<option value="">{t("stats.allUsers")}</option>
|
||
{users.map((u) => (
|
||
<option key={u.id} value={u.id}>
|
||
{u.username}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<select value="" onChange={(e) => exportAs(e.target.value)} aria-label={t("stats.export")} title={t("stats.exportHint")}>
|
||
<option value="">{t("stats.export")}</option>
|
||
<option value="xlsx">{t("stats.exportXlsx")}</option>
|
||
{(["day", "month", "model", "provider", "agent", "user", "session"] as const).map((k) => (
|
||
<option key={k} value={`csv:${k}`}>
|
||
{t("stats.exportCsv", { table: t(`stats.table.${k}` as Key) })}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button className="small" onClick={() => setTableView(!tableView)}>
|
||
{tableView ? t("stats.chartView") : t("stats.tableView")}
|
||
</button>
|
||
</div>
|
||
<div className="page">
|
||
<div className="page-inner">
|
||
{error && <div className="notice err">{error}</div>}
|
||
{!data && !error && <div className="empty">{t("common.loading")}</div>}
|
||
{isAdmin && <BudgetPanel />}
|
||
{data && tot && view && (
|
||
<>
|
||
<div className="kpis">
|
||
<Kpi label={t("stats.cost")} value={fmtCost(tot.cost, cur)} sub={t("stats.perRequest", { v: fmtCost(tot.requests ? tot.cost / tot.requests : 0, cur) })} />
|
||
<Kpi label={t("stats.requests")} value={fmtNumber(tot.requests)} sub={tot.errors ? t("stats.errors", { n: tot.errors }) : undefined} />
|
||
<Kpi label={t("stats.input")} value={fmtCompact(tot.input_tokens + tot.cache_read_tokens + tot.cache_write_tokens)} sub={`${t("stats.cacheRead")}: ${fmtCompact(tot.cache_read_tokens)}`} />
|
||
<Kpi label={t("stats.output")} value={fmtCompact(tot.output_tokens)} />
|
||
<Kpi label={t("stats.cacheHit")} value={fmtPercent(tot.cache_hit_rate)} />
|
||
<Kpi label={t("stats.ttft")} value={`${fmtNumber(tot.avg_ttft_ms)} ms`} sub={t("stats.ttftHint")} />
|
||
<Kpi label={t("stats.outputSpeed")} value={`${fmtNumber(tot.output_tps, 1)} tok/s`} />
|
||
<Kpi label={t("stats.prefillSpeed")} value={`${fmtCompact(tot.prefill_tps)} tok/s`} />
|
||
</div>
|
||
|
||
{tot.requests === 0 ? (
|
||
<div className="empty">{t("stats.noData")}</div>
|
||
) : tableView ? (
|
||
<Fold id="perDay" title={t("stats.perDay")}>
|
||
<AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} />
|
||
</Fold>
|
||
) : (
|
||
<>
|
||
<Fold id="costPerDay" title={t(monthly ? "stats.costPerMonth" : "stats.costPerDay")}>
|
||
<Legend series={view.modelSeries} />
|
||
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} />
|
||
</Fold>
|
||
<Fold id="tokensPerDay" title={t(monthly ? "stats.tokensPerMonth" : "stats.tokensPerDay")}>
|
||
<Legend series={view.tokenSeries} />
|
||
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} />
|
||
</Fold>
|
||
<Fold id="heatmap" title={t("stats.heatmap")}>
|
||
<div className="row" style={{ justifyContent: "flex-end", marginBottom: 6 }}>
|
||
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)} aria-label={t("stats.heatmap")}>
|
||
<option value="requests">{t("stats.requests")}</option>
|
||
<option value="tokens">{t("stats.tokens")}</option>
|
||
<option value="cost">{t("stats.cost")}</option>
|
||
</select>
|
||
</div>
|
||
<Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} />
|
||
</Fold>
|
||
</>
|
||
)}
|
||
|
||
{tot.requests > 0 && (
|
||
<>
|
||
<Fold id="byModel" title={t("stats.byModel")}>
|
||
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} group={(r) => String(r.model).split("/")[0]} />
|
||
</Fold>
|
||
<Fold id="byProvider" title={t("stats.byProvider")}>
|
||
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
|
||
</Fold>
|
||
<Fold id="byAgent" title={t("stats.byAgent")}>
|
||
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
||
</Fold>
|
||
{isAdmin && !userFilter && data.by_user.length > 1 && (
|
||
<Fold id="byUser" title={t("stats.byUser")}>
|
||
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
|
||
</Fold>
|
||
)}
|
||
<Fold id="topSessions" title={t("stats.topSessions")}>
|
||
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} />
|
||
</Fold>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|