generated from nathanwoodburn/python-webserver-template
519 lines
21 KiB
HTML
519 lines
21 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, 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, payouts, 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, payouts, 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, payouts, and mining estimates.</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>
|
|
|
|
<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 class="table-controls">
|
|
<button type="button" class="table-toggle" id="blocksToggle" hidden>Show more</button>
|
|
</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 class="table-controls">
|
|
<button type="button" class="table-toggle" id="balancesToggle" hidden>Show more</button>
|
|
</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 class="table-controls">
|
|
<button type="button" class="table-toggle" id="payoutsToggle" hidden>Show more</button>
|
|
</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>
|
|
|
|
<section class="panel setup-panel">
|
|
<div class="panel-head">
|
|
<h2>Chain Debug</h2>
|
|
<p>Inspect the last 10 block hashes and spot tip changes or short reorgs.</p>
|
|
</div>
|
|
<a class="setup-link" href="/debug">Open Debug Chain View</a>
|
|
</section>
|
|
|
|
<section class="panel setup-panel">
|
|
<div class="panel-head">
|
|
<h2>Historic Stats</h2>
|
|
<p>View hashrate and worker trends over time, plus top address rankings.</p>
|
|
</div>
|
|
<a class="setup-link" href="/stats">Open Historic Stats View</a>
|
|
</section>
|
|
<footer class="footer">
|
|
<p>Powered by <a href="https://nathan.woodburn.au" target="_blank" rel="noopener">Nathan.Woodburn/</a></p>
|
|
</footer>
|
|
</main>
|
|
|
|
<div class="modal-backdrop" id="rewardModal" hidden>
|
|
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="rewardModalTitle">
|
|
<div class="modal-head">
|
|
<h3 id="rewardModalTitle">Estimated Earnings Calculator</h3>
|
|
<button type="button" class="ghost-btn" id="closeRewardModal" aria-label="Close">Close</button>
|
|
</div>
|
|
<p class="muted modal-copy">Uses the current pool estimate for reward per H/s per day.</p>
|
|
<label for="hashrateInput">Your hashrate (H/s)</label>
|
|
<input id="hashrateInput" type="number" min="0" step="any" placeholder="e.g. 1.1" inputmode="decimal">
|
|
<p class="muted" id="rewardRateDisplay">Current rate: loading...</p>
|
|
<p class="reward-estimate" id="rewardEstimateOutput">Enter your hashrate to estimate earnings.</p>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<script>
|
|
const refreshMs = 10000;
|
|
const previewRowCount = 5;
|
|
let estimatedRewardPerHashDailyFbc = 0;
|
|
const limitedTableState = {
|
|
blocks: false,
|
|
balances: false,
|
|
payouts: false
|
|
};
|
|
const limitedTableCache = {};
|
|
|
|
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 fmtBlockConfirmations(status, confirmations) {
|
|
const normalized = String(status || "").toLowerCase();
|
|
if (normalized === "mature") {
|
|
return "✓";
|
|
}
|
|
return `${fmtInt(confirmations)}/100`;
|
|
}
|
|
|
|
function renderCards(containerId, cards) {
|
|
const el = document.getElementById(containerId);
|
|
el.innerHTML = cards.map(card => `
|
|
<article class="stat-card">
|
|
<span>${card.clickable ? `<button type="button" class="stat-link" id="${card.id}">${card.label}</button>` : card.label}</span>
|
|
<strong>${card.value}</strong>
|
|
</article>
|
|
`).join("");
|
|
}
|
|
|
|
function updateEstimateOutput() {
|
|
const hashrateInput = document.getElementById("hashrateInput");
|
|
const output = document.getElementById("rewardEstimateOutput");
|
|
const hashrate = Number(hashrateInput.value);
|
|
|
|
if (!Number.isFinite(hashrate) || hashrate < 0) {
|
|
output.textContent = "Enter a valid non-negative hashrate.";
|
|
return;
|
|
}
|
|
|
|
if (!hashrateInput.value.trim()) {
|
|
output.textContent = "Enter your hashrate to estimate earnings.";
|
|
return;
|
|
}
|
|
|
|
const dailyFbc = hashrate * estimatedRewardPerHashDailyFbc;
|
|
const weeklyFbc = dailyFbc * 7;
|
|
output.textContent = `Estimated earnings: ${fmtFloat(dailyFbc, 2)} FBC/day (${fmtFloat(weeklyFbc, 2)} FBC/week)`;
|
|
}
|
|
|
|
function updateRewardRate(rate) {
|
|
estimatedRewardPerHashDailyFbc = Number(rate || 0);
|
|
const rateDisplay = document.getElementById("rewardRateDisplay");
|
|
rateDisplay.textContent = `Current rate: ${fmtFloat(estimatedRewardPerHashDailyFbc, 2)} FBC per H/s per day`;
|
|
updateEstimateOutput();
|
|
}
|
|
|
|
function initRewardCalculator() {
|
|
const modal = document.getElementById("rewardModal");
|
|
const closeBtn = document.getElementById("closeRewardModal");
|
|
const hashrateInput = document.getElementById("hashrateInput");
|
|
const poolSummary = document.getElementById("poolSummary");
|
|
|
|
function openModal() {
|
|
modal.hidden = false;
|
|
hashrateInput.focus();
|
|
updateEstimateOutput();
|
|
}
|
|
|
|
function closeModal() {
|
|
modal.hidden = true;
|
|
}
|
|
|
|
poolSummary.addEventListener("click", (event) => {
|
|
const trigger = event.target.closest("#rewardRateTrigger");
|
|
if (trigger) {
|
|
openModal();
|
|
}
|
|
});
|
|
|
|
closeBtn.addEventListener("click", closeModal);
|
|
modal.addEventListener("click", (event) => {
|
|
if (event.target === modal) {
|
|
closeModal();
|
|
}
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape" && !modal.hidden) {
|
|
closeModal();
|
|
}
|
|
});
|
|
hashrateInput.addEventListener("input", updateEstimateOutput);
|
|
}
|
|
|
|
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 renderLimitedTable(key) {
|
|
const config = limitedTableCache[key];
|
|
if (!config) return;
|
|
|
|
const { bodyId, toggleId, rowsHtml, emptyText, cols } = config;
|
|
const showAll = limitedTableState[key];
|
|
const visibleRows = showAll ? rowsHtml : rowsHtml.slice(0, previewRowCount);
|
|
const toggle = document.getElementById(toggleId);
|
|
|
|
renderRows(bodyId, visibleRows, emptyText, cols);
|
|
|
|
if (!toggle) return;
|
|
if (rowsHtml.length <= previewRowCount) {
|
|
toggle.hidden = true;
|
|
return;
|
|
}
|
|
|
|
toggle.hidden = false;
|
|
toggle.textContent = showAll ? "Show less" : `Show more (${rowsHtml.length - previewRowCount})`;
|
|
}
|
|
|
|
function setLimitedTable(key, config) {
|
|
limitedTableCache[key] = config;
|
|
renderLimitedTable(key);
|
|
}
|
|
|
|
function initTableToggles() {
|
|
const toggleMap = {
|
|
blocksToggle: "blocks",
|
|
balancesToggle: "balances",
|
|
payoutsToggle: "payouts"
|
|
};
|
|
|
|
Object.entries(toggleMap).forEach(([toggleId, key]) => {
|
|
const toggle = document.getElementById(toggleId);
|
|
if (!toggle) return;
|
|
toggle.addEventListener("click", () => {
|
|
limitedTableState[key] = !limitedTableState[key];
|
|
renderLimitedTable(key);
|
|
});
|
|
});
|
|
}
|
|
|
|
function shortTxid(txid) {
|
|
if (!txid) return "-";
|
|
if (txid.length <= 18) return txid;
|
|
return `${txid.slice(0, 10)}...${txid.slice(-6)}`;
|
|
}
|
|
|
|
function statusClass(status, fallback = "pending") {
|
|
const normalized = String(status || fallback).trim().toLowerCase();
|
|
if (normalized === "sent") return "paid";
|
|
if (normalized === "mature") return "confirmed";
|
|
return normalized;
|
|
}
|
|
|
|
function statusLabel(status, fallback = "pending") {
|
|
return String(status || fallback).trim();
|
|
}
|
|
|
|
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, 2) + " H/s" },
|
|
{ label: "Network Hashrate", value: fmtFloat(summary.network_hashrate, 2) + " H/s" },
|
|
{ label: "Currently Mining", value: `Block: ${summary.current_mining_block ? fmtInt(summary.current_mining_block) : "-"}` },
|
|
{ label: "Est. Reward / H/s / Day", value: fmtFloat(summary.estimated_reward_per_hash_daily_fbc, 2) + " FBC", clickable: true, id: "rewardRateTrigger" },
|
|
{ label: "Active Workers", value: fmtInt(summary.workers) },
|
|
{ label: "Blocks Found", value: fmtInt(summary.blocks_found) },
|
|
{ label: "Awaiting Maturity", value: fmtInt(summary.blocks_waiting_maturity) },
|
|
{ label: "Pending Payouts (FBC)", value: fmtFloat(summary.blocks_waiting_maturity_reward_fbc, 2) },
|
|
{ label: "Total Paid (FBC)", value: fmtFloat(summary.total_paid_fbc, 2) }
|
|
]);
|
|
updateRewardRate(summary.estimated_reward_per_hash_daily_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, ${fmtInt(summary.blocks_waiting_maturity)} awaiting maturity`;
|
|
|
|
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
|
|
);
|
|
|
|
setLimitedTable("blocks", {
|
|
bodyId: "blocksBody",
|
|
toggleId: "blocksToggle",
|
|
rowsHtml: blocks.map(b => `
|
|
<tr>
|
|
<td data-label="Height">${fmtInt(b.height)}</td>
|
|
<td data-label="Status"><span class="pill ${statusClass(b.status, "unknown")}">${statusLabel(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="Confirmed">${fmtBlockConfirmations(b.status, b.confirmations)}</td>
|
|
<td data-label="Time">${fmtTime(b.time)}</td>
|
|
</tr>
|
|
`),
|
|
emptyText: "No blocks available.",
|
|
cols: 6
|
|
});
|
|
|
|
setLimitedTable("balances", {
|
|
bodyId: "balancesBody",
|
|
toggleId: "balancesToggle",
|
|
rowsHtml: 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>
|
|
`),
|
|
emptyText: "No balances available.",
|
|
cols: 4
|
|
});
|
|
|
|
setLimitedTable("payouts", {
|
|
bodyId: "payoutsBody",
|
|
toggleId: "payoutsToggle",
|
|
rowsHtml: 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 ${statusClass(p.status)}">${statusLabel(p.status)}</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>
|
|
`),
|
|
emptyText: "No payouts available.",
|
|
cols: 5
|
|
});
|
|
}
|
|
|
|
async function refreshAll() {
|
|
try {
|
|
await loadOverview();
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
initMobileAccordions();
|
|
initRewardCalculator();
|
|
initTableToggles();
|
|
refreshAll();
|
|
setInterval(refreshAll, refreshMs);
|
|
</script>
|
|
</body>
|
|
|
|
</html> |