From 0bc7d5758fd4b9c8da1b5980ebd564347d9a5fc1 Mon Sep 17 00:00:00 2001 From: Dark_Hunter Date: Sat, 4 Jul 2026 06:07:03 +1200 Subject: [PATCH 1/2] Add live pool dashboard with stats, charts, and worker search Finish the unreleased web dashboard: serve live stats from in-memory mining state, add history charts, searchable worker table, and ignore runtime rewards sync files. --- .gitignore | 2 + config/config.json | 1 + dashboard/rewards.json | 1 - dashboard/static/index.html | 182 ++++++++++++++++++++++-------- dashboard/static/script.js | 214 ++++++++++++++++++++++++++++++++++-- dashboard/static/styles.css | 61 +++++++++- src/dashboard.js | 114 +++++++++++++++++-- src/index.js | 5 +- src/poolStats.js | 24 ++++ src/sync.js | 14 ++- 10 files changed, 547 insertions(+), 71 deletions(-) delete mode 100644 dashboard/rewards.json create mode 100644 src/poolStats.js diff --git a/.gitignore b/.gitignore index e5dcf55..0d7ebb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ duinocoin/ node_modules/ config/ +dashboard/rewards*.json +dashboard/workers_*.json diff --git a/config/config.json b/config/config.json index 0dd6201..aec1923 100644 --- a/config/config.json +++ b/config/config.json @@ -6,6 +6,7 @@ "host": "0.0.0.0", "port": 6000, + "dashboard_port": 6030, "serverIP": "server.duinocoin.com", "serverPort": 2810, diff --git a/dashboard/rewards.json b/dashboard/rewards.json deleted file mode 100644 index 9e26dfe..0000000 --- a/dashboard/rewards.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/dashboard/static/index.html b/dashboard/static/index.html index d4cd73a..6b81d43 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -6,11 +6,10 @@ + - Pulse Pool Stats - - - + Pool Dashboard + @@ -21,92 +20,181 @@

Welcome to

-

- a - - Duino-Coin - - mining node 馃憢 +

+ a Duino-Coin mining node + 路 + duinocoin.com

+

Node statistics

-
-
+
+

Connections: - - 0 - + 0

-
+

- CPU usage: - - 0% - + CPU: + 0%

-
+

- RAM usage: - - 0% - + RAM: + 0%

-
+

Workers: - - workers.json - + 0 +

+
+
+

+ + Users: + 0 +

+
+
+

+ + Hashrate: + 0 H/s

-

- ~ More data will be added here when Bila adds more api stuff ~ +

+
+

+ + Uptime: + - +

+
+
+

+ + Accept rate: + - +

+
+
+

+ + Last sync: + - +

+
+
+

+ + Sync #: + 0 +

+
+
+

+ workers.json 路 + rewards.json 路 + ping + 路 Updating...

+
+

- + History

-

- Pulse pool (also named Kolka pool) is the first Duino-Coin mining node created by Bilaboz from the Duino team.
- It was launched at 6/07/2021 and helps in off-loading the master server from PC and ESP miners. +

+
+

Connections

+
+ +
+
+
+

Hashrate

+
+ +
+
+
+
+ +
+

+ + Workers

+
+
+

+ + +

+
+
+
+ +
+
+

Showing 0 of 0

+
+
+ + + + + + + + + + + + + + +
UserHashrateMinerRigDiffPingOKBad
+
+
-

-

-

-

-

-

-

- Designed by revox from Duino team 2021
- Made with Bulma + Duino-Coin pool dashboard 路 + duinocoin.com

- \ No newline at end of file + diff --git a/dashboard/static/script.js b/dashboard/static/script.js index 2388d3a..47544f5 100644 --- a/dashboard/static/script.js +++ b/dashboard/static/script.js @@ -2,19 +2,215 @@ const connections = document.getElementById("connections"); const cpu = document.getElementById("cpu"); const ram = document.getElementById("ram"); const workers = document.getElementById("workers"); +const users = document.getElementById("users"); +const hashrate = document.getElementById("hashrate"); +const poolName = document.getElementById("pool-name"); +const poolMotdText = document.getElementById("pool-motd-text"); +const workersTable = document.getElementById("workers-table"); +const uptimeEl = document.getElementById("uptime"); +const acceptRateEl = document.getElementById("accept-rate"); +const lastSyncEl = document.getElementById("last-sync"); +const syncCountEl = document.getElementById("sync-count"); +const lastUpdatedEl = document.getElementById("last-updated"); +const statsErrorEl = document.getElementById("stats-error"); +const workersErrorEl = document.getElementById("workers-error"); +const workersCountEl = document.getElementById("workers-count"); +const workerSearch = document.getElementById("worker-search"); +const workerSort = document.getElementById("worker-sort"); + +let connectionsChart = null; +let hashrateChart = null; +let searchTimeout = null; + +const chartDefaults = { + responsive: true, + maintainAspectRatio: false, + animation: false, + scales: { + x: { + ticks: { maxTicksLimit: 6, color: "#b5b5b5" }, + grid: { color: "rgba(255,255,255,0.08)" }, + }, + y: { + beginAtZero: true, + ticks: { color: "#b5b5b5" }, + grid: { color: "rgba(255,255,255,0.08)" }, + }, + }, + plugins: { legend: { display: false } }, +}; + +function formatHashrate(h) { + if (h >= 1e6) return (h / 1e6).toFixed(2) + " MH/s"; + if (h >= 1e3) return (h / 1e3).toFixed(1) + " kH/s"; + return Math.round(h) + " H/s"; +} + +function formatUptime(seconds) { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m ${seconds % 60}s`; +} + +function formatTime(ts) { + if (!ts) return "-"; + return new Date(ts).toLocaleTimeString(); +} + +function setCell(row, text) { + const td = document.createElement("td"); + td.textContent = text; + row.appendChild(td); +} + +function initCharts() { + const connCtx = document.getElementById("connections-chart"); + const hrCtx = document.getElementById("hashrate-chart"); + + connectionsChart = new Chart(connCtx, { + type: "line", + data: { + labels: [], + datasets: [{ + data: [], + borderColor: "#ffb86c", + backgroundColor: "rgba(255,184,108,0.1)", + fill: true, + tension: 0.3, + pointRadius: 0, + }], + }, + options: chartDefaults, + }); + + hashrateChart = new Chart(hrCtx, { + type: "line", + data: { + labels: [], + datasets: [{ + data: [], + borderColor: "#8be9fd", + backgroundColor: "rgba(139,233,253,0.1)", + fill: true, + tension: 0.3, + pointRadius: 0, + }], + }, + options: { + ...chartDefaults, + scales: { + ...chartDefaults.scales, + y: { + ...chartDefaults.scales.y, + ticks: { + color: "#b5b5b5", + callback: (v) => formatHashrate(v), + }, + }, + }, + }, + }); +} + +function updateCharts(history) { + if (!connectionsChart || !hashrateChart || !history) return; + + const labels = history.map((p) => new Date(p.t).toLocaleTimeString()); + const connData = history.map((p) => p.connections); + const hrData = history.map((p) => p.hashrate); + + connectionsChart.data.labels = labels; + connectionsChart.data.datasets[0].data = connData; + connectionsChart.update(); + + hashrateChart.data.labels = labels; + hashrateChart.data.datasets[0].data = hrData; + hashrateChart.update(); +} function fetch_statistics() { fetch("/statistics") - .then(response => response.json()) - .then(data => { - connections.innerHTML = data["connections"]; - cpu.innerHTML = data["cpu"].toFixed(2) + "%"; - ram.innerHTML = data["ram"].toFixed(2) + "%"; + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }) + .then((data) => { + statsErrorEl.classList.add("is-hidden"); + connections.textContent = data.connections; + cpu.textContent = data.cpu.toFixed(1) + "%"; + ram.textContent = data.ram.toFixed(1) + "%"; + workers.textContent = data.workers; + users.textContent = data.users; + hashrate.textContent = formatHashrate(data.hashrate); + uptimeEl.textContent = formatUptime(data.uptime || 0); + acceptRateEl.textContent = data.acceptRate.toFixed(1) + "%"; + lastSyncEl.textContent = formatTime(data.lastSyncAt); + syncCountEl.textContent = data.syncCount; + + if (data.poolName) poolName.textContent = data.poolName; + if (data.motd) poolMotdText.textContent = data.motd; + + updateCharts(data.history); + lastUpdatedEl.textContent = "Updated " + new Date().toLocaleTimeString(); + }) + .catch((err) => { + statsErrorEl.textContent = "Failed to load statistics: " + err.message; + statsErrorEl.classList.remove("is-hidden"); + }); +} + +function getWorkerQuery() { + const [sort, order] = workerSort.value.split(":"); + const params = new URLSearchParams({ sort, order }); + const search = workerSearch.value.trim(); + if (search) params.set("search", search); + return params.toString(); +} + +function fetch_workers() { + fetch("/workers?" + getWorkerQuery()) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }) + .then((rows) => { + workersErrorEl.classList.add("is-hidden"); + workersTable.innerHTML = ""; + + rows.forEach((row) => { + const tr = document.createElement("tr"); + setCell(tr, row.username || "-"); + setCell(tr, formatHashrate(row.hashrate || 0)); + setCell(tr, row.miner || "-"); + setCell(tr, row.rig || "-"); + setCell(tr, row.difficulty != null ? String(row.difficulty) : "-"); + setCell(tr, row.ping != null ? row.ping.toFixed(2) + "s" : "-"); + setCell(tr, String(row.accepted || 0)); + setCell(tr, String(row.rejected || 0)); + workersTable.appendChild(tr); + }); + + workersCountEl.textContent = `Showing ${rows.length} worker${rows.length === 1 ? "" : "s"}`; }) + .catch((err) => { + workersErrorEl.textContent = "Failed to load workers: " + err.message; + workersErrorEl.classList.remove("is-hidden"); + }); } +workerSearch.addEventListener("input", () => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(fetch_workers, 300); +}); + +workerSort.addEventListener("change", fetch_workers); + +initCharts(); fetch_statistics(); -setInterval(function() { - // run every 5s - fetch_statistics(); -}, 5000); \ No newline at end of file +fetch_workers(); + +setInterval(fetch_statistics, 5000); +setInterval(fetch_workers, 15000); diff --git a/dashboard/static/styles.css b/dashboard/static/styles.css index 8ffd048..2569a4e 100644 --- a/dashboard/static/styles.css +++ b/dashboard/static/styles.css @@ -1,4 +1,48 @@ - /* Some dracula colors from https://draculatheme.com/contribute */ +.table-container { + max-height: 520px; + overflow-y: auto; +} + +.table-container thead th { + position: sticky; + top: 0; + background: #1f2d3d; + z-index: 1; +} + +.chart-container { + position: relative; + height: 200px; +} + +.workers-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; +} + +.workers-toolbar .field { + margin-bottom: 0; + flex: 1 1 200px; +} + +.workers-count { + margin-bottom: 0; + white-space: nowrap; +} + +.status-row { + margin-top: 0.5rem; + padding-top: 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +footer { + padding: 2rem 0 3rem; +} + .has-text-success { color: #50fa7b; } @@ -15,10 +59,8 @@ #FFF 90%); -webkit-background-clip: text; background-clip: text; - -webkit-text-fill-color: transparent; text-fill-color: transparent; - background-size: 200% auto; animation: text_shine 2.5s ease infinite alternate; } @@ -27,4 +69,15 @@ to { background-position: 200%; } -} \ No newline at end of file +} + +@media screen and (max-width: 768px) { + .workers-toolbar { + flex-direction: column; + align-items: stretch; + } + + .workers-count { + text-align: center; + } +} diff --git a/src/dashboard.js b/src/dashboard.js index c245b29..c69e3af 100755 --- a/src/dashboard.js +++ b/src/dashboard.js @@ -1,21 +1,119 @@ -/* Duino-Coin Pool dashboard generator +/* Duino-Coin Pool dashboard For documention about these functions see https://github.com/revoxhere/duino-coin/blob/useful-tools 2019-2024 Duino-Coin community */ +const path = require("path"); const express = require("express"); const log = require("./logging"); +const poolStats = require("./poolStats"); +const mining = require("./mining"); +const { + dashboard_port, + poolName, + motd, + port: poolPort, +} = require("../config/config.json"); + const app = express(); +const staticDir = path.join(__dirname, "../dashboard/static"); + +const SORT_FIELDS = { + hashrate: (w) => w.hashrate, + username: (w) => (w.username || "").toLowerCase(), + accepted: (w) => w.accepted, + rejected: (w) => w.rejected, +}; + +app.use(express.static(staticDir)); + +app.get("/ping", (req, res) => { + res.json({ result: "Pong!", success: true }); +}); + +app.get("/statistics", (req, res) => { + const workers = Object.values(mining.stats.minersStats); + const users = new Set(workers.map((w) => w.u)); + const hashrate = workers.reduce((sum, w) => sum + (w.h || 0), 0); + const acceptedShares = workers.reduce((sum, w) => sum + (w.a || 0), 0); + const rejectedShares = workers.reduce((sum, w) => sum + (w.r || 0), 0); + const totalShares = acceptedShares + rejectedShares; + const acceptRate = totalShares > 0 ? (acceptedShares / totalShares) * 100 : 100; + const uptime = poolStats.startedAt + ? Math.floor((Date.now() - poolStats.startedAt) / 1000) + : 0; -app.get("/ping", async (req, res) => { - require("./index"); res.json({ - result: "Pong!", - success: true, + poolName, + motd, + poolPort, + connections: poolStats.connections, + cpu: poolStats.cpu, + ram: poolStats.ram, + workers: workers.length, + users: users.size, + hashrate, + syncCount: poolStats.syncCount, + lastSyncAt: poolStats.lastSyncAt, + uptime, + acceptRate, + acceptedShares, + rejectedShares, + history: poolStats.history, + }); +}); + +app.get("/workers", (req, res) => { + const search = (req.query.search || "").toLowerCase().trim(); + const sort = SORT_FIELDS[req.query.sort] ? req.query.sort : "hashrate"; + const order = req.query.order === "asc" ? "asc" : "desc"; + + let workers = Object.values(mining.stats.minersStats).map((w) => ({ + username: w.u, + hashrate: w.h, + sharetime: w.s, + accepted: w.a, + rejected: w.r, + difficulty: w.d, + miner: w.sft, + rig: w.id, + algorithm: w.al, + lastSeen: w.t, + ping: w.pg, + reward: w.rw, + })); + + if (search) { + workers = workers.filter( + (w) => + (w.username && w.username.toLowerCase().includes(search)) || + (w.miner && w.miner.toLowerCase().includes(search)) || + (w.rig && w.rig.toLowerCase().includes(search)) + ); + } + + const sortFn = SORT_FIELDS[sort]; + workers.sort((a, b) => { + const av = sortFn(a); + const bv = sortFn(b); + if (av < bv) return order === "asc" ? -1 : 1; + if (av > bv) return order === "asc" ? 1 : -1; + return 0; }); + + res.json(workers); }); -app.listen(8080).on("error", function (err) { - log.warning(`Ping listener is probably already running (${err})`); +app.get("/rewards", (req, res) => { + res.json(mining.stats.balancesToUpdate); }); -log.info("Ping listener on port 8080 enabled"); + +app.get("/", (req, res) => { + res.sendFile(path.join(staticDir, "index.html")); +}); + +app.listen(dashboard_port, "0.0.0.0").on("error", (err) => { + log.warning(`Dashboard listener failed (${err})`); +}); + +log.info(`Dashboard listening on port ${dashboard_port}`); diff --git a/src/index.js b/src/index.js index fc765e6..e944a28 100755 --- a/src/index.js +++ b/src/index.js @@ -9,8 +9,10 @@ const handle = require("./connectionHandler"); const sync = require("./sync"); const { spawn } = require("child_process"); const log = require("./logging"); +const poolStats = require("./poolStats"); let { use_ngrok, port, host, autoRestart, guessPort, use_serveo } = require("../config/config.json"); +poolStats.startedAt = Date.now(); connections = 0; const getRand = (min, max) => { @@ -65,7 +67,7 @@ if (use_ngrok) { sync.updatePoolReward(); sync.login(); -// require("./dashboard"); +require("./dashboard"); const server = net.createServer(handle); server.listen(port, host, 0, () => { @@ -94,6 +96,7 @@ setInterval(() => { server.getConnections((error, count) => { if (!error) { connections = count; + poolStats.connections = count; log.info(`Connected clients: ${count}`); } }); diff --git a/src/poolStats.js b/src/poolStats.js new file mode 100644 index 0000000..62b2a92 --- /dev/null +++ b/src/poolStats.js @@ -0,0 +1,24 @@ +/* Live pool statistics shared between sync, index, and dashboard */ + +const HISTORY_MAX = 60; + +const poolStats = { + connections: 0, + cpu: 0, + ram: 0, + syncCount: 0, + startedAt: null, + lastSyncAt: null, + history: [], +}; + +const pushHistory = (sample) => { + poolStats.history.push(sample); + if (poolStats.history.length > HISTORY_MAX) { + poolStats.history.shift(); + } +}; + +module.exports = poolStats; +module.exports.pushHistory = pushHistory; +module.exports.HISTORY_MAX = HISTORY_MAX; diff --git a/src/sync.js b/src/sync.js index b136e0f..35be659 100755 --- a/src/sync.js +++ b/src/sync.js @@ -10,6 +10,7 @@ const osu = require("node-os-utils"); const dns = require("dns"); const log = require("./logging"); +const poolStats = require("./poolStats"); const FormData = require("form-data"); const { poolID, @@ -192,7 +193,6 @@ const logout = () => { const sync = async () => { const mining = require("./mining"); const blockIncrease = mining.stats.globalShares.increase; - require("./index"); if (sync_count > 0 && sync_count % 25 == 0) { if (use_ngrok) { @@ -207,6 +207,9 @@ const sync = async () => { const cpuUsage = await osu.cpu.usage(); let ramUsage = await osu.mem.info(); ramUsage = 100 - ramUsage.freeMemPercentage; + poolStats.cpu = cpuUsage; + poolStats.ram = ramUsage; + poolStats.connections = connections; mining.stats.globalShares.increase = 0; const syncData = { @@ -262,6 +265,15 @@ const sync = async () => { delete mining.stats.balancesToUpdate[k]; }); globalBlocks = []; + poolStats.syncCount = sync_count; + poolStats.lastSyncAt = Date.now(); + const workerList = Object.values(mining.stats.minersStats); + poolStats.pushHistory({ + t: poolStats.lastSyncAt, + connections: poolStats.connections, + hashrate: workerList.reduce((sum, w) => sum + (w.h || 0), 0), + workers: workerList.length, + }); log.success(`Successfull sync #${sync_count}`); } else { log.warning( From cda7e851ff1cc97a9ccfb495642cb53f64c6df98 Mon Sep 17 00:00:00 2001 From: ZL1LAC Date: Sat, 4 Jul 2026 07:58:56 +1200 Subject: [PATCH 2/2] Persist dashboard history with richer metrics and improved charts Save history to disk with configurable retention, add CPU/RAM/workers charts, tooltips, time-range label, and GET /history export. --- .gitignore | 1 + config/config.json | 2 + dashboard/static/index.html | 20 +++- dashboard/static/script.js | 197 ++++++++++++++++++++++++------------ dashboard/static/styles.css | 18 +++- src/dashboard.js | 9 ++ src/index.js | 21 +++- src/poolStats.js | 81 ++++++++++++++- src/sync.js | 2 + 9 files changed, 276 insertions(+), 75 deletions(-) diff --git a/.gitignore b/.gitignore index 0d7ebb8..d080adb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ config/ dashboard/rewards*.json dashboard/workers_*.json +dashboard/history*.json diff --git a/config/config.json b/config/config.json index aec1923..80f86db 100644 --- a/config/config.json +++ b/config/config.json @@ -7,6 +7,8 @@ "host": "0.0.0.0", "port": 6000, "dashboard_port": 6030, + "historyMaxPoints": 480, + "historyMaxAgeHours": 24, "serverIP": "server.duinocoin.com", "serverPort": 2810, diff --git a/dashboard/static/index.html b/dashboard/static/index.html index 6b81d43..ddf1c4c 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -112,6 +112,7 @@

workers.jsonrewards.json 路 + history.jsonpingUpdating...

@@ -123,19 +124,32 @@ History

-
-
+

+
+

Connections

-
+

Hashrate

+
+

Workers

+
+ +
+
+
+

CPU / RAM

+
+ +
+
diff --git a/dashboard/static/script.js b/dashboard/static/script.js index 47544f5..7f6728c 100644 --- a/dashboard/static/script.js +++ b/dashboard/static/script.js @@ -12,6 +12,7 @@ const acceptRateEl = document.getElementById("accept-rate"); const lastSyncEl = document.getElementById("last-sync"); const syncCountEl = document.getElementById("sync-count"); const lastUpdatedEl = document.getElementById("last-updated"); +const historyRangeEl = document.getElementById("history-range"); const statsErrorEl = document.getElementById("stats-error"); const workersErrorEl = document.getElementById("workers-error"); const workersCountEl = document.getElementById("workers-count"); @@ -20,26 +21,10 @@ const workerSort = document.getElementById("worker-sort"); let connectionsChart = null; let hashrateChart = null; +let workersChart = null; +let systemChart = null; let searchTimeout = null; -const chartDefaults = { - responsive: true, - maintainAspectRatio: false, - animation: false, - scales: { - x: { - ticks: { maxTicksLimit: 6, color: "#b5b5b5" }, - grid: { color: "rgba(255,255,255,0.08)" }, - }, - y: { - beginAtZero: true, - ticks: { color: "#b5b5b5" }, - grid: { color: "rgba(255,255,255,0.08)" }, - }, - }, - plugins: { legend: { display: false } }, -}; - function formatHashrate(h) { if (h >= 1e6) return (h / 1e6).toFixed(2) + " MH/s"; if (h >= 1e3) return (h / 1e3).toFixed(1) + " kH/s"; @@ -60,6 +45,60 @@ function formatTime(ts) { return new Date(ts).toLocaleTimeString(); } +function formatSpan(hours) { + if (hours < 1) return `${Math.round(hours * 60)}m`; + if (hours < 24) { + const h = Math.floor(hours); + const m = Math.round((hours - h) * 60); + return m > 0 ? `${h}h ${m}m` : `${h}h`; + } + const d = Math.floor(hours / 24); + const h = Math.round(hours % 24); + return h > 0 ? `${d}d ${h}h` : `${d}d`; +} + +function historyLabels(history, meta) { + const showDate = meta && meta.spanHours >= 24; + return history.map((p) => { + const d = new Date(p.t); + return showDate + ? d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) + : d.toLocaleTimeString(); + }); +} + +function chartOptions(extraY) { + return { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: "index", intersect: false }, + scales: { + x: { + ticks: { maxTicksLimit: 6, color: "#b5b5b5", maxRotation: 0 }, + grid: { color: "rgba(255,255,255,0.08)" }, + }, + y: { + beginAtZero: true, + ticks: { color: "#b5b5b5", ...extraY }, + grid: { color: "rgba(255,255,255,0.08)" }, + }, + }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title: (items) => { + if (!items.length) return ""; + const idx = items[0].dataIndex; + return new Date(items[0].chart.data.timestamps[idx]).toLocaleString(); + }, + }, + }, + }, + }; +} + function setCell(row, text) { const td = document.createElement("td"); td.textContent = text; @@ -67,47 +106,61 @@ function setCell(row, text) { } function initCharts() { - const connCtx = document.getElementById("connections-chart"); - const hrCtx = document.getElementById("hashrate-chart"); + connectionsChart = new Chart(document.getElementById("connections-chart"), { + type: "line", + data: { labels: [], timestamps: [], datasets: [{ + data: [], borderColor: "#ffb86c", backgroundColor: "rgba(255,184,108,0.1)", + fill: true, tension: 0.3, pointRadius: 0, + }]}, + options: chartOptions(), + }); - connectionsChart = new Chart(connCtx, { + hashrateChart = new Chart(document.getElementById("hashrate-chart"), { type: "line", - data: { - labels: [], - datasets: [{ - data: [], - borderColor: "#ffb86c", - backgroundColor: "rgba(255,184,108,0.1)", - fill: true, - tension: 0.3, - pointRadius: 0, - }], - }, - options: chartDefaults, + data: { labels: [], timestamps: [], datasets: [{ + data: [], borderColor: "#8be9fd", backgroundColor: "rgba(139,233,253,0.1)", + fill: true, tension: 0.3, pointRadius: 0, + }]}, + options: chartOptions({ + callback: (v) => formatHashrate(v), + }), }); - hashrateChart = new Chart(hrCtx, { + workersChart = new Chart(document.getElementById("workers-chart"), { type: "line", - data: { - labels: [], - datasets: [{ - data: [], - borderColor: "#8be9fd", - backgroundColor: "rgba(139,233,253,0.1)", - fill: true, - tension: 0.3, - pointRadius: 0, - }], - }, + data: { labels: [], timestamps: [], datasets: [{ + data: [], borderColor: "#50fa7b", backgroundColor: "rgba(80,250,123,0.1)", + fill: true, tension: 0.3, pointRadius: 0, + }]}, + options: chartOptions(), + }); + + systemChart = new Chart(document.getElementById("system-chart"), { + type: "line", + data: { labels: [], timestamps: [], datasets: [ + { + label: "CPU", + data: [], borderColor: "#ff79c6", backgroundColor: "rgba(255,121,198,0.05)", + fill: true, tension: 0.3, pointRadius: 0, + }, + { + label: "RAM", + data: [], borderColor: "#bd93f9", backgroundColor: "rgba(189,147,249,0.05)", + fill: true, tension: 0.3, pointRadius: 0, + }, + ]}, options: { - ...chartDefaults, - scales: { - ...chartDefaults.scales, - y: { - ...chartDefaults.scales.y, - ticks: { - color: "#b5b5b5", - callback: (v) => formatHashrate(v), + ...chartOptions({ callback: (v) => v + "%" }), + plugins: { + legend: { display: true, labels: { color: "#b5b5b5", boxWidth: 12 } }, + tooltip: { + callbacks: { + title: (items) => { + if (!items.length) return ""; + const idx = items[0].dataIndex; + return new Date(items[0].chart.data.timestamps[idx]).toLocaleString(); + }, + label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y.toFixed(1)}%`, }, }, }, @@ -115,20 +168,33 @@ function initCharts() { }); } -function updateCharts(history) { - if (!connectionsChart || !hashrateChart || !history) return; +function updateChart(chart, history, meta, valueFn) { + chart.data.labels = historyLabels(history, meta); + chart.data.timestamps = history.map((p) => p.t); + chart.data.datasets[0].data = history.map(valueFn); + chart.update(); +} + +function updateCharts(history, meta) { + if (!history || !history.length) return; - const labels = history.map((p) => new Date(p.t).toLocaleTimeString()); - const connData = history.map((p) => p.connections); - const hrData = history.map((p) => p.hashrate); + updateChart(connectionsChart, history, meta, (p) => p.connections); + updateChart(hashrateChart, history, meta, (p) => p.hashrate); + updateChart(workersChart, history, meta, (p) => p.workers); - connectionsChart.data.labels = labels; - connectionsChart.data.datasets[0].data = connData; - connectionsChart.update(); + systemChart.data.labels = historyLabels(history, meta); + systemChart.data.timestamps = history.map((p) => p.t); + systemChart.data.datasets[0].data = history.map((p) => p.cpu != null ? p.cpu : null); + systemChart.data.datasets[1].data = history.map((p) => p.ram != null ? p.ram : null); + systemChart.update(); +} - hashrateChart.data.labels = labels; - hashrateChart.data.datasets[0].data = hrData; - hashrateChart.update(); +function updateHistoryRange(meta) { + if (!meta || !meta.points) { + historyRangeEl.textContent = "No history yet"; + return; + } + historyRangeEl.textContent = `Last ${formatSpan(meta.spanHours)} 路 ${meta.points} points`; } function fetch_statistics() { @@ -153,7 +219,8 @@ function fetch_statistics() { if (data.poolName) poolName.textContent = data.poolName; if (data.motd) poolMotdText.textContent = data.motd; - updateCharts(data.history); + updateCharts(data.history, data.historyMeta); + updateHistoryRange(data.historyMeta); lastUpdatedEl.textContent = "Updated " + new Date().toLocaleTimeString(); }) .catch((err) => { diff --git a/dashboard/static/styles.css b/dashboard/static/styles.css index 2569a4e..57bc657 100644 --- a/dashboard/static/styles.css +++ b/dashboard/static/styles.css @@ -12,7 +12,23 @@ .chart-container { position: relative; - height: 200px; + height: 180px; +} + +.history-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +.chart-box { + min-width: 0; +} + +@media screen and (max-width: 768px) { + .history-grid { + grid-template-columns: 1fr; + } } .workers-toolbar { diff --git a/src/dashboard.js b/src/dashboard.js index c69e3af..91011eb 100755 --- a/src/dashboard.js +++ b/src/dashboard.js @@ -7,6 +7,7 @@ const path = require("path"); const express = require("express"); const log = require("./logging"); const poolStats = require("./poolStats"); +const { getHistoryMeta } = poolStats; const mining = require("./mining"); const { dashboard_port, @@ -60,6 +61,14 @@ app.get("/statistics", (req, res) => { acceptedShares, rejectedShares, history: poolStats.history, + historyMeta: getHistoryMeta(), + }); +}); + +app.get("/history", (req, res) => { + res.json({ + meta: getHistoryMeta(), + points: poolStats.history, }); }); diff --git a/src/index.js b/src/index.js index e944a28..90c9c1c 100755 --- a/src/index.js +++ b/src/index.js @@ -10,8 +10,25 @@ const sync = require("./sync"); const { spawn } = require("child_process"); const log = require("./logging"); const poolStats = require("./poolStats"); -let { use_ngrok, port, host, autoRestart, guessPort, use_serveo } = require("../config/config.json"); - +let { + use_ngrok, + port, + host, + autoRestart, + guessPort, + use_serveo, + base_sync_folder, + poolName, + historyMaxPoints, + historyMaxAgeHours, +} = require("../config/config.json"); + +poolStats.initHistory( + base_sync_folder || `${__dirname}/../dashboard/`, + poolName || "pool", + historyMaxPoints, + historyMaxAgeHours +); poolStats.startedAt = Date.now(); connections = 0; diff --git a/src/poolStats.js b/src/poolStats.js index 62b2a92..7137263 100644 --- a/src/poolStats.js +++ b/src/poolStats.js @@ -1,6 +1,11 @@ /* Live pool statistics shared between sync, index, and dashboard */ -const HISTORY_MAX = 60; +const fs = require("fs"); +const path = require("path"); +const log = require("./logging"); + +const DEFAULT_MAX_POINTS = 480; +const DEFAULT_MAX_AGE_HOURS = 24; const poolStats = { connections: 0, @@ -10,15 +15,83 @@ const poolStats = { startedAt: null, lastSyncAt: null, history: [], + historyMaxPoints: DEFAULT_MAX_POINTS, + historyMaxAgeHours: DEFAULT_MAX_AGE_HOURS, +}; + +let historyFilePath = null; + +const isValidPoint = (p) => + p && + typeof p.t === "number" && + typeof p.connections === "number" && + typeof p.hashrate === "number" && + typeof p.workers === "number"; + +const trimHistory = () => { + const maxAgeMs = poolStats.historyMaxAgeHours * 60 * 60 * 1000; + const cutoff = Date.now() - maxAgeMs; + poolStats.history = poolStats.history.filter((p) => p.t >= cutoff); + while (poolStats.history.length > poolStats.historyMaxPoints) { + poolStats.history.shift(); + } +}; + +const saveHistory = () => { + if (!historyFilePath) return; + try { + fs.writeFileSync( + historyFilePath, + JSON.stringify(poolStats.history), + "utf8" + ); + } catch (err) { + log.warning(`Failed to save history: ${err}`); + } +}; + +const loadHistory = () => { + if (!historyFilePath || !fs.existsSync(historyFilePath)) return; + try { + const raw = JSON.parse(fs.readFileSync(historyFilePath, "utf8")); + if (!Array.isArray(raw)) throw new Error("not an array"); + poolStats.history = raw.filter(isValidPoint); + trimHistory(); + log.info(`Loaded ${poolStats.history.length} history points`); + } catch (err) { + log.warning(`Failed to load history: ${err}`); + poolStats.history = []; + } +}; + +const initHistory = (folder, poolName, maxPoints, maxAgeHours) => { + poolStats.historyMaxPoints = maxPoints || DEFAULT_MAX_POINTS; + poolStats.historyMaxAgeHours = maxAgeHours || DEFAULT_MAX_AGE_HOURS; + historyFilePath = path.join(folder, `history_${poolName}.json`); + loadHistory(); }; const pushHistory = (sample) => { + if (!isValidPoint(sample)) return; poolStats.history.push(sample); - if (poolStats.history.length > HISTORY_MAX) { - poolStats.history.shift(); + trimHistory(); + saveHistory(); +}; + +const getHistoryMeta = () => { + const points = poolStats.history.length; + if (points === 0) { + return { oldest: null, newest: null, points: 0, spanHours: 0 }; } + const oldest = poolStats.history[0].t; + const newest = poolStats.history[points - 1].t; + const spanHours = (newest - oldest) / (1000 * 60 * 60); + return { oldest, newest, points, spanHours }; }; module.exports = poolStats; +module.exports.initHistory = initHistory; module.exports.pushHistory = pushHistory; -module.exports.HISTORY_MAX = HISTORY_MAX; +module.exports.getHistoryMeta = getHistoryMeta; +module.exports.DEFAULT_MAX_POINTS = DEFAULT_MAX_POINTS; +module.exports.DEFAULT_MAX_AGE_HOURS = DEFAULT_MAX_AGE_HOURS; diff --git a/src/sync.js b/src/sync.js index 35be659..368f4a1 100755 --- a/src/sync.js +++ b/src/sync.js @@ -273,6 +273,8 @@ const sync = async () => { connections: poolStats.connections, hashrate: workerList.reduce((sum, w) => sum + (w.h || 0), 0), workers: workerList.length, + cpu: cpuUsage, + ram: ramUsage, }); log.success(`Successfull sync #${sync_count}`); } else {