From e6911a39157382e2b608041979ad2f41b937b14f Mon Sep 17 00:00:00 2001 From: you Date: Fri, 20 Mar 2026 01:34:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20performance=20instrumentation=20?= =?UTF-8?q?=E2=80=94=20server=20timing=20middleware,=20client=20API=20trac?= =?UTF-8?q?king,=20/api/perf=20endpoint,=20#/perf=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/app.js | 30 +++++++++++++++- public/index.html | 5 +-- public/perf.js | 88 +++++++++++++++++++++++++++++++++++++++++++++++ public/style.css | 13 +++++++ server.js | 65 ++++++++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 public/perf.js diff --git a/public/app.js b/public/app.js index 5817a869..58661372 100644 --- a/public/app.js +++ b/public/app.js @@ -11,11 +11,36 @@ function payloadTypeName(n) { return PAYLOAD_TYPES[n] || 'UNKNOWN'; } function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; } // --- Utilities --- +const _apiPerf = { calls: 0, totalMs: 0, log: [] }; async function api(path) { + const t0 = performance.now(); const res = await fetch('/api' + path); if (!res.ok) throw new Error(`API ${res.status}: ${path}`); - return res.json(); + const data = await res.json(); + const ms = performance.now() - t0; + _apiPerf.calls++; + _apiPerf.totalMs += ms; + _apiPerf.log.push({ path, ms: Math.round(ms), time: Date.now() }); + if (_apiPerf.log.length > 200) _apiPerf.log.shift(); + if (ms > 500) console.warn(`[SLOW API] ${path} took ${Math.round(ms)}ms`); + return data; } +// Expose for console debugging: apiPerf() +window.apiPerf = function() { + const byPath = {}; + _apiPerf.log.forEach(e => { + if (!byPath[e.path]) byPath[e.path] = { count: 0, totalMs: 0, maxMs: 0 }; + byPath[e.path].count++; + byPath[e.path].totalMs += e.ms; + if (e.ms > byPath[e.path].maxMs) byPath[e.path].maxMs = e.ms; + }); + const rows = Object.entries(byPath).map(([p, s]) => ({ + path: p, count: s.count, avgMs: Math.round(s.totalMs / s.count), maxMs: s.maxMs, + totalMs: Math.round(s.totalMs) + })).sort((a, b) => b.totalMs - a.totalMs); + console.table(rows); + return { calls: _apiPerf.calls, avgMs: Math.round(_apiPerf.totalMs / _apiPerf.calls), endpoints: rows }; +}; function timeAgo(iso) { if (!iso) return '—'; @@ -217,7 +242,10 @@ function navigate() { const app = document.getElementById('app'); if (pages[basePage]?.init) { + const t0 = performance.now(); pages[basePage].init(app, routeParam); + const ms = performance.now() - t0; + if (ms > 100) console.warn(`[SLOW PAGE] ${basePage} init took ${Math.round(ms)}ms`); app.classList.remove('page-enter'); void app.offsetWidth; app.classList.add('page-enter'); } else { app.innerHTML = `

${route}

Page not yet implemented.

`; diff --git a/public/index.html b/public/index.html index d8a59fa8..4c109c76 100644 --- a/public/index.html +++ b/public/index.html @@ -20,7 +20,7 @@ - + - + @@ -87,5 +87,6 @@ + diff --git a/public/perf.js b/public/perf.js new file mode 100644 index 00000000..3ba857c9 --- /dev/null +++ b/public/perf.js @@ -0,0 +1,88 @@ +/* === MeshCore Analyzer — perf.js === */ +'use strict'; + +(function () { + let interval = null; + + async function render(app) { + app.innerHTML = '

⚡ Performance Dashboard

Loading...
'; + await refresh(); + } + + async function refresh() { + const el = document.getElementById('perfContent'); + if (!el) return; + try { + const [server, client] = await Promise.all([ + fetch('/api/perf').then(r => r.json()), + Promise.resolve(window.apiPerf ? window.apiPerf() : null) + ]); + + let html = ''; + + // Server overview + html += `
+
${server.totalRequests}
Total Requests
+
${server.avgMs}ms
Avg Response
+
${Math.round(server.uptime / 60)}m
Uptime
+
${server.slowQueries.length}
Slow (>100ms)
+
`; + + // Server endpoints table + const eps = Object.entries(server.endpoints); + if (eps.length) { + html += '

Server Endpoints (sorted by total time)

'; + html += '
'; + for (const [path, s] of eps) { + const total = Math.round(s.count * s.avgMs); + const cls = s.p95Ms > 200 ? ' class="perf-slow"' : s.p95Ms > 50 ? ' class="perf-warn"' : ''; + html += ``; + } + html += '
EndpointCountAvgP50P95MaxTotal
${path}${s.count}${s.avgMs}ms${s.p50Ms}ms${s.p95Ms}ms${s.maxMs}ms${total}ms
'; + } + + // Client API calls + if (client && client.endpoints.length) { + html += '

Client API Calls (this session)

'; + html += '
'; + for (const s of client.endpoints) { + const cls = s.maxMs > 500 ? ' class="perf-slow"' : s.avgMs > 200 ? ' class="perf-warn"' : ''; + html += ``; + } + html += '
EndpointCountAvgMaxTotal
${s.path}${s.count}${s.avgMs}ms${s.maxMs}ms${s.totalMs}ms
'; + } + + // Slow queries + if (server.slowQueries.length) { + html += '

Recent Slow Queries (>100ms)

'; + html += '
'; + for (const q of server.slowQueries.slice().reverse()) { + html += ``; + } + html += '
TimePathDurationStatus
${new Date(q.time).toLocaleTimeString()}${q.path}${q.ms}ms${q.status}
'; + } + + html += `
`; + el.innerHTML = html; + + document.getElementById('perfReset')?.addEventListener('click', async () => { + await fetch('/api/perf/reset', { method: 'POST' }); + if (window._apiPerf) { window._apiPerf = { calls: 0, totalMs: 0, log: [] }; } + refresh(); + }); + document.getElementById('perfRefresh')?.addEventListener('click', refresh); + } catch (err) { + el.innerHTML = `

Error: ${err.message}

`; + } + } + + registerPage('perf', { + init(app) { + render(app); + interval = setInterval(refresh, 5000); + }, + destroy() { + if (interval) { clearInterval(interval); interval = null; } + } + }); +})(); diff --git a/public/style.css b/public/style.css index 5f59c4b2..2eab618e 100644 --- a/public/style.css +++ b/public/style.css @@ -1413,3 +1413,16 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); } } .mobile-sheet-close:hover { color: var(--text); } .mobile-sheet-content { padding-top: 4px; } + +/* Perf dashboard */ +.perf-card { background: var(--surface-1); border: 1px solid var(--border); border-radius: 8px; padding: 12px 20px; min-width: 120px; text-align: center; } +.perf-num { font-size: 24px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; } +.perf-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; } +.perf-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.perf-table th { text-align: left; padding: 6px 10px; border-bottom: 2px solid var(--border); color: var(--text-muted); font-size: 11px; text-transform: uppercase; } +.perf-table td { padding: 5px 10px; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; } +.perf-table code { font-size: 12px; color: var(--text); } +.perf-table .perf-slow { background: rgba(239, 68, 68, 0.08); } +.perf-table .perf-slow td { color: #ef4444; } +.perf-table .perf-warn { background: rgba(251, 191, 36, 0.06); } +.perf-table .perf-warn td { color: #f59e0b; } diff --git a/server.js b/server.js index 5be01a13..ed57a987 100644 --- a/server.js +++ b/server.js @@ -34,6 +34,71 @@ db.seed(); const app = express(); const server = http.createServer(app); +// --- Performance Instrumentation --- +const perfStats = { + requests: 0, + totalMs: 0, + endpoints: {}, // { path: { count, totalMs, maxMs, avgMs, p95: [], lastSlow } } + slowQueries: [], // last 50 requests > 100ms + startedAt: Date.now(), + reset() { + this.requests = 0; this.totalMs = 0; this.endpoints = {}; this.slowQueries = []; this.startedAt = Date.now(); + } +}; + +app.use((req, res, next) => { + if (!req.path.startsWith('/api/')) return next(); + const start = process.hrtime.bigint(); + const origEnd = res.end; + res.end = function(...args) { + const ms = Number(process.hrtime.bigint() - start) / 1e6; + perfStats.requests++; + perfStats.totalMs += ms; + // Normalize parameterized routes + const key = req.route ? req.route.path : req.path.replace(/[0-9a-f]{8,}/gi, ':id'); + if (!perfStats.endpoints[key]) perfStats.endpoints[key] = { count: 0, totalMs: 0, maxMs: 0, recent: [] }; + const ep = perfStats.endpoints[key]; + ep.count++; + ep.totalMs += ms; + if (ms > ep.maxMs) ep.maxMs = ms; + ep.recent.push(ms); + if (ep.recent.length > 100) ep.recent.shift(); + if (ms > 100) { + perfStats.slowQueries.push({ path: req.path, ms: Math.round(ms * 10) / 10, time: new Date().toISOString(), status: res.statusCode }); + if (perfStats.slowQueries.length > 50) perfStats.slowQueries.shift(); + } + origEnd.apply(res, args); + }; + next(); +}); + +app.get('/api/perf', (req, res) => { + const summary = {}; + for (const [path, ep] of Object.entries(perfStats.endpoints)) { + const sorted = [...ep.recent].sort((a, b) => a - b); + const p95 = sorted[Math.floor(sorted.length * 0.95)] || 0; + const p50 = sorted[Math.floor(sorted.length * 0.5)] || 0; + summary[path] = { + count: ep.count, + avgMs: Math.round(ep.totalMs / ep.count * 10) / 10, + p50Ms: Math.round(p50 * 10) / 10, + p95Ms: Math.round(p95 * 10) / 10, + maxMs: Math.round(ep.maxMs * 10) / 10, + }; + } + // Sort by total time spent (count * avg) descending + const sorted = Object.entries(summary).sort((a, b) => (b[1].count * b[1].avgMs) - (a[1].count * a[1].avgMs)); + res.json({ + uptime: Math.round((Date.now() - perfStats.startedAt) / 1000), + totalRequests: perfStats.requests, + avgMs: perfStats.requests ? Math.round(perfStats.totalMs / perfStats.requests * 10) / 10 : 0, + endpoints: Object.fromEntries(sorted), + slowQueries: perfStats.slowQueries.slice(-20), + }); +}); + +app.post('/api/perf/reset', (req, res) => { perfStats.reset(); res.json({ ok: true }); }); + // --- WebSocket --- const wss = new WebSocketServer({ server });