Give the token ledger its own card on the dashboard

The AI card answers whether the model is healthy now; what it has spent over a month was a
different question crowding it out one line at a time.
This commit is contained in:
Gmer4Lfe
2026-08-09 11:00:00 -04:00
parent 33e696071e
commit 1afeaf0e15
4 changed files with 226 additions and 48 deletions
+128 -5
View File
@@ -315,7 +315,12 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
it is not here. api/ai.php refuses every action independently, so this is presentation
rather than access control. -->
<div class="vv-card" id="vv-ai-stats-card" style="grid-column:span 1;">
<!-- Placement for this row lives in css/varaverk.css, not on these cards. It is two rows
deep — AI over Tokens in the first column — and a grid-column span here cannot say which
row a card belongs to, so an inline span would only fight the stylesheet for half the
answer. -->
<div class="vv-card" id="vv-ai-stats-card">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="2.5" width="7" height="7" rx="1.2"/><circle cx="4.7" cy="5" r="0.7" fill="#888" stroke="none"/><circle cx="7.3" cy="5" r="0.7" fill="#888" stroke="none"/><line x1="4.5" y1="7.3" x2="7.5" y2="7.3"/><line x1="6" y1="2.5" x2="6" y2="0.8"/><line x1="2.5" y1="6" x2="0.8" y2="6"/><line x1="9.5" y1="6" x2="11.2" y2="6"/></svg></span>
@@ -326,7 +331,22 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
<div id="vv-ai-stats-body">Loading...</div>
</div>
<div class="vv-card" id="vv-ai-chats-card" style="grid-column:span 2;">
<!-- The token ledger, the same one the AI tab draws, under the card whose subsystem spends
them. Its own card rather than three more lines on the AI card above: that card answers
"is the model healthy right now", and cost accumulated over a month is a different
question that was crowding it out one line at a time. -->
<div class="vv-card" id="vv-ai-tokens-card">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><line x1="2" y1="10.3" x2="2" y2="6.5"/><line x1="6" y1="10.3" x2="6" y2="2.4"/><line x1="10" y1="10.3" x2="10" y2="7.6"/></svg></span>
Tokens
</span>
<a href="/plugins/varaverk/Varaverk.page?tab=ai" class="vv-card-cog" title="AI tab">⚙</a>
</h3>
<div id="vv-ai-tokens-body">Loading...</div>
</div>
<div class="vv-card" id="vv-ai-chats-card">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="13" height="12" viewBox="0 0 13 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><path d="M1 2.2C1 1.5 1.5 1 2.2 1H10.8C11.5 1 12 1.5 12 2.2V6.8C12 7.5 11.5 8 10.8 8H4.5L2 10.5V8H2.2C1.5 8 1 7.5 1 6.8Z"/></svg></span>
@@ -338,7 +358,7 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
<!-- Named for what it is, not "dock" — the Scheduler tab's vv-ai-dock is a different
component and vvAiDockOn() there tests for that id by name. -->
<div class="vv-card" id="vv-ai-assistant-card" style="grid-column:span 5;">
<div class="vv-card" id="vv-ai-assistant-card">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="5"/><path d="M4.4 4.6C4.4 3.7 5.1 3.1 6 3.1C6.9 3.1 7.6 3.7 7.6 4.5C7.6 5.9 6 5.7 6 7"/><circle cx="6" cy="8.8" r="0.6" fill="#888" stroke="none"/></svg></span>
@@ -1939,7 +1959,7 @@ function vvPollMonitor(live) {
function vvRenderAi(ai) {
const box = document.getElementById('vv-ai-stats-body');
if (!box) return;
const rt = ai.runtime ?? {}, ix = ai.index ?? {}, tok = ai.tokens ?? null;
const rt = ai.runtime ?? {}, ix = ai.index ?? {};
const line = (label, value, cls) =>
`<div style="display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:6px;">`
@@ -1977,11 +1997,111 @@ function vvRenderAi(ai) {
if (ix.stale) h += `<div style="font-size:10px;color:#8a6a3a;margin:-2px 0 7px;">source newer — reindex</div>`;
}
if (tok && tok.turns) h += line('Today', tok.total.toLocaleString() + ' tok');
box.innerHTML = h;
}
// ── AI token ledger card ─────────────────────────────────────────────────────
// Not part of the monitor payload. The cache writer runs once a minute and this is the one card
// on the page whose numbers only move when a turn finishes, so it is fetched on its own from
// api/ai.php?action=tokens — the same endpoint and the same shape the AI tab reads, so the two
// surfaces cannot disagree about what has been spent.
let vvTokData = null, vvTokScope = 'all';
function vvTokAgo(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now()/1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d/60) + 'm';
if (d < 86400) return Math.floor(d/3600) + 'h';
return Math.floor(d/86400) + 'd';
}
function vvLoadTokens() {
return fetch('/plugins/varaverk/api/ai.php?action=tokens')
.then(r => r.json())
.then(d => { if (d.ok) { vvTokData = d.tokens; vvRenderTokens(); } })
.catch(() => {});
}
function vvRenderTokens() {
const box = document.getElementById('vv-ai-tokens-body');
if (!box || !vvTokData) return;
const num = n => (n || 0).toLocaleString();
const hosts = vvTokData.hosts || {};
// A host with no rows reads "not collected here", never 0. Each host writes to its own data/
// and only ai_token_sync.sh moves a ledger between them, so a zero would claim the partner did
// no work when the truth is that this host cannot see its ledger at all.
let h = `<div class="vv-ai-tok-l vv-ai-tok-head">Nodes</div>`
+ `<div class="vv-ai-hostrow${vvTokScope === 'all' ? ' active' : ''}" data-scope="all">`
+ `<span class="vv-ai-hostrow-n">All hosts</span>`
+ `<span class="vv-ai-hostrow-v">${num(vvTokData.all.total)}</span></div>`;
Object.keys(hosts).forEach(id => {
const x = hosts[id];
const tag = x.self ? ' · this host' : x.synced ? ' · synced ' + vvTokAgo(x.synced) : '';
h += `<div class="vv-ai-hostrow${vvTokScope === id ? ' active' : ''}" data-scope="${vvEscAttr(id)}">`
+ `<span class="vv-ai-hostrow-n">${vvEscHtml(x.name)}</span>`
+ `<span class="vv-ai-hostrow-h">${vvEscHtml(id)}${vvEscHtml(tag)}</span>`
+ (x.seen ? `<span class="vv-ai-hostrow-v">${num(x.all.total)}</span>`
: `<span class="vv-ai-hostrow-v none">not collected here</span>`)
+ `</div>`;
});
const sel = vvTokScope === 'all' ? vvTokData : hosts[vvTokScope];
const scope = vvTokScope === 'all' ? 'all hosts'
: (hosts[vvTokScope] ? hosts[vvTokScope].name : vvTokScope);
h += `<div class="vv-ai-tok-l vv-ai-tok-head">Token usage — ${vvEscHtml(scope)}</div>`;
if (!sel || !sel.all.turns) {
h += vvTokScope === 'all'
? `<div class="vv-ai-none">no turns recorded yet</div>`
: `<div class="vv-ai-none">no rows from this host in the local ledger</div>`;
box.innerHTML = h;
return;
}
const cell = (label, b) =>
`<div><div class="vv-ai-tok-l">${label}</div>`
+ `<div class="vv-ai-tok-v">${num(b.total)}</div>`
+ `<div class="vv-ai-tok-s">${num(b.turns)} turn${b.turns === 1 ? '' : 's'}`
+ ` · ${num(b.prompt)} in / ${num(b.completion)} out</div></div>`;
h += `<div class="vv-ai-tok-grid">`
+ cell('Today', sel.today) + cell('Last 7 days', sel.week) + cell('All time', sel.all)
+ `</div>`;
// Profile and source splits describe the whole ledger, so they appear only under the all-hosts
// scope rather than sitting beneath a host heading they do not describe.
const foot = [];
if (vvTokData.first) foot.push(`since ${vvEscHtml(vvTokData.first)} · ${vvTokData.days} day${vvTokData.days === 1 ? '' : 's'}`);
if (vvTokData.best_tok_s) foot.push(`best ${vvEscHtml(vvTokData.best_tok_s)} tok/s`);
if (vvTokScope === 'all') {
const by = o => Object.keys(o || {}).map(k => `${vvEscHtml(k)} ${num(o[k])}`).join(' · ');
if (Object.keys(vvTokData.profiles || {}).length) foot.push('profile: ' + by(vvTokData.profiles));
if (Object.keys(vvTokData.sources || {}).length) foot.push('source: ' + by(vvTokData.sources));
}
if (foot.length) h += `<div class="vv-ai-tok-foot">`
+ foot.map(f => `<span>${f}</span>`).join('') + `</div>`;
box.innerHTML = h;
}
// Delegated on the container, which survives every render — the rows inside do not, and binding
// per row would rebind the whole list on each poll to no purpose.
if (document.getElementById('vv-ai-tokens-body')) {
document.getElementById('vv-ai-tokens-body').addEventListener('click', e => {
const row = e.target.closest('.vv-ai-hostrow');
if (!row) return;
vvTokScope = row.dataset.scope;
vvRenderTokens(); // scope is a view of data already held; no refetch
});
// 60s, not the 5s the dashboard polls at. The ledger only moves when a turn completes, and a
// turn completing here refreshes the card directly — this interval exists for turns taken on
// the AI tab and for the partner ledger that ai_token_sync.sh pulls every two hours.
vvPollRunner(vvLoadTokens, 60000);
}
// 5s against a payload the cache writer refreshes once a minute. Polling faster cannot make the
// data newer — it only decides how soon the page notices the writer's update.
vvPollRunner(vvPollMonitor, 5000);
@@ -2696,6 +2816,9 @@ if (document.getElementById('vv-mon-ai-chat')) {
empty: 'Ask anything. Questions about this installation are handed to the Varaverk '
+ 'assistant automatically.',
onChats: id => { if (vvMonChatList) vvMonChatList.setActive(id); },
// The turn that just finished is the one spend you watched happen. Waiting up to a minute
// for the poll to reflect it reads as the card being broken.
onTurn: () => vvLoadTokens(),
});
vvMonChatList = VvAiChatList({ into: 'vv-mon-ai-chats', chat: vvMonChat });
vvMonChatList.reload();