claude-finops 0.16.0 → 0.17.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/README.md +3 -21
- package/config/pricing.json +1 -141
- package/finops/actions.py +11 -18
- package/finops/analytics.py +31 -62
- package/finops/api.py +9 -19
- package/finops/cloud.py +10 -107
- package/finops/diagnose.py +12 -85
- package/finops/etl.py +6 -10
- package/finops/procs.py +1 -189
- package/finops/segments.py +2 -2
- package/package.json +2 -5
- package/web/app.js +170 -223
- package/finops/agents.py +0 -271
package/web/app.js
CHANGED
|
@@ -5,7 +5,7 @@ const {fmtUSD, fmtNum, fmtInt, fmtPct, seriesVar} = C;
|
|
|
5
5
|
const S = {
|
|
6
6
|
view: 'hygiene',
|
|
7
7
|
opts: null,
|
|
8
|
-
filter: {start: null, end: null,
|
|
8
|
+
filter: {start: null, end: null, models: [], projects: [], categories: [],
|
|
9
9
|
include_sandbox: true, min_cost: null, min_tokens: null},
|
|
10
10
|
range: '30d',
|
|
11
11
|
metric: 'cost',
|
|
@@ -34,7 +34,7 @@ const qs = () => {
|
|
|
34
34
|
const f = S.filter, p = new URLSearchParams();
|
|
35
35
|
if (f.start) p.set('start', f.start);
|
|
36
36
|
if (f.end) p.set('end', f.end);
|
|
37
|
-
for (const k of ['
|
|
37
|
+
for (const k of ['models', 'projects', 'categories'])
|
|
38
38
|
if (f[k].length) p.set(k, f[k].join(','));
|
|
39
39
|
if (!f.include_sandbox) p.set('include_sandbox', '0');
|
|
40
40
|
if (f.min_cost) p.set('min_cost', f.min_cost);
|
|
@@ -55,7 +55,7 @@ async function api(route, extra = '') {
|
|
|
55
55
|
const bust = () => S.cache.clear();
|
|
56
56
|
|
|
57
57
|
/* ============================ formatting helpers ============================ */
|
|
58
|
-
const naLabel = () =>
|
|
58
|
+
const naLabel = () => 'Unavailable from Claude Code data';
|
|
59
59
|
const NA = () => `<span class="na">${esc(naLabel())}</span>`;
|
|
60
60
|
const BADGE = {actual: '<span class="badge">Actual</span>',
|
|
61
61
|
estimated: '<span class="badge est">Estimated</span>',
|
|
@@ -132,13 +132,12 @@ const NAV = [
|
|
|
132
132
|
['hygiene', 'gauge', 'Context hygiene'],
|
|
133
133
|
['overview', 'dashboard', 'Executive overview'],
|
|
134
134
|
['advisor', 'bulb', 'What should I do?'],
|
|
135
|
-
['agents', 'bot', 'Agents'],
|
|
136
135
|
['live', 'activity', 'Running sessions'],
|
|
137
136
|
['scorecard', 'award', 'FinOps scorecard'],
|
|
138
137
|
]],
|
|
139
138
|
['Usage', [
|
|
140
139
|
['usage', 'barChart', 'Usage timeline'],
|
|
141
|
-
['burn', 'flame', 'Burn rate & limits'
|
|
140
|
+
['burn', 'flame', 'Burn rate & limits'],
|
|
142
141
|
['models', 'cpu', 'Model analysis'],
|
|
143
142
|
['context', 'layers', 'Context & cache'],
|
|
144
143
|
]],
|
|
@@ -148,23 +147,23 @@ const NAV = [
|
|
|
148
147
|
['prompts', 'message', 'Prompt explorer'],
|
|
149
148
|
['rankings', 'sortDesc', 'Cost rankings'],
|
|
150
149
|
['categories', 'tag', 'Prompt intelligence'],
|
|
151
|
-
['developer', 'code', 'Developer activity'
|
|
150
|
+
['developer', 'code', 'Developer activity'],
|
|
152
151
|
]],
|
|
153
152
|
['Optimize', [
|
|
154
|
-
['diagnose', 'scan', 'Why so many tokens?'
|
|
155
|
-
['attribution', 'users', 'Who used the tokens'
|
|
156
|
-
['subagents', 'bot', 'Subagent models'
|
|
153
|
+
['diagnose', 'scan', 'Why so many tokens?'],
|
|
154
|
+
['attribution', 'users', 'Who used the tokens'],
|
|
155
|
+
['subagents', 'bot', 'Subagent models'],
|
|
157
156
|
['waste', 'trash', 'Waste detection'],
|
|
158
|
-
['freemodels', 'gift', 'Free models'
|
|
157
|
+
['freemodels', 'gift', 'Free models'],
|
|
159
158
|
['jev', 'sparkles', 'Jev (fast decisions)'],
|
|
160
|
-
['compare', 'scale', 'Compare models'
|
|
161
|
-
['toolkit', 'wrench', 'Skills & MCP'
|
|
159
|
+
['compare', 'scale', 'Compare models'],
|
|
160
|
+
['toolkit', 'wrench', 'Skills & MCP'],
|
|
162
161
|
['recommendations', 'listChecks', 'Recommendations'],
|
|
163
162
|
['anomalies', 'zap', 'Anomalies'],
|
|
164
163
|
]],
|
|
165
164
|
['Plan', [
|
|
166
|
-
['forecast', 'trendUp', 'Forecast'
|
|
167
|
-
['budgets', 'wallet', 'Budgets'
|
|
165
|
+
['forecast', 'trendUp', 'Forecast'],
|
|
166
|
+
['budgets', 'wallet', 'Budgets'],
|
|
168
167
|
['cloud', 'cloud', 'Billed vs local', 'cloud'],
|
|
169
168
|
['exports', 'download', 'Export & data'],
|
|
170
169
|
]],
|
|
@@ -278,7 +277,7 @@ function shell() {
|
|
|
278
277
|
<div class="sub">Command Center</div></div>
|
|
279
278
|
<nav class="nav">${NAV.map(([g, items], gi) => `<div class="group g${gi}">${g}</div>` +
|
|
280
279
|
items.map(([id, ic, label]) =>
|
|
281
|
-
`<a data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${I(ic)}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
|
|
280
|
+
`<a href="/${id}" data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${I(ic)}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
|
|
282
281
|
</nav>
|
|
283
282
|
<div class="who" id="who"></div></aside>
|
|
284
283
|
<div class="main">
|
|
@@ -298,7 +297,7 @@ function shell() {
|
|
|
298
297
|
<button class="iconbtn sq" id="theme"></button>
|
|
299
298
|
<button class="iconbtn sq" id="refresh" title="Reload this page's numbers from the warehouse. To pick up new sessions, use Sync" aria-label="Reload view">${ICON.reload}</button>
|
|
300
299
|
</div>
|
|
301
|
-
<button class="act sync" id="sync" title="Re-read
|
|
300
|
+
<button class="act sync" id="sync" title="Re-read Claude Code's transcripts from disk into the warehouse">${ICON.sync}Sync</button>
|
|
302
301
|
</div>
|
|
303
302
|
<div class="filters" id="filters"></div>
|
|
304
303
|
</header>
|
|
@@ -306,7 +305,7 @@ function shell() {
|
|
|
306
305
|
</div></div>`;
|
|
307
306
|
|
|
308
307
|
document.querySelectorAll('.nav a').forEach(a =>
|
|
309
|
-
a.onclick = ()
|
|
308
|
+
a.onclick = e => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button) return; e.preventDefault(); go(a.dataset.view); });
|
|
310
309
|
$('#tour-btn').onclick = () => tourForView();
|
|
311
310
|
$('#theme').onclick = () => {
|
|
312
311
|
const cur = document.documentElement.getAttribute('data-theme');
|
|
@@ -452,12 +451,7 @@ function applyRange(r) {
|
|
|
452
451
|
function filterBar() {
|
|
453
452
|
const f = S.filter, o = S.opts;
|
|
454
453
|
const nSel = a => a.length ? `<span class="n">${a.length}</span>` : '';
|
|
455
|
-
const ag = o.agents || [];
|
|
456
454
|
$('#filters').innerHTML = `
|
|
457
|
-
${ag.length > 1 ? `<div class="seg" role="group" aria-label="Agent"><span class="seg-l">Agent</span>
|
|
458
|
-
${ag.map(a => `<button class="${f.agents.includes(a.id) ? 'on' : ''}" data-agent="${esc(a.id)}"
|
|
459
|
-
title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}. Cmd/Shift-click to combine">${esc(a.name)}${a.requests ? '' : ' ·'}</button>`).join('')}
|
|
460
|
-
<button class="${f.agents.length === ag.length ? 'on' : ''}" data-agent="*" title="Every agent together">All</button></div>` : ''}
|
|
461
455
|
<div class="seg" role="group" aria-label="Date range"><span class="seg-l">Range</span>
|
|
462
456
|
${RANGES.map(([k, l]) => `<button class="${S.range === k ? 'on' : ''}"
|
|
463
457
|
data-range="${k}">${l}</button>`).join('')}</div>
|
|
@@ -481,17 +475,6 @@ function filterBar() {
|
|
|
481
475
|
$('#filters').querySelectorAll('[data-range]').forEach(b =>
|
|
482
476
|
b.onclick = e => { if (b.dataset.range === 'custom') { e.stopPropagation(); return openCustom(b); }
|
|
483
477
|
applyRange(b.dataset.range); bust(); render(); });
|
|
484
|
-
$('#filters').querySelectorAll('[data-agent]').forEach(b => b.onclick = e => {
|
|
485
|
-
const id = b.dataset.agent;
|
|
486
|
-
// click = only this agent; Cmd/Ctrl/Shift-click = add or remove it (multi-agent view)
|
|
487
|
-
if (id === '*') f.agents = ag.map(a => a.id);
|
|
488
|
-
else if (e.metaKey || e.ctrlKey || e.shiftKey)
|
|
489
|
-
f.agents = f.agents.includes(id) ? f.agents.filter(x => x !== id) : [...f.agents, id];
|
|
490
|
-
else f.agents = [id];
|
|
491
|
-
if (!f.agents.length) f.agents = [id === '*' ? ag[0].id : id];
|
|
492
|
-
try { localStorage.setItem('finops-agents', JSON.stringify(f.agents)); } catch (_) {}
|
|
493
|
-
bust(); render();
|
|
494
|
-
});
|
|
495
478
|
$('#sbx').onclick = () => { f.include_sandbox = !f.include_sandbox; bust(); render(); };
|
|
496
479
|
const clr = $('#clr'); if (clr) clr.onclick = () => {
|
|
497
480
|
f.models = []; f.projects = []; f.categories = []; f.min_cost = null; f.min_tokens = null;
|
|
@@ -561,7 +544,10 @@ function openCustom(btn) {
|
|
|
561
544
|
function closeDrawer() {
|
|
562
545
|
document.querySelectorAll('.scrim,.drawer').forEach(e => e.remove());
|
|
563
546
|
S.drawerStack = [];
|
|
547
|
+
if (S.drawerRoute) { S.drawerRoute = null; syncURL(true); }
|
|
564
548
|
}
|
|
549
|
+
// An open drawer is part of the URL (?session=…), so refresh and Back bring it back.
|
|
550
|
+
function setDrawerRoute(k, id) { S.drawerRoute = {k, id: String(id)}; syncURL(true); }
|
|
565
551
|
function drawer(title, bodyHtml, sub) {
|
|
566
552
|
C.hideTip();
|
|
567
553
|
document.querySelectorAll('.scrim,.drawer').forEach(e => e.remove());
|
|
@@ -594,6 +580,7 @@ const wholeNote = (what, x) => {
|
|
|
594
580
|
};
|
|
595
581
|
async function openPrompt(id) {
|
|
596
582
|
const d = drawer('Prompt detail', '<div class="loading">Loading…</div>');
|
|
583
|
+
setDrawerRoute('prompt', id);
|
|
597
584
|
const p = await fetch(`/api/prompt/${id}?${qs()}`).then(r => r.json());
|
|
598
585
|
d.querySelector('.content').innerHTML = `${wholeNote('prompt', p)}
|
|
599
586
|
<div class="grid g4">
|
|
@@ -648,6 +635,7 @@ async function openPrompt(id) {
|
|
|
648
635
|
|
|
649
636
|
async function openSession(id) {
|
|
650
637
|
const d = drawer('Session detail', '<div class="loading">Loading…</div>', esc(shortId(id)));
|
|
638
|
+
setDrawerRoute('session', id);
|
|
651
639
|
const s = await fetch(`/api/session/${encodeURIComponent(id)}?${qs()}`).then(r => r.json());
|
|
652
640
|
const cont = d.querySelector('.content');
|
|
653
641
|
cont.innerHTML = `${wholeNote('session', s)}
|
|
@@ -746,7 +734,7 @@ VIEWS.overview = async (page) => {
|
|
|
746
734
|
${alloc.configured
|
|
747
735
|
? kpi('Remaining', fmtUSD(alloc.remaining),
|
|
748
736
|
`${alloc.days_until_limit ?? '—'} days at current burn`, {badge: BADGE.estimated})
|
|
749
|
-
: kpi('Remaining allowance', null,
|
|
737
|
+
: kpi('Remaining allowance', null, 'No plan limit exposed by Claude Code data')}
|
|
750
738
|
${kpi('Period forecast', fcExp == null ? null : fmtUSD(fcExp),
|
|
751
739
|
`${bp.remaining_days} days left in ${bp.start} → ${bp.end}`, {badge: BADGE.forecast})}
|
|
752
740
|
</div>
|
|
@@ -837,7 +825,7 @@ VIEWS.overview = async (page) => {
|
|
|
837
825
|
C.gauge(gEl, {pct: pctOfPeriod, status: 'healthy', label: 'of billing period elapsed'});
|
|
838
826
|
$('#burnnotes', page).innerHTML = `<div class="stack">
|
|
839
827
|
<div class="item"><div class="hd">No plan allowance available</div>
|
|
840
|
-
<div class="dt"
|
|
828
|
+
<div class="dt">Claude Code data does not expose plan limits, remaining credits,
|
|
841
829
|
or message allowances. Set <span class="mono">limits.monthly_cost_allowance_usd</span>
|
|
842
830
|
in <span class="mono">config/settings.json</span> to unlock usage-vs-limit,
|
|
843
831
|
days-until-limit and limit-date projections.</div></div>
|
|
@@ -914,7 +902,7 @@ VIEWS.overview = async (page) => {
|
|
|
914
902
|
page._focusData = d;
|
|
915
903
|
wireFocus(page);
|
|
916
904
|
}).catch(() => {});
|
|
917
|
-
|
|
905
|
+
fetch('/api/act_now').then(r => r.json()).then(d => {
|
|
918
906
|
if (!page.isConnected || !(d.items || []).length) return;
|
|
919
907
|
const el = h(actNowStrip(d.items));
|
|
920
908
|
page.insertBefore(el, page.firstChild);
|
|
@@ -951,7 +939,7 @@ function wireActNow(root, items) {
|
|
|
951
939
|
b.disabled = true;
|
|
952
940
|
try {
|
|
953
941
|
if (what === 'compact') {
|
|
954
|
-
const r = await sessionAction(it.pid, 'compact'
|
|
942
|
+
const r = await sessionAction(it.pid, 'compact');
|
|
955
943
|
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
956
944
|
say(r.ok ? r.message : `Failed: ${r.error}`, r.ok);
|
|
957
945
|
b.disabled = false; return;
|
|
@@ -984,7 +972,7 @@ async function jumpTo(view, anchor) {
|
|
|
984
972
|
/* ---------- row actions: Resume (copy) and, for a running session, Compact ---------- */
|
|
985
973
|
async function liveSessions() {
|
|
986
974
|
try {
|
|
987
|
-
const d = await fetch('/api/live
|
|
975
|
+
const d = await fetch('/api/live').then(r => r.json());
|
|
988
976
|
return new Map((d.sessions || []).filter(x => x.signalable && x.pid).map(x => [x.session_id, x.pid]));
|
|
989
977
|
} catch { return new Map(); }
|
|
990
978
|
}
|
|
@@ -1007,7 +995,7 @@ function wireRowActs(host) {
|
|
|
1007
995
|
if (!armed(b)) return;
|
|
1008
996
|
b.disabled = true;
|
|
1009
997
|
try {
|
|
1010
|
-
const r = await sessionAction(+b.dataset.compact, 'compact'
|
|
998
|
+
const r = await sessionAction(+b.dataset.compact, 'compact');
|
|
1011
999
|
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
1012
1000
|
b.innerHTML = r.ok ? `${I('check')} Sent` : 'Failed'; b.title = r.ok ? r.message : r.error;
|
|
1013
1001
|
} catch (err) { b.textContent = 'Failed'; b.title = err.message; }
|
|
@@ -1061,7 +1049,7 @@ VIEWS.advisor = async (page) => {
|
|
|
1061
1049
|
${kpi('Spend involved', fmtUSD(r.actual_cost_usd), null, {small: 1, badge: BADGE.estimated})}
|
|
1062
1050
|
</div>
|
|
1063
1051
|
${r.alternatives && r.alternatives.length ? `<div class="dt" style="margin-top:6px">
|
|
1064
|
-
<b>Your options
|
|
1052
|
+
<b>Your options</b> — pick the trade-off you want:</div>
|
|
1065
1053
|
${table([
|
|
1066
1054
|
{h: 'Model', f: a => esc(a.name) + (a.suggested
|
|
1067
1055
|
? ' <span class="badge rec">suggested</span>' : '')},
|
|
@@ -1377,7 +1365,7 @@ VIEWS.projects = async (page) => {
|
|
|
1377
1365
|
${card('Project FinOps table', '<div id="pt"></div>', {badge: BADGE.estimated,
|
|
1378
1366
|
hint: 'Project → Session → Prompt drill-down',
|
|
1379
1367
|
footer: 'Project identity comes from the working directory recorded in each transcript. '
|
|
1380
|
-
+
|
|
1368
|
+
+ 'Repository/remote metadata is not present in Claude Code data.'})}`;
|
|
1381
1369
|
C.barsH($('#pb', page), {rows: rows.slice(0, 18), label: r => r.name, value: r => r.cost,
|
|
1382
1370
|
color: seriesVar(2), onClick: r => { S.filter.projects = [String(r.project_id)];
|
|
1383
1371
|
bust(); go('sessions'); },
|
|
@@ -1621,7 +1609,7 @@ VIEWS.categories = async (page) => {
|
|
|
1621
1609
|
${card('Cost by activity', `<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
|
1622
1610
|
<div class="chart" id="cd"></div><div style="flex:1;min-width:220px" class="legend"
|
|
1623
1611
|
id="cl" style="flex-direction:column;align-items:flex-start"></div></div>`,
|
|
1624
|
-
{badge: BADGE.estimated, hint:
|
|
1612
|
+
{badge: BADGE.estimated, hint: 'where your Claude Code budget goes'})}
|
|
1625
1613
|
${card('Prompts by activity', '<div class="chart" id="cb"></div>', {badge: BADGE.actual})}
|
|
1626
1614
|
</div>
|
|
1627
1615
|
${card('Activity breakdown', '<div id="ct"></div>', {badge: BADGE.estimated,
|
|
@@ -2124,23 +2112,22 @@ async function drawPrompts(box, sid) {
|
|
|
2124
2112
|
|
|
2125
2113
|
VIEWS.live = async (page) => {
|
|
2126
2114
|
const [res, usage0] = await Promise.all([
|
|
2127
|
-
fetch('/api/live
|
|
2115
|
+
fetch('/api/live').then(r => r.json()), // never cached: always live
|
|
2128
2116
|
fetch('/api/usage').then(r => r.json()).catch(e => ({ok: false, error: e.message}))]);
|
|
2129
2117
|
const list = res.sessions || [];
|
|
2130
2118
|
const liveActions = res.actions || ['interrupt', 'close', 'kill'];
|
|
2131
2119
|
const canInterrupt = liveActions.includes('interrupt');
|
|
2132
|
-
const agentName = id => ((S.opts?.agents || []).find(a => a.id === id) || {}).name || id;
|
|
2133
2120
|
const busy = list.filter(x => x.status === 'busy');
|
|
2134
2121
|
const sevOf = x => x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low';
|
|
2135
2122
|
page.innerHTML = `
|
|
2136
|
-
${card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
|
|
2123
|
+
${usage0 ? card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
|
|
2137
2124
|
{badge: BADGE.actual, hint: 'Claude Code\'s own /usage: plan limits, not cost. Costs no tokens.',
|
|
2138
|
-
actions: `<button class="btn pb-copy" id="usage-refresh">${I('reload')} Refresh usage</button>`})}
|
|
2125
|
+
actions: `<button class="btn pb-copy" id="usage-refresh">${I('reload')} Refresh usage</button>`}) : ''}
|
|
2139
2126
|
<div class="grid g4">
|
|
2140
2127
|
${kpi('Running sessions', fmtInt(list.length), `${busy.length} working right now`, {badge: BADGE.actual})}
|
|
2141
2128
|
${kpi('Working (spending now)', fmtInt(busy.length), 'Only these consume tokens right now')}
|
|
2142
2129
|
${kpi('Largest live context', fmtNum(Math.max(0, ...list.map(x => x.context || 0))), 'Re-read on every step')}
|
|
2143
|
-
${kpi('Spent by live sessions', fmtUSD(list.reduce((a, x) => a + (x.est_cost_usd || 0), 0)), 'Since each started
|
|
2130
|
+
${kpi('Spent by live sessions', fmtUSD(list.reduce((a, x) => a + (x.est_cost_usd || 0), 0)), 'Since each started', {badge: BADGE.estimated})}
|
|
2144
2131
|
</div>
|
|
2145
2132
|
<div class="note"><b>Idle sessions don't use tokens</b>; they only cost again when you send the next message,
|
|
2146
2133
|
which re-reads their whole context. <b>Interrupt</b> stops the current turn (like Esc).
|
|
@@ -2148,16 +2135,13 @@ VIEWS.live = async (page) => {
|
|
|
2148
2135
|
Windows console; anywhere else it lands on your clipboard to paste).
|
|
2149
2136
|
<b>Close</b> exits the session cleanly; copy its resume command to bring it back.
|
|
2150
2137
|
<b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
|
|
2151
|
-
Codex, Gemini and Cursor keep no session registry, so they count as running when their transcript was
|
|
2152
|
-
written in the last 20 minutes. Cursor sessions live inside the IDE and can't be stopped from here.
|
|
2153
2138
|
<span class="spacer"></span><button class="btn pb-copy" id="live-refresh">${I('reload')} Refresh</button></div>
|
|
2154
2139
|
<div class="stack">${list.map((x, i) => `
|
|
2155
2140
|
<div class="item sev-${sevOf(x)}" data-i="${i}">
|
|
2156
2141
|
<div class="hd">${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
|
|
2157
|
-
<span class="pill">${esc(agentName(x.agent))}</span>
|
|
2158
2142
|
<span class="sname">${esc((!MASKED && x.name) || shortId(x.session_id))}</span> <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
|
|
2159
2143
|
${x.hosts_dashboard ? '<span class="status high">runs this dashboard</span>' : ''}
|
|
2160
|
-
${
|
|
2144
|
+
${limitBadge(x.session_id)}
|
|
2161
2145
|
<span class="spacer"></span>
|
|
2162
2146
|
<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>
|
|
2163
2147
|
<div class="dt">${x.status === 'busy' ? '<b>Working now</b>' : 'Idle'}${x.uptime ? ` · up ${esc(x.uptime)}` : ''} ·
|
|
@@ -2168,17 +2152,17 @@ VIEWS.live = async (page) => {
|
|
|
2168
2152
|
: 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
|
|
2169
2153
|
<div class="live-actions">
|
|
2170
2154
|
${x.signalable ? `${canInterrupt ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>${I('pause')} Interrupt</button>` : ''}
|
|
2171
|
-
|
|
2155
|
+
<button class="act" data-a="compact" title="Types /compact into that session's terminal">${I('compact')} Compact</button>
|
|
2172
2156
|
<button class="act warn" data-a="close">${I('stop')} Close session</button>
|
|
2173
|
-
<button class="act danger" data-a="kill">${I('x')} Force kill</button>` :
|
|
2157
|
+
<button class="act danger" data-a="kill">${I('x')} Force kill</button>` : '<span class="note">No matching process found: stop it in its terminal.</span>'}
|
|
2174
2158
|
${x.resume ? `<button class="act ghost" data-copy="${esc(x.resume)}">Copy resume command</button>` : ''}
|
|
2175
|
-
|
|
2176
|
-
${x.
|
|
2177
|
-
${x.
|
|
2178
|
-
${x.
|
|
2159
|
+
<button class="act ghost" data-ho="1">${I('handover')} Hand over / split</button>
|
|
2160
|
+
${x.session_id ? '<button class="act ghost" data-lim="1">' + I('gauge') + ' Set limit</button>' : ''}
|
|
2161
|
+
${x.session_id ? '<button class="act ghost" data-pr="1">' + I('message') + ' Prompts</button>' : ''}
|
|
2162
|
+
${x.signalable ? '<button class="act ghost" data-rn="1">' + I('tag') + ' Rename</button>' : ''}
|
|
2179
2163
|
<span class="live-msg"></span>
|
|
2180
2164
|
</div>
|
|
2181
|
-
${
|
|
2165
|
+
${`<div class="handover" hidden>
|
|
2182
2166
|
<div class="dt">Starts fresh session(s) in <code>${esc(x.cwd)}</code>, seeded with a brief built from this
|
|
2183
2167
|
transcript (goal, recent requests, task list, files changed, last status). No tokens are spent building it.</div>
|
|
2184
2168
|
<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>
|
|
@@ -2190,9 +2174,9 @@ VIEWS.live = async (page) => {
|
|
|
2190
2174
|
</div>
|
|
2191
2175
|
<div class="ho-out note"></div>
|
|
2192
2176
|
</div>`}
|
|
2193
|
-
${x.
|
|
2194
|
-
${x.
|
|
2195
|
-
${x.
|
|
2177
|
+
${x.session_id ? `<div class="handover limitbox" hidden></div>` : ''}
|
|
2178
|
+
${x.session_id ? `<div class="handover promptbox" hidden></div>` : ''}
|
|
2179
|
+
${x.signalable ? `<div class="handover renamebox" hidden>
|
|
2196
2180
|
<div class="dt">Types <code>/rename <name></code> into that session's terminal, so Claude Code saves the
|
|
2197
2181
|
name itself (in herdr, the agents sidebar gets the same name). If the terminal can't be reached, the command lands on your clipboard to paste.</div>
|
|
2198
2182
|
<div class="live-actions">
|
|
@@ -2200,10 +2184,10 @@ VIEWS.live = async (page) => {
|
|
|
2200
2184
|
<button class="act rn-go">Rename</button>
|
|
2201
2185
|
<span class="live-msg rn-msg" role="status"></span>
|
|
2202
2186
|
</div></div>` : ''}
|
|
2203
|
-
</div>`).join('') ||
|
|
2187
|
+
</div>`).join('') || '<div class="empty">No Claude Code sessions are running</div>'}</div>`;
|
|
2204
2188
|
$('#live-refresh', page).onclick = () => render();
|
|
2205
2189
|
const ub = $('#usage-refresh', page);
|
|
2206
|
-
ub.onclick = async () => {
|
|
2190
|
+
if (ub) ub.onclick = async () => {
|
|
2207
2191
|
ub.disabled = true; ub.textContent = 'Reading…';
|
|
2208
2192
|
try {
|
|
2209
2193
|
const u = await fetch('/api/usage?refresh=1').then(r => r.json());
|
|
@@ -2278,7 +2262,7 @@ VIEWS.live = async (page) => {
|
|
|
2278
2262
|
}
|
|
2279
2263
|
b.classList.remove('armed'); b.disabled = true; b.textContent = 'Working…';
|
|
2280
2264
|
try {
|
|
2281
|
-
const r = await sessionAction(pid, b.dataset.a
|
|
2265
|
+
const r = await sessionAction(pid, b.dataset.a);
|
|
2282
2266
|
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
2283
2267
|
msg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
|
|
2284
2268
|
msg.className = 'live-msg ' + (r.ok ? 'ok' : 'err');
|
|
@@ -2316,8 +2300,7 @@ const harnessChip = h => h ? `<b>${HARNESS[h.verdict][0]}</b>` : '—';
|
|
|
2316
2300
|
|
|
2317
2301
|
VIEWS.diagnose = async (page) => {
|
|
2318
2302
|
const d = await api('diagnose');
|
|
2319
|
-
const
|
|
2320
|
-
const gdir = {claude: '~/.claude', codex: '~/.codex', gemini: '~/.gemini'}[d.agent || 'claude'];
|
|
2303
|
+
const md = d.vocab?.md || 'CLAUDE.md';
|
|
2321
2304
|
PB.length = 0;
|
|
2322
2305
|
const t = d.total || {}, tr = d.trend || {};
|
|
2323
2306
|
const sevChip = s => dot(s === 'high' ? 'red' : s === 'medium' ? 'yellow' : 'grey');
|
|
@@ -2349,7 +2332,7 @@ VIEWS.diagnose = async (page) => {
|
|
|
2349
2332
|
${x.fixes.map(t => `<div class="dt">• ${esc(t)}</div>`).join('')}${pb(x.playbook)}</div>`).join('')
|
|
2350
2333
|
|| '<div class="empty">No session passed 150K context</div>'}</div>`,
|
|
2351
2334
|
{badge: BADGE.recommendation, hint: '"Above baseline" = context re-read beyond 100K'})}
|
|
2352
|
-
<div id="dx-mem"></div>${card(`Add to ${md}
|
|
2335
|
+
<div id="dx-mem"></div>${card(`Add to ${md} / memory (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
|
|
2353
2336
|
<div class="item sev-${x.kind === 'security' ? 'high' : x.already_saved ? 'low' : 'medium'}">
|
|
2354
2337
|
<div class="hd">${I(x.kind === 'security' ? 'lock' : x.kind === 'template' ? 'cog' : x.kind === 'reference' ? 'link' : 'note')}
|
|
2355
2338
|
${esc(pt(x.text.slice(0, 140)))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
|
|
@@ -2373,14 +2356,14 @@ VIEWS.diagnose = async (page) => {
|
|
|
2373
2356
|
${pb(r.playbook)}</div>`).join('')
|
|
2374
2357
|
|| '<div class="empty">Nothing stands out</div>'}</div>`, {badge: BADGE.recommendation})}
|
|
2375
2358
|
${card(`Projects: ${esc(d.vocab?.name || 'Claude')} config health`, `<div id="dx-proj"></div>`,
|
|
2376
|
-
{badge: BADGE.recommendation, hint:
|
|
2377
|
-
${
|
|
2378
|
-
{badge: BADGE.recommendation, hint: 'Harness = CLAUDE.md, permissions, hooks, skills, subagents. Need is judged from your usage in each project'})
|
|
2359
|
+
{badge: BADGE.recommendation, hint: 'CLAUDE.md, memory, .claude/settings.json and MCP checked on disk now', flush: 1})}
|
|
2360
|
+
${card('Projects: does it need a harness?', `<div id="dx-harness" class="stack"></div>`,
|
|
2361
|
+
{badge: BADGE.recommendation, hint: 'Harness = CLAUDE.md, permissions, hooks, skills, subagents. Need is judged from your usage in each project'})}
|
|
2379
2362
|
<div id="dx-issues"></div>
|
|
2380
|
-
${card(
|
|
2363
|
+
${card('Global config (~/.claude)', `<div class="stack">
|
|
2381
2364
|
<div class="dt">Default model: <b>${esc(d.global.default_model || 'not set')}</b> ·
|
|
2382
2365
|
MCP servers: ${esc(d.global.mcp_servers.join(', ') || 'none')} ·
|
|
2383
|
-
|
|
2366
|
+
Custom agents: ${esc(d.global.custom_agents.join(', ') || 'none')}</div>
|
|
2384
2367
|
${d.global.files.map(x => `<div class="dt">${esc(x.path)} — ~${fmtInt(x.tokens)} tokens</div>`).join('')}
|
|
2385
2368
|
${d.global.issues.map(i => `<div class="item sev-${i.severity}"><div class="hd">${sevChip(i.severity)} ${esc(i.title)}</div>
|
|
2386
2369
|
<div class="dt">${esc(i.fix)}</div>${pb(i.playbook)}</div>`).join('')}</div>`)}
|
|
@@ -2391,9 +2374,9 @@ VIEWS.diagnose = async (page) => {
|
|
|
2391
2374
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
2392
2375
|
{h: 'Avg ctx', num: 1, f: r => fmtNum(r.avg_context)},
|
|
2393
2376
|
{h: md, f: r => r.claude_md.paths.length ? `~${fmtInt(r.claude_md.tokens)} tok` : '<span class="na">missing</span>'},
|
|
2394
|
-
|
|
2377
|
+
{h: 'Memory', num: 1, f: r => r.memory_tokens == null ? '—' : `~${fmtInt(r.memory_tokens)}`},
|
|
2395
2378
|
{h: 'Explore %', num: 1, f: r => fmtPct(r.explore_pct)},
|
|
2396
|
-
|
|
2379
|
+
{h: 'Harness', f: r => harnessChip(r.harness)},
|
|
2397
2380
|
{h: 'Needs update', f: r => r.issues.length ? r.issues.map(i => sevChip(i.severity) + ' ' + esc(i.title)).join('<br>') : I('check')},
|
|
2398
2381
|
];
|
|
2399
2382
|
page.querySelectorAll('.sess-link').forEach(l => l.onclick = e => { e.preventDefault(); openSession(l.dataset.sess); });
|
|
@@ -2628,7 +2611,7 @@ async function drawKeys(page) {
|
|
|
2628
2611
|
</div>`).join('');
|
|
2629
2612
|
const done = async (pid, text) => {
|
|
2630
2613
|
S.opts = await fetch('/api/options').then(r => r.json());
|
|
2631
|
-
|
|
2614
|
+
applyChrome(); // Billed vs local appears once a key exists
|
|
2632
2615
|
await drawKeys(page);
|
|
2633
2616
|
const ok = $('#key-' + pid + '-ok', page); if (ok) ok.textContent = text;
|
|
2634
2617
|
};
|
|
@@ -2853,7 +2836,7 @@ VIEWS.budgets = async (page) => {
|
|
|
2853
2836
|
: `Last 30 days: ${fmtNum(SG.tokens_30d)} tokens.`,
|
|
2854
2837
|
'g-tokens': v => !sizes.length ? '' : v ? overShare(v) : `Half your sessions stay under ${fmtNum(pctile(.5))} tokens.`,
|
|
2855
2838
|
};
|
|
2856
|
-
const planHint =
|
|
2839
|
+
const planHint = 'Your own figure, from your plan or invoice. Claude Code data does not include it.';
|
|
2857
2840
|
const stepVal = sg.step_pct ?? 25;
|
|
2858
2841
|
// Does any per-session budget exist (global or a project override)? The hook needs one.
|
|
2859
2842
|
const anyBudget = !!sg.session_tokens || [sg.projects, sg.sessions].some(m => Object.values(m || {}).some(o => o.session_tokens));
|
|
@@ -3022,7 +3005,7 @@ VIEWS.budgets = async (page) => {
|
|
|
3022
3005
|
const gp = Object.entries(sg.projects || {}).map(([path, o]) =>
|
|
3023
3006
|
({path, off: !!o.off, tokens: o.session_tokens == null ? '' : o.session_tokens.toLocaleString('en-US')}));
|
|
3024
3007
|
const known = [...new Map((S.opts.projects || [])
|
|
3025
|
-
.filter(p => p.path &&
|
|
3008
|
+
.filter(p => p.path && !p.is_sandbox)
|
|
3026
3009
|
.map(p => [p.path, p])).values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
3027
3010
|
const drawProjects = () => {
|
|
3028
3011
|
$('#g-projects', page).innerHTML = gp.length ? gp.map((o, i) => `
|
|
@@ -3129,7 +3112,7 @@ VIEWS.budgets = async (page) => {
|
|
|
3129
3112
|
VIEWS.settings = async (page) => {
|
|
3130
3113
|
page.innerHTML = card('API keys', `<div class="cfg">
|
|
3131
3114
|
<div class="fld-hint" style="margin-bottom:8px">An API key is a password that lets this dashboard ask
|
|
3132
|
-
Anthropic
|
|
3115
|
+
Anthropic for your company's bill. You only need one for the <b>Billed vs local</b> page,
|
|
3133
3116
|
which compares what was billed with what this computer recorded. That page shows up in the sidebar
|
|
3134
3117
|
once a key is saved. Skip this if you don't use it. Keys stay on this computer, in a file only you
|
|
3135
3118
|
can read. They are never shown again.</div>
|
|
@@ -3216,11 +3199,11 @@ VIEWS.developer = async (page) => {
|
|
|
3216
3199
|
{h: 'Touches', num: 1, f: r => fmtInt(r.touches)},
|
|
3217
3200
|
], d.files), {badge: BADGE.actual})}
|
|
3218
3201
|
</div>
|
|
3219
|
-
${card(
|
|
3202
|
+
${card('Not available from Claude Code data', `<dl class="kv">
|
|
3220
3203
|
${Object.entries(u).map(([k, v]) => `<dt>${esc(k.replace(/_/g, ' '))}</dt>
|
|
3221
3204
|
<dd class="na">${esc(v)}</dd>`).join('')}
|
|
3222
3205
|
</dl>`, {badge: '<span class="badge na">Unavailable</span>',
|
|
3223
|
-
footer: '
|
|
3206
|
+
footer: 'Claude Code 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.'})}`;
|
|
3224
3207
|
C.barsH($('#rb', page), {rows: d.cost_per_repository.slice(0, 12), label: r => r.repository,
|
|
3225
3208
|
value: r => r.cost, color: seriesVar(2)});
|
|
3226
3209
|
C.barsH($('#tb', page), {rows: d.tools.slice(0, 14), label: r => r.name, value: r => r.calls,
|
|
@@ -3314,7 +3297,7 @@ VIEWS.exports = async (page) => {
|
|
|
3314
3297
|
<div class="dt">Read directly from your transcripts: token counts, timestamps, models,
|
|
3315
3298
|
tool calls, file paths, session and project identity.</div></div>
|
|
3316
3299
|
<div class="item"><div class="hd">${BADGE.estimated} Estimated</div>
|
|
3317
|
-
<div class="dt">Derived: every dollar figure.
|
|
3300
|
+
<div class="dt">Derived: every dollar figure. Claude Code data contains no billed
|
|
3318
3301
|
amounts, so cost = tokens × <span class="mono">config/pricing.json</span>.</div></div>
|
|
3319
3302
|
<div class="item"><div class="hd">${BADGE.forecast} Forecast</div>
|
|
3320
3303
|
<div class="dt">Projected from history. Assumes recent patterns continue; it is not a
|
|
@@ -3410,29 +3393,22 @@ async function updateNavBadges() {
|
|
|
3410
3393
|
const set = (id, n, lvl) => { const el = document.querySelector(`[data-nb="${id}"]`);
|
|
3411
3394
|
if (el) { el.textContent = n ? n : ''; el.className = 'nb' + (n ? ' l' + lvl : ''); } };
|
|
3412
3395
|
try {
|
|
3413
|
-
const [d, w, a] = await Promise.all([
|
|
3396
|
+
const [d, w, a] = await Promise.all([api('diagnose'), api('waste'), api('anomalies')]);
|
|
3414
3397
|
if (d) { const hid = focusHidden(), f = focusItems(d).filter(i => !hid.has(i.key)), urgent = f.filter(i => i.lvl === 1).length;
|
|
3415
3398
|
set('diagnose', urgent || f.length, urgent ? 1 : 2); }
|
|
3416
3399
|
const hw = w.findings.filter(x => x.severity === 'high').length;
|
|
3417
3400
|
set('waste', hw || w.findings.length, hw ? 1 : 2);
|
|
3418
3401
|
set('anomalies', (a.anomalies || []).length, 2);
|
|
3419
|
-
const lv = await fetch('/api/live
|
|
3402
|
+
const lv = await fetch('/api/live').then(r => r.json());
|
|
3420
3403
|
const heavy = (lv.sessions || []).filter(x => x.severity !== 'ok').length;
|
|
3421
3404
|
set('live', (lv.sessions || []).length, heavy ? 1 : 2);
|
|
3422
3405
|
} catch { /* badges are a hint; never block the page */ }
|
|
3423
3406
|
}
|
|
3424
3407
|
|
|
3425
|
-
/* ----------
|
|
3426
|
-
//
|
|
3427
|
-
// selection; cost pages need at least one agent whose usage is priced.
|
|
3428
|
-
const selAgents = () => (S.opts?.agents || []).filter(a => S.filter.agents.includes(a.id));
|
|
3429
|
-
const hasClaude = () => S.filter.agents.includes('claude');
|
|
3430
|
-
const hasPriced = () => selAgents().some(a => a.data !== 'activity');
|
|
3408
|
+
/* ---------- chrome ---------- */
|
|
3409
|
+
// Billed vs local needs an API key; every other page always shows.
|
|
3431
3410
|
const hasCloudKey = () => Object.values(S.opts?.cloud || {}).some(Boolean);
|
|
3432
|
-
const navAllowed = scope =>
|
|
3433
|
-
: scope === 'cloud' ? hasCloudKey() : hasPriced());
|
|
3434
|
-
// "Claude Code", "Codex", or "agent" for a mixed selection: used in data-availability text
|
|
3435
|
-
const agentWord = () => { const a = selAgents(); return a.length === 1 ? a[0].name : 'agent'; };
|
|
3411
|
+
const navAllowed = scope => scope !== 'cloud' || hasCloudKey();
|
|
3436
3412
|
// Whose usage this is. Read from ~/.claude.json by the server, so a screenshot
|
|
3437
3413
|
// or a shared dashboard always says which account the numbers belong to.
|
|
3438
3414
|
function whoBlock() {
|
|
@@ -3450,22 +3426,25 @@ function whoBlock() {
|
|
|
3450
3426
|
el.title = `Claude Code is signed in as ${a.name || ''} <${a.email || ''}>`.trim();
|
|
3451
3427
|
}
|
|
3452
3428
|
|
|
3453
|
-
function
|
|
3454
|
-
const
|
|
3455
|
-
const label = a.length === 1 ? a[0].name.replace(/ (Code|CLI)$/, '') : a.length ? 'Multi-agent' : 'AI';
|
|
3429
|
+
function applyChrome() {
|
|
3430
|
+
const label = 'Claude';
|
|
3456
3431
|
whoBlock();
|
|
3457
3432
|
// Stamp the version onto the logo. Two copies of this app can be installed at
|
|
3458
3433
|
// once (npm global, a checkout) and the browser cannot tell them apart.
|
|
3434
|
+
const from = S.opts?.served_from;
|
|
3435
|
+
const verText = S.opts?.version ? 'v' + S.opts.version + (from ? (from.kind === 'source'
|
|
3436
|
+
? ` · source${from.commit ? ' ' + from.commit : ''}` : ' · npm') : '') : '';
|
|
3437
|
+
const verTitle = 'Version serving this page' + (from?.path ? ` (${from.path})` : '');
|
|
3459
3438
|
const ver = $('#ver');
|
|
3460
|
-
if (ver &&
|
|
3461
|
-
ver.textContent =
|
|
3462
|
-
ver.title =
|
|
3439
|
+
if (ver && verText) {
|
|
3440
|
+
ver.textContent = verText;
|
|
3441
|
+
ver.title = verTitle;
|
|
3463
3442
|
}
|
|
3464
3443
|
const mark = $('.brand .mark');
|
|
3465
3444
|
if (mark) mark.innerHTML = `<span class="dot"></span>${esc(label)} FinOps` +
|
|
3466
|
-
(
|
|
3445
|
+
(verText ? `<span class="ver" id="ver" title="${esc(verTitle)}">${esc(verText)}</span>` : '');
|
|
3467
3446
|
const sub = $('.brand .sub');
|
|
3468
|
-
if (sub) sub.textContent =
|
|
3447
|
+
if (sub) sub.textContent = 'Command Center';
|
|
3469
3448
|
document.title = `${label} FinOps Command Center`;
|
|
3470
3449
|
const scopes = Object.fromEntries(NAV.flatMap(([, items]) => items.map(([id, , , sc]) => [id, sc])));
|
|
3471
3450
|
document.querySelectorAll('.nav a').forEach(el => el.style.display = navAllowed(scopes[el.dataset.view]) ? '' : 'none');
|
|
@@ -3475,7 +3454,7 @@ function applyAgentChrome() {
|
|
|
3475
3454
|
while (el && !el.classList.contains('group')) { if (el.style.display !== 'none') any = true; el = el.nextElementSibling; }
|
|
3476
3455
|
g.style.display = any ? '' : 'none';
|
|
3477
3456
|
});
|
|
3478
|
-
//
|
|
3457
|
+
// Billed vs local without a key: fall back to the overview
|
|
3479
3458
|
if (scopes[S.view] && !navAllowed(scopes[S.view])) S.view = 'overview';
|
|
3480
3459
|
}
|
|
3481
3460
|
|
|
@@ -3483,7 +3462,63 @@ function applyAgentChrome() {
|
|
|
3483
3462
|
const TITLES = Object.fromEntries(NAV.flatMap(([, items]) => items.map(([id, , l]) => [id, l])));
|
|
3484
3463
|
TITLES.search = 'Global search';
|
|
3485
3464
|
|
|
3486
|
-
function go(view) {
|
|
3465
|
+
function go(view) {
|
|
3466
|
+
const changed = view !== S.view;
|
|
3467
|
+
S.view = view; S.drawerRoute = null; closeDrawer();
|
|
3468
|
+
syncURL(changed); // a new page is a history entry; a new search on the same page is not
|
|
3469
|
+
render();
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
/* Each page has its own URL: /<view>, plus ?session= / ?prompt= / ?add_model= for an open
|
|
3473
|
+
drawer, ?recent=1 for the Recent panel and ?q= for a search. Refresh keeps you there,
|
|
3474
|
+
Back/Forward walk through it. ?view=<name> still works for old links. */
|
|
3475
|
+
const HOME_VIEW = S.view;
|
|
3476
|
+
const DRAWER_KEYS = ['session', 'prompt', 'add_model'];
|
|
3477
|
+
let ROUTE_APPLYING = false;
|
|
3478
|
+
function routeURL() {
|
|
3479
|
+
const q = new URLSearchParams();
|
|
3480
|
+
if (S.view === 'search' && S.searchTerm) q.set('q', S.searchTerm);
|
|
3481
|
+
if (S.drawerRoute) q.set(S.drawerRoute.k, S.drawerRoute.id);
|
|
3482
|
+
if (RECENT.open) q.set('recent', '1');
|
|
3483
|
+
const s = q.toString();
|
|
3484
|
+
return '/' + S.view + (s ? '?' + s : '');
|
|
3485
|
+
}
|
|
3486
|
+
function syncURL(push) {
|
|
3487
|
+
if (ROUTE_APPLYING) return;
|
|
3488
|
+
const url = routeURL();
|
|
3489
|
+
if (url !== location.pathname + location.search) history[push ? 'pushState' : 'replaceState'](null, '', url);
|
|
3490
|
+
}
|
|
3491
|
+
function readRoute() {
|
|
3492
|
+
const p = new URLSearchParams(location.search);
|
|
3493
|
+
let view = decodeURIComponent(location.pathname.replace(/^\/+|\/+$/g, '')) || p.get('view') || '';
|
|
3494
|
+
// The repriced Model switch view was removed; its links now open the measured comparison.
|
|
3495
|
+
if (view === 'modelswitch') view = 'compare';
|
|
3496
|
+
const known = NAV.some(([, items]) => items.some(([id]) => id === view)) || (view === 'search' && p.get('q'));
|
|
3497
|
+
const k = DRAWER_KEYS.find(k => p.get(k));
|
|
3498
|
+
return {view: known ? view : null, q: p.get('q') || '', drawer: k ? {k, id: p.get(k)} : null,
|
|
3499
|
+
recent: p.get('recent') === '1'};
|
|
3500
|
+
}
|
|
3501
|
+
function openDrawerRoute(d) {
|
|
3502
|
+
if (d.k === 'session') openSession(d.id);
|
|
3503
|
+
else if (d.k === 'prompt') openPrompt(d.id);
|
|
3504
|
+
else if (d.k === 'add_model') addFreeModel(d.id);
|
|
3505
|
+
}
|
|
3506
|
+
// Back/Forward: show what the URL says, without writing new history.
|
|
3507
|
+
addEventListener('popstate', async () => {
|
|
3508
|
+
const r = readRoute(), v = r.view || HOME_VIEW, have = S.drawerRoute;
|
|
3509
|
+
const viewChanged = v !== S.view || (v === 'search' && r.q !== S.searchTerm);
|
|
3510
|
+
const reopen = r.drawer && (viewChanged || !have || have.k !== r.drawer.k || have.id !== r.drawer.id);
|
|
3511
|
+
ROUTE_APPLYING = true;
|
|
3512
|
+
try {
|
|
3513
|
+
if (viewChanged || reopen || !r.drawer) closeDrawer();
|
|
3514
|
+
if (r.recent !== RECENT.open) toggleRecent(r.recent);
|
|
3515
|
+
S.view = v;
|
|
3516
|
+
if (v === 'search') S.searchTerm = r.q;
|
|
3517
|
+
S.drawerRoute = r.drawer; // keep it in the URL while the page re-renders
|
|
3518
|
+
} finally { ROUTE_APPLYING = false; }
|
|
3519
|
+
if (viewChanged) await render();
|
|
3520
|
+
if (reopen) openDrawerRoute(r.drawer);
|
|
3521
|
+
});
|
|
3487
3522
|
|
|
3488
3523
|
async function render() {
|
|
3489
3524
|
C.hideTip(); // a tooltip whose chart is about to be replaced must not linger
|
|
@@ -3494,7 +3529,8 @@ async function render() {
|
|
|
3494
3529
|
page.className = 'page';
|
|
3495
3530
|
page.id = 'page';
|
|
3496
3531
|
stale.replaceWith(page);
|
|
3497
|
-
|
|
3532
|
+
applyChrome();
|
|
3533
|
+
syncURL(false);
|
|
3498
3534
|
document.querySelectorAll('.nav a').forEach(a =>
|
|
3499
3535
|
a.classList.toggle('on', a.dataset.view === S.view));
|
|
3500
3536
|
$('#ttl').textContent = TITLES[S.view] || S.view;
|
|
@@ -3527,19 +3563,21 @@ async function render() {
|
|
|
3527
3563
|
stop act on sessions, so the list is grouped by session; running ones sit on
|
|
3528
3564
|
top. Close and Force kill stay on Running sessions, one click away from here. */
|
|
3529
3565
|
const RECENT = {open: false, offset: 0, limit: 30, q: '', open_ids: new Set()};
|
|
3530
|
-
// Recent means all time: keep
|
|
3566
|
+
// Recent means all time: keep project filters, drop the date range and thresholds.
|
|
3531
3567
|
const recentQs = () => { const p = new URLSearchParams(qs());
|
|
3532
3568
|
['start', 'end', 'min_cost', 'min_tokens'].forEach(k => p.delete(k)); return p.toString(); };
|
|
3533
3569
|
const fmtWhen = iso => iso ? `${ago(iso)} · ${esc(iso.slice(0, 16).replace('T', ' '))}` : '';
|
|
3534
3570
|
|
|
3535
3571
|
function toggleRecent(force) {
|
|
3572
|
+
const was = RECENT.open;
|
|
3536
3573
|
RECENT.open = force ?? !RECENT.open;
|
|
3574
|
+
if (was !== RECENT.open) syncURL(true);
|
|
3537
3575
|
document.querySelectorAll('.recent,.recent-scrim').forEach(e => e.remove());
|
|
3538
3576
|
$('#recent-btn')?.classList.toggle('on', RECENT.open);
|
|
3539
3577
|
if (!RECENT.open) return;
|
|
3540
3578
|
const scrim = h('<div class="recent-scrim"></div>');
|
|
3541
3579
|
const p = h(`<aside class="recent" aria-label="Recent sessions"><header>
|
|
3542
|
-
<h2>Recent</h2><span class="note">all time ·
|
|
3580
|
+
<h2>Recent</h2><span class="note">all time · project filters apply</span>
|
|
3543
3581
|
<span class="spacer"></span><button class="iconbtn" data-x>Close ${I('x')}</button></header>
|
|
3544
3582
|
<div class="recent-search"><span class="mag">${I('search')}</span>
|
|
3545
3583
|
<input id="recent-q" placeholder="Search your prompts…" value="${esc(RECENT.q)}"></div>
|
|
@@ -3559,7 +3597,7 @@ async function renderRecent() {
|
|
|
3559
3597
|
if (!box) return;
|
|
3560
3598
|
const more = RECENT.offset > 0;
|
|
3561
3599
|
const [live, res] = await Promise.all([
|
|
3562
|
-
more || RECENT.q ? null : fetch('/api/live
|
|
3600
|
+
more || RECENT.q ? null : fetch('/api/live')
|
|
3563
3601
|
.then(r => r.json()).catch(() => ({sessions: []})),
|
|
3564
3602
|
RECENT.q
|
|
3565
3603
|
? fetch(`/api/prompts?${recentQs()}&order=recent&limit=${RECENT.limit}&offset=${RECENT.offset}&q=${encodeURIComponent(RECENT.q)}`).then(r => r.json())
|
|
@@ -3590,7 +3628,7 @@ const recentLive = list => !list.length ? '' : `<div class="recent-hd">Running n
|
|
|
3590
3628
|
${x.est_cost_usd != null ? `<span>${fmtUSD(x.est_cost_usd)}</span>` : ''}</div>
|
|
3591
3629
|
<div class="live-actions">
|
|
3592
3630
|
${x.resume ? `<button class="act ghost" data-resume="${esc(x.resume)}" title="Copy: ${esc(x.resume)}">${I('copy')} Resume</button>` : ''}
|
|
3593
|
-
${x.signalable
|
|
3631
|
+
${x.signalable ? '<button class="act" data-la="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>' : ''}
|
|
3594
3632
|
${x.signalable ? `<button class="act warn" data-la="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : 'title="Stops the current turn, like pressing Esc"'}>${I('pause')} Stop</button>` : ''}
|
|
3595
3633
|
${x.session_id ? `<button class="act ghost" data-open-sess="${esc(x.session_id)}">Details</button>` : ''}
|
|
3596
3634
|
<span class="live-msg"></span></div></div>`).join('')}
|
|
@@ -3660,7 +3698,7 @@ function wireRecent(root) {
|
|
|
3660
3698
|
if (!armed(b)) return; // two clicks, like Running sessions
|
|
3661
3699
|
b.disabled = true;
|
|
3662
3700
|
try {
|
|
3663
|
-
const r = await sessionAction(x.pid, b.dataset.la
|
|
3701
|
+
const r = await sessionAction(x.pid, b.dataset.la);
|
|
3664
3702
|
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
3665
3703
|
msg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
|
|
3666
3704
|
msg.className = 'live-msg ' + (r.ok ? 'ok' : 'err');
|
|
@@ -3676,93 +3714,20 @@ function wireRecent(root) {
|
|
|
3676
3714
|
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
|
3677
3715
|
shell();
|
|
3678
3716
|
S.opts = await fetch('/api/options').then(r => r.json());
|
|
3679
|
-
S.filter.agents = defaultAgents();
|
|
3680
3717
|
applyRange('30d');
|
|
3681
|
-
//
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3718
|
+
// The URL picks the screen (and any open drawer), so a refresh or a link lands where it points.
|
|
3719
|
+
const route = readRoute();
|
|
3720
|
+
if (route.view) S.view = route.view;
|
|
3721
|
+
if (route.view === 'search') S.searchTerm = route.q;
|
|
3722
|
+
S.drawerRoute = route.drawer; // kept in the URL until the drawer opens below
|
|
3723
|
+
RECENT.open = route.recent;
|
|
3687
3724
|
await render();
|
|
3688
|
-
if (
|
|
3725
|
+
if (route.recent) toggleRecent(true);
|
|
3726
|
+
if (route.drawer) openDrawerRoute(route.drawer);
|
|
3727
|
+
if (!route.view) maybeFirstTour(); // arriving on a deep link is not a first visit
|
|
3689
3728
|
let t; addEventListener('resize', () => { clearTimeout(t); t = setTimeout(render, 220); });
|
|
3690
3729
|
})();
|
|
3691
3730
|
|
|
3692
|
-
/* ============================ agents ============================ */
|
|
3693
|
-
// Last choice if still valid, else the agent with the most usage (Claude on most machines).
|
|
3694
|
-
function defaultAgents() {
|
|
3695
|
-
const ag = S.opts?.agents || [];
|
|
3696
|
-
let saved = [];
|
|
3697
|
-
try { saved = JSON.parse(localStorage.getItem('finops-agents') || '[]'); } catch (_) {}
|
|
3698
|
-
saved = saved.filter(id => ag.some(a => a.id === id));
|
|
3699
|
-
if (saved.length) return saved;
|
|
3700
|
-
const top = [...ag].sort((a, b) => b.requests - a.requests)[0];
|
|
3701
|
-
return top ? [top.id] : [];
|
|
3702
|
-
}
|
|
3703
|
-
|
|
3704
|
-
VIEWS.agents = async (page) => {
|
|
3705
|
-
const d = await api('by_agent');
|
|
3706
|
-
const all = S.opts.agents || [];
|
|
3707
|
-
const colorOf = id => seriesVar(Math.max(all.findIndex(a => a.id === id), 0));
|
|
3708
|
-
const dataBadge = k => ({full: '<span class="badge">Full data</span>',
|
|
3709
|
-
tokens: '<span class="badge">Tokens + model</span>',
|
|
3710
|
-
activity: '<span class="badge rec">Activity only</span>'}[k] || '');
|
|
3711
|
-
// pivot daily rows into one row per day with a column per agent
|
|
3712
|
-
const byDay = new Map();
|
|
3713
|
-
for (const r of d.daily) {
|
|
3714
|
-
const row = byDay.get(r.day) || {day: r.day};
|
|
3715
|
-
row[r.agent] = S.metric === 'cost' ? r.cost : r.tokens;
|
|
3716
|
-
byDay.set(r.day, row);
|
|
3717
|
-
}
|
|
3718
|
-
const shown = d.agents.map(a => a.agent);
|
|
3719
|
-
page.innerHTML = `
|
|
3720
|
-
<div class="note">Showing: <b>${esc(shown.map(id => all.find(a => a.id === id)?.name || id).join(' + ') || 'nothing in range')}</b>.
|
|
3721
|
-
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.
|
|
3722
|
-
Every other page follows the same selection.</div>
|
|
3723
|
-
<div class="grid g4">${d.agents.map(a => kpi(a.name,
|
|
3724
|
-
// an agent with no price data leads with what it does have, never "0 tok"
|
|
3725
|
-
a.cost ? fmtUSD(a.cost) : a.tokens ? fmtNum(a.tokens) + ' tokens' : fmtInt(a.requests) + ' requests',
|
|
3726
|
-
`${fmtInt(a.sessions)} sessions · ${fmtInt(a.prompts)} prompts · ${fmtInt(a.active_days)} active days`,
|
|
3727
|
-
{badge: a.cost ? BADGE.estimated : ''})).join('')}</div>
|
|
3728
|
-
${card('Side by side', `<div id="ag-tbl"></div>`, {flush: 1, badge: BADGE.estimated,
|
|
3729
|
-
hint: 'Costs are estimated at each provider\'s API list price'})}
|
|
3730
|
-
${card(`Daily ${S.metric === 'cost' ? 'estimated cost' : 'tokens'} by agent`, `<div class="chart" id="ag-trend"></div><div id="ag-leg"></div>`,
|
|
3731
|
-
{actions: `<button class="chip ${S.metric === 'cost' ? 'on' : ''}" data-m="cost">Cost</button>
|
|
3732
|
-
<button class="chip ${S.metric !== 'cost' ? 'on' : ''}" data-m="tokens">Tokens</button>`})}
|
|
3733
|
-
${card('What each agent records on this machine', `<div class="stack">${all.map(a => `
|
|
3734
|
-
<div class="item"><div class="hd">${esc(a.name)} ${dataBadge(a.data)}<span class="spacer"></span>
|
|
3735
|
-
<span class="note">${a.requests ? `${esc(a.first)} → ${esc(a.last)} · ${fmtInt(a.sessions)} sessions` : 'installed, no usage recorded'}</span></div>
|
|
3736
|
-
<div class="dt">${esc(a.note)}</div></div>`).join('')}</div>`)}`;
|
|
3737
|
-
$('#ag-tbl', page).innerHTML = table([
|
|
3738
|
-
{h: 'Agent', f: r => `<b>${esc(r.name)}</b> ${dataBadge(r.data)}`},
|
|
3739
|
-
{h: 'Est. cost', num: 1, f: r => r.data === 'activity' ? '<span class="na">not priced</span>' : fmtUSD(r.cost)},
|
|
3740
|
-
{h: 'Tokens', num: 1, f: r => r.tokens ? fmtNum(r.tokens) : '<span class="na">not recorded</span>'},
|
|
3741
|
-
{h: 'Output', num: 1, f: r => r.output_tokens ? fmtNum(r.output_tokens) : '—'},
|
|
3742
|
-
{h: 'Cache reads', num: 1, f: r => r.cache_read_tokens ? fmtNum(r.cache_read_tokens) : '—'},
|
|
3743
|
-
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
3744
|
-
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
3745
|
-
{h: 'Tool calls', num: 1, f: r => fmtInt(r.tool_calls)},
|
|
3746
|
-
{h: 'Cost / prompt', num: 1, f: r => r.cost && r.prompts ? fmtUSD(r.cost / r.prompts) : '—'},
|
|
3747
|
-
{h: 'Models', trunc: 1, title: r => r.models, f: r => esc((r.models || '').split(',').map(modelName).join(', '))},
|
|
3748
|
-
], d.agents);
|
|
3749
|
-
const series = shown.map(id => ({key: id, label: all.find(a => a.id === id)?.name || id, color: colorOf(id)}));
|
|
3750
|
-
C.timeSeries($('#ag-trend', page), {rows: [...byDay.values()], x: 'day', series, type: 'bar',
|
|
3751
|
-
fmt: S.metric === 'cost' ? fmtUSD : fmtNum, height: 230, xLabel: shortDay});
|
|
3752
|
-
C.legend($('#ag-leg', page), series.map(s => ({label: s.label, color: s.color})));
|
|
3753
|
-
page.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { S.metric = b.dataset.m; render(); });
|
|
3754
|
-
addChart(page, 'Share by agent', el => {
|
|
3755
|
-
el.innerHTML = '<div class="grid g2"><div><div class="note">Estimated cost</div><div id="ag-d1"></div></div>' +
|
|
3756
|
-
'<div><div class="note">Tokens</div><div id="ag-d2"></div></div></div>';
|
|
3757
|
-
C.donut($('#ag-d1', el), {rows: d.agents.filter(a => a.cost > 0), label: a => a.name, value: a => a.cost,
|
|
3758
|
-
color: a => colorOf(a.agent), size: 170, centerValue: fmtUSD(d.agents.reduce((x, a) => x + a.cost, 0)), centerLabel: 'estimated'});
|
|
3759
|
-
C.donut($('#ag-d2', el), {rows: d.agents.filter(a => a.tokens > 0), label: a => a.name, value: a => a.tokens, fmt: fmtNum,
|
|
3760
|
-
color: a => colorOf(a.agent), size: 170, centerValue: fmtNum(d.agents.reduce((x, a) => x + (a.tokens || 0), 0)), centerLabel: 'tokens'});
|
|
3761
|
-
const lg = document.createElement('div'); el.appendChild(lg);
|
|
3762
|
-
C.legend(lg, d.agents.map(a => ({label: `${a.name} · ${a.cost ? fmtUSD(a.cost) + ' · ' : ''}${fmtNum(a.tokens)} tok`, color: colorOf(a.agent)})));
|
|
3763
|
-
}, {badge: BADGE.estimated});
|
|
3764
|
-
};
|
|
3765
|
-
|
|
3766
3731
|
/* ============================ machine actions ============================ */
|
|
3767
3732
|
async function doAction(path, body = {}) {
|
|
3768
3733
|
const r = await fetch(`/api/do/${path}`, {method: 'POST',
|
|
@@ -3796,7 +3761,7 @@ async function syncLabel() {
|
|
|
3796
3761
|
const b = $('#sync'); if (!b) return;
|
|
3797
3762
|
const d = await fetch('/api/sync').then(r => r.json()).catch(() => ({}));
|
|
3798
3763
|
if (d.job && d.job.state === 'running') return runSync();
|
|
3799
|
-
b.title = `Last synced ${ago(d.built_at)}. Re-read
|
|
3764
|
+
b.title = `Last synced ${ago(d.built_at)}. Re-read Claude Code's transcripts from disk into the warehouse`;
|
|
3800
3765
|
b.classList.remove('busy');
|
|
3801
3766
|
b.innerHTML = `${ICON.sync}Sync <span class="ago">· ${ago(d.built_at)}</span>`;
|
|
3802
3767
|
}
|
|
@@ -3865,6 +3830,7 @@ async function testFreeModel(id, host, btn) {
|
|
|
3865
3830
|
// Show exactly what will happen, collect anything needed, run only after the user confirms.
|
|
3866
3831
|
async function addFreeModel(id) {
|
|
3867
3832
|
const d = drawer('Add free model', '<div class="loading">Checking prerequisites…</div>');
|
|
3833
|
+
setDrawerRoute('add_model', id);
|
|
3868
3834
|
const c = d.querySelector('.content');
|
|
3869
3835
|
const plan = await fetch(`/api/free_models/${id}/plan`).then(r => r.json());
|
|
3870
3836
|
if (plan.blocked) { c.innerHTML = `<div class="item sev-high"><div class="dt" style="white-space:pre-wrap">${esc(plan.blocked)}</div></div>`; return; }
|
|
@@ -3918,7 +3884,7 @@ VIEWS.compare = async (page) => {
|
|
|
3918
3884
|
? (r.requests ? `${fmtInt(r.requests)} req · ${fmtUSD(r.cost)}` : '<span class="na">not used</span>')
|
|
3919
3885
|
: !r.fits ? `${I('xCircle')} needs ${r.min_ram_gb} GB RAM` : r.installed ? `${I('checkCircle')} <code>${esc(r.command)}</code>` : `<a data-go="freemodels">${I('plus')} Add</a>`;
|
|
3920
3886
|
page.innerHTML = `
|
|
3921
|
-
${card(hasFree ? 'Claude vs free models' :
|
|
3887
|
+
${card(hasFree ? 'Claude vs free models' : 'Claude models side by side', `<div id="cmp"></div>`, {flush: 1, badge: BADGE.recommendation,
|
|
3922
3888
|
hint: hasFree ? `This machine: ${esc(d.os)}, ${d.ram_gb} GB RAM` : 'List prices, your usage in range', footer: esc(d.note)})}
|
|
3923
3889
|
${!hasFree ? '' : `<div class="grid g3">
|
|
3924
3890
|
${card('Use Claude for', `<div class="dt">Multi-file changes, debugging, anything agentic or long-running.
|
|
@@ -4252,16 +4218,13 @@ VIEWS.cloud = async (page) => {
|
|
|
4252
4218
|
`${fmtNum(t.local_claude_tokens)} tokens from local transcripts`, {badge: BADGE.estimated})}
|
|
4253
4219
|
${kpi('Gap', t.billed_claude_cost ? fmtUSD(gap) : null,
|
|
4254
4220
|
'Other machines, other members, or work off this machine')}
|
|
4255
|
-
${kpi('Cursor team spend', t.cursor_spend == null ? null : fmtUSD(t.cursor_spend),
|
|
4256
|
-
`${fmtInt(t.cursor_members)} members`, {badge: BADGE.actual})}
|
|
4257
4221
|
</div>
|
|
4258
4222
|
${card('Set up the APIs', `<div class="stack">${setup}</div>`,
|
|
4259
4223
|
{hint: 'Stored keys stay on this machine (0600) and are never shown again'})}
|
|
4260
4224
|
${card('Billed vs local, per day', `<div id="cl-day"></div>`, {flush: 1, badge: BADGE.actual,
|
|
4261
|
-
hint: 'Claude Code: what
|
|
4225
|
+
hint: 'Claude Code: what Anthropic billed against what this machine recorded'})}
|
|
4262
4226
|
${card('Claude Code users (org-wide)', `<div id="cl-users"></div>`, {flush: 1, badge: BADGE.actual,
|
|
4263
4227
|
hint: 'Per user per day, aggregated over the range — includes Pro/Max subscription users'})}
|
|
4264
|
-
${card('Cursor members', `<div id="cl-cur"></div>`, {flush: 1, badge: BADGE.actual})}
|
|
4265
4228
|
${card('Billed API cost by line item', `<div id="cl-api"></div>`, {flush: 1, badge: BADGE.actual,
|
|
4266
4229
|
hint: 'From the Anthropic cost report: API spend only, not subscription plans'})}`;
|
|
4267
4230
|
page.querySelectorAll('[data-go-settings]').forEach(a => a.onclick = e => { e.preventDefault(); go('settings'); });
|
|
@@ -4284,13 +4247,6 @@ VIEWS.cloud = async (page) => {
|
|
|
4284
4247
|
{h: 'PRs', num: 1, f: r => fmtInt(r.prs)},
|
|
4285
4248
|
{h: 'Where', trunc: 1, f: r => esc(r.terminals.join(', ') || '—')},
|
|
4286
4249
|
], d.users);
|
|
4287
|
-
$('#cl-cur', page).innerHTML = table([
|
|
4288
|
-
{h: 'Member', f: r => esc(r.member)},
|
|
4289
|
-
{h: 'Spend', num: 1, f: r => r.spend_usd == null ? '—' : fmtUSD(r.spend_usd)},
|
|
4290
|
-
{h: 'Active days', num: 1, f: r => fmtInt(r.days)},
|
|
4291
|
-
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
4292
|
-
{h: 'Lines +/-', num: 1, f: r => `${fmtInt(r.lines_added)} / ${fmtInt(r.lines_removed)}`},
|
|
4293
|
-
], d.cursor_members);
|
|
4294
4250
|
$('#cl-api', page).innerHTML = table([
|
|
4295
4251
|
{h: 'Line item', f: r => esc(r.description)},
|
|
4296
4252
|
{h: 'Billed $', num: 1, f: r => fmtUSD(r.cost_usd)},
|
|
@@ -4316,21 +4272,18 @@ VIEWS.cloud = async (page) => {
|
|
|
4316
4272
|
// Targets: a CSS selector, 'kpis' (the first KPI row), or 'cardN' (Nth card on the page).
|
|
4317
4273
|
const TOUR_DONE = 'finops-tour-done';
|
|
4318
4274
|
const TOUR_WELCOME = [
|
|
4319
|
-
{el: '.brand', t: 'Welcome to FinOps', see: 'One dashboard for what
|
|
4275
|
+
{el: '.brand', t: 'Welcome to FinOps', see: 'One dashboard for what Claude Code spends in tokens and money.',
|
|
4320
4276
|
get: 'Where the tokens go, why, and what to change to spend less. Everything is read from files on this machine.',
|
|
4321
4277
|
act: 'Use Next and Previous to move through the tour, or Skip to close it.'},
|
|
4322
4278
|
{el: '.nav', t: 'Dashboards', see: 'Every dashboard, grouped: Command center, Usage, Drill-down, Optimize, Plan.',
|
|
4323
4279
|
get: 'Start with <b>Why so many tokens?</b> or <b>What should I do?</b> for a ranked to-do list.',
|
|
4324
4280
|
act: 'Click any item to open that dashboard. A number next to an item means something needs attention.'},
|
|
4325
|
-
{el: '[data-agent]', t: 'Pick the agent', see: 'Agent chips: one agent, several, or All.',
|
|
4326
|
-
get: 'Every dashboard is filtered to the agents you pick. Dashboards that need data an agent doesn\'t record are hidden.',
|
|
4327
|
-
act: 'Click <b>Codex</b> or <b>All</b> to switch.'},
|
|
4328
4281
|
{el: '[data-range]', t: 'Date range & filters', see: 'Range chips, plus Model, Project, Category and Threshold filters.',
|
|
4329
4282
|
get: 'Narrow any dashboard to a period, a model or a project.',
|
|
4330
4283
|
act: `Try <b>7 days</b>, then open <b>Project ${I('chevDown')}</b> to focus on one repo.`},
|
|
4331
4284
|
{el: '#gsearch', t: 'Search', see: 'Search across prompts, sessions, models and dates.',
|
|
4332
4285
|
get: 'Jump straight to the prompt or session you remember.', act: 'Type a word from a recent prompt and press Enter.'},
|
|
4333
|
-
{el: '#sync', t: 'Keep it current', see: 'Sync re-reads
|
|
4286
|
+
{el: '#sync', t: 'Keep it current', see: 'Sync re-reads Claude Code\'s local transcripts.',
|
|
4334
4287
|
get: 'Fresh numbers after you\'ve been working.', act: `Click <b>${I('sync')} Sync</b> whenever the numbers look stale.`},
|
|
4335
4288
|
{el: '#tour-btn', t: 'Tour any dashboard', see: 'This button starts a tour of the dashboard you\'re on.',
|
|
4336
4289
|
get: 'A short explanation of each section: what it shows, what you get, what you can do.',
|
|
@@ -4357,15 +4310,10 @@ const TOURS = {
|
|
|
4357
4310
|
{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.'},
|
|
4358
4311
|
{el: 'card:All recommendations', t: 'All recommendations', see: 'Every recommendation, ranked by spend involved, 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.'},
|
|
4359
4312
|
{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.'}],
|
|
4360
|
-
agents: [
|
|
4361
|
-
{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.'},
|
|
4362
|
-
{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.'},
|
|
4363
|
-
{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.'},
|
|
4364
|
-
{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".'}],
|
|
4365
4313
|
live: [
|
|
4366
4314
|
{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.'},
|
|
4367
|
-
{el: '#live-refresh', t: 'Refresh & the rules', see: 'The note explains what Interrupt, Close and Force kill do
|
|
4368
|
-
{el: '.item[data-i]', t: 'A session', see: '
|
|
4315
|
+
{el: '#live-refresh', t: 'Refresh & the rules', see: 'The note explains what Interrupt, Compact, Close and Force kill do.', get: 'Confidence before you stop something.', act: `Click <b>${I('reload')} Refresh</b> for the latest state.`},
|
|
4316
|
+
{el: '.item[data-i]', t: 'A session', see: 'Project, model, 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, <b>Hand over</b> it to a fresh session, see its <b>Prompts</b> or <b>Rename</b> it.'}],
|
|
4369
4317
|
scorecard: [
|
|
4370
4318
|
{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.'},
|
|
4371
4319
|
{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.'},
|
|
@@ -4426,9 +4374,9 @@ const TOURS = {
|
|
|
4426
4374
|
{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.'},
|
|
4427
4375
|
{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.'},
|
|
4428
4376
|
{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.'},
|
|
4429
|
-
{el: 'card:Projects:', t: 'Project config health', see: 'Per project: whether it has memory,
|
|
4377
|
+
{el: 'card:Projects:', t: 'Project config health', see: 'Per project: whether it has memory, a CLAUDE.md, hooks and the rest.', get: 'The config gaps that make every session start from zero.', act: 'Fix the projects flagged red.'},
|
|
4430
4378
|
{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.'},
|
|
4431
|
-
{el: 'card:Global config', t: 'Global config', see: 'Your machine-wide
|
|
4379
|
+
{el: 'card:Global config', t: 'Global config', see: 'Your machine-wide Claude Code 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.'},
|
|
4432
4380
|
{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.'}],
|
|
4433
4381
|
attribution: [
|
|
4434
4382
|
{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.'},
|
|
@@ -4492,14 +4440,13 @@ const TOURS = {
|
|
|
4492
4440
|
{el: 'card:Set your limits', t: 'Suggestions and shorthand', see: 'Chips under each amount suggest values from your own last 30 days and session sizes; amounts accept 20M, 500k or $3,000.', get: 'A sensible budget in one click, and a clear message when a value will not work.', act: 'Click a suggestion, adjust it, and Save.'},
|
|
4493
4441
|
{el: 'card:Set your limits', t: 'Session guard', see: 'A token budget for each Claude Code session, warn percentages, what happens after you approve, per-project overrides, and the Live warnings box (optional).', get: 'A warning while a session grows, and a pause for your approval once it reaches its budget.', act: 'Set a budget, save, then, if you want live warnings, <b>Install</b> in the Live warnings box. It is optional. It applies to new sessions.'}],
|
|
4494
4442
|
settings: [
|
|
4495
|
-
{el: 'card:API keys', t: 'API keys', see: '
|
|
4443
|
+
{el: 'card:API keys', t: 'API keys', see: 'An optional key for the Anthropic Admin API.', get: 'The Billed vs local page, without the terminal.', act: 'Paste a key and press Save key. It is never shown again, only its last four characters.'}],
|
|
4496
4444
|
cloud: [
|
|
4497
|
-
{el: 'kpis', t: 'Billed vs local', see: 'What
|
|
4498
|
-
{el: 'card:Set up the APIs', t: 'Set up the APIs', see: '
|
|
4445
|
+
{el: 'kpis', t: 'Billed vs local', see: 'What Anthropic 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>${I('cloudDown')} Refresh from APIs</b> to fetch — this is the only page that goes online.`},
|
|
4446
|
+
{el: 'card:Set up the APIs', t: 'Set up the APIs', see: 'Whether an Anthropic Admin key was found, and how to get one.', get: 'Org-wide Claude Code usage per user.', act: 'Add a key on the Settings page, or run claude-finops --set-key.'},
|
|
4499
4447
|
{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.'},
|
|
4500
4448
|
{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.'},
|
|
4501
|
-
{el: 'card:
|
|
4502
|
-
{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.'}],
|
|
4449
|
+
{el: 'card:Billed API cost by line item', t: 'Billed line items', see: 'Anthropic\'s own line items for the period.', get: 'The invoice view, straight from the API.', act: 'Reconcile this with your finance numbers.'}],
|
|
4503
4450
|
search: [
|
|
4504
4451
|
{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.'},
|
|
4505
4452
|
{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.'},
|