claude-finops 0.11.0 → 0.12.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 +4 -3
- package/package.json +1 -1
- package/web/app.js +152 -34
- package/web/styles.css +112 -8
package/README.md
CHANGED
|
@@ -318,7 +318,7 @@ agents listed under Multi-agent.
|
|
|
318
318
|
server binds to `127.0.0.1` only, but treat the database and any export you
|
|
319
319
|
generate as sensitive.
|
|
320
320
|
|
|
321
|
-
**Hide prompts** in the top bar hides prompt text and session titles as dots,
|
|
321
|
+
**Hide prompts** (the eye icon in the top bar) hides prompt text and session titles as dots,
|
|
322
322
|
for when you share your screen. Session titles fall back to their short id. The
|
|
323
323
|
setting is remembered in that browser. It only changes what the page shows: the
|
|
324
324
|
database and exports still contain the full text.
|
|
@@ -364,7 +364,8 @@ files on Windows.
|
|
|
364
364
|
|
|
365
365
|
## Actions (these change your machine, and only when you click)
|
|
366
366
|
|
|
367
|
-
-
|
|
367
|
+
- **Sync** (top bar, database icon) re-reads `~/.claude` and swaps in fresh data without restarting.
|
|
368
|
+
The circular-arrow **Reload** next to it only redraws the page from the data already loaded.
|
|
368
369
|
- **Free models** adds a launcher such as `claude-qwen` in `~/.local/bin` that runs Claude Code
|
|
369
370
|
on a free model (Ollama locally, or OpenRouter). It lists every step first, and asks before it
|
|
370
371
|
installs Ollama or downloads a model. Your normal `claude` is unchanged. Edit the list in
|
|
@@ -378,7 +379,7 @@ files on Windows.
|
|
|
378
379
|
- **Hand over / split** (Running sessions) writes a brief of the session to
|
|
379
380
|
`~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals, one per sub-task if
|
|
380
381
|
you list them; it can close the old session (resumable).
|
|
381
|
-
-
|
|
382
|
+
- **Recent** (clock icon in the top bar, or press `R`) opens a side panel on any page: running sessions first,
|
|
382
383
|
with Resume, Compact and Stop (Interrupt), then past sessions newest first. Click a session to
|
|
383
384
|
list its prompts, and click a prompt for its full details. Search matches your prompt text.
|
|
384
385
|
It covers all dates but keeps the agent and project filters. Close and Force kill stay on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-finops": "bin/claude-finops.js"
|
package/web/app.js
CHANGED
|
@@ -91,7 +91,7 @@ const vsYesterday = o => {
|
|
|
91
91
|
};
|
|
92
92
|
function kpi(label, value, detail, opts = {}) {
|
|
93
93
|
const na = value == null;
|
|
94
|
-
return `<div class="kpi${na ? ' na' : ''}">
|
|
94
|
+
return `<div class="kpi${na ? ' na' : ''}${opts.tone && !na ? ' tone-' + opts.tone : ''}"${opts.title ? ` title="${esc(opts.title)}"` : ''}>
|
|
95
95
|
<div class="l">${esc(label)}${opts.badge ? ' ' + opts.badge : ''}</div>
|
|
96
96
|
<div class="v${opts.small ? ' sm' : ''}">${na ? esc(naLabel()) : value}</div>
|
|
97
97
|
${detail ? `<div class="d">${detail}</div>` : ''}</div>`;
|
|
@@ -161,6 +161,31 @@ const NAV = [
|
|
|
161
161
|
]],
|
|
162
162
|
];
|
|
163
163
|
|
|
164
|
+
// Toolbar icons: 24px line icons drawn in currentColor, so they follow the theme.
|
|
165
|
+
const toolIcon = d => `<svg viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
|
|
166
|
+
const ICON = {
|
|
167
|
+
tour: toolIcon('<circle cx="12" cy="12" r="10"/><path d="m16.2 7.8-2.1 6.3-6.3 2.1 2.1-6.3z"/>'),
|
|
168
|
+
recent: toolIcon('<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>'),
|
|
169
|
+
eye: toolIcon('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
|
|
170
|
+
eyeOff: toolIcon('<path d="M10.7 5.1A10.4 10.4 0 0 1 12 5c6.4 0 10 7 10 7a18 18 0 0 1-2.2 3.2"/><path d="M6.6 6.6A17.6 17.6 0 0 0 2 12s3.6 7 10 7a9.7 9.7 0 0 0 5.4-1.6"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/><path d="m2 2 20 20"/>'),
|
|
171
|
+
moon: toolIcon('<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>'),
|
|
172
|
+
sun: toolIcon('<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>'),
|
|
173
|
+
reload: toolIcon('<path d="M21 12a9 9 0 1 1-2.6-6.4L21 8"/><path d="M21 3v5h-5"/>'),
|
|
174
|
+
// Sync pulls transcripts into the warehouse: a database with an arrow going in.
|
|
175
|
+
sync: toolIcon('<ellipse cx="11" cy="5" rx="7" ry="3"/><path d="M4 5v6c0 1.66 3.13 3 7 3"/><path d="M4 11v6c0 1.66 3.13 3 7 3"/><path d="M18 5v5"/><path d="M19 13v8"/><path d="m16 18 3 3 3-3"/>'),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// The theme button shows the theme it switches to: a moon in light mode, a sun in dark.
|
|
179
|
+
function themeLabel() {
|
|
180
|
+
const b = $('#theme'); if (!b) return;
|
|
181
|
+
const cur = document.documentElement.getAttribute('data-theme')
|
|
182
|
+
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
183
|
+
const to = cur === 'dark' ? 'light' : 'dark';
|
|
184
|
+
b.innerHTML = to === 'dark' ? ICON.moon : ICON.sun;
|
|
185
|
+
b.title = `Switch to ${to} theme`;
|
|
186
|
+
b.setAttribute('aria-label', b.title);
|
|
187
|
+
}
|
|
188
|
+
|
|
164
189
|
function shell() {
|
|
165
190
|
document.body.innerHTML = `<div class="app">
|
|
166
191
|
<aside class="sidebar">
|
|
@@ -175,18 +200,21 @@ function shell() {
|
|
|
175
200
|
<div class="main">
|
|
176
201
|
<header class="topbar">
|
|
177
202
|
<div class="r1">
|
|
178
|
-
<h1 id="ttl">Executive overview</h1>
|
|
179
|
-
|
|
203
|
+
<div class="ttl-block"><h1 id="ttl">Executive overview</h1>
|
|
204
|
+
<div class="crumbs" id="crumbs"></div></div>
|
|
205
|
+
<div class="scope" id="scope" aria-label="Totals for the current filters"></div>
|
|
180
206
|
<span class="spacer"></span>
|
|
181
207
|
<div class="search"><span class="mag">⌕</span>
|
|
182
|
-
<input id="gsearch" placeholder="Search prompts, sessions, models
|
|
208
|
+
<input id="gsearch" placeholder="Search prompts, sessions, models…"></div>
|
|
183
209
|
<button class="iconbtn upd" id="upd" hidden></button>
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
210
|
+
<div class="tools">
|
|
211
|
+
<button class="iconbtn" id="tour-btn" title="Walk through this dashboard">${ICON.tour}Tour</button>
|
|
212
|
+
<button class="iconbtn sq" id="recent-btn" title="Recent sessions and prompts, from any page (R)" aria-label="Recent">${ICON.recent}</button>
|
|
213
|
+
<button class="iconbtn sq" id="mask"></button>
|
|
214
|
+
<button class="iconbtn sq" id="theme"></button>
|
|
215
|
+
<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>
|
|
216
|
+
</div>
|
|
217
|
+
<button class="act sync" id="sync" title="Re-read every agent's transcripts from disk into the warehouse">${ICON.sync}Sync</button>
|
|
190
218
|
</div>
|
|
191
219
|
<div class="filters" id="filters"></div>
|
|
192
220
|
</header>
|
|
@@ -202,8 +230,10 @@ function shell() {
|
|
|
202
230
|
: (matchMedia('(prefers-color-scheme: dark)').matches ? 'light' : 'dark');
|
|
203
231
|
document.documentElement.setAttribute('data-theme', next);
|
|
204
232
|
localStorage.setItem('finops-theme', next);
|
|
233
|
+
themeLabel();
|
|
205
234
|
render();
|
|
206
235
|
};
|
|
236
|
+
themeLabel();
|
|
207
237
|
$('#refresh').onclick = () => { bust(); render(); };
|
|
208
238
|
maskLabel();
|
|
209
239
|
$('#mask').onclick = () => {
|
|
@@ -222,11 +252,73 @@ function shell() {
|
|
|
222
252
|
else if (S.view === 'search') go('overview'); }, 260); };
|
|
223
253
|
}
|
|
224
254
|
|
|
255
|
+
// The page loader: a skeleton in the shape of a dashboard (KPI row, two charts, a table)
|
|
256
|
+
// so the layout doesn't jump when data lands, plus a status line that says what is
|
|
257
|
+
// happening and, on a slow query, how long it has taken.
|
|
258
|
+
const LOADER_STEPS = ['Reading the warehouse', 'Pricing every request', 'Adding up tokens and cache',
|
|
259
|
+
'Grouping by session and project', 'Drawing the charts'];
|
|
260
|
+
const LOADER_TIPS = ['Press R anywhere to open Recent sessions.',
|
|
261
|
+
'Click any row in a table to open its details.',
|
|
262
|
+
'The eye button hides prompt text before you share your screen.',
|
|
263
|
+
'"Actual" numbers come from transcripts; "Estimated" ones use list prices.',
|
|
264
|
+
'Tour walks you through whichever dashboard you are on.'];
|
|
265
|
+
function pageLoader() {
|
|
266
|
+
const bone = (w, h = 10) => `<i class="bone" style="width:${w};height:${h}px"></i>`;
|
|
267
|
+
const bars = [38, 62, 45, 80, 56, 90, 70, 48, 66, 84, 58, 74]
|
|
268
|
+
.map((v, i) => `<i style="height:${v}%;animation-delay:${i * 70}ms"></i>`).join('');
|
|
269
|
+
return `<div class="pl" role="status" aria-live="polite">
|
|
270
|
+
<div class="pl-status"><span class="pl-mark"><i></i><i></i><i></i></span>
|
|
271
|
+
<span class="pl-msg">${LOADER_STEPS[0]}…</span><span class="pl-time"></span></div>
|
|
272
|
+
<div class="grid g4">${Array.from({length: 4}, () =>
|
|
273
|
+
`<div class="kpi skel">${bone('55%', 8)}${bone('45%', 22)}${bone('70%', 8)}</div>`).join('')}</div>
|
|
274
|
+
<div class="grid g2">
|
|
275
|
+
<div class="card skel"><div class="body">${bone('35%', 10)}<div class="pl-bars">${bars}</div></div></div>
|
|
276
|
+
<div class="card skel"><div class="body">${bone('30%', 10)}<div class="pl-bars alt">${bars}</div></div></div>
|
|
277
|
+
</div>
|
|
278
|
+
<div class="card skel"><div class="body">${bone('25%', 10)}
|
|
279
|
+
${Array.from({length: 4}, (_, i) => `<div class="pl-row">${bone(`${60 - i * 8}%`)}${bone('12%')}${bone('9%')}</div>`).join('')}
|
|
280
|
+
</div></div>
|
|
281
|
+
<div class="pl-tip">Tip: ${esc(LOADER_TIPS[Math.floor(Math.random() * LOADER_TIPS.length)])}</div>
|
|
282
|
+
</div>`;
|
|
283
|
+
}
|
|
284
|
+
function loaderTicker(page) {
|
|
285
|
+
const t0 = Date.now();
|
|
286
|
+
let i = 0;
|
|
287
|
+
const id = setInterval(() => {
|
|
288
|
+
const msg = page.querySelector('.pl-msg');
|
|
289
|
+
if (!msg || !page.isConnected) return clearInterval(id); // the view has rendered
|
|
290
|
+
i = Math.min(i + 1, LOADER_STEPS.length - 1);
|
|
291
|
+
msg.textContent = LOADER_STEPS[i] + '…';
|
|
292
|
+
const s = Math.round((Date.now() - t0) / 1000);
|
|
293
|
+
if (s >= 3) page.querySelector('.pl-time').textContent = `${s}s · large ranges take a little longer`;
|
|
294
|
+
}, 1100);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Headline totals for whatever the filters select, on every page, so a number
|
|
298
|
+
// on the page can always be read against the whole it came from.
|
|
299
|
+
async function scopeStrip() {
|
|
300
|
+
const el = $('#scope'); if (!el) return;
|
|
301
|
+
const key = qs();
|
|
302
|
+
el.dataset.key = key;
|
|
303
|
+
let o;
|
|
304
|
+
try { o = await api('overview'); } catch { el.innerHTML = ''; return; }
|
|
305
|
+
if (el.dataset.key !== key) return; // the filters moved on while this loaded
|
|
306
|
+
const cell = (l, v, t) => `<div class="sc"${t ? ` title="${esc(t)}"` : ''}><span>${l}</span><b>${v}</b></div>`;
|
|
307
|
+
el.innerHTML =
|
|
308
|
+
cell('Est. spend', fmtUSD(o.est_cost_usd), 'Estimated at list prices for the current filters') +
|
|
309
|
+
cell('Sessions', fmtInt(o.sessions)) +
|
|
310
|
+
cell('Prompts', fmtInt(o.prompts)) +
|
|
311
|
+
cell('Requests', fmtNum(o.requests), fmtInt(o.requests) + ' model requests') +
|
|
312
|
+
cell('Active days', fmtInt(o.active_days));
|
|
313
|
+
}
|
|
314
|
+
|
|
225
315
|
function maskLabel() {
|
|
226
316
|
const b = $('#mask');
|
|
227
|
-
|
|
317
|
+
// The icon shows the current state: an open eye while prompts are visible.
|
|
318
|
+
b.innerHTML = MASKED ? ICON.eyeOff : ICON.eye;
|
|
228
319
|
b.title = MASKED ? 'Prompt text is hidden. Click to show it'
|
|
229
320
|
: 'Hide prompt text and session titles, e.g. before sharing your screen';
|
|
321
|
+
b.setAttribute('aria-label', MASKED ? 'Show prompts' : 'Hide prompts');
|
|
230
322
|
b.setAttribute('aria-pressed', MASKED);
|
|
231
323
|
b.classList.toggle('on', MASKED);
|
|
232
324
|
}
|
|
@@ -275,14 +367,13 @@ function filterBar() {
|
|
|
275
367
|
const nSel = a => a.length ? `<span class="n">${a.length}</span>` : '';
|
|
276
368
|
const ag = o.agents || [];
|
|
277
369
|
$('#filters').innerHTML = `
|
|
278
|
-
${ag.length > 1 ? `<
|
|
279
|
-
${ag.map(a => `<button class="
|
|
280
|
-
title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}">${esc(a.name)}${a.requests ? '' : ' ·'}</button>`).join('')}
|
|
281
|
-
<button class="
|
|
282
|
-
|
|
283
|
-
${RANGES.map(([k, l]) => `<button class="
|
|
284
|
-
data-range="${k}">${l}</button>`).join('')}
|
|
285
|
-
<span class="divider"></span>
|
|
370
|
+
${ag.length > 1 ? `<div class="seg" role="group" aria-label="Agent"><span class="seg-l">Agent</span>
|
|
371
|
+
${ag.map(a => `<button class="${f.agents.includes(a.id) ? 'on' : ''}" data-agent="${esc(a.id)}"
|
|
372
|
+
title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}. Cmd/Shift-click to combine">${esc(a.name)}${a.requests ? '' : ' ·'}</button>`).join('')}
|
|
373
|
+
<button class="${f.agents.length === ag.length ? 'on' : ''}" data-agent="*" title="Every agent together">All</button></div>` : ''}
|
|
374
|
+
<div class="seg" role="group" aria-label="Date range"><span class="seg-l">Range</span>
|
|
375
|
+
${RANGES.map(([k, l]) => `<button class="${S.range === k ? 'on' : ''}"
|
|
376
|
+
data-range="${k}">${l}</button>`).join('')}</div>
|
|
286
377
|
<div class="chipsel"><button class="chip ${f.models.length ? 'on' : ''}" data-pop="models">
|
|
287
378
|
Model ${nSel(f.models)} ▾</button></div>
|
|
288
379
|
<div class="chipsel"><button class="chip ${f.projects.length ? 'on' : ''}" data-pop="projects">
|
|
@@ -291,13 +382,14 @@ function filterBar() {
|
|
|
291
382
|
Category ${nSel(f.categories)} ▾</button></div>
|
|
292
383
|
<div class="chipsel"><button class="chip ${(f.min_cost || f.min_tokens) ? 'on' : ''}"
|
|
293
384
|
data-pop="thresholds">Thresholds ▾</button></div>
|
|
294
|
-
<button class="chip ${f.include_sandbox ? '' : 'on'}" id="sbx"
|
|
295
|
-
${f.include_sandbox ? '
|
|
385
|
+
<button class="chip toggle ${f.include_sandbox ? '' : 'on'}" id="sbx" role="switch" aria-checked="${!f.include_sandbox}"
|
|
386
|
+
title="Sandbox agents are throwaway projects. ${f.include_sandbox ? 'They are included; click to leave them out' : 'They are left out; click to include them'}">
|
|
387
|
+
<i></i>Hide sandbox</button>
|
|
296
388
|
${(f.models.length || f.projects.length || f.categories.length || f.min_cost || f.min_tokens)
|
|
297
|
-
? '<button class="chip" id="clr">Clear ✕</button>' : ''}
|
|
389
|
+
? '<button class="chip clr" id="clr">Clear filters ✕</button>' : ''}
|
|
298
390
|
<span class="spacer"></span>
|
|
299
|
-
<span class="
|
|
300
|
-
${esc(o.meta.transcript_files)} transcripts ·
|
|
391
|
+
<span class="coverage" title="Data in the warehouse · last rebuilt ${esc((o.meta.built_at||'').slice(0,16).replace('T',' '))}">
|
|
392
|
+
${esc(o.meta.transcript_files)} transcripts · since ${esc(o.date_range.first)}</span>`;
|
|
301
393
|
|
|
302
394
|
$('#filters').querySelectorAll('[data-range]').forEach(b =>
|
|
303
395
|
b.onclick = () => { if (b.dataset.range === 'custom') return openCustom(b);
|
|
@@ -1450,6 +1542,12 @@ VIEWS.hygiene = async (page) => {
|
|
|
1450
1542
|
const T = hy.thresholds.map(String), kT = String(hy.rank_threshold);
|
|
1451
1543
|
const K = t => `${Math.round(+t / 1000)}K`;
|
|
1452
1544
|
const ab = t => hy.above[t];
|
|
1545
|
+
// Colour grade by how much of the spend went to re-sending a large prefix. The bands are
|
|
1546
|
+
// a reading aid, not a verdict: long agentic work legitimately runs at high context.
|
|
1547
|
+
const grade = (v, bands) => v == null ? null
|
|
1548
|
+
: v < bands[0] ? 'good' : v < bands[1] ? 'warning' : v < bands[2] ? 'serious' : 'critical';
|
|
1549
|
+
const SHARE = [25, 50, 75], FIT = [5, 15, 30];
|
|
1550
|
+
const bandTip = b => `Green under ${b[0]}%, amber ${b[0]}–${b[1]}%, orange ${b[1]}–${b[2]}%, red ${b[2]}% and up`;
|
|
1453
1551
|
page.innerHTML = `
|
|
1454
1552
|
<div class="note" style="margin:0 0 10px">Everything on this page is observed from your transcripts.
|
|
1455
1553
|
It shows where spend sat while a large prefix was being re-sent on every turn. It does
|
|
@@ -1459,16 +1557,18 @@ VIEWS.hygiene = async (page) => {
|
|
|
1459
1557
|
recorded and also counts.</div>
|
|
1460
1558
|
<div class="grid g4">
|
|
1461
1559
|
${T.map(t => kpi(`Spend in requests ≥ ${K(t)} context`, fmtPct(ab(t).share_pct),
|
|
1462
|
-
`${fmtUSD(ab(t).cost_usd)} · ${fmtInt(ab(t).requests)} requests`,
|
|
1560
|
+
`${fmtUSD(ab(t).cost_usd)} · ${fmtInt(ab(t).requests)} requests`,
|
|
1561
|
+
{badge: BADGE.actual, tone: grade(ab(t).share_pct, SHARE), title: bandTip(SHARE)})).join('')}
|
|
1463
1562
|
${T.map(t => kpi(`Spend after a session first crossed ${K(t)}`, fmtPct(ab(t).share_after_first_cross_pct),
|
|
1464
|
-
`${fmtInt(ab(t).sessions)} of ${fmtInt(hy.sessions)} sessions crossed it`,
|
|
1563
|
+
`${fmtInt(ab(t).sessions)} of ${fmtInt(hy.sessions)} sessions crossed it`,
|
|
1564
|
+
{badge: BADGE.actual, tone: grade(ab(t).share_after_first_cross_pct, SHARE), title: bandTip(SHARE)})).join('')}
|
|
1465
1565
|
</div>
|
|
1466
1566
|
<div class="grid g3">
|
|
1467
1567
|
${kpi('Spend near or over the context window', fmtPct(fit.near_or_over_cost_pct),
|
|
1468
1568
|
`≥ ${fit.threshold_pct}% of the window in use · ${fmtInt(fit.near_requests + fit.over_requests)} requests`,
|
|
1469
|
-
{badge: BADGE.actual})}
|
|
1470
|
-
${kpi('Sessions in range', fmtInt(hy.sessions), `${fmtInt(hy.requests)} main-thread requests`, {badge: BADGE.actual})}
|
|
1471
|
-
${kpi('Spend in range', fmtUSD(hy.cost_usd), 'subagent turns excluded (own prefix)', {badge: BADGE.estimated})}
|
|
1569
|
+
{badge: BADGE.actual, tone: grade(fit.near_or_over_cost_pct, FIT), title: bandTip(FIT)})}
|
|
1570
|
+
${kpi('Sessions in range', fmtInt(hy.sessions), `${fmtInt(hy.requests)} main-thread requests`, {badge: BADGE.actual, tone: 'info'})}
|
|
1571
|
+
${kpi('Spend in range', fmtUSD(hy.cost_usd), 'subagent turns excluded (own prefix)', {badge: BADGE.estimated, tone: 'info'})}
|
|
1472
1572
|
</div>
|
|
1473
1573
|
${card('Context per request, most expensive session after ' + K(kT), '<div class="chart" id="hy-traj"></div>',
|
|
1474
1574
|
{badge: BADGE.actual, hint: 'prompt-side tokens on each request, in order · click a row below to change session'})}
|
|
@@ -2608,7 +2708,9 @@ async function render() {
|
|
|
2608
2708
|
if (!f.include_sandbox) bits.push('excl. sandbox');
|
|
2609
2709
|
$('#crumbs').innerHTML = bits.join(' <span style="opacity:.4">·</span> ');
|
|
2610
2710
|
filterBar();
|
|
2611
|
-
|
|
2711
|
+
scopeStrip();
|
|
2712
|
+
page.innerHTML = pageLoader();
|
|
2713
|
+
loaderTicker(page);
|
|
2612
2714
|
updateNavBadges();
|
|
2613
2715
|
try {
|
|
2614
2716
|
await (VIEWS[S.view] || VIEWS.overview)(page);
|
|
@@ -2892,12 +2994,13 @@ async function syncLabel() {
|
|
|
2892
2994
|
const b = $('#sync'); if (!b) return;
|
|
2893
2995
|
const d = await fetch('/api/sync').then(r => r.json()).catch(() => ({}));
|
|
2894
2996
|
if (d.job && d.job.state === 'running') return runSync();
|
|
2895
|
-
b.title = `Last synced ${ago(d.built_at)}. Re-read every agent's
|
|
2896
|
-
b.
|
|
2997
|
+
b.title = `Last synced ${ago(d.built_at)}. Re-read every agent's transcripts from disk into the warehouse`;
|
|
2998
|
+
b.classList.remove('busy');
|
|
2999
|
+
b.innerHTML = `${ICON.sync}Sync <span class="ago">· ${ago(d.built_at)}</span>`;
|
|
2897
3000
|
}
|
|
2898
3001
|
async function runSync() {
|
|
2899
3002
|
const b = $('#sync');
|
|
2900
|
-
b.disabled = true; b.
|
|
3003
|
+
b.disabled = true; b.classList.add('busy'); b.innerHTML = `${ICON.sync}Syncing…`;
|
|
2901
3004
|
try {
|
|
2902
3005
|
const {job} = await doAction('sync');
|
|
2903
3006
|
const j = await followJob(job);
|
|
@@ -3305,7 +3408,15 @@ const TOURS = {
|
|
|
3305
3408
|
{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.'},
|
|
3306
3409
|
{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.'}],
|
|
3307
3410
|
hygiene: [
|
|
3308
|
-
{el: 'kpis', t: 'Where spend sat', see: '
|
|
3411
|
+
{el: 'kpis', t: 'Where spend sat', see: 'Headline shares of your spend, colour-graded: green is low, amber and orange are rising, red means most of the bill sat there. Hover a card for its bands.', get: 'How much of the bill was re-sending a large prefix.', act: 'Walk through each card with Next; the colour is a reading aid, not a verdict.'},
|
|
3412
|
+
{el: 'kpi:Spend in requests ≥ 100K', t: 'Spend in requests ≥ 100K context', see: 'The share of spend on requests whose prompt side (context re-sent to the model) was 100K tokens or more.', get: 'How much you paid while carrying a big conversation. Every turn re-sends it, so large context costs on every request.', act: 'Red here with a red "after crossing" card means sessions keep going long after they got big.'},
|
|
3413
|
+
{el: 'kpi:Spend in requests ≥ 150K', t: 'Spend in requests ≥ 150K context', see: 'The same share, but for requests at 150K tokens or more.', get: 'The heaviest tier: close to where auto-compaction kicks in.', act: 'Compare with the 100K card: a small gap means sessions that pass 100K usually keep growing to 150K.'},
|
|
3414
|
+
{el: 'kpi:Spend after a session first crossed 100K', t: 'Spend after first crossing 100K', see: 'The share of spend that came after a session first reached 100K, even if a later /compact brought it back down. The detail line counts how many sessions ever crossed it.', get: 'How much of your bill is the long tail of big sessions.', act: 'If this is much higher than the "requests ≥ 100K" card, compaction is working but sessions still run long afterwards.'},
|
|
3415
|
+
{el: 'kpi:Spend after a session first crossed 150K', t: 'Spend after first crossing 150K', see: 'The same, measured from the first time a session reached 150K.', get: 'Spend in sessions that went all the way to the heavy end.', act: 'Few sessions but a high share? Open them in the ranked list below.'},
|
|
3416
|
+
{el: 'kpi:Spend near or over the context window', t: 'Near or over the context window', see: 'The share of spend on requests using 90% or more of the model\'s context window, or going over it.', get: 'Turns that risk truncation or a forced compaction mid-task.', act: 'Anything above amber is worth a look: a /compact or fresh session before you hit the wall keeps the answer quality up.'},
|
|
3417
|
+
{el: 'kpi:Sessions in range', t: 'Sessions in range', see: 'How many sessions and main-thread requests the page is based on (blue: context, not graded).', get: 'The sample size behind the percentages.', act: 'Widen the date range if this is small; a handful of sessions makes the shares jumpy.'},
|
|
3418
|
+
{el: 'kpi:Spend in range', t: 'Spend in range', see: 'Total estimated main-thread spend the shares are taken from. Subagent turns are left out because they run on their own prefix.', get: 'The dollar base: multiply any share above by this to get dollars.', act: 'Marked Estimated because it uses list prices, not your invoice.'},
|
|
3419
|
+
{el: 'card:Context per request', t: 'Context per request', see: 'Prompt-side tokens on every request of one session, in order.', get: 'Where a session grew, and where compaction dropped it back.', act: 'Click a row in the ranked list below to switch session.'},
|
|
3309
3420
|
{el: 'card:Sessions ranked', t: 'Sessions', see: 'Each session\'s context trajectory and what it spent after crossing the threshold.', get: 'The sessions where a fresh start would have mattered most.', act: 'Click a row for its trajectory; alt-click to open the session.'}],
|
|
3310
3421
|
waste: [
|
|
3311
3422
|
{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.'},
|
|
@@ -3363,6 +3474,13 @@ function tourTarget(el) {
|
|
|
3363
3474
|
const page = $('#page');
|
|
3364
3475
|
if (!el) return null;
|
|
3365
3476
|
if (el === 'kpis') return page?.querySelector('.kpi')?.closest('.grid') || null;
|
|
3477
|
+
// 'kpi:Label start' — one KPI card, matched by the start of its label (badge text ignored).
|
|
3478
|
+
if (el.startsWith('kpi:')) {
|
|
3479
|
+
const want = el.slice(4).trim().toLowerCase();
|
|
3480
|
+
for (const k of page?.querySelectorAll('.kpi') || [])
|
|
3481
|
+
if ((k.querySelector('.l')?.firstChild?.textContent || '').trim().toLowerCase().startsWith(want)) return k;
|
|
3482
|
+
return null;
|
|
3483
|
+
}
|
|
3366
3484
|
const m = /^card(\d+)$/.exec(el);
|
|
3367
3485
|
if (m) return page?.querySelectorAll('.card')[+m[1]] || null;
|
|
3368
3486
|
// 'card:Some title' — match a card by the start of its heading, so a step keeps
|
package/web/styles.css
CHANGED
|
@@ -101,14 +101,28 @@ button, input, select { font: inherit; color: inherit; }
|
|
|
101
101
|
|
|
102
102
|
/* ---------- header ---------- */
|
|
103
103
|
.topbar { position:sticky; top:0; z-index:40; background: var(--surface);
|
|
104
|
-
border-bottom:1px solid var(--border); padding:10px 20px; }
|
|
105
|
-
.topbar .r1 { display:flex; align-items:center; gap:
|
|
106
|
-
.
|
|
107
|
-
.
|
|
108
|
-
.crumbs
|
|
104
|
+
border-bottom:1px solid var(--border); padding:10px 20px 9px; }
|
|
105
|
+
.topbar .r1 { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
|
106
|
+
.ttl-block { display:flex; flex-direction:column; gap:1px; min-width:0; }
|
|
107
|
+
.topbar h1 { font-size:16px; margin:0; font-weight:640; letter-spacing:-0.01em; line-height:1.2; }
|
|
108
|
+
.crumbs { color:var(--muted); font-size:11px; display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
|
|
109
|
+
.crumbs b { color: var(--text-2); font-weight:560; font-variant-numeric: tabular-nums; }
|
|
109
110
|
.spacer { flex:1; }
|
|
111
|
+
|
|
112
|
+
/* Headline totals for the current filters. */
|
|
113
|
+
.scope { display:flex; align-items:stretch; margin-left:6px; padding-left:14px;
|
|
114
|
+
border-left:1px solid var(--border); min-height:30px; }
|
|
115
|
+
.scope:empty { display:none; }
|
|
116
|
+
.scope .sc { display:flex; flex-direction:column; justify-content:center; padding:0 12px; gap:1px;
|
|
117
|
+
border-right:1px solid var(--border); }
|
|
118
|
+
.scope .sc:first-child { padding-left:0; }
|
|
119
|
+
.scope .sc:last-child { border-right:0; }
|
|
120
|
+
.scope .sc span { font-size:9px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted);
|
|
121
|
+
font-weight:620; white-space:nowrap; }
|
|
122
|
+
.scope .sc b { font-size:13.5px; font-weight:650; font-variant-numeric: tabular-nums; white-space:nowrap; }
|
|
123
|
+
|
|
110
124
|
.search { position:relative; }
|
|
111
|
-
.search input { width:
|
|
125
|
+
.search input { width:230px; padding:6px 10px 6px 28px; border-radius:8px; font-size:12.5px;
|
|
112
126
|
border:1px solid var(--border); background:var(--surface-2); }
|
|
113
127
|
.search input:focus { outline:2px solid var(--s1); outline-offset:-1px; border-color:transparent; }
|
|
114
128
|
.search .mag { position:absolute; left:9px; top:50%; transform:translateY(-50%);
|
|
@@ -116,9 +130,48 @@ button, input, select { font: inherit; color: inherit; }
|
|
|
116
130
|
.iconbtn { border:1px solid var(--border); background:var(--surface-2); border-radius:8px;
|
|
117
131
|
padding:6px 10px; cursor:pointer; font-size:12px; color:var(--text-2); }
|
|
118
132
|
.iconbtn:hover { background: var(--surface-3); color:var(--text); }
|
|
133
|
+
/* One joined toolbar instead of five loose buttons. */
|
|
134
|
+
.tools { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden;
|
|
135
|
+
background:var(--surface-2); }
|
|
136
|
+
.tools .iconbtn { border:0; border-radius:0; border-right:1px solid var(--border); background:transparent;
|
|
137
|
+
height:30px; line-height:1; }
|
|
138
|
+
.tools .iconbtn:last-child { border-right:0; }
|
|
139
|
+
.tools .iconbtn.sq { width:32px; padding:0; font-size:13px; }
|
|
140
|
+
.tools .iconbtn { display:inline-flex; align-items:center; justify-content:center; gap:6px; }
|
|
141
|
+
.tools svg { width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:1.9;
|
|
142
|
+
stroke-linecap:round; stroke-linejoin:round; flex:none; }
|
|
143
|
+
.tools .iconbtn.on { background: var(--surface-3); color:var(--s1); box-shadow: inset 0 -2px 0 var(--s1); }
|
|
144
|
+
.act.sync { height:30px; white-space:nowrap; display:inline-flex; align-items:center; gap:6px; }
|
|
145
|
+
.act.sync svg { width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:1.9;
|
|
146
|
+
stroke-linecap:round; stroke-linejoin:round; flex:none; }
|
|
147
|
+
.act.sync .ago { color:var(--muted); font-weight:450; }
|
|
148
|
+
/* While syncing, the arrow into the database pulses so the button reads as working. */
|
|
149
|
+
.act.sync.busy svg path:nth-last-child(-n+2) { animation: sync-in 1s ease-in-out infinite; }
|
|
150
|
+
@keyframes sync-in { 0%,100% { opacity:1; transform:translateY(0); } 50% { opacity:.35; transform:translateY(-2px); } }
|
|
151
|
+
@media (prefers-reduced-motion: reduce) { .act.sync.busy svg path { animation:none; } }
|
|
119
152
|
|
|
120
153
|
/* ---------- filter bar ---------- */
|
|
121
|
-
.filters { display:flex; gap:
|
|
154
|
+
.filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:9px; }
|
|
155
|
+
/* Segmented control: a labelled group of mutually exclusive choices. */
|
|
156
|
+
.seg { display:inline-flex; align-items:center; border:1px solid var(--border); border-radius:8px;
|
|
157
|
+
background:var(--surface-2); padding:2px; gap:1px; }
|
|
158
|
+
.seg .seg-l { font-size:9.5px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted);
|
|
159
|
+
font-weight:620; padding:0 7px 0 6px; }
|
|
160
|
+
.seg button { border:0; background:transparent; border-radius:6px; padding:3px 9px; font-size:11.5px;
|
|
161
|
+
color:var(--text-2); cursor:pointer; white-space:nowrap; }
|
|
162
|
+
.seg button:hover { background:var(--surface-3); color:var(--text); }
|
|
163
|
+
.seg button.on { background:var(--s1); color:#fff; font-weight:550; }
|
|
164
|
+
.chip.toggle i { width:22px; height:12px; border-radius:99px; background:var(--border-strong);
|
|
165
|
+
position:relative; display:inline-block; transition:background .15s; }
|
|
166
|
+
.chip.toggle i::after { content:''; position:absolute; top:2px; left:2px; width:8px; height:8px;
|
|
167
|
+
border-radius:50%; background:#fff; transition:left .15s; }
|
|
168
|
+
.chip.toggle.on { background:var(--surface-2); border-color:var(--border); color:var(--text); font-weight:500; }
|
|
169
|
+
.chip.toggle.on i { background:var(--s1); }
|
|
170
|
+
.chip.toggle.on i::after { left:12px; }
|
|
171
|
+
.chip.clr { color:var(--critical-ink); border-color:color-mix(in srgb, var(--critical) 35%, var(--border)); }
|
|
172
|
+
.coverage { font-size:10.5px; color:var(--muted); white-space:nowrap; font-variant-numeric: tabular-nums; }
|
|
173
|
+
@media (max-width: 1450px) { .scope .sc:nth-child(n+4) { display:none; } }
|
|
174
|
+
@media (max-width: 1150px) { .scope, .coverage { display:none; } }
|
|
122
175
|
.chipsel { position:relative; }
|
|
123
176
|
.chip { border:1px solid var(--border); background:var(--surface-2); border-radius:99px;
|
|
124
177
|
padding:4px 11px; font-size:11.5px; cursor:pointer; color:var(--text-2);
|
|
@@ -194,6 +247,17 @@ button, input, select { font: inherit; color: inherit; }
|
|
|
194
247
|
.kpi .d { font-size:11px; color:var(--text-2); }
|
|
195
248
|
.kpi .d .up { color:var(--critical-ink); font-weight:600; }
|
|
196
249
|
.kpi .d .down { color:var(--good-ink); font-weight:600; }
|
|
250
|
+
/* Colour-graded KPI: a tinted surface, a left stripe and a tinted value. */
|
|
251
|
+
.kpi[class*="tone-"] { border-left-width:4px; }
|
|
252
|
+
.kpi.tone-good { border-left-color:var(--good); background:color-mix(in srgb, var(--good) 6%, var(--surface)); }
|
|
253
|
+
.kpi.tone-warning { border-left-color:var(--warning); background:color-mix(in srgb, var(--warning) 8%, var(--surface)); }
|
|
254
|
+
.kpi.tone-serious { border-left-color:var(--serious); background:color-mix(in srgb, var(--serious) 8%, var(--surface)); }
|
|
255
|
+
.kpi.tone-critical { border-left-color:var(--critical); background:color-mix(in srgb, var(--critical) 7%, var(--surface)); }
|
|
256
|
+
.kpi.tone-info { border-left-color:var(--s1); }
|
|
257
|
+
.kpi.tone-good .v { color:var(--good-ink); }
|
|
258
|
+
.kpi.tone-warning .v { color:var(--warning-ink); }
|
|
259
|
+
.kpi.tone-serious .v { color:var(--serious-ink); }
|
|
260
|
+
.kpi.tone-critical .v { color:var(--critical-ink); }
|
|
197
261
|
.kpi.na .v { font-size:12px; font-weight:500; color:var(--muted); font-style:italic; }
|
|
198
262
|
|
|
199
263
|
/* ---------- table ---------- */
|
|
@@ -284,7 +348,47 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
284
348
|
.pill { display:inline-block; font-size:10.5px; padding:1px 7px; border-radius:99px;
|
|
285
349
|
background:var(--surface-3); color:var(--text-2); border:1px solid var(--border); }
|
|
286
350
|
.empty { padding:26px 10px; text-align:center; color:var(--muted); font-size:12px; }
|
|
287
|
-
.loading { padding:40px; text-align:center; color:var(--muted); font-size:12px;
|
|
351
|
+
.loading { padding:40px; text-align:center; color:var(--muted); font-size:12px;
|
|
352
|
+
display:flex; align-items:center; justify-content:center; gap:9px; }
|
|
353
|
+
.loading::before { content:''; width:14px; height:14px; border-radius:50%; flex:none;
|
|
354
|
+
border:2px solid color-mix(in srgb, var(--s1) 22%, transparent); border-top-color:var(--s1);
|
|
355
|
+
animation: spin .8s linear infinite; }
|
|
356
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
357
|
+
|
|
358
|
+
/* ---------- page loader: dashboard-shaped skeleton + live status ---------- */
|
|
359
|
+
.pl { display:flex; flex-direction:column; gap:12px; }
|
|
360
|
+
.pl-status { display:flex; align-items:center; gap:10px; align-self:flex-start; padding:6px 12px 6px 8px;
|
|
361
|
+
border:1px solid color-mix(in srgb, var(--s1) 30%, var(--border)); border-radius:99px;
|
|
362
|
+
background:color-mix(in srgb, var(--s1) 7%, var(--surface)); font-size:12px; color:var(--text-2); }
|
|
363
|
+
.pl-mark { display:inline-flex; align-items:flex-end; gap:2px; height:14px; width:16px; }
|
|
364
|
+
.pl-mark i { width:4px; border-radius:1.5px; background:var(--s1); animation: pl-eq 0.9s ease-in-out infinite; }
|
|
365
|
+
.pl-mark i:nth-child(2) { background:var(--s2); animation-delay:.15s; }
|
|
366
|
+
.pl-mark i:nth-child(3) { background:var(--s3); animation-delay:.3s; }
|
|
367
|
+
@keyframes pl-eq { 0%,100% { height:30%; } 50% { height:100%; } }
|
|
368
|
+
.pl-msg { font-weight:560; color:var(--text); }
|
|
369
|
+
.pl-time { color:var(--muted); font-size:11px; font-variant-numeric: tabular-nums; }
|
|
370
|
+
.pl-time:empty { display:none; }
|
|
371
|
+
.pl-tip { align-self:center; font-size:11.5px; color:var(--muted); padding:4px 0 8px; }
|
|
372
|
+
.skel { box-shadow:none; gap:8px; }
|
|
373
|
+
.kpi.skel { gap:9px; }
|
|
374
|
+
.bone { display:block; border-radius:5px; background:var(--surface-3); position:relative; overflow:hidden; }
|
|
375
|
+
.bone::after, .pl-bars i::after { content:''; position:absolute; inset:0; transform:translateX(-100%);
|
|
376
|
+
background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--surface) 70%, transparent), transparent);
|
|
377
|
+
animation: shimmer 1.4s ease-in-out infinite; }
|
|
378
|
+
@keyframes shimmer { to { transform:translateX(100%); } }
|
|
379
|
+
.pl-bars { display:flex; align-items:flex-end; gap:6px; height:130px; margin-top:14px;
|
|
380
|
+
border-bottom:1px solid var(--grid); }
|
|
381
|
+
.pl-bars i { flex:1; border-radius:4px 4px 0 0; position:relative; overflow:hidden;
|
|
382
|
+
background:color-mix(in srgb, var(--s1) 16%, var(--surface-3));
|
|
383
|
+
animation: pl-grow 1.6s ease-in-out infinite alternate; transform-origin:bottom; }
|
|
384
|
+
.pl-bars.alt i { background:color-mix(in srgb, var(--s3) 16%, var(--surface-3)); }
|
|
385
|
+
@keyframes pl-grow { from { transform:scaleY(.55); } to { transform:scaleY(1); } }
|
|
386
|
+
.pl-row { display:flex; justify-content:space-between; gap:16px; padding:9px 0; border-bottom:1px solid var(--grid); }
|
|
387
|
+
.pl-row:last-child { border-bottom:0; }
|
|
388
|
+
@media (prefers-reduced-motion: reduce) {
|
|
389
|
+
.bone::after, .pl-bars i::after, .pl-bars i, .pl-mark i, .loading::before { animation:none; }
|
|
390
|
+
.pl-mark i { height:70%; }
|
|
391
|
+
}
|
|
288
392
|
.tabs { display:flex; gap:3px; border-bottom:1px solid var(--border); margin-bottom:2px;
|
|
289
393
|
overflow-x:auto; }
|
|
290
394
|
.tabs button { background:none; border:none; padding:7px 12px; font-size:12px; cursor:pointer;
|