From a01d7b9d7fe70200a6ada351f969283e19986e00 Mon Sep 17 00:00:00 2001 From: Claude Chronicler #88 Date: Tue, 14 Apr 2026 06:21:38 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Node=20Health=20module=20=E2=80=94=20NC?= =?UTF-8?q?1=20+=20TX1=20thermal=20and=20system=20monitoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New route: /admin/node-health (30s auto-refresh) - Temps via lm-sensors (k10temp + NVMe) displayed in both °C and °F - RAM and disk progress bars with color thresholds - Load averages, CPU %, uptime per node - Nav item added under Operations - lm-sensors installed on NC1 and TX1 Task #28 | Chronicler #88 --- .../arbiter-3.0/src/routes/admin/index.js | 2 + .../src/routes/admin/node-health.js | 223 +++++++++++ .../src/views/admin/node-health/index.ejs | 348 ++++++++++++++++++ services/arbiter-3.0/src/views/layout.ejs | 3 + 4 files changed, 576 insertions(+) create mode 100644 services/arbiter-3.0/src/routes/admin/node-health.js create mode 100644 services/arbiter-3.0/src/views/admin/node-health/index.ejs diff --git a/services/arbiter-3.0/src/routes/admin/index.js b/services/arbiter-3.0/src/routes/admin/index.js index 0c2f2d3..f4e28d0 100644 --- a/services/arbiter-3.0/src/routes/admin/index.js +++ b/services/arbiter-3.0/src/routes/admin/index.js @@ -23,6 +23,7 @@ const aboutRouter = require('./about'); const mcpLogsRouter = require('./mcp-logs'); const tasksRouter = require('./tasks'); const forgeRouter = require('./forge'); +const nodeHealthRouter = require('./node-health'); router.use(requireTrinityAccess); @@ -133,5 +134,6 @@ router.use('/about', aboutRouter); router.use('/mcp-logs', mcpLogsRouter); router.use('/tasks', tasksRouter); router.use('/forge', forgeRouter); +router.use('/node-health', nodeHealthRouter); module.exports = router; diff --git a/services/arbiter-3.0/src/routes/admin/node-health.js b/services/arbiter-3.0/src/routes/admin/node-health.js new file mode 100644 index 0000000..8877f24 --- /dev/null +++ b/services/arbiter-3.0/src/routes/admin/node-health.js @@ -0,0 +1,223 @@ +const express = require('express'); +const router = express.Router(); + +/** + * Node Health Module — Trinity Console + * + * Live thermal and system health monitoring for NC1 and TX1 game nodes. + * Pulls CPU temps, NVMe temps, RAM, disk, load, and uptime via Trinity Core. + * + * GET /admin/node-health — Main page + * GET /admin/node-health/data — JSON data endpoint (HTMX auto-refresh) + * + * Chronicler #88 | April 14, 2026 + */ + +const TRINITY_CORE_URL = 'https://mcp.firefrostgaming.com'; +const TRINITY_CORE_TOKEN = 'FFG-Trinity-2026-Core-Access'; +const CACHE_TTL = 30000; // 30 seconds + +const NODES = { + 'nc1-charlotte': { label: 'NC1 Charlotte', role: 'Game Node (Secondary)', color: '#4ECDC4' }, + 'tx1-dallas': { label: 'TX1 Dallas', role: 'Game Node (Primary)', color: '#FF6B35' } +}; + +let nodeCache = { data: null, lastFetch: 0 }; + +async function trinityExec(server, command) { + try { + const res = await fetch(`${TRINITY_CORE_URL}/exec`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TRINITY_CORE_TOKEN}` + }, + body: JSON.stringify({ server, command }) + }); + if (!res.ok) throw new Error(`Trinity Core error: ${res.status}`); + const data = await res.json(); + return data.output || data.stdout || ''; + } catch (err) { + console.error(`[NODE-HEALTH] trinityExec failed for ${server}:`, err.message); + return null; + } +} + +function cToF(c) { + return Math.round((c * 9 / 5) + 32); +} + +function parseSensors(output) { + if (!output) return null; + const lines = output.split('\n'); + const temps = {}; + + // k10temp block — CPU temps + let inK10 = false; + for (const line of lines) { + if (line.startsWith('k10temp')) { inK10 = true; continue; } + if (inK10 && line.trim() === '') { inK10 = false; continue; } + if (inK10) { + const match = line.match(/^(\w+):\s+\+?([\d.]+)°C/); + if (match) temps[match[1]] = parseFloat(match[2]); + } + } + + // nvme block — NVMe composite temp + let inNvme = false; + for (const line of lines) { + if (line.startsWith('nvme')) { inNvme = true; continue; } + if (inNvme && line.trim() === '') { inNvme = false; continue; } + if (inNvme) { + const match = line.match(/^Composite:\s+\+?([\d.]+)°C/); + if (match) { temps['NVMe'] = parseFloat(match[1]); break; } + } + } + + return temps; +} + +function parseNodeData(sensorsOut, statsOut) { + const temps = parseSensors(sensorsOut); + if (!statsOut) return null; + + const lines = statsOut.split('\n'); + const get = (marker) => { + const idx = lines.findIndex(l => l.includes(marker)); + return idx >= 0 && idx + 1 < lines.length ? lines[idx + 1]?.trim() : ''; + }; + + // RAM + const memLine = get('=== RAM ==='); + const memParts = memLine.split(/\s+/); + const totalRam = memParts[1] || '?'; + const usedRam = memParts[2] || '?'; + + function toGi(val) { + if (!val) return 0; + const num = parseFloat(val); + if (val.includes('Mi')) return num / 1024; + if (val.includes('Gi')) return num; + return num; + } + const ramPct = toGi(totalRam) > 0 + ? Math.round((toGi(usedRam) / toGi(totalRam)) * 100) + : 0; + + // Disk + const diskLine = get('=== DISK ==='); + const diskParts = diskLine.split(/\s+/); + const totalDisk = diskParts[1] || '?'; + const usedDisk = diskParts[2] || '?'; + const diskPct = parseInt(diskParts[4]) || 0; + + // Uptime & load + const uptimeLine = get('=== UPTIME ==='); + const uptimeMatch = uptimeLine.match(/up\s+(.+?),\s+\d+\s+user/); + const uptime = uptimeMatch ? uptimeMatch[1].trim() : '?'; + const loadMatch = uptimeLine.match(/load average:\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)/); + const load = loadMatch + ? [parseFloat(loadMatch[1]), parseFloat(loadMatch[2]), parseFloat(loadMatch[3])] + : [0, 0, 0]; + + // CPU % + const cpuLine = get('=== CPU_USAGE ==='); + const idleMatch = cpuLine.match(/(\d+\.\d+)\s+id/); + const cpuPct = idleMatch ? Math.round(100 - parseFloat(idleMatch[1])) : 0; + + // Build temp display with both C and F + const tempDisplay = {}; + if (temps) { + for (const [key, c] of Object.entries(temps)) { + tempDisplay[key] = { c, f: cToF(c) }; + } + } + + return { + temps: tempDisplay, + ram: { total: totalRam, used: usedRam, pct: ramPct }, + disk: { total: totalDisk, used: usedDisk, pct: diskPct }, + uptime, + load, + cpuPct, + online: true + }; +} + +async function fetchNodeHealth() { + const now = Date.now(); + if (nodeCache.data && (now - nodeCache.lastFetch < CACHE_TTL)) { + return nodeCache.data; + } + + console.log('[NODE-HEALTH] Fetching node health data...'); + + const statsCommand = [ + 'echo "=== RAM ===" && free -h | grep Mem', + 'echo "=== DISK ===" && df -h / | tail -1', + 'echo "=== UPTIME ===" && uptime', + 'echo "=== CPU_USAGE ===" && top -bn1 | grep "Cpu(s)"' + ].join(' && '); + + const nodeIds = Object.keys(NODES); + + const [nc1Sensors, tx1Sensors, nc1Stats, tx1Stats] = await Promise.all([ + trinityExec('nc1-charlotte', 'sensors'), + trinityExec('tx1-dallas', 'sensors'), + trinityExec('nc1-charlotte', statsCommand), + trinityExec('tx1-dallas', statsCommand) + ]); + + const data = { + 'nc1-charlotte': { + ...NODES['nc1-charlotte'], + ...(parseNodeData(nc1Sensors, nc1Stats) || { online: false }) + }, + 'tx1-dallas': { + ...NODES['tx1-dallas'], + ...(parseNodeData(tx1Sensors, tx1Stats) || { online: false }) + }, + fetchedAt: new Date().toISOString() + }; + + nodeCache = { data, lastFetch: now }; + console.log('[NODE-HEALTH] Fetch complete.'); + return data; +} + +// GET /admin/node-health +router.get('/', async (req, res) => { + try { + const nodes = await fetchNodeHealth(); + res.render('admin/node-health/index', { + title: 'Node Health', + currentPath: '/node-health', + nodes, + adminUser: req.user, + layout: 'layout' + }); + } catch (err) { + console.error('[NODE-HEALTH] Route error:', err); + res.render('admin/node-health/index', { + title: 'Node Health', + currentPath: '/node-health', + nodes: null, + error: err.message, + adminUser: req.user, + layout: 'layout' + }); + } +}); + +// GET /admin/node-health/data — JSON for auto-refresh +router.get('/data', async (req, res) => { + nodeCache = { data: null, lastFetch: 0 }; // force fresh + try { + const nodes = await fetchNodeHealth(); + res.json({ success: true, nodes }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +module.exports = router; diff --git a/services/arbiter-3.0/src/views/admin/node-health/index.ejs b/services/arbiter-3.0/src/views/admin/node-health/index.ejs new file mode 100644 index 0000000..c1b56b0 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/node-health/index.ejs @@ -0,0 +1,348 @@ + + + + + +
+ +
+
+

Node Health

+
+ <% if (nodes) { %>Last updated: <%= new Date(nodes.fetchedAt).toLocaleTimeString() %><% } %> +
+
+ +
+ +
+ <% if (!nodes) { %> +
Failed to load node data. <%= error || '' %>
+ <% } else { %> + <% const nodeIds = ['nc1-charlotte', 'tx1-dallas']; %> + <% nodeIds.forEach(function(id) { %> + <% const node = nodes[id]; %> + <%- include('_node_card', { node, id }) %> + <% }); %> + <% } %> +
+ +
+ + diff --git a/services/arbiter-3.0/src/views/layout.ejs b/services/arbiter-3.0/src/views/layout.ejs index 6e21da6..289e2fe 100644 --- a/services/arbiter-3.0/src/views/layout.ejs +++ b/services/arbiter-3.0/src/views/layout.ejs @@ -141,6 +141,9 @@ 🌐 Infrastructure + + 🌡️ Node Health + ⏰ Scheduler