const SATS_PER_PLM = 100000000; // Display formatter: a raw sats/SATS_PER_PLM division renders binary // floating-point artefacts (0.7000000000000001) in the tables below (B-22). // Input fields keep the raw value — they have to stay parseable. function fmtPlm(sats) { if (sats === null || sats === undefined) return '—'; return new Intl.NumberFormat('it-IT', { maximumFractionDigits: 8 }).format(sats / SATS_PER_PLM); } let adminToken = sessionStorage.getItem('plm_admin_token'); function toast(message, type) { const container = document.getElementById('toast-container'); const el = document.createElement('div'); el.className = 'toast ' + type; el.textContent = message; container.appendChild(el); setTimeout(() => el.remove(), 4000); } async function withLoading(button, label, fn) { const original = button.textContent; button.disabled = true; button.textContent = label; try { await fn(); } finally { button.disabled = false; button.textContent = original; } } async function callAdmin(method, path, body) { const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken }; const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined }); const data = await res.json().catch(() => ({})); // detail is a bare string on the admin endpoints, but the shared dependencies // (auth) answer with the structured {code, message} form of app/api/errors.py. if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText); return data; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function badge(status) { return `${escapeHtml(status)}`; } function fmtDate(iso) { if (!iso) return '—'; return new Date(iso).toLocaleString('it-IT'); } const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out']; const CHAIN_STATUS_LABELS = { waiting: 'In attesa del prossimo round', open: 'Round aperto', drawing: 'Estrazione in corso', }; let chainStatusInterval = null; async function refreshChainStatus() { try { const res = await fetch('/rounds/current'); const data = await res.json(); let statusKey; if (!data.round_id) statusKey = 'waiting'; else if (DRAWING_STATUSES.includes(data.status)) statusKey = 'drawing'; else statusKey = 'open'; document.getElementById('chain-status-dot').className = 'status-dot status-' + statusKey; document.getElementById('chain-status-label').textContent = CHAIN_STATUS_LABELS[statusKey]; document.getElementById('chain-block').textContent = 'Blocco ' + (data.chain_tip_height != null ? '#' + data.chain_tip_height : '—'); } catch (e) { // leave the last-known status on screen rather than blanking it out } } function startChainStatusPolling() { refreshChainStatus(); clearInterval(chainStatusInterval); chainStatusInterval = setInterval(refreshChainStatus, 15000); } function stopChainStatusPolling() { clearInterval(chainStatusInterval); chainStatusInterval = null; } const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit']; const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog }; let currentAdminView = 'parametri'; function switchView(name) { currentAdminView = name; for (const key of VIEWS) { document.getElementById('nav-' + key).classList.toggle('active', key === name); document.getElementById('view-' + key).classList.toggle('active', key === name); } if (VIEW_LOADERS[name]) VIEW_LOADERS[name](); } function showDashboard() { document.getElementById('login-section').classList.add('hidden'); document.getElementById('dashboard-section').classList.remove('hidden'); startChainStatusPolling(); } async function loadDashboard() { await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog()]); } async function adminLogin() { const btn = document.getElementById('login-btn'); adminToken = document.getElementById('admin-token').value; await withLoading(btn, 'Verifica…', async () => { try { await callAdmin('GET', '/admin/config'); sessionStorage.setItem('plm_admin_token', adminToken); showDashboard(); await loadDashboard(); } catch (e) { adminToken = null; toast('Token non valido.', 'error'); } }); } function adminLogout() { stopChainStatusPolling(); sessionStorage.removeItem('plm_admin_token'); adminToken = null; document.getElementById('admin-token').value = ''; document.getElementById('dashboard-section').classList.add('hidden'); document.getElementById('login-section').classList.remove('hidden'); } async function adminLoadConfig() { try { const data = await callAdmin('GET', '/admin/config'); document.getElementById('admin-fee-address').value = data.fee_address; document.getElementById('admin-bet-amount').value = data.bet_amount_sats / SATS_PER_PLM; document.getElementById('admin-round-duration').value = data.round_duration_seconds; document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds; document.getElementById('admin-draw-animation').value = data.draw_animation_seconds; document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb; document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds; renderMaintenanceState(data.paused); } catch (e) { toast('Errore nel caricamento configurazione: ' + e.message, 'error'); } } function renderMaintenanceState(paused) { const dot = document.getElementById('maintenance-dot'); const label = document.getElementById('maintenance-status-label'); const btn = document.getElementById('maintenance-btn'); btn.dataset.paused = paused ? '1' : '0'; if (paused) { dot.className = 'status-dot status-paused'; label.textContent = 'In pausa: nessun nuovo round verrà aperto'; btn.textContent = 'Riprendi lotteria'; btn.classList.remove('btn-stop'); } else { dot.className = 'status-dot status-open'; label.textContent = 'Attiva: i round si susseguono normalmente'; btn.textContent = 'Interrompi dopo questo round'; btn.classList.add('btn-stop'); } } async function toggleMaintenance() { const btn = document.getElementById('maintenance-btn'); const isPaused = btn.dataset.paused === '1'; const path = isPaused ? '/admin/resume' : '/admin/pause'; if (!isPaused && !window.confirm( "Nessun nuovo round verrà aperto dopo quello in corso, fino a quando non riprendi la lotteria. " + "Il round attuale (se presente) verrà comunque completato e il vincitore pagato. Continuare?" )) { return; } btn.disabled = true; try { const data = await callAdmin('POST', path, {}); renderMaintenanceState(data.paused); toast(data.paused ? 'Lotteria in pausa.' : 'Lotteria ripresa.', 'success'); refreshChainStatus(); } catch (e) { toast('Errore: ' + e.message, 'error'); } finally { btn.disabled = false; } } async function adminSave() { const btn = document.getElementById('save-btn'); const feeAddress = document.getElementById('admin-fee-address').value; const betAmountPlm = parseFloat(document.getElementById('admin-bet-amount').value); const body = { fee_address: feeAddress, bet_amount_sats: Math.round(betAmountPlm * SATS_PER_PLM), round_duration_seconds: parseInt(document.getElementById('admin-round-duration').value, 10), round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10), draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10), fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10), rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10), }; await withLoading(btn, 'Salvataggio…', async () => { try { await callAdmin('PUT', '/admin/config', body); toast('Configurazione salvata.', 'success'); } catch (e) { toast('Errore nel salvataggio: ' + e.message, 'error'); } }); } async function loadUsers() { try { const users = await callAdmin('GET', '/admin/users'); const tbody = document.getElementById('users-tbody'); tbody.innerHTML = users.map((u) => ` ${u.id} ${escapeHtml(u.username)} ${escapeHtml(u.address)} ${fmtPlm(u.balance_sats)} ${fmtDate(u.created_at)} `).join('') || 'Nessun utente registrato.'; } catch (e) { toast('Errore nel caricamento utenti: ' + e.message, 'error'); } } async function revealPrivkey(userId, button) { const box = document.getElementById('privkey-' + userId); if (!box.classList.contains('hidden')) { box.classList.add('hidden'); box.textContent = ''; button.textContent = 'Mostra'; return; } if (!window.confirm('Stai per visualizzare la chiave privata di questo utente. L\'accesso verrà registrato nell\'audit log. Continuare?')) { return; } await withLoading(button, '…', async () => { try { const data = await callAdmin('GET', '/admin/users/' + userId + '/privkey'); box.textContent = data.wif; box.classList.remove('hidden'); button.textContent = 'Nascondi'; } catch (e) { toast('Errore: ' + e.message, 'error'); } }); } async function resetUserPassword(userId, button) { if (!window.confirm( "Verrà generata una nuova password casuale per questo utente, che non potrà più accedere con quella vecchia. " + "L'azione viene registrata nell'audit log. Continuare?" )) { return; } const box = document.getElementById('newpass-' + userId); await withLoading(button, '…', async () => { try { const data = await callAdmin('POST', '/admin/users/' + userId + '/reset-password'); box.textContent = 'Nuova password per ' + data.username + ': ' + data.new_password; box.classList.remove('hidden'); toast('Password reimpostata.', 'success'); } catch (e) { toast('Errore: ' + e.message, 'error'); } }); } async function loadRounds() { try { const rounds = await callAdmin('GET', '/admin/rounds'); const tbody = document.getElementById('rounds-tbody'); tbody.innerHTML = rounds.map((r) => ` ${r.id} ${badge(r.status)} ${fmtDate(r.opened_at)} ${r.winner_username ? escapeHtml(r.winner_username) : '—'} ${fmtPlm(r.pool_amount_sats)} ${fmtPlm(r.winner_amount_sats)} ${fmtPlm(r.fee_amount_sats)} ${r.payout_txid ? escapeHtml(r.payout_txid) : '—'} `).join('') || 'Nessun round ancora.'; } catch (e) { toast('Errore nel caricamento round: ' + e.message, 'error'); } } async function loadPending() { try { const items = await callAdmin('GET', '/admin/pending-transactions'); const tbody = document.getElementById('pending-tbody'); tbody.innerHTML = items.map((p) => ` ${p.id} ${escapeHtml(p.kind)} ${badge(p.status)} ${escapeHtml(p.current_txid)} ${p.fee_rate_sat_vb} sat/vB ${p.attempt_count} ${fmtDate(p.broadcast_at)} `).join('') || 'Nessuna transazione pendente.'; } catch (e) { toast('Errore nel caricamento transazioni pendenti: ' + e.message, 'error'); } } async function loadAuditLog() { try { const entries = await callAdmin('GET', '/admin/audit-log'); const tbody = document.getElementById('audit-tbody'); tbody.innerHTML = entries.map((e) => ` ${e.id} ${escapeHtml(e.event_type)}
${escapeHtml(JSON.stringify(e.payload))}
${e.user_id ?? '—'} ${e.round_id ?? '—'} ${fmtDate(e.created_at)} `).join('') || 'Nessun evento registrato.'; } catch (e) { toast('Errore nel caricamento audit log: ' + e.message, 'error'); } } document.getElementById('admin-token').addEventListener('keydown', (e) => { if (e.key === 'Enter') adminLogin(); }); function initAuthState() { adminToken = sessionStorage.getItem('plm_admin_token'); if (!adminToken) { document.getElementById('dashboard-section').classList.add('hidden'); document.getElementById('login-section').classList.remove('hidden'); return; } callAdmin('GET', '/admin/config') .then(() => { showDashboard(); return loadDashboard(); }) .catch(() => adminLogout()); } // Bfcache can restore a frozen snapshot of this page (DOM/JS state as it was // before navigating away) without re-running any of this script — so a stale // view could survive across back/forward navigation, e.g. showing a dashboard // for a token that's since been rotated or explicitly logged out of. Cache- // Control: no-store on this response should already prevent that, but // re-validate here too as a safety net for browsers that ignore it. window.addEventListener('pageshow', (event) => { if (event.persisted) initAuthState(); }); // Same server-push channel as app/static/index.html (see app/rounds/events.py): // a content-free "something changed" ping. Here it refreshes the chain-status // bar immediately, and reloads whichever admin section is currently open // (Utenti/Round/Transazioni pendenti/Audit log) so it doesn't need a manual // switch-away-and-back to pick up a new row. Polling stays in place as a // fallback if this connection is ever blocked or drops. let adminEventSource = null; function onAdminServerEvent() { if (!adminToken) return; refreshChainStatus(); if (VIEW_LOADERS[currentAdminView]) VIEW_LOADERS[currentAdminView](); } function connectAdminEvents() { if (adminEventSource) return; adminEventSource = new EventSource('/rounds/stream'); adminEventSource.addEventListener('update', onAdminServerEvent); // Fires on the initial connection AND every successful auto-reconnect — // re-syncs immediately instead of waiting for the next event or poll tick // to notice whatever changed while this connection was down. adminEventSource.addEventListener('open', onAdminServerEvent); } connectAdminEvents(); initAuthState();