Files
codecraft/web/src/components/Stats.tsx
T
Richard ZinkandClaude Opus 5.5 a0d0e707f2 Add Web Push, guided start screen, SVG icons, chunked uploads, rename
- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished
  notifications while no app window is visible; settings with test button
- Guided new-session flow: task, craft target (website/app/game/old
  software/fresh), clean room or deep, description, model, folder
  (new, existing or cloned git repository); originals upload to original/
- Craft phase bar (craft_phase tool) for study/specify/build/check/ready
- Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB)
- All UI glyphs and emojis replaced by themeable SVG icons
- Rename dialog with manual name or model-generated suggestion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:03:26 +01:00

285 lines
13 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 { useMemo, useState } from "react";
import { useFetch } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, weekdayNames, 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_model: Row<"model">[];
by_provider: Row<"provider">[];
by_agent: Row<"agent">[];
by_user: Row<"user">[];
heatmap: (Aggregate & { slot: number })[];
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";
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;
}
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 }: { rows: (Aggregate & Record<string, any>)[]; keyName: string; label: string; currency: string; link?: (r: any) => void }) {
const t = useT();
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>
{rows.map((r) => (
<tr key={r[keyName]}>
<td>{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>
))}
</tbody>
</table>
</div>
);
}
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 days = RANGES.find((r) => r.key === range)!.days;
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;
const from = days ? now - days * 86400000 : 0;
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
const view = useMemo(() => {
if (!data) return undefined;
const cats = 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(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 = 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 } : {};
});
const cells = Array<number>(168).fill(0);
for (const h of data.heatmap) {
cells[h.slot] =
heat === "requests" ? h.requests : heat === "cost" ? h.cost : h.input_tokens + h.cache_read_tokens + h.cache_write_tokens + h.output_tokens;
}
return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells };
}, [data, heat, t]);
const dayLabel = (d: string) =>
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>
{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>
)}
<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 ? (
<div className="panel">
<h3>{t("stats.perDay")}</h3>
<AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} />
</div>
) : (
<>
<div className="panel">
<h3>{t("stats.costPerDay")}</h3>
<Legend series={view.modelSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} />
</div>
<div className="panel">
<h3>{t("stats.tokensPerDay")}</h3>
<Legend series={view.tokenSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} />
</div>
<div className="panel">
<h3>
{t("stats.heatmap")}
<span className="spacer" />
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)}>
<option value="requests">{t("stats.requests")}</option>
<option value="tokens">{t("stats.tokens")}</option>
<option value="cost">{t("stats.cost")}</option>
</select>
</h3>
<Heatmap cells={view.cells} weekdays={weekdayNames()} format={heatFormat} />
</div>
</>
)}
{tot.requests > 0 && (
<>
<div className="panel">
<h3>{t("stats.byModel")}</h3>
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} />
</div>
<div className="two-col">
<div className="panel">
<h3>{t("stats.byProvider")}</h3>
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
</div>
<div className="panel">
<h3>{t("stats.byAgent")}</h3>
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
</div>
</div>
{isAdmin && !userFilter && data.by_user.length > 1 && (
<div className="panel">
<h3>{t("stats.byUser")}</h3>
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
</div>
)}
<div className="panel">
<h3>{t("stats.topSessions")}</h3>
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => navigate(`/s/${r.session_id}`)} />
</div>
</>
)}
</>
)}
</div>
</div>
</>
);
}