generated from nathanwoodburn/python-webserver-template
352 lines
15 KiB
HTML
352 lines
15 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 Stats | Woodburn</title>
|
|
<meta name="description" content="Historic Firepool hashrate, worker trends, and top mining address analytics.">
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:site_name" content="Firepool">
|
|
<meta property="og:title" content="Firepool Stats | Woodburn">
|
|
<meta property="og:description" content="Historic pool hashrate, workers, and top mining address trends.">
|
|
<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 Stats | Woodburn">
|
|
<meta name="twitter:description" content="Historic pool hashrate, workers, and top mining address trends.">
|
|
<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/stats.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 Stats</h1>
|
|
<p class="subtitle">Historic hashrate, worker trends, and top mining addresses.</p>
|
|
</div>
|
|
<div class="hero-meta">
|
|
<span id="lastUpdated">Last update: pending</span>
|
|
<a class="nav-link" href="/">Back to Dashboard</a>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="panel controls-panel">
|
|
<div class="control-row">
|
|
<label for="windowHours">History Window</label>
|
|
<select id="windowHours">
|
|
<option value="1">Last 1 hour</option>
|
|
<option value="6">Last 6 hours</option>
|
|
<option value="12">Last 12 hours</option>
|
|
<option value="24" selected>Last 24 hours</option>
|
|
<option value="72">Last 3 days</option>
|
|
<option value="168">Last 7 days</option>
|
|
<option value="720">Last 30 days</option>
|
|
</select>
|
|
<button id="refreshBtn" type="button">Refresh</button>
|
|
</div>
|
|
<p class="muted" id="status">Loading history...</p>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<h2>Snapshot Summary</h2>
|
|
<p>Live values and history window metadata</p>
|
|
</div>
|
|
<div class="stats-grid" id="summaryCards"></div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<h2>Pool Hashrate Over Time</h2>
|
|
<p id="hashrateMeta">Awaiting data...</p>
|
|
</div>
|
|
<div class="chart-wrap">
|
|
<svg id="hashrateChart" viewBox="0 0 1000 320" preserveAspectRatio="none" role="img" aria-label="Pool hashrate line chart"></svg>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<h2>Workers Over Time</h2>
|
|
<p id="workerMeta">Awaiting data...</p>
|
|
</div>
|
|
<div class="chart-wrap">
|
|
<svg id="workersChart" viewBox="0 0 1000 320" preserveAspectRatio="none" role="img" aria-label="Worker count line chart"></svg>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<h2>Top Addresses In Latest Snapshot</h2>
|
|
<p>Ranked by latest hashrate bucket</p>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Rank</th>
|
|
<th>Address</th>
|
|
<th>Hashrate</th>
|
|
<th>Blocks</th>
|
|
<th>Accepted</th>
|
|
<th>Rejected</th>
|
|
<th>Stale</th>
|
|
<th>Total Earned (FBC)</th>
|
|
<th>Total Paid (FBC)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="latestTopAddressesBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<h2>Top Addresses Across Window</h2>
|
|
<p>Sorted by peak hashrate over selected period</p>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Address</th>
|
|
<th>Peak Hashrate</th>
|
|
<th>Avg Hashrate</th>
|
|
<th>Max Total Earned (FBC)</th>
|
|
<th>Max Blocks</th>
|
|
<th>Samples</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="windowTopAddressesBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</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 = 30000;
|
|
|
|
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 fmtTime(value) {
|
|
if (!value) return "-";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function setStatus(text) {
|
|
document.getElementById("status").textContent = text;
|
|
}
|
|
|
|
function refreshStamp() {
|
|
const stamp = document.getElementById("lastUpdated");
|
|
stamp.textContent = `Last update: ${new Date().toLocaleString()}`;
|
|
}
|
|
|
|
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 renderCards(cards) {
|
|
const root = document.getElementById("summaryCards");
|
|
root.innerHTML = cards
|
|
.map(card => `
|
|
<article class="stat-card">
|
|
<span>${card.label}</span>
|
|
<strong>${card.value}</strong>
|
|
</article>
|
|
`)
|
|
.join("");
|
|
}
|
|
|
|
function linePath(points) {
|
|
if (!points.length) return "";
|
|
return points.map((point, index) => `${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" ");
|
|
}
|
|
|
|
function renderLineChart(svgId, series, opts) {
|
|
const svg = document.getElementById(svgId);
|
|
const width = 1000;
|
|
const height = 320;
|
|
const pad = { left: 52, right: 18, top: 20, bottom: 36 };
|
|
const values = series.map(item => Number(item.value || 0));
|
|
const min = Math.min(...values);
|
|
const max = Math.max(...values);
|
|
const span = Math.max(1e-9, max - min);
|
|
|
|
if (!series.length) {
|
|
svg.innerHTML = `<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" class="chart-empty">No datapoints yet</text>`;
|
|
return;
|
|
}
|
|
|
|
const points = series.map((item, index) => {
|
|
const x = pad.left + (index / Math.max(1, series.length - 1)) * (width - pad.left - pad.right);
|
|
const normalized = (Number(item.value || 0) - min) / span;
|
|
const y = pad.top + (1 - normalized) * (height - pad.top - pad.bottom);
|
|
return { x, y };
|
|
});
|
|
|
|
const gridLines = 4;
|
|
let grid = "";
|
|
for (let i = 0; i <= gridLines; i += 1) {
|
|
const y = pad.top + (i / gridLines) * (height - pad.top - pad.bottom);
|
|
grid += `<line class="grid-line" x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}"></line>`;
|
|
}
|
|
|
|
const first = series[0];
|
|
const last = series[series.length - 1];
|
|
const yMid = (min + max) / 2;
|
|
|
|
svg.innerHTML = `
|
|
<rect class="chart-bg" x="0" y="0" width="${width}" height="${height}"></rect>
|
|
${grid}
|
|
<path class="line line-${opts.tone}" d="${linePath(points)}"></path>
|
|
<circle class="line-point line-${opts.tone}" cx="${points[points.length - 1].x}" cy="${points[points.length - 1].y}" r="4"></circle>
|
|
<text class="axis-label" x="${pad.left}" y="${height - 10}">${fmtTime(first.recorded_at)}</text>
|
|
<text class="axis-label" x="${width - pad.right}" y="${height - 10}" text-anchor="end">${fmtTime(last.recorded_at)}</text>
|
|
<text class="axis-label" x="8" y="${pad.top + 2}">${opts.valueFmt(max)}</text>
|
|
<text class="axis-label" x="8" y="${pad.top + (height - pad.top - pad.bottom) / 2 + 2}">${opts.valueFmt(yMid)}</text>
|
|
<text class="axis-label" x="8" y="${height - pad.bottom}">${opts.valueFmt(min)}</text>
|
|
`;
|
|
}
|
|
|
|
async function fetchOverview() {
|
|
const res = await fetch("/api/v1/pool/overview");
|
|
if (!res.ok) throw new Error(`Overview request failed (${res.status})`);
|
|
return res.json();
|
|
}
|
|
|
|
async function fetchHistory(hours) {
|
|
const res = await fetch(`/api/v1/pool/history?hours=${encodeURIComponent(hours)}&limit=5000`);
|
|
if (!res.ok) throw new Error(`History request failed (${res.status})`);
|
|
return res.json();
|
|
}
|
|
|
|
async function refresh() {
|
|
const hours = Number(document.getElementById("windowHours").value || 24);
|
|
setStatus("Loading stats...");
|
|
try {
|
|
const [overview, history] = await Promise.all([fetchOverview(), fetchHistory(hours)]);
|
|
const summary = overview.summary || {};
|
|
|
|
renderCards([
|
|
{ label: "Current Pool Hashrate", value: `${fmtFloat(summary.hashrate)} H/s` },
|
|
{ label: "Current Worker Count", value: fmtInt(summary.workers) },
|
|
{ label: "Network Hashrate", value: `${fmtFloat(summary.network_hashrate, 2)} H/s` },
|
|
{ label: "History Points", value: fmtInt((history.snapshots || []).length) },
|
|
{ label: "Sample Interval", value: `${fmtInt(history.sample_interval_seconds)} seconds` },
|
|
{ label: "Window", value: `${fmtInt(history.window_hours)} hours` }
|
|
]);
|
|
|
|
const hashrateSeries = Array.isArray(history.hashrate_series) ? history.hashrate_series : [];
|
|
const workerSeries = Array.isArray(history.worker_series) ? history.worker_series : [];
|
|
|
|
renderLineChart("hashrateChart", hashrateSeries, {
|
|
tone: "hot",
|
|
valueFmt: (v) => `${fmtFloat(v, 2)} H/s`
|
|
});
|
|
|
|
renderLineChart("workersChart", workerSeries, {
|
|
tone: "cool",
|
|
valueFmt: (v) => fmtInt(v)
|
|
});
|
|
|
|
const hashrateFirst = hashrateSeries[0];
|
|
const hashrateLast = hashrateSeries[hashrateSeries.length - 1];
|
|
const workersFirst = workerSeries[0];
|
|
const workersLast = workerSeries[workerSeries.length - 1];
|
|
|
|
document.getElementById("hashrateMeta").textContent = hashrateSeries.length
|
|
? `From ${fmtTime(hashrateFirst.recorded_at)} to ${fmtTime(hashrateLast.recorded_at)} (${fmtInt(hashrateSeries.length)} points)`
|
|
: "No hashrate points collected yet.";
|
|
|
|
document.getElementById("workerMeta").textContent = workerSeries.length
|
|
? `From ${fmtTime(workersFirst.recorded_at)} to ${fmtTime(workersLast.recorded_at)} (${fmtInt(workerSeries.length)} points)`
|
|
: "No worker points collected yet.";
|
|
|
|
const latestTop = Array.isArray(history.latest_top_addresses) ? history.latest_top_addresses : [];
|
|
renderRows(
|
|
"latestTopAddressesBody",
|
|
latestTop.map(row => `
|
|
<tr>
|
|
<td>${fmtInt(row.rank)}</td>
|
|
<td class="mono">${row.address || "-"}</td>
|
|
<td>${fmtFloat(row.hashrate)}</td>
|
|
<td>${fmtInt(row.blocks)}</td>
|
|
<td>${fmtInt(row.accepted)}</td>
|
|
<td>${fmtInt(row.rejected)}</td>
|
|
<td>${fmtInt(row.stale)}</td>
|
|
<td>${fmtFbc(row.total_earned_fbc)}</td>
|
|
<td>${fmtFbc(row.total_paid_fbc)}</td>
|
|
</tr>
|
|
`),
|
|
"No latest top address data yet.",
|
|
9
|
|
);
|
|
|
|
const windowTop = Array.isArray(history.top_addresses_over_period) ? history.top_addresses_over_period : [];
|
|
renderRows(
|
|
"windowTopAddressesBody",
|
|
windowTop.map(row => `
|
|
<tr>
|
|
<td class="mono">${row.address || "-"}</td>
|
|
<td>${fmtFloat(row.peak_hashrate)}</td>
|
|
<td>${fmtFloat(row.avg_hashrate)}</td>
|
|
<td>${fmtFbc(row.max_total_earned_fbc)}</td>
|
|
<td>${fmtInt(row.max_blocks)}</td>
|
|
<td>${fmtInt(row.samples)}</td>
|
|
</tr>
|
|
`),
|
|
"No top address data across this window yet.",
|
|
6
|
|
);
|
|
|
|
refreshStamp();
|
|
setStatus("Stats loaded.");
|
|
} catch (err) {
|
|
console.error(err);
|
|
setStatus(`Error loading stats: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
document.getElementById("refreshBtn").addEventListener("click", refresh);
|
|
document.getElementById("windowHours").addEventListener("change", refresh);
|
|
|
|
refresh();
|
|
setInterval(refresh, refreshMs);
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|