From c7142e3116c8e2a3b0551d45b71c6f3af2451666 Mon Sep 17 00:00:00 2001 From: Paulo Modanez Date: Thu, 12 Nov 2026 15:12:14 -0300 Subject: [PATCH 1/2] =?UTF-8?q?Conquistas:=201=20trilh=C3=A3o=20e=20o=20pr?= =?UTF-8?q?=C3=AAmio=20de=20ouro=20(bloco=20achado)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01XjfaWQbpm2Tv3YnJgEKrFs --- CHANGELOG.md | 8 ++++++++ src/luck.js | 7 +++++++ src/painel.js | 8 ++++++-- tests/luck.test.js | 16 ++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15ad94e..23b0ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### English +- **Two new achievements:** 🌋 **Trillion hash** (a share with difficulty over 1 trillion) and the 🏆 **Golden prize: block found!**, unlocked when a share passes the network difficulty. The golden prize sits on its own full-width row, shows its trophy even while locked and lights up in gold when won. + +### Português +- **Duas conquistas novas:** 🌋 **Um trilhão** (um share com dificuldade acima de 1 trilhão) e o 🏆 **Prêmio de ouro: bloco achado!**, que desbloqueia quando um share passa da dificuldade da rede. O prêmio de ouro fica numa linha inteira só para ele, mostra o troféu mesmo trancado e acende em dourado quando sai. + ## v0.6.0 — 2026-11-12 ### English diff --git a/src/luck.js b/src/luck.js index 3bd9b96..a5b82e0 100644 --- a/src/luck.js +++ b/src/luck.js @@ -73,17 +73,21 @@ const ACHIEVEMENTS = [ { id: 'diff_1k', icon: '🎯', pt: ['Clube dos 1.000', 'Um share com dificuldade acima de 1.000'], en: ['1,000 club', 'A share with difficulty over 1,000'] }, { id: 'diff_1m', icon: '🚀', pt: ['Um em um milhão', 'Um share com dificuldade acima de 1 milhão'], en: ['One in a million', 'A share with difficulty over 1 million'] }, { id: 'diff_1b', icon: '☄️', pt: ['Quase bloco (1 bi)', 'Um share com dificuldade acima de 1 bilhão'], en: ['Billionaire hash', 'A share with difficulty over 1 billion'] }, + { id: 'diff_1t', icon: '🌋', pt: ['Um trilhão', 'Um share com dificuldade acima de 1 trilhão'], en: ['Trillion hash', 'A share with difficulty over 1 trillion'] }, { id: 'shares_1k', icon: '📦', pt: ['Mil tentativas', '1.000 shares aceitos na fazenda'], en: ['A thousand tries', '1,000 accepted shares on the farm'] }, { id: 'shares_100k', icon: '🏭', pt: ['100 mil tentativas', '100.000 shares aceitos na fazenda'], en: ['100,000 tries', '100,000 accepted shares on the farm'] }, { id: 'luck_2x', icon: '🍀', pt: ['Dobro da sorte', 'Worker com sorte de pelo menos 2× o esperado'], en: ['Double luck', 'A worker with at least 2× expected luck'] }, { id: 'luck_10x', icon: '⚡', pt: ['Raio na cabeça (10×)', 'Worker com sorte de pelo menos 10× o esperado'], en: ['Lightning strike (10×)', 'A worker with at least 10× expected luck'] }, { id: 'week_nonstop', icon: '🛡️', pt: ['Uma semana sem parar', '7 dias seguidos minerando sem cair'], en: ['One week nonstop', '7 days in a row mining without stopping'] }, { id: 'underdog', icon: '🐢', pt: ['Troféu vovô', 'Líder da semana com menos de 10% do maior hashrate'], en: ['Underdog trophy', 'Weekly leader with under 10% of the top hashrate'] }, + // o prêmio de ouro: um share acima da dificuldade da rede é um bloco + { id: 'block', icon: '🏆', golden: true, pt: ['Prêmio de ouro: bloco achado!', 'Um share acima da dificuldade da rede: a fazenda achou um bloco'], en: ['Golden prize: block found!', 'A share above the network difficulty: the farm found a block'] }, ]; // Avalia quais conquistas já foram atingidas e preserva as já desbloqueadas em `saved`. function evaluateAchievements(ctx = {}, saved = {}, now = Date.now()) { const best = Number(ctx.best) || 0; + const netDiff = Number(ctx.networkDifficulty) || 0; const bestWorker = ctx.bestWorker && ctx.bestWorker !== '—' ? ctx.bestWorker : null; const totalShares = Number(ctx.totalShares) || 0; const workers = Array.isArray(ctx.workers) ? ctx.workers : []; @@ -104,12 +108,14 @@ function evaluateAchievements(ctx = {}, saved = {}, now = Date.now()) { diff_1k: () => (best >= 1e3 ? { worker: bestWorker } : null), diff_1m: () => (best >= 1e6 ? { worker: bestWorker } : null), diff_1b: () => (best >= 1e9 ? { worker: bestWorker } : null), + diff_1t: () => (best >= 1e12 ? { worker: bestWorker } : null), shares_1k: () => (totalShares >= 1e3 ? { worker: null } : null), shares_100k: () => (totalShares >= 1e5 ? { worker: null } : null), luck_2x: () => (topLuck && topLuck.factor >= 2 ? { worker: topLuck.name } : null), luck_10x: () => (topLuck && topLuck.factor >= 10 ? { worker: topLuck.name } : null), week_nonstop: () => (nonstop ? { worker: nonstop.name } : null), underdog: () => (isUnderdog ? { worker: leaderName } : null), + block: () => (netDiff > 0 && best >= netDiff ? { worker: bestWorker } : null), }; const map = {}; @@ -130,6 +136,7 @@ function evaluateAchievements(ctx = {}, saved = {}, now = Date.now()) { const list = ACHIEVEMENTS.map((a) => ({ id: a.id, icon: a.icon, + golden: !!a.golden, pt: a.pt, en: a.en, unlocked: !!map[a.id], diff --git a/src/painel.js b/src/painel.js index 2d1428a..cec94a0 100644 --- a/src/painel.js +++ b/src/painel.js @@ -1014,6 +1014,7 @@ function snapshot(internal = false) { const topRec = [...records].sort((a, b) => b.value - a.value)[0]; const ach = evaluateAchievements({ best, + networkDifficulty: pool.difficulty, bestWorker: topRec ? topRec.worker : null, totalShares: Math.max(proxyTotalShares, poolTotalShares), workers: view, @@ -1244,6 +1245,9 @@ const PAGE = ` .ach-i { font-size:18px; line-height:1.2; } .ach-t { font-size:13px; font-weight:600; color:var(--txt); } .ach.on .ach-t { color:var(--gold); } + .ach.golden { grid-column:1 / -1; border-style:dashed; } + .ach.golden.on { border:2px solid #f2a900; background:linear-gradient(90deg, rgba(242,169,0,.18), rgba(242,169,0,.04)); box-shadow:0 0 18px rgba(242,169,0,.35); } + .ach.golden.on .ach-t { color:#f2a900; font-size:15px; } .ach-d { font-size:11px; color:var(--dim); line-height:1.3; margin-top:2px; } .hist-card .chart { height:240px; } body.kiosk .luck-duo { flex:1; min-height:0; } @@ -2131,11 +2135,11 @@ function renderGamification(j) { const isEn = LOC !== 'pt-BR'; for (const a of list) { const box = document.createElement('div'); - box.className = 'ach' + (a.unlocked ? ' on' : ''); + box.className = 'ach' + (a.unlocked ? ' on' : '') + (a.golden ? ' golden' : ''); const pair = (isEn ? a.en : a.pt) || a.pt || ['', '']; const ico = document.createElement('span'); ico.className = 'ach-i'; - ico.textContent = a.unlocked ? a.icon : '🔒'; + ico.textContent = a.unlocked || a.golden ? a.icon : '🔒'; // o troféu de ouro aparece mesmo trancado, para dar vontade const txt = document.createElement('div'); const t = document.createElement('div'); t.className = 'ach-t'; diff --git a/tests/luck.test.js b/tests/luck.test.js index 99e2915..8605b26 100644 --- a/tests/luck.test.js +++ b/tests/luck.test.js @@ -227,3 +227,19 @@ test('cobertura de todas as conquistas e edge cases de evaluateAchievements', () assert.ok(nonArrayRes.map.first_share); assert.strictEqual(nonArrayRes.map.first_share.worker, null); }); + +test('conquistas: 1 trilhão e o prêmio de ouro (bloco = share acima da dificuldade da rede)', () => { + const net = 148.4e12, now = Date.now(); + const quase = evaluateAchievements({ best: 2e12, bestWorker: 'orangeone', networkDifficulty: net }, {}, now); + const ids = quase.newlyUnlocked.map((a) => a.id); + assert.ok(ids.includes('diff_1t')); + assert.ok(!ids.includes('block')); + const semRede = evaluateAchievements({ best: 2e14, bestWorker: 'orangeone' }, {}, now); // sem dificuldade conhecida, não inventa bloco + assert.ok(!semRede.newlyUnlocked.some((a) => a.id === 'block')); + const bloco = evaluateAchievements({ best: 150e12, bestWorker: 'orangeone', networkDifficulty: net }, quase.map, now); + assert.deepStrictEqual(bloco.newlyUnlocked.map((a) => a.id), ['block']); + assert.strictEqual(bloco.map.block.worker, 'orangeone'); + const g = bloco.list.find((a) => a.id === 'block'); + assert.ok(g.golden && g.unlocked); + assert.strictEqual(ACHIEVEMENTS[ACHIEVEMENTS.length - 1].id, 'block', 'o prêmio de ouro fica por último'); +}); From 7aafe090278563b34bf6282a682a7b3e2186af08 Mon Sep 17 00:00:00 2001 From: Paulo Modanez Date: Thu, 12 Nov 2026 15:19:28 -0300 Subject: [PATCH 2/2] =?UTF-8?q?Painel:=20modal=20do=20worker=20com=20as=20?= =?UTF-8?q?=C3=BAltimas=2024=20h=20(shares=20e=20dificuldade=20dos=20hashe?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O proxy passa a guardar, por worker e por hora, aceitos, recusados, melhor dificuldade e trabalho, mais os 10 melhores hashes das últimas 24 h (/stats/worker?name=). O painel repassa em /api/worker e abre um modal ao clicar na linha do worker: resumo, shares por hora, melhor dificuldade por hora (log), melhores hashes e últimos shares. Claude-Session: https://claude.ai/code/session_01XjfaWQbpm2Tv3YnJgEKrFs --- CHANGELOG.md | 2 ++ src/i18n.js | 2 ++ src/painel.js | 70 +++++++++++++++++++++++++++++++++++++++- src/proxy.js | 52 ++++++++++++++++++++++++++++- tests/proxy.unit.test.js | 20 ++++++++++++ 5 files changed, 144 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b0ac9..c2ebc4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,11 @@ ## Unreleased ### English +- **Worker details (last 24 h):** click a worker in the table to open a dialog with its last 24 hours through the proxy: accepted and rejected shares, best share and average hashrate, a chart of shares per hour (rejections in red), a chart of the best difficulty per hour (log scale, so the ESP32 fit too), the 10 best hashes with their time and the latest shares. The proxy keeps this per hour in its stats file (survives restarts) and serves it at `/stats/worker?name=`; the dashboard at `/api/worker?name=`. Not in kiosk mode. - **Two new achievements:** 🌋 **Trillion hash** (a share with difficulty over 1 trillion) and the 🏆 **Golden prize: block found!**, unlocked when a share passes the network difficulty. The golden prize sits on its own full-width row, shows its trophy even while locked and lights up in gold when won. ### Português +- **Detalhes do worker (últimas 24 h):** clique num worker da tabela para abrir um modal com as últimas 24 horas dele pelo proxy: shares aceitos e recusados, melhor share e hashrate médio, gráfico de shares por hora (recusados em vermelho), gráfico da melhor dificuldade por hora (escala logarítmica, para os ESP32 caberem), os 10 melhores hashes com a hora e os últimos shares. O proxy guarda isso por hora no arquivo de estatísticas (sobrevive ao reinício) e entrega em `/stats/worker?name=`; o painel, em `/api/worker?name=`. Não abre no modo quiosque. - **Duas conquistas novas:** 🌋 **Um trilhão** (um share com dificuldade acima de 1 trilhão) e o 🏆 **Prêmio de ouro: bloco achado!**, que desbloqueia quando um share passa da dificuldade da rede. O prêmio de ouro fica numa linha inteira só para ele, mostra o troféu mesmo trancado e acende em dourado quando sai. ## v0.6.0 — 2026-11-12 diff --git a/src/i18n.js b/src/i18n.js index ec4748f..1302fc8 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -139,6 +139,8 @@ const EN = [ ["href=\"#\">Abrir na carteira", "href=\"#\">Open in wallet"], ["id=\"dnCopy\">Copiar endereço", "id=\"dnCopy\">Copy address"], ["

Dá para esconder este item em Configurações → Fazenda.

", "

You can hide this item in Settings → Farm.

"], + ["const WKT = { click: 'ver as últimas 24 h', loading: 'carregando…', none: 'Sem dados do proxy para este worker (ele minera direto numa pool ou ainda não mandou shares pelo proxy).', acc: 'Shares aceitos', rej: 'Recusados', best: 'Melhor (24 h)', hr: 'Hashrate (24 h)', perHour: 'Shares por hora', bestHour: 'Melhor dificuldade por hora', top: 'Melhores hashes (24 h)', last: 'Últimos shares', ever: 'recorde', online: 'conectado', offline: 'desconectado', noShares: 'nenhum share nas últimas 24 h' };", "const WKT = { click: 'see the last 24 h', loading: 'loading…', none: 'No proxy data for this worker (it mines straight to a pool or has not sent shares through the proxy yet).', acc: 'Accepted shares', rej: 'Rejected', best: 'Best (24 h)', hr: 'Hashrate (24 h)', perHour: 'Shares per hour', bestHour: 'Best difficulty per hour', top: 'Best hashes (24 h)', last: 'Latest shares', ever: 'record', online: 'connected', offline: 'disconnected', noShares: 'no shares in the last 24 h' };"], + ["
", "
"], ["const DN = { copied: 'Endereço copiado. Obrigado! 🧡', select: 'Selecionado: use Ctrl+C / Copiar.' };", "const DN = { copied: 'Address copied. Thank you! 🧡', select: 'Selected: use Ctrl+C / Copy.' };"], ["id=\"cxSound\" title=\"Som dos recordes\">", "id=\"cxSound\" title=\"Record sound\">"], ["const SND = { on: 'Som dos recordes: ligado', off: 'Som dos recordes: desligado' };", "const SND = { on: 'Record sound: on', off: 'Record sound: off' };"], diff --git a/src/painel.js b/src/painel.js index cec94a0..6a91c2d 100644 --- a/src/painel.js +++ b/src/painel.js @@ -1300,6 +1300,17 @@ const PAGE = ` .pm-btns { flex-wrap:wrap; } a.pm-btn { text-decoration:none; display:inline-flex; align-items:center; } .dn-foot { font-size:12px; } .cx-item.cx-donate .ic { filter:none; opacity:.9; } + /* modal do worker: últimas 24 h */ + #rows tr[data-w] { cursor:pointer; } #rows tr[data-w]:hover td { background:var(--gold-soft); } + .wk-box { max-width:680px; max-height:calc(100vh - 32px); overflow:auto; } + .wk-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; } .wk-head b { font-size:18px; } .wk-head .sub { color:var(--dim); font-size:13px; } + .wk-sum { display:grid; grid-template-columns:repeat(4, 1fr); gap:8px; } + .wk-sum div { border:1px solid var(--line); border-radius:8px; padding:8px 10px; } .wk-sum span { display:block; color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.05em; } .wk-sum strong { font-size:16px; } + .wk-ch { width:100%; height:auto; display:block; } .wk-l { color:var(--dim); font-size:12px; text-transform:uppercase; letter-spacing:.06em; margin-top:4px; } + .wk-list { display:grid; grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); gap:4px 12px; font-size:13px; font-variant-numeric:tabular-nums; } + .wk-list span { color:var(--dim); } .wk-list .bad { color:var(--warn); } + #wkMsg { color:var(--dim); font-size:13px; } + @media (max-width:560px) { .wk-sum { grid-template-columns:repeat(2, 1fr); } } .pm-btn.pm-ok { background:var(--gold); border-color:var(--gold); color:var(--on-gold, #111); font-weight:600; } .tooltip { position:fixed; pointer-events:none; background:var(--tip-bg); box-shadow:var(--shadow); border:1px solid var(--line); border-radius:8px; padding:6px 9px; font-size:12px; display:none; z-index:9; white-space:nowrap; } .tooltip b { color:var(--txt); } .tooltip span { color:var(--dim); } @@ -1525,6 +1536,10 @@ const PAGE = `
Abrir na carteira

Dá para esconder este item em Configurações → Fazenda.

+
@@ -1564,6 +1579,7 @@ const MESES = ['jan','fev','mar','abr','mai','jun','jul','ago','set','out','nov' const mesAno = (ms) => { const d = new Date(ms); return MESES[d.getMonth()] + '/' + d.getFullYear(); }; const diaMes = (ms) => new Date(ms).toLocaleDateString(LOC, { day: '2-digit', month: '2-digit' }); const hora = (ms) => new Date(ms).toLocaleTimeString(LOC, { hour: '2-digit', minute: '2-digit' }); +const WKT = { click: 'ver as últimas 24 h', loading: 'carregando…', none: 'Sem dados do proxy para este worker (ele minera direto numa pool ou ainda não mandou shares pelo proxy).', acc: 'Shares aceitos', rej: 'Recusados', best: 'Melhor (24 h)', hr: 'Hashrate (24 h)', perHour: 'Shares por hora', bestHour: 'Melhor dificuldade por hora', top: 'Melhores hashes (24 h)', last: 'Últimos shares', ever: 'recorde', online: 'conectado', offline: 'desconectado', noShares: 'nenhum share nas últimas 24 h' }; const money = (v) => v.toLocaleString(LOC, { style: 'currency', currency: CUR }); const moneyCompact = (v) => v >= 1e6 ? v.toLocaleString(LOC, { style: 'currency', currency: CUR, notation: 'compact', maximumFractionDigits: 2 }) : money(v); @@ -1919,7 +1935,7 @@ function renderFarm(j) { const netInfo = det ? ' ' + esc(det) + '' : ''; const rej = w.rejected > 0 ? ' ✗ ' + w.rejected + ' (stale ' + (w.stale || 0) + ' · duplicado ' + (w.duplicate || 0) + ')' : ''; const trophy = j.championship && j.championship.leader && j.championship.leader.name === w.name ? ' 🏆' : ''; - return '' + esc(w.name) + trophy + pool + netInfo + rej + temp + + return '' + esc(w.name) + trophy + pool + netInfo + rej + temp + '' + hr + '' + best + '' + seen + ''; }).join('') : 'nenhum worker enviou shares ainda'; @@ -2395,6 +2411,48 @@ $('dnCopy').addEventListener('click', async () => { let ok = false; try { ok = document.execCommand('copy'); } catch (_) { /* segue */ } set('dnOk', ok ? DN.copied : DN.select); }); +// ---- modal do worker: as últimas 24 h que o proxy guardou (shares por hora e dificuldade dos hashes) +function wkChart(hours, val, color, logScale, fmt, stack) { + const W = 640, H = 120, P = 22, n = hours.length, bw = (W - 8) / n; + const tr = (v) => (logScale ? (v > 0 ? Math.max(0.05, Math.log10(v) + 3) : 0) : v); // log: 0,001 → 0 (cabem os ESP32) + const max = Math.max(1e-9, ...hours.map((h) => tr(val(h)) + (stack ? tr(stack(h)) : 0))); + let svg = ''; + hours.forEach((h, i) => { + const x = 4 + i * bw, v = val(h), y = (H - 14) * tr(v) / max; + const tip = hora(h.t) + ' · ' + fmt(h); + if (v > 0) svg += '' + esc(tip) + ''; + if (stack && stack(h) > 0) { const y2 = (H - 14) * tr(stack(h)) / max; svg += '' + esc(tip) + ''; } + if (i % 6 === 0 || i === n - 1) svg += '' + hora(h.t) + ''; + }); + const top = hours.reduce((b, h) => (val(h) > val(b) ? h : b), hours[0]); + if (top && val(top) > 0) { const i = hours.indexOf(top), x = 4 + i * bw + bw / 2, y = H - (H - 14) * tr(val(top)) / max - 3; svg += '' + esc(fmt(top)) + ''; } + return svg + ''; +} +async function openWorker(name) { + $('wkModal').hidden = false; + set('wkName', name); set('wkSub', ''); set('wkMsg', WKT.loading); $('wkBody').innerHTML = ''; + let d = null; + try { const r = await fetch('/api/worker?name=' + encodeURIComponent(name)); if (r.ok) d = await r.json(); } catch (_) { /* segue */ } + if ($('wkModal').hidden || $('wkName').textContent !== name) return; + if (!d || !d.hours) { set('wkMsg', WKT.none); return; } + set('wkMsg', ''); + set('wkSub', [d.online ? '🟢 ' + WKT.online : '⚪ ' + WKT.offline, d.pool, d.ip, WKT.ever + ' ' + diffTxt(d.bestEver)].filter(Boolean).join(' · ')); + const card = (l, v) => '
' + l + '' + v + '
'; + let html = '
' + card(WKT.acc, nf(d.accepted)) + card(WKT.rej, nf(d.rejected)) + card(WKT.best, d.best > 0 ? diffTxt(d.best) : '–') + card(WKT.hr, fmtHash(d.hashrate24h)) + '
'; + if (!d.accepted && !d.rejected) html += '

' + WKT.noShares + '

'; + else { + html += '
' + WKT.perHour + '
' + wkChart(d.hours, (h) => h.accepted, 'var(--ok)', false, (h) => nf(h.accepted) + (h.rejected ? ' · ✗ ' + h.rejected : '') + ' · ' + fmtHash(h.hashrate), (h) => h.rejected); + html += '
' + WKT.bestHour + '
' + wkChart(d.hours, (h) => h.best, 'var(--gold)', true, (h) => diffTxt(h.best)); + } + if (d.top && d.top.length) html += '
' + WKT.top + '
' + d.top.map((t, i) => '
' + (i === 0 ? '🏆 ' : '') + '' + diffTxt(t.diff) + ' ' + hora(t.at) + '
').join('') + '
'; + if (d.recent && d.recent.length) html += '
' + WKT.last + '
' + d.recent.map((r) => '' + (r.ok ? '' : '✗ ') + diffTxt(r.diff) + ' ' + hora(r.at) + '
').join('') + '
'; + $('wkBody').innerHTML = html; +} +const closeWorker = () => { $('wkModal').hidden = true; set('wkName', ''); }; +$('rows').addEventListener('click', (e) => { const tr = e.target.closest('tr[data-w]'); if (tr && !document.body.classList.contains('kiosk')) openWorker(tr.dataset.w); }); +$('wkClose').addEventListener('click', closeWorker); +$('wkModal').addEventListener('click', (e) => { if (e.target === $('wkModal')) closeWorker(); }); +document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('wkModal').hidden) closeWorker(); }); const closeDonate = () => { $('donateModal').hidden = true; set('dnOk', ''); }; $('dnClose').addEventListener('click', closeDonate); $('donateModal').addEventListener('click', (e) => { if (e.target === $('donateModal')) closeDonate(); }); @@ -2707,6 +2765,16 @@ function requestHandler(req, res) { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify(widgetData(pickLang(req, LANGUAGE)))); } + if (req.url.startsWith('/api/worker?')) { + // últimas 24 h de um worker (modal do painel): o proxy guarda por hora + const name = (new URL(req.url, 'http://x').searchParams.get('name') || '').slice(0, 128); + const base = PROXY_URL(); + const send = (code, body) => { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); }; + if (!base || !name) return send(404, { error: 'noproxy' }); + getJson(base.replace(/\/stats\/?$/, '') + '/stats/worker?name=' + encodeURIComponent(name)) + .then((d) => send(200, d)).catch(() => send(404, { error: 'noworker' })); + return; + } if (req.url.startsWith('/api/alarm')) { // teste manual: /api/alarm?e=T (recorde), P, S, 0, 1 ou 2 const e = (new URL(req.url, 'http://x').searchParams.get('e') || 'T').slice(0, 1); diff --git a/src/proxy.js b/src/proxy.js index afc1f6a..33a9ba1 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -166,6 +166,46 @@ function addBin(w, at, target) { const cut = Date.now() - DAY_MS; while (bins.length && bins[0][0] < cut) bins.shift(); } +// Últimas 24 h por worker, para o modal do painel: por hora [início, aceitos, recusados, melhor dificuldade, trabalho] +// e os 10 melhores hashes [hora, dificuldade]. Ficam no arquivo de estatísticas (sobrevivem ao reinício). +const HOUR_MS = 3600e3, TOP_N = 10; +function noteHour(w, at, ok, diff, target) { + const t = Math.floor(at / HOUR_MS) * HOUR_MS; + const hours = w.hours || (w.hours = []); + let h = hours[hours.length - 1]; + if (!h || h[0] !== t) { h = [t, 0, 0, 0, 0]; hours.push(h); } + if (ok) { h[1]++; if (diff > h[3]) h[3] = diff; h[4] += target || 0; } else h[2]++; + const cut = Date.now() - DAY_MS; + while (hours.length && hours[0][0] + HOUR_MS <= cut) hours.shift(); + if (ok && diff > 0) { + const top = (w.top24 || []).filter(([ta]) => ta > cut); + top.push([at, diff]); + top.sort((x, y) => y[1] - x[1]); + w.top24 = top.slice(0, TOP_N); + } +} +function workerDay(w, now = Date.now()) { + const cut = now - DAY_MS; + const byT = new Map((w.hours || []).filter(([t]) => t + HOUR_MS > cut).map((h) => [h[0], h])); + const first = Math.floor(now / HOUR_MS) * HOUR_MS - 23 * HOUR_MS; + const hours = []; + for (let t = first; t <= now; t += HOUR_MS) { + const h = byT.get(t) || [t, 0, 0, 0, 0]; + const span = Math.min(HOUR_MS, now - t) / 1000; + hours.push({ t, accepted: h[1], rejected: h[2], best: h[3], hashrate: span > 0 ? h[4] * 4294967296 / span : 0 }); + } + const top = (w.top24 || []).filter(([t]) => t > cut).map(([at, diff]) => ({ at, diff })); + return { + name: w.name, pool: w.pool || null, ip: w.ip || null, online: w.online > 0, + hours, top, + accepted: hours.reduce((n, h) => n + h.accepted, 0), + rejected: hours.reduce((n, h) => n + h.rejected, 0), + best: hours.reduce((m, h) => Math.max(m, h.best), 0), + bestEver: w.bestEver || 0, + hashrate24h: hashrateOver(w, DAY_MS), + recent: recent.filter((r) => r.worker === w.name).slice(0, 15), + }; +} function hashrateOver(w, ms) { const now = Date.now(); if (ms >= DAY_MS) { @@ -419,6 +459,7 @@ function session(down, route) { w.lastShareDiff = p.diff; w.window.push([p.at, p.target]); addBin(w, p.at, p.target); + noteHour(w, p.at, true, p.diff, p.target); if (p.diff > w.bestSession) w.bestSession = p.diff; if (p.diff > w.bestEver) w.bestEver = p.diff; if (p.hash && (!w.bestHash || p.diff > w.bestHash.diff)) w.bestHash = { diff: p.diff, hash: p.hash, at: p.at }; @@ -443,6 +484,7 @@ function session(down, route) { return; } else { w.rejected++; + noteHour(w, p.at, false); const kind = rejectKind(m.error); if (kind === 'stale' || kind === 'duplicate') w[kind] = (w[kind] || 0) + 1; entry.error = Array.isArray(m.error) ? m.error[1] : (m.error && m.error.message) || String(m.error || 'recusado'); @@ -502,10 +544,16 @@ function startServers() { const statsServer = http.createServer((req, res) => { if (!req.url.startsWith('/stats')) { res.writeHead(404); return res.end(); } + if (req.url.startsWith('/stats/worker')) { + const name = new URL(req.url, 'http://x').searchParams.get('name') || ''; + const w = Object.prototype.hasOwnProperty.call(workers, name) ? workers[name] : null; + res.writeHead(w ? 200 : 404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + return res.end(JSON.stringify(w ? workerDay(w) : { error: 'noworker' })); + } const list = Object.values(workers).map((w) => { const { window, subs, ...rest } = w; const hr = hashrate(w); // poda a janela antes de contar - const { bins, ...slim } = rest; + const { bins, hours, top24, ...slim } = rest; return { ...slim, hashrate: hr, shares1h: w.window.length, hashrate15m: hashrateOver(w, 15 * 60e3), hashrate24h: hashrateOver(w, DAY_MS), stale: w.stale || 0, duplicate: w.duplicate || 0 }; }); res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); @@ -579,6 +627,8 @@ module.exports = { hashrateOver, rejectKind, addBin, + noteHour, + workerDay, safeJson, saveStats, session, diff --git a/tests/proxy.unit.test.js b/tests/proxy.unit.test.js index 7585055..d288981 100644 --- a/tests/proxy.unit.test.js +++ b/tests/proxy.unit.test.js @@ -172,3 +172,23 @@ test('hashrate de ESP32 na reserva: um share aceito por sorte não vira 100x mai const hn = proxy.hashrate(n); assert.ok(Math.abs(hn - 60 * 4294967296 / 1800) / hn < 0.05); }); + +test('últimas 24 h do worker (modal): por hora, top 10 e o que passou de 24 h sai', () => { + const now = Date.now(); + const w = { name: 'orangeone', pool: 'atlaspool', ip: '192.168.0.138', online: 1, bestEver: 15e6 }; + proxy.noteHour(w, now - 30 * 3600e3, true, 9e9, 2000); // velho: não conta + for (let i = 0; i < 12; i++) proxy.noteHour(w, now - i * 1000, true, 1000 * (i + 1), 2000); + proxy.noteHour(w, now - 3 * 3600e3, true, 5e6, 2000); + proxy.noteHour(w, now - 3 * 3600e3, false); + const d = proxy.workerDay(w, now); + assert.strictEqual(d.hours.length, 24); + assert.strictEqual(d.hours[23].t, Math.floor(now / 3600e3) * 3600e3); + assert.strictEqual(d.accepted, 13); + assert.strictEqual(d.rejected, 1); + assert.strictEqual(d.best, 5e6); + assert.strictEqual(d.top.length, 10); + assert.strictEqual(d.top[0].diff, 5e6); + assert.ok(d.top.every((t) => t.diff !== 9e9)); + assert.strictEqual(d.name, 'orangeone'); + assert.ok(d.hours[23].hashrate > 0); +});