diff --git a/public/index.html b/public/index.html index 2f5a6d7a..c7efb652 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -81,29 +81,29 @@
- - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/perf.js b/public/perf.js index 852c89f9..5f1dbc4b 100644 --- a/public/perf.js +++ b/public/perf.js @@ -31,19 +31,37 @@
${server.slowQueries.length}
Slow (>100ms)
`; - // 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 += `

System Health

-
${m.heapUsed}MB
Heap Used / ${m.heapTotal}MB
-
${m.rss}MB
RSS
-
${el.p95Ms}ms
Event Loop p95
-
${el.maxLagMs}ms
EL Max Lag
-
${el.currentLagMs}ms
EL Current
-
${health.websocket.clients}
WS Clients
-
`; + 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 += `

๐Ÿ”ง Go Runtime

+
${gr.goroutines}
Goroutines
+
${gr.numGC}
GC Collections
+
${gr.pauseTotalMs}ms
GC Pause Total
+
${gr.lastPauseMs}ms
Last GC Pause
+
${gr.heapAllocMB}MB
Heap Alloc
+
${gr.heapSysMB}MB
Heap Sys
+
${gr.heapInuseMB}MB
Heap Inuse
+
${gr.heapIdleMB}MB
Heap Idle
+
${gr.numCPU}
CPUs
+
${health.websocket.clients}
WS Clients
+
`; + } 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 += `

System Health

+
${m.heapUsed}MB
Heap Used / ${m.heapTotal}MB
+
${m.rss}MB
RSS
+
${el.p95Ms}ms
Event Loop p95
+
${el.maxLagMs}ms
EL Max Lag
+
${el.currentLagMs}ms
EL Current
+
${health.websocket.clients}
WS Clients
+
`; + } } // Cache stats diff --git a/test-perf-go-runtime.js b/test-perf-go-runtime.js new file mode 100644 index 00000000..f271072f --- /dev/null +++ b/test-perf-go-runtime.js @@ -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);