fix(dashboard): restore dual-proof lightbox — screenshot + text badges
Some checks are pending
CI / rust (pull_request) Waiting to run
CI / markdown (pull_request) Waiting to run
CI / port (pull_request) Waiting to run
CI / agent-jail-pkgs (pull_request) Waiting to run

Restores structure removed by PR #242:
  <img id=lb-img> for screenshot proofs (future — daemon doesn't populate yet)
  <pre id=lb-text> for proof_text (glasspane state JSON)
  Typed badges: ▸ screenshot when screenshot_uuid populated, ▸ text otherwise

Co-authored-by: Hermes <hermes@clawdie.si>
This commit is contained in:
Sam & Claude 2026-06-27 22:34:40 +02:00
parent 80543c5f46
commit ff9c8511f9

View file

@ -3,301 +3,197 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cost Dashboard · clawdie.si</title>
<title>Clawdie · Cost Dashboard</title>
<style>
/* ══ reset & base ═══════════════════════════════════════════════════════ */
*{margin:0;padding:0;box-sizing:border-box}
/* ══ reset ══════════════════════════════════════════════════════════════ */
*,*::before,*::after{box-sizing:border-box; margin:0; padding:0}
:root{
--bg:#0d1117; --surface:#161b22; --border:#21262d; --muted:#484f58;
--fg:#c9d1d9; --fg2:#8b949e; --accent:#00b4d8; --green:#3fb950;
--red:#f85149; --yellow:#d29922; --orange:#db6d28; --cache:#238636;
--fresh:#30363d; --bar-h:6px; --card-w:200px;
--bg:#0b0e14; --surface:#131820; --surface2:#1a2230;
--fg:#d4dae3; --fg2:#8695a7; --fg3:#556270;
--accent:#00b4d8; --green:#2ecc71; --red:#e74c3c; --amber:#f39c12;
--radius:6px; --font:system-ui,-apple-system,sans-serif;
}
body{
background:var(--bg); color:var(--fg);
font-family:'DM Mono','SF Mono','Cascadia Code',monospace;
padding:2rem; min-height:100vh;
}
/* ══ header ═════════════════════════════════════════════════════════════ */
.header{
display:flex; align-items:center; justify-content:space-between;
flex-wrap:wrap; gap:1rem; margin-bottom:1.5rem;
padding-bottom:1rem; border-bottom:1px solid var(--border);
}
h1{font-size:1.1rem; font-weight:400; color:var(--fg2); letter-spacing:.08em; text-transform:uppercase}
h1 span{color:var(--accent)}
h1 .dot{display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; background:var(--green)}
.summary-bar{display:flex; gap:1.5rem; flex-wrap:wrap}
.summary-item{text-align:center}
.summary-item .val{font-size:1.3rem; color:var(--accent)}
.summary-item .lbl{font-size:.65rem; color:var(--fg2); text-transform:uppercase; letter-spacing:.05em}
/* ══ controls ═══════════════════════════════════════════════════════════ */
.controls{display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;margin-bottom:1.5rem}
.controls label{font-size:.7rem; color:var(--fg2); text-transform:uppercase; letter-spacing:.05em}
.controls select, .controls input[type="text"]{
background:var(--surface); color:var(--fg); border:1px solid var(--border);
border-radius:4px; padding:.35rem .5rem; font-family:inherit; font-size:.75rem
}
.controls select:hover, .controls input:hover{border-color:var(--accent)}
.btn{
background:none; border:1px solid var(--border); color:var(--fg2);
border-radius:4px; padding:.35rem .8rem; font-family:inherit; font-size:.72rem;
cursor:pointer; transition:border-color .2s,color .2s
}
.btn:hover{border-color:var(--accent); color:var(--accent)}
.btn.active{background:var(--accent); color:var(--bg); border-color:var(--accent)}
.btn-json{background:var(--surface); color:var(--yellow); border-color:var(--yellow)}
.btn-json:hover{background:var(--yellow); color:var(--bg)}
/* ══ nodes ══════════════════════════════════════════════════════════════ */
.nodes{margin-bottom:2rem}
.node-row{
background:var(--surface); border:1px solid var(--border); border-radius:8px;
padding:1rem 1.25rem; margin-bottom:.75rem; transition:border-color .2s
}
.node-row:hover{border-color:var(--accent)}
.node-head{display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.5rem}
.node-name{font-size:.9rem; color:var(--accent); font-weight:600}
.node-name .status-dot{font-size:.6rem; margin-right:4px}
.node-name .status-dot.online{color:var(--green)}
.node-name .status-dot.offline{color:var(--fg2)}
.node-meta{display:flex; gap:1.2rem; font-size:.72rem; color:var(--fg2)}
.node-meta strong{color:var(--fg); font-weight:400}
.node-cards{display:flex; gap:.75rem; flex-wrap:wrap; margin-top:.75rem; padding-top:.75rem; border-top:1px solid var(--border)}
.node-empty{font-size:.72rem; color:var(--muted); font-style:italic; padding:.5rem 0}
/* ══ cost cards ═════════════════════════════════════════════════════════ */
.card{
background:var(--bg); border:1px solid var(--border); border-radius:6px;
padding:.65rem .75rem; width:var(--card-w); cursor:default;
transition:border-color .2s,transform .15s; position:relative
}
.card:hover{transform:translateY(-1px)}
body{background:var(--bg); color:var(--fg); font-family:var(--font); padding:1.5rem 2rem; min-height:100vh}
h1{font-size:1.3rem; font-weight:600; margin-bottom:1.5rem; color:var(--fg)}
h1 .dot{display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:.5rem}
h1 .dot.green{background:var(--green)}
h1 .dot.amber{background:var(--amber)}
/* ══ summary ════════════════════════════════════════════════════════════ */
.summary{display:flex; gap:1rem; margin-bottom:2rem; flex-wrap:wrap}
.summary .stat{background:var(--surface); border-radius:var(--radius); padding:.75rem 1.25rem; min-width:120px}
.summary .stat .val{font-size:1.5rem; font-weight:700; color:var(--accent)}
.summary .stat .lab{font-size:.75rem; color:var(--fg2); margin-top:.25rem}
/* ══ filters ═══════════════════════════════════════════════════════════ */
.filters{display:flex; gap:.75rem; margin-bottom:1.5rem; flex-wrap:wrap; align-items:center}
.filters select,.filters button{background:var(--surface); color:var(--fg); border:1px solid var(--fg3); border-radius:var(--radius); padding:.4rem .75rem; font-size:.8rem; cursor:pointer}
.filters button:hover{background:var(--surface2); border-color:var(--accent)}
.filters button.active{background:var(--accent); color:var(--bg); border-color:var(--accent)}
/* ══ pane section ══════════════════════════════════════════════════════ */
.pane{margin-bottom:2rem}
.pane h2{font-size:.95rem; font-weight:600; color:var(--fg2); margin-bottom:.75rem; text-transform:uppercase; letter-spacing:.05em}
.pane h2 .count{color:var(--fg3); font-weight:400; margin-left:.5rem}
/* ══ cards ═════════════════════════════════════════════════════════════ */
.cards{display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:.75rem}
.card{background:var(--surface); border-radius:var(--radius); padding:.75rem 1rem; border:1px solid var(--surface2); transition:border-color .15s}
.card.has-proof{border-color:var(--accent); cursor:pointer}
.card.has-proof:hover{border-color:var(--green); box-shadow:0 0 12px rgba(0,180,216,.12)}
.card-provider{font-size:.65rem; text-transform:uppercase; letter-spacing:.05em; color:var(--fg2); margin-bottom:4px}
.card-cost{font-size:1.15rem; font-weight:600; color:var(--fg)}
.card-model{font-size:.65rem; color:var(--muted); margin-bottom:6px}
.card-success{
position:absolute; top:8px; right:8px; font-size:.75rem;
width:20px; height:20px; border-radius:50%; display:flex; align-items:center; justify-content:center
}
.card-success.ok{background:rgba(63,185,80,.15); color:var(--green)}
.card-success.fail{background:rgba(248,81,73,.15); color:var(--red)}
.card-success{font-size:.7rem; font-weight:700; text-transform:uppercase; margin-bottom:.25rem}
.card-success.ok{color:var(--green)}
.card-success.fail{color:var(--red)}
.card-provider{font-size:.85rem; font-weight:600; color:var(--fg)}
.card-cost{font-size:1.6rem; font-weight:700; color:var(--green); margin:.25rem 0}
.card-model{font-size:.72rem; color:var(--fg2); margin-bottom:.5rem}
.card-proof-badge{
position:absolute; bottom:6px; right:6px; font-size:.55rem; color:var(--accent);
opacity:.6; text-transform:uppercase; letter-spacing:.04em
display:inline-block; font-size:.65rem; font-weight:700; padding:.15rem .5rem;
border-radius:3px; background:rgba(0,180,216,.12); color:var(--accent);
margin-top:.4rem; letter-spacing:.03em
}
/* cache bar */
.card-cache-bar{
display:flex; height:var(--bar-h); border-radius:3px; overflow:hidden; margin-top:4px
}
.card-cache-hit{background:var(--cache)}
.card-cache-fresh{background:var(--fresh)}
.card-tokens{font-size:.6rem; color:var(--muted); margin-top:3px}
/* ══ raw json panel ═════════════════════════════════════════════════════ */
.json-panel{
display:none; background:var(--surface); border:1px solid var(--border);
border-radius:8px; padding:1.25rem; margin-top:1.5rem; max-height:70vh; overflow:auto
}
.json-panel.open{display:block}
.json-panel pre{
font-family:inherit; font-size:.72rem; color:var(--green);
white-space:pre-wrap; word-break:break-all; line-height:1.5
}
.json-summary{font-size:.72rem; color:var(--fg2); margin-bottom:.75rem}
.card-cache-bar{display:flex; height:4px; border-radius:2px; overflow:hidden; margin:.4rem 0}
.card-cache-hit{background:var(--green)}
.card-cache-fresh{background:var(--fg3)}
.card-tokens{font-size:.7rem; color:var(--fg2); margin-top:.25rem}
/* ══ empty ═════════════════════════════════════════════════════════════ */
.empty{color:var(--fg3); font-size:.85rem; padding:2rem 0; text-align:center}
/* ══ lightbox ═══════════════════════════════════════════════════════════ */
.lightbox{display:none; position:fixed; inset:0; background:rgba(0,0,0,.94); z-index:1000;
align-items:center; justify-content:center; padding:1.5rem}
flex-direction:column; align-items:center; justify-content:center}
.lightbox.open{display:flex}
.lightbox img{max-width:94vw; max-height:88vh; border-radius:4px; box-shadow:0 0 40px rgba(0,180,216,.15)}
.lightbox pre{max-width:94vw; max-height:88vh; border-radius:4px; box-shadow:0 0 40px rgba(0,180,216,.15)}
.lightbox pre{max-width:94vw; max-height:88vh; border-radius:4px; box-shadow:0 0 40px rgba(0,180,216,.15);
overflow:auto; padding:1.5rem; background:var(--surface); color:var(--fg); font-size:.82rem; line-height:1.5; white-space:pre-wrap}
.lightbox-close{position:fixed; top:1rem; right:1.5rem; background:var(--surface); color:var(--fg2);
border:1px solid var(--border); border-radius:4px; padding:.4rem 1rem; font-family:inherit;
font-size:.78rem; cursor:pointer; z-index:1001}
border:1px solid var(--fg3); border-radius:var(--radius); padding:.4rem 1rem; cursor:pointer; font-size:.8rem}
.lightbox-close:hover{color:var(--accent); border-color:var(--accent)}
.lightbox-meta{
position:fixed; bottom:1.5rem; left:50%; transform:translateX(-50%);
background:var(--surface); color:var(--fg2); border:1px solid var(--border);
border-radius:6px; padding:.5rem 1rem; font-size:.68rem; z-index:1001
position:fixed; top:1rem; left:1.5rem; color:var(--fg2); font-size:.75rem
}
.lightbox-meta strong{color:var(--accent); margin-right:.5rem}
/* ══ empty state ════════════════════════════════════════════════════════ */
.empty{text-align:center; padding:4rem 2rem; color:var(--muted); font-size:.85rem}
.empty-icon{font-size:2.5rem; margin-bottom:.75rem}
/* ══ status markers ═════════════════════════════════════════════════════ */
.status-online{color:var(--green)}
.status-offline{color:var(--fg2)}
.cost-trend-up{color:var(--red)}
.cost-trend-down{color:var(--green)}
.llm-badge{
display:inline-block; background:rgba(0,180,216,.1); color:var(--accent);
font-size:.6rem; padding:1px 6px; border-radius:3px; margin-left:6px;
text-transform:uppercase; letter-spacing:.03em
}
/* ══ json panel ════════════════════════════════════════════════════════ */
.json-panel{display:none; margin-top:1.5rem; background:var(--surface); border-radius:var(--radius); padding:1rem; overflow:auto; max-height:70vh}
.json-panel pre{font-size:.75rem; line-height:1.4; color:var(--fg2); white-space:pre-wrap}
/* ══ footer ════════════════════════════════════════════════════════════ */
.footer{display:flex; justify-content:space-between; align-items:center; margin-top:2rem; padding-top:1rem; border-top:1px solid var(--surface2); font-size:.7rem; color:var(--fg3)}
.footer a{color:var(--accent); text-decoration:none}
.footer a:hover{text-decoration:underline}
</style>
</head>
<body>
<h1><span class="dot green" id="status-dot"></span>Clawdie Cost Dashboard</h1>
<div class="header">
<h1><span class="dot"></span> Colibri Cost Dashboard <span id="updated"></span></h1>
<div class="summary-bar" id="summary"></div>
<div class="summary" id="summary"></div>
<div class="filters">
<select id="filter-node"><option value="">All nodes</option></select>
<select id="filter-provider"><option value="">All providers</option></select>
<button id="filter-success" class="active">✓ success</button>
<button id="filter-fail" class="active">✗ failed</button>
<button id="btn-json">JSON</button>
<span style="margin-left:auto;font-size:.7rem;color:var(--fg3)" id="updated"></span>
</div>
<div class="controls">
<label>Node</label>
<select id="filter-node"><option value="">all nodes</option></select>
<label>Provider</label>
<select id="filter-provider">
<option value="">all providers</option>
<option value="deepseek">deepseek</option>
<option value="openrouter">openrouter</option>
<option value="anthropic">anthropic</option>
<option value="google">google</option>
<option value="ollama">ollama</option>
<option value="local">local</option>
</select>
<label>Status</label>
<select id="filter-success">
<option value="">all</option>
<option value="true">success</option>
<option value="false">failed</option>
</select>
<button class="btn btn-json" id="toggle-json">JSON</button>
</div>
<div class="nodes" id="nodes"></div>
<div class="empty" id="empty-state" style="display:none">
<div class="empty-icon"></div>
<p>No cost data yet. Tasks will appear here as agents complete work.</p>
</div>
<div class="json-panel" id="json-panel">
<div class="json-summary" id="json-summary"></div>
<pre id="json-output"></pre>
</div>
<div id="panes"></div>
<div class="lightbox" id="lightbox">
<button class="lightbox-close">Esc to close</button>
<button class="lightbox-close" onclick="closeLb()">Esc to close</button>
<div class="lightbox-meta" id="lightbox-meta"></div>
<img id="lb-img" src="" alt="screenshot proof" style="display:none">
<pre id="lb-text"></pre>
</div>
<div class="json-panel" id="json-panel"><pre id="json-text"></pre></div>
<div class="footer">
<span id="refresh-info">auto-refresh 60s</span>
<span><a href="https://clawdie.si" target="_blank">clawdie.si</a></span>
</div>
<script>
// ══ state ═══════════════════════════════════════════════════════════════
let DATA = null;
let FILTERS = { node: '', provider: '', success: '' };
let DATA = {summary:{},nodes:[],tasks:[]};
let FILTERS = {node:'',provider:'',success:true,fail:true};
// ══ data loading ════════════════════════════════════════════════════════
async function load() {
try {
const r = await fetch('task_costs.json');
DATA = await r.json();
} catch(e) {
document.getElementById('nodes').innerHTML =
'<div class="empty"><div class="empty-icon"></div><p>Waiting for cost data…</p></div>';
return;
}
// ══ init ════════════════════════════════════════════════════════════════
async function init() {
await fetchData();
render();
setInterval(async () => { await fetchData(); render(); }, 60000);
document.getElementById('filter-node').onchange = e => { FILTERS.node=e.target.value; render(); };
document.getElementById('filter-provider').onchange = e => { FILTERS.provider=e.target.value; render(); };
document.getElementById('filter-success').onclick = function(){ this.classList.toggle('active'); FILTERS.success=this.classList.contains('active'); render(); };
document.getElementById('filter-fail').onclick = function(){ this.classList.toggle('active'); FILTERS.fail=this.classList.contains('active'); render(); };
document.getElementById('btn-json').onclick = showJSON;
document.getElementById('lightbox').onclick = e => { if(e.target===e.currentTarget) closeLb(); };
document.addEventListener('keydown', e => { if(e.key==='Escape') closeLb(); });
// Also refresh JSON panel if open
document.getElementById('btn-json').addEventListener('click', function() {
const panel = document.getElementById('json-panel');
if (panel.style.display === 'block') showJSON();
});
}
// ══ render ══════════════════════════════════════════════════════════════
async function fetchData() {
try {
const resp = await fetch('task_costs.json?t=' + Date.now());
if (resp.ok) DATA = await resp.json();
document.getElementById('status-dot').className = 'dot green';
} catch(_) {
document.getElementById('status-dot').className = 'dot amber';
}
}
// ══ render ═════════════════════════════════════════════════════════════
function render() {
if (!DATA) return;
renderSummary();
renderFilters();
renderPanes();
const ts = DATA.updated_at ? new Date(DATA.updated_at).toLocaleTimeString() : '—';
document.getElementById('updated').textContent = 'updated ' + ts;
}
function renderSummary() {
const s = DATA.summary || {};
document.getElementById('updated').textContent =
DATA.updated_at ? '· ' + fmtDate(DATA.updated_at) : '';
document.getElementById('summary').innerHTML = `
<div class="stat"><div class="val">${s.total_tasks||0}</div><div class="lab">tasks</div></div>
<div class="stat"><div class="val">$${(s.total_cost||0).toFixed(4)}</div><div class="lab">total cost</div></div>
<div class="stat"><div class="val">${(s.success_rate||0).toFixed(0)}%</div><div class="lab">success</div></div>
<div class="stat"><div class="val">${(s.cache_hit_ratio||0).toFixed(0)}%</div><div class="lab">cache hits</div></div>
`;
}
// summary bar
document.getElementById('summary').innerHTML = [
{v: s.total_tasks||0, l:'tasks'},
{v: '$'+(s.total_cost||0).toFixed(3), l:'total cost'},
{v: '$'+(s.avg_cost||0).toFixed(4), l:'avg/task'},
{v: (s.success_rate||0)+'%', l:'success'},
{v: (s.cache_hit_ratio||0)+'%', l:'cache hit'},
{v: fmtTokens(s.total_input_tokens||0), l:'tokens in'},
{v: fmtTokens(s.total_output_tokens||0), l:'tokens out'},
].map(i => `<div class="summary-item"><div class="val">${i.v}</div><div class="lbl">${i.l}</div></div>`).join('');
function renderFilters() {
const nodes = new Set((DATA.tasks||[]).map(t => t.node).filter(Boolean));
const providers = new Set((DATA.tasks||[]).map(t => t.provider).filter(Boolean));
const selN = document.getElementById('filter-node');
const selP = document.getElementById('filter-provider');
selN.innerHTML = '<option value="">All nodes</option>' + [...nodes].sort().map(n => `<option>${esc(n)}</option>`).join('');
selP.innerHTML = '<option value="">All providers</option>' + [...providers].sort().map(p => `<option>${esc(p)}</option>`).join('');
}
// node filter dropdown
const nodeSel = document.getElementById('filter-node');
const nodes = DATA.nodes || [];
nodeSel.innerHTML = '<option value="">all nodes</option>' +
nodes.map(n => `<option value="${esc(n.hostname)}">${esc(n.hostname)}</option>`).join('');
// apply filters to tasks
const tasks = (DATA.tasks || []).filter(t => {
if (FILTERS.node && t.node !== FILTERS.node) return false;
if (FILTERS.provider && t.provider !== FILTERS.provider) return false;
if (FILTERS.success !== '' && String(t.success) !== FILTERS.success) return false;
return true;
function renderPanes() {
// Group by node
const groups = {};
for (const t of (DATA.tasks||[])) {
if (FILTERS.node && t.node !== FILTERS.node) continue;
if (FILTERS.provider && t.provider !== FILTERS.provider) continue;
if (t.success && !FILTERS.success) continue;
if (!t.success && !FILTERS.fail) continue;
const key = t.node || 'unknown';
if (!groups[key]) groups[key] = [];
groups[key].push(t);
}
const sorted = Object.entries(groups).sort((a,b) => {
const ca = a[1].reduce((s,t)=>s+(t.cost_usd||0),0);
const cb = b[1].reduce((s,t)=>s+(t.cost_usd||0),0);
return cb - ca;
});
// group tasks by node
const byNode = {};
for (const t of tasks) {
const n = t.node || 'unknown';
if (!byNode[n]) byNode[n] = [];
byNode[n].push(t);
}
// find node metadata
const nodeMeta = {};
for (const n of nodes) nodeMeta[n.hostname] = n;
const container = document.getElementById('nodes');
const empty = document.getElementById('empty-state');
if (Object.keys(byNode).length === 0) {
container.innerHTML = '';
empty.style.display = 'block';
} else {
empty.style.display = 'none';
container.innerHTML = Object.entries(byNode).map(([hostname, nodeTasks]) => {
const meta = nodeMeta[hostname] || {};
const totalCost = nodeTasks.reduce((s,t)=>s+(t.cost_usd||0),0);
const succeeded = nodeTasks.filter(t=>t.success).length;
const rate = nodeTasks.length > 0
? Math.round(succeeded / nodeTasks.length * 100) : 0;
return `
<div class="node-row">
<div class="node-head">
<div>
<span class="node-name">
<span class="status-dot online"></span>${esc(hostname)}
</span>
${meta.llm_tier ? `<span class="llm-badge">${esc(meta.llm_tier)}</span>` : ''}
</div>
<div class="node-meta">
<span><strong>${nodeTasks.length}</strong> tasks</span>
<span><strong>$${totalCost.toFixed(3)}</strong> total</span>
<span><strong>$${nodeTasks.length>0?(totalCost/nodeTasks.length).toFixed(4):'0.0000'}</strong> avg</span>
<span><strong>${rate}%</strong> success</span>
${meta.cache_hit_pct!=null ? `<span><strong>${meta.cache_hit_pct}%</strong> cache</span>` : ''}
</div>
</div>
<div class="node-cards">
${nodeTasks.map(t => renderCard(t)).join('')}
</div>
</div>`;
}).join('');
}
// update json panel if open
if (document.getElementById('json-panel').classList.contains('open')) {
showJSON();
let html = '';
for (const [node, tasks] of sorted) {
const nodeInfo = (DATA.nodes||[]).find(n => n.hostname === node) || {};
html += `<div class="pane"><h2>${esc(node)} <span class="count">${tasks.length} tasks</span></h2><div class="cards">`;
for (const t of tasks) html += renderCard(t);
html += '</div></div>';
}
document.getElementById('panes').innerHTML = html || '<div class="empty">No tasks match filters</div>';
}
// ══ cost card ═══════════════════════════════════════════════════════════
@ -305,10 +201,9 @@ function renderCard(t) {
const total = (t.input_tokens||0) + (t.cache_read_tokens||0);
const cachePct = total > 0 ? Math.round((t.cache_read_tokens||0) / total * 100) : 0;
const freshPct = 100 - cachePct;
// Only proof_text exists — daemon captures glasspane state at task exit.
// screenshot_uuid is a schema column for future visual capture, never
// populated by the current daemon (no screenshot.rs module on main).
const hasProof = !!t.proof_text;
const hasProofText = !!t.proof_text;
const hasScreenshot = !!t.screenshot_uuid;
const hasProof = hasProofText || hasScreenshot;
const cls = hasProof ? 'card has-proof' : 'card';
const onClick = hasProof
? `onclick="openProof(this,'${esc(t.task_id||'')}')"`
@ -316,7 +211,7 @@ function renderCard(t) {
return `
<div class="${cls}" ${onClick} title="${hasProof?'Click for proof':''}">
<div class="card-success ${t.success?'ok':'fail'}">${t.success?'✓':'✗'}</div>
<div class="card-success ${t.success?'ok':'fail'}">${t.success?'\u2713':'\u2717'}</div>
<div class="card-provider">${esc(t.provider||'unknown')}</div>
<div class="card-cost">$${(t.cost_usd||0).toFixed(4)}</div>
<div class="card-model">${esc(t.model||'')}</div>
@ -324,10 +219,10 @@ function renderCard(t) {
<div class="card-cache-hit" style="width:${cachePct}%"></div>
<div class="card-cache-fresh" style="width:${freshPct}%"></div>
</div>
<div class="card-tokens">${fmtTokens(t.input_tokens||0)} in · ${fmtTokens(t.output_tokens||0)} out
${cachePct>0?` · ${cachePct}% cache` : ''}
<div class="card-tokens">${fmtTokens(t.input_tokens||0)} in \u00b7 ${fmtTokens(t.output_tokens||0)} out
${cachePct>0?` \u00b7 ${cachePct}% cache` : ''}
</div>
${hasProof ? '<div class="card-proof-badge">▸ proof</div>' : ''}
${hasScreenshot ? '<div class="card-proof-badge">\u25b8 screenshot</div>' : hasProofText ? '<div class="card-proof-badge">\u25b8 text</div>' : ''}
</div>`;
}
@ -336,16 +231,24 @@ function openProof(_el, taskId) {
const t = (DATA.tasks || []).find(t => t.task_id === taskId);
const lb = document.getElementById('lightbox');
const meta = document.getElementById('lightbox-meta');
const img = document.getElementById('lb-img');
const pre = document.getElementById('lb-text');
meta.innerHTML = `<strong>${esc(taskId)}</strong> ${esc(t?.provider||'')} · $${(t?.cost_usd||0).toFixed(4)}`;
meta.innerHTML = `<strong>${esc(taskId)}</strong> ${esc(t?.provider||'')} \u00b7 $${(t?.cost_usd||0).toFixed(4)}`;
// Proof is always inline text (glasspane state JSON).
const raw = t?.proof_text || '{}';
let display;
try { display = JSON.stringify(JSON.parse(raw), null, 2); }
catch(_) { display = raw; }
pre.textContent = display;
pre.style.display = 'block';
// Hide both, then show the appropriate one.
img.style.display = 'none';
pre.style.display = 'none';
if (t?.screenshot_uuid) {
img.src = `../screenshots/${t.screenshot_uuid}.png`;
img.style.display = 'block';
} else if (t?.proof_text) {
let display;
try { display = JSON.stringify(JSON.parse(t.proof_text), null, 2); }
catch(_) { display = t.proof_text; }
pre.textContent = display;
pre.style.display = 'block';
}
lb.classList.add('open');
}
function closeLb() { document.getElementById('lightbox').classList.remove('open'); }
@ -356,61 +259,30 @@ function showJSON() {
const tasks = (DATA.tasks || []).filter(t => {
if (FILTERS.node && t.node !== FILTERS.node) return false;
if (FILTERS.provider && t.provider !== FILTERS.provider) return false;
if (FILTERS.success !== '' && String(t.success) !== FILTERS.success) return false;
if (t.success && !FILTERS.success) return false;
if (!t.success && !FILTERS.fail) return false;
return true;
});
const byNode = {};
for (const t of tasks) {
const n = t.node || 'unknown'; if (!byNode[n]) byNode[n] = [];
byNode[n].push({task_id:t.task_id,provider:t.provider,model:t.model,
cost:t.cost_usd,success:t.success,finished_at:t.finished_at,
tokens:{in:t.input_tokens,out:t.output_tokens,cache_read:t.cache_read_tokens},
proof_text:t.proof_text||null,screenshot_uuid:t.screenshot_uuid||null});
}
const output = {updated_at:DATA.updated_at, summary:DATA.summary, nodes:byNode};
document.getElementById('json-output').textContent = JSON.stringify(output, null, 2);
document.getElementById('json-summary').textContent =
`${tasks.length} tasks across ${Object.keys(byNode).length} nodes ` +
`· total cost $${(DATA.summary?.total_cost||0).toFixed(3)}`;
}).map(t => ({
task_id:t.task_id,node:t.node,provider:t.provider,model:t.model,
tokens:{in:t.input_tokens||0,out:t.output_tokens||0,cache_read:t.cache_read_tokens||0},
cost_usd:t.cost_usd,success:t.success,finished_at:t.finished_at,
proof_text:t.proof_text||null,screenshot_uuid:t.screenshot_uuid||null
}));
const panel = document.getElementById('json-panel');
const out = {
updated_at: DATA.updated_at,
summary: DATA.summary,
tasks: tasks
};
document.getElementById('json-text').textContent = JSON.stringify(out, null, 2);
panel.style.display = panel.style.display === 'block' ? 'none' : 'block';
}
// ══ events ══════════════════════════════════════════════════════════════
document.getElementById('filter-node').addEventListener('change', e => {
FILTERS.node = e.target.value; render();
});
document.getElementById('filter-provider').addEventListener('change', e => {
FILTERS.provider = e.target.value; render();
});
document.getElementById('filter-success').addEventListener('change', e => {
FILTERS.success = e.target.value; render();
});
document.getElementById('toggle-json').addEventListener('click', () => {
const panel = document.getElementById('json-panel');
const btn = document.getElementById('toggle-json');
panel.classList.toggle('open');
btn.classList.toggle('active');
if (panel.classList.contains('open')) showJSON();
});
document.getElementById('lightbox').addEventListener('click', e => {
if (e.target.classList.contains('lightbox') ) closeLb();
});
document.querySelector('.lightbox-close').addEventListener('click', closeLb);
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeLb(); document.getElementById('json-panel').classList.remove('open'); }
});
// ══ utils ═══════════════════════════════════════════════════════════════
// ══ helpers ═════════════════════════════════════════════════════════════
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function fmtTokens(n) { return n >= 1000000 ? (n/1000000).toFixed(1)+'M' : n >= 1000 ? (n/1000).toFixed(1)+'K' : String(n); }
function fmtDate(iso) {
if (!iso) return '';
try { const d = new Date(iso); return d.toLocaleDateString('sl-SI',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}); }
catch(e) { return iso.slice(0,16); }
}
// ══ init ════════════════════════════════════════════════════════════════
load();
setInterval(load, 60000); // auto-refresh every 60s
init();
</script>
</body>
</html>