diff --git a/.gitignore b/.gitignore index e5dcf55..d080adb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ duinocoin/ node_modules/ config/ +dashboard/rewards*.json +dashboard/workers_*.json +dashboard/history*.json diff --git a/config/config.json b/config/config.json index 0dd6201..80f86db 100644 --- a/config/config.json +++ b/config/config.json @@ -6,6 +6,9 @@ "host": "0.0.0.0", "port": 6000, + "dashboard_port": 6030, + "historyMaxPoints": 480, + "historyMaxAgeHours": 24, "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..ddf1c4c 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -6,11 +6,10 @@ + - Pulse Pool Stats - - - + Pool Dashboard + @@ -21,92 +20,195 @@

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 +

+
+
+
+
+

+ + Uptime: + - +

+
+
+

+ + Accept rate: + - +

+
+
+

+ + Last sync: + - +

+
+
+

+ + Sync #: + 0

-

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

+ workers.json 路 + rewards.json 路 + history.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

+
+ +
+
+
+

CPU / RAM

+
+ +
+
+
+
+ +
+

+ + Workers

+
+
+

+ + +

+
+
+
+ +
+
+

Showing 0 of 0

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

-

-

-

-

-

-

- \ No newline at end of file + diff --git a/dashboard/static/script.js b/dashboard/static/script.js index 2388d3a..7f6728c 100644 --- a/dashboard/static/script.js +++ b/dashboard/static/script.js @@ -2,19 +2,282 @@ 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 historyRangeEl = document.getElementById("history-range"); +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 workersChart = null; +let systemChart = null; +let searchTimeout = null; + +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 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; + row.appendChild(td); +} + +function initCharts() { + 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(), + }); + + hashrateChart = new Chart(document.getElementById("hashrate-chart"), { + type: "line", + 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), + }), + }); + + workersChart = new Chart(document.getElementById("workers-chart"), { + type: "line", + 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: { + ...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)}%`, + }, + }, + }, + }, + }); +} + +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; + + updateChart(connectionsChart, history, meta, (p) => p.connections); + updateChart(hashrateChart, history, meta, (p) => p.hashrate); + updateChart(workersChart, history, meta, (p) => p.workers); + + 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(); +} + +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() { 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, data.historyMeta); + updateHistoryRange(data.historyMeta); + 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..57bc657 100644 --- a/dashboard/static/styles.css +++ b/dashboard/static/styles.css @@ -1,4 +1,64 @@ - /* 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: 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 { + 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 +75,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 +85,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..91011eb 100755 --- a/src/dashboard.js +++ b/src/dashboard.js @@ -1,21 +1,128 @@ -/* 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 { getHistoryMeta } = 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, + historyMeta: getHistoryMeta(), + }); +}); + +app.get("/history", (req, res) => { + res.json({ + meta: getHistoryMeta(), + points: 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..90c9c1c 100755 --- a/src/index.js +++ b/src/index.js @@ -9,8 +9,27 @@ const handle = require("./connectionHandler"); const sync = require("./sync"); const { spawn } = require("child_process"); const log = require("./logging"); -let { use_ngrok, port, host, autoRestart, guessPort, use_serveo } = require("../config/config.json"); - +const poolStats = require("./poolStats"); +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; const getRand = (min, max) => { @@ -65,7 +84,7 @@ if (use_ngrok) { sync.updatePoolReward(); sync.login(); -// require("./dashboard"); +require("./dashboard"); const server = net.createServer(handle); server.listen(port, host, 0, () => { @@ -94,6 +113,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..7137263 --- /dev/null +++ b/src/poolStats.js @@ -0,0 +1,97 @@ +/* Live pool statistics shared between sync, index, and dashboard */ + +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, + cpu: 0, + ram: 0, + syncCount: 0, + 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); + 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.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 b136e0f..368f4a1 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,17 @@ 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, + cpu: cpuUsage, + ram: ramUsage, + }); log.success(`Successfull sync #${sync_count}`); } else { log.warning(