fix(dashboard): restore dual-proof lightbox — screenshot + text badges
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:
parent
80543c5f46
commit
ff9c8511f9
1 changed files with 192 additions and 320 deletions
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue