feat(admin): operations console, fees wired into payouts
Fees now flow through settlement. The payout and the deduction are posted as separate ledger transactions rather than netted, so a player's history shows the full win and the charge as itemised lines instead of a quietly smaller win. The admin console shows treasury, liability, revenue, every posting, every round, and risk flags. Auth is a constant-time token compare and the surface is not mounted at all unless ARCADE_ADMIN_TOKEN is set, so a default deployment has no admin endpoint to attack. The token lives in browser memory only. It is read-only over game outcomes by design: seeds show only after settlement and nothing can alter a crash point. A control that could would make the fairness proof a lie. The console immediately found a real bug: 343 unresolved rounds, because the reconciler only considered rounds with bets and abandoned empty ones accumulated forever, burying the signal. Now cleared automatically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
113
cmd/arcade/static/admin.css
Normal file
113
cmd/arcade/static/admin.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* Operations console.
|
||||
*
|
||||
* Denser than the player interface on purpose: an operator is reading tables,
|
||||
* not playing a game. Same palette, but amber is reserved for money the house
|
||||
* holds and red for anything that needs a decision. */
|
||||
|
||||
body.admin { font-size: 13px; }
|
||||
body.admin .filigree { opacity: 0.25; }
|
||||
|
||||
.wrap { max-width: 1400px; margin: 0 auto; padding: 14px 14px 60px; }
|
||||
|
||||
.livedot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--green); margin-left: 10px;
|
||||
box-shadow: 0 0 10px var(--green);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||
.livedot.stale { background: var(--red); box-shadow: 0 0 10px var(--red); }
|
||||
|
||||
/* Six tiles across on a wide screen, two on a phone. */
|
||||
.tiles.ops { grid-template-columns: repeat(2, 1fr); }
|
||||
@media (min-width: 700px) { .tiles.ops { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (min-width: 1100px) { .tiles.ops { grid-template-columns: repeat(6, 1fr); } }
|
||||
|
||||
#tile-books.bad { border-color: var(--red); }
|
||||
#tile-books.bad .tile-value { color: var(--red); }
|
||||
|
||||
.opsnav { display: flex; gap: 4px; margin: 14px 0 12px; flex-wrap: wrap; }
|
||||
.opsnav button {
|
||||
font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
padding: 8px 14px; color: var(--green-dim);
|
||||
}
|
||||
.opsnav button.on {
|
||||
color: var(--green); border-color: var(--green); background: #00291c;
|
||||
}
|
||||
|
||||
.grid2 { display: grid; grid-template-columns: 1fr; gap: 12px; }
|
||||
@media (min-width: 900px) { .grid2 { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
.chart.tall { height: 190px; }
|
||||
|
||||
/* ---------- data tables ---------- */
|
||||
|
||||
.tablewrap { overflow-x: auto; margin-top: 8px; }
|
||||
|
||||
table.data {
|
||||
width: 100%; border-collapse: collapse; font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||
}
|
||||
table.data th {
|
||||
text-align: left; padding: 6px 10px 6px 0;
|
||||
font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: var(--green-dim); font-weight: 400;
|
||||
border-bottom: 1px solid var(--green-ghost);
|
||||
position: sticky; top: 0; background: #00120c;
|
||||
}
|
||||
table.data td {
|
||||
padding: 6px 10px 6px 0;
|
||||
border-bottom: 1px solid #06231a;
|
||||
color: var(--green);
|
||||
}
|
||||
table.data tr:hover td { background: #00ff9c0a; }
|
||||
|
||||
td.num { text-align: right; padding-right: 18px; }
|
||||
td.pos { color: var(--amber); }
|
||||
td.neg { color: var(--green-dim); }
|
||||
td.dim { color: var(--green-dim); }
|
||||
td.mono { font-size: 10px; color: var(--green-dim); }
|
||||
|
||||
.pill {
|
||||
display: inline-block; padding: 2px 6px; border-radius: 2px; font-size: 9.5px;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
border: 1px solid var(--green-ghost); color: var(--green-dim);
|
||||
}
|
||||
.pill.fee { color: var(--amber); border-color: #4a3300; }
|
||||
.pill.payout { color: var(--magenta); border-color: #4a0f2c; }
|
||||
.pill.void { color: var(--red); border-color: #4a1119; }
|
||||
|
||||
/* ---------- key/value grid ---------- */
|
||||
|
||||
.kvgrid {
|
||||
display: grid; grid-template-columns: 1fr; gap: 1px;
|
||||
background: var(--green-ghost); border: 1px solid var(--green-ghost);
|
||||
margin-top: 8px;
|
||||
}
|
||||
@media (min-width: 700px) { .kvgrid { grid-template-columns: repeat(3, 1fr); } }
|
||||
.kvgrid > div { background: #00120c; padding: 10px 12px; }
|
||||
.kvgrid .k {
|
||||
display: block; font-size: 9px; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: var(--green-dim); margin-bottom: 3px;
|
||||
}
|
||||
.kvgrid .v { font-size: 15px; color: var(--amber); font-weight: 700; }
|
||||
|
||||
/* ---------- risk flags ---------- */
|
||||
|
||||
.flags { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
||||
.flag {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
||||
border: 1px solid var(--green-ghost); border-radius: 3px; background: #00120c;
|
||||
}
|
||||
.flag .n {
|
||||
font-size: 19px; font-weight: 700; font-variant-numeric: tabular-nums;
|
||||
min-width: 46px;
|
||||
}
|
||||
.flag .t { font-size: 12px; color: var(--green-dim); }
|
||||
.flag.ok .n { color: var(--green); }
|
||||
.flag.warn { border-color: #4a3300; }
|
||||
.flag.warn .n { color: var(--amber); }
|
||||
.flag.bad { border-color: var(--red); }
|
||||
.flag.bad .n { color: var(--red); }
|
||||
|
||||
#player-filter { max-width: 340px; }
|
||||
193
cmd/arcade/static/admin.html
Normal file
193
cmd/arcade/static/admin.html
Normal file
@@ -0,0 +1,193 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>QA :: OPERATIONS</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
</head>
|
||||
<body class="admin">
|
||||
|
||||
<svg class="filigree" aria-hidden="true">
|
||||
<defs>
|
||||
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
|
||||
<g fill="none" stroke="currentColor" stroke-width="0.7">
|
||||
<path d="M15 0 L45 0 L60 26 L45 52 L15 52 L0 26 Z"/>
|
||||
<path d="M30 26 L60 26 M30 26 L15 0 M30 26 L15 52"/>
|
||||
<circle cx="30" cy="26" r="1.6"/>
|
||||
</g>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#orn)"/>
|
||||
</svg>
|
||||
|
||||
<!-- Gate. The token is held in memory only; it is never written to storage,
|
||||
so closing the tab ends the session. -->
|
||||
<section class="panel center" id="gate">
|
||||
<h1>OPERATIONS</h1>
|
||||
<p class="muted small">Restricted. Access is logged.</p>
|
||||
<input id="token" type="password" placeholder="operator token" autocomplete="off">
|
||||
<button class="primary" id="unlock">Authenticate</button>
|
||||
<p class="fineprint" id="gate-msg"></p>
|
||||
</section>
|
||||
|
||||
<div id="console" hidden>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">QUANTUM<span>OPS</span></div>
|
||||
<div class="livedot" id="livedot" title="auto-refreshing"></div>
|
||||
<div class="balance">
|
||||
<span class="label">house pot</span>
|
||||
<span class="value" id="hdr-pot">—</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Headline position -->
|
||||
<section class="wrap">
|
||||
<div class="tiles ops">
|
||||
<div class="tile">
|
||||
<span class="tile-label">house pot</span>
|
||||
<span class="tile-value" id="k-pot">—</span>
|
||||
<span class="tile-sub">operator funds</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="tile-label">owed to players</span>
|
||||
<span class="tile-value" id="k-owed">—</span>
|
||||
<span class="tile-sub">liability</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="tile-label">fees collected</span>
|
||||
<span class="tile-value up" id="k-fees">—</span>
|
||||
<span class="tile-sub" id="k-fees-24h">—</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="tile-label">margin 24h</span>
|
||||
<span class="tile-value" id="k-margin">—</span>
|
||||
<span class="tile-sub" id="k-volume">—</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="tile-label">players</span>
|
||||
<span class="tile-value" id="k-players">—</span>
|
||||
<span class="tile-sub" id="k-active">—</span>
|
||||
</div>
|
||||
<div class="tile" id="tile-books">
|
||||
<span class="tile-label">books</span>
|
||||
<span class="tile-value" id="k-books">—</span>
|
||||
<span class="tile-sub" id="k-conservation">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="opsnav" id="opsnav">
|
||||
<button class="on" data-panel="dash">Dashboard</button>
|
||||
<button data-panel="players">Players</button>
|
||||
<button data-panel="ledger">Ledger</button>
|
||||
<button data-panel="rounds">Rounds</button>
|
||||
<button data-panel="risk">Risk</button>
|
||||
</nav>
|
||||
|
||||
<!-- DASHBOARD -->
|
||||
<section class="opspanel" id="panel-dash">
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<h2>Revenue, 30 days</h2>
|
||||
<div class="chart tall" id="chart-revenue"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Stakes vs payouts</h2>
|
||||
<div class="chart tall" id="chart-flow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Fee schedule in force</h2>
|
||||
<div id="fee-schedule" class="kvgrid"></div>
|
||||
<p class="muted small">
|
||||
Rendered from the same values the server charges. If this table is
|
||||
wrong, the code is wrong — it is not a separate document.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PLAYERS -->
|
||||
<section class="opspanel" id="panel-players" hidden>
|
||||
<div class="card">
|
||||
<h2>Accounts</h2>
|
||||
<input id="player-filter" placeholder="filter by name or key" autocomplete="off">
|
||||
<div class="tablewrap">
|
||||
<table class="data" id="tbl-players">
|
||||
<thead><tr>
|
||||
<th>id</th><th>name</th><th>balance</th><th>bets</th>
|
||||
<th>wagered</th><th>won</th><th>net</th><th>last seen</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LEDGER -->
|
||||
<section class="opspanel" id="panel-ledger" hidden>
|
||||
<div class="card">
|
||||
<h2>Every posting</h2>
|
||||
<p class="muted small">
|
||||
Append-only. Rows are never modified or deleted; corrections appear
|
||||
as compensating entries.
|
||||
</p>
|
||||
<div class="tablewrap">
|
||||
<table class="data" id="tbl-ledger">
|
||||
<thead><tr>
|
||||
<th>id</th><th>kind</th><th>round</th><th>account</th>
|
||||
<th>amount</th><th>before</th><th>after</th><th>when</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ROUNDS -->
|
||||
<section class="opspanel" id="panel-rounds" hidden>
|
||||
<div class="card">
|
||||
<h2>Recent rounds</h2>
|
||||
<p class="muted small">
|
||||
Seeds appear only after settlement. There is no control here that
|
||||
reveals a sealed seed or alters an outcome — that is what makes the
|
||||
fairness proof worth anything.
|
||||
</p>
|
||||
<div class="tablewrap">
|
||||
<table class="data" id="tbl-rounds">
|
||||
<thead><tr>
|
||||
<th>id</th><th>game</th><th>crash</th><th>players</th>
|
||||
<th>staked</th><th>paid</th><th>fees</th><th>house</th><th>seed</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RISK -->
|
||||
<section class="opspanel" id="panel-risk" hidden>
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<h2>Attention</h2>
|
||||
<div id="risk-flags" class="flags"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Largest net winners</h2>
|
||||
<div class="tablewrap">
|
||||
<table class="data" id="tbl-winners">
|
||||
<thead><tr><th>account</th><th>name</th><th>net</th><th>bets</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
317
cmd/arcade/static/admin.js
Normal file
317
cmd/arcade/static/admin.js
Normal file
@@ -0,0 +1,317 @@
|
||||
/* 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;
|
||||
});
|
||||
Reference in New Issue
Block a user