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', 'bugreports']; const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports }; 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(), loadBugReports()]); } 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; // B-66: an empty fee address blocks every future round, so say so here rather // than leaving an empty field to be noticed. document .getElementById('admin-fee-address-warning') .classList.toggle('hidden', !!(data.fee_address || '').trim()); 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) => `
${escapeHtml(JSON.stringify(e.payload))}