Files
codecraft/web/src/components/Stats.tsx
T

435 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
);
}