feat(perf): show Go Runtime stats instead of Event Loop on Go backend

When engine=go, the perf page now renders Go-specific runtime stats
(goroutines, GC collections, GC pause times, heap breakdown, CPUs)
instead of the misleading Node.js Event Loop metrics. Falls back to
the existing Node UI when engine is not 'go' or goRuntime data is
missing. Includes color-coded GC pause thresholds.

fixes #153

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-03-27 11:49:10 -07:00
co-authored by Copilot
parent 979b649028
commit 744702ccf6
3 changed files with 310 additions and 39 deletions
+27 -27
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="MeshCore Analyzer">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774633218">
<link rel="stylesheet" href="home.css?v=1774633218">
<link rel="stylesheet" href="live.css?v=1774633218">
<link rel="stylesheet" href="style.css?v=1774637275">
<link rel="stylesheet" href="home.css?v=1774637275">
<link rel="stylesheet" href="live.css?v=1774637275">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,29 +81,29 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774633218"></script>
<script src="customize.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774633218"></script>
<script src="hop-resolver.js?v=1774633218"></script>
<script src="hop-display.js?v=1774633218"></script>
<script src="app.js?v=1774633218"></script>
<script src="home.js?v=1774633218"></script>
<script src="packet-filter.js?v=1774633218"></script>
<script src="packets.js?v=1774633218"></script>
<script src="map.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774633218" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774637275"></script>
<script src="customize.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774637275"></script>
<script src="hop-resolver.js?v=1774637275"></script>
<script src="hop-display.js?v=1774637275"></script>
<script src="app.js?v=1774637275"></script>
<script src="home.js?v=1774637275"></script>
<script src="packet-filter.js?v=1774637275"></script>
<script src="packets.js?v=1774637275"></script>
<script src="map.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774637275" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+30 -12
View File
@@ -31,19 +31,37 @@
<div class="perf-card"><div class="perf-num">${server.slowQueries.length}</div><div class="perf-label">Slow (&gt;100ms)</div></div>
</div>`;
// System health (memory, event loop, WS)
// System health (memory, event loop / go runtime, WS)
if (health) {
const m = health.memory, el = health.eventLoop;
const elColor = el.p95Ms > 500 ? 'var(--status-red)' : el.p95Ms > 100 ? 'var(--status-yellow)' : 'var(--status-green)';
const memColor = m.heapUsed > m.heapTotal * 0.85 ? 'var(--status-red)' : m.heapUsed > m.heapTotal * 0.7 ? 'var(--status-yellow)' : 'var(--status-green)';
html += `<h3>System Health</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num" style="color:${memColor}">${m.heapUsed}MB</div><div class="perf-label">Heap Used / ${m.heapTotal}MB</div></div>
<div class="perf-card"><div class="perf-num">${m.rss}MB</div><div class="perf-label">RSS</div></div>
<div class="perf-card"><div class="perf-num" style="color:${elColor}">${el.p95Ms}ms</div><div class="perf-label">Event Loop p95</div></div>
<div class="perf-card"><div class="perf-num">${el.maxLagMs}ms</div><div class="perf-label">EL Max Lag</div></div>
<div class="perf-card"><div class="perf-num">${el.currentLagMs}ms</div><div class="perf-label">EL Current</div></div>
<div class="perf-card"><div class="perf-num">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div>
</div>`;
const isGo = health.engine === 'go';
if (isGo && server.goRuntime) {
const gr = server.goRuntime;
const gcColor = gr.lastPauseMs > 5 ? 'var(--status-red)' : gr.lastPauseMs > 1 ? 'var(--status-yellow)' : 'var(--status-green)';
html += `<h3>🔧 Go Runtime</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${gr.goroutines}</div><div class="perf-label">Goroutines</div></div>
<div class="perf-card"><div class="perf-num">${gr.numGC}</div><div class="perf-label">GC Collections</div></div>
<div class="perf-card"><div class="perf-num" style="color:${gcColor}">${gr.pauseTotalMs}ms</div><div class="perf-label">GC Pause Total</div></div>
<div class="perf-card"><div class="perf-num">${gr.lastPauseMs}ms</div><div class="perf-label">Last GC Pause</div></div>
<div class="perf-card"><div class="perf-num">${gr.heapAllocMB}MB</div><div class="perf-label">Heap Alloc</div></div>
<div class="perf-card"><div class="perf-num">${gr.heapSysMB}MB</div><div class="perf-label">Heap Sys</div></div>
<div class="perf-card"><div class="perf-num">${gr.heapInuseMB}MB</div><div class="perf-label">Heap Inuse</div></div>
<div class="perf-card"><div class="perf-num">${gr.heapIdleMB}MB</div><div class="perf-label">Heap Idle</div></div>
<div class="perf-card"><div class="perf-num">${gr.numCPU}</div><div class="perf-label">CPUs</div></div>
<div class="perf-card"><div class="perf-num">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div>
</div>`;
} else {
const m = health.memory, el = health.eventLoop;
const elColor = el.p95Ms > 500 ? 'var(--status-red)' : el.p95Ms > 100 ? 'var(--status-yellow)' : 'var(--status-green)';
const memColor = m.heapUsed > m.heapTotal * 0.85 ? 'var(--status-red)' : m.heapUsed > m.heapTotal * 0.7 ? 'var(--status-yellow)' : 'var(--status-green)';
html += `<h3>System Health</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num" style="color:${memColor}">${m.heapUsed}MB</div><div class="perf-label">Heap Used / ${m.heapTotal}MB</div></div>
<div class="perf-card"><div class="perf-num">${m.rss}MB</div><div class="perf-label">RSS</div></div>
<div class="perf-card"><div class="perf-num" style="color:${elColor}">${el.p95Ms}ms</div><div class="perf-label">Event Loop p95</div></div>
<div class="perf-card"><div class="perf-num">${el.maxLagMs}ms</div><div class="perf-label">EL Max Lag</div></div>
<div class="perf-card"><div class="perf-num">${el.currentLagMs}ms</div><div class="perf-label">EL Current</div></div>
<div class="perf-card"><div class="perf-num">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div>
</div>`;
}
}
// Cache stats
+253
View File
@@ -0,0 +1,253 @@
/* Tests for perf.js Go runtime vs Node event loop rendering (fixes #153) */
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
// Minimal sandbox to run perf.js in a browser-like context
function makeSandbox() {
let capturedHtml = '';
const pages = {};
const ctx = {
window: { addEventListener: () => {}, apiPerf: null },
document: {
getElementById: (id) => {
if (id === 'perfContent') return { set innerHTML(v) { capturedHtml = v; } };
return null;
},
addEventListener: () => {},
},
console,
Date, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError,
parseInt, parseFloat, isNaN, isFinite,
setTimeout: () => {}, clearTimeout: () => {},
setInterval: () => 0, clearInterval: () => {},
performance: { now: () => Date.now() },
Map, Set, Promise,
registerPage: (name, handler) => { pages[name] = handler; },
_apiCache: null,
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
};
ctx.window.document = ctx.document;
ctx.globalThis = ctx;
return { ctx, pages, getHtml: () => capturedHtml };
}
// Load perf.js into sandbox
function loadPerf() {
const sb = makeSandbox();
const code = fs.readFileSync('public/perf.js', 'utf8');
vm.runInNewContext(code, sb.ctx);
return sb;
}
// Stub fetch to return controlled data
function stubFetch(sb, perfData, healthData) {
sb.ctx.fetch = (url) => {
if (url === '/api/perf') return Promise.resolve({ json: () => Promise.resolve(perfData) });
if (url === '/api/health') return Promise.resolve({ json: () => Promise.resolve(healthData) });
return Promise.resolve({ json: () => Promise.resolve({}) });
};
}
const basePerf = {
totalRequests: 100, avgMs: 5, uptime: 3600,
slowQueries: [], endpoints: {}, cache: null, packetStore: null, sqlite: null
};
const nodeHealth = {
engine: 'node',
uptimeHuman: '1h',
memory: { heapUsed: 100, heapTotal: 200, rss: 250 },
eventLoop: { p95Ms: 10, maxLagMs: 20, currentLagMs: 1 },
websocket: { clients: 3 }
};
const goRuntime = {
goroutines: 17, numGC: 31, pauseTotalMs: 2.1, lastPauseMs: 0.03,
heapAllocMB: 473, heapSysMB: 1035, heapInuseMB: 663, heapIdleMB: 371, numCPU: 2
};
const goHealth = {
engine: 'go',
uptimeHuman: '2h',
websocket: { clients: 5 }
};
console.log('\n🧪 perf.js — Go Runtime vs Node Event Loop\n');
// --- Node engine tests ---
test('Node engine shows Event Loop labels', async () => {
const sb = loadPerf();
stubFetch(sb, basePerf, nodeHealth);
await sb.pages.perf.init({ set innerHTML(v) {} });
// Wait for async refresh
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(html.includes('Event Loop p95'), 'should show Event Loop p95');
assert.ok(html.includes('EL Max Lag'), 'should show EL Max Lag');
assert.ok(html.includes('EL Current'), 'should show EL Current');
assert.ok(html.includes('System Health'), 'should show System Health heading');
});
test('Node engine does NOT show Go Runtime heading', async () => {
const sb = loadPerf();
stubFetch(sb, basePerf, nodeHealth);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(!html.includes('Go Runtime'), 'should not show Go Runtime');
assert.ok(!html.includes('Goroutines'), 'should not show Goroutines');
});
test('Node engine shows memory stats', async () => {
const sb = loadPerf();
stubFetch(sb, basePerf, nodeHealth);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(html.includes('Heap Used'), 'should show Heap Used');
assert.ok(html.includes('RSS'), 'should show RSS');
});
// --- Go engine tests ---
test('Go engine shows Go Runtime heading', async () => {
const sb = loadPerf();
stubFetch(sb, { ...basePerf, goRuntime }, goHealth);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(html.includes('Go Runtime'), 'should show Go Runtime heading');
});
test('Go engine shows all goRuntime fields', async () => {
const sb = loadPerf();
stubFetch(sb, { ...basePerf, goRuntime }, goHealth);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(html.includes('Goroutines'), 'should show Goroutines');
assert.ok(html.includes('GC Collections'), 'should show GC Collections');
assert.ok(html.includes('GC Pause Total'), 'should show GC Pause Total');
assert.ok(html.includes('Last GC Pause'), 'should show Last GC Pause');
assert.ok(html.includes('Heap Alloc'), 'should show Heap Alloc');
assert.ok(html.includes('Heap Sys'), 'should show Heap Sys');
assert.ok(html.includes('Heap Inuse'), 'should show Heap Inuse');
assert.ok(html.includes('Heap Idle'), 'should show Heap Idle');
assert.ok(html.includes('CPUs'), 'should show CPUs');
});
test('Go engine shows goRuntime values', async () => {
const sb = loadPerf();
stubFetch(sb, { ...basePerf, goRuntime }, goHealth);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(html.includes('17'), 'goroutines value');
assert.ok(html.includes('31'), 'numGC value');
assert.ok(html.includes('2.1ms'), 'pauseTotalMs value');
assert.ok(html.includes('0.03ms'), 'lastPauseMs value');
assert.ok(html.includes('473MB'), 'heapAllocMB value');
assert.ok(html.includes('1035MB'), 'heapSysMB value');
assert.ok(html.includes('663MB'), 'heapInuseMB value');
assert.ok(html.includes('371MB'), 'heapIdleMB value');
});
test('Go engine does NOT show Event Loop labels', async () => {
const sb = loadPerf();
stubFetch(sb, { ...basePerf, goRuntime }, goHealth);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(!html.includes('Event Loop'), 'should not show Event Loop');
assert.ok(!html.includes('EL Max Lag'), 'should not show EL Max Lag');
assert.ok(!html.includes('EL Current'), 'should not show EL Current');
});
test('Go engine still shows WS Clients', async () => {
const sb = loadPerf();
stubFetch(sb, { ...basePerf, goRuntime }, goHealth);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(html.includes('WS Clients'), 'should show WS Clients');
assert.ok(html.includes('>5<'), 'should show 5 WS clients');
});
// --- GC color threshold tests ---
test('Go GC pause green when lastPauseMs <= 1', async () => {
const sb = loadPerf();
const gr = { ...goRuntime, lastPauseMs: 0.5 };
stubFetch(sb, { ...basePerf, goRuntime: gr }, goHealth);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(html.includes('var(--status-green)'), 'should use green for low GC pause');
});
test('Go GC pause yellow when lastPauseMs > 1 and <= 5', async () => {
const sb = loadPerf();
const gr = { ...goRuntime, lastPauseMs: 3 };
stubFetch(sb, { ...basePerf, goRuntime: gr }, goHealth);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(html.includes('var(--status-yellow)'), 'should use yellow for moderate GC pause');
});
test('Go GC pause red when lastPauseMs > 5', async () => {
const sb = loadPerf();
const gr = { ...goRuntime, lastPauseMs: 10 };
stubFetch(sb, { ...basePerf, goRuntime: gr }, goHealth);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(html.includes('var(--status-red)'), 'should use red for high GC pause');
});
// --- Fallback: engine=go but no goRuntime falls back to Node UI ---
test('engine=go but missing goRuntime falls back to Node UI', async () => {
const sb = loadPerf();
const goHealthWithMemory = {
...goHealth,
memory: { heapUsed: 50, heapTotal: 100, rss: 80 },
eventLoop: { p95Ms: 5, maxLagMs: 10, currentLagMs: 1 }
};
stubFetch(sb, basePerf, goHealthWithMemory);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(html.includes('Event Loop p95'), 'should fall back to Event Loop');
assert.ok(!html.includes('Go Runtime'), 'should not show Go Runtime');
});
// --- Missing engine field ---
test('Missing engine field shows Node UI', async () => {
const sb = loadPerf();
const healthNoEngine = {
uptimeHuman: '1h',
memory: { heapUsed: 100, heapTotal: 200, rss: 250 },
eventLoop: { p95Ms: 10, maxLagMs: 20, currentLagMs: 1 },
websocket: { clients: 2 }
};
stubFetch(sb, basePerf, healthNoEngine);
await sb.pages.perf.init({ set innerHTML(v) {} });
await new Promise(r => setTimeout(r, 50));
const html = sb.getHtml();
assert.ok(html.includes('Event Loop p95'), 'should show Event Loop');
assert.ok(!html.includes('Go Runtime'), 'should not show Go Runtime');
});
console.log(`\n${passed} passed, ${failed} failed\n`);
process.exit(failed ? 1 : 0);