feat: Add initial server
All checks were successful
Build Docker / BuildImage (push) Successful in 58s
Check Code Quality / RuffCheck (push) Successful in 1m6s

This commit is contained in:
2026-04-04 19:22:20 +11:00
parent 27437f9f80
commit 5519132c27
6 changed files with 1375 additions and 298 deletions

View File

@@ -1,41 +1,392 @@
:root {
--bg: #0a0e1a;
--bg-alt: #141829;
--panel: rgba(20, 24, 41, 0.75);
--panel-border: rgba(255, 107, 53, 0.25);
--text: #f5f5f5;
--muted: #a0a0a0;
--accent: #ff6b35;
--warning: #ffa500;
--danger: #ff4444;
}
* {
box-sizing: border-box;
}
body {
background-color: #000000;
color: #ffffff;
}
h1 {
font-size: 50px;
margin: 0;
padding: 0;
color: var(--text);
background: radial-gradient(circle at top left, #2a2a3e 0%, var(--bg) 48%, #0a0a0f 100%);
font-family: "Space Grotesk", "Segoe UI", sans-serif;
min-height: 100vh;
position: relative;
}
.centre {
margin-top: 10%;
.bg-grid {
position: fixed;
inset: 0;
z-index: 0;
background-image: linear-gradient(rgba(255, 107, 53, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 107, 53, 0.06) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at center, black 30%, transparent 100%);
}
.bg-orb {
position: fixed;
border-radius: 999px;
filter: blur(70px);
opacity: 0.5;
z-index: 0;
pointer-events: none;
}
.orb-a {
width: 300px;
height: 300px;
background: #ff6b35;
top: 7%;
left: 4%;
}
.orb-b {
width: 260px;
height: 260px;
background: #ff8c42;
right: 7%;
bottom: 10%;
}
.layout {
max-width: 1200px;
margin: 0 auto;
padding: 28px 16px 48px;
position: relative;
z-index: 1;
}
.hero {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 14px;
align-items: end;
margin-bottom: 20px;
}
.kicker {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent);
font-size: 12px;
}
h1 {
margin: 6px 0;
font-size: clamp(30px, 5vw, 52px);
line-height: 1;
}
.subtitle {
margin: 0;
color: var(--muted);
}
.hero-meta {
display: grid;
gap: 8px;
color: var(--muted);
font-size: 14px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.stats-grid.compact {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.stat-card {
border: 1px solid var(--panel-border);
border-radius: 14px;
background: linear-gradient(180deg, rgba(9, 45, 79, 0.84), var(--panel));
padding: 10px 12px;
display: grid;
gap: 4px;
min-width: 0;
}
.stat-card span {
color: var(--muted);
font-size: 12px;
}
.stat-card strong {
font-size: 22px;
line-height: 1;
letter-spacing: 0.02em;
word-break: break-word;
overflow-wrap: break-word;
}
.panel {
border: 1px solid var(--panel-border);
border-radius: 16px;
margin-bottom: 16px;
padding: 12px;
background: var(--panel);
backdrop-filter: blur(5px);
}
.panel.split {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.panel-head h2 {
margin: 0;
font-size: 20px;
}
.panel-head p {
margin: 2px 0 10px;
color: var(--muted);
font-size: 13px;
}
.search-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
input,
button {
border: 1px solid var(--panel-border);
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
color: var(--text);
font: inherit;
padding: 10px 11px;
}
input {
flex: 1;
min-width: 0;
}
button {
cursor: pointer;
background: linear-gradient(120deg, #ff6b35, #ff8c42);
color: #fff;
font-weight: 700;
transition: opacity 0.2s;
}
button:hover {
opacity: 0.9;
}
.muted {
color: var(--muted);
font-size: 14px;
margin-bottom: 10px;
}
.table-wrap {
width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 700px;
}
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid rgba(141, 206, 255, 0.13);
font-size: 13px;
vertical-align: top;
}
td {
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
}
th {
color: #ff8c42;
font-weight: 600;
}
.pill {
border-radius: 99px;
padding: 2px 9px;
display: inline-block;
font-size: 11px;
text-transform: uppercase;
}
.pill.immature,
.pill.pending {
background: rgba(249, 202, 104, 0.25);
color: var(--warning);
}
.pill.confirmed,
.pill.paid,
.pill.success {
background: rgba(120, 230, 200, 0.2);
color: var(--accent);
}
.pill.orphan,
.pill.failed,
.pill.rejected {
background: rgba(255, 127, 136, 0.2);
color: var(--danger);
}
.empty {
color: var(--muted);
}
@media (min-width: 920px) {
.panel.split {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 850px) {
.layout {
padding: 20px 12px 36px;
}
.hero {
align-items: flex-start;
}
.hero-meta {
width: 100%;
}
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
}
}
@media (max-width: 720px) {
.search-row {
flex-direction: column;
}
button {
width: 100%;
}
.stats-grid,
.stats-grid.compact {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 620px) {
h1 {
font-size: clamp(26px, 8vw, 36px);
}
.stats-grid,
.stats-grid.compact {
grid-template-columns: 1fr;
}
.panel {
padding: 10px;
}
.table-wrap {
overflow: visible;
}
table {
min-width: 0;
width: 100%;
}
thead {
display: none;
}
tbody,
tr,
td {
display: block;
width: 100%;
}
tr {
border: 1px solid rgba(255, 107, 53, 0.2);
border-radius: 12px;
margin-bottom: 10px;
padding: 6px 8px;
background: rgba(40, 30, 20, 0.4);
}
td {
border: 0;
border-bottom: 1px dashed rgba(255, 107, 53, 0.15);
padding: 7px 0;
display: grid;
grid-template-columns: minmax(90px, 35%) 1fr;
gap: 8px;
align-items: center;
overflow-wrap: break-word;
word-break: break-word;
}
td:last-child {
border-bottom: 0;
}
td::before {
content: attr(data-label);
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.empty {
display: block;
border: 0;
padding: 8px 0;
}
.empty::before {
content: none;
}
}
.footer {
text-align: center;
padding: 20px 0;
color: var(--muted);
font-size: 14px;
}
a {
color: #ffffff;
.footer a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Mike section styling */
.mike-section {
margin-top: 30px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
padding: 20px;
background-color: rgba(50, 50, 50, 0.3);
border-radius: 8px;
}
.mike-section h2 {
color: #f0f0f0;
margin-top: 0;
}
.mike-section p {
line-height: 1.6;
margin-bottom: 15px;
font-weight: 500;
}

View File

@@ -4,57 +4,410 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nathan.Woodburn/</title>
<link rel="icon" href="/assets/img/favicon.png" type="image/png">
<title>Firepool | Woodburn</title>
<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="spacer"></div>
<div class="centre">
<h1>Nathan.Woodburn/</h1>
<span>The current date and time is {{datetime}}</span>
</div>
<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>
<section class="stats-grid" id="poolSummary"></section>
<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>
<section class="panel">
<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>
</section>
<section class="panel">
<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>
</section>
<section class="panel">
<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>
</section>
<section class="panel">
<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>
</section>
<footer class="footer">
<p>Powered by <a href="https://nathan.woodburn.au" target="_blank" rel="noopener">Nathan.Woodburn/</a>
</footer>
</main>
<div class="spacer"></div>
<div class="centre">
<h2 id="test-content-header">Pulling data</h2>
<span class="test-content">This is a test content area that will be updated with data from the server.</span>
<br>
<br>
<span class="test-content-timestamp">Timestamp: Waiting to pull data</span>
</div>
<script>
function fetchData() {
// Fetch the data from the server
fetch('/api/v1/data')
.then(response => response.json())
.then(data => {
// Get the data header element
const dataHeader = document.getElementById('test-content-header');
// Update the header with the fetched data
dataHeader.textContent = data.header;
// Get the test content element
const testContent = document.querySelector('.test-content');
// Update the content with the fetched data
testContent.textContent = data.content;
const refreshMs = 10000;
// Get the timestamp element
const timestampElement = document.querySelector('.test-content-timestamp');
// Update the timestamp with the fetched data
timestampElement.textContent = `Timestamp: ${data.timestamp}`;
})
.catch(error => console.error('Error fetching data:', error));
function fmtInt(value) {
return Number(value || 0).toLocaleString();
}
// Initial fetch after 2 seconds
setTimeout(fetchData, 2000);
// Then fetch every 2 seconds
setInterval(fetchData, 2000);
</script>
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()}`;
}
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 || ""}">${shortTxid(p.txid)}</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}`;
}
});
refreshAll();
setInterval(refreshAll, refreshMs);
</script>
</body>
</html>