Files
firepool/templates/index.html
Nathan Woodburn dbbba6a8eb
All checks were successful
Build Docker / BuildImage (push) Successful in 51s
Check Code Quality / RuffCheck (push) Successful in 1m9s
feat: Add link for payout txs
2026-04-04 21:13:53 +11:00

465 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Firepool | Woodburn</title>
<meta name="description" content="Firepool dashboard with live pool stats, worker search, payouts, and estimated mining returns.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Firepool">
<meta property="og:title" content="Firepool | Woodburn">
<meta property="og:description" content="Live pool telemetry, worker search, and estimated mining rewards.">
<meta property="og:url" content="{{ request.url }}">
<meta property="og:image" content="https://explorer.hns.au/assets/img/favicon.png">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Firepool | Woodburn">
<meta name="twitter:description" content="Live pool telemetry, worker search, and estimated mining rewards.">
<meta name="twitter:image" content="https://explorer.hns.au/assets/img/favicon.png">
<link rel="icon" href="https://explorer.hns.au/assets/img/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/index.css">
</head>
<body>
<div class="bg-orb orb-a"></div>
<div class="bg-orb orb-b"></div>
<div class="bg-grid"></div>
<main class="layout">
<header class="hero">
<div>
<p class="kicker">Nathan.Woodburn/</p>
<h1>Firepool</h1>
<p class="subtitle">Live pool telemetry and miner drill-down for your address.</p>
</div>
<div class="hero-meta">
<span id="lastUpdated">Last update: pending</span>
</div>
</header>
<details class="panel mobile-accordion stats-accordion" data-accordion="summary" open>
<summary>Pool Stats</summary>
<div class="accordion-content">
<div class="panel-head">
<h2>Pool Stats</h2>
<p>Live network and reward snapshot</p>
</div>
<section class="stats-grid" id="poolSummary"></section>
</div>
</details>
<section class="panel search-panel">
<div class="panel-head">
<h2>Miner Search</h2>
<p>Search by base address. Workers like <strong>address.rig1</strong> are included automatically.</p>
</div>
<form id="searchForm" class="search-row">
<input id="addressInput" type="text" autocomplete="off" placeholder="Enter wallet address">
<button type="submit">Search</button>
</form>
<div id="searchMessage" class="muted">Enter an address to load individual worker stats.</div>
<div class="stats-grid compact" id="minerSummary"></div>
</section>
<details class="panel mobile-accordion" data-accordion="workers" open>
<summary>Workers</summary>
<div class="accordion-content">
<div class="panel-head">
<h2>Workers</h2>
<p id="workerCountLabel">Pool workers</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Username</th>
<th>Hashrate</th>
<th>Accepted</th>
<th>Rejected</th>
<th>Stale</th>
<th>Blocks</th>
<th>Est. Daily (FBC)</th>
</tr>
</thead>
<tbody id="workersBody"></tbody>
</table>
</div>
</div>
</details>
<details class="panel mobile-accordion" data-accordion="blocks" open>
<summary>Blocks</summary>
<div class="accordion-content">
<div class="panel-head">
<h2>Blocks</h2>
<p id="blockCountLabel">Recent block history</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Height</th>
<th>Status</th>
<th>Reward (FBC)</th>
<th>Found By</th>
<th>Confirmations</th>
<th>Time</th>
</tr>
</thead>
<tbody id="blocksBody"></tbody>
</table>
</div>
</div>
</details>
<details class="panel mobile-accordion" data-accordion="balances" open>
<summary>Balances</summary>
<div class="accordion-content">
<div class="panel-head">
<h2>Balances</h2>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Address</th>
<th>Balance (FBC)</th>
<th>Total Earned (FBC)</th>
<th>Total Paid (FBC)</th>
</tr>
</thead>
<tbody id="balancesBody"></tbody>
</table>
</div>
</div>
</details>
<details class="panel mobile-accordion" data-accordion="payouts" open>
<summary>Payouts</summary>
<div class="accordion-content">
<div class="panel-head">
<h2>Payouts</h2>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Address</th>
<th>Amount (FBC)</th>
<th>Status</th>
<th>TXID</th>
<th>Time</th>
</tr>
</thead>
<tbody id="payoutsBody"></tbody>
</table>
</div>
</div>
</details>
<section class="panel setup-panel">
<div class="panel-head">
<h2>Setup a Miner</h2>
<p>New to the pool? Follow the quick start guide to get your worker online.</p>
</div>
<a class="setup-link" href="/setup-miner">Open Miner Setup Guide</a>
</section>
<footer class="footer">
<p>Powered by <a href="https://nathan.woodburn.au" target="_blank" rel="noopener">Nathan.Woodburn/</a></p>
</footer>
</main>
<script>
const refreshMs = 10000;
function fmtInt(value) {
return Number(value || 0).toLocaleString();
}
function fmtFloat(value, digits = 3) {
return Number(value || 0).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: digits
});
}
function fmtFbc(value) {
return fmtFloat(value, 6);
}
function fmtUptime(seconds) {
const total = Number(seconds || 0);
const d = Math.floor(total / 86400);
const h = Math.floor((total % 86400) / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return `${d}d ${h}h ${m}m ${s}s`;
}
function fmtTime(unixSeconds) {
if (!unixSeconds) return "-";
return new Date(Number(unixSeconds) * 1000).toLocaleString();
}
function renderCards(containerId, cards) {
const el = document.getElementById(containerId);
el.innerHTML = cards.map(card => `
<article class="stat-card">
<span>${card.label}</span>
<strong>${card.value}</strong>
</article>
`).join("");
}
function renderRows(bodyId, rowsHtml, emptyText, cols) {
const body = document.getElementById(bodyId);
if (!rowsHtml.length) {
body.innerHTML = `<tr><td colspan="${cols}" class="empty">${emptyText}</td></tr>`;
return;
}
body.innerHTML = rowsHtml.join("");
}
function shortTxid(txid) {
if (!txid) return "-";
if (txid.length <= 18) return txid;
return `${txid.slice(0, 10)}...${txid.slice(-6)}`;
}
function refreshStamp() {
const stamp = document.getElementById("lastUpdated");
stamp.textContent = `Last update: ${new Date().toLocaleString()}`;
}
function initMobileAccordions() {
const accordions = document.querySelectorAll('.mobile-accordion');
if (!accordions.length) return;
const isCompact = window.matchMedia('(max-width: 720px)').matches;
accordions.forEach((accordion, idx) => {
accordion.open = !isCompact || idx === 0;
});
}
async function loadOverview() {
const res = await fetch("/api/v1/pool/overview");
if (!res.ok) {
throw new Error(`Overview request failed (${res.status})`);
}
const data = await res.json();
const summary = data.summary || {};
refreshStamp();
renderCards("poolSummary", [
{ label: "Pool Hashrate", value: fmtFloat(summary.hashrate) + " H/s" },
{ label: "Network Hashrate", value: fmtFloat(summary.network_hashrate, 2) + " H/s" },
{ label: "Block Time", value: fmtInt(summary.block_time_seconds) + " s" },
{ label: "Block Reward", value: fmtFbc(summary.current_block_reward_fbc) + " FBC" },
{ label: "Blocks / Day", value: fmtInt(summary.blocks_per_day) },
{ label: "Network Emission / Day", value: fmtFbc(summary.daily_network_emission_fbc) + " FBC" },
{ label: "Est. Reward / H/s / Day", value: fmtFloat(summary.estimated_reward_per_hash_daily_fbc, 2) + " FBC" },
{ label: "Active Workers", value: fmtInt(summary.workers) },
{ label: "Blocks Found", value: fmtInt(summary.blocks_found) },
{ label: "Fee", value: `${fmtFloat(summary.fee, 2)}%` },
{ label: "Total Balance (FBC)", value: fmtFbc(summary.total_balance_fbc) },
{ label: "Total Paid (FBC)", value: fmtFbc(summary.total_paid_fbc) }
]);
const workers = Array.isArray(data.workers) ? data.workers : [];
const blocks = Array.isArray(data.blocks) ? data.blocks : [];
const balances = Array.isArray(data.balances) ? data.balances : [];
const payouts = Array.isArray(data.payouts) ? data.payouts : [];
document.getElementById("workerCountLabel").textContent = `${workers.length} workers in pool`;
document.getElementById("blockCountLabel").textContent = `${blocks.length} blocks recorded`;
renderRows(
"workersBody",
workers.map(w => `
<tr>
<td data-label="Username">${w.username || "-"}</td>
<td data-label="Hashrate">${fmtFloat(w.hashrate, 6)}</td>
<td data-label="Accepted">${fmtInt(w.accepted)}</td>
<td data-label="Rejected">${fmtInt(w.rejected)}</td>
<td data-label="Stale">${fmtInt(w.stale)}</td>
<td data-label="Blocks">${fmtInt(w.blocks)}</td>
<td data-label="Est. Daily (FBC)">${w.estimated_reward ? fmtFloat(w.estimated_reward.daily_fbc, 2) : "-"}</td>
</tr>
`),
"No workers available.",
7
);
renderRows(
"blocksBody",
blocks.map(b => `
<tr>
<td data-label="Height">${fmtInt(b.height)}</td>
<td data-label="Status"><span class="pill ${String(b.status || "unknown").toLowerCase()}">${b.status || "unknown"}</span></td>
<td data-label="Reward (FBC)">${fmtFbc(Number(b.reward || 0) / 1000000)}</td>
<td data-label="Found By">${b.found_by || "-"}</td>
<td data-label="Confirmations">${fmtInt(b.confirmations)}</td>
<td data-label="Time">${fmtTime(b.time)}</td>
</tr>
`),
"No blocks available.",
6
);
renderRows(
"balancesBody",
balances.map(b => `
<tr>
<td data-label="Address">${b.address || "-"}</td>
<td data-label="Balance (FBC)">${fmtFbc(b.fbc ?? Number(b.balance || 0) / 1000000)}</td>
<td data-label="Total Earned (FBC)">${fmtFbc(Number(b.total_earned || 0) / 1000000)}</td>
<td data-label="Total Paid (FBC)">${fmtFbc(Number(b.total_paid || 0) / 1000000)}</td>
</tr>
`),
"No balances available.",
4
);
renderRows(
"payoutsBody",
payouts.map(p => `
<tr>
<td data-label="Address">${p.address || "-"}</td>
<td data-label="Amount (FBC)">${fmtFbc(Number(p.amount || 0) / 1000000)}</td>
<td data-label="Status"><span class="pill ${String(p.status || "pending").toLowerCase()}">${p.status || "pending"}</span></td>
<td data-label="TXID" title="${p.txid || ""}"><a href="https://explorer.fistbump.org/tx/${p.txid}" target="_blank" rel="noopener" class="txid-link">${shortTxid(p.txid)}</a></td>
<td data-label="Time">${fmtTime(p.time)}</td>
</tr>
`),
"No payouts available.",
5
);
}
async function loadMiner(address) {
const msg = document.getElementById("searchMessage");
if (!address) {
msg.textContent = "Enter an address to load individual worker stats.";
document.getElementById("minerSummary").innerHTML = "";
return;
}
msg.textContent = `Loading miner data for ${address}...`;
const res = await fetch(`/api/v1/pool/miner?address=${encodeURIComponent(address)}`);
if (!res.ok) {
const errText = await res.text();
throw new Error(`Miner request failed (${res.status}): ${errText}`);
}
const data = await res.json();
const summary = data.summary || {};
msg.textContent = `Showing ${summary.matching_workers || 0} worker(s) for ${data.address}.`;
renderCards("minerSummary", [
{ label: "Matching Workers", value: fmtInt(summary.matching_workers) },
{ label: "Hashrate", value: fmtFloat(summary.hashrate) },
{ label: "Accepted", value: fmtInt(summary.accepted) },
{ label: "Rejected", value: fmtInt(summary.rejected) },
{ label: "Stale", value: fmtInt(summary.stale) },
{ label: "Blocks", value: fmtInt(summary.blocks_found) },
{ label: "Est. Daily (FBC)", value: fmtFloat(summary.estimated_daily_reward_fbc, 2) },
{ label: "Est. Weekly (FBC)", value: fmtFloat(summary.estimated_weekly_reward_fbc, 2) },
{ label: "Pool Share", value: fmtFloat(summary.pool_share_pct, 2) + "%" },
{ label: "Balance (FBC)", value: fmtFbc(summary.total_balance_fbc) },
{ label: "Paid (FBC)", value: fmtFbc(summary.total_paid_fbc) }
]);
renderRows(
"workersBody",
(data.workers || []).map(w => `
<tr>
<td data-label="Username">${w.username || "-"}</td>
<td data-label="Hashrate">${fmtFloat(w.hashrate, 6)}</td>
<td data-label="Accepted">${fmtInt(w.accepted)}</td>
<td data-label="Rejected">${fmtInt(w.rejected)}</td>
<td data-label="Stale">${fmtInt(w.stale)}</td>
<td data-label="Blocks">${fmtInt(w.blocks)}</td>
<td data-label="Est. Daily (FBC)">${w.estimated_reward ? fmtFloat(w.estimated_reward.daily_fbc, 2) : "-"}</td>
</tr>
`),
"No matching workers for this address.",
7
);
renderRows(
"blocksBody",
(data.blocks || []).map(b => `
<tr>
<td data-label="Height">${fmtInt(b.height)}</td>
<td data-label="Status"><span class="pill ${String(b.status || "unknown").toLowerCase()}">${b.status || "unknown"}</span></td>
<td data-label="Reward (FBC)">${fmtFbc(Number(b.reward || 0) / 1000000)}</td>
<td data-label="Found By">${b.found_by || "-"}</td>
<td data-label="Confirmations">${fmtInt(b.confirmations)}</td>
<td data-label="Time">${fmtTime(b.time)}</td>
</tr>
`),
"No matching blocks for this address.",
6
);
renderRows(
"balancesBody",
(data.balances || []).map(b => `
<tr>
<td data-label="Address">${b.address || "-"}</td>
<td data-label="Balance (FBC)">${fmtFbc(b.fbc ?? Number(b.balance || 0) / 1000000)}</td>
<td data-label="Total Earned (FBC)">${fmtFbc(Number(b.total_earned || 0) / 1000000)}</td>
<td data-label="Total Paid (FBC)">${fmtFbc(Number(b.total_paid || 0) / 1000000)}</td>
</tr>
`),
"No matching balances for this address.",
4
);
renderRows(
"payoutsBody",
(data.payouts || []).map(p => `
<tr>
<td data-label="Address">${p.address || "-"}</td>
<td data-label="Amount (FBC)">${fmtFbc(Number(p.amount || 0) / 1000000)}</td>
<td data-label="Status"><span class="pill ${String(p.status || "pending").toLowerCase()}">${p.status || "pending"}</span></td>
<td data-label="TXID" title="${p.txid || ""}">${shortTxid(p.txid)}</td>
<td data-label="Time">${fmtTime(p.time)}</td>
</tr>
`),
"No matching payouts for this address.",
5
);
}
async function refreshAll() {
try {
await loadOverview();
} catch (err) {
console.error(err);
document.getElementById("searchMessage").textContent = `Error loading pool data: ${err.message}`;
}
}
document.getElementById("searchForm").addEventListener("submit", async (event) => {
event.preventDefault();
const address = document.getElementById("addressInput").value.trim();
try {
await loadMiner(address);
} catch (err) {
console.error(err);
document.getElementById("searchMessage").textContent = `Error loading miner data: ${err.message}`;
}
});
initMobileAccordions();
refreshAll();
setInterval(refreshAll, refreshMs);
</script>
</body>
</html>