claude-finops 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 +361 -0
- package/bin/claude-finops.js +74 -0
- package/config/free_models.json +49 -0
- package/config/model_compare.json +13 -0
- package/config/pricing.json +232 -0
- package/config/settings.json +57 -0
- package/finops/__init__.py +0 -0
- package/finops/actions.py +671 -0
- package/finops/agents.py +296 -0
- package/finops/analytics.py +1579 -0
- package/finops/api.py +426 -0
- package/finops/classify.py +48 -0
- package/finops/cloud.py +336 -0
- package/finops/diagnose.py +1002 -0
- package/finops/etl.py +533 -0
- package/finops/paths.py +111 -0
- package/finops/playbook.py +229 -0
- package/finops/pricing.py +74 -0
- package/finops/procs.py +499 -0
- package/finops/report.py +169 -0
- package/package.json +52 -0
- package/run.cmd +4 -0
- package/run.py +210 -0
- package/run.sh +3 -0
- package/web/app.js +2923 -0
- package/web/charts.js +340 -0
- package/web/index.html +14 -0
- package/web/styles.css +387 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,2923 @@
|
|
|
1
|
+
import * as C from './charts.js';
|
|
2
|
+
const {fmtUSD, fmtNum, fmtInt, fmtPct, seriesVar} = C;
|
|
3
|
+
|
|
4
|
+
/* ============================ state ============================ */
|
|
5
|
+
const S = {
|
|
6
|
+
view: 'overview',
|
|
7
|
+
opts: null,
|
|
8
|
+
filter: {start: null, end: null, agents: [], models: [], projects: [], categories: [],
|
|
9
|
+
include_sandbox: true, min_cost: null, min_tokens: null},
|
|
10
|
+
range: '30d',
|
|
11
|
+
metric: 'cost',
|
|
12
|
+
grain: 'day',
|
|
13
|
+
cache: new Map(),
|
|
14
|
+
drawerStack: [],
|
|
15
|
+
};
|
|
16
|
+
const $ = (s, r = document) => r.querySelector(s);
|
|
17
|
+
const h = (html) => { const t = document.createElement('template');
|
|
18
|
+
t.innerHTML = html.trim(); return t.content.firstElementChild; };
|
|
19
|
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c =>
|
|
20
|
+
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
21
|
+
|
|
22
|
+
const qs = () => {
|
|
23
|
+
const f = S.filter, p = new URLSearchParams();
|
|
24
|
+
if (f.start) p.set('start', f.start);
|
|
25
|
+
if (f.end) p.set('end', f.end);
|
|
26
|
+
for (const k of ['agents', 'models', 'projects', 'categories'])
|
|
27
|
+
if (f[k].length) p.set(k, f[k].join(','));
|
|
28
|
+
if (!f.include_sandbox) p.set('include_sandbox', '0');
|
|
29
|
+
if (f.min_cost) p.set('min_cost', f.min_cost);
|
|
30
|
+
if (f.min_tokens) p.set('min_tokens', f.min_tokens);
|
|
31
|
+
return p.toString();
|
|
32
|
+
};
|
|
33
|
+
async function api(route, extra = '') {
|
|
34
|
+
const url = `/api/${route}?${qs()}${extra}`;
|
|
35
|
+
if (S.cache.has(url)) return S.cache.get(url);
|
|
36
|
+
// never cache failures: a response from a restarting server would stick until reload
|
|
37
|
+
const pr = fetch(url).then(r => r.json()).then(d => {
|
|
38
|
+
if (d && d.error) throw new Error(`API /${route} failed:\n${d.error}`);
|
|
39
|
+
return d;
|
|
40
|
+
}).catch(e => { S.cache.delete(url); throw e; });
|
|
41
|
+
S.cache.set(url, pr);
|
|
42
|
+
return pr;
|
|
43
|
+
}
|
|
44
|
+
const bust = () => S.cache.clear();
|
|
45
|
+
|
|
46
|
+
/* ============================ formatting helpers ============================ */
|
|
47
|
+
const naLabel = () => `Unavailable from connected ${agentWord()} data`;
|
|
48
|
+
const NA = () => `<span class="na">${esc(naLabel())}</span>`;
|
|
49
|
+
const BADGE = {actual: '<span class="badge">Actual</span>',
|
|
50
|
+
estimated: '<span class="badge est">Estimated</span>',
|
|
51
|
+
forecast: '<span class="badge fc">Forecast</span>',
|
|
52
|
+
recommendation: '<span class="badge rec">Recommendation</span>'};
|
|
53
|
+
const statusGlyph = s => ({healthy: '๐ข', high: '๐ก', approaching: '๐ ', critical: '๐ด'}[s] || 'โช');
|
|
54
|
+
const statusChip = (s, txt) => `<span class="status ${s}"><span class="glyph">${statusGlyph(s)}</span>${esc(txt || s)}</span>`;
|
|
55
|
+
const modelColor = m => {
|
|
56
|
+
const list = (S.opts?.models || []).map(x => x.model);
|
|
57
|
+
const i = list.indexOf(m);
|
|
58
|
+
return seriesVar(i < 0 ? 7 : i);
|
|
59
|
+
};
|
|
60
|
+
const modelName = m => S.opts?.pricing?.models?.[m]?.display_name || m;
|
|
61
|
+
// Categories keep one colour across every filter selection: the slot comes from the
|
|
62
|
+
// global category list, never from this view's ranking.
|
|
63
|
+
const catColor = c => {
|
|
64
|
+
const list = (S.opts?.categories || []).map(x => x.category);
|
|
65
|
+
const i = list.indexOf(c);
|
|
66
|
+
return seriesVar(i < 0 ? 7 : i);
|
|
67
|
+
};
|
|
68
|
+
const dur = s => s == null ? 'โ' : s < 60 ? `${Math.round(s)}s`
|
|
69
|
+
: s < 3600 ? `${Math.floor(s/60)}m ${Math.round(s%60)}s`
|
|
70
|
+
: `${Math.floor(s/3600)}h ${Math.round((s%3600)/60)}m`;
|
|
71
|
+
const shortDay = d => (d || '').slice(5);
|
|
72
|
+
const shortId = s => (s || '').slice(0, 8);
|
|
73
|
+
|
|
74
|
+
function kpi(label, value, detail, opts = {}) {
|
|
75
|
+
const na = value == null;
|
|
76
|
+
return `<div class="kpi${na ? ' na' : ''}">
|
|
77
|
+
<div class="l">${esc(label)}${opts.badge ? ' ' + opts.badge : ''}</div>
|
|
78
|
+
<div class="v${opts.small ? ' sm' : ''}">${na ? esc(naLabel()) : value}</div>
|
|
79
|
+
${detail ? `<div class="d">${detail}</div>` : ''}</div>`;
|
|
80
|
+
}
|
|
81
|
+
function card(title, bodyHtml, opts = {}) {
|
|
82
|
+
return `<section class="card"${opts.style ? ` style="${opts.style}"` : ''}>
|
|
83
|
+
<header><h3>${esc(title)}</h3>${opts.badge || ''}
|
|
84
|
+
${opts.hint ? `<span class="hint">${esc(opts.hint)}</span>` : ''}
|
|
85
|
+
<span class="spacer"></span>${opts.actions || ''}</header>
|
|
86
|
+
<div class="body${opts.flush ? ' flush' : ''}">${bodyHtml}</div>
|
|
87
|
+
${opts.footer ? `<footer>${opts.footer}</footer>` : ''}</section>`;
|
|
88
|
+
}
|
|
89
|
+
function table(cols, rows, opts = {}) {
|
|
90
|
+
if (!rows.length) return '<div class="empty">Nothing to show for the current filters</div>';
|
|
91
|
+
const th = cols.map((c, i) => `<th class="${c.num ? 'num ' : ''}nosort">${esc(c.h)}</th>`).join('');
|
|
92
|
+
const tb = rows.map((r, ri) => `<tr class="${opts.onRow ? 'clickable' : ''}" data-i="${ri}">${
|
|
93
|
+
cols.map(c => `<td class="${c.num ? 'num ' : ''}${c.trunc ? 'trunc' : ''}"${
|
|
94
|
+
c.title ? ` title="${esc(c.title(r))}"` : ''}>${c.f(r)}</td>`).join('')}</tr>`).join('');
|
|
95
|
+
return `<div class="tbl-wrap"><table class="tbl"><thead><tr>${th}</tr></thead>
|
|
96
|
+
<tbody>${tb}</tbody></table></div>`;
|
|
97
|
+
}
|
|
98
|
+
function wireTable(host, rows, onRow) {
|
|
99
|
+
if (!onRow || !host) return;
|
|
100
|
+
host.querySelectorAll('table.tbl tbody tr.clickable').forEach(tr =>
|
|
101
|
+
tr.onclick = () => onRow(rows[+tr.dataset.i]));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ============================ chrome ============================ */
|
|
105
|
+
const NAV = [
|
|
106
|
+
['Command center', [
|
|
107
|
+
['overview', 'โง', 'Executive overview'],
|
|
108
|
+
['advisor', 'โฆ', 'What should I do?'],
|
|
109
|
+
['agents', 'โ', 'Agents'],
|
|
110
|
+
['live', 'โป', 'Running sessions'],
|
|
111
|
+
['scorecard', 'โ', 'FinOps scorecard'],
|
|
112
|
+
]],
|
|
113
|
+
['Usage', [
|
|
114
|
+
['usage', 'โค', 'Usage timeline'],
|
|
115
|
+
['burn', 'โ', 'Burn rate & limits', 'priced'],
|
|
116
|
+
['models', 'โ', 'Model analysis'],
|
|
117
|
+
['context', 'โญ', 'Context & cache'],
|
|
118
|
+
]],
|
|
119
|
+
['Drill-down', [
|
|
120
|
+
['projects', 'โฃ', 'Projects'],
|
|
121
|
+
['sessions', 'โฆ', 'Sessions'],
|
|
122
|
+
['prompts', 'โฐ', 'Prompt explorer'],
|
|
123
|
+
['rankings', 'โ', 'Cost rankings'],
|
|
124
|
+
['categories', 'โ', 'Prompt intelligence'],
|
|
125
|
+
['developer', 'โ', 'Developer activity', 'priced'],
|
|
126
|
+
]],
|
|
127
|
+
['Optimize', [
|
|
128
|
+
['diagnose', 'โ', 'Why so many tokens?', 'priced'],
|
|
129
|
+
['attribution', 'โง', 'Who used the tokens', 'priced'],
|
|
130
|
+
['waste', 'โ ', 'Waste detection'],
|
|
131
|
+
['modelswitch', 'โ', 'Model switch', 'priced'],
|
|
132
|
+
['freemodels', 'โ', 'Free models', 'claude'],
|
|
133
|
+
['compare', 'โ', 'Compare models', 'priced'],
|
|
134
|
+
['toolkit', 'โ', 'Skills & MCP', 'claude'],
|
|
135
|
+
['recommendations', 'โฏ', 'Recommendations'],
|
|
136
|
+
['anomalies', 'โก', 'Anomalies'],
|
|
137
|
+
]],
|
|
138
|
+
['Plan', [
|
|
139
|
+
['forecast', 'โญ', 'Forecast', 'priced'],
|
|
140
|
+
['budgets', 'โ', 'Budgets', 'priced'],
|
|
141
|
+
['cloud', 'โ', 'Billed vs local', 'cloud'],
|
|
142
|
+
['exports', 'โฉ', 'Export & data'],
|
|
143
|
+
]],
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
function shell() {
|
|
147
|
+
document.body.innerHTML = `<div class="app">
|
|
148
|
+
<aside class="sidebar">
|
|
149
|
+
<div class="brand"><div class="mark"><span class="dot"></span>Claude FinOps</div>
|
|
150
|
+
<div class="sub">Command Center</div></div>
|
|
151
|
+
<nav class="nav">${NAV.map(([g, items], gi) => `<div class="group g${gi}">${g}</div>` +
|
|
152
|
+
items.map(([id, ic, label]) =>
|
|
153
|
+
`<a data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${ic}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
|
|
154
|
+
</nav></aside>
|
|
155
|
+
<div class="main">
|
|
156
|
+
<header class="topbar">
|
|
157
|
+
<div class="r1">
|
|
158
|
+
<h1 id="ttl">Executive overview</h1>
|
|
159
|
+
<div class="crumbs" id="crumbs"></div>
|
|
160
|
+
<span class="spacer"></span>
|
|
161
|
+
<div class="search"><span class="mag">โ</span>
|
|
162
|
+
<input id="gsearch" placeholder="Search prompts, sessions, models, datesโฆ"></div>
|
|
163
|
+
<button class="iconbtn" id="tour-btn" title="Walk through this dashboard">? Tour</button>
|
|
164
|
+
<button class="iconbtn" id="theme" title="Toggle theme">โ</button>
|
|
165
|
+
<button class="iconbtn" id="refresh" title="Reload data">โป</button>
|
|
166
|
+
<button class="act" id="sync" title="Re-read every agent's local data so the dashboard is current">โณ Sync</button>
|
|
167
|
+
</div>
|
|
168
|
+
<div class="filters" id="filters"></div>
|
|
169
|
+
</header>
|
|
170
|
+
<div class="page" id="page"><div class="loading">Loading warehouseโฆ</div></div>
|
|
171
|
+
</div></div>`;
|
|
172
|
+
|
|
173
|
+
document.querySelectorAll('.nav a').forEach(a =>
|
|
174
|
+
a.onclick = () => go(a.dataset.view));
|
|
175
|
+
$('#tour-btn').onclick = () => tourForView();
|
|
176
|
+
$('#theme').onclick = () => {
|
|
177
|
+
const cur = document.documentElement.getAttribute('data-theme');
|
|
178
|
+
const next = cur === 'dark' ? 'light' : cur === 'light' ? 'dark'
|
|
179
|
+
: (matchMedia('(prefers-color-scheme: dark)').matches ? 'light' : 'dark');
|
|
180
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
181
|
+
localStorage.setItem('finops-theme', next);
|
|
182
|
+
render();
|
|
183
|
+
};
|
|
184
|
+
$('#refresh').onclick = () => { bust(); render(); };
|
|
185
|
+
$('#sync').onclick = runSync;
|
|
186
|
+
syncLabel();
|
|
187
|
+
let t;
|
|
188
|
+
$('#gsearch').oninput = e => { clearTimeout(t); const v = e.target.value;
|
|
189
|
+
t = setTimeout(() => { if (v.trim().length >= 2) { S.searchTerm = v; go('search'); }
|
|
190
|
+
else if (S.view === 'search') go('overview'); }, 260); };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ---------- filter bar ---------- */
|
|
194
|
+
const RANGES = [['today', 'Today'], ['7d', '7 days'], ['14d', '14 days'], ['30d', '30 days'],
|
|
195
|
+
['period', 'Billing period'], ['all', 'All time'], ['custom', 'Custom']];
|
|
196
|
+
|
|
197
|
+
function applyRange(r) {
|
|
198
|
+
S.range = r;
|
|
199
|
+
const last = S.opts.date_range.last, bp = S.opts.billing_period;
|
|
200
|
+
const dayShift = n => { const d = new Date(last + 'T00:00:00Z');
|
|
201
|
+
d.setUTCDate(d.getUTCDate() - n + 1); return d.toISOString().slice(0, 10); };
|
|
202
|
+
if (r === 'today') { S.filter.start = last; S.filter.end = last; }
|
|
203
|
+
else if (r === '7d') { S.filter.start = dayShift(7); S.filter.end = last; }
|
|
204
|
+
else if (r === '14d') { S.filter.start = dayShift(14); S.filter.end = last; }
|
|
205
|
+
else if (r === '30d') { S.filter.start = dayShift(30); S.filter.end = last; }
|
|
206
|
+
else if (r === 'period') { S.filter.start = bp.start; S.filter.end = bp.end; }
|
|
207
|
+
else if (r === 'all') { S.filter.start = null; S.filter.end = null; }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function filterBar() {
|
|
211
|
+
const f = S.filter, o = S.opts;
|
|
212
|
+
const nSel = a => a.length ? `<span class="n">${a.length}</span>` : '';
|
|
213
|
+
const ag = o.agents || [];
|
|
214
|
+
$('#filters').innerHTML = `
|
|
215
|
+
${ag.length > 1 ? `<span class="note" style="margin-right:2px">Agent</span>
|
|
216
|
+
${ag.map(a => `<button class="chip ${f.agents.includes(a.id) ? 'on' : ''}" data-agent="${esc(a.id)}"
|
|
217
|
+
title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}">${esc(a.name)}${a.requests ? '' : ' ยท'}</button>`).join('')}
|
|
218
|
+
<button class="chip ${f.agents.length === ag.length ? 'on' : ''}" data-agent="*" title="Every agent together">All</button>
|
|
219
|
+
<span class="divider"></span>` : ''}
|
|
220
|
+
${RANGES.map(([k, l]) => `<button class="chip ${S.range === k ? 'on' : ''}"
|
|
221
|
+
data-range="${k}">${l}</button>`).join('')}
|
|
222
|
+
<span class="divider"></span>
|
|
223
|
+
<div class="chipsel"><button class="chip ${f.models.length ? 'on' : ''}" data-pop="models">
|
|
224
|
+
Model ${nSel(f.models)} โพ</button></div>
|
|
225
|
+
<div class="chipsel"><button class="chip ${f.projects.length ? 'on' : ''}" data-pop="projects">
|
|
226
|
+
Project ${nSel(f.projects)} โพ</button></div>
|
|
227
|
+
<div class="chipsel"><button class="chip ${f.categories.length ? 'on' : ''}" data-pop="categories">
|
|
228
|
+
Category ${nSel(f.categories)} โพ</button></div>
|
|
229
|
+
<div class="chipsel"><button class="chip ${(f.min_cost || f.min_tokens) ? 'on' : ''}"
|
|
230
|
+
data-pop="thresholds">Thresholds โพ</button></div>
|
|
231
|
+
<button class="chip ${f.include_sandbox ? '' : 'on'}" id="sbx">
|
|
232
|
+
${f.include_sandbox ? 'Including' : 'Excluding'} sandbox agents</button>
|
|
233
|
+
${(f.models.length || f.projects.length || f.categories.length || f.min_cost || f.min_tokens)
|
|
234
|
+
? '<button class="chip" id="clr">Clear โ</button>' : ''}
|
|
235
|
+
<span class="spacer"></span>
|
|
236
|
+
<span class="note">${o.date_range.first} โ ${o.date_range.last} ยท
|
|
237
|
+
${esc(o.meta.transcript_files)} transcripts ยท built ${esc((o.meta.built_at||'').slice(0,16).replace('T',' '))}</span>`;
|
|
238
|
+
|
|
239
|
+
$('#filters').querySelectorAll('[data-range]').forEach(b =>
|
|
240
|
+
b.onclick = () => { if (b.dataset.range === 'custom') return openCustom(b);
|
|
241
|
+
applyRange(b.dataset.range); bust(); render(); });
|
|
242
|
+
$('#filters').querySelectorAll('[data-agent]').forEach(b => b.onclick = e => {
|
|
243
|
+
const id = b.dataset.agent;
|
|
244
|
+
// click = only this agent; Cmd/Ctrl/Shift-click = add or remove it (multi-agent view)
|
|
245
|
+
if (id === '*') f.agents = ag.map(a => a.id);
|
|
246
|
+
else if (e.metaKey || e.ctrlKey || e.shiftKey)
|
|
247
|
+
f.agents = f.agents.includes(id) ? f.agents.filter(x => x !== id) : [...f.agents, id];
|
|
248
|
+
else f.agents = [id];
|
|
249
|
+
if (!f.agents.length) f.agents = [id === '*' ? ag[0].id : id];
|
|
250
|
+
try { localStorage.setItem('finops-agents', JSON.stringify(f.agents)); } catch (_) {}
|
|
251
|
+
bust(); render();
|
|
252
|
+
});
|
|
253
|
+
$('#sbx').onclick = () => { f.include_sandbox = !f.include_sandbox; bust(); render(); };
|
|
254
|
+
const clr = $('#clr'); if (clr) clr.onclick = () => {
|
|
255
|
+
f.models = []; f.projects = []; f.categories = []; f.min_cost = null; f.min_tokens = null;
|
|
256
|
+
bust(); render(); };
|
|
257
|
+
$('#filters').querySelectorAll('[data-pop]').forEach(b => b.onclick = e => {
|
|
258
|
+
e.stopPropagation(); openPop(b, b.dataset.pop); });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function closePops() { document.querySelectorAll('.pop').forEach(p => p.remove()); }
|
|
262
|
+
document.addEventListener('click', e => { if (!e.target.closest('.pop')) closePops(); });
|
|
263
|
+
|
|
264
|
+
function openPop(btn, kind) {
|
|
265
|
+
const open = btn.parentElement.querySelector('.pop');
|
|
266
|
+
closePops(); if (open) return;
|
|
267
|
+
const f = S.filter, o = S.opts;
|
|
268
|
+
let inner = '';
|
|
269
|
+
if (kind === 'thresholds') {
|
|
270
|
+
inner = `<div class="hd">Minimum estimated cost (USD)</div>
|
|
271
|
+
<input type="number" step="0.01" id="mc" value="${f.min_cost ?? ''}" placeholder="any">
|
|
272
|
+
<div class="hd">Minimum billable tokens</div>
|
|
273
|
+
<input type="number" id="mt" value="${f.min_tokens ?? ''}" placeholder="any">
|
|
274
|
+
<button class="chip on" id="applyth" style="width:100%;justify-content:center">Apply</button>`;
|
|
275
|
+
} else {
|
|
276
|
+
const key = kind, src = kind === 'models' ? o.models.map(m => [m.model, modelName(m.model), m.n])
|
|
277
|
+
: kind === 'projects' ? o.projects.map(p => [String(p.project_id),
|
|
278
|
+
p.name + (p.is_sandbox ? ' ยท sandbox' : ''), p.n])
|
|
279
|
+
: o.categories.map(c => [c.category, c.category, c.n]);
|
|
280
|
+
inner = src.map(([v, l, n]) => `<label><input type="checkbox" value="${esc(v)}"
|
|
281
|
+
${f[key].includes(v) ? 'checked' : ''}> ${esc(l)}
|
|
282
|
+
<span class="spacer"></span><span class="n" style="color:var(--muted)">${fmtInt(n)}</span></label>`).join('');
|
|
283
|
+
}
|
|
284
|
+
const pop = h(`<div class="pop">${inner}</div>`);
|
|
285
|
+
btn.parentElement.appendChild(pop);
|
|
286
|
+
if (kind === 'thresholds') {
|
|
287
|
+
pop.querySelector('#applyth').onclick = () => {
|
|
288
|
+
f.min_cost = pop.querySelector('#mc').value || null;
|
|
289
|
+
f.min_tokens = pop.querySelector('#mt').value || null;
|
|
290
|
+
closePops(); bust(); render(); };
|
|
291
|
+
} else {
|
|
292
|
+
pop.querySelectorAll('input').forEach(i => i.onchange = () => {
|
|
293
|
+
const set = new Set(f[kind]);
|
|
294
|
+
i.checked ? set.add(i.value) : set.delete(i.value);
|
|
295
|
+
f[kind] = [...set]; bust(); render(); });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function openCustom(btn) {
|
|
299
|
+
closePops();
|
|
300
|
+
const pop = h(`<div class="pop"><div class="hd">From</div>
|
|
301
|
+
<input type="date" id="cs" value="${S.filter.start || S.opts.date_range.first}">
|
|
302
|
+
<div class="hd">To</div>
|
|
303
|
+
<input type="date" id="ce" value="${S.filter.end || S.opts.date_range.last}">
|
|
304
|
+
<button class="chip on" id="ca" style="width:100%;justify-content:center">Apply</button></div>`);
|
|
305
|
+
btn.parentElement.insertBefore(pop, btn.nextSibling);
|
|
306
|
+
pop.onclick = e => e.stopPropagation();
|
|
307
|
+
pop.querySelector('#ca').onclick = () => {
|
|
308
|
+
S.filter.start = pop.querySelector('#cs').value;
|
|
309
|
+
S.filter.end = pop.querySelector('#ce').value;
|
|
310
|
+
S.range = 'custom'; closePops(); bust(); render(); };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* ============================ drawers ============================ */
|
|
314
|
+
function closeDrawer() {
|
|
315
|
+
document.querySelectorAll('.scrim,.drawer').forEach(e => e.remove());
|
|
316
|
+
S.drawerStack = [];
|
|
317
|
+
}
|
|
318
|
+
function drawer(title, bodyHtml, sub) {
|
|
319
|
+
C.hideTip();
|
|
320
|
+
document.querySelectorAll('.scrim,.drawer').forEach(e => e.remove());
|
|
321
|
+
const scrim = h('<div class="scrim"></div>');
|
|
322
|
+
const d = h(`<aside class="drawer"><header>
|
|
323
|
+
<h2>${esc(title)}</h2>${sub ? `<span class="crumbs">${sub}</span>` : ''}
|
|
324
|
+
<span class="spacer"></span>
|
|
325
|
+
<button class="iconbtn" data-x>Close โ</button></header>
|
|
326
|
+
<div class="content">${bodyHtml}</div></aside>`);
|
|
327
|
+
scrim.onclick = closeDrawer;
|
|
328
|
+
d.querySelector('[data-x]').onclick = closeDrawer;
|
|
329
|
+
document.body.append(scrim, d);
|
|
330
|
+
return d;
|
|
331
|
+
}
|
|
332
|
+
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDrawer(); });
|
|
333
|
+
|
|
334
|
+
async function openPrompt(id) {
|
|
335
|
+
const d = drawer('Prompt detail', '<div class="loading">Loadingโฆ</div>');
|
|
336
|
+
const p = await fetch(`/api/prompt/${id}`).then(r => r.json());
|
|
337
|
+
const adv = p.advisor || {};
|
|
338
|
+
d.querySelector('.content').innerHTML = `
|
|
339
|
+
<div class="grid g4">
|
|
340
|
+
${kpi('Estimated cost', fmtUSD(p.est_cost_usd), null, {badge: BADGE.estimated})}
|
|
341
|
+
${kpi('Billable tokens', fmtInt(p.billable_tokens))}
|
|
342
|
+
${kpi('Output tokens', fmtInt(p.output_tokens))}
|
|
343
|
+
${kpi('Peak context', fmtInt(p.max_context_tokens))}
|
|
344
|
+
</div>
|
|
345
|
+
${card('Prompt', `<div class="prompt-text">${esc(p.text)}</div>
|
|
346
|
+
<div class="mt" style="margin-top:8px;display:flex;gap:14px;flex-wrap:wrap;font-size:11px;color:var(--muted)">
|
|
347
|
+
<span>${esc(p.ts || '')}</span><span class="pill">${esc(p.category)}</span>
|
|
348
|
+
<span>confidence ${(100*(p.category_confidence||0)).toFixed(0)}%</span>
|
|
349
|
+
${(p.category_evidence||[]).length ? `<span>matched: ${esc(p.category_evidence.join(', '))}</span>` : ''}
|
|
350
|
+
<span>${fmtInt(p.char_len)} chars ยท ${fmtInt(p.word_len)} words</span>
|
|
351
|
+
${p.source ? `<span class="pill">${esc(p.source)}</span>` : ''}
|
|
352
|
+
</div>`, {badge: BADGE.actual})}
|
|
353
|
+
${card('Cost drivers & optimization advice', adv.available ? `
|
|
354
|
+
<div class="stack">
|
|
355
|
+
<div><b style="font-size:12px">Why this was expensive</b>
|
|
356
|
+
<ul style="margin:5px 0 0 18px;font-size:12px;color:var(--text-2)">
|
|
357
|
+
${adv.why_expensive.map(x => `<li>${esc(x)}</li>`).join('')}</ul></div>
|
|
358
|
+
<div><b style="font-size:12px">Suggested changes</b>
|
|
359
|
+
<ul style="margin:5px 0 0 18px;font-size:12px;color:var(--text-2)">
|
|
360
|
+
${adv.suggestions.map(x => `<li>${esc(x)}</li>`).join('')}</ul></div>
|
|
361
|
+
<div class="grid g3">
|
|
362
|
+
${kpi('Est. token reduction', '~' + adv.estimated_token_reduction_pct + '%', null, {badge: BADGE.recommendation})}
|
|
363
|
+
${kpi('Est. cost reduction', '~' + adv.estimated_cost_reduction_pct + '%')}
|
|
364
|
+
${kpi('Est. cost avoided', '~' + fmtUSD(adv.estimated_cost_reduction_usd))}
|
|
365
|
+
</div></div>`
|
|
366
|
+
: `<div class="na">${esc(adv.message || 'No analysis available')}</div>`,
|
|
367
|
+
{badge: BADGE.recommendation,
|
|
368
|
+
footer: adv.available ? esc(adv.disclaimer) : null})}
|
|
369
|
+
${card('Requests in this turn', table([
|
|
370
|
+
{h: 'Time', f: r => `<span class="mono">${esc((r.ts||'').slice(11,19))}</span>`},
|
|
371
|
+
{h: 'Model', f: r => `<span class="swatch" style="background:${modelColor(r.model)}"></span>${esc(modelName(r.model))}`},
|
|
372
|
+
{h: 'Effort', f: r => `<span class="pill">${esc(r.effort || 'โ')}</span>`},
|
|
373
|
+
{h: 'Input', num: 1, f: r => fmtInt(r.input_tokens)},
|
|
374
|
+
{h: 'Output', num: 1, f: r => fmtInt(r.output_tokens)},
|
|
375
|
+
{h: 'Thinking', num: 1, f: r => fmtInt(r.thinking_tokens)},
|
|
376
|
+
{h: 'Cache read', num: 1, f: r => fmtInt(r.cache_read_tokens)},
|
|
377
|
+
{h: 'Cache write', num: 1, f: r => fmtInt(r.cache_write_tokens)},
|
|
378
|
+
{h: 'Context', num: 1, f: r => fmtInt(r.context_tokens)},
|
|
379
|
+
{h: 'Latency', num: 1, f: r => r.latency_ms ? (r.latency_ms/1000).toFixed(1)+'s' : 'โ'},
|
|
380
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.est_cost_usd)},
|
|
381
|
+
], p.requests), {badge: BADGE.actual, hint: 'cost estimated'})}
|
|
382
|
+
<div class="grid g2">
|
|
383
|
+
${card('Tool calls', p.tool_summary.length ? table([
|
|
384
|
+
{h: 'Tool', f: r => esc(r.name)}, {h: 'Calls', num: 1, f: r => fmtInt(r.n)}],
|
|
385
|
+
p.tool_summary) : '<div class="na">No tool calls recorded</div>', {badge: BADGE.actual})}
|
|
386
|
+
${card('Files touched', p.files.length ? table([
|
|
387
|
+
{h: 'File', trunc: 1, title: r => r.path, f: r => `<span class="mono">${esc(r.path)}</span>`},
|
|
388
|
+
{h: 'Op', f: r => `<span class="pill">${esc(r.op)}</span>`},
|
|
389
|
+
{h: '#', num: 1, f: r => fmtInt(r.n)}], p.files)
|
|
390
|
+
: '<div class="na">No files touched</div>', {badge: BADGE.actual})}
|
|
391
|
+
</div>
|
|
392
|
+
${card('Context', `<dl class="kv">
|
|
393
|
+
<dt>Session</dt><dd><a data-sess="${esc(p.session_id)}">${esc(p.session_title || shortId(p.session_id))}</a></dd>
|
|
394
|
+
<dt>Project</dt><dd>${esc(p.project)}</dd>
|
|
395
|
+
<dt>Git branch</dt><dd>${p.git_branch ? esc(p.git_branch) : NA()}</dd>
|
|
396
|
+
<dt>Models</dt><dd>${esc(p.models || 'โ')}</dd>
|
|
397
|
+
<dt>Tool calls</dt><dd>${fmtInt(p.tool_calls)}</dd>
|
|
398
|
+
<dt>Avg latency</dt><dd>${p.latency_ms ? (p.latency_ms/1000).toFixed(1)+'s' : NA()}</dd>
|
|
399
|
+
<dt>Response text</dt><dd>${NA()} โ transcripts are parsed for usage metadata, not stored replies</dd>
|
|
400
|
+
</dl>`)}`;
|
|
401
|
+
const link = d.querySelector('[data-sess]');
|
|
402
|
+
if (link) link.onclick = () => openSession(link.dataset.sess);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function openSession(id) {
|
|
406
|
+
const d = drawer('Session detail', '<div class="loading">Loadingโฆ</div>', esc(shortId(id)));
|
|
407
|
+
const s = await fetch(`/api/session/${encodeURIComponent(id)}`).then(r => r.json());
|
|
408
|
+
const cont = d.querySelector('.content');
|
|
409
|
+
cont.innerHTML = `
|
|
410
|
+
<div class="grid g4">
|
|
411
|
+
${kpi('Estimated cost', fmtUSD(s.est_cost_usd), null, {badge: BADGE.estimated})}
|
|
412
|
+
${kpi('Billable tokens', fmtInt(s.billable_tokens))}
|
|
413
|
+
${kpi('Prompts / requests', `${fmtInt(s.prompt_count)} / ${fmtInt(s.request_count)}`)}
|
|
414
|
+
${kpi('Duration', dur(s.duration_s))}
|
|
415
|
+
${kpi('Peak context', fmtInt(s.max_context_tokens))}
|
|
416
|
+
${kpi('Avg context', fmtInt(s.avg_context_tokens))}
|
|
417
|
+
${kpi('Cost / prompt', s.prompt_count ? fmtUSD(s.est_cost_usd / s.prompt_count) : null)}
|
|
418
|
+
${kpi('Files touched', fmtInt(s.files_touched))}
|
|
419
|
+
</div>
|
|
420
|
+
${card('Context & cost per request', '<div class="chart" id="sesschart"></div>' +
|
|
421
|
+
'<div class="legend" id="sessleg"></div>', {badge: BADGE.actual,
|
|
422
|
+
hint: 'context tokens actual, cost estimated'})}
|
|
423
|
+
${card('Prompts in this session', table([
|
|
424
|
+
{h: 'Time', f: r => `<span class="mono">${esc((r.ts||'').slice(11,16))}</span>`},
|
|
425
|
+
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
426
|
+
{h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
|
|
427
|
+
{h: 'Tools', num: 1, f: r => fmtInt(r.tool_calls)},
|
|
428
|
+
{h: 'Peak ctx', num: 1, f: r => fmtInt(r.max_context)},
|
|
429
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.ptokens)},
|
|
430
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
|
|
431
|
+
], s.prompts, {onRow: 1}), {badge: BADGE.estimated, flush: 0})}
|
|
432
|
+
<div class="grid g2">
|
|
433
|
+
${card('Tools used', s.tools.length ? table([{h: 'Tool', f: r => esc(r.name)},
|
|
434
|
+
{h: 'Calls', num: 1, f: r => fmtInt(r.n)}], s.tools) : '<div class="na">None</div>')}
|
|
435
|
+
${card('Files touched', s.files.length ? table([
|
|
436
|
+
{h: 'File', trunc: 1, title: r => r.path, f: r => `<span class="mono">${esc(r.path)}</span>`},
|
|
437
|
+
{h: 'Ops', f: r => esc(r.ops)}, {h: '#', num: 1, f: r => fmtInt(r.n)}], s.files)
|
|
438
|
+
: '<div class="na">None</div>')}
|
|
439
|
+
</div>
|
|
440
|
+
${card('Session metadata', `<dl class="kv">
|
|
441
|
+
<dt>Session ID</dt><dd class="mono">${esc(s.id)}</dd>
|
|
442
|
+
<dt>Title</dt><dd>${s.title ? esc(s.title) : NA()}</dd>
|
|
443
|
+
<dt>Project</dt><dd>${esc(s.project)}</dd>
|
|
444
|
+
<dt>Git branch</dt><dd>${s.git_branch ? esc(s.git_branch) : NA()}</dd>
|
|
445
|
+
<dt>CLI version</dt><dd>${s.cli_version ? esc(s.cli_version) : NA()}</dd>
|
|
446
|
+
<dt>Started / ended</dt><dd>${esc(s.started_at||'โ')} โ ${esc(s.ended_at||'โ')}</dd>
|
|
447
|
+
<dt>Models</dt><dd>${esc(s.models || 'โ')}</dd>
|
|
448
|
+
<dt>Lines changed</dt><dd>${NA()}</dd>
|
|
449
|
+
<dt>Commits / PRs</dt><dd>${NA()}</dd>
|
|
450
|
+
</dl>`)}`;
|
|
451
|
+
wireTable(cont, s.prompts, r => openPrompt(r.prompt_id));
|
|
452
|
+
const tl = s.timeline.map((r, i) => ({i, ...r}));
|
|
453
|
+
C.timeSeries($('#sesschart', cont), {rows: tl, x: 'i', type: 'area',
|
|
454
|
+
series: [{key: 'context_tokens', label: 'Context tokens', color: seriesVar(0)}],
|
|
455
|
+
fmt: fmtNum, height: 190, xLabel: v => '#' + (v + 1)});
|
|
456
|
+
C.legend($('#sessleg', cont), [{label: 'Context tokens per request', color: seriesVar(0)}]);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
/* ---------- charts added to table-first pages ---------- */
|
|
461
|
+
// Inserts a chart card after the page's KPI row (or at the top) and draws into it.
|
|
462
|
+
function addChart(page, title, draw, opts = {}) {
|
|
463
|
+
const c = h(card(title, '<div class="cchart"></div>', opts));
|
|
464
|
+
const anchor = opts.after ? page.querySelector(opts.after) : page.querySelector(':scope > .grid');
|
|
465
|
+
if (anchor) anchor.after(c); else page.prepend(c);
|
|
466
|
+
draw(c.querySelector('.cchart'));
|
|
467
|
+
}
|
|
468
|
+
const clip = (t, n = 48) => { t = String(t || ''); return t.length > n ? t.slice(0, n - 1) + 'โฆ' : t; };
|
|
469
|
+
|
|
470
|
+
/* ============================ views ============================ */
|
|
471
|
+
const VIEWS = {};
|
|
472
|
+
|
|
473
|
+
/* ---------- overview ---------- */
|
|
474
|
+
VIEWS.overview = async (page) => {
|
|
475
|
+
const b = await api('bundle');
|
|
476
|
+
const {overview: o, burn, timeline, models, projects, categories, waste, scorecard,
|
|
477
|
+
advisor, forecast, leaderboards} = b;
|
|
478
|
+
const bp = o.billing_period;
|
|
479
|
+
const alloc = burn.allowances.cost;
|
|
480
|
+
const st = alloc.configured ? alloc.status : null;
|
|
481
|
+
const fcExp = forecast.available ? forecast.scenarios.expected.end_of_period_cost : null;
|
|
482
|
+
|
|
483
|
+
page.innerHTML = `
|
|
484
|
+
<div class="grid g5">
|
|
485
|
+
${kpi('Estimated spend', fmtUSD(o.est_cost_usd),
|
|
486
|
+
`${fmtUSD(o.cost_today.c)} today ยท ${fmtUSD(o.cost_week.c)} last 7d`, {badge: BADGE.estimated})}
|
|
487
|
+
${kpi('Billable tokens', fmtNum(o.billable_tokens),
|
|
488
|
+
`${fmtNum(o.output_tokens)} output ยท ${fmtNum(o.cache_read_tokens)} cache read`,
|
|
489
|
+
{badge: BADGE.actual})}
|
|
490
|
+
${alloc.configured
|
|
491
|
+
? kpi('Plan usage', fmtPct(alloc.used_pct), statusChip(alloc.status), {badge: BADGE.estimated})
|
|
492
|
+
: kpi('Plan usage', null, 'Set monthly_cost_allowance_usd in config/settings.json')}
|
|
493
|
+
${alloc.configured
|
|
494
|
+
? kpi('Remaining', fmtUSD(alloc.remaining),
|
|
495
|
+
`${alloc.days_until_limit ?? 'โ'} days at current burn`, {badge: BADGE.estimated})
|
|
496
|
+
: kpi('Remaining allowance', null, `No plan limit exposed by ${agentWord()} data`)}
|
|
497
|
+
${kpi('Period forecast', fcExp == null ? null : fmtUSD(fcExp),
|
|
498
|
+
`${bp.remaining_days} days left in ${bp.start} โ ${bp.end}`, {badge: BADGE.forecast})}
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
<div class="grid g32">
|
|
502
|
+
${card('Usage burn rate', `<div class="grid g4" style="gap:8px">
|
|
503
|
+
${kpi('Period to date', fmtUSD(burn.used.cost_usd), null, {small: 1})}
|
|
504
|
+
${kpi('Daily average', fmtUSD(burn.daily_avg_cost), null, {small: 1})}
|
|
505
|
+
${kpi('7-day burn rate', fmtUSD(burn.avg7_cost), 'per day', {small: 1})}
|
|
506
|
+
${kpi('Projected period', fmtUSD(burn.projected_period_cost),
|
|
507
|
+
`at ${fmtUSD(burn.burn_rate_cost_per_day)}/day`, {small: 1})}
|
|
508
|
+
</div>
|
|
509
|
+
<div style="display:flex;gap:18px;align-items:center;margin-top:10px;flex-wrap:wrap">
|
|
510
|
+
<div class="chart" id="gauge" style="width:180px;flex:none"></div>
|
|
511
|
+
<div style="flex:1;min-width:220px" id="burnnotes"></div>
|
|
512
|
+
</div>`, {badge: BADGE.estimated,
|
|
513
|
+
footer: esc(burn.forecast_note || '')})}
|
|
514
|
+
${card('Cost trend', `<div class="legend" id="trendleg"></div>
|
|
515
|
+
<div class="chart" id="trend"></div>`, {badge: BADGE.estimated,
|
|
516
|
+
actions: `<span class="note">click a day to filter</span>`})}
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
<div class="grid g2">
|
|
520
|
+
${card('Model cost', `<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
|
521
|
+
<div class="chart" id="mdonut"></div><div style="flex:1;min-width:230px" id="mtable"></div></div>`,
|
|
522
|
+
{badge: BADGE.estimated})}
|
|
523
|
+
${card('Project cost', '<div class="chart" id="pbars"></div>', {badge: BADGE.estimated,
|
|
524
|
+
hint: 'top 10 ยท click to drill in'})}
|
|
525
|
+
</div>
|
|
526
|
+
|
|
527
|
+
<div class="grid g2">
|
|
528
|
+
${card('Top 10 most expensive prompts', '<div id="topprompts"></div>',
|
|
529
|
+
{badge: BADGE.estimated, hint: 'click a row for full prompt + advice'})}
|
|
530
|
+
${card('Most expensive sessions', '<div id="topsessions"></div>', {badge: BADGE.estimated})}
|
|
531
|
+
</div>
|
|
532
|
+
|
|
533
|
+
<div class="grid g2">
|
|
534
|
+
${card('Waste detection', `<div class="stack">${waste.findings.slice(0, 5).map(f =>
|
|
535
|
+
`<div class="item sev-${f.severity}"><div class="hd">${statusGlyph(
|
|
536
|
+
f.severity === 'high' ? 'critical' : f.severity === 'medium' ? 'high' : 'healthy')}
|
|
537
|
+
${esc(f.title)}<span class="spacer"></span>
|
|
538
|
+
<span style="font-variant-numeric:tabular-nums">${fmtUSD(f.est_excess_usd)} excess</span></div>
|
|
539
|
+
<div class="dt">${esc(f.detail)}</div></div>`).join('')}</div>`,
|
|
540
|
+
{badge: BADGE.estimated,
|
|
541
|
+
hint: `${fmtPct(waste.high_severity_pct)} high-severity exposure`,
|
|
542
|
+
footer: esc(waste.note)})}
|
|
543
|
+
${card('Optimization opportunities', `<div class="stack">${
|
|
544
|
+
b.recommendations.recommendations.length
|
|
545
|
+
? b.recommendations.recommendations.slice(0, 5).map(r => `<div class="item">
|
|
546
|
+
<div class="hd">${esc(r.title)}<span class="spacer"></span>
|
|
547
|
+
<span class="badge rec">${r.confidence} confidence</span></div>
|
|
548
|
+
<div class="mt"><span>Actual ${fmtUSD(r.actual_cost_usd)}</span>
|
|
549
|
+
<span>Est. alternative ${fmtUSD(r.estimated_alternative_cost_usd)}</span>
|
|
550
|
+
<span style="color:var(--good-ink);font-weight:600">
|
|
551
|
+
Est. saving ${fmtUSD(r.estimated_savings_usd)} (${r.estimated_savings_pct}%)</span></div>
|
|
552
|
+
<div class="note">${esc(r.caveat)}</div></div>`).join('')
|
|
553
|
+
: '<div class="empty">No recommendation met the evidence threshold</div>'}</div>`,
|
|
554
|
+
{badge: BADGE.recommendation})}
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
<div class="grid g2">
|
|
558
|
+
${card('Forecast', '<div class="chart" id="fan"></div><div class="legend" id="fanleg"></div>',
|
|
559
|
+
{badge: BADGE.forecast, hint: forecast.available ? forecast.method : ''})}
|
|
560
|
+
${card('FinOps score', `<div class="scorewrap">
|
|
561
|
+
<div><div class="scorenum">${scorecard.score}</div>
|
|
562
|
+
<div class="scoregrade">out of 100 ยท grade ${scorecard.grade}</div></div>
|
|
563
|
+
<div style="flex:1;min-width:230px" class="stack">${scorecard.dimensions.map(d => `
|
|
564
|
+
<div><div style="display:flex;justify-content:space-between;font-size:11.5px">
|
|
565
|
+
<span>${esc(d.name)}</span><span style="font-variant-numeric:tabular-nums">${d.score}</span></div>
|
|
566
|
+
<div class="meter ${d.score >= 75 ? 'healthy' : d.score >= 50 ? 'high'
|
|
567
|
+
: d.score >= 30 ? 'approaching' : 'critical'}"><i style="width:${d.score}%"></i></div></div>`).join('')}
|
|
568
|
+
</div></div>`, {badge: BADGE.estimated})}
|
|
569
|
+
</div>`;
|
|
570
|
+
|
|
571
|
+
// burn gauge + notes
|
|
572
|
+
const gEl = $('#gauge', page);
|
|
573
|
+
if (alloc.configured) {
|
|
574
|
+
C.gauge(gEl, {pct: alloc.used_pct, status: alloc.status, label: 'of plan allowance'});
|
|
575
|
+
$('#burnnotes', page).innerHTML = `<div class="stack">
|
|
576
|
+
<div class="item"><div class="hd">${statusGlyph(alloc.status)} You are likely to reach your
|
|
577
|
+
current limit in <b>${alloc.days_until_limit}</b> days${alloc.limit_date
|
|
578
|
+
? ` (around ${esc(alloc.limit_date)})` : ''}.</div></div>
|
|
579
|
+
<div class="item"><div class="hd">At the current burn rate you will
|
|
580
|
+
${alloc.projected_overage_pct > 0 ? `exceed your allowance by
|
|
581
|
+
<b>${fmtPct(alloc.projected_overage_pct)}</b>` : `finish the period at
|
|
582
|
+
<b>${fmtPct(100 + alloc.projected_overage_pct)}</b> of allowance`}.</div></div></div>`;
|
|
583
|
+
} else {
|
|
584
|
+
const pctOfPeriod = bp.pct_elapsed;
|
|
585
|
+
C.gauge(gEl, {pct: pctOfPeriod, status: 'healthy', label: 'of billing period elapsed'});
|
|
586
|
+
$('#burnnotes', page).innerHTML = `<div class="stack">
|
|
587
|
+
<div class="item"><div class="hd">No plan allowance available</div>
|
|
588
|
+
<div class="dt">${agentWord()} data does not expose plan limits, remaining credits,
|
|
589
|
+
or message allowances. Set <span class="mono">limits.monthly_cost_allowance_usd</span>
|
|
590
|
+
in <span class="mono">config/settings.json</span> to unlock usage-vs-limit,
|
|
591
|
+
days-until-limit and limit-date projections.</div></div>
|
|
592
|
+
<div class="item"><div class="hd">Days until limit</div><div class="dt">${
|
|
593
|
+
S.opts.unavailable_label} โ requires a configured allowance.</div></div></div>`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// trend
|
|
597
|
+
const tSeries = [{key: 'cost', label: 'Estimated cost', color: seriesVar(0), fmt: fmtUSD}];
|
|
598
|
+
C.legend($('#trendleg', page), tSeries.map(s => ({label: s.label, color: s.color})));
|
|
599
|
+
C.timeSeries($('#trend', page), {rows: timeline, x: 'bucket', series: tSeries, type: 'bar',
|
|
600
|
+
fmt: fmtUSD, height: 218, xLabel: shortDay,
|
|
601
|
+
onClick: r => { S.filter.start = r.bucket; S.filter.end = r.bucket; S.range = 'custom';
|
|
602
|
+
bust(); render(); }});
|
|
603
|
+
|
|
604
|
+
// models
|
|
605
|
+
const mr = models.rows.filter(r => r.cost > 0);
|
|
606
|
+
C.donut($('#mdonut', page), {rows: mr, label: r => r.display_name, value: r => r.cost,
|
|
607
|
+
size: 176, color: r => modelColor(r.model), centerValue: fmtUSD(o.est_cost_usd), centerLabel: 'estimated',
|
|
608
|
+
onClick: r => { S.filter.models = [r.model]; bust(); render(); }});
|
|
609
|
+
$('#mtable', page).innerHTML = table([
|
|
610
|
+
{h: 'Model', f: r => `<span class="swatch" style="background:${modelColor(r.model)}"></span>${esc(r.display_name)}`},
|
|
611
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
612
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
613
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
614
|
+
{h: '% cost', num: 1, f: r => fmtPct(r.cost_pct)},
|
|
615
|
+
], mr);
|
|
616
|
+
|
|
617
|
+
C.barsH($('#pbars', page), {rows: projects.slice(0, 10), label: r => r.name,
|
|
618
|
+
value: r => r.cost, color: seriesVar(2),
|
|
619
|
+
sub: r => `<div class="row"><span class="k">Sessions</span><span class="v">${fmtInt(r.sessions)}</span></div>
|
|
620
|
+
<div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(r.tokens)}</span></div>`,
|
|
621
|
+
onClick: r => { S.filter.projects = [String(r.project_id)]; bust(); go('sessions'); }});
|
|
622
|
+
|
|
623
|
+
const lp = leaderboards.most_expensive.slice(0, 10);
|
|
624
|
+
$('#topprompts', page).innerHTML = table([
|
|
625
|
+
{h: '#', num: 1, f: (r) => lp.indexOf(r) + 1},
|
|
626
|
+
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
627
|
+
{h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
|
|
628
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.ptokens)},
|
|
629
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
|
|
630
|
+
], lp, {onRow: 1});
|
|
631
|
+
wireTable($('#topprompts', page), lp, r => openPrompt(r.prompt_id));
|
|
632
|
+
|
|
633
|
+
const ls = await api('sessions', '&limit=10&order=cost');
|
|
634
|
+
$('#topsessions', page).innerHTML = table([
|
|
635
|
+
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
636
|
+
f: r => esc(r.title || shortId(r.session_id))},
|
|
637
|
+
{h: 'Project', f: r => esc(r.project)},
|
|
638
|
+
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
639
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
640
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
641
|
+
], ls, {onRow: 1});
|
|
642
|
+
wireTable($('#topsessions', page), ls, r => openSession(r.session_id));
|
|
643
|
+
|
|
644
|
+
if (forecast.available) {
|
|
645
|
+
C.forecastFan($('#fan', page), {history: burn.series, scenarios: forecast.scenarios,
|
|
646
|
+
remainingDays: forecast.remaining_days});
|
|
647
|
+
$('#fanleg', page).innerHTML = `<span class="it"><span class="swatch"
|
|
648
|
+
style="background:var(--s1)"></span>Cumulative actual (estimated cost)</span>
|
|
649
|
+
<span class="it"><span class="swatch" style="background:var(--s1);opacity:.35"></span>
|
|
650
|
+
Forecast band: conservative โ high</span>`;
|
|
651
|
+
} else $('#fan', page).innerHTML = '<div class="empty">Not enough history to forecast</div>';
|
|
652
|
+
|
|
653
|
+
renderAdvisorHero(page, advisor);
|
|
654
|
+
// focus strip loads after the page so a slow diagnosis never delays the overview
|
|
655
|
+
api('diagnose').then(d => {
|
|
656
|
+
if (!page.isConnected) return;
|
|
657
|
+
const el = h(focusStrip(d) || '<div></div>');
|
|
658
|
+
page.insertBefore(el, page.firstChild);
|
|
659
|
+
wireFocus(page);
|
|
660
|
+
}).catch(() => {});
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
function renderAdvisorHero(page, advisor) {
|
|
664
|
+
if (!page || !advisor || !Array.isArray(advisor.actions)) return;
|
|
665
|
+
const hero = h(`<div class="hero"><h3>โฆ AI FinOps Advisor โ ${esc(advisor.question)}
|
|
666
|
+
<span class="spacer"></span>
|
|
667
|
+
<span class="note" style="font-weight:400">${esc(advisor.generated_from)}</span></h3>
|
|
668
|
+
<div class="advisor-list">${advisor.actions.length ? advisor.actions.map((a, i) =>
|
|
669
|
+
`<div class="advisor-item"><div class="num p${Math.min(a.priority, 3)}"></div>
|
|
670
|
+
<div class="tx"><b>${esc(a.text)} <span class="badge ${a.basis === 'forecast' ? 'fc'
|
|
671
|
+
: a.basis === 'recommendation' ? 'rec' : 'est'}">${esc(a.basis.split(':')[0])}</span></b>
|
|
672
|
+
<span>${esc(a.detail)}</span></div></div>`).join('')
|
|
673
|
+
: '<div class="empty">Nothing needs your attention in this range</div>'}</div>
|
|
674
|
+
${advisor.estimated_savings_range_usd[1] > 0 ? `<div class="note" style="margin-top:9px">
|
|
675
|
+
Estimated savings opportunity in range: <b>${fmtUSD(advisor.estimated_savings_range_usd[0])}
|
|
676
|
+
โ ${fmtUSD(advisor.estimated_savings_range_usd[1])}</b> โ modelled, not booked.</div>` : ''}
|
|
677
|
+
</div>`);
|
|
678
|
+
page.insertBefore(hero, page.firstChild);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/* ---------- advisor view ---------- */
|
|
682
|
+
VIEWS.advisor = async (page) => {
|
|
683
|
+
const [advisor, waste, recs, anos, sc] = await Promise.all([api('advisor'), api('waste'),
|
|
684
|
+
api('recommendations'), api('anomalies'), api('scorecard')]);
|
|
685
|
+
page.innerHTML = `
|
|
686
|
+
<div class="grid g2">
|
|
687
|
+
${card('Biggest optimization opportunity', sc.biggest_opportunity ? `
|
|
688
|
+
<div class="item sev-high"><div class="hd">${esc(sc.biggest_opportunity.title)}</div>
|
|
689
|
+
<div class="dt">${esc(sc.biggest_opportunity.detail)}</div>
|
|
690
|
+
<div class="mt"><span>Estimated excess
|
|
691
|
+
<b>${fmtUSD(sc.biggest_opportunity.estimated_excess_usd)}</b></span>
|
|
692
|
+
<span>of ${fmtUSD(sc.biggest_opportunity.exposed_cost_usd)} exposed</span></div>
|
|
693
|
+
<div class="dt"><b>Action:</b> ${esc(sc.biggest_opportunity.action || '')}</div></div>`
|
|
694
|
+
: '<div class="empty">Nothing flagged</div>', {badge: BADGE.estimated})}
|
|
695
|
+
${card('What is going well', `<ul style="margin:0 0 0 18px;font-size:12.5px;color:var(--text-2)">
|
|
696
|
+
${sc.what_is_good.map(x => `<li>${esc(x)}</li>`).join('') || '<li class="na">โ</li>'}</ul>
|
|
697
|
+
<h3 style="font-size:12px;margin:12px 0 5px">Needs attention</h3>
|
|
698
|
+
<ul style="margin:0 0 0 18px;font-size:12.5px;color:var(--text-2)">
|
|
699
|
+
${sc.needs_attention.map(x => `<li>${esc(x)}</li>`).join('') || '<li class="na">โ</li>'}</ul>`)}
|
|
700
|
+
</div>
|
|
701
|
+
${card('All recommendations', recs.recommendations.length ? `<div class="stack">
|
|
702
|
+
${recs.recommendations.map(r => `<div class="item"><div class="hd">${esc(r.title)}
|
|
703
|
+
<span class="spacer"></span><span class="badge rec">${esc(r.confidence)} confidence</span></div>
|
|
704
|
+
${r.current_model ? `<div class="dt"><b>Currently on:</b> ${esc(r.current_model)}${
|
|
705
|
+
r.scope ? ` ยท ${esc(r.scope)}` : ''}</div>` : ''}
|
|
706
|
+
<div class="grid g3" style="gap:8px;margin:4px 0">
|
|
707
|
+
${kpi('Actual cost', fmtUSD(r.actual_cost_usd), null, {small: 1, badge: BADGE.estimated})}
|
|
708
|
+
${kpi('Est. alternative', fmtUSD(r.estimated_alternative_cost_usd), null, {small: 1})}
|
|
709
|
+
${kpi('Est. potential saving', fmtUSD(r.estimated_savings_usd),
|
|
710
|
+
`${r.estimated_savings_pct}%`, {small: 1, badge: BADGE.recommendation})}
|
|
711
|
+
</div>
|
|
712
|
+
${r.alternatives && r.alternatives.length ? `<div class="dt" style="margin-top:6px">
|
|
713
|
+
<b>Your options${r.agent ? ` within ${esc(r.agent)}` : ''}</b> โ pick the trade-off you want:</div>
|
|
714
|
+
${table([
|
|
715
|
+
{h: 'Model', f: a => esc(a.name) + (a.suggested
|
|
716
|
+
? ' <span class="badge rec">suggested</span>' : '')},
|
|
717
|
+
{h: 'Tier', f: a => esc(a.tier)},
|
|
718
|
+
{h: 'Est. cost', num: 1, f: a => fmtUSD(a.estimated_cost_usd)},
|
|
719
|
+
{h: 'Est. saving', num: 1, f: a =>
|
|
720
|
+
`${fmtUSD(a.estimated_savings_usd)} <span class="note">(${a.estimated_savings_pct}%)</span>`},
|
|
721
|
+
], r.alternatives)}` : ''}
|
|
722
|
+
<div class="note">${esc(r.caveat)}</div></div>`).join('')}</div>`
|
|
723
|
+
: '<div class="empty">No recommendation met the evidence threshold for this range</div>',
|
|
724
|
+
{badge: BADGE.recommendation,
|
|
725
|
+
footer: 'Estimated potential savings are modelled from token counts and configured pricing. They are opportunities to evaluate, never booked savings, and do not account for output quality.'})}
|
|
726
|
+
${card('Anomalies to inspect', `<div class="stack" id="anolist">${anos.anomalies.map((a, i) =>
|
|
727
|
+
`<div class="item sev-${a.severity} clickable" data-i="${i}"><div class="hd">
|
|
728
|
+
${a.severity === 'high' ? '๐จ' : 'โ ๏ธ'} ${esc(a.title)}</div>
|
|
729
|
+
<div class="dt">${esc(a.detail)}</div>
|
|
730
|
+
<div class="mt"><span>ratio ${a.ratio}ร</span><span>click to inspect</span></div></div>`).join('')
|
|
731
|
+
|| '<div class="empty">No anomalies detected</div>'}</div>`, {badge: BADGE.estimated})}`;
|
|
732
|
+
renderAdvisorHero(page, advisor);
|
|
733
|
+
wireAnomalies(page, anos.anomalies);
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
function wireAnomalies(page, list) {
|
|
737
|
+
page.querySelectorAll('#anolist .item[data-i]').forEach(el => el.onclick = () => {
|
|
738
|
+
const a = list[+el.dataset.i];
|
|
739
|
+
if (a.drilldown?.session_id) return openSession(a.drilldown.session_id);
|
|
740
|
+
if (a.drilldown?.filter) { Object.assign(S.filter, a.drilldown.filter);
|
|
741
|
+
S.range = 'custom'; bust(); go('prompts'); }
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/* ---------- usage timeline ---------- */
|
|
746
|
+
const METRICS = [
|
|
747
|
+
['cost', 'Estimated cost', fmtUSD], ['tokens', 'Billable tokens', fmtNum],
|
|
748
|
+
['requests', 'Requests', fmtInt], ['sessions', 'Sessions', fmtInt],
|
|
749
|
+
['prompts', 'Prompts', fmtInt],
|
|
750
|
+
['input_tokens', 'Input tokens', fmtNum], ['output_tokens', 'Output tokens', fmtNum],
|
|
751
|
+
['cache_read_tokens', 'Cache read', fmtNum], ['cache_write_tokens', 'Cache write', fmtNum],
|
|
752
|
+
['avg_context', 'Avg context', fmtNum],
|
|
753
|
+
];
|
|
754
|
+
VIEWS.usage = async (page) => {
|
|
755
|
+
const [tl, models, ov] = await Promise.all([api('timeline', `&grain=${S.grain}`),
|
|
756
|
+
api('models'), api('overview')]);
|
|
757
|
+
const [, mlabel, mfmt] = METRICS.find(m => m[0] === S.metric) || METRICS[0];
|
|
758
|
+
page.innerHTML = `
|
|
759
|
+
<div class="grid g5">
|
|
760
|
+
${kpi('Total tokens', fmtNum(ov.billable_tokens), null, {badge: BADGE.actual})}
|
|
761
|
+
${kpi('Input', fmtNum(ov.input_tokens), 'uncached input only')}
|
|
762
|
+
${kpi('Output', fmtNum(ov.output_tokens), `${fmtNum(ov.thinking_tokens)} thinking`)}
|
|
763
|
+
${kpi('Cache read', fmtNum(ov.cache_read_tokens))}
|
|
764
|
+
${kpi('Cache write', fmtNum(ov.cache_write_tokens))}
|
|
765
|
+
${kpi('Requests', fmtInt(ov.requests))}
|
|
766
|
+
${kpi('Sessions', fmtInt(ov.sessions))}
|
|
767
|
+
${kpi('Prompts', fmtInt(ov.prompts))}
|
|
768
|
+
${kpi('Active days', fmtInt(ov.active_days))}
|
|
769
|
+
${kpi('Est. spend', fmtUSD(ov.est_cost_usd), null, {badge: BADGE.estimated})}
|
|
770
|
+
</div>
|
|
771
|
+
${card(mlabel + ' over time', `
|
|
772
|
+
<div class="filters" style="margin:0 0 8px">
|
|
773
|
+
${METRICS.map(([k, l]) => `<button class="chip ${S.metric === k ? 'on' : ''}"
|
|
774
|
+
data-metric="${k}">${l}</button>`).join('')}
|
|
775
|
+
<span class="divider"></span>
|
|
776
|
+
<button class="chip ${S.grain === 'day' ? 'on' : ''}" data-grain="day">Daily</button>
|
|
777
|
+
<button class="chip ${S.grain === 'hour' ? 'on' : ''}" data-grain="hour">Hourly</button>
|
|
778
|
+
</div>
|
|
779
|
+
<div class="chart" id="tl"></div>`,
|
|
780
|
+
{badge: S.metric === 'cost' ? BADGE.estimated : BADGE.actual,
|
|
781
|
+
hint: 'click a bucket to drill into that day', flush: 0})}
|
|
782
|
+
${card('Model mix over time', '<div class="legend" id="mixleg"></div>' +
|
|
783
|
+
'<div class="chart" id="mix"></div>', {badge: BADGE.estimated,
|
|
784
|
+
hint: 'stacked estimated cost per model'})}
|
|
785
|
+
${card('Daily detail', '<div id="dtl"></div>', {badge: BADGE.estimated,
|
|
786
|
+
hint: 'click a row to filter the whole dashboard to that day'})}`;
|
|
787
|
+
|
|
788
|
+
page.querySelectorAll('[data-metric]').forEach(b => b.onclick = () => {
|
|
789
|
+
S.metric = b.dataset.metric; render(); });
|
|
790
|
+
page.querySelectorAll('[data-grain]').forEach(b => b.onclick = () => {
|
|
791
|
+
S.grain = b.dataset.grain; bust(); render(); });
|
|
792
|
+
|
|
793
|
+
C.timeSeries($('#tl', page), {rows: tl, x: 'bucket',
|
|
794
|
+
series: [{key: S.metric, label: mlabel, color: seriesVar(0), fmt: mfmt}],
|
|
795
|
+
fmt: mfmt, type: S.metric === 'avg_context' ? 'area' : 'bar', height: 260,
|
|
796
|
+
xLabel: v => S.grain === 'day' ? shortDay(v) : v.slice(8).replace('T', ' ') + ':00',
|
|
797
|
+
onClick: r => { if (S.grain === 'day') { S.filter.start = r.bucket; S.filter.end = r.bucket;
|
|
798
|
+
S.range = 'custom'; bust(); render(); } }});
|
|
799
|
+
|
|
800
|
+
// per-model stacked mix
|
|
801
|
+
const byDay = new Map();
|
|
802
|
+
const mlist = models.rows.filter(r => r.cost > 0).map(r => r.model);
|
|
803
|
+
const perModel = await Promise.all(mlist.map(async m => {
|
|
804
|
+
const r = await fetch(`/api/timeline?${qs()}&models=${encodeURIComponent(m)}`)
|
|
805
|
+
.then(x => x.json()).catch(() => []);
|
|
806
|
+
return [m, Array.isArray(r) ? r : []];
|
|
807
|
+
}));
|
|
808
|
+
for (const [m, rows] of perModel) for (const r of rows) {
|
|
809
|
+
if (!byDay.has(r.bucket)) byDay.set(r.bucket, {bucket: r.bucket});
|
|
810
|
+
byDay.get(r.bucket)[m] = r.cost;
|
|
811
|
+
}
|
|
812
|
+
const mixRows = [...byDay.values()].sort((a, b) => a.bucket < b.bucket ? -1 : 1);
|
|
813
|
+
const mixSeries = mlist.map(m => ({key: m, label: modelName(m), color: modelColor(m), fmt: fmtUSD}));
|
|
814
|
+
C.legend($('#mixleg', page), mixSeries.map(s => ({label: s.label, color: s.color})));
|
|
815
|
+
C.timeSeries($('#mix', page), {rows: mixRows, x: 'bucket', series: mixSeries, type: 'bar',
|
|
816
|
+
fmt: fmtUSD, height: 220, xLabel: shortDay});
|
|
817
|
+
|
|
818
|
+
const detail = [...tl].reverse();
|
|
819
|
+
$('#dtl', page).innerHTML = table([
|
|
820
|
+
{h: S.grain === 'day' ? 'Day' : 'Hour', f: r => esc(r.bucket)},
|
|
821
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
822
|
+
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
823
|
+
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
824
|
+
{h: 'Input', num: 1, f: r => fmtNum(r.input_tokens)},
|
|
825
|
+
{h: 'Output', num: 1, f: r => fmtNum(r.output_tokens)},
|
|
826
|
+
{h: 'Cache read', num: 1, f: r => fmtNum(r.cache_read_tokens)},
|
|
827
|
+
{h: 'Cache write', num: 1, f: r => fmtNum(r.cache_write_tokens)},
|
|
828
|
+
{h: 'Avg context', num: 1, f: r => fmtNum(r.avg_context)},
|
|
829
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
830
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
831
|
+
], detail, {onRow: 1});
|
|
832
|
+
wireTable($('#dtl', page), detail, r => { if (S.grain === 'day') {
|
|
833
|
+
S.filter.start = r.bucket; S.filter.end = r.bucket; S.range = 'custom'; bust(); render(); }});
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
/* ---------- burn & limits ---------- */
|
|
837
|
+
VIEWS.burn = async (page) => {
|
|
838
|
+
const burn = await api('burn');
|
|
839
|
+
const bp = burn.period;
|
|
840
|
+
const rows = Object.entries(burn.allowances);
|
|
841
|
+
page.innerHTML = `
|
|
842
|
+
<div class="grid g4">
|
|
843
|
+
${kpi('Period', `${bp.start} โ ${bp.end}`, `${bp.elapsed_days} of ${bp.total_days} days elapsed`,
|
|
844
|
+
{small: 1, badge: BADGE.actual})}
|
|
845
|
+
${kpi('Days remaining', fmtInt(bp.remaining_days), fmtPct(bp.pct_elapsed) + ' elapsed')}
|
|
846
|
+
${kpi('Used this period', fmtUSD(burn.used.cost_usd),
|
|
847
|
+
`${fmtNum(burn.used.tokens)} tokens ยท ${fmtInt(burn.used.requests)} requests`,
|
|
848
|
+
{badge: BADGE.estimated})}
|
|
849
|
+
${kpi('Projected period total', fmtUSD(burn.projected_period_cost),
|
|
850
|
+
`${fmtNum(burn.projected_period_tokens)} tokens`, {badge: BADGE.forecast})}
|
|
851
|
+
${kpi('Daily average', fmtUSD(burn.daily_avg_cost), fmtNum(burn.daily_avg_tokens) + ' tokens/day')}
|
|
852
|
+
${kpi('7-day average', fmtUSD(burn.avg7_cost), fmtNum(burn.avg7_tokens) + ' tokens/day')}
|
|
853
|
+
${kpi('Projection rate', fmtUSD(burn.burn_rate_cost_per_day),
|
|
854
|
+
`${burn.burn_rate_window_days}-day mean ยท per day`, {badge: BADGE.estimated})}
|
|
855
|
+
${kpi('Remaining credits', burn.remaining_credits_usd === S.opts.unavailable_label
|
|
856
|
+
? null : fmtUSD(burn.remaining_credits_usd))}
|
|
857
|
+
</div>
|
|
858
|
+
<div class="grid g3">${rows.map(([k, a]) => card(
|
|
859
|
+
`Allowance: ${k}`,
|
|
860
|
+
a.configured ? `
|
|
861
|
+
<div style="display:flex;gap:14px;align-items:center;flex-wrap:wrap">
|
|
862
|
+
<div class="chart" id="g-${k}" style="width:170px;flex:none"></div>
|
|
863
|
+
<div style="flex:1;min-width:170px"><dl class="kv">
|
|
864
|
+
<dt>Used</dt><dd>${k === 'cost' ? fmtUSD(a.used) : fmtNum(a.used)} (${fmtPct(a.used_pct)})</dd>
|
|
865
|
+
<dt>Remaining</dt><dd>${k === 'cost' ? fmtUSD(a.remaining) : fmtNum(a.remaining)}
|
|
866
|
+
(${fmtPct(a.remaining_pct)})</dd>
|
|
867
|
+
<dt>Allowance</dt><dd>${k === 'cost' ? fmtUSD(a.allowance) : fmtNum(a.allowance)}</dd>
|
|
868
|
+
<dt>Days to limit</dt><dd>${a.days_until_limit ?? 'โ'}</dd>
|
|
869
|
+
<dt>Limit date</dt><dd>${a.limit_date ? esc(a.limit_date) : 'โ'}</dd>
|
|
870
|
+
<dt>Projected</dt><dd>${k === 'cost' ? fmtUSD(a.projected_end_of_period)
|
|
871
|
+
: fmtNum(a.projected_end_of_period)}</dd>
|
|
872
|
+
<dt>Overage</dt><dd>${fmtPct(a.projected_overage_pct)}</dd>
|
|
873
|
+
</dl></div></div>
|
|
874
|
+
<div class="item" style="margin-top:9px">
|
|
875
|
+
<div class="hd">${statusGlyph(a.status)} You are likely to reach this limit in
|
|
876
|
+
<b>${a.days_until_limit}</b> days.</div>
|
|
877
|
+
<div class="dt">At the current burn rate you will
|
|
878
|
+
${a.projected_overage_pct > 0
|
|
879
|
+
? `exceed the allowance by <b>${fmtPct(a.projected_overage_pct)}</b>.`
|
|
880
|
+
: `finish at <b>${fmtPct(100 + a.projected_overage_pct)}</b> of allowance.`}</div></div>`
|
|
881
|
+
: `<div class="empty" style="text-align:left">
|
|
882
|
+
<div class="na">${esc(a.message)}</div>
|
|
883
|
+
<p style="font-size:11.5px;color:var(--muted);margin-top:8px">Claude Code transcripts
|
|
884
|
+
record token usage only โ they carry no plan tier, message allowance, credit balance
|
|
885
|
+
or billed amount. Configure this in
|
|
886
|
+
<span class="mono">config/settings.json โ limits</span> to enable usage-vs-limit,
|
|
887
|
+
days-until-limit and limit-date projections.</p></div>`,
|
|
888
|
+
{badge: a.configured ? BADGE.estimated : ''})).join('')}</div>
|
|
889
|
+
${card('Daily consumption within the billing period', '<div class="chart" id="bs"></div>',
|
|
890
|
+
{badge: BADGE.estimated})}`;
|
|
891
|
+
for (const [k, a] of rows) if (a.configured)
|
|
892
|
+
C.gauge($(`#g-${k}`, page), {pct: a.used_pct, status: a.status, label: 'of allowance'});
|
|
893
|
+
C.timeSeries($('#bs', page), {rows: burn.series, x: 'day', type: 'bar',
|
|
894
|
+
series: [{key: 'cost', label: 'Estimated cost', color: seriesVar(0), fmt: fmtUSD}],
|
|
895
|
+
fmt: fmtUSD, height: 220, xLabel: shortDay});
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
/* ---------- models ---------- */
|
|
899
|
+
VIEWS.models = async (page) => {
|
|
900
|
+
const m = await api('models');
|
|
901
|
+
const sup = m.superlatives, rows = m.rows;
|
|
902
|
+
const find = k => rows.find(r => r.model === sup[k]);
|
|
903
|
+
page.innerHTML = `
|
|
904
|
+
<div class="grid g4">
|
|
905
|
+
${kpi('Most expensive', sup.most_expensive ? esc(modelName(sup.most_expensive)) : null,
|
|
906
|
+
find('most_expensive') ? fmtUSD(find('most_expensive').cost) + ' estimated' : '', {small: 1})}
|
|
907
|
+
${kpi('Most frequently used', sup.most_used ? esc(modelName(sup.most_used)) : null,
|
|
908
|
+
find('most_used') ? fmtInt(find('most_used').requests) + ' requests' : '', {small: 1})}
|
|
909
|
+
${kpi('Most token-efficient', sup.most_token_efficient ? esc(modelName(sup.most_token_efficient)) : null,
|
|
910
|
+
find('most_token_efficient')
|
|
911
|
+
? fmtPct(find('most_token_efficient').output_per_input * 100, 2) + ' output per prompt token' : '',
|
|
912
|
+
{small: 1})}
|
|
913
|
+
${kpi('Best cost per output', sup.best_cost_per_output ? esc(modelName(sup.best_cost_per_output)) : null,
|
|
914
|
+
find('best_cost_per_output')
|
|
915
|
+
? '$' + find('best_cost_per_output').cost_per_1k_output.toFixed(3) + ' / 1K output' : '',
|
|
916
|
+
{small: 1, badge: BADGE.estimated})}
|
|
917
|
+
</div>
|
|
918
|
+
<div class="grid g2">
|
|
919
|
+
${card('Cost share', `<div style="display:flex;justify-content:center">
|
|
920
|
+
<div class="chart" id="md"></div></div><div class="legend" id="mdl"></div>`,
|
|
921
|
+
{badge: BADGE.estimated})}
|
|
922
|
+
${card('Token share', '<div class="chart" id="mb"></div>', {badge: BADGE.actual})}
|
|
923
|
+
</div>
|
|
924
|
+
${card('Model FinOps table', '<div id="mt"></div>', {badge: BADGE.estimated,
|
|
925
|
+
hint: 'models detected from your data โ nothing hardcoded',
|
|
926
|
+
footer: 'Pricing from config/pricing.json (updated ' + esc(S.opts.pricing.updated)
|
|
927
|
+
+ '). Models with no price entry fall back to default pricing and are marked.'})}
|
|
928
|
+
${card('Price table in effect', table([
|
|
929
|
+
{h: 'Model', f: r => `<span class="swatch" style="background:${modelColor(r[0])}"></span>${esc(r[1].display_name || r[0])}`},
|
|
930
|
+
{h: 'Tier', f: r => `<span class="pill">${esc(r[1].tier)}</span>`},
|
|
931
|
+
{h: 'Input /M', num: 1, f: r => '$' + r[1].input},
|
|
932
|
+
{h: 'Output /M', num: 1, f: r => '$' + r[1].output},
|
|
933
|
+
{h: 'Cache write 5m /M', num: 1, f: r => '$' + r[1].cache_write_5m},
|
|
934
|
+
{h: 'Cache write 1h /M', num: 1, f: r => '$' + r[1].cache_write_1h},
|
|
935
|
+
{h: 'Cache read /M', num: 1, f: r => '$' + r[1].cache_read},
|
|
936
|
+
{h: 'Context window', num: 1, f: r => r[1].context_window ? fmtNum(r[1].context_window) : 'โ'},
|
|
937
|
+
], Object.entries(S.opts.pricing.models)), {hint: 'edit config/pricing.json to update โ no code changes needed'})}`;
|
|
938
|
+
|
|
939
|
+
const priced = rows.filter(r => r.cost > 0);
|
|
940
|
+
C.donut($('#md', page), {rows: priced, label: r => r.display_name, value: r => r.cost, size: 200,
|
|
941
|
+
color: r => modelColor(r.model),
|
|
942
|
+
centerValue: fmtUSD(priced.reduce((a, r) => a + r.cost, 0)), centerLabel: 'estimated'});
|
|
943
|
+
C.legend($('#mdl', page), priced.map(r => ({label: `${r.display_name} โ ${fmtUSD(r.cost)} (${r.cost_pct}%)`,
|
|
944
|
+
color: modelColor(r.model)})));
|
|
945
|
+
C.barsH($('#mb', page), {rows: rows.filter(r => r.tokens > 0), label: r => r.display_name,
|
|
946
|
+
value: r => r.tokens, fmt: fmtNum, color: r => modelColor(r.model),
|
|
947
|
+
sub: r => `<div class="row"><span class="k">Share</span><span class="v">${r.token_pct}%</span></div>`});
|
|
948
|
+
$('#mt', page).innerHTML = table([
|
|
949
|
+
{h: 'Model', f: r => `<span class="swatch" style="background:${modelColor(r.model)}"></span>${esc(r.display_name)}
|
|
950
|
+
${r.pricing_known ? '' : '<span class="badge na" title="no price entry โ default pricing used">default price</span>'}`},
|
|
951
|
+
{h: 'Tier', f: r => `<span class="pill">${esc(r.tier)}</span>`},
|
|
952
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
953
|
+
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
954
|
+
{h: 'Input', num: 1, f: r => fmtNum(r.input_tokens)},
|
|
955
|
+
{h: 'Output', num: 1, f: r => fmtNum(r.output_tokens)},
|
|
956
|
+
{h: 'Cache read', num: 1, f: r => fmtNum(r.cache_read_tokens)},
|
|
957
|
+
{h: 'Cache write', num: 1, f: r => fmtNum(r.cache_write_tokens)},
|
|
958
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
959
|
+
{h: 'Avg context', num: 1, f: r => fmtNum(r.avg_context)},
|
|
960
|
+
{h: 'Ctx util', num: 1, f: r => r.utilization_pct == null ? 'โ' : fmtPct(r.utilization_pct)},
|
|
961
|
+
{h: 'Avg latency', num: 1, f: r => r.avg_latency_ms ? (r.avg_latency_ms/1000).toFixed(1)+'s' : 'โ'},
|
|
962
|
+
{h: '$/1K out', num: 1, f: r => r.cost_per_1k_output ? '$' + r.cost_per_1k_output.toFixed(3) : 'โ'},
|
|
963
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
964
|
+
{h: '% cost', num: 1, f: r => fmtPct(r.cost_pct)},
|
|
965
|
+
], rows, {onRow: 1});
|
|
966
|
+
wireTable($('#mt', page), rows, r => { S.filter.models = [r.model]; bust(); go('prompts'); });
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
/* ---------- projects ---------- */
|
|
970
|
+
VIEWS.projects = async (page) => {
|
|
971
|
+
const rows = await api('projects');
|
|
972
|
+
page.innerHTML = `
|
|
973
|
+
${card('Project cost ranking', '<div class="chart" id="pb"></div>', {badge: BADGE.estimated,
|
|
974
|
+
hint: 'click a bar to drill into its sessions'})}
|
|
975
|
+
${card('Project FinOps table', '<div id="pt"></div>', {badge: BADGE.estimated,
|
|
976
|
+
hint: 'Project โ Session โ Prompt drill-down',
|
|
977
|
+
footer: 'Project identity comes from the working directory recorded in each transcript. '
|
|
978
|
+
+ `Repository/remote metadata is not present in ${agentWord()} data.`})}`;
|
|
979
|
+
C.barsH($('#pb', page), {rows: rows.slice(0, 18), label: r => r.name, value: r => r.cost,
|
|
980
|
+
color: seriesVar(2), onClick: r => { S.filter.projects = [String(r.project_id)];
|
|
981
|
+
bust(); go('sessions'); },
|
|
982
|
+
sub: r => `<div class="row"><span class="k">Sessions</span><span class="v">${fmtInt(r.sessions)}</span></div>
|
|
983
|
+
<div class="row"><span class="k">Avg / session</span><span class="v">${fmtUSD(r.avg_cost_per_session)}</span></div>`});
|
|
984
|
+
$('#pt', page).innerHTML = table([
|
|
985
|
+
{h: 'Project', f: r => esc(r.name) + (r.is_sandbox
|
|
986
|
+
? ' <span class="pill">sandbox agent</span>' : '')},
|
|
987
|
+
{h: 'Path', trunc: 1, title: r => r.path || '', f: r => `<span class="mono sub">${esc(r.path || 'โ')}</span>`},
|
|
988
|
+
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
989
|
+
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
990
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
991
|
+
{h: 'Files', num: 1, f: r => fmtInt(r.files_touched)},
|
|
992
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
993
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
994
|
+
{h: 'Avg / session', num: 1, f: r => fmtUSD(r.avg_cost_per_session)},
|
|
995
|
+
{h: 'Avg / prompt', num: 1, f: r => r.avg_cost_per_prompt == null ? 'โ' : fmtUSD(r.avg_cost_per_prompt)},
|
|
996
|
+
{h: 'Budget', num: 1, f: r => r.budget_usd ? fmtPct(r.budget_used_pct) + ' of ' + fmtUSD(r.budget_usd) : 'โ'},
|
|
997
|
+
], rows, {onRow: 1});
|
|
998
|
+
wireTable($('#pt', page), rows, r => { S.filter.projects = [String(r.project_id)];
|
|
999
|
+
bust(); go('sessions'); });
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
/* ---------- sessions ---------- */
|
|
1003
|
+
VIEWS.sessions = async (page) => {
|
|
1004
|
+
const order = S.sessOrder || 'cost';
|
|
1005
|
+
const rows = await api('sessions', `&limit=400&order=${order}`);
|
|
1006
|
+
const eff = await api('efficiency');
|
|
1007
|
+
const avgTok = rows.length ? rows.reduce((a, r) => a + r.tokens, 0) / rows.length : 0;
|
|
1008
|
+
page.innerHTML = `
|
|
1009
|
+
<div class="grid g4">
|
|
1010
|
+
${kpi('Sessions in range', fmtInt(rows.length), null, {badge: BADGE.actual})}
|
|
1011
|
+
${kpi('Avg cost / session', fmtUSD(rows.reduce((a, r) => a + r.cost, 0) / (rows.length || 1)),
|
|
1012
|
+
null, {badge: BADGE.estimated})}
|
|
1013
|
+
${kpi('Avg tokens / session', fmtNum(avgTok))}
|
|
1014
|
+
${kpi('Avg tokens / prompt', fmtNum(rows.reduce((a, r) => a + (r.tokens_per_prompt || 0), 0)
|
|
1015
|
+
/ (rows.filter(r => r.tokens_per_prompt).length || 1)))}
|
|
1016
|
+
</div>
|
|
1017
|
+
${card('Session explorer', `<div class="filters" style="margin:0 0 8px">
|
|
1018
|
+
${[['cost', 'Most expensive'], ['tokens', 'Most tokens'], ['duration', 'Longest'],
|
|
1019
|
+
['prompts', 'Most prompts'], ['recent', 'Most recent']].map(([k, l]) =>
|
|
1020
|
+
`<button class="chip ${order === k ? 'on' : ''}" data-so="${k}">${l}</button>`).join('')}
|
|
1021
|
+
<span class="spacer"></span>
|
|
1022
|
+
<span class="note">rows above ${fmtNum(avgTok * 3)} tokens are unusually expensive</span>
|
|
1023
|
+
</div><div id="st"></div>`, {badge: BADGE.estimated, hint: 'click a row to open the session'})}
|
|
1024
|
+
<div class="grid g2">
|
|
1025
|
+
${card('Lowest output yield', table([
|
|
1026
|
+
{h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
|
|
1027
|
+
{h: 'Output share', num: 1, f: r => fmtPct(r.output_ratio * 100, 2)},
|
|
1028
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1029
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1030
|
+
], eff.low_efficiency_sessions, {onRow: 1}), {badge: BADGE.estimated, hint: 'low efficiency'})}
|
|
1031
|
+
${card('Highest output yield', table([
|
|
1032
|
+
{h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
|
|
1033
|
+
{h: 'Output share', num: 1, f: r => fmtPct(r.output_ratio * 100, 2)},
|
|
1034
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1035
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1036
|
+
], eff.high_efficiency_sessions, {onRow: 1}), {badge: BADGE.estimated, hint: 'high efficiency'})}
|
|
1037
|
+
</div>`;
|
|
1038
|
+
page.querySelectorAll('[data-so]').forEach(b => b.onclick = () => {
|
|
1039
|
+
S.sessOrder = b.dataset.so; bust(); render(); });
|
|
1040
|
+
$('#st', page).innerHTML = table([
|
|
1041
|
+
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
1042
|
+
f: r => `${r.tokens > avgTok * 3 ? '๐ด ' : ''}${esc(r.title || shortId(r.session_id))}
|
|
1043
|
+
<div class="sub mono">${esc(shortId(r.session_id))}</div>`},
|
|
1044
|
+
{h: 'Project', f: r => esc(r.project)},
|
|
1045
|
+
{h: 'Branch', f: r => r.git_branch ? `<span class="mono">${esc(r.git_branch)}</span>` : 'โ'},
|
|
1046
|
+
{h: 'Start', f: r => `<span class="mono">${esc((r.started_at || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
1047
|
+
{h: 'Duration', num: 1, f: r => dur(r.duration_s)},
|
|
1048
|
+
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
1049
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1050
|
+
{h: 'Tools', num: 1, f: r => fmtInt(r.tool_calls)},
|
|
1051
|
+
{h: 'Files', num: 1, f: r => fmtInt(r.files_touched)},
|
|
1052
|
+
{h: 'Models', f: r => (r.models || '').split(',').map(m =>
|
|
1053
|
+
`<span class="swatch" title="${esc(modelName(m))}" style="background:${modelColor(m)}"></span>`).join('')},
|
|
1054
|
+
{h: 'Input', num: 1, f: r => fmtNum(r.input_tokens)},
|
|
1055
|
+
{h: 'Output', num: 1, f: r => fmtNum(r.output_tokens)},
|
|
1056
|
+
{h: 'Cache read', num: 1, f: r => fmtNum(r.cache_read_tokens)},
|
|
1057
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1058
|
+
{h: 'Peak ctx', num: 1, f: r => fmtNum(r.max_context)},
|
|
1059
|
+
{h: 'Tok/prompt', num: 1, f: r => fmtNum(r.tokens_per_prompt)},
|
|
1060
|
+
{h: '$/prompt', num: 1, f: r => r.cost_per_prompt == null ? 'โ' : fmtUSD(r.cost_per_prompt)},
|
|
1061
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1062
|
+
], rows, {onRow: 1});
|
|
1063
|
+
wireTable($('#st', page), rows, r => openSession(r.session_id));
|
|
1064
|
+
const effCards = [...page.querySelectorAll('.card')].filter(c =>
|
|
1065
|
+
/output yield/.test(c.querySelector('h3')?.textContent || ''));
|
|
1066
|
+
wireTable(effCards[0], eff.low_efficiency_sessions, r => openSession(r.session_id));
|
|
1067
|
+
wireTable(effCards[1], eff.high_efficiency_sessions, r => openSession(r.session_id));
|
|
1068
|
+
addChart(page, 'Top sessions by estimated cost', el => C.barsH(el, {
|
|
1069
|
+
rows: [...rows].sort((a, b) => b.cost - a.cost).slice(0, 12),
|
|
1070
|
+
label: r => clip(r.title || shortId(r.session_id), 42), value: r => r.cost,
|
|
1071
|
+
sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
|
|
1072
|
+
<div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(r.tokens)}</span></div>
|
|
1073
|
+
<div class="row"><span class="k">Prompts</span><span class="v">${fmtInt(r.prompts)}</span></div>`,
|
|
1074
|
+
onClick: r => openSession(r.session_id)}), {badge: BADGE.estimated, hint: 'Click a bar to open the session'});
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
/* ---------- prompt explorer ---------- */
|
|
1078
|
+
VIEWS.prompts = async (page) => {
|
|
1079
|
+
const order = S.promptOrder || 'cost';
|
|
1080
|
+
const q = S.promptQ || '';
|
|
1081
|
+
const rows = await api('prompts',
|
|
1082
|
+
`&limit=400&order=${order}${q ? '&q=' + encodeURIComponent(q) : ''}`);
|
|
1083
|
+
page.innerHTML = `
|
|
1084
|
+
${card('Prompt explorer', `
|
|
1085
|
+
<div class="filters" style="margin:0 0 8px">
|
|
1086
|
+
<div class="search"><span class="mag">โ</span>
|
|
1087
|
+
<input id="pq" value="${esc(q)}" placeholder="Search prompt textโฆ"></div>
|
|
1088
|
+
<span class="divider"></span>
|
|
1089
|
+
${[['cost', 'Most expensive'], ['tokens', 'Most tokens'], ['length', 'Longest'],
|
|
1090
|
+
['efficiency', 'Most efficient'], ['cheapest', 'Cheapest'], ['recent', 'Most recent']]
|
|
1091
|
+
.map(([k, l]) => `<button class="chip ${order === k ? 'on' : ''}" data-po="${k}">${l}</button>`).join('')}
|
|
1092
|
+
<span class="spacer"></span>
|
|
1093
|
+
<span class="note">${fmtInt(rows.length)} prompts ยท global filters apply</span>
|
|
1094
|
+
</div><div id="pt"></div>`,
|
|
1095
|
+
{badge: BADGE.estimated, hint: 'click any row for the full prompt, usage and advice',
|
|
1096
|
+
footer: 'Prompt text is read from your local transcripts and never leaves this machine.'})}`;
|
|
1097
|
+
const inp = $('#pq', page);
|
|
1098
|
+
let t; inp.oninput = e => { clearTimeout(t); const v = e.target.value;
|
|
1099
|
+
t = setTimeout(() => { S.promptQ = v; bust(); render().then(() => {
|
|
1100
|
+
const i = $('#pq'); if (i) { i.focus(); i.setSelectionRange(v.length, v.length); } }); }, 300); };
|
|
1101
|
+
page.querySelectorAll('[data-po]').forEach(b => b.onclick = () => {
|
|
1102
|
+
S.promptOrder = b.dataset.po; bust(); render(); });
|
|
1103
|
+
$('#pt', page).innerHTML = table([
|
|
1104
|
+
{h: 'When', f: r => `<span class="mono">${esc((r.ts || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
1105
|
+
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
1106
|
+
{h: 'Category', f: r => `<span class="pill" title="confidence ${(100*(r.category_confidence||0)).toFixed(0)}%${
|
|
1107
|
+
r.category_evidence?.length ? ' ยท matched: ' + esc(r.category_evidence.join(', ')) : ''}">${esc(r.category)}</span>`},
|
|
1108
|
+
{h: 'Project', f: r => esc(r.project)},
|
|
1109
|
+
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
1110
|
+
f: r => `<span class="mono sub">${esc(r.session_title || shortId(r.session_id))}</span>`},
|
|
1111
|
+
{h: 'Models', f: r => (r.models || '').split(',').map(m =>
|
|
1112
|
+
`<span class="swatch" title="${esc(modelName(m))}" style="background:${modelColor(m)}"></span>`).join('')},
|
|
1113
|
+
{h: 'Chars', num: 1, f: r => fmtInt(r.char_len)},
|
|
1114
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1115
|
+
{h: 'Input', num: 1, f: r => fmtNum(r.input_tokens)},
|
|
1116
|
+
{h: 'Output', num: 1, f: r => fmtNum(r.output_tokens)},
|
|
1117
|
+
{h: 'Cache read', num: 1, f: r => fmtNum(r.cache_read_tokens)},
|
|
1118
|
+
{h: 'Cache write', num: 1, f: r => fmtNum(r.cache_write_tokens)},
|
|
1119
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.ptokens)},
|
|
1120
|
+
{h: 'Peak ctx', num: 1, f: r => fmtNum(r.max_context)},
|
|
1121
|
+
{h: 'Tools', num: 1, f: r => fmtInt(r.tool_calls)},
|
|
1122
|
+
{h: 'Files', num: 1, f: r => fmtInt(r.files_touched)},
|
|
1123
|
+
{h: 'Latency', num: 1, f: r => r.latency_ms ? (r.latency_ms / 1000).toFixed(1) + 's' : 'โ'},
|
|
1124
|
+
{h: 'Tok eff', num: 1, f: r => fmtPct(r.efficiency * 100, 2)},
|
|
1125
|
+
{h: '$/1K out', num: 1, f: r => r.cost_per_1k_output ? '$' + r.cost_per_1k_output.toFixed(3) : 'โ'},
|
|
1126
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
|
|
1127
|
+
], rows, {onRow: 1});
|
|
1128
|
+
wireTable($('#pt', page), rows, r => openPrompt(r.prompt_id));
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
/* ---------- rankings ---------- */
|
|
1132
|
+
const RANK_TABS = [
|
|
1133
|
+
['most_expensive', 'Top 20 most expensive'], ['most_token_heavy', 'Top 20 most token-heavy'],
|
|
1134
|
+
['cheapest', 'Top 20 cheapest'], ['most_efficient', 'Top 20 most efficient'],
|
|
1135
|
+
['longest_sessions', 'Top 20 longest sessions']];
|
|
1136
|
+
VIEWS.rankings = async (page) => {
|
|
1137
|
+
const lb = await api('leaderboards', '&n=20');
|
|
1138
|
+
const tab = S.rankTab || 'most_expensive';
|
|
1139
|
+
const isSess = tab === 'longest_sessions';
|
|
1140
|
+
const rows = lb[tab];
|
|
1141
|
+
page.innerHTML = card('Cost & efficiency leaderboards', `
|
|
1142
|
+
<div class="tabs">${RANK_TABS.map(([k, l]) =>
|
|
1143
|
+
`<button class="${tab === k ? 'on' : ''}" data-rt="${k}">${l}</button>`).join('')}</div>
|
|
1144
|
+
<div id="rt" style="margin-top:8px"></div>`,
|
|
1145
|
+
{badge: BADGE.estimated, hint: 'every row is clickable'});
|
|
1146
|
+
page.querySelectorAll('[data-rt]').forEach(b => b.onclick = () => {
|
|
1147
|
+
S.rankTab = b.dataset.rt; render(); });
|
|
1148
|
+
const cols = isSess ? [
|
|
1149
|
+
{h: '#', num: 1, f: r => rows.indexOf(r) + 1},
|
|
1150
|
+
{h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
|
|
1151
|
+
{h: 'Project', f: r => esc(r.project)},
|
|
1152
|
+
{h: 'Duration', num: 1, f: r => dur(r.duration_s)},
|
|
1153
|
+
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
1154
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1155
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1156
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1157
|
+
{h: 'Start', f: r => `<span class="mono">${esc((r.started_at || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
1158
|
+
] : [
|
|
1159
|
+
{h: '#', num: 1, f: r => rows.indexOf(r) + 1},
|
|
1160
|
+
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
1161
|
+
{h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
|
|
1162
|
+
{h: 'Models', f: r => (r.models || '').split(',').map(m =>
|
|
1163
|
+
`<span class="swatch" title="${esc(modelName(m))}" style="background:${modelColor(m)}"></span>`).join('')},
|
|
1164
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.ptokens)},
|
|
1165
|
+
{h: 'Output', num: 1, f: r => fmtNum(r.output_tokens)},
|
|
1166
|
+
{h: 'Tok eff', num: 1, f: r => fmtPct(r.efficiency * 100, 2)},
|
|
1167
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
|
|
1168
|
+
{h: 'Session', trunc: 1, f: r => esc(r.session_title || shortId(r.session_id))},
|
|
1169
|
+
{h: 'Date', f: r => esc(r.day)},
|
|
1170
|
+
];
|
|
1171
|
+
$('#rt', page).innerHTML = table(cols, rows, {onRow: 1});
|
|
1172
|
+
wireTable($('#rt', page), rows, r => isSess ? openSession(r.session_id) : openPrompt(r.prompt_id));
|
|
1173
|
+
addChart(page, 'Top 12 prompts by estimated cost', el => C.barsH(el, {
|
|
1174
|
+
rows: (lb.most_expensive || []).slice(0, 12), label: r => clip(r.preview, 46), value: r => r.pcost,
|
|
1175
|
+
sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
|
|
1176
|
+
<div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(r.ptokens)}</span></div>`}),
|
|
1177
|
+
{badge: BADGE.estimated, after: '.nothing'});
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
/* ---------- categories / prompt intelligence ---------- */
|
|
1181
|
+
VIEWS.categories = async (page) => {
|
|
1182
|
+
const c = await api('categories');
|
|
1183
|
+
const rows = c.rows;
|
|
1184
|
+
page.innerHTML = `
|
|
1185
|
+
<div class="grid g2">
|
|
1186
|
+
${card('Cost by activity', `<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
|
1187
|
+
<div class="chart" id="cd"></div><div style="flex:1;min-width:220px" class="legend"
|
|
1188
|
+
id="cl" style="flex-direction:column;align-items:flex-start"></div></div>`,
|
|
1189
|
+
{badge: BADGE.estimated, hint: `where your ${agentWord()} budget goes`})}
|
|
1190
|
+
${card('Prompts by activity', '<div class="chart" id="cb"></div>', {badge: BADGE.actual})}
|
|
1191
|
+
</div>
|
|
1192
|
+
${card('Activity breakdown', '<div id="ct"></div>', {badge: BADGE.estimated,
|
|
1193
|
+
hint: 'click a row to filter the dashboard to that activity',
|
|
1194
|
+
footer: esc(c.note) + ' Classification is keyword-based and transparent โ open any prompt to see the matched terms and confidence.'})}`;
|
|
1195
|
+
C.donut($('#cd', page), {rows, label: r => r.category, value: r => r.cost, size: 190,
|
|
1196
|
+
color: r => catColor(r.category),
|
|
1197
|
+
centerValue: fmtUSD(rows.reduce((a, r) => a + r.cost, 0)), centerLabel: 'estimated',
|
|
1198
|
+
onClick: r => { S.filter.categories = [r.category]; bust(); go('prompts'); }});
|
|
1199
|
+
$('#cl', page).innerHTML = rows.map((r, i) => `<span class="it">
|
|
1200
|
+
<span class="swatch" style="background:${catColor(r.category)}"></span>
|
|
1201
|
+
${esc(r.category)} โ <b>${fmtPct(r.cost_pct)}</b> ยท ${fmtUSD(r.cost)}</span>`).join('');
|
|
1202
|
+
C.barsH($('#cb', page), {rows, label: r => r.category, value: r => r.prompts, fmt: fmtInt,
|
|
1203
|
+
color: r => catColor(r.category),
|
|
1204
|
+
onClick: r => { S.filter.categories = [r.category]; bust(); go('prompts'); }});
|
|
1205
|
+
$('#ct', page).innerHTML = table([
|
|
1206
|
+
{h: 'Activity', f: (r) => `<span class="swatch" style="background:${catColor(r.category)}"></span>${esc(r.category)}`},
|
|
1207
|
+
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
1208
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1209
|
+
{h: 'Avg prompt chars', num: 1, f: r => fmtInt(r.avg_prompt_chars)},
|
|
1210
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1211
|
+
{h: 'Output', num: 1, f: r => fmtNum(r.output_tokens)},
|
|
1212
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1213
|
+
{h: '$/prompt', num: 1, f: r => fmtUSD(r.cost_per_prompt)},
|
|
1214
|
+
{h: '% of spend', num: 1, f: r => `<div style="display:flex;align-items:center;gap:7px;justify-content:flex-end">
|
|
1215
|
+
<span class="minibar" style="width:${Math.max(2, r.cost_pct)}px;background:${catColor(r.category)}"></span>
|
|
1216
|
+
${fmtPct(r.cost_pct)}</div>`},
|
|
1217
|
+
{h: 'Avg confidence', num: 1, f: r => fmtPct((r.confidence || 0) * 100, 0)},
|
|
1218
|
+
], rows, {onRow: 1});
|
|
1219
|
+
wireTable($('#ct', page), rows, r => { S.filter.categories = [r.category]; bust(); go('prompts'); });
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
/* ---------- context & cache ---------- */
|
|
1223
|
+
VIEWS.context = async (page) => {
|
|
1224
|
+
const [ctx, eff] = await Promise.all([api('context'), api('efficiency')]);
|
|
1225
|
+
const ca = eff.cache;
|
|
1226
|
+
page.innerHTML = `
|
|
1227
|
+
<div class="grid g5">
|
|
1228
|
+
${kpi('Avg context / request', fmtNum(ctx.avg_context), null, {badge: BADGE.actual})}
|
|
1229
|
+
${kpi('Max context seen', fmtNum(ctx.max_context),
|
|
1230
|
+
ctx.typical_context_window ? `window ${fmtNum(ctx.typical_context_window)}` : '')}
|
|
1231
|
+
${kpi('Context utilization', ctx.typical_context_window
|
|
1232
|
+
? fmtPct(100 * ctx.avg_context / ctx.typical_context_window) : null,
|
|
1233
|
+
'average vs largest configured window')}
|
|
1234
|
+
${kpi('Tokens / request', fmtNum(eff.tokens_per_request))}
|
|
1235
|
+
${kpi('Cache hit ratio', ca.reads ? fmtPct(eff.cache_hit_ratio * 100) : null,
|
|
1236
|
+
'reads รท (reads + writes)', {badge: BADGE.actual})}
|
|
1237
|
+
</div>
|
|
1238
|
+
${ctx.large_context_cost_pct > 0 ? `<div class="hero">
|
|
1239
|
+
<h3>โ ๏ธ Context warnings</h3>
|
|
1240
|
+
<div class="stack">
|
|
1241
|
+
<div class="item sev-${ctx.large_context_cost_pct > 30 ? 'high' : 'medium'}">
|
|
1242
|
+
<div class="hd">${fmtPct(ctx.large_context_cost_pct)} of your estimated spend came from
|
|
1243
|
+
requests with more than ${fmtNum(ctx.threshold)} context tokens.</div>
|
|
1244
|
+
<div class="dt">${fmtInt(ctx.large_context_requests)} requests, ${fmtUSD(ctx.large_context_cost)} estimated.</div></div>
|
|
1245
|
+
<div class="item sev-medium"><div class="hd">These are expensive because the same context is
|
|
1246
|
+
re-sent on every turn.</div>
|
|
1247
|
+
<div class="dt">Caching discounts the repeat, but a large prefix still dominates the bill.
|
|
1248
|
+
/compact or a fresh session resets it.</div></div>
|
|
1249
|
+
</div></div>` : ''}
|
|
1250
|
+
<div class="grid g2">
|
|
1251
|
+
${card('Cost by context size', '<div class="chart" id="cxb"></div>', {badge: BADGE.estimated,
|
|
1252
|
+
hint: 'context = input + cache read + cache write per request'})}
|
|
1253
|
+
${card('Caching: with vs without', `
|
|
1254
|
+
<div class="grid g3" style="gap:8px">
|
|
1255
|
+
${kpi('Est. cost with caching', fmtUSD(ca.cost_with_cache), null, {small: 1, badge: BADGE.estimated})}
|
|
1256
|
+
${kpi('Est. cost without caching', fmtUSD(ca.cost_without_cache), 'same tokens at input rates', {small: 1})}
|
|
1257
|
+
${kpi('Est. savings', fmtUSD(ca.estimated_savings_usd), fmtPct(ca.savings_pct),
|
|
1258
|
+
{small: 1, badge: BADGE.estimated})}
|
|
1259
|
+
</div>
|
|
1260
|
+
<div class="chart" id="cachebar" style="margin-top:10px"></div>
|
|
1261
|
+
<dl class="kv" style="margin-top:10px">
|
|
1262
|
+
<dt>Cache reads</dt><dd>${fmtNum(ca.reads)} tokens</dd>
|
|
1263
|
+
<dt>Cache writes</dt><dd>${fmtNum(ca.writes)} tokens</dd>
|
|
1264
|
+
<dt>Tokens served from cache</dt><dd>${fmtNum(ca.reads)}</dd>
|
|
1265
|
+
</dl>`, {badge: BADGE.estimated,
|
|
1266
|
+
footer: 'The no-cache baseline prices every cached token at the plain input rate. It is a modelled counterfactual, not a bill you avoided.'})}
|
|
1267
|
+
</div>
|
|
1268
|
+
${card('Sessions with the largest context', '<div id="hs"></div>', {badge: BADGE.estimated,
|
|
1269
|
+
hint: `above ${fmtNum(ctx.threshold)} peak context tokens`})}
|
|
1270
|
+
${card('Context distribution', table([
|
|
1271
|
+
{h: 'Context bucket', f: r => esc(r.bucket)},
|
|
1272
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1273
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1274
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1275
|
+
{h: '% of spend', num: 1, f: r => fmtPct(r.cost_pct)},
|
|
1276
|
+
], ctx.buckets), {badge: BADGE.estimated})}`;
|
|
1277
|
+
C.barsH($('#cxb', page), {rows: ctx.buckets, label: r => r.bucket, value: r => r.cost,
|
|
1278
|
+
color: seriesVar(0),
|
|
1279
|
+
sub: r => `<div class="row"><span class="k">Requests</span><span class="v">${fmtInt(r.requests)}</span></div>
|
|
1280
|
+
<div class="row"><span class="k">Share</span><span class="v">${r.cost_pct}%</span></div>`});
|
|
1281
|
+
C.barsH($('#cachebar', page), {rows: [
|
|
1282
|
+
{k: 'Without caching (modelled)', v: ca.cost_without_cache},
|
|
1283
|
+
{k: 'With caching (your actual usage)', v: ca.cost_with_cache}],
|
|
1284
|
+
label: r => r.k, value: r => r.v, color: (r, i) => i ? seriesVar(2) : seriesVar(1), height: 60});
|
|
1285
|
+
$('#hs', page).innerHTML = table([
|
|
1286
|
+
{h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
|
|
1287
|
+
{h: 'Project', f: r => esc(r.project)},
|
|
1288
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1289
|
+
{h: 'Avg context', num: 1, f: r => fmtNum(r.avg_context)},
|
|
1290
|
+
{h: 'Peak context', num: 1, f: r => fmtNum(r.max_context)},
|
|
1291
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1292
|
+
], ctx.heavy_sessions, {onRow: 1});
|
|
1293
|
+
wireTable($('#hs', page), ctx.heavy_sessions, r => openSession(r.session_id));
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
/* ---------- model switch ---------- */
|
|
1297
|
+
VIEWS.modelswitch = async (page) => {
|
|
1298
|
+
const m = await api('model_switch');
|
|
1299
|
+
const sc = m.savings_by_confidence || {};
|
|
1300
|
+
const conf = c => `<span class="badge rec">${esc(c)} confidence</span>`;
|
|
1301
|
+
page.innerHTML = `
|
|
1302
|
+
<div class="grid g4">
|
|
1303
|
+
${kpi('Potential savings', fmtUSD(m.estimated_savings_usd),
|
|
1304
|
+
`${fmtPct(m.total_cost_usd ? 100 * m.estimated_savings_usd / m.total_cost_usd : 0)} of ${fmtUSD(m.total_cost_usd)} in range`,
|
|
1305
|
+
{badge: BADGE.recommendation})}
|
|
1306
|
+
${kpi('Safe to switch', fmtUSD(m.safe_savings_usd), 'High + medium confidence only', {badge: BADGE.recommendation})}
|
|
1307
|
+
${kpi('Try on a sample first', fmtUSD(sc.low || 0), 'Low confidence: coding and refactoring', {badge: BADGE.recommendation})}
|
|
1308
|
+
${kpi('Stays on current model', fmtUSD(m.blocked_by_context_usd),
|
|
1309
|
+
`${fmtInt(m.blocked_by_context_requests)} requests too big for the cheaper model's context`, {badge: BADGE.estimated})}
|
|
1310
|
+
</div>
|
|
1311
|
+
${card('Switch these', `<div id="ms-sw"></div>`, {badge: BADGE.recommendation, flush: 1,
|
|
1312
|
+
hint: 'Each request repriced on the model its work needs, same tokens',
|
|
1313
|
+
footer: esc(m.caveat)})}
|
|
1314
|
+
${card('Default model per project', `<div id="ms-pj"></div>`, {badge: BADGE.recommendation, flush: 1,
|
|
1315
|
+
hint: 'Based on how much frontier-model spend is reasoning-heavy work'})}
|
|
1316
|
+
${card('How to switch', `<div class="stack">
|
|
1317
|
+
${(m.providers?.length ? m.providers : ['anthropic']).map(pv => { const h = (m.how_by_agent || {})[pv] || m.how; return `
|
|
1318
|
+
<div class="dt"><b>${esc(h.agent || 'Claude Code')}</b></div>
|
|
1319
|
+
<div class="dt"><b>This session:</b> <code>${esc(h.session)}</code></div>
|
|
1320
|
+
<div class="dt"><b>Whole project:</b> <code>${esc(h.project)}</code></div>
|
|
1321
|
+
${pv === 'anthropic' ? `<div class="dt"><b>Subagents:</b> <code>${esc(m.how.subagent)}</code></div>` : ''}`; }).join('')}
|
|
1322
|
+
<div class="dt note">Kept on the top model: ${esc(Object.entries(m.rules).filter(([, v]) => v === 'keep').map(([k]) => k.replace('_', ' ')).join(', '))}.</div>
|
|
1323
|
+
</div>`)}`;
|
|
1324
|
+
$('#ms-sw', page).innerHTML = table([
|
|
1325
|
+
{h: 'Work', f: r => esc(r.scope)},
|
|
1326
|
+
{h: 'Now', f: r => esc(r.current_name)},
|
|
1327
|
+
{h: 'Switch to', f: r => `<b>${esc(r.recommended_name)}</b>`},
|
|
1328
|
+
{h: 'Confidence', f: r => conf(r.confidence)},
|
|
1329
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1330
|
+
{h: 'Est. cost now', num: 1, f: r => fmtUSD(r.cost)},
|
|
1331
|
+
{h: 'After switch', num: 1, f: r => fmtUSD(r.alt)},
|
|
1332
|
+
{h: 'Saves', num: 1, f: r => `<b>${fmtUSD(r.estimated_savings_usd)}</b> (${fmtPct(r.estimated_savings_pct)})`},
|
|
1333
|
+
], m.switches);
|
|
1334
|
+
$('#ms-pj', page).innerHTML = table([
|
|
1335
|
+
{h: 'Project', trunc: 1, f: r => esc(r.project)},
|
|
1336
|
+
{h: 'Frontier spend', num: 1, f: r => fmtUSD(r.frontier_cost)},
|
|
1337
|
+
{h: 'Reasoning-heavy', num: 1, f: r => fmtPct(r.keep_pct)},
|
|
1338
|
+
{h: 'Suggested default', f: r => `<b>${esc(r.suggested_default)}</b>`},
|
|
1339
|
+
{h: 'Could save', num: 1, f: r => fmtUSD(r.estimated_savings_usd)},
|
|
1340
|
+
{h: 'Why', f: r => esc(r.why)},
|
|
1341
|
+
], m.projects);
|
|
1342
|
+
addChart(page, 'Savings by switch', el => C.barsH(el, {
|
|
1343
|
+
rows: m.switches.slice(0, 10), label: r => clip(`${r.scope} โ ${r.recommended_name}`, 48),
|
|
1344
|
+
value: r => r.estimated_savings_usd,
|
|
1345
|
+
color: r => r.confidence === 'high' ? seriesVar(2) : r.confidence === 'medium' ? seriesVar(0) : 'var(--warning)',
|
|
1346
|
+
sub: r => `<div class="row"><span class="k">Now</span><span class="v">${fmtUSD(r.cost)}</span></div>
|
|
1347
|
+
<div class="row"><span class="k">After</span><span class="v">${fmtUSD(r.alt)}</span></div>
|
|
1348
|
+
<div class="row"><span class="k">Confidence</span><span class="v">${esc(r.confidence)}</span></div>`}),
|
|
1349
|
+
{badge: BADGE.recommendation, hint: 'Colour = confidence (green high, blue medium, amber low)'});
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
/* ---------- waste ---------- */
|
|
1353
|
+
VIEWS.waste = async (page) => {
|
|
1354
|
+
const w = await api('waste');
|
|
1355
|
+
page.innerHTML = `
|
|
1356
|
+
<div class="grid g4">
|
|
1357
|
+
${kpi('Estimated excess', fmtUSD(w.estimated_excess_usd),
|
|
1358
|
+
`${fmtPct(w.excess_pct)} of ${fmtUSD(w.total_cost_usd)} โ the actual waste figure`,
|
|
1359
|
+
{badge: BADGE.estimated})}
|
|
1360
|
+
${kpi('Exposed spend', fmtUSD(w.exposed_cost_usd),
|
|
1361
|
+
`${fmtPct(w.exposed_pct)} sits in items a rule touched`, {badge: BADGE.estimated})}
|
|
1362
|
+
${kpi('Prompts flagged', fmtInt(w.affected_prompts))}
|
|
1363
|
+
${kpi('Sessions flagged', fmtInt(w.affected_sessions))}
|
|
1364
|
+
${kpi('Rules triggered', fmtInt(w.findings.length),
|
|
1365
|
+
`${w.findings.filter(f => f.severity === 'high').length} high severity`)}
|
|
1366
|
+
</div>
|
|
1367
|
+
<div class="note"><b>Exposed spend</b> is what the flagged items cost in total โ money worth
|
|
1368
|
+
reviewing, not money wasted. <b>Estimated excess</b> is how much more that work cost than a
|
|
1369
|
+
reasonable baseline, de-duplicated across rules, and is what the FinOps score grades. Each
|
|
1370
|
+
rule below states the baseline it measures against.</div>
|
|
1371
|
+
${['high', 'medium', 'low'].map(sev => {
|
|
1372
|
+
const list = w.findings.filter(f => f.severity === sev);
|
|
1373
|
+
if (!list.length) return '';
|
|
1374
|
+
const title = sev === 'high' ? '๐ด High waste'
|
|
1375
|
+
: sev === 'medium' ? '๐ก Optimization opportunities' : 'โช Low-priority observations';
|
|
1376
|
+
return card(title, `<div class="stack">${list.map((f, i) => `
|
|
1377
|
+
<div class="item sev-${sev}">
|
|
1378
|
+
<div class="hd">${esc(f.title)}<span class="spacer"></span>
|
|
1379
|
+
<span style="font-variant-numeric:tabular-nums">${fmtUSD(f.est_excess_usd)} excess</span>
|
|
1380
|
+
<span class="note" style="font-variant-numeric:tabular-nums">of ${fmtUSD(f.est_cost_usd)} exposed</span></div>
|
|
1381
|
+
<div class="dt">${esc(f.detail)}</div>
|
|
1382
|
+
<div class="dt"><b>Excess measured as:</b> ${esc(f.excess_basis)}</div>
|
|
1383
|
+
<div class="dt"><b>Recommended:</b> ${esc(f.recommended_action)}</div>
|
|
1384
|
+
<details style="margin-top:5px"><summary style="cursor:pointer;font-size:11.5px;color:var(--s1)">
|
|
1385
|
+
Show ${f.evidence.length} flagged items</summary>
|
|
1386
|
+
<div class="ev" data-kind="${esc(f.kind)}" style="margin-top:6px"></div></details>
|
|
1387
|
+
</div>`).join('')}</div>`, {badge: BADGE.estimated});
|
|
1388
|
+
}).join('')}
|
|
1389
|
+
<div class="note">${esc(w.note)}</div>`;
|
|
1390
|
+
// fill evidence tables
|
|
1391
|
+
const all = w.findings;
|
|
1392
|
+
page.querySelectorAll('.ev').forEach(host => {
|
|
1393
|
+
const f = all.find(x => x.kind === host.dataset.kind);
|
|
1394
|
+
const isSess = !!f.evidence[0]?.session_id && !f.evidence[0]?.prompt_id;
|
|
1395
|
+
const cols = isSess ? [
|
|
1396
|
+
{h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
|
|
1397
|
+
{h: 'Project', f: r => esc(r.project || 'โ')},
|
|
1398
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1399
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens ?? r.reads)},
|
|
1400
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1401
|
+
{h: 'Est. excess', num: 1, f: r => fmtUSD(r.excess)},
|
|
1402
|
+
] : [
|
|
1403
|
+
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
1404
|
+
{h: 'Detail', f: r => r.n ? `repeated ${r.n}ร` : r.char_len ? fmtInt(r.char_len) + ' chars'
|
|
1405
|
+
: r.tools ? fmtInt(r.tools) + ' tool calls'
|
|
1406
|
+
: r.out_tokens != null ? fmtInt(r.out_tokens) + ' output tokens' : 'โ'},
|
|
1407
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1408
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1409
|
+
{h: 'Est. excess', num: 1, f: r => fmtUSD(r.excess)},
|
|
1410
|
+
];
|
|
1411
|
+
host.innerHTML = table(cols, f.evidence, {onRow: 1});
|
|
1412
|
+
wireTable(host, f.evidence, r => r.prompt_id ? openPrompt(r.prompt_id)
|
|
1413
|
+
: openSession(r.session_id));
|
|
1414
|
+
});
|
|
1415
|
+
addChart(page, 'Excess cost by rule', el => C.barsH(el, {
|
|
1416
|
+
rows: w.findings.filter(f => f.est_excess_usd > 0).sort((a, b) => b.est_excess_usd - a.est_excess_usd),
|
|
1417
|
+
label: f => clip(f.title, 46), value: f => f.est_excess_usd,
|
|
1418
|
+
color: f => f.severity === 'high' ? 'var(--critical)' : f.severity === 'medium' ? 'var(--warning)' : seriesVar(0),
|
|
1419
|
+
sub: f => `<div class="row"><span class="k">Exposed</span><span class="v">${fmtUSD(f.est_cost_usd)}</span></div>`}),
|
|
1420
|
+
{badge: BADGE.estimated, hint: 'What each rule says you overspent, against its own baseline'});
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
/* ---------- attribution: session / subagent / skill / MCP / connector ---------- */
|
|
1424
|
+
VIEWS.attribution = async (page) => {
|
|
1425
|
+
const b = await api('breakdown');
|
|
1426
|
+
const srv = x => esc((x || '').replace(/^claude_ai_/, '').replace(/_/g, ' '));
|
|
1427
|
+
const sum = (rows, k) => rows.reduce((a, r) => a + (r[k] || 0), 0);
|
|
1428
|
+
const subTok = sum(b.subagents.types, 'tokens');
|
|
1429
|
+
const extCols = label => [
|
|
1430
|
+
{h: label, f: r => `<b>${srv(r.server)}</b>`},
|
|
1431
|
+
{h: 'Calls', num: 1, f: r => fmtInt(r.calls)},
|
|
1432
|
+
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
1433
|
+
{h: 'Injected', num: 1, f: r => fmtNum(r.injected)},
|
|
1434
|
+
{h: 'Largest', num: 1, f: r => fmtNum(r.largest)},
|
|
1435
|
+
{h: 'Carried (re-read)', num: 1, f: r => fmtNum(r.carried)},
|
|
1436
|
+
{h: 'Est. carry cost', num: 1, f: r => fmtUSD(r.carried_cost)},
|
|
1437
|
+
];
|
|
1438
|
+
const toolDetail = rows => rows.map(r => `<details style="margin:4px 0"><summary style="cursor:pointer;font-size:12px">
|
|
1439
|
+
${srv(r.server)} โ per tool</summary>${table([
|
|
1440
|
+
{h: 'Tool', f: t => esc(t.name.split('__').pop())},
|
|
1441
|
+
{h: 'Calls', num: 1, f: t => fmtInt(t.calls)},
|
|
1442
|
+
{h: 'Injected', num: 1, f: t => fmtNum(t.injected)},
|
|
1443
|
+
{h: 'Carried', num: 1, f: t => fmtNum(t.carried)}], r.tools)}</details>`).join('');
|
|
1444
|
+
page.innerHTML = `
|
|
1445
|
+
<div class="grid g4">
|
|
1446
|
+
${kpi('Subagent tokens', fmtNum(subTok), `${fmtInt(sum(b.subagents.types, 'runs'))} runs`, {badge: BADGE.actual})}
|
|
1447
|
+
${kpi('Skills (Claude-invoked)', fmtInt(b.skills.length), `${fmtNum(sum(b.skills, 'injected'))} tokens injected`, {badge: BADGE.actual})}
|
|
1448
|
+
${kpi('MCP servers used', fmtInt(b.mcp.length), `${fmtUSD(sum(b.mcp, 'carried_cost'))} est. carry cost`, {badge: BADGE.estimated})}
|
|
1449
|
+
${kpi('Connectors used', fmtInt(b.connectors.length), `${fmtUSD(sum(b.connectors, 'carried_cost'))} est. carry cost`, {badge: BADGE.estimated})}
|
|
1450
|
+
</div>
|
|
1451
|
+
<div class="note">${esc(b.note)}</div>
|
|
1452
|
+
${card('Sessions: main agent vs subagents', `<div id="at-sess"></div>`, {badge: BADGE.actual, flush: 1, hint: 'Top 30 by tokens; click to drill in'})}
|
|
1453
|
+
${card('Subagents by type', `<div id="at-types"></div>`, {badge: BADGE.actual, flush: 1})}
|
|
1454
|
+
${card('Most expensive subagent runs', `<div id="at-runs"></div>`, {badge: BADGE.actual, flush: 1})}
|
|
1455
|
+
${card('Skills', `<div id="at-skills"></div>`, {badge: BADGE.estimated, flush: 1, hint: 'Invoked by Claude via the Skill tool'})}
|
|
1456
|
+
${card('Slash commands & user-invoked skills', `<div id="at-slash"></div>`, {badge: BADGE.actual, flush: 1, hint: 'Whole turn attributed'})}
|
|
1457
|
+
${card('MCP servers', `<div id="at-mcp"></div>${toolDetail(b.mcp)}`, {badge: BADGE.estimated, flush: 1})}
|
|
1458
|
+
${card('Connectors (claude.ai)', `<div id="at-conn"></div>${toolDetail(b.connectors)}`, {badge: BADGE.estimated, flush: 1})}
|
|
1459
|
+
${b.configured_unused_mcp.length ? `<div class="note">Configured but never called: <b>${esc(b.configured_unused_mcp.join(', '))}</b>. Their tool definitions still load into every session. Remove them with <code>claude mcp remove <name></code>.</div>` : ''}`;
|
|
1460
|
+
const put = (id, cols, rows, onRow) => { const el = $(id, page); el.innerHTML = table(cols, rows, {onRow: !!onRow}); wireTable(el, rows, onRow); };
|
|
1461
|
+
put('#at-sess', [
|
|
1462
|
+
{h: 'Session', trunc: 1, title: r => r.session_id, f: r => esc(r.title || shortId(r.session_id))},
|
|
1463
|
+
{h: 'Project', f: r => esc(r.project)},
|
|
1464
|
+
{h: 'Main agent', num: 1, f: r => fmtNum(r.main_tokens)},
|
|
1465
|
+
{h: 'Subagents', num: 1, f: r => r.sub_tokens ? `${fmtNum(r.sub_tokens)} (${r.subagents})` : 'โ'},
|
|
1466
|
+
{h: 'Peak ctx', num: 1, f: r => fmtNum(r.max_ctx)},
|
|
1467
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1468
|
+
{h: 'Skills / MCP', trunc: 1, f: r => esc([...r.skills, ...r.mcp.map(m => m.replace(/^claude_ai_/, ''))].join(', ') || 'โ')},
|
|
1469
|
+
], b.sessions, r => openSession(r.session_id));
|
|
1470
|
+
put('#at-types', [
|
|
1471
|
+
{h: 'Agent type', f: r => `<b>${esc(r.agent_type)}</b>`},
|
|
1472
|
+
{h: 'Runs', num: 1, f: r => fmtInt(r.runs)},
|
|
1473
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1474
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1475
|
+
{h: 'Tokens / run', num: 1, f: r => fmtNum(r.tokens_per_run)},
|
|
1476
|
+
{h: 'Returned to main', num: 1, f: r => fmtNum(r.returned_tokens)},
|
|
1477
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1478
|
+
], b.subagents.types);
|
|
1479
|
+
put('#at-runs', [
|
|
1480
|
+
{h: 'Task', trunc: 1, title: r => r.agent_desc, f: r => esc(r.agent_desc || r.agent_id)},
|
|
1481
|
+
{h: 'Type', f: r => esc(r.agent_type)},
|
|
1482
|
+
{h: 'Project', f: r => esc(r.project)},
|
|
1483
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1484
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1485
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1486
|
+
], b.subagents.runs, r => openSession(r.session_id));
|
|
1487
|
+
put('#at-skills', extCols('Skill'), b.skills);
|
|
1488
|
+
put('#at-slash', [
|
|
1489
|
+
{h: 'Command', f: r => `<b>/${esc(r.server)}</b>${r.builtin ? ' <span class="na">built-in</span>' : ''}`},
|
|
1490
|
+
{h: 'Uses', num: 1, f: r => fmtInt(r.calls)},
|
|
1491
|
+
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
1492
|
+
{h: 'Turn tokens', num: 1, f: r => fmtNum(r.turn_tokens)},
|
|
1493
|
+
{h: 'Est. turn cost', num: 1, f: r => fmtUSD(r.turn_cost)},
|
|
1494
|
+
], b.slash_commands);
|
|
1495
|
+
put('#at-mcp', extCols('Server'), b.mcp);
|
|
1496
|
+
put('#at-conn', extCols('Connector'), b.connectors);
|
|
1497
|
+
addChart(page, 'Where subagent and MCP tokens went', el => {
|
|
1498
|
+
el.innerHTML = '<div class="grid g2"><div><div class="note">Subagent tokens by type</div><div id="at-c1"></div></div>' +
|
|
1499
|
+
'<div><div class="note">MCP & connector carry cost</div><div id="at-c2"></div></div></div>';
|
|
1500
|
+
C.barsH($('#at-c1', el), {rows: [...b.subagents.types].sort((x, y) => y.tokens - x.tokens).slice(0, 8),
|
|
1501
|
+
label: r => r.agent_type, value: r => r.tokens, fmt: fmtNum});
|
|
1502
|
+
C.barsH($('#at-c2', el), {rows: [...b.mcp, ...b.connectors].sort((x, y) => y.carried_cost - x.carried_cost).slice(0, 8),
|
|
1503
|
+
label: r => r.server.replace(/^claude_ai_/, ''), value: r => r.carried_cost});
|
|
1504
|
+
}, {badge: BADGE.estimated});
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
/* ---------- running sessions: stop / close from the browser ---------- */
|
|
1508
|
+
async function sessionAction(pid, action, body = {}) {
|
|
1509
|
+
const r = await fetch(`/api/live/${pid}/${action}`, {method: 'POST',
|
|
1510
|
+
headers: {'X-FinOps-Action': '1', 'Content-Type': 'application/json'}, body: JSON.stringify(body)});
|
|
1511
|
+
return r.json();
|
|
1512
|
+
}
|
|
1513
|
+
VIEWS.live = async (page) => {
|
|
1514
|
+
const res = await fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json()); // never cached: always live
|
|
1515
|
+
const list = res.sessions || [];
|
|
1516
|
+
const agentName = id => ((S.opts?.agents || []).find(a => a.id === id) || {}).name || id;
|
|
1517
|
+
const busy = list.filter(x => x.status === 'busy');
|
|
1518
|
+
const sevOf = x => x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low';
|
|
1519
|
+
page.innerHTML = `
|
|
1520
|
+
<div class="grid g4">
|
|
1521
|
+
${kpi('Running sessions', fmtInt(list.length), `${busy.length} working right now`, {badge: BADGE.actual})}
|
|
1522
|
+
${kpi('Working (spending now)', fmtInt(busy.length), 'Only these consume tokens right now')}
|
|
1523
|
+
${kpi('Largest live context', fmtNum(Math.max(0, ...list.map(x => x.context || 0))), 'Re-read on every step')}
|
|
1524
|
+
${kpi('Spent by live sessions', fmtUSD(list.reduce((a, x) => a + (x.est_cost_usd || 0), 0)), 'Since each started (priced agents only)', {badge: BADGE.estimated})}
|
|
1525
|
+
</div>
|
|
1526
|
+
<div class="note"><b>Idle sessions don't use tokens</b>; they only cost again when you send the next message,
|
|
1527
|
+
which re-reads their whole context. <b>Interrupt</b> stops the current turn (like Esc).
|
|
1528
|
+
<b>Close</b> exits the session cleanly; copy its resume command to bring it back.
|
|
1529
|
+
<b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
|
|
1530
|
+
Codex, Gemini and Cursor keep no session registry, so they count as running when their transcript was
|
|
1531
|
+
written in the last 20 minutes. Cursor sessions live inside the IDE and can't be stopped from here.
|
|
1532
|
+
<span class="spacer"></span><button class="btn pb-copy" id="live-refresh">โป Refresh</button></div>
|
|
1533
|
+
<div class="stack">${list.map((x, i) => `
|
|
1534
|
+
<div class="item sev-${sevOf(x)}" data-i="${i}">
|
|
1535
|
+
<div class="hd">${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
|
|
1536
|
+
<span class="pill">${esc(agentName(x.agent))}</span>
|
|
1537
|
+
${esc(x.name || shortId(x.session_id))} <span class="note">ยท ${esc(x.project)}${x.pid ? ` ยท pid ${x.pid}` : ''}${x.model ? ` ยท ${esc(x.model)}` : ''}</span>
|
|
1538
|
+
${x.hosts_dashboard ? '<span class="status high">runs this dashboard</span>' : ''}
|
|
1539
|
+
<span class="spacer"></span>
|
|
1540
|
+
<span style="font-variant-numeric:tabular-nums">${x.context == null ? 'context not recorded' : fmtNum(x.context) + ' context'} ยท ${fmtInt(x.steps)} steps${x.est_cost_usd == null ? '' : ' ยท ' + fmtUSD(x.est_cost_usd)}</span></div>
|
|
1541
|
+
<div class="dt">${x.status === 'busy' ? '<b>Working now</b>' : 'Idle'}${x.uptime ? ` ยท up ${esc(x.uptime)}` : ''} ยท
|
|
1542
|
+
last activity ${x.last_write_s == null ? 'โ' : dur(x.last_write_s)} ago${x.memory_mb == null ? '' : ` ยท ${fmtInt(x.memory_mb)} MB`} ยท
|
|
1543
|
+
<span class="note">${esc(x.cwd)}</span></div>
|
|
1544
|
+
${x.severity !== 'ok' ? `<div class="dt"><b>Advice:</b> ${x.severity === 'high'
|
|
1545
|
+
? 'Very large context. Use <b>Hand over</b> to continue in a fresh session, or split the remaining work into sub-sessions.'
|
|
1546
|
+
: 'Getting heavy. /compact at the next break, or close it if the task is done.'}</div>` : ''}
|
|
1547
|
+
<div class="live-actions">
|
|
1548
|
+
${x.signalable ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>โธ Interrupt</button>
|
|
1549
|
+
<button class="act warn" data-a="close">โน Close session</button>
|
|
1550
|
+
<button class="act danger" data-a="kill">โ Force kill</button>` : `<span class="note">${x.agent === 'cursor' ? 'Runs inside the Cursor IDE: stop it there.' : 'No matching process found: stop it in its terminal.'}</span>`}
|
|
1551
|
+
${x.resume ? `<button class="act ghost" data-copy="${esc(x.resume)}">Copy resume command</button>` : ''}
|
|
1552
|
+
${x.agent === 'claude' ? '<button class="act ghost" data-ho="1">โข Hand over / split</button>' : ''}
|
|
1553
|
+
<span class="live-msg"></span>
|
|
1554
|
+
</div>
|
|
1555
|
+
${x.agent !== 'claude' ? '' : `<div class="handover" hidden>
|
|
1556
|
+
<div class="dt">Starts fresh session(s) in <code>${esc(x.cwd)}</code>, seeded with a brief built from this
|
|
1557
|
+
transcript (goal, recent requests, task list, files changed, last status). No tokens are spent building it.</div>
|
|
1558
|
+
<textarea rows="3" placeholder="Leave empty to continue the same work in one new session. Or split it: one sub-task per line, one new session each (max 8)."></textarea>
|
|
1559
|
+
<div class="live-actions">
|
|
1560
|
+
<label class="note"><input type="checkbox" class="ho-close" checked> Close this session afterwards (resumable)</label>
|
|
1561
|
+
<span class="spacer"></span>
|
|
1562
|
+
<button class="act ghost ho-brief">Write brief only</button>
|
|
1563
|
+
<button class="act warn ho-go">Start new session(s)</button>
|
|
1564
|
+
</div>
|
|
1565
|
+
<div class="ho-out note"></div>
|
|
1566
|
+
</div>`}
|
|
1567
|
+
</div>`).join('') || `<div class="empty">No ${esc(agentWord())} sessions are running</div>`}</div>`;
|
|
1568
|
+
$('#live-refresh', page).onclick = () => render();
|
|
1569
|
+
page.querySelectorAll('.item[data-i]').forEach(row => {
|
|
1570
|
+
const x = list[+row.dataset.i], pid = x.pid, msg = row.querySelector('.live-msg');
|
|
1571
|
+
const ho = row.querySelector('.handover'), ta = ho?.querySelector('textarea'), hoOut = ho?.querySelector('.ho-out');
|
|
1572
|
+
const refreshGo = () => { const n = ta.value.split('\n').filter(l => l.trim()).length;
|
|
1573
|
+
ho.querySelector('.ho-go').textContent = n > 1 ? `Start ${Math.min(n, 8)} sub-sessions` : 'Start new session'; };
|
|
1574
|
+
if (ho) { ta.oninput = refreshGo; refreshGo(); }
|
|
1575
|
+
const runHandover = async (launch, btn) => {
|
|
1576
|
+
const label = btn.textContent; btn.disabled = true; btn.textContent = 'Workingโฆ';
|
|
1577
|
+
try {
|
|
1578
|
+
const r = await sessionAction(pid, 'handover', {tasks: ta.value.split('\n'), launch,
|
|
1579
|
+
close: launch && ho.querySelector('.ho-close').checked});
|
|
1580
|
+
hoOut.innerHTML = r.ok ? `<div class="live-msg ok">${esc(r.message)}</div>` + r.sessions.map(s =>
|
|
1581
|
+
`<div>${s.launched ? 'โถ' : 'โข'} ${esc(s.task || 'Continue the same work')} โ <code>${esc(s.brief)}</code>${s.error ? ` <span class="live-msg err">${esc(s.error)}</span>` : ''}</div>`).join('')
|
|
1582
|
+
: `<div class="live-msg err">Failed: ${esc(r.error)}</div>`;
|
|
1583
|
+
if (r.ok && r.closed && r.closed.exited) { row.classList.add('gone'); setTimeout(() => render(), 2500); }
|
|
1584
|
+
} catch (e) { hoOut.innerHTML = `<div class="live-msg err">Failed: ${esc(e.message)}</div>`; }
|
|
1585
|
+
btn.disabled = false; btn.textContent = label; refreshGo();
|
|
1586
|
+
};
|
|
1587
|
+
if (ho) {
|
|
1588
|
+
ho.querySelector('.ho-go').onclick = e => runHandover(true, e.currentTarget);
|
|
1589
|
+
ho.querySelector('.ho-brief').onclick = e => runHandover(false, e.currentTarget);
|
|
1590
|
+
}
|
|
1591
|
+
row.querySelectorAll('button.act:not(.ho-go):not(.ho-brief)').forEach(b => {
|
|
1592
|
+
if (b.dataset.ho) { b.onclick = () => { ho.hidden = !ho.hidden; if (!ho.hidden) ta.focus(); }; return; }
|
|
1593
|
+
if (b.dataset.copy) { b.onclick = async () => { try { await navigator.clipboard.writeText(b.dataset.copy); msg.textContent = 'Copied'; } catch { msg.textContent = b.dataset.copy; } }; return; }
|
|
1594
|
+
const label = b.textContent;
|
|
1595
|
+
b.onclick = async () => {
|
|
1596
|
+
// two-click confirm, inline (no browser dialogs)
|
|
1597
|
+
if (!b.classList.contains('armed')) {
|
|
1598
|
+
row.querySelectorAll('button.armed').forEach(o => { o.classList.remove('armed'); o.textContent = o.dataset.label; });
|
|
1599
|
+
b.dataset.label = label; b.classList.add('armed');
|
|
1600
|
+
b.textContent = x.hosts_dashboard && b.dataset.a !== 'interrupt'
|
|
1601
|
+
? 'Sure? This is the session that launched the dashboard (the dashboard keeps running)' : `Click again to ${b.dataset.a}`;
|
|
1602
|
+
setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = label; } }, 4000);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
b.classList.remove('armed'); b.disabled = true; b.textContent = 'Workingโฆ';
|
|
1606
|
+
try {
|
|
1607
|
+
const r = await sessionAction(pid, b.dataset.a, {agent: x.agent});
|
|
1608
|
+
msg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
|
|
1609
|
+
msg.className = 'live-msg ' + (r.ok ? 'ok' : 'err');
|
|
1610
|
+
if (r.ok && r.exited) { row.classList.add('gone'); setTimeout(() => render(), 1500); }
|
|
1611
|
+
} catch (e) { msg.textContent = 'Failed: ' + e.message; msg.className = 'live-msg err'; }
|
|
1612
|
+
b.disabled = false; b.textContent = label;
|
|
1613
|
+
};
|
|
1614
|
+
});
|
|
1615
|
+
});
|
|
1616
|
+
};
|
|
1617
|
+
|
|
1618
|
+
/* ---------- token diagnosis ---------- */
|
|
1619
|
+
// Steps + a copy-paste prompt for Claude, attached server-side to each finding.
|
|
1620
|
+
const PB = [];
|
|
1621
|
+
const pb = x => {
|
|
1622
|
+
if (!x) return '';
|
|
1623
|
+
let promptHtml = '';
|
|
1624
|
+
if (x.prompt) {
|
|
1625
|
+
PB.push(x.prompt);
|
|
1626
|
+
promptHtml = `<div class="pb-prompt"><div class="pb-hd">Prompt for Claude${x.where ? ` <span class="note">ยท run in ${esc(x.where)}</span>` : ''}
|
|
1627
|
+
<span class="spacer"></span><button class="btn pb-copy" data-pb="${PB.length - 1}">Copy</button></div>
|
|
1628
|
+
<pre>${esc(x.prompt)}</pre></div>`;
|
|
1629
|
+
}
|
|
1630
|
+
return `<details class="pb" open><summary>How to fix</summary>
|
|
1631
|
+
<ol>${x.steps.map(t => `<li>${esc(t)}</li>`).join('')}</ol>${promptHtml}</details>`;
|
|
1632
|
+
};
|
|
1633
|
+
const wirePB = page => page.querySelectorAll('.pb-copy').forEach(b => b.onclick = async () => {
|
|
1634
|
+
try { await navigator.clipboard.writeText(PB[+b.dataset.pb]); b.textContent = 'Copied โ'; }
|
|
1635
|
+
catch { b.textContent = 'Copy failed'; }
|
|
1636
|
+
setTimeout(() => b.textContent = 'Copy', 1500);
|
|
1637
|
+
});
|
|
1638
|
+
const HARNESS = {needed: ['๐ด Needed', 'high'], partial: ['๐ก Partial', 'medium'],
|
|
1639
|
+
in_place: ['โ
In place', 'low'], not_needed: ['โช Not needed', 'low'], unknown: ['โ Unknown', 'low']};
|
|
1640
|
+
const harnessChip = h => h ? `<b>${HARNESS[h.verdict][0]}</b>` : 'โ';
|
|
1641
|
+
|
|
1642
|
+
VIEWS.diagnose = async (page) => {
|
|
1643
|
+
const d = await api('diagnose');
|
|
1644
|
+
const isCl = (d.agent || 'claude') === 'claude', md = d.vocab?.md || 'CLAUDE.md';
|
|
1645
|
+
const gdir = {claude: '~/.claude', codex: '~/.codex', gemini: '~/.gemini'}[d.agent || 'claude'];
|
|
1646
|
+
PB.length = 0;
|
|
1647
|
+
const t = d.total || {}, tr = d.trend || {};
|
|
1648
|
+
const sevChip = s => s === 'high' ? '๐ด' : s === 'medium' ? '๐ก' : 'โช';
|
|
1649
|
+
page.innerHTML = `${focusStrip(d)}
|
|
1650
|
+
<div class="note" style="font-size:14px"><b>${esc(d.headline)}</b>
|
|
1651
|
+
${tr.explanation ? `<br>${esc(tr.explanation)}` : ''}</div>
|
|
1652
|
+
<div class="grid g4">
|
|
1653
|
+
${kpi('Tokens in range', fmtNum(t.b), `${fmtInt(t.n)} requests ยท ${fmtInt(t.sessions)} sessions`, {badge: BADGE.actual})}
|
|
1654
|
+
${kpi('Avg context / request', fmtNum(t.ctx), 'Re-sent on every step', {badge: BADGE.actual})}
|
|
1655
|
+
${kpi('Cache-read share', fmtPct(t.b ? 100 * t.cr / t.b : 0), 'History re-read', {badge: BADGE.actual})}
|
|
1656
|
+
${kpi('Last 7d vs prior 7d', tr.change_pct == null ? null : `${tr.change_pct > 0 ? '+' : ''}${tr.change_pct}%`,
|
|
1657
|
+
tr.volume_factor ? `${tr.volume_factor}ร requests ยท ${tr.size_factor}ร size each` : '', {badge: BADGE.actual})}
|
|
1658
|
+
</div>
|
|
1659
|
+
<div id="dx-live"></div>${card('Running now', d.live_sessions.length ? `<div class="stack">${d.live_sessions.map(x => `
|
|
1660
|
+
<div class="item sev-${x.severity === 'ok' ? 'low' : x.severity}">
|
|
1661
|
+
<div class="hd">${x.severity === 'high' ? '๐ด' : x.severity === 'medium' ? '๐ก' : '๐ข'}
|
|
1662
|
+
${esc(x.title || shortId(x.session_id))} <span class="note">ยท ${esc(x.project)}</span><span class="spacer"></span>
|
|
1663
|
+
<span style="font-variant-numeric:tabular-nums">${fmtNum(x.context)} context ยท ${fmtInt(x.steps)} steps</span></div>
|
|
1664
|
+
<div class="dt">${esc(x.advice)} <span class="note">Last write ${x.idle_min} min ago; context grew
|
|
1665
|
+
${fmtNum(x.start_context)} โ ${fmtNum(x.context)}.</span></div>${pb(x.playbook)}</div>`).join('')}</div>`
|
|
1666
|
+
: '<div class="empty">No session written in the last 20 minutes</div>',
|
|
1667
|
+
{badge: BADGE.actual, hint: 'Read live from transcripts on each load'})}
|
|
1668
|
+
<div id="dx-past"></div>${card('Past sessions that carried too much context', `<div class="stack">${d.session_health.map(x => `
|
|
1669
|
+
<div class="item sev-${x.peak >= 300000 ? 'high' : 'medium'}">
|
|
1670
|
+
<div class="hd"><a href="#" class="sess-link" data-sess="${esc(x.session_id)}">${esc(x.title || shortId(x.session_id))}</a>
|
|
1671
|
+
<span class="note">ยท ${esc(x.project)}</span><span class="spacer"></span>
|
|
1672
|
+
<span style="font-variant-numeric:tabular-nums">peak ${fmtNum(x.peak)} ยท ${fmtUSD(x.cost)} ยท
|
|
1673
|
+
~${fmtUSD(x.avoidable_cost)} above a 100K baseline</span></div>
|
|
1674
|
+
${x.fixes.map(t => `<div class="dt">โข ${esc(t)}</div>`).join('')}${pb(x.playbook)}</div>`).join('')
|
|
1675
|
+
|| '<div class="empty">No session passed 150K context</div>'}</div>`,
|
|
1676
|
+
{badge: BADGE.recommendation, hint: '"Above baseline" = context re-read beyond 100K, at cache-read price (est.)'})}
|
|
1677
|
+
<div id="dx-mem"></div>${card(`Add to ${md}${isCl ? ' / memory' : ''} (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
|
|
1678
|
+
<div class="item sev-${x.kind === 'security' ? 'high' : x.already_saved ? 'low' : 'medium'}">
|
|
1679
|
+
<div class="hd">${x.kind === 'security' ? '๐' : x.kind === 'template' ? 'โ' : x.kind === 'reference' ? '๐' : '๐'}
|
|
1680
|
+
${esc(x.text.slice(0, 140))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
|
|
1681
|
+
<span class="spacer"></span><span class="note">${fmtInt(x.sessions)} sessions</span></div>
|
|
1682
|
+
<div class="dt">${esc(x.why)}</div>
|
|
1683
|
+
<div class="dt"><b>Put it in:</b> ${esc(x.target)}</div>
|
|
1684
|
+
${(x.examples || []).length ? `<details><summary style="cursor:pointer;font-size:11.5px">Examples</summary>
|
|
1685
|
+
${x.examples.map(e => `<div class="dt note">โ${esc(e)}โ</div>`).join('')}</details>` : ''}${pb(x.playbook)}</div>`).join('')
|
|
1686
|
+
|| '<div class="empty">No repeated instructions found</div>'}</div>`,
|
|
1687
|
+
{badge: BADGE.recommendation, hint: 'Mined from non-sandbox prompts; secrets masked'})}
|
|
1688
|
+
${card('Why consumption is high', `<div class="stack">${d.drivers.map(x => `
|
|
1689
|
+
<div class="item"><div class="hd">${esc(x.title)}<span class="spacer"></span>
|
|
1690
|
+
<span style="font-variant-numeric:tabular-nums">${fmtPct(x.share_pct)} of tokens</span></div>
|
|
1691
|
+
<div class="dt">${esc(x.detail)}</div></div>`).join('')}</div>`,
|
|
1692
|
+
{badge: BADGE.actual, hint: 'Drivers overlap, so shares do not add up to 100%'})}
|
|
1693
|
+
<div id="dx-recs"></div>${card('What to change', `<div class="stack">${d.recommendations.map(r => `
|
|
1694
|
+
<div class="item sev-${r.priority === 1 ? 'high' : r.priority === 2 ? 'medium' : 'low'}">
|
|
1695
|
+
<div class="hd">${esc(r.title)}<span class="spacer"></span>
|
|
1696
|
+
${r.est_savings_usd ? `<span>~${fmtUSD(r.est_savings_usd)} est.</span>` : ''}</div>
|
|
1697
|
+
<div class="dt"><b>Why:</b> ${esc(r.why)}</div>
|
|
1698
|
+
<div class="dt"><b>How:</b> ${esc(r.how)}</div>
|
|
1699
|
+
${r.savings_basis ? `<div class="dt note">${esc(r.savings_basis)}</div>` : ''}${pb(r.playbook)}</div>`).join('')
|
|
1700
|
+
|| '<div class="empty">Nothing stands out</div>'}</div>`, {badge: BADGE.recommendation})}
|
|
1701
|
+
${card(`Projects: ${esc(d.vocab?.name || 'Claude')} config health`, `<div id="dx-proj"></div>`,
|
|
1702
|
+
{badge: BADGE.recommendation, hint: isCl ? 'CLAUDE.md, memory, .claude/settings.json and MCP checked on disk now' : `${md} checked on disk now`, flush: 1})}
|
|
1703
|
+
${isCl ? card('Projects: does it need a harness?', `<div id="dx-harness" class="stack"></div>`,
|
|
1704
|
+
{badge: BADGE.recommendation, hint: 'Harness = CLAUDE.md, permissions, hooks, skills, subagents. Need is judged from your usage in each project'}) : ''}
|
|
1705
|
+
<div id="dx-issues"></div>
|
|
1706
|
+
${card(`Global config (${gdir})`, `<div class="stack">
|
|
1707
|
+
<div class="dt">Default model: <b>${esc(d.global.default_model || 'not set')}</b> ยท
|
|
1708
|
+
MCP servers: ${esc(d.global.mcp_servers.join(', ') || 'none')} ยท
|
|
1709
|
+
${isCl ? `Custom agents: ${esc(d.global.custom_agents.join(', ') || 'none')}` : ''}</div>
|
|
1710
|
+
${d.global.files.map(x => `<div class="dt">${esc(x.path)} โ ~${fmtInt(x.tokens)} tokens</div>`).join('')}
|
|
1711
|
+
${d.global.issues.map(i => `<div class="item sev-${i.severity}"><div class="hd">${sevChip(i.severity)} ${esc(i.title)}</div>
|
|
1712
|
+
<div class="dt">${esc(i.fix)}</div>${pb(i.playbook)}</div>`).join('')}</div>`)}
|
|
1713
|
+
<div class="note">${esc(d.note)}</div>`;
|
|
1714
|
+
const cols = [
|
|
1715
|
+
{h: 'Project', trunc: 1, title: r => r.path, f: r => esc(r.name) + (r.exists ? '' : ' <span class="na">(path missing)</span>')},
|
|
1716
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1717
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1718
|
+
{h: 'Avg ctx', num: 1, f: r => fmtNum(r.avg_context)},
|
|
1719
|
+
{h: md, f: r => r.claude_md.paths.length ? `~${fmtInt(r.claude_md.tokens)} tok` : '<span class="na">missing</span>'},
|
|
1720
|
+
...(isCl ? [{h: 'Memory', num: 1, f: r => r.memory_tokens == null ? 'โ' : `~${fmtInt(r.memory_tokens)}`}] : []),
|
|
1721
|
+
{h: 'Explore %', num: 1, f: r => fmtPct(r.explore_pct)},
|
|
1722
|
+
...(isCl ? [{h: 'Harness', f: r => harnessChip(r.harness)}] : []),
|
|
1723
|
+
{h: 'Needs update', f: r => r.issues.length ? r.issues.map(i => sevChip(i.severity) + ' ' + esc(i.title)).join('<br>') : 'โ'},
|
|
1724
|
+
];
|
|
1725
|
+
page.querySelectorAll('.sess-link').forEach(l => l.onclick = e => { e.preventDefault(); openSession(l.dataset.sess); });
|
|
1726
|
+
$('#dx-proj', page).innerHTML = table(cols, d.projects);
|
|
1727
|
+
const order = {needed: 0, partial: 1, in_place: 2, not_needed: 3, unknown: 4};
|
|
1728
|
+
if ($('#dx-harness', page)) $('#dx-harness', page).innerHTML = [...d.projects].filter(p => p.harness)
|
|
1729
|
+
.sort((a, b) => order[a.harness.verdict] - order[b.harness.verdict] || b.tokens - a.tokens)
|
|
1730
|
+
.map(p => { const h = p.harness; return `<div class="item sev-${HARNESS[h.verdict][1]}">
|
|
1731
|
+
<div class="hd">${harnessChip(h)} ${esc(p.name)}<span class="spacer"></span>
|
|
1732
|
+
<span class="note">${h.have.length ? 'Has: ' + esc(h.have.join(', ')) : 'Nothing set up'}</span></div>
|
|
1733
|
+
${h.reasons.length ? `<div class="dt"><b>Why:</b> ${esc(h.reasons.join(' ยท '))}</div>` : ''}
|
|
1734
|
+
${h.missing.map(m => `<div class="dt"><b>Add ${esc(m.piece)}:</b> ${esc(m.fix)}</div>`).join('')}</div>`; })
|
|
1735
|
+
.join('') || '<div class="empty">No projects in range</div>';
|
|
1736
|
+
const withIssues = d.projects.filter(p => p.issues.length);
|
|
1737
|
+
$('#dx-issues', page).innerHTML = withIssues.map(p => card(`Fix: ${p.name}`, `<div class="stack">
|
|
1738
|
+
<div class="dt note">${esc(p.path)}</div>
|
|
1739
|
+
${p.issues.map(i => `<div class="item sev-${i.severity}"><div class="hd">${sevChip(i.severity)} ${esc(i.title)}</div>
|
|
1740
|
+
<div class="dt">${esc(i.fix)}</div>${pb(i.playbook)}</div>`).join('')}</div>`, {badge: BADGE.recommendation})).join('');
|
|
1741
|
+
wirePB(page);
|
|
1742
|
+
wireFocus(page);
|
|
1743
|
+
addChart(page, 'What drives your token use', el => C.barsH(el, {
|
|
1744
|
+
rows: d.drivers.filter(x => x.share_pct != null).sort((a, b) => b.share_pct - a.share_pct),
|
|
1745
|
+
label: x => clip(x.title, 44), value: x => x.share_pct, fmt: v => fmtPct(v), max: 100,
|
|
1746
|
+
color: x => x.share_pct >= 50 ? 'var(--critical)' : x.share_pct >= 25 ? 'var(--warning)' : seriesVar(0)}),
|
|
1747
|
+
{badge: BADGE.actual, hint: 'Share of tokens or cost each driver accounts for'});
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
/* ---------- recommendations ---------- */
|
|
1751
|
+
VIEWS.recommendations = VIEWS.advisor;
|
|
1752
|
+
|
|
1753
|
+
/* ---------- anomalies ---------- */
|
|
1754
|
+
VIEWS.anomalies = async (page) => {
|
|
1755
|
+
const [a, tl] = await Promise.all([api('anomalies'), api('timeline')]);
|
|
1756
|
+
const dates = new Set(a.anomalies.filter(x => x.date).map(x => x.date));
|
|
1757
|
+
page.innerHTML = `
|
|
1758
|
+
${card('Daily spend with anomalies highlighted', '<div class="chart" id="an"></div>' +
|
|
1759
|
+
'<div class="legend" id="anl"></div>', {badge: BADGE.estimated})}
|
|
1760
|
+
${card('Detected anomalies', `<div class="stack" id="anolist">${a.anomalies.length
|
|
1761
|
+
? a.anomalies.map((x, i) => `<div class="item sev-${x.severity} clickable" data-i="${i}">
|
|
1762
|
+
<div class="hd">${x.severity === 'high' ? '๐จ' : 'โ ๏ธ'} ${esc(x.title)}
|
|
1763
|
+
<span class="spacer"></span><span class="pill">${esc(x.type)}</span></div>
|
|
1764
|
+
<div class="dt">${esc(x.detail)}</div>
|
|
1765
|
+
<div class="mt"><span>observed ${fmtNum(x.metric_value)}</span>
|
|
1766
|
+
<span>baseline ${fmtNum(x.baseline)}</span><span>ratio ${x.ratio}ร</span>
|
|
1767
|
+
<span style="color:var(--s1)">click to inspect โ</span></div></div>`).join('')
|
|
1768
|
+
: '<div class="empty">No anomalies detected in this range</div>'}</div>`,
|
|
1769
|
+
{badge: BADGE.estimated,
|
|
1770
|
+
footer: 'Thresholds are configurable in config/settings.json โ anomaly.'})}`;
|
|
1771
|
+
C.timeSeries($('#an', page), {rows: tl, x: 'bucket', type: 'bar', fmt: fmtUSD, height: 220,
|
|
1772
|
+
xLabel: shortDay,
|
|
1773
|
+
series: [{key: 'cost', label: 'Estimated cost', fmt: fmtUSD,
|
|
1774
|
+
color: seriesVar(0)}]});
|
|
1775
|
+
// repaint anomaly days in the critical color
|
|
1776
|
+
const bars = $('#an', page).querySelectorAll('rect');
|
|
1777
|
+
tl.forEach((r, i) => { if (dates.has(r.bucket) && bars[i]) bars[i].setAttribute('fill', 'var(--critical)'); });
|
|
1778
|
+
$('#anl', page).innerHTML = `<span class="it"><span class="swatch" style="background:${seriesVar(0)}"></span>Normal day</span>
|
|
1779
|
+
<span class="it"><span class="swatch" style="background:var(--critical)"></span>๐จ Anomalous day</span>`;
|
|
1780
|
+
wireAnomalies(page, a.anomalies);
|
|
1781
|
+
};
|
|
1782
|
+
|
|
1783
|
+
/* ---------- forecast ---------- */
|
|
1784
|
+
VIEWS.forecast = async (page) => {
|
|
1785
|
+
const [f, burn] = await Promise.all([api('forecast'), api('burn')]);
|
|
1786
|
+
if (!f.available) { page.innerHTML = card('Forecast', `<div class="empty">${esc(f.message)}</div>`);
|
|
1787
|
+
return; }
|
|
1788
|
+
page.innerHTML = `
|
|
1789
|
+
<div class="grid g4">
|
|
1790
|
+
${kpi('End of day', fmtUSD(f.end_of_day_cost), null, {badge: BADGE.forecast})}
|
|
1791
|
+
${kpi('End of week', fmtUSD(f.end_of_week_cost), null, {badge: BADGE.forecast})}
|
|
1792
|
+
${kpi('End of billing period', fmtUSD(f.scenarios.expected.end_of_period_cost),
|
|
1793
|
+
`${f.remaining_days} days remaining`, {badge: BADGE.forecast})}
|
|
1794
|
+
${kpi('Estimated monthly cost', fmtUSD(f.estimated_monthly_cost), null, {badge: BADGE.forecast})}
|
|
1795
|
+
${kpi('Period to date', fmtUSD(f.period_used), fmtNum(f.period_used_tokens) + ' tokens',
|
|
1796
|
+
{badge: BADGE.estimated})}
|
|
1797
|
+
${kpi('Projected tokens', fmtNum(f.end_of_period_tokens), null, {badge: BADGE.forecast})}
|
|
1798
|
+
${kpi('Daily mean ยฑ ฯ', `${fmtUSD(f.daily_mean)} ยฑ ${fmtUSD(f.daily_stdev)}`,
|
|
1799
|
+
`over ${f.sample_days} days`, {small: 1})}
|
|
1800
|
+
${f.limit_exhaustion_date === S.opts.unavailable_label
|
|
1801
|
+
? kpi('Limit exhaustion date', null, 'Requires a configured allowance')
|
|
1802
|
+
: kpi('Limit exhaustion date', esc(f.limit_exhaustion_date),
|
|
1803
|
+
f.will_exceed ? '๐ด forecast exceeds allowance' : '๐ข within allowance', {badge: BADGE.forecast})}
|
|
1804
|
+
</div>
|
|
1805
|
+
${card('Cumulative spend and forecast fan', '<div class="chart" id="fan2"></div>' +
|
|
1806
|
+
'<div class="legend" id="fl2"></div>', {badge: BADGE.forecast,
|
|
1807
|
+
hint: f.method,
|
|
1808
|
+
footer: 'Scenarios are the 14-day mean daily spend minus, at, and plus one standard deviation, projected across the remaining days of the billing period. They assume your recent pattern continues.'})}
|
|
1809
|
+
${card('Scenarios', table([
|
|
1810
|
+
{h: 'Scenario', f: r => `<b>${esc(r[0])}</b>`},
|
|
1811
|
+
{h: 'Daily rate', num: 1, f: r => fmtUSD(r[1].daily_rate)},
|
|
1812
|
+
{h: 'Projected end of period', num: 1, f: r => fmtUSD(r[1].end_of_period_cost)},
|
|
1813
|
+
{h: 'vs today', num: 1, f: r => '+' + fmtUSD(r[1].end_of_period_cost - f.period_used)},
|
|
1814
|
+
], Object.entries(f.scenarios)), {badge: BADGE.forecast})}`;
|
|
1815
|
+
C.forecastFan($('#fan2', page), {history: burn.series, scenarios: f.scenarios,
|
|
1816
|
+
remainingDays: f.remaining_days, height: 300});
|
|
1817
|
+
$('#fl2', page).innerHTML = `<span class="it"><span class="swatch" style="background:var(--s1)"></span>
|
|
1818
|
+
Cumulative actual (estimated cost)</span>
|
|
1819
|
+
<span class="it"><span class="swatch" style="background:var(--s1);opacity:.35"></span>
|
|
1820
|
+
Forecast band (conservative โ high)</span>`;
|
|
1821
|
+
};
|
|
1822
|
+
|
|
1823
|
+
/* ---------- budgets ---------- */
|
|
1824
|
+
VIEWS.budgets = async (page) => {
|
|
1825
|
+
const b = await api('budgets');
|
|
1826
|
+
const st = S.opts.settings;
|
|
1827
|
+
page.innerHTML = `
|
|
1828
|
+
${card('Budget vs actual vs forecast', `<div class="stack">${b.lines.map(l => l.configured ? `
|
|
1829
|
+
<div class="item">
|
|
1830
|
+
<div class="hd">${esc(l.name)}<span class="spacer"></span>${statusChip(l.status)}</div>
|
|
1831
|
+
<div class="grid g4" style="gap:8px;margin:4px 0">
|
|
1832
|
+
${kpi('Budget', l.unit === 'tokens' ? fmtNum(l.budget) : fmtUSD(l.budget), null, {small: 1})}
|
|
1833
|
+
${kpi('Actual', l.unit === 'tokens' ? fmtNum(l.actual) : fmtUSD(l.actual),
|
|
1834
|
+
fmtPct(l.used_pct) + ' used', {small: 1, badge: BADGE.estimated})}
|
|
1835
|
+
${kpi('Forecast', l.forecast == null ? null
|
|
1836
|
+
: (l.unit === 'tokens' ? fmtNum(l.forecast) : fmtUSD(l.forecast)),
|
|
1837
|
+
l.forecast_pct == null ? '' : fmtPct(l.forecast_pct) + ' of budget',
|
|
1838
|
+
{small: 1, badge: BADGE.forecast})}
|
|
1839
|
+
${kpi('Variance', l.variance == null ? null
|
|
1840
|
+
: (l.variance >= 0 ? '+' : '') + (l.unit === 'tokens' ? fmtNum(l.variance) : fmtUSD(l.variance)),
|
|
1841
|
+
l.variance == null ? '' : (l.variance > 0 ? 'over budget' : 'under budget'), {small: 1})}
|
|
1842
|
+
</div>
|
|
1843
|
+
<div class="meter ${l.status}"><i style="width:${Math.min(l.used_pct, 100)}%"></i></div>
|
|
1844
|
+
<div class="mt">
|
|
1845
|
+
${l.thresholds_breached.length ? `<span>โ ๏ธ breached ${l.thresholds_breached.join('%, ')}%</span>` : '<span>No threshold breached</span>'}
|
|
1846
|
+
${l.thresholds_forecast_breach.length
|
|
1847
|
+
? `<span style="color:var(--serious-ink)">forecast to breach ${l.thresholds_forecast_breach.join('%, ')}%</span>` : ''}
|
|
1848
|
+
</div></div>`
|
|
1849
|
+
: `<div class="item"><div class="hd">${esc(l.name)}<span class="spacer"></span>
|
|
1850
|
+
<span class="badge na">not configured</span></div>
|
|
1851
|
+
<div class="dt">Actual to date: ${l.unit === 'tokens' ? fmtNum(l.actual) : fmtUSD(l.actual)}
|
|
1852
|
+
(estimated). Set a budget below to track variance and get threshold warnings.</div></div>`
|
|
1853
|
+
).join('')}</div>`, {badge: BADGE.estimated,
|
|
1854
|
+
hint: `alert thresholds: ${b.thresholds_pct.join('%, ')}%`})}
|
|
1855
|
+
${card('Configure budgets, limits and thresholds', `
|
|
1856
|
+
<div class="grid g3">
|
|
1857
|
+
<div><div class="hd" style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;font-weight:620;margin-bottom:6px">Budgets</div>
|
|
1858
|
+
<label class="pop" style="position:static;display:block;border:none;box-shadow:none;padding:0">
|
|
1859
|
+
<div class="hd">Monthly budget (USD)</div>
|
|
1860
|
+
<input type="number" step="1" id="b-monthly" value="${st.budgets.monthly_usd ?? ''}" placeholder="not set">
|
|
1861
|
+
<div class="hd">Daily budget (USD)</div>
|
|
1862
|
+
<input type="number" step="0.5" id="b-daily" value="${st.budgets.daily_usd ?? ''}" placeholder="not set">
|
|
1863
|
+
<div class="hd">Monthly token budget</div>
|
|
1864
|
+
<input type="number" id="b-tokens" value="${st.budgets.monthly_tokens ?? ''}" placeholder="not set">
|
|
1865
|
+
</label></div>
|
|
1866
|
+
<div><div class="hd" style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;font-weight:620;margin-bottom:6px">Plan limits</div>
|
|
1867
|
+
<label class="pop" style="position:static;display:block;border:none;box-shadow:none;padding:0">
|
|
1868
|
+
<div class="hd">Monthly cost allowance (USD)</div>
|
|
1869
|
+
<input type="number" step="1" id="l-cost" value="${st.limits.monthly_cost_allowance_usd ?? ''}" placeholder="not exposed by ${agentWord()} data">
|
|
1870
|
+
<div class="hd">Monthly token allowance</div>
|
|
1871
|
+
<input type="number" id="l-tok" value="${st.limits.monthly_token_allowance ?? ''}" placeholder="not exposed by ${agentWord()} data">
|
|
1872
|
+
<div class="hd">Monthly request allowance</div>
|
|
1873
|
+
<input type="number" id="l-req" value="${st.limits.monthly_request_allowance ?? ''}" placeholder="not exposed by ${agentWord()} data">
|
|
1874
|
+
<div class="hd">Remaining credits (USD)</div>
|
|
1875
|
+
<input type="number" step="0.01" id="l-cred" value="${st.limits.remaining_credits_usd ?? ''}" placeholder="not exposed by ${agentWord()} data">
|
|
1876
|
+
</label></div>
|
|
1877
|
+
<div><div class="hd" style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;font-weight:620;margin-bottom:6px">Alert thresholds</div>
|
|
1878
|
+
<label class="pop" style="position:static;display:block;border:none;box-shadow:none;padding:0">
|
|
1879
|
+
<div class="hd">Comma-separated %</div>
|
|
1880
|
+
<input type="text" id="t-thr" value="${st.alert_thresholds_pct.join(',')}"
|
|
1881
|
+
style="width:100%;padding:5px 7px;border-radius:6px;border:1px solid var(--border);background:var(--surface-2)">
|
|
1882
|
+
</label>
|
|
1883
|
+
<button class="chip on" id="savecfg" style="margin-top:10px;width:100%;justify-content:center">Save configuration</button>
|
|
1884
|
+
<div class="note" style="margin-top:8px">Writes to <span class="mono">config/settings.json</span>.
|
|
1885
|
+
Leave a field blank to keep it unconfigured โ the dashboard will report it as unavailable
|
|
1886
|
+
rather than inventing a value.</div>
|
|
1887
|
+
</div>
|
|
1888
|
+
</div>`, {footer: `Plan allowances are NOT available from ${agentWord()} data. Anything you enter here is your own declared figure, used only to compute usage-vs-limit and days-until-limit.`})}`;
|
|
1889
|
+
$('#savecfg', page).onclick = async () => {
|
|
1890
|
+
const v = id => { const x = $('#' + id, page).value.trim(); return x === '' ? null : +x; };
|
|
1891
|
+
await fetch('/api/settings', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
1892
|
+
body: JSON.stringify({
|
|
1893
|
+
budgets: {monthly_usd: v('b-monthly'), daily_usd: v('b-daily'), monthly_tokens: v('b-tokens')},
|
|
1894
|
+
limits: {monthly_cost_allowance_usd: v('l-cost'), monthly_token_allowance: v('l-tok'),
|
|
1895
|
+
monthly_request_allowance: v('l-req'), remaining_credits_usd: v('l-cred')},
|
|
1896
|
+
alert_thresholds_pct: $('#t-thr', page).value.split(',').map(x => +x.trim()).filter(Boolean),
|
|
1897
|
+
})}).then(r => r.json());
|
|
1898
|
+
S.opts = await fetch('/api/options').then(r => r.json());
|
|
1899
|
+
bust(); render();
|
|
1900
|
+
};
|
|
1901
|
+
const bl = b.lines.filter(l => l.configured && l.budget);
|
|
1902
|
+
if (bl.length) addChart(page, 'Budget used', el => C.barsH(el, {
|
|
1903
|
+
rows: bl, label: l => l.name, value: l => 100 * l.actual / l.budget, fmt: v => fmtPct(v), max: 100,
|
|
1904
|
+
color: l => l.actual >= l.budget ? 'var(--critical)' : l.actual >= 0.75 * l.budget ? 'var(--warning)' : seriesVar(2)}),
|
|
1905
|
+
{badge: BADGE.estimated, after: '.nothing'});
|
|
1906
|
+
};
|
|
1907
|
+
|
|
1908
|
+
/* ---------- scorecard ---------- */
|
|
1909
|
+
VIEWS.scorecard = async (page) => {
|
|
1910
|
+
const [sc, advisor] = await Promise.all([api('scorecard'), api('advisor')]);
|
|
1911
|
+
page.innerHTML = `
|
|
1912
|
+
${card('AI FinOps Score', `<div class="scorewrap">
|
|
1913
|
+
<div style="text-align:center">
|
|
1914
|
+
<div class="scorenum">${sc.score}</div>
|
|
1915
|
+
<div class="scoregrade">out of 100 ยท grade <b>${sc.grade}</b></div>
|
|
1916
|
+
<div class="chart" id="sg" style="width:190px;margin-top:6px"></div></div>
|
|
1917
|
+
<div style="flex:1;min-width:280px" class="stack">${sc.dimensions.map(d => `
|
|
1918
|
+
<div><div style="display:flex;justify-content:space-between;font-size:12px">
|
|
1919
|
+
<span><b>${esc(d.name)}</b> <span class="note">weight ${d.weight}</span></span>
|
|
1920
|
+
<span style="font-variant-numeric:tabular-nums;font-weight:640">${d.score}</span></div>
|
|
1921
|
+
<div class="meter ${d.score >= 75 ? 'healthy' : d.score >= 50 ? 'high'
|
|
1922
|
+
: d.score >= 30 ? 'approaching' : 'critical'}"><i style="width:${d.score}%"></i></div>
|
|
1923
|
+
<div class="note">${esc(d.detail)}</div></div>`).join('')}
|
|
1924
|
+
</div></div>`, {badge: BADGE.estimated,
|
|
1925
|
+
footer: 'The score is a weighted mean of the dimensions above, each graded against a reference in config/settings.json โ scorecard. It is a self-consistency measure of your own usage, not a benchmark against other users.'})}
|
|
1926
|
+
<div class="grid g3">
|
|
1927
|
+
${card('โ
What is good', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
|
|
1928
|
+
sc.what_is_good.map(x => `<li style="margin-bottom:6px">${esc(x)}</li>`).join('')
|
|
1929
|
+
|| '<li class="na">Nothing scored above 60 in this range</li>'}</ul>`)}
|
|
1930
|
+
${card('โ ๏ธ Needs attention', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
|
|
1931
|
+
sc.needs_attention.map(x => `<li style="margin-bottom:6px">${esc(x)}</li>`).join('')
|
|
1932
|
+
|| '<li class="na">Nothing scored below 70</li>'}</ul>`)}
|
|
1933
|
+
${card('๐ฏ Biggest opportunity', sc.biggest_opportunity ? `
|
|
1934
|
+
<div class="item sev-high"><div class="hd">${esc(sc.biggest_opportunity.title)}</div>
|
|
1935
|
+
<div class="dt">${esc(sc.biggest_opportunity.detail)}</div>
|
|
1936
|
+
<div class="mt"><span>Estimated excess ${fmtUSD(sc.biggest_opportunity.estimated_excess_usd)}</span>
|
|
1937
|
+
<span>of ${fmtUSD(sc.biggest_opportunity.exposed_cost_usd)} exposed</span></div>
|
|
1938
|
+
<div class="dt"><b>Action:</b> ${esc(sc.biggest_opportunity.action || '')}</div></div>`
|
|
1939
|
+
: '<div class="empty">Nothing flagged</div>', {badge: BADGE.estimated})}
|
|
1940
|
+
</div>`;
|
|
1941
|
+
C.gauge($('#sg', page), {pct: sc.score,
|
|
1942
|
+
status: sc.score >= 75 ? 'healthy' : sc.score >= 55 ? 'high'
|
|
1943
|
+
: sc.score >= 40 ? 'approaching' : 'critical', label: 'FinOps score', size: 190});
|
|
1944
|
+
renderAdvisorHero(page, advisor);
|
|
1945
|
+
addChart(page, 'Score by dimension', el => C.barsH(el, {
|
|
1946
|
+
rows: [...sc.dimensions].sort((a, b) => a.score - b.score), label: x => x.name, value: x => x.score,
|
|
1947
|
+
fmt: v => Math.round(v) + '/100', max: 100,
|
|
1948
|
+
color: x => x.score < 50 ? 'var(--critical)' : x.score < 75 ? 'var(--warning)' : 'var(--good)'}),
|
|
1949
|
+
{hint: 'Weakest first', after: ':scope > .card'});
|
|
1950
|
+
};
|
|
1951
|
+
|
|
1952
|
+
/* ---------- developer / Claude Code ---------- */
|
|
1953
|
+
VIEWS.developer = async (page) => {
|
|
1954
|
+
const d = await api('developer');
|
|
1955
|
+
const u = d.unavailable;
|
|
1956
|
+
page.innerHTML = `
|
|
1957
|
+
<div class="grid g2">
|
|
1958
|
+
${card('Cost per repository', '<div class="chart" id="rb"></div>', {badge: BADGE.estimated,
|
|
1959
|
+
hint: 'sandbox agent workspaces excluded'})}
|
|
1960
|
+
${card('Tool usage', '<div class="chart" id="tb"></div>', {badge: BADGE.actual,
|
|
1961
|
+
hint: 'top tools by call count'})}
|
|
1962
|
+
</div>
|
|
1963
|
+
${card('Repository FinOps', table([
|
|
1964
|
+
{h: 'Repository', f: r => esc(r.repository)},
|
|
1965
|
+
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
1966
|
+
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
1967
|
+
{h: 'Files touched', num: 1, f: r => fmtInt(r.files_touched)},
|
|
1968
|
+
{h: 'Cost / session', num: 1, f: r => fmtUSD(r.cost_per_session)},
|
|
1969
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1970
|
+
], d.cost_per_repository), {badge: BADGE.estimated})}
|
|
1971
|
+
<div class="grid g2">
|
|
1972
|
+
${card('Cost by git branch', table([
|
|
1973
|
+
{h: 'Branch', f: r => `<span class="mono">${esc(r.branch)}</span>`},
|
|
1974
|
+
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
1975
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1976
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1977
|
+
], d.branches), {badge: BADGE.estimated,
|
|
1978
|
+
hint: 'branch recorded per session in the transcript'})}
|
|
1979
|
+
${card('Most-touched files', table([
|
|
1980
|
+
{h: 'File', trunc: 1, title: r => r.path, f: r => `<span class="mono">${esc(r.path)}</span>`},
|
|
1981
|
+
{h: 'Ops', f: r => esc(r.ops)},
|
|
1982
|
+
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
1983
|
+
{h: 'Touches', num: 1, f: r => fmtInt(r.touches)},
|
|
1984
|
+
], d.files), {badge: BADGE.actual})}
|
|
1985
|
+
</div>
|
|
1986
|
+
${card(`Not available from ${esc(agentWord())} data`, `<dl class="kv">
|
|
1987
|
+
${Object.entries(u).map(([k, v]) => `<dt>${esc(k.replace(/_/g, ' '))}</dt>
|
|
1988
|
+
<dd class="na">${esc(v)}</dd>`).join('')}
|
|
1989
|
+
</dl>`, {badge: '<span class="badge na">Unavailable</span>',
|
|
1990
|
+
footer: 'Agent transcripts record which files a tool call targeted, but not diff sizes, commits, PRs or issue linkage. Correlating spend with commits or PRs would require a separate git/GitHub data source, which this dashboard deliberately does not fabricate.'})}`;
|
|
1991
|
+
C.barsH($('#rb', page), {rows: d.cost_per_repository.slice(0, 12), label: r => r.repository,
|
|
1992
|
+
value: r => r.cost, color: seriesVar(2)});
|
|
1993
|
+
C.barsH($('#tb', page), {rows: d.tools.slice(0, 14), label: r => r.name, value: r => r.calls,
|
|
1994
|
+
fmt: fmtInt, color: seriesVar(0)});
|
|
1995
|
+
};
|
|
1996
|
+
|
|
1997
|
+
/* ---------- search ---------- */
|
|
1998
|
+
VIEWS.search = async (page) => {
|
|
1999
|
+
const term = S.searchTerm || '';
|
|
2000
|
+
if (term.trim().length < 2) { page.innerHTML = '<div class="empty">Type at least 2 characters</div>';
|
|
2001
|
+
return; }
|
|
2002
|
+
const r = await fetch(`/api/search?q=${encodeURIComponent(term)}`).then(x => x.json());
|
|
2003
|
+
const sec = (title, html, n) => card(`${title} (${n})`, n ? html
|
|
2004
|
+
: '<div class="empty">No matches</div>');
|
|
2005
|
+
page.innerHTML = `
|
|
2006
|
+
<div class="note">Global search for "<b>${esc(term)}</b>" โ searches prompt text, session IDs
|
|
2007
|
+
and titles, git branches, project names and paths, model IDs, tool names and targets,
|
|
2008
|
+
and dates. Independent of the global filters.</div>
|
|
2009
|
+
${sec('Prompts', '<div id="sp"></div>', r.prompts.length)}
|
|
2010
|
+
${sec('Sessions', '<div id="ss"></div>', r.sessions.length)}
|
|
2011
|
+
<div class="grid g3">
|
|
2012
|
+
${sec('Projects', table([{h: 'Project', f: x => esc(x.name)},
|
|
2013
|
+
{h: 'Path', trunc: 1, f: x => `<span class="mono sub">${esc(x.path || 'โ')}</span>`}],
|
|
2014
|
+
r.projects), r.projects.length)}
|
|
2015
|
+
${sec('Models', table([{h: 'Model', f: x => esc(modelName(x.model))},
|
|
2016
|
+
{h: 'Requests', num: 1, f: x => fmtInt(x.requests)},
|
|
2017
|
+
{h: 'Est. cost', num: 1, f: x => fmtUSD(x.cost)}], r.models), r.models.length)}
|
|
2018
|
+
${sec('Days', table([{h: 'Day', f: x => esc(x.day)},
|
|
2019
|
+
{h: 'Requests', num: 1, f: x => fmtInt(x.requests)},
|
|
2020
|
+
{h: 'Est. cost', num: 1, f: x => fmtUSD(x.cost)}], r.days), r.days.length)}
|
|
2021
|
+
</div>
|
|
2022
|
+
${sec('Tools & targets', table([{h: 'Tool', f: x => esc(x.name)},
|
|
2023
|
+
{h: 'Target', trunc: 1, title: x => x.target || '', f: x => `<span class="mono">${esc(x.target || 'โ')}</span>`},
|
|
2024
|
+
{h: 'Calls', num: 1, f: x => fmtInt(x.n)}], r.tools), r.tools.length)}`;
|
|
2025
|
+
if (r.prompts.length) {
|
|
2026
|
+
$('#sp', page).innerHTML = table([
|
|
2027
|
+
{h: 'When', f: x => `<span class="mono">${esc((x.ts || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
2028
|
+
{h: 'Prompt', trunc: 1, title: x => x.preview, f: x => esc(x.preview)},
|
|
2029
|
+
{h: 'Category', f: x => `<span class="pill">${esc(x.category)}</span>`},
|
|
2030
|
+
{h: 'Tokens', num: 1, f: x => fmtNum(x.billable_tokens)},
|
|
2031
|
+
{h: 'Est. cost', num: 1, f: x => fmtUSD(x.est_cost_usd)},
|
|
2032
|
+
], r.prompts, {onRow: 1});
|
|
2033
|
+
wireTable($('#sp', page), r.prompts, x => openPrompt(x.prompt_id));
|
|
2034
|
+
}
|
|
2035
|
+
if (r.sessions.length) {
|
|
2036
|
+
$('#ss', page).innerHTML = table([
|
|
2037
|
+
{h: 'Session', trunc: 1, f: x => esc(x.title || shortId(x.session_id))},
|
|
2038
|
+
{h: 'Project', f: x => esc(x.project)},
|
|
2039
|
+
{h: 'Branch', f: x => x.git_branch ? `<span class="mono">${esc(x.git_branch)}</span>` : 'โ'},
|
|
2040
|
+
{h: 'Tokens', num: 1, f: x => fmtNum(x.billable_tokens)},
|
|
2041
|
+
{h: 'Est. cost', num: 1, f: x => fmtUSD(x.est_cost_usd)},
|
|
2042
|
+
], r.sessions, {onRow: 1});
|
|
2043
|
+
wireTable($('#ss', page), r.sessions, x => openSession(x.session_id));
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
|
|
2047
|
+
/* ---------- exports & data provenance ---------- */
|
|
2048
|
+
const EXPORTS = [['usage', 'Usage timeline'], ['prompts', 'Prompts'], ['sessions', 'Sessions'],
|
|
2049
|
+
['models', 'Model breakdown'], ['projects', 'Projects'], ['costs', 'Costs'],
|
|
2050
|
+
['waste', 'Waste findings'], ['recommendations', 'FinOps recommendations'],
|
|
2051
|
+
['forecast', 'Forecast scenarios']];
|
|
2052
|
+
VIEWS.exports = async (page) => {
|
|
2053
|
+
const m = S.opts.meta, p = S.opts.pricing;
|
|
2054
|
+
page.innerHTML = `
|
|
2055
|
+
${card('Export', `<div class="tbl-wrap"><table class="tbl">
|
|
2056
|
+
<thead><tr><th class="nosort">Dataset</th><th class="nosort">CSV</th>
|
|
2057
|
+
<th class="nosort">JSON</th></tr></thead><tbody>
|
|
2058
|
+
${EXPORTS.map(([k, l]) => `<tr><td>${l}</td>
|
|
2059
|
+
<td><a href="/api/export/${k}?${qs()}&format=csv">Download CSV</a></td>
|
|
2060
|
+
<td><a href="/api/export/${k}?${qs()}&format=json">Download JSON</a></td></tr>`).join('')}
|
|
2061
|
+
<tr><td><b>Full PDF report</b></td><td colspan="2">
|
|
2062
|
+
<a href="/api/export/report?${qs()}" target="_blank">Open printable report</a>
|
|
2063
|
+
<span class="note"> โ then Cmd/Ctrl+P โ Save as PDF</span></td></tr>
|
|
2064
|
+
</tbody></table></div>`,
|
|
2065
|
+
{hint: 'exports honour the current global filters',
|
|
2066
|
+
footer: 'Prompt exports include your full prompt text. Handle the files accordingly.'})}
|
|
2067
|
+
${card('Data provenance', `<dl class="kv">
|
|
2068
|
+
<dt>Source</dt><dd class="mono">${esc(m.source_dir)}</dd>
|
|
2069
|
+
<dt>Transcript files parsed</dt><dd>${esc(m.transcript_files)}</dd>
|
|
2070
|
+
<dt>Warehouse built</dt><dd>${esc(m.built_at)}</dd>
|
|
2071
|
+
<dt>Data range</dt><dd>${esc(S.opts.date_range.first)} โ ${esc(S.opts.date_range.last)}</dd>
|
|
2072
|
+
<dt>Cost basis</dt><dd><span class="badge est">Estimated</span> token counts ร configured pricing</dd>
|
|
2073
|
+
<dt>Price table updated</dt><dd>${esc(p.updated)} ยท ${esc(p.source)}</dd>
|
|
2074
|
+
<dt>Rebuild command</dt><dd class="mono">python3 -m finops.etl</dd>
|
|
2075
|
+
</dl>`, {badge: BADGE.actual})}
|
|
2076
|
+
${card('Accuracy contract', `
|
|
2077
|
+
<div class="grid g4">
|
|
2078
|
+
<div class="item"><div class="hd">${BADGE.actual} Actual</div>
|
|
2079
|
+
<div class="dt">Read directly from your transcripts: token counts, timestamps, models,
|
|
2080
|
+
tool calls, file paths, session and project identity.</div></div>
|
|
2081
|
+
<div class="item"><div class="hd">${BADGE.estimated} Estimated</div>
|
|
2082
|
+
<div class="dt">Derived: every dollar figure. ${agentWord()} data contains no billed
|
|
2083
|
+
amounts, so cost = tokens ร <span class="mono">config/pricing.json</span>.</div></div>
|
|
2084
|
+
<div class="item"><div class="hd">${BADGE.forecast} Forecast</div>
|
|
2085
|
+
<div class="dt">Projected from history. Assumes recent patterns continue; it is not a
|
|
2086
|
+
commitment or a quote.</div></div>
|
|
2087
|
+
<div class="item"><div class="hd">${BADGE.recommendation} Recommendation</div>
|
|
2088
|
+
<div class="dt">Modelled opportunity. Savings estimates hold token usage constant on the
|
|
2089
|
+
alternative and do not model output quality.</div></div>
|
|
2090
|
+
</div>
|
|
2091
|
+
<h3 style="font-size:12.5px;margin:14px 0 6px">Deliberately not fabricated</h3>
|
|
2092
|
+
<dl class="kv">
|
|
2093
|
+
<dt>Plan tier & allowance</dt><dd class="na">${esc(naLabel())} โ configure it yourself if you know it</dd>
|
|
2094
|
+
<dt>Remaining credits</dt><dd class="na">${esc(naLabel())}</dd>
|
|
2095
|
+
<dt>Message / request allowance</dt><dd class="na">${esc(naLabel())}</dd>
|
|
2096
|
+
<dt>Billed invoice amounts</dt><dd class="na">${esc(naLabel())}</dd>
|
|
2097
|
+
<dt>Assistant response text</dt><dd class="na">Not stored โ only usage metadata is extracted</dd>
|
|
2098
|
+
<dt>Lines changed / commits / PRs</dt><dd class="na">${esc(naLabel())}</dd>
|
|
2099
|
+
</dl>`)}`;
|
|
2100
|
+
};
|
|
2101
|
+
|
|
2102
|
+
/* ============================ focus cues ============================ */
|
|
2103
|
+
// "What needs me first": one ranked list built from the diagnosis, used for the nav
|
|
2104
|
+
// badges and the Focus strip at the top of the overview and diagnosis pages.
|
|
2105
|
+
function focusItems(d) {
|
|
2106
|
+
const out = [];
|
|
2107
|
+
for (const x of d.live_sessions || []) if (x.severity !== 'ok')
|
|
2108
|
+
out.push({lvl: x.severity === 'high' ? 1 : 2, text: `Running session at ${fmtNum(x.context)} context: ${x.title || shortId(x.session_id)}`, view: 'diagnose', anchor: 'dx-live'});
|
|
2109
|
+
for (const m of d.memory_suggestions || []) if (m.kind === 'security')
|
|
2110
|
+
out.push({lvl: 1, text: `Credentials pasted in prompts (${m.sessions} sessions): rotate them`, view: 'diagnose', anchor: 'dx-mem'});
|
|
2111
|
+
for (const r of d.recommendations || []) if (r.priority === 1)
|
|
2112
|
+
out.push({lvl: 1, text: r.title + (r.est_savings_usd ? ` (~${fmtUSD(r.est_savings_usd)})` : ''), view: 'diagnose', anchor: 'dx-recs'});
|
|
2113
|
+
for (const p of d.projects || []) for (const i of p.issues) if (i.severity === 'high')
|
|
2114
|
+
out.push({lvl: 1, text: `${p.name}: ${i.title}`, view: 'diagnose', anchor: 'dx-issues'});
|
|
2115
|
+
const s0 = (d.session_health || [])[0];
|
|
2116
|
+
if (s0) out.push({lvl: 2, text: `Heaviest session: ${s0.title || shortId(s0.session_id)} (peak ${fmtNum(s0.peak)})`, view: 'diagnose', anchor: 'dx-past'});
|
|
2117
|
+
for (const p of d.projects || []) for (const i of p.issues) if (i.severity === 'medium')
|
|
2118
|
+
out.push({lvl: 2, text: `${p.name}: ${i.title}`, view: 'diagnose', anchor: 'dx-issues'});
|
|
2119
|
+
return out.sort((a, b) => a.lvl - b.lvl);
|
|
2120
|
+
}
|
|
2121
|
+
function focusStrip(d, n = 5) {
|
|
2122
|
+
const items = focusItems(d);
|
|
2123
|
+
if (!items.length) return '';
|
|
2124
|
+
return `<section class="focus"><div class="focus-hd">๐ฏ Focus on these first
|
|
2125
|
+
<span class="note">${items.filter(i => i.lvl === 1).length} urgent ยท ${items.filter(i => i.lvl === 2).length} next</span></div>
|
|
2126
|
+
${items.slice(0, n).map((it, k) => `<a class="focus-it l${it.lvl}" data-go="${it.view}" data-anchor="${it.anchor}">
|
|
2127
|
+
<span class="fn">${k + 1}</span><span class="ft">${esc(it.text)}</span>
|
|
2128
|
+
<span class="fl">${it.lvl === 1 ? 'Fix first' : 'Next'}</span></a>`).join('')}</section>`;
|
|
2129
|
+
}
|
|
2130
|
+
function wireFocus(root) {
|
|
2131
|
+
root.querySelectorAll('.focus-it').forEach(a => a.onclick = async () => {
|
|
2132
|
+
if (S.view !== a.dataset.go) { go(a.dataset.go); await new Promise(r => setTimeout(r, 400)); }
|
|
2133
|
+
const el = document.getElementById(a.dataset.anchor);
|
|
2134
|
+
if (el) { el.scrollIntoView({behavior: 'smooth', block: 'start'}); el.classList.add('flash');
|
|
2135
|
+
setTimeout(() => el.classList.remove('flash'), 1600); }
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
async function updateNavBadges() {
|
|
2139
|
+
const set = (id, n, lvl) => { const el = document.querySelector(`[data-nb="${id}"]`);
|
|
2140
|
+
if (el) { el.textContent = n ? n : ''; el.className = 'nb' + (n ? ' l' + lvl : ''); } };
|
|
2141
|
+
try {
|
|
2142
|
+
const [d, w, a] = await Promise.all([hasPriced() ? api('diagnose') : null, api('waste'), api('anomalies')]);
|
|
2143
|
+
if (d) { const f = focusItems(d), urgent = f.filter(i => i.lvl === 1).length;
|
|
2144
|
+
set('diagnose', urgent || f.length, urgent ? 1 : 2); }
|
|
2145
|
+
const hw = w.findings.filter(x => x.severity === 'high').length;
|
|
2146
|
+
set('waste', hw || w.findings.length, hw ? 1 : 2);
|
|
2147
|
+
set('anomalies', (a.anomalies || []).length, 2);
|
|
2148
|
+
const lv = await fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json());
|
|
2149
|
+
const heavy = (lv.sessions || []).filter(x => x.severity !== 'ok').length;
|
|
2150
|
+
set('live', (lv.sessions || []).length, heavy ? 1 : 2);
|
|
2151
|
+
} catch { /* badges are a hint; never block the page */ }
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/* ---------- agent-aware chrome ---------- */
|
|
2155
|
+
// What the current agent selection can show: Claude-only pages need Claude Code in the
|
|
2156
|
+
// selection; cost pages need at least one agent whose usage is priced.
|
|
2157
|
+
const selAgents = () => (S.opts?.agents || []).filter(a => S.filter.agents.includes(a.id));
|
|
2158
|
+
const hasClaude = () => S.filter.agents.includes('claude');
|
|
2159
|
+
const hasPriced = () => selAgents().some(a => a.data !== 'activity');
|
|
2160
|
+
const hasCloudKey = () => Object.values(S.opts?.cloud || {}).some(Boolean);
|
|
2161
|
+
const navAllowed = scope => !scope || (scope === 'claude' ? hasClaude()
|
|
2162
|
+
: scope === 'cloud' ? hasCloudKey() : hasPriced());
|
|
2163
|
+
// "Claude Code", "Codex", or "agent" for a mixed selection: used in data-availability text
|
|
2164
|
+
const agentWord = () => { const a = selAgents(); return a.length === 1 ? a[0].name : 'agent'; };
|
|
2165
|
+
function applyAgentChrome() {
|
|
2166
|
+
const a = selAgents();
|
|
2167
|
+
const label = a.length === 1 ? a[0].name.replace(/ (Code|CLI)$/, '') : a.length ? 'Multi-agent' : 'AI';
|
|
2168
|
+
const mark = $('.brand .mark');
|
|
2169
|
+
if (mark) mark.innerHTML = `<span class="dot"></span>${esc(label)} FinOps`;
|
|
2170
|
+
const sub = $('.brand .sub');
|
|
2171
|
+
if (sub) sub.textContent = a.length > 1 ? a.map(x => x.name).join(' + ') : 'Command Center';
|
|
2172
|
+
document.title = `${label} FinOps Command Center`;
|
|
2173
|
+
const scopes = Object.fromEntries(NAV.flatMap(([, items]) => items.map(([id, , , sc]) => [id, sc])));
|
|
2174
|
+
document.querySelectorAll('.nav a').forEach(el => el.style.display = navAllowed(scopes[el.dataset.view]) ? '' : 'none');
|
|
2175
|
+
// hide a group header when every item under it is hidden
|
|
2176
|
+
document.querySelectorAll('.nav .group').forEach(g => {
|
|
2177
|
+
let el = g.nextElementSibling, any = false;
|
|
2178
|
+
while (el && !el.classList.contains('group')) { if (el.style.display !== 'none') any = true; el = el.nextElementSibling; }
|
|
2179
|
+
g.style.display = any ? '' : 'none';
|
|
2180
|
+
});
|
|
2181
|
+
// leaving a page the new selection can't show: fall back to the overview
|
|
2182
|
+
if (scopes[S.view] && !navAllowed(scopes[S.view])) S.view = 'overview';
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
/* ============================ router ============================ */
|
|
2186
|
+
const TITLES = Object.fromEntries(NAV.flatMap(([, items]) => items.map(([id, , l]) => [id, l])));
|
|
2187
|
+
TITLES.search = 'Global search';
|
|
2188
|
+
|
|
2189
|
+
function go(view) { S.view = view; closeDrawer(); render(); }
|
|
2190
|
+
|
|
2191
|
+
async function render() {
|
|
2192
|
+
C.hideTip(); // a tooltip whose chart is about to be replaced must not linger
|
|
2193
|
+
// A fresh container per render: if a slower, superseded view resolves later it
|
|
2194
|
+
// writes into its own detached node and can never clobber the current view.
|
|
2195
|
+
const stale = $('#page');
|
|
2196
|
+
const page = document.createElement('div');
|
|
2197
|
+
page.className = 'page';
|
|
2198
|
+
page.id = 'page';
|
|
2199
|
+
stale.replaceWith(page);
|
|
2200
|
+
applyAgentChrome();
|
|
2201
|
+
document.querySelectorAll('.nav a').forEach(a =>
|
|
2202
|
+
a.classList.toggle('on', a.dataset.view === S.view));
|
|
2203
|
+
$('#ttl').textContent = TITLES[S.view] || S.view;
|
|
2204
|
+
const f = S.filter;
|
|
2205
|
+
const bits = [];
|
|
2206
|
+
if (f.start || f.end) bits.push(`<b>${esc(f.start || 'โฆ')} โ ${esc(f.end || 'โฆ')}</b>`);
|
|
2207
|
+
else bits.push('<b>all time</b>');
|
|
2208
|
+
if (f.models.length) bits.push(f.models.map(m => esc(modelName(m))).join(', '));
|
|
2209
|
+
if (f.projects.length) bits.push(f.projects.map(p =>
|
|
2210
|
+
esc((S.opts.projects.find(x => String(x.project_id) === p) || {}).name || p)).join(', '));
|
|
2211
|
+
if (f.categories.length) bits.push(f.categories.join(', '));
|
|
2212
|
+
if (!f.include_sandbox) bits.push('excl. sandbox');
|
|
2213
|
+
$('#crumbs').innerHTML = bits.join(' <span style="opacity:.4">ยท</span> ');
|
|
2214
|
+
filterBar();
|
|
2215
|
+
page.innerHTML = '<div class="loading">Computingโฆ</div>';
|
|
2216
|
+
updateNavBadges();
|
|
2217
|
+
try {
|
|
2218
|
+
await (VIEWS[S.view] || VIEWS.overview)(page);
|
|
2219
|
+
} catch (e) {
|
|
2220
|
+
page.innerHTML = `<div class="card"><div class="body">
|
|
2221
|
+
<b>Something went wrong rendering this view.</b>
|
|
2222
|
+
<pre class="prompt-text" style="margin-top:8px">${esc(e.stack || e)}</pre></div></div>`;
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/* ============================ boot ============================ */
|
|
2227
|
+
(async function boot() {
|
|
2228
|
+
const saved = localStorage.getItem('finops-theme');
|
|
2229
|
+
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
|
2230
|
+
shell();
|
|
2231
|
+
S.opts = await fetch('/api/options').then(r => r.json());
|
|
2232
|
+
S.filter.agents = defaultAgents();
|
|
2233
|
+
applyRange('30d');
|
|
2234
|
+
// ?view=<name> opens straight to one screen, so a link (or a screenshot run)
|
|
2235
|
+
// can point at a specific report rather than always landing on the overview.
|
|
2236
|
+
const want = new URLSearchParams(location.search).get('view');
|
|
2237
|
+
if (want && NAV.some(([, items]) => items.some(([id]) => id === want))) S.view = want;
|
|
2238
|
+
await render();
|
|
2239
|
+
if (!want) maybeFirstTour(); // arriving on a deep link is not a first visit
|
|
2240
|
+
let t; addEventListener('resize', () => { clearTimeout(t); t = setTimeout(render, 220); });
|
|
2241
|
+
})();
|
|
2242
|
+
|
|
2243
|
+
/* ============================ agents ============================ */
|
|
2244
|
+
// Last choice if still valid, else the agent with the most usage (Claude on most machines).
|
|
2245
|
+
function defaultAgents() {
|
|
2246
|
+
const ag = S.opts?.agents || [];
|
|
2247
|
+
let saved = [];
|
|
2248
|
+
try { saved = JSON.parse(localStorage.getItem('finops-agents') || '[]'); } catch (_) {}
|
|
2249
|
+
saved = saved.filter(id => ag.some(a => a.id === id));
|
|
2250
|
+
if (saved.length) return saved;
|
|
2251
|
+
const top = [...ag].sort((a, b) => b.requests - a.requests)[0];
|
|
2252
|
+
return top ? [top.id] : [];
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
VIEWS.agents = async (page) => {
|
|
2256
|
+
const d = await api('by_agent');
|
|
2257
|
+
const all = S.opts.agents || [];
|
|
2258
|
+
const colorOf = id => seriesVar(Math.max(all.findIndex(a => a.id === id), 0));
|
|
2259
|
+
const dataBadge = k => ({full: '<span class="badge">Full data</span>',
|
|
2260
|
+
tokens: '<span class="badge">Tokens + model</span>',
|
|
2261
|
+
activity: '<span class="badge rec">Activity only</span>'}[k] || '');
|
|
2262
|
+
// pivot daily rows into one row per day with a column per agent
|
|
2263
|
+
const byDay = new Map();
|
|
2264
|
+
for (const r of d.daily) {
|
|
2265
|
+
const row = byDay.get(r.day) || {day: r.day};
|
|
2266
|
+
row[r.agent] = S.metric === 'cost' ? r.cost : r.tokens;
|
|
2267
|
+
byDay.set(r.day, row);
|
|
2268
|
+
}
|
|
2269
|
+
const shown = d.agents.map(a => a.agent);
|
|
2270
|
+
page.innerHTML = `
|
|
2271
|
+
<div class="note">Showing: <b>${esc(shown.map(id => all.find(a => a.id === id)?.name || id).join(' + ') || 'nothing in range')}</b>.
|
|
2272
|
+
Click an agent chip above to see only it; <b>Cmd/Ctrl-click</b> to add more, or <b>All</b> for every agent together.
|
|
2273
|
+
Every other page follows the same selection.</div>
|
|
2274
|
+
<div class="grid g4">${d.agents.map(a => kpi(a.name,
|
|
2275
|
+
a.cost ? fmtUSD(a.cost) : fmtNum(a.tokens) + ' tok',
|
|
2276
|
+
`${fmtInt(a.sessions)} sessions ยท ${fmtInt(a.prompts)} prompts ยท ${fmtInt(a.active_days)} active days`,
|
|
2277
|
+
{badge: a.cost ? BADGE.estimated : ''})).join('')}</div>
|
|
2278
|
+
${card('Side by side', `<div id="ag-tbl"></div>`, {flush: 1, badge: BADGE.estimated,
|
|
2279
|
+
hint: 'Costs are estimated at each provider\'s API list price'})}
|
|
2280
|
+
${card(`Daily ${S.metric === 'cost' ? 'estimated cost' : 'tokens'} by agent`, `<div class="chart" id="ag-trend"></div><div id="ag-leg"></div>`,
|
|
2281
|
+
{actions: `<button class="chip ${S.metric === 'cost' ? 'on' : ''}" data-m="cost">Cost</button>
|
|
2282
|
+
<button class="chip ${S.metric !== 'cost' ? 'on' : ''}" data-m="tokens">Tokens</button>`})}
|
|
2283
|
+
${card('What each agent records on this machine', `<div class="stack">${all.map(a => `
|
|
2284
|
+
<div class="item"><div class="hd">${esc(a.name)} ${dataBadge(a.data)}<span class="spacer"></span>
|
|
2285
|
+
<span class="note">${a.requests ? `${esc(a.first)} โ ${esc(a.last)} ยท ${fmtInt(a.sessions)} sessions` : 'installed, no usage recorded'}</span></div>
|
|
2286
|
+
<div class="dt">${esc(a.note)}</div></div>`).join('')}</div>`)}`;
|
|
2287
|
+
$('#ag-tbl', page).innerHTML = table([
|
|
2288
|
+
{h: 'Agent', f: r => `<b>${esc(r.name)}</b> ${dataBadge(r.data)}`},
|
|
2289
|
+
{h: 'Est. cost', num: 1, f: r => r.data === 'activity' ? '<span class="na">not priced</span>' : fmtUSD(r.cost)},
|
|
2290
|
+
{h: 'Tokens', num: 1, f: r => r.tokens ? fmtNum(r.tokens) : '<span class="na">not recorded</span>'},
|
|
2291
|
+
{h: 'Output', num: 1, f: r => r.output_tokens ? fmtNum(r.output_tokens) : 'โ'},
|
|
2292
|
+
{h: 'Cache reads', num: 1, f: r => r.cache_read_tokens ? fmtNum(r.cache_read_tokens) : 'โ'},
|
|
2293
|
+
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
2294
|
+
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
2295
|
+
{h: 'Tool calls', num: 1, f: r => fmtInt(r.tool_calls)},
|
|
2296
|
+
{h: 'Cost / prompt', num: 1, f: r => r.cost && r.prompts ? fmtUSD(r.cost / r.prompts) : 'โ'},
|
|
2297
|
+
{h: 'Models', trunc: 1, title: r => r.models, f: r => esc((r.models || '').split(',').map(modelName).join(', '))},
|
|
2298
|
+
], d.agents);
|
|
2299
|
+
const series = shown.map(id => ({key: id, label: all.find(a => a.id === id)?.name || id, color: colorOf(id)}));
|
|
2300
|
+
C.timeSeries($('#ag-trend', page), {rows: [...byDay.values()], x: 'day', series, type: 'bar',
|
|
2301
|
+
fmt: S.metric === 'cost' ? fmtUSD : fmtNum, height: 230, xLabel: shortDay});
|
|
2302
|
+
C.legend($('#ag-leg', page), series.map(s => ({label: s.label, color: s.color})));
|
|
2303
|
+
page.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { S.metric = b.dataset.m; render(); });
|
|
2304
|
+
addChart(page, 'Share by agent', el => {
|
|
2305
|
+
el.innerHTML = '<div class="grid g2"><div><div class="note">Estimated cost</div><div id="ag-d1"></div></div>' +
|
|
2306
|
+
'<div><div class="note">Tokens</div><div id="ag-d2"></div></div></div>';
|
|
2307
|
+
C.donut($('#ag-d1', el), {rows: d.agents.filter(a => a.cost > 0), label: a => a.name, value: a => a.cost,
|
|
2308
|
+
color: a => colorOf(a.agent), size: 170, centerValue: fmtUSD(d.agents.reduce((x, a) => x + a.cost, 0)), centerLabel: 'estimated'});
|
|
2309
|
+
C.donut($('#ag-d2', el), {rows: d.agents.filter(a => a.tokens > 0), label: a => a.name, value: a => a.tokens, fmt: fmtNum,
|
|
2310
|
+
color: a => colorOf(a.agent), size: 170, centerValue: fmtNum(d.agents.reduce((x, a) => x + (a.tokens || 0), 0)), centerLabel: 'tokens'});
|
|
2311
|
+
const lg = document.createElement('div'); el.appendChild(lg);
|
|
2312
|
+
C.legend(lg, d.agents.map(a => ({label: `${a.name} ยท ${a.cost ? fmtUSD(a.cost) + ' ยท ' : ''}${fmtNum(a.tokens)} tok`, color: colorOf(a.agent)})));
|
|
2313
|
+
}, {badge: BADGE.estimated});
|
|
2314
|
+
};
|
|
2315
|
+
|
|
2316
|
+
/* ============================ machine actions ============================ */
|
|
2317
|
+
async function doAction(path, body = {}) {
|
|
2318
|
+
const r = await fetch(`/api/do/${path}`, {method: 'POST',
|
|
2319
|
+
headers: {'X-FinOps-Action': '1', 'Content-Type': 'application/json'}, body: JSON.stringify(body)});
|
|
2320
|
+
const d = await r.json();
|
|
2321
|
+
if (d.error) throw new Error(d.error.split('\n').filter(Boolean).pop());
|
|
2322
|
+
return d;
|
|
2323
|
+
}
|
|
2324
|
+
// Poll a background job, streaming its log into `host`; resolves with the final status.
|
|
2325
|
+
async function followJob(id, host) {
|
|
2326
|
+
for (;;) {
|
|
2327
|
+
const j = await fetch(`/api/job/${id}`).then(r => r.json());
|
|
2328
|
+
if (host) {
|
|
2329
|
+
let box = host.querySelector('.prompt-text');
|
|
2330
|
+
if (!box) { host.innerHTML = '<div class="prompt-text"></div>'; box = host.firstChild; }
|
|
2331
|
+
// follow the newest line unless the user scrolled up to read
|
|
2332
|
+
const atEnd = box.scrollHeight - box.scrollTop - box.clientHeight < 24;
|
|
2333
|
+
box.textContent = j.log.join('\n') || 'Startingโฆ';
|
|
2334
|
+
if (atEnd) box.scrollTop = box.scrollHeight;
|
|
2335
|
+
}
|
|
2336
|
+
if (j.state !== 'running') return j;
|
|
2337
|
+
await new Promise(r => setTimeout(r, 1200));
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
/* ---------- sync ---------- */
|
|
2342
|
+
const ago = iso => { if (!iso) return 'never';
|
|
2343
|
+
const m = Math.round((Date.now() - Date.parse(iso)) / 60000);
|
|
2344
|
+
return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`; };
|
|
2345
|
+
async function syncLabel() {
|
|
2346
|
+
const b = $('#sync'); if (!b) return;
|
|
2347
|
+
const d = await fetch('/api/sync').then(r => r.json()).catch(() => ({}));
|
|
2348
|
+
if (d.job && d.job.state === 'running') return runSync();
|
|
2349
|
+
b.title = `Last synced ${ago(d.built_at)}. Re-read every agent's local data so the dashboard is current`;
|
|
2350
|
+
b.textContent = `โณ Sync ยท ${ago(d.built_at)}`;
|
|
2351
|
+
}
|
|
2352
|
+
async function runSync() {
|
|
2353
|
+
const b = $('#sync');
|
|
2354
|
+
b.disabled = true; b.textContent = 'โณ Syncingโฆ';
|
|
2355
|
+
try {
|
|
2356
|
+
const {job} = await doAction('sync');
|
|
2357
|
+
const j = await followJob(job);
|
|
2358
|
+
if (j.state !== 'done') throw new Error(j.log.pop());
|
|
2359
|
+
S.opts = await fetch('/api/options').then(r => r.json()); bust(); render();
|
|
2360
|
+
} catch (e) { b.title = 'Sync failed: ' + e.message; }
|
|
2361
|
+
b.disabled = false; syncLabel();
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/* ---------- free models ---------- */
|
|
2365
|
+
VIEWS.freemodels = async (page) => {
|
|
2366
|
+
const d = await fetch('/api/free_models').then(r => r.json());
|
|
2367
|
+
page.innerHTML = `
|
|
2368
|
+
<div class="note">${esc(d.how_it_works)} Free models are weaker than Claude: use them for routine
|
|
2369
|
+
work (see <a data-go="modelswitch">Model switch</a> for which work that is) and keep Claude for hard problems.</div>
|
|
2370
|
+
${!d.bin_on_path ? `<div class="item sev-medium"><div class="dt">${esc(d.bin_dir)} is not on your PATH, so the new commands
|
|
2371
|
+
won't run by name. Add <code>export PATH="$HOME/.local/bin:$PATH"</code> to ~/.zshrc.</div></div>` : ''}
|
|
2372
|
+
${card('How to use and test a free model', `<div class="stack">
|
|
2373
|
+
<div class="dt"><b>1. Test:</b> click <b>โถ Test it</b> on an added model. It asks the model directly, then runs a real one-line Claude Code session through the new command.</div>
|
|
2374
|
+
<div class="dt"><b>2. Use:</b> open a terminal in any project and run the command, e.g. <code>claude-qwen-coder</code>. It's normal Claude Code, running on the free model.</div>
|
|
2375
|
+
<div class="dt"><b>3. Check which model is on:</b> type <code>/model</code> or <code>/status</code> inside that session. It shows the Qwen model.</div>
|
|
2376
|
+
<div class="dt"><b>Will it appear in /model in my normal <code>claude</code>?</b> No. Normal <code>claude</code> talks only to Anthropic, which doesn't serve Qwen. Each free model lives behind its own command, so your Claude sessions stay unchanged. Inside a free-model session, <code>/model</code> can't switch to Opus/Sonnet either; exit and run <code>claude</code> for that.</div>
|
|
2377
|
+
<div class="dt note">Quick manual check from a terminal: <code>claude-qwen-coder -p "say hi"</code></div>
|
|
2378
|
+
</div>`)}
|
|
2379
|
+
<div class="grid g3">${d.models.map(m => card(m.name, `<div class="stack">
|
|
2380
|
+
<div class="dt"><b>Good for:</b> ${esc(m.good_for)}</div>
|
|
2381
|
+
<div class="dt"><b>Limits:</b> ${esc(m.limits)}</div>
|
|
2382
|
+
<div class="dt note">${m.provider === 'ollama'
|
|
2383
|
+
? `Runs on this machine ยท ~${m.download_gb} GB download ยท wants ${m.min_ram_gb} GB RAM (you have ${m.ram_gb} GB)`
|
|
2384
|
+
: 'Runs in the cloud via OpenRouter ยท no download'}</div>
|
|
2385
|
+
${m.installed
|
|
2386
|
+
? `<div class="dt">โ
Added. Run <code>${esc(m.command)}</code> in any project.</div>
|
|
2387
|
+
<div><button class="act" data-test="${esc(m.id)}">โถ Test it</button>
|
|
2388
|
+
<button class="act ghost" data-rm="${esc(m.id)}">Remove</button></div>
|
|
2389
|
+
<div data-testlog="${esc(m.id)}"></div>`
|
|
2390
|
+
: `<div><button class="act" data-add="${esc(m.id)}" ${m.fits_ram ? '' : 'title="Less RAM than recommended"'}>๏ผ Add ${esc(m.command)}</button></div>`}
|
|
2391
|
+
</div>`, {badge: m.installed ? '<span class="badge">Added</span>' : ''})).join('')}</div>`;
|
|
2392
|
+
page.querySelectorAll('[data-go]').forEach(a => a.onclick = () => go(a.dataset.go));
|
|
2393
|
+
page.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFreeModel(b.dataset.add));
|
|
2394
|
+
page.querySelectorAll('[data-test]').forEach(b => b.onclick = () => testFreeModel(b.dataset.test, page.querySelector(`[data-testlog="${b.dataset.test}"]`), b));
|
|
2395
|
+
page.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => {
|
|
2396
|
+
if (b.dataset.armed) { await doAction(`free_model/${b.dataset.rm}/remove`); render(); }
|
|
2397
|
+
else { b.dataset.armed = 1; b.classList.add('armed'); b.textContent = 'Click again to remove'; }
|
|
2398
|
+
});
|
|
2399
|
+
};
|
|
2400
|
+
|
|
2401
|
+
async function testFreeModel(id, host, btn) {
|
|
2402
|
+
btn.disabled = true;
|
|
2403
|
+
try {
|
|
2404
|
+
const {job} = await doAction(`free_model/${id}/test`);
|
|
2405
|
+
const j = await followJob(job, host);
|
|
2406
|
+
host.insertAdjacentHTML('beforeend', j.state === 'done'
|
|
2407
|
+
? '<div class="item sev-low"><div class="dt">โ
Working end to end.</div></div>'
|
|
2408
|
+
: '<div class="item sev-high"><div class="dt">Test failed. See the log above.</div></div>');
|
|
2409
|
+
} catch (e) { host.innerHTML = `<div class="item sev-high"><div class="dt">${esc(e.message)}</div></div>`; }
|
|
2410
|
+
btn.disabled = false;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// Show exactly what will happen, collect anything needed, run only after the user confirms.
|
|
2414
|
+
async function addFreeModel(id) {
|
|
2415
|
+
const d = drawer('Add free model', '<div class="loading">Checking prerequisitesโฆ</div>');
|
|
2416
|
+
const c = d.querySelector('.content');
|
|
2417
|
+
const plan = await fetch(`/api/free_models/${id}/plan`).then(r => r.json());
|
|
2418
|
+
if (plan.blocked) { c.innerHTML = `<div class="item sev-high"><div class="dt" style="white-space:pre-wrap">${esc(plan.blocked)}</div></div>`; return; }
|
|
2419
|
+
c.innerHTML = `
|
|
2420
|
+
<h3>${esc(plan.model.name)}</h3>
|
|
2421
|
+
<div class="dt">This will:</div>
|
|
2422
|
+
<div class="stack">${plan.steps.map((s, i) => `<div class="item ${s.warn ? 'sev-medium' : ''}"><div class="dt">
|
|
2423
|
+
${s.warn ? 'โ ' : `${i + 1}.`} ${esc(s.do)}${s.consent ? ' <span class="pill">needs your OK</span>' : ''}</div></div>`).join('')}</div>
|
|
2424
|
+
${plan.needs.map(n => `${n.steps ? `<div class="item"><div class="hd">How to get your ${esc(n.label)}</div>
|
|
2425
|
+
<ol class="dt" style="margin:6px 0 8px 18px;padding:0;line-height:1.7">${n.steps.map(x => `<li>${esc(x)}</li>`).join('')}</ol>
|
|
2426
|
+
${n.url ? `<a class="act" href="${esc(n.url)}" target="_blank" rel="noopener" style="text-decoration:none;display:inline-block">Open ${esc(new URL(n.url).host)} โ</a>` : ''}</div>` : ''}
|
|
2427
|
+
<label class="dt"><b>${esc(n.label)}</b><br>
|
|
2428
|
+
<input type="password" data-need="${esc(n.field)}" autocomplete="off" style="width:100%;margin-top:6px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--surface-2)">
|
|
2429
|
+
<span class="note">${esc(n.help)}</span></label>`).join('')}
|
|
2430
|
+
${plan.consent_required ? `<label class="dt"><input type="checkbox" id="fm-ok"> I'm OK with the steps marked "needs your OK"</label>` : ''}
|
|
2431
|
+
<div><button class="act" id="fm-go">Add ${esc(plan.model.command)}</button></div>
|
|
2432
|
+
<div id="fm-log"></div>`;
|
|
2433
|
+
$('#fm-go', c).onclick = async () => {
|
|
2434
|
+
const body = {consent: plan.consent_required ? $('#fm-ok', c).checked : true};
|
|
2435
|
+
if (!body.consent) { $('#fm-log', c).innerHTML = '<div class="item sev-medium"><div class="dt">Tick the box to confirm first.</div></div>'; return; }
|
|
2436
|
+
for (const n of c.querySelectorAll('[data-need]')) {
|
|
2437
|
+
if (!n.value.trim()) { $('#fm-log', c).innerHTML = `<div class="item sev-medium"><div class="dt">Enter the ${esc(n.closest('label').querySelector('b').textContent)}.</div></div>`; return; }
|
|
2438
|
+
const need = plan.needs.find(x => x.field === n.dataset.need);
|
|
2439
|
+
if (need?.pattern && !new RegExp(need.pattern).test(n.value.trim())) {
|
|
2440
|
+
$('#fm-log', c).innerHTML = `<div class="item sev-medium"><div class="dt">That doesn't look like an ${esc(need.label)}. It should start with sk-or-.</div></div>`; return; }
|
|
2441
|
+
body[n.dataset.need] = n.value.trim();
|
|
2442
|
+
}
|
|
2443
|
+
$('#fm-go', c).disabled = true;
|
|
2444
|
+
try {
|
|
2445
|
+
const {job} = await doAction(`free_model/${id}/install`, body);
|
|
2446
|
+
const j = await followJob(job, $('#fm-log', c));
|
|
2447
|
+
if (j.state === 'done') { $('#fm-log', c).insertAdjacentHTML('beforeend',
|
|
2448
|
+
`<div class="item sev-low"><div class="dt">โ
Added. Open a terminal in any project and run <code>${esc(j.result.command)}</code>. Inside it, <code>/model</code> shows the Qwen model; your normal <code>claude</code> is unchanged.</div>
|
|
2449
|
+
<div><button class="act" id="fm-test">โถ Test it now</button></div><div id="fm-testlog"></div></div>`);
|
|
2450
|
+
$('#fm-test', c).onclick = e => testFreeModel(id, $('#fm-testlog', c), e.target);
|
|
2451
|
+
if (S.view === 'freemodels') render(); }
|
|
2452
|
+
else $('#fm-go', c).disabled = false;
|
|
2453
|
+
} catch (e) { $('#fm-log', c).innerHTML = `<div class="item sev-high"><div class="dt">${esc(e.message)}</div></div>`; $('#fm-go', c).disabled = false; }
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/* ---------- compare Claude vs free models ---------- */
|
|
2458
|
+
VIEWS.compare = async (page) => {
|
|
2459
|
+
const d = await fetch('/api/compare?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json());
|
|
2460
|
+
const hasFree = d.rows.some(r => r.kind === 'free');
|
|
2461
|
+
const stars = n => n == null ? '<span class="na">โ</span>' : 'โ
'.repeat(Math.floor(n)) + (n % 1 ? 'ยฝ' : '') +
|
|
2462
|
+
`<span style="opacity:.25">${'โ
'.repeat(5 - Math.ceil(n))}</span>`;
|
|
2463
|
+
const price = r => r.kind === 'free' ? '<b>$0</b>' : `${fmtUSD(r.price_in)} / ${fmtUSD(r.price_out)}`;
|
|
2464
|
+
const status = r => r.kind !== 'free'
|
|
2465
|
+
? (r.requests ? `${fmtInt(r.requests)} req ยท ${fmtUSD(r.cost)}` : '<span class="na">not used</span>')
|
|
2466
|
+
: !r.fits ? `โ needs ${r.min_ram_gb} GB RAM` : r.installed ? `โ
<code>${esc(r.command)}</code>` : `<a data-go="freemodels">๏ผ Add</a>`;
|
|
2467
|
+
page.innerHTML = `
|
|
2468
|
+
${card(hasFree ? 'Claude vs free models' : `${esc(agentWord())} models side by side`, `<div id="cmp"></div>`, {flush: 1, badge: BADGE.recommendation,
|
|
2469
|
+
hint: hasFree ? `This machine: ${esc(d.os)}, ${d.ram_gb} GB RAM` : 'List prices, your usage in range', footer: esc(d.note)})}
|
|
2470
|
+
${!hasFree ? '' : `<div class="grid g3">
|
|
2471
|
+
${card('Use Claude for', `<div class="dt">Multi-file changes, debugging, anything agentic or long-running.
|
|
2472
|
+
Sonnet is the value pick; keep Opus for the hardest problems (see <a data-go="modelswitch">Model switch</a>).</div>`)}
|
|
2473
|
+
${card('Use a free model for', `<div class="dt">Offline or private work, throwaway snippets, explanations,
|
|
2474
|
+
single-file edits. Cloud Qwen is the strongest free option; locally, pick the biggest one that fits your RAM.</div>`)}
|
|
2475
|
+
${card('Try it on your own work', `<div class="dt">Give the same small task to a free model (e.g. <code>claude-qwen</code>)
|
|
2476
|
+
and to Sonnet (<code>/model sonnet</code>), then compare the diff and how many turns each took.</div>`)}
|
|
2477
|
+
</div>`}`;
|
|
2478
|
+
$('#cmp', page).innerHTML = table([
|
|
2479
|
+
{h: 'Model', f: r => `<b>${esc(r.name)}</b>${r.kind === 'free' ? ' <span class="pill">free</span>' : ''}`},
|
|
2480
|
+
{h: 'Runs', f: r => esc(r.where)},
|
|
2481
|
+
{h: '$ / 1M in ยท out', num: 1, f: price},
|
|
2482
|
+
{h: 'Tool use', f: r => stars(r.tools)},
|
|
2483
|
+
{h: 'Reasoning', f: r => stars(r.reasoning)},
|
|
2484
|
+
{h: 'Multi-file', f: r => stars(r.multifile)},
|
|
2485
|
+
{h: 'Speed', f: r => esc(r.speed)},
|
|
2486
|
+
{h: 'Best for', f: r => esc(r.best_for)},
|
|
2487
|
+
{h: 'You', f: status},
|
|
2488
|
+
], d.rows);
|
|
2489
|
+
page.querySelectorAll('[data-go]').forEach(a => a.onclick = e => { e.preventDefault(); go(a.dataset.go); });
|
|
2490
|
+
addChart(page, 'Output price per 1M tokens', el => C.barsH(el, {
|
|
2491
|
+
rows: d.rows, label: r => r.name.replace(/ \((local|free).*\)$/, ''), value: r => r.price_out || 0,
|
|
2492
|
+
color: r => r.kind === 'free' ? seriesVar(2) : seriesVar(0)}),
|
|
2493
|
+
{hint: hasFree ? 'Free models cost $0; Claude prices are list prices' : 'List prices', after: ':scope > .card'});
|
|
2494
|
+
};
|
|
2495
|
+
|
|
2496
|
+
/* ---------- skills & MCP from recurring work ---------- */
|
|
2497
|
+
VIEWS.toolkit = async (page) => {
|
|
2498
|
+
const d = await fetch('/api/suggestions').then(r => r.json());
|
|
2499
|
+
const ev = x => (x || []).map(e => `<code>${esc(e)}</code>`).join(' ');
|
|
2500
|
+
page.innerHTML = `
|
|
2501
|
+
<div class="note">${esc(d.note)}</div>
|
|
2502
|
+
${card('MCP servers for work you repeat', `<div class="stack">${d.mcp.map(m => `
|
|
2503
|
+
<div class="item ${m.installed ? '' : 'sev-medium'}"><div class="hd">${esc(m.name)}
|
|
2504
|
+
<span class="spacer"></span><span class="note">${fmtInt(m.sessions)} sessions ยท ${esc(m.projects.join(', '))}</span></div>
|
|
2505
|
+
<div class="dt">${esc(m.what)}</div>
|
|
2506
|
+
${m.evidence.length ? `<div class="dt note">Seen: ${ev(m.evidence)}</div>` : ''}
|
|
2507
|
+
${m.installed ? '<div class="dt">โ
Already connected</div>' : `
|
|
2508
|
+
<div class="dt"><code>${esc(m.command)}</code></div>
|
|
2509
|
+
<div><button class="act" data-mcp="${esc(m.id)}">๏ผ Add to Claude</button><span class="mcp-out"></span></div>`}
|
|
2510
|
+
</div>`).join('') || '<div class="empty">No recurring pattern points to an MCP server</div>'}</div>`,
|
|
2511
|
+
{badge: BADGE.recommendation})}
|
|
2512
|
+
${card('Skills from what you repeat', `<div class="stack">${d.skills.map((s, i) => `
|
|
2513
|
+
<div class="item ${s.installed ? '' : 'sev-medium'}"><div class="hd">/${esc(s.name)}
|
|
2514
|
+
<span class="spacer"></span><span class="note">${fmtInt(s.sessions)} sessions ยท ${fmtInt(s.runs)} runs ยท ${esc(s.projects.join(', '))}</span></div>
|
|
2515
|
+
<div class="dt">${esc(s.what)}</div>
|
|
2516
|
+
<div class="dt note">Examples: ${ev(s.examples.slice(0, 2))}</div>
|
|
2517
|
+
${s.installed ? '<div class="dt">โ
Skill exists</div>' : `<div><button class="act" data-skill="${i}">๏ผ Create skill</button><span class="sk-out"></span></div>`}
|
|
2518
|
+
</div>`).join('') || '<div class="empty">Nothing repeats often enough yet</div>'}</div>`,
|
|
2519
|
+
{badge: BADGE.recommendation, hint: `Created in ${d.skills_dir}; edit the SKILL.md afterwards`})}`;
|
|
2520
|
+
page.querySelectorAll('[data-skill]').forEach(b => b.onclick = async () => {
|
|
2521
|
+
const out = b.nextElementSibling; b.disabled = true;
|
|
2522
|
+
try { const r = await doAction('skill', d.skills[+b.dataset.skill]);
|
|
2523
|
+
out.innerHTML = ` โ
Created <code>${esc(r.path)}</code>. Use it with <code>${esc(r.use)}</code>.`; }
|
|
2524
|
+
catch (e) { out.textContent = ' ' + e.message; b.disabled = false; }
|
|
2525
|
+
});
|
|
2526
|
+
page.querySelectorAll('[data-mcp]').forEach(b => b.onclick = async () => {
|
|
2527
|
+
const m = d.mcp.find(x => x.id === b.dataset.mcp); const out = b.nextElementSibling;
|
|
2528
|
+
const body = {};
|
|
2529
|
+
for (const n of m.needs) {
|
|
2530
|
+
if (!b.dataset.asked) {
|
|
2531
|
+
out.innerHTML = ` <input data-need="${esc(n.field)}" placeholder="${esc(n.label)}" style="width:320px;padding:4px 8px;border:1px solid var(--border);border-radius:6px;background:var(--surface-2)"> <span class="note">${esc(n.help)}</span>`;
|
|
2532
|
+
b.dataset.asked = 1; b.textContent = 'Confirm add'; return;
|
|
2533
|
+
}
|
|
2534
|
+
body[n.field] = out.querySelector(`[data-need="${n.field}"]`).value;
|
|
2535
|
+
}
|
|
2536
|
+
b.disabled = true;
|
|
2537
|
+
try { const r = await doAction(`mcp/${m.id}`, body); out.innerHTML = ` โ
Added. ${esc(r.next)}`; }
|
|
2538
|
+
catch (e) { out.textContent = ' ' + e.message; b.disabled = false; }
|
|
2539
|
+
});
|
|
2540
|
+
};
|
|
2541
|
+
|
|
2542
|
+
/* ---------- billed vs local (vendor APIs) ---------- */
|
|
2543
|
+
VIEWS.cloud = async (page) => {
|
|
2544
|
+
const d = await fetch('/api/cloud?days=30').then(r => r.json());
|
|
2545
|
+
const t = d.totals, cfg = d.configured, pv = d.providers;
|
|
2546
|
+
const none = !Object.values(cfg).some(Boolean);
|
|
2547
|
+
const gap = (t.billed_claude_cost || 0) - (t.local_claude_cost || 0);
|
|
2548
|
+
const setup = Object.entries(pv).map(([k, p]) => `
|
|
2549
|
+
<div class="item sev-${cfg[k] ? 'low' : 'medium'}">
|
|
2550
|
+
<div class="hd">${cfg[k] ? 'โ' : 'โ'} ${esc(p.name)}<span class="spacer"></span>
|
|
2551
|
+
<span class="note">${cfg[k] ? 'key found' : 'no key'}</span></div>
|
|
2552
|
+
<div class="dt"><b>Gives you:</b> ${esc(p.covers)}</div>
|
|
2553
|
+
<div class="dt"><b>Get a key:</b> ${esc(p.how)}</div>
|
|
2554
|
+
<div class="dt">Then set <code>${esc(p.env)}</code> in your environment, or add
|
|
2555
|
+
<code>"${esc(p.field)}"</code> to <code>~/.claude-finops/secrets.local.json</code>
|
|
2556
|
+
(gitignored, never packaged). Restart the dashboard afterwards.</div>
|
|
2557
|
+
</div>`).join('');
|
|
2558
|
+
page.innerHTML = `
|
|
2559
|
+
<div class="note"><b>The only page that talks to the internet.</b> ${esc(d.note)}
|
|
2560
|
+
Nothing is fetched until you click Refresh.
|
|
2561
|
+
<span class="spacer"></span>
|
|
2562
|
+
<span class="note">Last fetched: ${esc(d.fetched_at || 'never')}</span>
|
|
2563
|
+
<button class="act" id="cl-sync" ${none ? 'disabled title="Add a key first"' : ''}>โฏ Refresh from APIs</button></div>
|
|
2564
|
+
<div id="cl-log"></div>
|
|
2565
|
+
${Object.keys(d.errors).length ? `<div class="note sev-high">${Object.entries(d.errors)
|
|
2566
|
+
.map(([k, v]) => `<div><b>${esc(k)}</b>: ${esc(v)}</div>`).join('')}</div>` : ''}
|
|
2567
|
+
<div class="grid g4">
|
|
2568
|
+
${kpi('Billed (Claude Code, org)', t.billed_claude_cost ? fmtUSD(t.billed_claude_cost) : null,
|
|
2569
|
+
`${fmtInt(t.org_users)} users ยท ${fmtNum(t.billed_claude_tokens)} tokens`, {badge: BADGE.actual})}
|
|
2570
|
+
${kpi('This machine (estimated)', fmtUSD(t.local_claude_cost),
|
|
2571
|
+
`${fmtNum(t.local_claude_tokens)} tokens from local transcripts`, {badge: BADGE.estimated})}
|
|
2572
|
+
${kpi('Gap', t.billed_claude_cost ? fmtUSD(gap) : null,
|
|
2573
|
+
'Other machines, other members, or work off this machine')}
|
|
2574
|
+
${kpi('Cursor team spend', t.cursor_spend == null ? null : fmtUSD(t.cursor_spend),
|
|
2575
|
+
`${fmtInt(t.cursor_members)} members`, {badge: BADGE.actual})}
|
|
2576
|
+
</div>
|
|
2577
|
+
${card('Set up the APIs', `<div class="stack">${setup}</div>`,
|
|
2578
|
+
{hint: 'Keys are read from your environment or a local file; the dashboard never stores them'})}
|
|
2579
|
+
${card('Billed vs local, per day', `<div id="cl-day"></div>`, {flush: 1, badge: BADGE.actual,
|
|
2580
|
+
hint: 'Claude Code: what the vendor billed against what this machine recorded'})}
|
|
2581
|
+
${card('Claude Code users (org-wide)', `<div id="cl-users"></div>`, {flush: 1, badge: BADGE.actual,
|
|
2582
|
+
hint: 'Per user per day, aggregated over the range โ includes Pro/Max subscription users'})}
|
|
2583
|
+
${card('Cursor members', `<div id="cl-cur"></div>`, {flush: 1, badge: BADGE.actual})}
|
|
2584
|
+
${card('Billed API cost by line item', `<div id="cl-api"></div>`, {flush: 1, badge: BADGE.actual,
|
|
2585
|
+
hint: 'From the Anthropic cost report: API spend only, not subscription plans'})}`;
|
|
2586
|
+
$('#cl-day', page).innerHTML = table([
|
|
2587
|
+
{h: 'Day', f: r => esc(r.day)},
|
|
2588
|
+
{h: 'Billed $', num: 1, f: r => fmtUSD(r.billed_cost)},
|
|
2589
|
+
{h: 'Local $ (est.)', num: 1, f: r => r.local_cost == null ? 'โ' : fmtUSD(r.local_cost)},
|
|
2590
|
+
{h: 'Billed tokens', num: 1, f: r => fmtNum(r.billed_tokens)},
|
|
2591
|
+
{h: 'Local tokens', num: 1, f: r => r.local_tokens == null ? 'โ' : fmtNum(r.local_tokens)},
|
|
2592
|
+
{h: 'Sessions billed / local', num: 1, f: r => `${fmtInt(r.billed_sessions)} / ${r.local_sessions == null ? 'โ' : fmtInt(r.local_sessions)}`},
|
|
2593
|
+
], d.by_day);
|
|
2594
|
+
$('#cl-users', page).innerHTML = table([
|
|
2595
|
+
{h: 'User', f: r => esc(r.actor)},
|
|
2596
|
+
{h: 'Plan', f: r => esc(r.customer_type || 'โ')},
|
|
2597
|
+
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
2598
|
+
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
2599
|
+
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
2600
|
+
{h: 'Lines +/-', num: 1, f: r => `${fmtInt(r.lines_added)} / ${fmtInt(r.lines_removed)}`},
|
|
2601
|
+
{h: 'Commits', num: 1, f: r => fmtInt(r.commits)},
|
|
2602
|
+
{h: 'PRs', num: 1, f: r => fmtInt(r.prs)},
|
|
2603
|
+
{h: 'Where', trunc: 1, f: r => esc(r.terminals.join(', ') || 'โ')},
|
|
2604
|
+
], d.users);
|
|
2605
|
+
$('#cl-cur', page).innerHTML = table([
|
|
2606
|
+
{h: 'Member', f: r => esc(r.member)},
|
|
2607
|
+
{h: 'Spend', num: 1, f: r => r.spend_usd == null ? 'โ' : fmtUSD(r.spend_usd)},
|
|
2608
|
+
{h: 'Active days', num: 1, f: r => fmtInt(r.days)},
|
|
2609
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
2610
|
+
{h: 'Lines +/-', num: 1, f: r => `${fmtInt(r.lines_added)} / ${fmtInt(r.lines_removed)}`},
|
|
2611
|
+
], d.cursor_members);
|
|
2612
|
+
$('#cl-api', page).innerHTML = table([
|
|
2613
|
+
{h: 'Line item', f: r => esc(r.description)},
|
|
2614
|
+
{h: 'Billed $', num: 1, f: r => fmtUSD(r.cost_usd)},
|
|
2615
|
+
], d.api_cost_by_description);
|
|
2616
|
+
const btn = $('#cl-sync', page);
|
|
2617
|
+
if (btn) btn.onclick = async () => {
|
|
2618
|
+
btn.disabled = true; btn.textContent = 'Fetchingโฆ';
|
|
2619
|
+
try {
|
|
2620
|
+
const {job} = await doAction('cloud_sync', {days: 30});
|
|
2621
|
+
const j = await followJob(job, $('#cl-log', page));
|
|
2622
|
+
if (j.state === 'error') throw new Error(j.error || 'failed');
|
|
2623
|
+
render();
|
|
2624
|
+
} catch (e) {
|
|
2625
|
+
$('#cl-log', page).innerHTML = `<div class="note sev-high">Failed: ${esc(e.message)}</div>`;
|
|
2626
|
+
btn.disabled = false; btn.textContent = 'โฏ Refresh from APIs';
|
|
2627
|
+
}
|
|
2628
|
+
};
|
|
2629
|
+
};
|
|
2630
|
+
|
|
2631
|
+
/* ============================ walkthrough ============================ */
|
|
2632
|
+
// A guided tour: a welcome tour on first visit, and a per-dashboard tour any time via
|
|
2633
|
+
// the "? Tour" button. Each step says what you see, what you get, and one thing to try.
|
|
2634
|
+
// Targets: a CSS selector, 'kpis' (the first KPI row), or 'cardN' (Nth card on the page).
|
|
2635
|
+
const TOUR_DONE = 'finops-tour-done';
|
|
2636
|
+
const TOUR_WELCOME = [
|
|
2637
|
+
{el: '.brand', t: 'Welcome to FinOps', see: 'One dashboard for what your coding agents (Claude Code, Codex, Gemini CLI, Cursor) spend in tokens and money.',
|
|
2638
|
+
get: 'Where the tokens go, why, and what to change to spend less. Everything is read from files on this machine.',
|
|
2639
|
+
act: 'Use Next and Previous to move through the tour, or Skip to close it.'},
|
|
2640
|
+
{el: '.nav', t: 'Dashboards', see: 'Every dashboard, grouped: Command center, Usage, Drill-down, Optimize, Plan.',
|
|
2641
|
+
get: 'Start with <b>Why so many tokens?</b> or <b>What should I do?</b> for a ranked to-do list.',
|
|
2642
|
+
act: 'Click any item to open that dashboard. A number next to an item means something needs attention.'},
|
|
2643
|
+
{el: '[data-agent]', t: 'Pick the agent', see: 'Agent chips: one agent, several, or All.',
|
|
2644
|
+
get: 'Every dashboard is filtered to the agents you pick. Dashboards that need data an agent doesn\'t record are hidden.',
|
|
2645
|
+
act: 'Click <b>Codex</b> or <b>All</b> to switch.'},
|
|
2646
|
+
{el: '[data-range]', t: 'Date range & filters', see: 'Range chips, plus Model, Project, Category and Threshold filters.',
|
|
2647
|
+
get: 'Narrow any dashboard to a period, a model or a project.',
|
|
2648
|
+
act: 'Try <b>7 days</b>, then open <b>Project โพ</b> to focus on one repo.'},
|
|
2649
|
+
{el: '#gsearch', t: 'Search', see: 'Search across prompts, sessions, models and dates.',
|
|
2650
|
+
get: 'Jump straight to the prompt or session you remember.', act: 'Type a word from a recent prompt and press Enter.'},
|
|
2651
|
+
{el: '#sync', t: 'Keep it current', see: 'Sync re-reads every agent\'s local data.',
|
|
2652
|
+
get: 'Fresh numbers after you\'ve been working.', act: 'Click <b>โณ Sync</b> whenever the numbers look stale.'},
|
|
2653
|
+
{el: '#tour-btn', t: 'Tour any dashboard', see: 'This button starts a tour of the dashboard you\'re on.',
|
|
2654
|
+
get: 'A short explanation of each section: what it shows, what you get, what you can do.',
|
|
2655
|
+
act: 'Open any dashboard and click <b>? Tour</b> to replay it. Next: this dashboard.'},
|
|
2656
|
+
];
|
|
2657
|
+
// Per-dashboard tours. One step per section the dashboard renders, in the order you
|
|
2658
|
+
// scroll past them; steps whose section is absent for the current filters are dropped
|
|
2659
|
+
// at start (see startTour), so a short dashboard simply gets a short tour.
|
|
2660
|
+
const TOURS = {
|
|
2661
|
+
overview: [
|
|
2662
|
+
{el: 'kpis', t: 'Headline numbers', see: 'Estimated spend, billable tokens, plan usage, remaining allowance and the period forecast.', get: 'A quick read on how much you use and what it costs, for the filters above.', act: 'Change the date range and watch every number follow it.'},
|
|
2663
|
+
{el: 'card:Usage burn rate', t: 'Burn rate', see: 'Period to date, daily average, 7-day rate and the projected period total, with a gauge against your allowance.', get: 'Whether today\'s pace lands you inside or outside your plan.', act: 'Set monthly_cost_allowance_usd in config/settings.json to make the gauge exact.'},
|
|
2664
|
+
{el: 'card:Cost trend', t: 'Cost trend', see: 'Spend per day across the range.', get: 'The days that drove the bill.', act: 'Hover a point for the exact day, then narrow the range to that week.'},
|
|
2665
|
+
{el: 'card:Model cost', t: 'Model cost', see: 'How spend splits across the models you used.', get: 'Whether an expensive model is doing routine work.', act: 'A big share on a top-tier model? Check <b>Model switch</b>.'},
|
|
2666
|
+
{el: 'card:Project cost', t: 'Project cost', see: 'Spend per repository.', get: 'Which codebase costs the most to work in.', act: 'Click a project to filter every dashboard to it.'},
|
|
2667
|
+
{el: 'card:Top 10 most expensive prompts', t: 'Most expensive prompts', see: 'The ten single prompts that cost the most.', get: 'The requests worth rewriting or splitting.', act: 'Click a prompt to see every step it triggered.'},
|
|
2668
|
+
{el: 'card:Most expensive sessions', t: 'Most expensive sessions', see: 'The longest-running, highest-cost sessions.', get: 'The marathon sessions where context kept being re-read.', act: 'Click one to see where it grew.'},
|
|
2669
|
+
{el: 'card:Waste detection', t: 'Waste at a glance', see: 'A summary of the patterns that burn tokens for nothing.', get: 'An estimate of what you could avoid.', act: 'Open <b>Waste detection</b> for the full findings and evidence.'},
|
|
2670
|
+
{el: 'card:Optimization opportunities', t: 'Opportunities', see: 'The top ranked changes with estimated savings.', get: 'What to fix first.', act: 'Open <b>What should I do?</b> for the full list with ready-made prompts.'},
|
|
2671
|
+
{el: 'card:Forecast', t: 'Forecast', see: 'Projected spend to the end of the billing period.', get: 'An early read on whether you\'ll go over.', act: 'Open <b>Forecast</b> for the optimistic and pessimistic scenarios.'},
|
|
2672
|
+
{el: 'card:FinOps score', t: 'FinOps score', see: 'Your overall score for cache use, model mix, waste and budget.', get: 'One number for how efficiently you work.', act: 'Open <b>FinOps scorecard</b> to see what each component grades.'}],
|
|
2673
|
+
advisor: [
|
|
2674
|
+
{el: 'card:Biggest optimization opportunity', t: 'Start here', see: 'The single change with the largest estimated saving, with the evidence behind it.', get: 'The best use of your next ten minutes.', act: 'Read the estimate, then apply the change.'},
|
|
2675
|
+
{el: 'card:What is going well', t: 'Strengths & gaps', see: 'What your habits already do well, and the items that need attention.', get: 'Confirmation of what to keep doing, and where you lose money.', act: 'Work down the <b>Needs attention</b> list.'},
|
|
2676
|
+
{el: 'card:All recommendations', t: 'All recommendations', see: 'Every recommendation, ranked by estimated saving, each with its reasoning.', get: 'A prioritised to-do list built from your own usage.', act: 'Copy a recommendation\'s prompt straight into your agent.'},
|
|
2677
|
+
{el: 'card:Anomalies to inspect', t: 'Anomalies', see: 'Days and sessions that spent far more than your normal.', get: 'Surprises caught before they repeat.', act: 'Click one to see what happened that day.'}],
|
|
2678
|
+
agents: [
|
|
2679
|
+
{el: 'kpis', t: 'Agent totals', see: 'One tile per agent with its usage for the current filters.', get: 'How spend splits across Claude Code, Codex, Gemini CLI and Cursor.', act: 'Use the agent chips in the header to focus on one.'},
|
|
2680
|
+
{el: 'card:Side by side', t: 'Side by side', see: 'Every agent in one table: cost, tokens, requests, sessions.', get: 'A like-for-like comparison of what each agent costs you.', act: 'Compare tokens per request: a high number means large contexts.'},
|
|
2681
|
+
{el: 'card:Daily', t: 'Daily by agent', see: 'Cost or tokens per day, split by agent.', get: 'When you switched agents and what that did to spend.', act: 'Use the metric toggle to swap cost for tokens.'},
|
|
2682
|
+
{el: 'card:What each agent records', t: 'What each agent records', see: 'Which fields each agent writes to disk on this machine.', get: 'Why some dashboards are hidden for some agents: the data simply isn\'t recorded.', act: 'Check here first when a number reads "not recorded".'}],
|
|
2683
|
+
live: [
|
|
2684
|
+
{el: 'kpis', t: 'Running now', see: 'Sessions running right now, which are working (spending) at this moment, the largest live context and what live sessions have spent.', get: 'What is costing you money this second.', act: 'Only <b>working</b> sessions consume tokens; idle ones cost again on your next message.'},
|
|
2685
|
+
{el: '#live-refresh', t: 'Refresh & the rules', see: 'The note explains what Interrupt, Close and Force kill do, and how non-Claude agents are detected.', get: 'Confidence before you stop something.', act: 'Click <b>โป Refresh</b> for the latest state.'},
|
|
2686
|
+
{el: '.item[data-i]', t: 'A session', see: 'Agent, project, context size, steps, cost and last activity, plus advice when a session gets heavy.', get: 'Control over sessions that are quietly growing expensive.', act: 'Interrupt, Close or Force kill it (each needs two clicks), copy its resume command, or <b>Hand over</b> a Claude session to a fresh one.'}],
|
|
2687
|
+
scorecard: [
|
|
2688
|
+
{el: 'card:AI FinOps Score', t: 'Your score', see: 'The overall score with a component breakdown: cache use, model mix, waste and budget.', get: 'Where your habits are strong and where they cost money.', act: 'Hover a component to see how it is calculated.'},
|
|
2689
|
+
{el: 'card:โ
What is good', t: 'What is good', see: 'The components you already score well on.', get: 'The habits worth keeping.', act: 'Keep these when you change your setup.'},
|
|
2690
|
+
{el: 'card:โ ๏ธ Needs attention', t: 'Needs attention', see: 'The components dragging the score down.', get: 'A short list of what to fix.', act: 'Start at the top: it carries the most weight.'},
|
|
2691
|
+
{el: 'card:๐ฏ Biggest opportunity', t: 'Biggest opportunity', see: 'The single change that would move the score most.', get: 'One concrete action with an estimated saving.', act: 'Apply it, then Sync and re-check the score.'}],
|
|
2692
|
+
usage: [
|
|
2693
|
+
{el: 'kpis', t: 'Token breakdown', see: 'Total, input, output, thinking, cache read and cache write tokens, plus requests, sessions, prompts and active days.', get: 'Exactly which kind of token you spend on. Cache read is cheap; uncached input is not.', act: 'Compare cache read against input: a low ratio means context is being re-sent, not reused.'},
|
|
2694
|
+
{el: 'card0', t: 'Usage over time', see: 'Tokens or cost per day (or per hour on short ranges).', get: 'Spikes and quiet periods at a glance.', act: 'Hover a bar for the day\'s numbers; narrow the range to zoom in.'},
|
|
2695
|
+
{el: 'card:Model mix over time', t: 'Model mix over time', see: 'Which models carried the work on each day.', get: 'Whether you drifted to an expensive model over time.', act: 'Click a legend entry to isolate one model.'},
|
|
2696
|
+
{el: 'card:Daily detail', t: 'Daily detail', see: 'A row per day with tokens, requests and estimated cost.', get: 'The exact numbers behind the charts.', act: 'Sort by cost to find the outlier days.'}],
|
|
2697
|
+
burn: [
|
|
2698
|
+
{el: 'kpis', t: 'Burn rate & limits', see: 'Billing period, days remaining, used to date, projected total, daily and 7-day averages, and remaining credits.', get: 'Early warning before you hit a cap.', act: 'Compare the projected period total with your allowance.'},
|
|
2699
|
+
{el: 'card0', t: 'Allowance gauges', see: 'One gauge per allowance you configured: cost, tokens or requests.', get: 'How much of each limit is gone, and how many days it lasts at this rate.', act: 'Set your limits in config/settings.json to make these exact.'},
|
|
2700
|
+
{el: 'card:Daily consumption within the billing period', t: 'Daily consumption', see: 'Each day of the billing period against the pace you\'d need to stay inside the limit.', get: 'Which days pushed you off pace.', act: 'Hover a bar to see that day against the target line.'}],
|
|
2701
|
+
models: [
|
|
2702
|
+
{el: 'kpis', t: 'Model headlines', see: 'How many models you used and what the mix costs.', get: 'A first read on whether the mix is right.', act: 'Filter to one model with <b>Model โพ</b> above.'},
|
|
2703
|
+
{el: 'card:Cost share', t: 'Cost share', see: 'Spend split across models.', get: 'The model that owns your bill.', act: 'Check whether that model is doing work a cheaper one could.'},
|
|
2704
|
+
{el: 'card:Token share', t: 'Token share', see: 'The same split by tokens instead of money.', get: 'The gap between the two charts is the price difference at work.', act: 'A model with a small token share but a big cost share is your expensive one.'},
|
|
2705
|
+
{el: 'card:Model FinOps table', t: 'Model FinOps table', see: 'Per model: cost, tokens, context, output and cost per 1K output.', get: 'What each model really costs for the work you give it.', act: 'Sort by cost per 1K output, then open <b>Model switch</b>.'},
|
|
2706
|
+
{el: 'card:Price table in effect', t: 'Prices in effect', see: 'The per-model prices used for every estimate in this dashboard.', get: 'Transparency: you can check the maths.', act: 'Edit the price file if your rates differ.'}],
|
|
2707
|
+
context: [
|
|
2708
|
+
{el: 'kpis', t: 'Context & cache', see: 'Average and peak context per request, and how much is served from cache.', get: 'How much re-reading history costs you, and what caching saves.', act: 'Large average context? Clear or compact sessions more often.'},
|
|
2709
|
+
{el: 'card:Cost by context size', t: 'Cost by context size', see: 'Spend grouped by how large the context was.', get: 'Proof of how quickly cost climbs with context.', act: 'See how much sits in the largest buckets.'},
|
|
2710
|
+
{el: 'card:Caching: with vs without', t: 'What caching saves', see: 'What you paid against what the same work would cost with no cache.', get: 'The value of cache hits in money.', act: 'A small gap means sessions are restarted too often to build a cache.'},
|
|
2711
|
+
{el: 'card:Sessions with the largest context', t: 'Largest contexts', see: 'The sessions that carried the most history.', get: 'The sessions to split or hand over next time.', act: 'Click one to see where it grew.'},
|
|
2712
|
+
{el: 'card:Context distribution', t: 'Context distribution', see: 'How your requests spread across context sizes.', get: 'Whether large contexts are the exception or the norm.', act: 'A long right tail means /compact earlier.'}],
|
|
2713
|
+
projects: [
|
|
2714
|
+
{el: 'card:Project cost ranking', t: 'Cost ranking', see: 'Projects ordered by spend.', get: 'Which repos cost the most.', act: 'Click a bar to filter every dashboard to that project.'},
|
|
2715
|
+
{el: 'card:Project FinOps table', t: 'Project FinOps table', see: 'Per project: cost, tokens, sessions, prompts and efficiency.', get: 'Whether one repo needs better memory or a harness.', act: 'High tokens per prompt? Check that repo in <b>Why so many tokens?</b>.'}],
|
|
2716
|
+
sessions: [
|
|
2717
|
+
{el: 'kpis', t: 'Session headlines', see: 'Sessions in range, average cost per session, and average tokens per session and per prompt.', get: 'Whether your sessions run long and heavy or short and cheap.', act: 'Compare tokens per prompt against other ranges.'},
|
|
2718
|
+
{el: 'card:Session explorer', t: 'Session explorer', see: 'Every session with cost, steps, peak context and project, with its own filters.', get: 'The marathon sessions that cost the most.', act: 'Sort by cost, then click a session to see its prompts.'},
|
|
2719
|
+
{el: 'card:Lowest output yield', t: 'Lowest output yield', see: 'Sessions that consumed a lot of context for very little output.', get: 'Where tokens went in and almost nothing came out.', act: 'These are the best candidates for a fresh session.'},
|
|
2720
|
+
{el: 'card:Highest output yield', t: 'Highest output yield', see: 'Sessions that produced the most output per token consumed.', get: 'What a well-scoped session looks like for your work.', act: 'Compare their size and prompts with the low-yield ones.'}],
|
|
2721
|
+
prompts: [
|
|
2722
|
+
{el: 'card:Prompt explorer', t: 'Prompt explorer', see: 'Each prompt you typed and what it cost end to end, including every step it triggered.', get: 'Which requests are expensive, and why.', act: 'Sort by cost and click a prompt to see its requests, tool calls and files touched.'},
|
|
2723
|
+
{el: '#pq', t: 'Search your prompts', see: 'A search box over the prompt text itself, on top of the global filters above.', get: 'A way to find the prompts about one feature, file or bug.', act: 'Type a word you use often and see what that kind of request costs you.'},
|
|
2724
|
+
{el: '.chip[data-po]', t: 'Sort the list', see: 'Sort by most expensive, most tokens, longest, most efficient, cheapest or most recent.', get: 'The same prompts read as a cost list or as an efficiency list.', act: 'Compare <b>Most expensive</b> with <b>Most efficient</b>: the difference is how you phrased them.'},
|
|
2725
|
+
{el: '#pt', t: 'A prompt row', see: 'One row per prompt with its tokens, requests and estimated cost.', get: 'The full chain of work a single sentence set off.', act: 'Click a row for the whole prompt, its requests, tool calls and the files it touched.'}],
|
|
2726
|
+
rankings: [
|
|
2727
|
+
{el: 'card:Cost & efficiency leaderboards', t: 'Leaderboards', see: 'Top prompts, sessions, days, models and projects by cost, plus the efficiency boards.', get: 'Your most expensive work in one place.', act: 'Switch board with the tabs, then click any row to drill in.'},
|
|
2728
|
+
{el: '.tabs', t: 'Pick a board', see: 'One tab per leaderboard: most expensive, longest sessions, and the efficiency boards.', get: 'The same range read from several angles without changing any filter.', act: 'Open <b>Longest sessions</b>: duration and cost usually rise together.'},
|
|
2729
|
+
{el: '#rt', t: 'The rows', see: 'The top 20 rows of the board you picked, ranked with their tokens and estimated cost.', get: 'The specific work behind the headline numbers elsewhere.', act: 'Click any row to open the prompt or session behind it.'}],
|
|
2730
|
+
categories: [
|
|
2731
|
+
{el: 'card:Cost by activity', t: 'Cost by activity', see: 'Spend per kind of work: coding, debugging, docs, review and so on.', get: 'Which kinds of work cost most.', act: 'Cheap, repetitive categories are the ones to move to a smaller model.'},
|
|
2732
|
+
{el: 'card:Prompts by activity', t: 'Prompts by activity', see: 'How many prompts fall into each category.', get: 'Volume next to cost: a category can be frequent but cheap.', act: 'Compare this with the cost chart to find the expensive outliers.'},
|
|
2733
|
+
{el: 'card:Activity breakdown', t: 'Activity breakdown', see: 'Per category: prompts, tokens, cost and the model used.', get: 'Concrete evidence for routing work to a cheaper model.', act: 'Then open <b>Model switch</b> to see the saving.'}],
|
|
2734
|
+
developer: [
|
|
2735
|
+
{el: 'card:Cost per repository', t: 'Cost per repository', see: 'Spend per repo checked out on this machine.', get: 'Which codebase is expensive to work in.', act: 'Click a repo to filter the dashboards.'},
|
|
2736
|
+
{el: 'card:Tool usage', t: 'Tool usage', see: 'Each tool the agent called and how often.', get: 'Tools with huge call counts: they fill context and cost money.', act: 'Big Read or Grep counts? Add project memory so the agent re-reads less.'},
|
|
2737
|
+
{el: 'card:Repository FinOps', t: 'Repository FinOps', see: 'Per repo: cost, tokens, sessions and prompts.', get: 'A per-repo efficiency comparison.', act: 'Fix the worst repo\'s config in <b>Why so many tokens?</b>.'},
|
|
2738
|
+
{el: 'card:Cost by git branch', t: 'Cost by branch', see: 'Spend attributed to the branch that was checked out.', get: 'What a feature branch actually cost.', act: 'Use it for per-feature cost reporting.'},
|
|
2739
|
+
{el: 'card:Most-touched files', t: 'Most-touched files', see: 'The files the agent read or edited most.', get: 'Files re-read over and over are pure context cost.', act: 'Summarise a hot file in your memory file so it isn\'t re-read every session.'}],
|
|
2740
|
+
diagnose: [
|
|
2741
|
+
{el: '.focus', t: 'Focus on these first', see: 'The few fixes with the biggest effect, pulled out of everything below.', get: 'A short path through a long dashboard.', act: 'Click a focus item to jump to its section.'},
|
|
2742
|
+
{el: 'card:Running now', t: 'Running now', see: 'Live sessions and how much context each is carrying.', get: 'A heavy session you can fix right now.', act: 'Hand over or compact anything flagged.'},
|
|
2743
|
+
{el: 'card:Past sessions that carried too much context', t: 'Past heavy sessions', see: 'Finished sessions that dragged an oversized context along.', get: 'The pattern to avoid repeating.', act: 'Look at how long they ran before you started a fresh one.'},
|
|
2744
|
+
{el: 'card:Add to', t: 'Memory suggestions', see: 'Facts your prompts repeat, ready to move into your memory file.', get: 'Shorter prompts and less re-explaining.', act: 'Copy a suggestion into the project memory file.'},
|
|
2745
|
+
{el: 'card:Why consumption is high', t: 'The drivers', see: 'The main drivers of your token use, each with its share.', get: 'A plain explanation of where tokens go.', act: 'Read the top driver first.'},
|
|
2746
|
+
{el: 'card:What to change', t: 'What to change', see: 'Each finding with a ready-made prompt that fixes it.', get: 'A fix your agent can apply for you.', act: 'Click <b>Copy</b> and paste it into your agent.'},
|
|
2747
|
+
{el: 'card:Projects:', t: 'Project config health', see: 'Per project: whether it has memory, an agents file, hooks and the rest.', get: 'The config gaps that make every session start from zero.', act: 'Fix the projects flagged red.'},
|
|
2748
|
+
{el: '#dx-harness', t: 'Does it need a harness?', see: 'Per project, whether the work you do there would be better served by a harness: hooks, subagents or a scripted workflow.', get: 'A read on when a repo has outgrown ad-hoc prompting.', act: 'Start with the project that repeats the same multi-step task most often.'},
|
|
2749
|
+
{el: 'card:Global config', t: 'Global config', see: 'Your machine-wide agent configuration and what\'s missing from it.', get: 'Settings that pay off across every project at once.', act: 'Apply the global fixes before the per-project ones.'},
|
|
2750
|
+
{el: '#dx-issues', t: 'Per-project fixes', see: 'One card per project that has open issues, each with the exact fix for that repo.', get: 'The work split by repo, so you can fix one and stop.', act: 'Fix your most expensive repo first; <b>Project cost</b> on the overview says which that is.'}],
|
|
2751
|
+
attribution: [
|
|
2752
|
+
{el: 'kpis', t: 'Who used the tokens', see: 'Tokens split by session, subagent, skill, MCP server and connector.', get: 'Which add-ons quietly cost the most context.', act: 'Compare main-agent tokens with subagent tokens.'},
|
|
2753
|
+
{el: 'card:Sessions: main agent vs subagents', t: 'Main agent vs subagents', see: 'How much work the main agent did versus the subagents it spawned.', get: 'Whether fan-out is paying for itself.', act: 'Heavy subagent spend on simple tasks? Spawn fewer.'},
|
|
2754
|
+
{el: 'card:Subagents by type', t: 'Subagents by type', see: 'Each subagent type and what it consumed.', get: 'The agent type worth narrowing or dropping.', act: 'Tighten the prompt of the most expensive type.'},
|
|
2755
|
+
{el: 'card:Most expensive subagent runs', t: 'Most expensive runs', see: 'Individual subagent runs ordered by cost.', get: 'The single runs that got out of hand.', act: 'Click one to see what it was asked to do.'},
|
|
2756
|
+
{el: 'card:Skills', t: 'Skills', see: 'Which skills loaded and what each added to context.', get: 'Skills that load on every turn but rarely help.', act: 'Trim the description of anything that loads too eagerly.'},
|
|
2757
|
+
{el: 'card:Slash commands', t: 'Slash commands', see: 'The commands and user-invoked skills you actually use.', get: 'Which shortcuts earn their keep.', act: 'Delete the ones you never call.'},
|
|
2758
|
+
{el: 'card:MCP servers', t: 'MCP servers', see: 'Each configured server, whether it was ever called, and its context cost.', get: 'Tool definitions loaded on every request for nothing.', act: 'Remove servers that are configured but never called.'},
|
|
2759
|
+
{el: 'card:Connectors', t: 'Connectors', see: 'claude.ai connectors and their share of context.', get: 'The same check for connectors as for MCP servers.', act: 'Disconnect what you don\'t use from this machine.'}],
|
|
2760
|
+
waste: [
|
|
2761
|
+
{el: 'kpis', t: 'Waste headlines', see: 'Estimated excess, exposed spend, and how many prompts, sessions and rules are involved.', get: 'An honest estimate of avoidable spend.', act: '<b>Exposed</b> is what flagged work cost in total; <b>excess</b> is how much more than a fair baseline.'},
|
|
2762
|
+
{el: 'card:๐ด High waste', t: 'High waste', see: 'The rules that fired hardest: repeated reads, retries, stale sessions.', get: 'The costly patterns, each with the baseline it is measured against.', act: 'Open <b>Show flagged items</b> to see the evidence, then fix these first.'},
|
|
2763
|
+
{el: 'card:๐ก Optimization opportunities', t: 'Medium findings', see: 'Patterns worth changing but not urgent.', get: 'The next tier of savings.', act: 'Batch these into one config change.'},
|
|
2764
|
+
{el: 'card:โช Low-priority observations', t: 'Low-priority observations', see: 'Small findings, kept for completeness.', get: 'Context for the numbers above.', act: 'Skim them; act only if one matches a habit you want to change.'}],
|
|
2765
|
+
modelswitch: [
|
|
2766
|
+
{el: 'kpis', t: 'Model switch', see: 'What you\'d save running routine work on a cheaper model from the same vendor, split by confidence.', get: 'Savings you can trust, separated from savings that need a judgement call.', act: 'Start with the high-confidence figure.'},
|
|
2767
|
+
{el: 'card:Switch these', t: 'Switch these', see: 'The specific prompts or categories that a smaller model could have handled.', get: 'Evidence per item, not a blanket recommendation.', act: 'Check a few prompts yourself before you trust the pattern.'},
|
|
2768
|
+
{el: 'card:Default model per project', t: 'Default per project', see: 'A suggested default model for each project, from the work you do there.', get: 'A setting you change once instead of choosing per prompt.', act: 'Apply it to your cheapest, most repetitive repo first.'},
|
|
2769
|
+
{el: 'card:How to switch', t: 'How to switch', see: 'The exact command or setting for each agent.', get: 'No guessing at flag names.', act: 'Copy the command for your agent and try it on a small task.'}],
|
|
2770
|
+
freemodels: [
|
|
2771
|
+
{el: 'card:How to use and test a free model', t: 'How it works', see: 'How to plug a local or free cloud model into Claude Code, and how to test it.', get: 'Zero-cost options for simple or private work.', act: 'Read the RAM guidance before you pick a model.'},
|
|
2772
|
+
{el: 'card1', t: 'A model', see: 'Each model with its size, RAM needs, strengths and limits.', get: 'A realistic idea of what runs on your machine.', act: 'Click <b>๏ผ Add</b> on a model that fits your RAM.'}],
|
|
2773
|
+
compare: [
|
|
2774
|
+
{el: 'card0', t: 'Models side by side', see: 'Price, context window, ratings and your own usage per model.', get: 'A clear pick for each kind of work.', act: 'Sort by output price, the one that usually dominates the bill.'},
|
|
2775
|
+
{el: 'card:Use Claude for', t: 'Use the big model for', see: 'The work that genuinely needs a top model.', get: 'Where paying more actually pays off.', act: 'Keep multi-file and agentic work here.'},
|
|
2776
|
+
{el: 'card:Use a free model for', t: 'Use a free model for', see: 'The work a free or local model handles fine.', get: 'The safe places to spend nothing.', act: 'Move throwaway snippets and explanations here.'},
|
|
2777
|
+
{el: 'card:Try it on your own work', t: 'Try it yourself', see: 'A short recipe for comparing models on a task of your own.', get: 'Your own evidence instead of someone\'s benchmark.', act: 'Run the same small task on both and compare the result.'}],
|
|
2778
|
+
toolkit: [
|
|
2779
|
+
{el: 'card:MCP servers for work you repeat', t: 'MCP suggestions', see: 'Servers suggested from the work your prompts repeat.', get: 'Fewer manual steps and smaller prompts.', act: 'Add one, then check its context cost in <b>Who used the tokens</b>.'},
|
|
2780
|
+
{el: 'card:Skills from what you repeat', t: 'Skill suggestions', see: 'Skills drafted from the instructions you keep retyping.', get: 'Repetition moved out of your prompts.', act: 'Create a suggested skill in one click.'}],
|
|
2781
|
+
recommendations: [
|
|
2782
|
+
{el: 'card:Biggest optimization opportunity', t: 'Start here', see: 'The change with the largest estimated saving.', get: 'The best single thing to do next.', act: 'Apply it, then Sync and re-check.'},
|
|
2783
|
+
{el: 'card:What is going well', t: 'Strengths & gaps', see: 'What already works, and what needs attention.', get: 'What to keep and what to change.', act: 'Work down the <b>Needs attention</b> list.'},
|
|
2784
|
+
{el: 'card:All recommendations', t: 'All recommendations', see: 'Every recommendation with its estimated saving and reasoning.', get: 'A prioritised list of what to change.', act: 'Start with the highest saving.'},
|
|
2785
|
+
{el: 'card:Anomalies to inspect', t: 'Anomalies', see: 'Unusual days and sessions worth a look.', get: 'Problems caught before they become habits.', act: 'Click one to see what happened.'}],
|
|
2786
|
+
anomalies: [
|
|
2787
|
+
{el: 'card:Daily spend with anomalies highlighted', t: 'Spend with anomalies', see: 'Daily spend with the outlier days marked against your normal band.', get: 'How far outside normal each day was.', act: 'Hover a highlighted day for its numbers.'},
|
|
2788
|
+
{el: 'card:Detected anomalies', t: 'Detected anomalies', see: 'Each anomaly with what triggered it.', get: 'The explanation, not just the spike.', act: 'Click one to see the sessions behind it.'}],
|
|
2789
|
+
forecast: [
|
|
2790
|
+
{el: 'kpis', t: 'Forecast', see: 'Projected spend for the rest of the billing period.', get: 'Whether you\'ll land over or under budget.', act: 'Set a budget under <b>Budgets</b> to compare against.'},
|
|
2791
|
+
{el: 'card:Cumulative spend and forecast fan', t: 'The forecast fan', see: 'Spend to date, then a fan of where the period could end.', get: 'A range, not a false-precision single number.', act: 'Read the width of the fan as the uncertainty.'},
|
|
2792
|
+
{el: 'card:Scenarios', t: 'Scenarios', see: 'Optimistic, expected and pessimistic end-of-period totals.', get: 'A best and worst case to plan against.', act: 'Budget against the pessimistic number.'}],
|
|
2793
|
+
budgets: [
|
|
2794
|
+
{el: 'card:Budget vs actual vs forecast', t: 'Budget vs actual', see: 'Each budget line with its budget, actual, forecast and variance.', get: 'A warning before you overspend, not after.', act: 'Watch the variance column: a positive forecast variance means trouble.'},
|
|
2795
|
+
{el: 'card:Configure budgets', t: 'Configure budgets', see: 'Your budget lines, limits and alert thresholds.', get: 'Numbers that make the forecast and burn dashboards meaningful.', act: 'Edit a budget and save; every dashboard picks it up.'}],
|
|
2796
|
+
cloud: [
|
|
2797
|
+
{el: 'kpis', t: 'Billed vs local', see: 'What the vendor billed the whole organisation next to what this machine recorded.', get: 'The gap: usage from other machines, other members, or work off this machine.', act: 'Click <b>โฏ Refresh from APIs</b> to fetch โ this is the only page that goes online.'},
|
|
2798
|
+
{el: 'card:Set up the APIs', t: 'Set up the APIs', see: 'Which provider keys were found, and how to get each one.', get: 'Org-wide Claude Code usage per user, and Cursor team spend.', act: 'Run claude-finops --set-key, or set the environment variable, then restart.'},
|
|
2799
|
+
{el: 'card:Billed vs local', t: 'The comparison', see: 'Billed totals against local totals for the same period.', get: 'Proof of how much of the bill this machine explains.', act: 'A large gap means most spend happens elsewhere: check the user tables.'},
|
|
2800
|
+
{el: 'card:Claude Code users', t: 'Users org-wide', see: 'Each Claude Code user in the organisation and their usage.', get: 'Who drives the bill across the team.', act: 'Compare your own row with the team average.'},
|
|
2801
|
+
{el: 'card:Cursor members', t: 'Cursor members', see: 'Cursor team members and their spend.', get: 'The same picture for Cursor seats.', act: 'Look for seats with no usage at all.'},
|
|
2802
|
+
{el: 'card:Billed API cost by line item', t: 'Billed line items', see: 'The vendor\'s own line items for the period.', get: 'The invoice view, straight from the API.', act: 'Reconcile this with your finance numbers.'}],
|
|
2803
|
+
search: [
|
|
2804
|
+
{el: 'card:Prompts', t: 'Matching prompts', see: 'Every prompt whose text matches your search, with what it cost.', get: 'One term, and everything you ever asked about it.', act: 'Click a prompt to open it with its requests and files.'},
|
|
2805
|
+
{el: 'card:Sessions', t: 'Matching sessions', see: 'Sessions whose title or prompts match, with cost and size.', get: 'The whole working session around a match, not just the one line.', act: 'Click a session to see how it grew.'},
|
|
2806
|
+
{el: 'card:Projects', t: 'Matching projects', see: 'Projects whose name matches, with their spend.', get: 'A quick jump into one repo\'s numbers.', act: 'Click a project to filter every dashboard to it.'},
|
|
2807
|
+
{el: 'card:Models', t: 'Matching models', see: 'Models whose name matches, with cost and tokens.', get: 'A direct route to one model\'s usage.', act: 'Search a model name to see what it cost you this range.'},
|
|
2808
|
+
{el: 'card:Days', t: 'Matching days', see: 'Days matching the search, with their totals.', get: 'A date lookup: type 2026-09 to pull a month.', act: 'Click a day to narrow every dashboard to it.'},
|
|
2809
|
+
{el: 'card:Tools & targets', t: 'Tools & targets', see: 'Tool calls and the files or targets they touched that match your search.', get: 'What the agent actually did with a given file.', act: 'Search a filename to see how often it was re-read.'}],
|
|
2810
|
+
exports: [
|
|
2811
|
+
{el: 'card:Export', t: 'Export', see: 'Every dataset behind these dashboards, as CSV or JSON, for the current filters.', get: 'Numbers you can share or analyse elsewhere.', act: 'Download the CSV for the current filters.'},
|
|
2812
|
+
{el: 'card:Data provenance', t: 'Data provenance', see: 'Where each number comes from: which files on this machine, read when.', get: 'Confidence that nothing is invented.', act: 'Check the last-read time if numbers look stale, then Sync.'},
|
|
2813
|
+
{el: 'card:Accuracy contract', t: 'Accuracy contract', see: 'What is measured, what is estimated, and what is deliberately not fabricated.', get: 'Clear limits on what these dashboards can claim.', act: 'Read this before you quote a number to someone else.'}],
|
|
2814
|
+
};
|
|
2815
|
+
|
|
2816
|
+
let TOUR = null;
|
|
2817
|
+
function tourTarget(el) {
|
|
2818
|
+
const page = $('#page');
|
|
2819
|
+
if (!el) return null;
|
|
2820
|
+
if (el === 'kpis') return page?.querySelector('.kpi')?.closest('.grid') || null;
|
|
2821
|
+
const m = /^card(\d+)$/.exec(el);
|
|
2822
|
+
if (m) return page?.querySelectorAll('.card')[+m[1]] || null;
|
|
2823
|
+
// 'card:Some title' โ match a card by the start of its heading, so a step keeps
|
|
2824
|
+
// pointing at the right section even when cards above it are conditional.
|
|
2825
|
+
if (el.startsWith('card:')) {
|
|
2826
|
+
const want = el.slice(5).trim().toLowerCase();
|
|
2827
|
+
for (const c of page?.querySelectorAll('.card') || [])
|
|
2828
|
+
if ((c.querySelector('header h3')?.textContent || '').trim().toLowerCase().startsWith(want)) return c;
|
|
2829
|
+
return null;
|
|
2830
|
+
}
|
|
2831
|
+
return document.querySelector(el);
|
|
2832
|
+
}
|
|
2833
|
+
function startTour(steps, name) {
|
|
2834
|
+
endTour(false);
|
|
2835
|
+
// A dashboard only renders the sections its data supports, so drop the steps whose
|
|
2836
|
+
// section isn't on the page rather than pointing the ring at nothing.
|
|
2837
|
+
steps = (steps || []).filter(s => !s.el || tourTarget(s.el));
|
|
2838
|
+
if (!steps.length) steps = [{t: TITLES[S.view] || 'This dashboard', see: 'This dashboard has no guided tour yet.', get: '', act: 'Use the filters above to explore it.'}];
|
|
2839
|
+
TOUR = {steps, i: 0, name};
|
|
2840
|
+
const layer = document.createElement('div');
|
|
2841
|
+
layer.id = 'tour';
|
|
2842
|
+
layer.innerHTML = `<div class="tour-ring"></div><div class="tour-pop" role="dialog" aria-live="polite"></div>`;
|
|
2843
|
+
document.body.appendChild(layer);
|
|
2844
|
+
document.addEventListener('keydown', tourKeys);
|
|
2845
|
+
showStep();
|
|
2846
|
+
}
|
|
2847
|
+
function tourKeys(e) {
|
|
2848
|
+
if (!TOUR) return;
|
|
2849
|
+
if (e.key === 'Escape') endTour(true);
|
|
2850
|
+
else if (e.key === 'ArrowRight') tourMove(1);
|
|
2851
|
+
else if (e.key === 'ArrowLeft') tourMove(-1);
|
|
2852
|
+
}
|
|
2853
|
+
function tourMove(d) {
|
|
2854
|
+
if (!TOUR) return;
|
|
2855
|
+
const n = TOUR.i + d;
|
|
2856
|
+
if (n < 0) return;
|
|
2857
|
+
if (n >= TOUR.steps.length) return endTour(true);
|
|
2858
|
+
TOUR.i = n; showStep();
|
|
2859
|
+
}
|
|
2860
|
+
function endTour(done) {
|
|
2861
|
+
document.removeEventListener('keydown', tourKeys);
|
|
2862
|
+
$('#tour')?.remove();
|
|
2863
|
+
if (done) { try { localStorage.setItem(TOUR_DONE, '1'); } catch (_) {} }
|
|
2864
|
+
TOUR = null;
|
|
2865
|
+
}
|
|
2866
|
+
function showStep() {
|
|
2867
|
+
const {steps, i, name} = TOUR, s = steps[i];
|
|
2868
|
+
const ring = $('#tour .tour-ring'), pop = $('#tour .tour-pop');
|
|
2869
|
+
const target = tourTarget(s.el);
|
|
2870
|
+
const last = i === steps.length - 1;
|
|
2871
|
+
pop.innerHTML = `
|
|
2872
|
+
<div class="tour-hd"><span class="tour-n">${esc(name)} ยท ${i + 1} / ${steps.length}</span>
|
|
2873
|
+
<button class="tour-x" data-t="skip" title="Close (Esc)">โ</button></div>
|
|
2874
|
+
<h4>${esc(s.t)}</h4>
|
|
2875
|
+
${s.see ? `<div class="tour-row"><b>What you see</b><span>${s.see}</span></div>` : ''}
|
|
2876
|
+
${s.get ? `<div class="tour-row"><b>What you get</b><span>${s.get}</span></div>` : ''}
|
|
2877
|
+
${s.act ? `<div class="tour-row"><b>Try this</b><span>${s.act}</span></div>` : ''}
|
|
2878
|
+
<div class="tour-ft">
|
|
2879
|
+
<button class="act ghost" data-t="skip">Skip tour</button><span class="spacer"></span>
|
|
2880
|
+
<button class="act" data-t="prev" ${i ? '' : 'disabled'}>โ Previous</button>
|
|
2881
|
+
<button class="act primary" data-t="next">${last ? 'Finish' : 'Next โ'}</button>
|
|
2882
|
+
</div>`;
|
|
2883
|
+
pop.querySelectorAll('[data-t]').forEach(b => b.onclick = () =>
|
|
2884
|
+
b.dataset.t === 'skip' ? endTour(true) : tourMove(b.dataset.t === 'next' ? 1 : -1));
|
|
2885
|
+
const place = () => {
|
|
2886
|
+
if (!TOUR) return;
|
|
2887
|
+
const vw = innerWidth, vh = innerHeight, pw = Math.min(360, vw - 32);
|
|
2888
|
+
pop.style.width = pw + 'px';
|
|
2889
|
+
const ph = pop.offsetHeight;
|
|
2890
|
+
if (!target || !target.offsetParent) {
|
|
2891
|
+
ring.style.display = 'none';
|
|
2892
|
+
pop.style.left = (vw - pw) / 2 + 'px'; pop.style.top = Math.max(16, (vh - ph) / 2) + 'px';
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
const r = target.getBoundingClientRect(), pad = 6;
|
|
2896
|
+
Object.assign(ring.style, {display: 'block', left: r.left - pad + 'px', top: r.top - pad + 'px',
|
|
2897
|
+
width: r.width + pad * 2 + 'px', height: Math.min(r.height, vh - 24) + pad * 2 + 'px'});
|
|
2898
|
+
let top = r.bottom + 14, left = Math.min(Math.max(16, r.left), vw - pw - 16);
|
|
2899
|
+
if (top + ph > vh - 16) top = r.top - ph - 14; // above
|
|
2900
|
+
if (top < 16) { // beside, else centered
|
|
2901
|
+
top = Math.max(16, Math.min(r.top, vh - ph - 16));
|
|
2902
|
+
left = r.right + 14 + pw < vw ? r.right + 14 : r.left - pw - 14 > 0 ? r.left - pw - 14 : (vw - pw) / 2;
|
|
2903
|
+
}
|
|
2904
|
+
pop.style.left = left + 'px'; pop.style.top = top + 'px';
|
|
2905
|
+
};
|
|
2906
|
+
if (target) target.scrollIntoView({block: 'nearest', behavior: 'instant'});
|
|
2907
|
+
place();
|
|
2908
|
+
requestAnimationFrame(place);
|
|
2909
|
+
pop.querySelector('[data-t="next"]').focus();
|
|
2910
|
+
}
|
|
2911
|
+
function tourForView() { startTour(TOURS[S.view], TITLES[S.view] || 'Tour'); }
|
|
2912
|
+
function maybeFirstTour() {
|
|
2913
|
+
let done = false;
|
|
2914
|
+
try { done = localStorage.getItem(TOUR_DONE) === '1'; } catch (_) {}
|
|
2915
|
+
if (done) return;
|
|
2916
|
+
// Per-dashboard tours are long now, so the first-visit tour only previews the first
|
|
2917
|
+
// few sections; the "? Tour" button plays the full one.
|
|
2918
|
+
const view = (TOURS[S.view] || []).slice(0, 3);
|
|
2919
|
+
const steps = [...TOUR_WELCOME, ...view.map(s => ({...s, t: `${TITLES[S.view]}: ${s.t}`}))];
|
|
2920
|
+
startTour(steps, 'Welcome');
|
|
2921
|
+
}
|
|
2922
|
+
addEventListener('resize', () => { if (TOUR) showStep(); });
|
|
2923
|
+
|