@@ -136,6 +136,27 @@ export function renderUi(prefix) {
136136 <div id="settled" class="hint">nothing settled yet</div>
137137 </div>
138138
139+ <div class="card hidden" id="admin-card">
140+ <h2>Operator</h2>
141+ <p class="hint">Disputes wait here. If nobody adjudicates before the grace period expires the
142+ oracle's resolution stands and the disputer forfeits their bond — so an unworked queue
143+ is a policy decision, not a pause.</p>
144+ <div id="admin-disputes" class="hint">no open disputes</div>
145+ <div class="row">
146+ <input id="ad-agent" placeholder="Agent WebID" style="flex:1;min-width:12rem" aria-label="Agent WebID">
147+ <button class="small" id="ad-lookup">History</button>
148+ <button class="small" id="ad-freeze">Freeze</button>
149+ <button class="small" id="ad-unfreeze">Unfreeze</button>
150+ </div>
151+ <div class="row">
152+ <input id="ad-credits" type="number" placeholder="± credits" style="width:8rem" aria-label="Credit adjustment">
153+ <input id="ad-reason" placeholder="Reason (journalled)" style="flex:1;min-width:10rem" aria-label="Reason">
154+ <button class="small" id="ad-adjust">Adjust</button>
155+ </div>
156+ <div class="msg" id="admin-msg"></div>
157+ <pre id="admin-out" class="hint" style="overflow-x:auto;max-height:16rem"></pre>
158+ </div>
159+
139160 <details class="card">
140161 <summary style="cursor:pointer;font-weight:600">Create a market</summary>
141162 <p class="hint">You escrow b·ln(n) credits as maker liquidity. You get your escrow back
@@ -310,6 +331,8 @@ export function renderUi(prefix) {
310331 $('auth-card').classList.add('hidden');
311332 $('me-card').classList.remove('hidden');
312333 renderMe();
334+ $('admin-card').classList.toggle('hidden', !me.isAdmin);
335+ if (me.isAdmin) renderDisputes();
313336 // A settlement that landed since the last poll is the moment that
314337 // matters most in a betting product — announce it.
315338 if (prev && me.settlements.length && (!prev.settlements.length
@@ -373,6 +396,47 @@ export function renderUi(prefix) {
373396 // Cash out a SPECIFIC outcome. Taking "the first outcome with shares"
374397 // silently sold the wrong leg for anyone holding two sides of a market,
375398 // so the outcome index is always passed explicitly.
399+ // ------------------------------------------------------------ admin
400+ async function renderDisputes() {
401+ const el = $('admin-disputes');
402+ try {
403+ const { disputes } = await api('/admin/disputes');
404+ if (!disputes.length) { el.innerHTML = '<span class="hint">no open disputes</span>'; return; }
405+ el.innerHTML = disputes.map((d) => '<div class="mrow"><b>' + esc(d.title) + '</b>'
406+ + '<div class="meta">resolved as <b>' + esc(d.outcomes[d.resolvedOutcome]) + '</b> · '
407+ + d.disputeDetail.length + ' dispute(s) · auto-settles ' + new Date(d.autoVoidsAt).toLocaleString()
408+ + '</div>'
409+ + d.disputeDetail.map((x) => '<div class="meta">· ' + esc(x.agent) + ' (bond ' + cr(x.bond) + '): '
410+ + esc(x.reason) + '</div>').join('')
411+ + '<div class="row">'
412+ + '<button class="small adj-up" data-m="' + esc(d.id) + '">Uphold</button>'
413+ + '<select class="adj-out" data-m="' + esc(d.id) + '" aria-label="Re-resolve to">'
414+ + d.outcomes.map((o, i) => '<option value="' + i + '">' + esc(o) + '</option>').join('')
415+ + '</select>'
416+ + '<button class="small adj-re" data-m="' + esc(d.id) + '">Re-resolve</button>'
417+ + '<button class="small adj-void" data-m="' + esc(d.id) + '">Void</button>'
418+ + '<button class="small adj-hide" data-m="' + esc(d.id) + '">Hide</button>'
419+ + '</div></div>').join('');
420+ const act2 = async (path, body) => {
421+ try { await api(path, { method: 'POST', body: JSON.stringify(body) }); toast('done'); await renderDisputes(); await refreshMe(); }
422+ catch (e) { $('admin-msg').textContent = e.message; $('admin-msg').className = 'msg err'; }
423+ };
424+ el.querySelectorAll('.adj-up').forEach((b) => {
425+ b.onclick = () => act2('/admin/adjudicate', { market: b.dataset.m, uphold: true });
426+ });
427+ el.querySelectorAll('.adj-re').forEach((b) => {
428+ const sel = el.querySelector('.adj-out[data-m="' + b.dataset.m + '"]');
429+ b.onclick = () => act2('/admin/adjudicate', { market: b.dataset.m, uphold: false, outcome: Number(sel.value) });
430+ });
431+ el.querySelectorAll('.adj-void').forEach((b) => {
432+ b.onclick = () => confirm('Void this market? Nobody wins.') && act2('/admin/adjudicate', { market: b.dataset.m, uphold: false });
433+ });
434+ el.querySelectorAll('.adj-hide').forEach((b) => {
435+ b.onclick = () => act2('/admin/hide', { market: b.dataset.m, hidden: true });
436+ });
437+ } catch (e) { el.innerHTML = '<span class="hint">' + esc(e.message) + '</span>'; }
438+ }
439+
376440 async function cashOut(marketId, outcome) {
377441 const m = await api('/markets/' + encodeURIComponent(marketId));
378442 if (!m.position) return;
@@ -619,6 +683,29 @@ export function renderUi(prefix) {
619683 } catch (e) { $('auth-msg').textContent = e.message; $('auth-msg').className = 'msg err'; }
620684 };
621685 $('back').onclick = (e) => { e.preventDefault(); location.hash = ''; };
686+ const adminAct = async (fn) => {
687+ $('admin-msg').textContent = ''; $('admin-msg').className = 'msg';
688+ try { await fn(); await refreshMe(); }
689+ catch (e) { $('admin-msg').textContent = e.message; $('admin-msg').className = 'msg err'; }
690+ };
691+ $('ad-lookup').onclick = () => adminAct(async () => {
692+ const h = await api('/admin/agent?agent=' + encodeURIComponent($('ad-agent').value.trim()));
693+ $('admin-out').textContent = JSON.stringify(h, null, 1);
694+ });
695+ $('ad-freeze').onclick = () => adminAct(() => api('/admin/freeze', {
696+ method: 'POST', body: JSON.stringify({ agent: $('ad-agent').value.trim(), frozen: true }),
697+ }).then(() => toast('frozen')));
698+ $('ad-unfreeze').onclick = () => adminAct(() => api('/admin/freeze', {
699+ method: 'POST', body: JSON.stringify({ agent: $('ad-agent').value.trim(), frozen: false }),
700+ }).then(() => toast('unfrozen')));
701+ $('ad-adjust').onclick = () => adminAct(() => api('/admin/adjust', {
702+ method: 'POST',
703+ body: JSON.stringify({
704+ agent: $('ad-agent').value.trim(),
705+ credits: Number($('ad-credits').value),
706+ reason: $('ad-reason').value,
707+ }),
708+ }).then(() => toast('adjusted — journalled')));
622709 $('t-buy').onclick = () => {
623710 if (!me) {
624711 $('auth-card').classList.remove('hidden');
0 commit comments