burnrate-cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -2
- package/dist/aggregate.js +213 -0
- package/dist/cli.js +387 -0
- package/dist/config.js +23 -0
- package/dist/dashboard/app.js +440 -0
- package/dist/dashboard/favicon.svg +1 -0
- package/dist/dashboard/index.html +131 -0
- package/dist/dashboard/styles.css +200 -0
- package/dist/demo.js +123 -0
- package/dist/format.js +47 -0
- package/dist/notify.js +32 -0
- package/dist/parsers/claude.js +80 -0
- package/dist/parsers/codex.js +93 -0
- package/dist/paths.js +26 -0
- package/dist/pricing.js +84 -0
- package/dist/server.js +99 -0
- package/dist/store.js +216 -0
- package/dist/types.js +1 -0
- package/package.json +47 -4
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// Burnrate dashboard — vanilla JS, no dependencies. All labels inserted with textContent.
|
|
2
|
+
const $ = (id) => document.getElementById(id);
|
|
3
|
+
const SVGNS = "http://www.w3.org/2000/svg";
|
|
4
|
+
const AGENTS = { "claude-code": { name: "Claude Code", color: "var(--series-1)" }, codex: { name: "Codex", color: "var(--series-2)" } };
|
|
5
|
+
const agentInfo = (a) => AGENTS[a] ?? { name: a, color: "var(--series-3)" };
|
|
6
|
+
|
|
7
|
+
let days = 30;
|
|
8
|
+
try { days = Number(localStorage.getItem("burnrate.days")) || 30; } catch {}
|
|
9
|
+
let data = null;
|
|
10
|
+
let showTable = false;
|
|
11
|
+
|
|
12
|
+
// ───────── formatting ─────────
|
|
13
|
+
const usd = (n, precise = false) => {
|
|
14
|
+
if (n == null || !Number.isFinite(n)) return "—";
|
|
15
|
+
if (!precise && Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}K`;
|
|
16
|
+
if (n > 0 && n < 0.01) return "<$0.01";
|
|
17
|
+
return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
18
|
+
};
|
|
19
|
+
const compactUsd = (n) => (n >= 1000 ? `$${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}K` : n >= 100 || n === 0 ? `$${Math.round(n)}` : `$${n.toFixed(n < 10 ? 2 : 1).replace(/\.?0+$/, "")}`);
|
|
20
|
+
const tok = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(Math.round(n)));
|
|
21
|
+
const clock = (ts) => new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
22
|
+
const dur = (ms) => {
|
|
23
|
+
const m = Math.max(0, Math.round(ms / 60000));
|
|
24
|
+
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ""}`;
|
|
25
|
+
};
|
|
26
|
+
const when = (ts) => {
|
|
27
|
+
const d = new Date(ts), now = new Date();
|
|
28
|
+
if (d.toDateString() === now.toDateString()) return `Today ${clock(ts)}`;
|
|
29
|
+
const y = new Date(now); y.setDate(now.getDate() - 1);
|
|
30
|
+
if (d.toDateString() === y.toDateString()) return `Yesterday ${clock(ts)}`;
|
|
31
|
+
return d.toLocaleDateString([], { day: "numeric", month: "short" }) + " " + clock(ts);
|
|
32
|
+
};
|
|
33
|
+
const shortProject = (p) => (p ? p.split(/[\\/]/).filter(Boolean).slice(-2).join("/") || p : "(unknown)");
|
|
34
|
+
const dayLabel = (iso, long = false) => {
|
|
35
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
36
|
+
return new Date(y, m - 1, d).toLocaleDateString([], long ? { weekday: "short", day: "numeric", month: "short" } : { day: "numeric", month: "short" });
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ───────── DOM helpers ─────────
|
|
40
|
+
function h(tag, attrs = {}, ...children) {
|
|
41
|
+
const el = document.createElement(tag);
|
|
42
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
43
|
+
if (v == null || v === false) continue;
|
|
44
|
+
if (k === "class") el.className = v;
|
|
45
|
+
else if (k === "style") Object.assign(el.style, v);
|
|
46
|
+
else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
|
|
47
|
+
else el.setAttribute(k, v === true ? "" : v);
|
|
48
|
+
}
|
|
49
|
+
for (const c of children.flat()) if (c != null && c !== false) el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
50
|
+
return el;
|
|
51
|
+
}
|
|
52
|
+
function s(tag, attrs = {}, ...children) {
|
|
53
|
+
const el = document.createElementNS(SVGNS, tag);
|
|
54
|
+
for (const [k, v] of Object.entries(attrs)) if (v != null) el.setAttribute(k, v);
|
|
55
|
+
for (const c of children.flat()) if (c) el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
56
|
+
return el;
|
|
57
|
+
}
|
|
58
|
+
const setText = (id, t) => ($(id).textContent = t);
|
|
59
|
+
|
|
60
|
+
// ───────── tooltip ─────────
|
|
61
|
+
const tip = $("tooltip");
|
|
62
|
+
function showTip(evt, title, rows) {
|
|
63
|
+
tip.replaceChildren(
|
|
64
|
+
h("div", { class: "tt-title" }, title),
|
|
65
|
+
...rows.map((r) =>
|
|
66
|
+
h("div", { class: "tt-row" }, r.color ? h("span", { class: "tt-key", style: { background: r.color } }) : null, h("span", { class: "tt-val" }, r.value), h("span", { class: "tt-name" }, r.name ?? "")),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
tip.hidden = false;
|
|
70
|
+
const rect = evt.currentTarget?.getBoundingClientRect?.() ?? { left: evt.clientX, top: evt.clientY, width: 0 };
|
|
71
|
+
const x = evt.clientX ?? rect.left + rect.width / 2;
|
|
72
|
+
const y = evt.clientY ?? rect.top;
|
|
73
|
+
const tw = tip.offsetWidth, th = tip.offsetHeight;
|
|
74
|
+
tip.style.left = `${Math.min(window.innerWidth - tw - 8, Math.max(8, x + 14))}px`;
|
|
75
|
+
tip.style.top = `${Math.max(8, y - th - 12)}px`;
|
|
76
|
+
}
|
|
77
|
+
const hideTip = () => (tip.hidden = true);
|
|
78
|
+
|
|
79
|
+
// ───────── scales ─────────
|
|
80
|
+
function niceMax(v, ticks = 4) {
|
|
81
|
+
if (v <= 0) return { max: 1, step: 0.25 };
|
|
82
|
+
const raw = v / ticks;
|
|
83
|
+
const mag = 10 ** Math.floor(Math.log10(raw));
|
|
84
|
+
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw);
|
|
85
|
+
return { max: step * Math.ceil(v / step), step };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ───────── charts ─────────
|
|
89
|
+
function dailyChart(el, daily) {
|
|
90
|
+
const W = Math.max(el.clientWidth, 320), H = 220, P = { l: 48, r: 8, t: 10, b: 26 };
|
|
91
|
+
const agents = Object.keys(AGENTS).filter((a) => daily.some((d) => d.byAgent[a]));
|
|
92
|
+
for (const d of daily) for (const a of Object.keys(d.byAgent)) if (!agents.includes(a)) agents.push(a);
|
|
93
|
+
const { max, step } = niceMax(Math.max(...daily.map((d) => d.total), 0));
|
|
94
|
+
const iw = W - P.l - P.r, ih = H - P.t - P.b;
|
|
95
|
+
const band = iw / daily.length;
|
|
96
|
+
const bw = Math.max(2, Math.min(24, band * 0.62));
|
|
97
|
+
const y = (v) => P.t + ih - (v / max) * ih;
|
|
98
|
+
const svg = s("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": `Daily spend, last ${daily.length} days` });
|
|
99
|
+
const grid = s("g", { class: "grid" });
|
|
100
|
+
for (let v = 0; v <= max + 1e-9; v += step) {
|
|
101
|
+
grid.append(s("line", { x1: P.l, x2: W - P.r, y1: y(v), y2: y(v) }));
|
|
102
|
+
grid.append(s("text", { class: "axis-text", x: P.l - 8, y: y(v) + 4, "text-anchor": "end" }, compactUsd(v)));
|
|
103
|
+
}
|
|
104
|
+
svg.append(grid);
|
|
105
|
+
const every = Math.ceil(daily.length / Math.floor(iw / 64));
|
|
106
|
+
daily.forEach((d, i) => {
|
|
107
|
+
const cx = P.l + band * i + band / 2;
|
|
108
|
+
const g = s("g", { class: "col", tabindex: "0", "aria-label": `${dayLabel(d.date, true)}: ${usd(d.total)}` });
|
|
109
|
+
let acc = 0;
|
|
110
|
+
const segs = agents.filter((a) => d.byAgent[a] > 0);
|
|
111
|
+
segs.forEach((a, si) => {
|
|
112
|
+
const v = d.byAgent[a];
|
|
113
|
+
const y0 = y(acc), y1 = y(acc + v);
|
|
114
|
+
acc += v;
|
|
115
|
+
const gap = si > 0 ? 2 : 0; // 2px surface gap between stacked segments
|
|
116
|
+
const top = y1, bottom = y0 - gap;
|
|
117
|
+
const hgt = Math.max(0, bottom - top);
|
|
118
|
+
if (hgt <= 0) return;
|
|
119
|
+
const r = si === segs.length - 1 ? Math.min(4, hgt, bw / 2) : 0; // rounded data-end only
|
|
120
|
+
const x0 = cx - bw / 2, x1 = cx + bw / 2;
|
|
121
|
+
const path = `M${x0},${bottom} L${x0},${top + r} Q${x0},${top} ${x0 + r},${top} L${x1 - r},${top} Q${x1},${top} ${x1},${top + r} L${x1},${bottom} Z`;
|
|
122
|
+
g.append(s("path", { d: path, fill: agentInfo(a).color, class: "bar" }));
|
|
123
|
+
});
|
|
124
|
+
g.append(s("rect", { class: "hit", x: P.l + band * i, y: P.t, width: band, height: ih }));
|
|
125
|
+
const rows = () => [
|
|
126
|
+
{ value: usd(d.total), name: "total" },
|
|
127
|
+
...agents.filter((a) => d.byAgent[a]).map((a) => ({ value: usd(d.byAgent[a]), name: agentInfo(a).name, color: agentInfo(a).color })),
|
|
128
|
+
];
|
|
129
|
+
g.addEventListener("pointermove", (e) => showTip(e, dayLabel(d.date, true), rows()));
|
|
130
|
+
g.addEventListener("pointerleave", hideTip);
|
|
131
|
+
g.addEventListener("focus", (e) => showTip(e, dayLabel(d.date, true), rows()));
|
|
132
|
+
g.addEventListener("blur", hideTip);
|
|
133
|
+
svg.append(g);
|
|
134
|
+
if (i % every === (daily.length - 1) % every) svg.append(s("text", { class: "axis-text", x: cx, y: H - 6, "text-anchor": "middle" }, dayLabel(d.date)));
|
|
135
|
+
});
|
|
136
|
+
svg.append(s("line", { class: "baseline", x1: P.l, x2: W - P.r, y1: y(0), y2: y(0) }));
|
|
137
|
+
el.replaceChildren(svg);
|
|
138
|
+
|
|
139
|
+
$("daily-legend").replaceChildren(
|
|
140
|
+
...(agents.length > 1 || agents.length === 1
|
|
141
|
+
? agents.map((a) => h("span", {}, h("span", { class: "key", style: { background: agentInfo(a).color } }), agentInfo(a).name))
|
|
142
|
+
: []),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function blockChart(el, b) {
|
|
147
|
+
const W = Math.max(el.clientWidth, 320), H = 180, P = { l: 48, r: 14, t: 14, b: 24 };
|
|
148
|
+
const iw = W - P.l - P.r, ih = H - P.t - P.b;
|
|
149
|
+
const top = Math.max(b.budget ?? 0, b.projected, b.cost, 0.01) * 1.08;
|
|
150
|
+
const { max, step } = niceMax(top);
|
|
151
|
+
const x = (t) => P.l + ((t - b.start) / (b.end - b.start)) * iw;
|
|
152
|
+
const y = (v) => P.t + ih - (v / max) * ih;
|
|
153
|
+
const svg = s("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": `Spend this block: ${usd(b.cost)}` });
|
|
154
|
+
const grid = s("g", { class: "grid" });
|
|
155
|
+
for (let v = 0; v <= max + 1e-9; v += step) {
|
|
156
|
+
grid.append(s("line", { x1: P.l, x2: W - P.r, y1: y(v), y2: y(v) }));
|
|
157
|
+
grid.append(s("text", { class: "axis-text", x: P.l - 8, y: y(v) + 4, "text-anchor": "end" }, compactUsd(v)));
|
|
158
|
+
}
|
|
159
|
+
svg.append(grid);
|
|
160
|
+
for (let t = b.start; t <= b.end; t += 3600_000) svg.append(s("text", { class: "axis-text", x: x(t), y: H - 6, "text-anchor": t === b.start ? "start" : t === b.end ? "end" : "middle" }, clock(t)));
|
|
161
|
+
|
|
162
|
+
const pts = b.series.map((p) => [x(p.t), y(p.cost)]);
|
|
163
|
+
if (pts.length) {
|
|
164
|
+
const line = pts.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" ");
|
|
165
|
+
const last = pts[pts.length - 1];
|
|
166
|
+
svg.append(s("path", { d: `${line} L${last[0]},${y(0)} L${pts[0][0]},${y(0)} Z`, fill: "var(--series-1)", "fill-opacity": "0.1" }));
|
|
167
|
+
if (b.active && b.remainingMs > 0) {
|
|
168
|
+
// Projection: same hue, recessive.
|
|
169
|
+
svg.append(s("path", { d: `M${last[0]},${last[1]} L${x(b.end)},${y(b.projected)}`, stroke: "var(--series-1)", "stroke-opacity": "0.35", "stroke-width": 2, fill: "none", "stroke-linecap": "round" }));
|
|
170
|
+
svg.append(s("text", { class: "axis-text", x: x(b.end) - 4, y: y(b.projected) - 8, "text-anchor": "end" }, `projected ${usd(b.projected)}`));
|
|
171
|
+
}
|
|
172
|
+
svg.append(s("path", { d: line, stroke: "var(--series-1)", "stroke-width": 2, fill: "none", "stroke-linejoin": "round", "stroke-linecap": "round" }));
|
|
173
|
+
svg.append(s("circle", { cx: last[0], cy: last[1], r: 6, fill: "var(--surface)" }));
|
|
174
|
+
svg.append(s("circle", { cx: last[0], cy: last[1], r: 4, fill: "var(--series-1)" }));
|
|
175
|
+
}
|
|
176
|
+
if (b.budget) {
|
|
177
|
+
svg.append(s("line", { class: "ref-line", x1: P.l, x2: W - P.r, y1: y(b.budget), y2: y(b.budget) }));
|
|
178
|
+
svg.append(s("text", { class: "ref-text", x: P.l + 6, y: y(b.budget) - 6 }, `${b.budgetSource === "learned" ? "Learned cap" : "Budget"} ${usd(b.budget)}`));
|
|
179
|
+
}
|
|
180
|
+
if (b.hitsBudgetAt) {
|
|
181
|
+
svg.append(s("circle", { cx: x(b.hitsBudgetAt), cy: y(b.budget), r: 4, fill: "var(--critical)" }));
|
|
182
|
+
}
|
|
183
|
+
svg.append(s("line", { class: "baseline", x1: P.l, x2: W - P.r, y1: y(0), y2: y(0) }));
|
|
184
|
+
|
|
185
|
+
// Crosshair + tooltip snapping to the nearest bucket.
|
|
186
|
+
const cross = s("line", { class: "crosshair", y1: P.t, y2: P.t + ih, visibility: "hidden" });
|
|
187
|
+
const hit = s("rect", { class: "hit", x: P.l, y: P.t, width: iw, height: ih });
|
|
188
|
+
svg.append(cross, hit);
|
|
189
|
+
hit.addEventListener("pointermove", (e) => {
|
|
190
|
+
const r = svg.getBoundingClientRect();
|
|
191
|
+
const px = ((e.clientX - r.left) / r.width) * W;
|
|
192
|
+
let best = b.series[0], bd = Infinity;
|
|
193
|
+
for (const p of b.series) {
|
|
194
|
+
const d = Math.abs(x(p.t) - px);
|
|
195
|
+
if (d < bd) { bd = d; best = p; }
|
|
196
|
+
}
|
|
197
|
+
if (!best) return;
|
|
198
|
+
cross.setAttribute("x1", x(best.t)); cross.setAttribute("x2", x(best.t)); cross.setAttribute("visibility", "visible");
|
|
199
|
+
showTip(e, clock(best.t), [{ value: usd(best.cost), name: "spent so far", color: "var(--series-1)" }]);
|
|
200
|
+
});
|
|
201
|
+
hit.addEventListener("pointerleave", () => { cross.setAttribute("visibility", "hidden"); hideTip(); });
|
|
202
|
+
el.replaceChildren(svg);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ───────── tables ─────────
|
|
206
|
+
function table(el, columns, rows, initialSort) {
|
|
207
|
+
let sort = initialSort;
|
|
208
|
+
const render = () => {
|
|
209
|
+
const sorted = [...rows].sort((a, b) => {
|
|
210
|
+
const c = columns.find((c) => c.key === sort.key);
|
|
211
|
+
const va = c.sortValue ? c.sortValue(a) : a[c.key], vb = c.sortValue ? c.sortValue(b) : b[c.key];
|
|
212
|
+
return (va > vb ? 1 : va < vb ? -1 : 0) * (sort.dir === "asc" ? 1 : -1);
|
|
213
|
+
});
|
|
214
|
+
const head = h("tr", {}, columns.map((c) =>
|
|
215
|
+
h("th", {
|
|
216
|
+
class: c.num ? "num" : "",
|
|
217
|
+
scope: "col",
|
|
218
|
+
"aria-sort": sort.key === c.key ? (sort.dir === "asc" ? "ascending" : "descending") : null,
|
|
219
|
+
tabindex: "0",
|
|
220
|
+
onclick: () => { sort = { key: c.key, dir: sort.key === c.key && sort.dir === "desc" ? "asc" : "desc" }; render(); },
|
|
221
|
+
onkeydown: (e) => { if (e.key === "Enter") e.currentTarget.click(); },
|
|
222
|
+
}, c.label),
|
|
223
|
+
));
|
|
224
|
+
const cell = (r, c) => h("td", { class: c.num ? "num" : "" }, c.render ? c.render(r) : String(r[c.key] ?? ""));
|
|
225
|
+
const body = sorted.map((r) => h("tr", {}, columns.map((c) => cell(r, c))));
|
|
226
|
+
el.replaceChildren(h("table", {}, h("thead", {}, head), h("tbody", {}, body)));
|
|
227
|
+
};
|
|
228
|
+
render();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function costCell(v, max) {
|
|
232
|
+
return h("div", { class: "cost-cell" }, h("span", { class: "inline-bar", style: { width: `${Math.max(2, (v / (max || 1)) * 60)}px` } }), usd(v));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ───────── render ─────────
|
|
236
|
+
function render(d) {
|
|
237
|
+
data = d;
|
|
238
|
+
const t = d.totals;
|
|
239
|
+
const empty = !d.eventCount;
|
|
240
|
+
$("empty").hidden = !empty;
|
|
241
|
+
document.querySelectorAll("main > :not(#empty)").forEach((n) => (n.hidden = empty));
|
|
242
|
+
setText("updated", `Updated ${clock(d.generatedAt)}`);
|
|
243
|
+
setText("footer-meta", `${d.eventCount.toLocaleString()} billed messages · ${d.timezone}`);
|
|
244
|
+
if (empty) return;
|
|
245
|
+
|
|
246
|
+
setText("today", usd(t.today));
|
|
247
|
+
const diff = t.today - t.yesterday;
|
|
248
|
+
$("today-sub").replaceChildren(
|
|
249
|
+
`${tok(t.tokensToday)} tokens · `,
|
|
250
|
+
t.yesterday > 0 ? h("span", { class: diff > 0 ? "delta-up" : "delta-down" }, `${diff > 0 ? "▲" : "▼"} ${usd(Math.abs(diff))} vs yesterday`) : "nothing yesterday",
|
|
251
|
+
);
|
|
252
|
+
setText("last7", usd(t.last7));
|
|
253
|
+
setText("last7-sub", `avg ${usd(t.last7 / 7)}/day`);
|
|
254
|
+
setText("mtd", usd(t.mtd));
|
|
255
|
+
setText("mtd-sub", `on track for ~${usd(t.monthProjection)}${d.month.budget ? ` of ${usd(d.month.budget)}` : ""}`);
|
|
256
|
+
if (d.plan?.valueMultiple) {
|
|
257
|
+
setText("value-label", `${d.plan.name} value`);
|
|
258
|
+
setText("value", `${d.plan.valueMultiple.toFixed(1)}×`);
|
|
259
|
+
setText("value-sub", `~${usd(t.monthProjection)} of API usage for ${usd(d.plan.monthlyUsd)}/mo`);
|
|
260
|
+
} else {
|
|
261
|
+
setText("value-label", "Prompt caching saved");
|
|
262
|
+
setText("value", usd(t.cacheSavings30d));
|
|
263
|
+
setText("value-sub", "in the last 30 days");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Block
|
|
267
|
+
const b = d.block;
|
|
268
|
+
const alertEl = $("block-alert");
|
|
269
|
+
if (b) {
|
|
270
|
+
setText("block-title", b.active ? "Current 5-hour block" : "Last 5-hour block");
|
|
271
|
+
setText("block-range", `${when(b.start)} – ${clock(b.end)}${b.active ? ` · ${dur(b.remainingMs)} left` : " · ended"}`);
|
|
272
|
+
$("block-stats").replaceChildren(
|
|
273
|
+
h("div", { class: "stat" }, h("div", { class: "v" }, usd(b.cost)), h("div", { class: "k" }, "spent")),
|
|
274
|
+
h("div", { class: "stat" }, h("div", { class: "v" }, b.active ? `${usd(b.burnPerHour)}/h` : "—"), h("div", { class: "k" }, "burn rate (last hour)")),
|
|
275
|
+
h("div", { class: "stat" }, h("div", { class: "v" }, b.active ? usd(b.projected) : usd(b.cost)), h("div", { class: "k" }, b.active ? "projected at reset" : "total")),
|
|
276
|
+
h("div", { class: "stat" }, h("div", { class: "v" }, tok(b.tokens)), h("div", { class: "k" }, "tokens")),
|
|
277
|
+
);
|
|
278
|
+
const icon = () => {
|
|
279
|
+
const sv = s("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": 2, "stroke-linecap": "round", "aria-hidden": "true" });
|
|
280
|
+
sv.append(s("path", { d: "M12 4 2.5 20h19z" }), s("path", { d: "M12 10v4.5M12 17.5v.01" }));
|
|
281
|
+
return sv;
|
|
282
|
+
};
|
|
283
|
+
if (b.active && b.budget && b.percent >= 1) {
|
|
284
|
+
alertEl.className = "alert critical";
|
|
285
|
+
alertEl.replaceChildren(icon(), h("span", {}, h("strong", {}, "Over budget. "), `${usd(b.cost)} of ${usd(b.budget)} — the block resets at ${clock(b.end)}.`));
|
|
286
|
+
alertEl.hidden = false;
|
|
287
|
+
} else if (b.hitsBudgetAt) {
|
|
288
|
+
alertEl.className = "alert warning";
|
|
289
|
+
alertEl.replaceChildren(icon(), h("span", {}, "At ", h("strong", {}, `${usd(b.burnPerHour)}/h`), " you'll hit your ", b.budgetSource === "learned" ? "learned cap" : "budget", " at ", h("strong", {}, clock(b.hitsBudgetAt)), ` — ${dur(b.hitsBudgetAt - Date.now())} from now, ${dur(b.end - b.hitsBudgetAt)} before the reset.`));
|
|
290
|
+
alertEl.hidden = false;
|
|
291
|
+
} else if (b.active && b.budget) {
|
|
292
|
+
alertEl.className = "alert good";
|
|
293
|
+
alertEl.replaceChildren(h("span", {}, `On pace: projected ${usd(b.projected)} of ${usd(b.budget)} by ${clock(b.end)}.`));
|
|
294
|
+
alertEl.hidden = false;
|
|
295
|
+
} else alertEl.hidden = true;
|
|
296
|
+
|
|
297
|
+
const mw = $("meter-wrap");
|
|
298
|
+
if (b.budget) {
|
|
299
|
+
mw.hidden = false;
|
|
300
|
+
const pct = Math.min(100, (b.percent ?? 0) * 100);
|
|
301
|
+
const fill = $("meter-fill");
|
|
302
|
+
fill.style.width = `${pct}%`;
|
|
303
|
+
fill.className = `meter-fill ${b.percent >= 1 ? "critical" : b.percent >= 0.8 ? "warning" : ""}`;
|
|
304
|
+
$("meter").setAttribute("aria-valuenow", String(Math.round(pct)));
|
|
305
|
+
setText("meter-label", `${Math.round((b.percent ?? 0) * 100)}% of ${b.budgetSource === "learned" ? "learned cap" : "block budget"} (${usd(b.budget)})`);
|
|
306
|
+
} else mw.hidden = true;
|
|
307
|
+
blockChart($("block-chart"), b);
|
|
308
|
+
}
|
|
309
|
+
// Codex-reported limits
|
|
310
|
+
const rl = $("ratelimits");
|
|
311
|
+
rl.replaceChildren();
|
|
312
|
+
const rlItems = [];
|
|
313
|
+
for (const r of d.rateLimits) {
|
|
314
|
+
for (const [label, w] of [["5-hour window", r.primary], ["Weekly window", r.secondary]]) {
|
|
315
|
+
if (!w) continue;
|
|
316
|
+
const pct = Math.min(100, w.usedPercent);
|
|
317
|
+
const fill = h("div", { class: `meter-fill ${pct >= 100 ? "critical" : pct >= 80 ? "warning" : ""}`, style: { width: `${pct}%` } });
|
|
318
|
+
rlItems.push(h("div", { class: "rl-item" }, h("div", {}, h("strong", {}, agentInfo(r.agent).name), ` · ${label}`), h("div", { class: "meter", role: "meter", "aria-valuenow": String(Math.round(pct)), "aria-valuemin": "0", "aria-valuemax": "100" }, fill), h("div", { class: "sub" }, `${Math.round(w.usedPercent)}% used (reported)${w.resetsAt ? ` · resets ${when(w.resetsAt)}` : ""}`)));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (rlItems.length) rl.append(h("div", { class: "rl" }, rlItems));
|
|
322
|
+
|
|
323
|
+
// Daily
|
|
324
|
+
const total = d.daily.reduce((a, x) => a + x.total, 0);
|
|
325
|
+
setText("daily-sub", `${usd(total)} over ${d.daily.length} days · ${d.agents.map((a) => `${agentInfo(a.agent).name} ${usd(a.cost)}`).join(" · ")}`);
|
|
326
|
+
dailyChart($("daily-chart"), d.daily);
|
|
327
|
+
table($("daily-table"), [
|
|
328
|
+
{ key: "date", label: "Day", render: (r) => dayLabel(r.date, true) },
|
|
329
|
+
...Object.keys(AGENTS).filter((a) => d.daily.some((x) => x.byAgent[a])).map((a) => ({ key: a, label: agentInfo(a).name, num: true, sortValue: (r) => r.byAgent[a] ?? 0, render: (r) => usd(r.byAgent[a] ?? 0) })),
|
|
330
|
+
{ key: "tokens", label: "Tokens", num: true, render: (r) => tok(r.tokens) },
|
|
331
|
+
{ key: "total", label: "Total", num: true, render: (r) => usd(r.total) },
|
|
332
|
+
], [...d.daily].reverse(), { key: "date", dir: "desc" });
|
|
333
|
+
$("daily-chart").hidden = showTable;
|
|
334
|
+
$("daily-table").hidden = !showTable;
|
|
335
|
+
|
|
336
|
+
// Sessions
|
|
337
|
+
const maxS = d.sessions[0]?.cost ?? 1;
|
|
338
|
+
if (d.sessions.length) {
|
|
339
|
+
table($("sessions"), [
|
|
340
|
+
{ key: "title", label: "Session", render: (r) => h("div", {}, h("span", { class: "title", title: r.title ?? r.id }, h("span", { class: "dot", style: { background: agentInfo(r.agent).color } }), r.title ?? `Session ${r.id.slice(0, 8)}`), h("span", { class: "meta" }, `${shortProject(r.project)} · ${when(r.start)} · ${dur(r.end - r.start)} · ${r.models.map((m) => m.replace(/^claude-/, "")).join(", ")}`)) },
|
|
341
|
+
{ key: "tokens", label: "Tokens", num: true, render: (r) => tok(r.tokens) },
|
|
342
|
+
{ key: "cost", label: "Cost", num: true, render: (r) => costCell(r.cost, maxS) },
|
|
343
|
+
], d.sessions.slice(0, 12), { key: "cost", dir: "desc" });
|
|
344
|
+
} else $("sessions").replaceChildren(h("p", { class: "empty-note" }, "No sessions in this range."));
|
|
345
|
+
|
|
346
|
+
const maxP = d.projects[0]?.cost ?? 1;
|
|
347
|
+
table($("projects"), [
|
|
348
|
+
{ key: "project", label: "Project", render: (r) => h("span", { class: "title", title: r.project }, shortProject(r.project)) },
|
|
349
|
+
{ key: "sessions", label: "Sessions", num: true },
|
|
350
|
+
{ key: "cost", label: "Cost", num: true, render: (r) => costCell(r.cost, maxP) },
|
|
351
|
+
], d.projects.slice(0, 20), { key: "cost", dir: "desc" });
|
|
352
|
+
|
|
353
|
+
table($("models"), [
|
|
354
|
+
{ key: "model", label: "Model", render: (r) => h("span", {}, r.model, r.priced ? null : [" ", h("span", { class: "pill warn", title: "Add a price in ~/.burnrate/prices.json" }, "unpriced")]) },
|
|
355
|
+
{ key: "input", label: "Input", num: true, render: (r) => tok(r.input) },
|
|
356
|
+
{ key: "output", label: "Output", num: true, render: (r) => tok(r.output) },
|
|
357
|
+
{ key: "cacheRead", label: "Cache read", num: true, render: (r) => tok(r.cacheRead) },
|
|
358
|
+
{ key: "cacheWrite", label: "Cache write", num: true, render: (r) => tok(r.cacheWrite) },
|
|
359
|
+
{ key: "cost", label: "Cost", num: true, render: (r) => usd(r.cost) },
|
|
360
|
+
], d.models, { key: "cost", dir: "desc" });
|
|
361
|
+
|
|
362
|
+
const lim = $("limits");
|
|
363
|
+
if (d.limits.length) {
|
|
364
|
+
lim.replaceChildren(
|
|
365
|
+
...(d.learnedCap ? [h("p", { class: "sub" }, `Learned 5-hour cap: ~${usd(d.learnedCap)} (what you had spent in the 5 hours before a recent limit hit).`)] : []),
|
|
366
|
+
...d.limits.slice(0, 8).map((l) => h("div", { class: "limit-row" }, h("span", { class: "when" }, when(l.ts)), h("span", {}, h("strong", {}, agentInfo(l.agent).name), l.blockCost != null ? ` after ${usd(l.blockCost)} in the previous 5 hours` : "", h("br"), h("span", { class: "meta sub" }, l.message)))),
|
|
367
|
+
);
|
|
368
|
+
} else lim.replaceChildren(h("p", { class: "empty-note" }, "No limit hits recorded. When you hit one, Burnrate learns your real cap from it."));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function load() {
|
|
372
|
+
$("main").classList.add("loading");
|
|
373
|
+
document.querySelectorAll(".seg").forEach((b) => b.setAttribute("aria-pressed", String(Number(b.dataset.days) === days)));
|
|
374
|
+
try {
|
|
375
|
+
const res = await fetch(`/api/summary?days=${days}`);
|
|
376
|
+
if (!res.ok) throw new Error(await res.text());
|
|
377
|
+
render(await res.json());
|
|
378
|
+
} catch (err) {
|
|
379
|
+
setText("updated", `Couldn't refresh: ${err.message}`);
|
|
380
|
+
} finally {
|
|
381
|
+
$("main").classList.remove("loading");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ───────── events ─────────
|
|
386
|
+
document.querySelectorAll(".seg").forEach((b) =>
|
|
387
|
+
b.addEventListener("click", () => {
|
|
388
|
+
days = Number(b.dataset.days);
|
|
389
|
+
try { localStorage.setItem("burnrate.days", String(days)); } catch {}
|
|
390
|
+
load();
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
$("daily-table-btn").addEventListener("click", (e) => {
|
|
394
|
+
showTable = !showTable;
|
|
395
|
+
e.currentTarget.setAttribute("aria-pressed", String(showTable));
|
|
396
|
+
$("daily-chart").hidden = showTable;
|
|
397
|
+
$("daily-table").hidden = !showTable;
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const dlg = $("settings");
|
|
401
|
+
$("settings-btn").addEventListener("click", () => {
|
|
402
|
+
if (!data) return;
|
|
403
|
+
const f = $("settings-form");
|
|
404
|
+
const c = data.config;
|
|
405
|
+
f.blockUsd.value = c.budgets.blockUsd ?? "";
|
|
406
|
+
f.weekUsd.value = c.budgets.weekUsd ?? "";
|
|
407
|
+
f.monthUsd.value = c.budgets.monthUsd ?? "";
|
|
408
|
+
const sel = $("plan-preset");
|
|
409
|
+
sel.replaceChildren(h("option", { value: "" }, "None / pay-as-you-go"), ...Object.entries(data.planPresets).map(([k, p]) => h("option", { value: k }, `${p.name} — $${p.monthlyUsd}/mo`)), h("option", { value: "custom" }, "Custom…"));
|
|
410
|
+
const match = Object.entries(data.planPresets).find(([, p]) => c.plan && p.name === c.plan.name);
|
|
411
|
+
sel.value = c.plan ? (match ? match[0] : "custom") : "";
|
|
412
|
+
f.planUsd.value = c.plan?.monthlyUsd ?? "";
|
|
413
|
+
setText("learned-hint", data.learnedCap ? `Learned from your last limit hit: ~${usd(data.learnedCap)}. Leave empty to use it.` : "Leave empty to learn it from your next limit hit.");
|
|
414
|
+
dlg.showModal();
|
|
415
|
+
});
|
|
416
|
+
$("plan-preset").addEventListener("change", (e) => {
|
|
417
|
+
const p = data.planPresets[e.target.value];
|
|
418
|
+
if (p) $("settings-form").planUsd.value = p.monthlyUsd;
|
|
419
|
+
});
|
|
420
|
+
dlg.addEventListener("close", async () => {
|
|
421
|
+
if (dlg.returnValue !== "save") return;
|
|
422
|
+
const f = $("settings-form");
|
|
423
|
+
const preset = data.planPresets[f.planPreset.value];
|
|
424
|
+
const planUsd = Number(f.planUsd.value);
|
|
425
|
+
const plan = f.planPreset.value === "" ? null : { name: preset ? preset.name : "Custom plan", monthlyUsd: planUsd || preset?.monthlyUsd || 0 };
|
|
426
|
+
await fetch("/api/config", {
|
|
427
|
+
method: "POST",
|
|
428
|
+
headers: { "Content-Type": "application/json" },
|
|
429
|
+
body: JSON.stringify({ budgets: { blockUsd: Number(f.blockUsd.value) || null, weekUsd: Number(f.weekUsd.value) || null, monthUsd: Number(f.monthUsd.value) || null }, plan }),
|
|
430
|
+
});
|
|
431
|
+
load();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
let resizeT;
|
|
435
|
+
window.addEventListener("resize", () => {
|
|
436
|
+
clearTimeout(resizeT);
|
|
437
|
+
resizeT = setTimeout(() => data && render(data), 150);
|
|
438
|
+
});
|
|
439
|
+
load();
|
|
440
|
+
setInterval(() => { if (!document.hidden) load(); }, 30_000);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#eb6834"/><path d="M16 6c1.8 3 6 5.6 6 11a6 6 0 0 1-12 0c0-2.6 1.3-4.4 2.6-5.6.2 1.7 1.1 2.9 2.3 3.4-.5-3.4.1-6.3 1.1-8.8z" fill="#fff"/></svg>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Burnrate</title>
|
|
7
|
+
<meta name="description" content="What your coding agents cost, and when you'll hit the limit." />
|
|
8
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
9
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div class="page">
|
|
13
|
+
<header class="topbar">
|
|
14
|
+
<a class="brand" href="/" aria-label="Burnrate home">
|
|
15
|
+
<img src="/favicon.svg" alt="" width="26" height="26" />
|
|
16
|
+
<span>Burnrate</span>
|
|
17
|
+
</a>
|
|
18
|
+
<div class="filters" role="group" aria-label="Date range">
|
|
19
|
+
<button class="seg" data-days="7">7 days</button>
|
|
20
|
+
<button class="seg" data-days="30">30 days</button>
|
|
21
|
+
<button class="seg" data-days="90">90 days</button>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="topbar-right">
|
|
24
|
+
<span class="updated" id="updated" aria-live="polite"></span>
|
|
25
|
+
<button class="btn" id="settings-btn" aria-haspopup="dialog">Budgets & plan</button>
|
|
26
|
+
</div>
|
|
27
|
+
</header>
|
|
28
|
+
|
|
29
|
+
<main id="main" class="loading">
|
|
30
|
+
<section class="hero-row" aria-label="Spend summary">
|
|
31
|
+
<div class="hero card">
|
|
32
|
+
<p class="label">Today</p>
|
|
33
|
+
<p class="hero-figure" id="today">—</p>
|
|
34
|
+
<p class="sub" id="today-sub"></p>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="tiles">
|
|
37
|
+
<div class="tile card"><p class="label">Last 7 days</p><p class="tile-value" id="last7">—</p><p class="sub" id="last7-sub"></p></div>
|
|
38
|
+
<div class="tile card"><p class="label">This month</p><p class="tile-value" id="mtd">—</p><p class="sub" id="mtd-sub"></p></div>
|
|
39
|
+
<div class="tile card"><p class="label" id="value-label">Prompt caching saved</p><p class="tile-value" id="value">—</p><p class="sub" id="value-sub"></p></div>
|
|
40
|
+
</div>
|
|
41
|
+
</section>
|
|
42
|
+
|
|
43
|
+
<section class="card block-card" aria-labelledby="block-title">
|
|
44
|
+
<div class="card-head">
|
|
45
|
+
<div>
|
|
46
|
+
<h2 id="block-title">Current 5-hour block</h2>
|
|
47
|
+
<p class="sub" id="block-range"></p>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="block-stats" id="block-stats"></div>
|
|
50
|
+
</div>
|
|
51
|
+
<div id="block-alert" class="alert" hidden></div>
|
|
52
|
+
<div class="meter-wrap" id="meter-wrap" hidden>
|
|
53
|
+
<div class="meter" role="meter" id="meter" aria-valuemin="0" aria-valuemax="100"><div class="meter-fill" id="meter-fill"></div></div>
|
|
54
|
+
<p class="meter-label" id="meter-label"></p>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="chart" id="block-chart" aria-label="Cumulative spend in the current block"></div>
|
|
57
|
+
<div id="ratelimits"></div>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<section class="card" aria-labelledby="daily-title">
|
|
61
|
+
<div class="card-head">
|
|
62
|
+
<div>
|
|
63
|
+
<h2 id="daily-title">Daily spend</h2>
|
|
64
|
+
<p class="sub" id="daily-sub"></p>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="legend" id="daily-legend"></div>
|
|
67
|
+
<button class="btn btn-ghost" id="daily-table-btn" aria-pressed="false">Table</button>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="chart" id="daily-chart"></div>
|
|
70
|
+
<div class="table-wrap" id="daily-table" hidden></div>
|
|
71
|
+
</section>
|
|
72
|
+
|
|
73
|
+
<div class="grid-2">
|
|
74
|
+
<section class="card" aria-labelledby="sessions-title">
|
|
75
|
+
<div class="card-head"><h2 id="sessions-title">Most expensive sessions</h2></div>
|
|
76
|
+
<div class="table-wrap" id="sessions"></div>
|
|
77
|
+
</section>
|
|
78
|
+
<section class="card" aria-labelledby="projects-title">
|
|
79
|
+
<div class="card-head"><h2 id="projects-title">Projects</h2></div>
|
|
80
|
+
<div class="table-wrap" id="projects"></div>
|
|
81
|
+
</section>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div class="grid-2">
|
|
85
|
+
<section class="card" aria-labelledby="models-title">
|
|
86
|
+
<div class="card-head"><h2 id="models-title">Models</h2></div>
|
|
87
|
+
<div class="table-wrap" id="models"></div>
|
|
88
|
+
</section>
|
|
89
|
+
<section class="card" aria-labelledby="limits-title">
|
|
90
|
+
<div class="card-head"><h2 id="limits-title">Limit hits</h2></div>
|
|
91
|
+
<div id="limits"></div>
|
|
92
|
+
</section>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<section class="empty card" id="empty" hidden>
|
|
96
|
+
<h2>No agent usage found yet</h2>
|
|
97
|
+
<p>Burnrate reads the logs Claude Code (<code>~/.claude/projects</code>) and Codex CLI (<code>~/.codex/sessions</code>) write on this machine. Run an agent, then refresh — or start Burnrate with <code>--path <dir></code>.</p>
|
|
98
|
+
</section>
|
|
99
|
+
</main>
|
|
100
|
+
|
|
101
|
+
<footer class="footer">
|
|
102
|
+
<span id="footer-meta"></span>
|
|
103
|
+
<span>Costs are API-equivalent estimates from public per-token prices. Everything stays on this machine.</span>
|
|
104
|
+
</footer>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div class="tooltip" id="tooltip" role="tooltip" hidden></div>
|
|
108
|
+
|
|
109
|
+
<dialog id="settings" aria-labelledby="settings-title">
|
|
110
|
+
<form method="dialog" id="settings-form">
|
|
111
|
+
<h2 id="settings-title">Budgets & plan</h2>
|
|
112
|
+
<p class="sub">Vendors don't publish exact limits. Set your own, or let Burnrate learn a cap from your last limit hit.</p>
|
|
113
|
+
<label>5-hour block budget (USD)<input type="number" min="0" step="0.5" name="blockUsd" placeholder="e.g. 40" /></label>
|
|
114
|
+
<p class="hint" id="learned-hint"></p>
|
|
115
|
+
<label>Rolling 7-day budget (USD)<input type="number" min="0" step="1" name="weekUsd" placeholder="optional" /></label>
|
|
116
|
+
<label>Monthly budget (USD)<input type="number" min="0" step="1" name="monthUsd" placeholder="optional" /></label>
|
|
117
|
+
<fieldset>
|
|
118
|
+
<legend>Subscription (to see value for money)</legend>
|
|
119
|
+
<select name="planPreset" id="plan-preset"><option value="">None / pay-as-you-go</option></select>
|
|
120
|
+
<label>Plan price per month (USD)<input type="number" min="0" step="1" name="planUsd" /></label>
|
|
121
|
+
</fieldset>
|
|
122
|
+
<div class="dialog-actions">
|
|
123
|
+
<button value="cancel" class="btn btn-ghost" formnovalidate>Cancel</button>
|
|
124
|
+
<button value="save" class="btn btn-primary" id="save-settings">Save</button>
|
|
125
|
+
</div>
|
|
126
|
+
</form>
|
|
127
|
+
</dialog>
|
|
128
|
+
|
|
129
|
+
<script src="/app.js" type="module"></script>
|
|
130
|
+
</body>
|
|
131
|
+
</html>
|