Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,251 @@
|
||||
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 type { Aggregate } from "../types";
|
||||
import { Heatmap, Legend, SERIES, StackedBars, type Series } from "./Charts";
|
||||
|
||||
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">[];
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</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>
|
||||
</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 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}`);
|
||||
|
||||
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")}>
|
||||
☰
|
||||
</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>
|
||||
<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>}
|
||||
{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={`${fmtNumber(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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user