/* Operations console. * * The token lives in memory only — never localStorage, never a cookie — so * closing the tab ends the session and nothing is left on a shared machine. * * Every value here is read from the ledger. Nothing is computed twice: if a * number looks wrong, the ledger is wrong, and that is the point of showing it. */ import * as charts from '/charts.js'; let token = null; let timer = null; const $ = (id) => document.getElementById(id); /* Money is stored in millisatoshis. Operators think in sats. */ const sats = (msat) => Math.round((msat || 0) / 1000).toLocaleString(); const signed = (msat) => (msat > 0 ? '+' : '') + sats(msat); function el(tag, attrs, ...children) { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs || {})) { if (k === 'class') node.className = v; else if (k === 'text') node.textContent = v; else node.setAttribute(k, v); } for (const c of children) { if (c == null) continue; node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } return node; } function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); } async function api(path) { const res = await fetch(path, { headers: { Authorization: 'Bearer ' + token } }); if (!res.ok) throw new Error(`${res.status}`); return res.json(); } /* ---------------- gate ---------------- */ async function unlock() { token = $('token').value.trim(); try { await api('/admin/api/overview'); } catch (e) { $('gate-msg').textContent = e.message === '401' ? 'Rejected.' : 'Unavailable: ' + e.message; token = null; return; } $('gate').hidden = true; $('console').hidden = false; await refreshAll(); // Poll rather than stream: the console is read-only and a few seconds of // staleness costs nothing, whereas another websocket per operator does. timer = setInterval(refreshAll, 5000); } /* ---------------- refresh ---------------- */ async function refreshAll() { try { await Promise.all([loadOverview(), loadActivePanel()]); $('livedot').classList.remove('stale'); } catch { // A failed poll marks the display stale rather than blanking it: old // numbers with a warning beat no numbers. $('livedot').classList.add('stale'); } } async function loadOverview() { const d = await api('/admin/api/overview'); $('hdr-pot').textContent = sats(d.house_pot_msat); $('k-pot').textContent = sats(d.house_pot_msat); $('k-owed').textContent = sats(d.owed_to_players); $('k-fees').textContent = sats(d.fees_all_time_msat); $('k-fees-24h').textContent = sats(d.fees_24h_msat) + ' in 24h'; const margin = $('k-margin'); margin.textContent = signed(d.gross_margin_24h); margin.className = 'tile-value ' + (d.gross_margin_24h >= 0 ? 'up' : 'down'); $('k-volume').textContent = sats(d.wagered_24h_msat) + ' wagered'; $('k-players').textContent = d.players_total.toLocaleString(); $('k-active').textContent = d.players_active_24h + ' active 24h'; // The books check is the one number that must never be wrong. const books = $('k-books'); books.textContent = d.books_balanced ? 'BALANCED' : 'IMBALANCE'; $('tile-books').classList.toggle('bad', !d.books_balanced); $('k-conservation').textContent = d.books_balanced ? 'sums to zero' : `off by ${d.conservation_msat} msat`; } function activePanel() { const on = document.querySelector('.opsnav button.on'); return on ? on.dataset.panel : 'dash'; } async function loadActivePanel() { switch (activePanel()) { case 'dash': return loadDashboard(); case 'players': return loadPlayers(); case 'ledger': return loadLedger(); case 'rounds': return loadRounds(); case 'risk': return loadRisk(); } } /* ---------------- dashboard ---------------- */ async function loadDashboard() { const d = await api('/admin/api/revenue'); const days = d.daily || []; charts.balanceChart($('chart-revenue'), days.map((x) => ({ BalanceAfter: x.net_msat }))); // Stakes in against payouts out, as a simple two-series comparison. charts.crashHistoryChart($('chart-flow'), days.map((x) => Math.max(1, x.stakes_in_msat / Math.max(1, x.paid_out_msat)))); const grid = $('fee-schedule'); clear(grid); const s = d.fee_schedule || {}; const rows = [ ['rake', s.rake_percent], ['rounding unit', s.rounding_unit], ['minimum payout', s.minimum_payout], ['game rtp', s.game_rtp_percent], ['effective rtp', s.effective_rtp_percent], ['worst case rounding', s.worst_case_rounding_per_payout], ]; for (const [k, v] of rows) { grid.appendChild(el('div', {}, el('span', { class: 'k', text: k }), el('span', { class: 'v', text: v || '—' }))); } } /* ---------------- players ---------------- */ let playersCache = []; async function loadPlayers() { const d = await api('/admin/api/players'); playersCache = d.players || []; renderPlayers(); } function renderPlayers() { const q = $('player-filter').value.trim().toLowerCase(); const body = $('tbl-players').querySelector('tbody'); clear(body); for (const p of playersCache) { if (q && !p.nickname.toLowerCase().includes(q) && !p.pubkey.includes(q)) continue; const tr = el('tr', {}); tr.appendChild(el('td', { class: 'dim', text: String(p.id) })); tr.appendChild(el('td', { text: p.nickname || '—' })); tr.appendChild(el('td', { class: 'num pos', text: sats(p.balance_msat) })); tr.appendChild(el('td', { class: 'num dim', text: String(p.bets) })); tr.appendChild(el('td', { class: 'num', text: sats(p.wagered_msat) })); tr.appendChild(el('td', { class: 'num', text: sats(p.won_msat) })); tr.appendChild(el('td', { class: 'num ' + (p.net_msat >= 0 ? 'pos' : 'neg'), text: signed(p.net_msat), })); tr.appendChild(el('td', { class: 'dim', text: p.last_seen ? new Date(p.last_seen).toLocaleString() : '—', })); body.appendChild(tr); } } /* ---------------- ledger ---------------- */ async function loadLedger() { const d = await api('/admin/api/transactions'); const body = $('tbl-ledger').querySelector('tbody'); clear(body); for (const e of d.transactions || []) { const tr = el('tr', {}); tr.appendChild(el('td', { class: 'dim', text: String(e.id) })); const cls = e.kind === 'operating_fee' ? 'pill fee' : e.kind === 'payout' ? 'pill payout' : 'pill'; tr.appendChild(el('td', {}, el('span', { class: cls, text: e.kind }))); tr.appendChild(el('td', { class: 'dim', text: e.round_id ? String(e.round_id) : '—' })); tr.appendChild(el('td', { text: e.nickname || String(e.account_id) })); tr.appendChild(el('td', { class: 'num ' + (e.amount_msat >= 0 ? 'pos' : 'neg'), text: signed(e.amount_msat), })); tr.appendChild(el('td', { class: 'num dim', text: sats(e.balance_before) })); tr.appendChild(el('td', { class: 'num', text: sats(e.balance_after) })); tr.appendChild(el('td', { class: 'dim', text: new Date(e.created_at).toLocaleTimeString() })); body.appendChild(tr); } } /* ---------------- rounds ---------------- */ async function loadRounds() { const d = await api('/admin/api/rounds'); const body = $('tbl-rounds').querySelector('tbody'); clear(body); for (const r of d.rounds || []) { const tr = el('tr', {}); tr.appendChild(el('td', { class: 'dim', text: String(r.id) })); tr.appendChild(el('td', { text: r.game })); const crash = r.crash_point ? (r.crash_point / 4294967296).toFixed(2) + '×' : '—'; tr.appendChild(el('td', { class: 'num', text: crash })); tr.appendChild(el('td', { class: 'num dim', text: String(r.players) })); tr.appendChild(el('td', { class: 'num', text: sats(r.staked_msat) })); tr.appendChild(el('td', { class: 'num', text: sats(r.paid_msat) })); tr.appendChild(el('td', { class: 'num pos', text: sats(r.rake_msat) })); tr.appendChild(el('td', { class: 'num ' + (r.house_result_msat >= 0 ? 'pos' : 'neg'), text: signed(r.house_result_msat), })); // The seed cell is the honest one: sealed until settlement, and there is // no control that opens it early. const seedCell = el('td', { class: 'mono' }); if (r.voided_at) { seedCell.appendChild(el('span', { class: 'pill void', text: 'void' })); } else if (r.server_seed) { seedCell.textContent = r.server_seed.slice(0, 16) + '…'; } else { seedCell.appendChild(el('span', { class: 'pill', text: 'sealed' })); } tr.appendChild(seedCell); body.appendChild(tr); } } /* ---------------- risk ---------------- */ async function loadRisk() { const d = await api('/admin/api/risk'); const flags = $('risk-flags'); clear(flags); const items = [ { n: d.withdrawals_to_review, t: 'withdrawals awaiting your approval', level: d.withdrawals_to_review > 0 ? 'warn' : 'ok', }, { n: d.pending_withdrawals, t: 'withdrawals queued or sending', level: 'ok', }, { n: d.unresolved_rounds, t: 'rounds unresolved past the staleness window', level: d.unresolved_rounds > 0 ? 'warn' : 'ok', }, { n: d.books_balanced ? 0 : d.conservation_msat, t: d.books_balanced ? 'ledger imbalance — books sum to zero' : 'LEDGER IMBALANCE — investigate immediately', level: d.books_balanced ? 'ok' : 'bad', }, ]; for (const it of items) { flags.appendChild(el('div', { class: 'flag ' + it.level }, el('span', { class: 'n', text: String(it.n) }), el('span', { class: 't', text: it.t }))); } const body = $('tbl-winners').querySelector('tbody'); clear(body); for (const wnr of d.top_winners || []) { const tr = el('tr', {}); tr.appendChild(el('td', { class: 'dim', text: String(wnr.account_id) })); tr.appendChild(el('td', { text: wnr.nickname || '—' })); tr.appendChild(el('td', { class: 'num pos', text: signed(wnr.net_msat) })); tr.appendChild(el('td', { class: 'num dim', text: String(wnr.bets) })); body.appendChild(tr); } } /* ---------------- wiring ---------------- */ function selectPanel(name) { document.querySelectorAll('.opsnav button').forEach((b) => b.classList.toggle('on', b.dataset.panel === name)); document.querySelectorAll('.opspanel').forEach((p) => (p.hidden = p.id !== 'panel-' + name)); loadActivePanel().catch(() => $('livedot').classList.add('stale')); } $('unlock').onclick = () => unlock(); $('token').onkeydown = (e) => { if (e.key === 'Enter') unlock(); }; $('player-filter').oninput = renderPlayers; document.querySelectorAll('.opsnav button').forEach((b) => (b.onclick = () => selectPanel(b.dataset.panel))); window.addEventListener('beforeunload', () => { if (timer) clearInterval(timer); token = null; });