mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-03 11:31:39 +00:00
3062745437
Red commit: df6a406a89 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2F1648-m2-headers-tables)
Partial fix for #1648 (M2 of 6). Do NOT close the tracking issue.
M2 covers page headers + table chrome: section glyphs, refresh/action
buttons, status pills, payload-type icon maps. Heavy on analytics.js.
## Per-file swap counts
| file | swaps |
| --- | --- |
| public/analytics.js | 89 |
| public/nodes.js | 29 |
| public/packets.js | 30 |
| public/live.js | 30 |
| public/map.js | 11 |
| public/perf.js | 9 |
| public/audio-lab.js | 5 |
| public/node-analytics.js | 4 |
| public/table-sort.js | 1 |
| public/traces.js | 1 |
| **total** | **209** |
Plus 48 new Phosphor SVG symbols vendored into
`public/icons/phosphor-sprite.svg`
(regular weight, alphabetical): arrows-out, battery-high, battery-low,
bomb,
book-open, buildings, caret-down, caret-up, cell-signal-high,
chart-line, chats,
check-circle, clipboard-text, clock, crosshair, dice-five, envelope,
flame,
gear, globe, graph, handshake, house-line, info, key, link,
list-numbers,
lock-open, map-pin, microphone, path, piano-keys, prohibit, pulse,
push-pin,
question, radio, repeat, ruler, share-network, shuffle, signpost,
speaker-high,
target, thermometer, trend-up, trophy, x-circle. Total sprite now 82
symbols, ~35 KB.
## Tests
- Static scan: `test-issue-1648-m2-emoji-scan.js` asserts ZERO emoji
codepoints (U+1F300–1FAFF, U+2600–27BF) and zero misc-icon chars
(◆●■▲★☆○✓✗⚠✉) in each M2 file, plus a minimum `<use href="…#ph-…">`
ref count per file.
- E2E: `test-issue-1648-m2-icons-e2e.js` — 15 Chromium assertions
(test-issue-1648-m2-icons-e2e.js:31–245) covering /analytics, /packets
filter row, /nodes table chrome, /live audio + feed buttons, /map
controls h3 + toggle, /traces, /perf, /audio-lab loop button, plus a
sprite-resolution check (every rendered `<use>` resolves to a defined
`<symbol>` — i.e. no `.notdef` glyph fallback). E2E assertion added:
`test-issue-1648-m2-icons-e2e.js:96`.
- Both wired into `.github/workflows/deploy.yml` E2E block.
Anti-tautology proof: reverting the audio-lab.js Packet Data h3 swap
(restoring `📦`) flips the static scan from PASS to assertion failure
`actual: 1, expected: 0` (audio-lab.js emoji-hit count check). Verified
locally before push.
## Browser verification
Local Chromium against `corescope-server -port 13581` + e2e fixture DB.
Screenshots of /analytics, /nodes, /packets, /live, /map at 1200×900 and
375×812. No `.notdef` glyphs; theme toggle preserved; sprite resolves on
every page.
## Out of scope (carried forward)
- customize.js / customize-v2.js NODE_EMOJI + PACKET_TYPE_EMOJI configs
**[M5]**
- `cmd/server/routes.go` L567-574 onboarding-tile emoji **[M6]**
- home.js welcome cards 🌱 ⚡ etc. **[M3]**
- route-view overlays (route-view-utils.js, route-view.js,
hop-display.js, path-inspector.js) **[M4]**
- channels.js modals + footer 💬 📋 🔒 **[M5]**
- roles.js NODE_SHAPE_EMOJI (used by route-view, not M2) **[M4]**
- packets.js L2169 expand caret swapped (was `▶/▼`); other ▶ in
audio-lab
alabPlay button left as-is — out of M2 range (U+25B6 ≠ emoji).
Adheres to rule 34: no `Fixes #1648`, no auto-close.
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
385 lines
25 KiB
JavaScript
385 lines
25 KiB
JavaScript
/* === CoreScope — perf.js === */
|
||
'use strict';
|
||
|
||
var GH = 'https://github.com/Kpa-clawbot/corescope';
|
||
|
||
// detectPerfAnomalies — pure, testable.
|
||
// Computes per-component write rates over a rolling time window and flags any
|
||
// component whose current per-second rate exceeds `factor` × its rolling
|
||
// baseline rate. Issue #1120 acceptance: 5-minute window, 10× threshold.
|
||
//
|
||
// Inputs:
|
||
// history: ordered array of snapshots [{ sampleAt: ISO, sources: { name: cum } }]
|
||
// current: the freshest snapshot, same shape
|
||
// opts:
|
||
// windowMs (default 5*60*1000) — rolling baseline window
|
||
// factor (default 10) — rate-multiplier threshold
|
||
// minHistorySec (default 30) — refuse to flag until baseline is stable
|
||
//
|
||
// Returns: { rates, baselineRates, flags } — all keyed by source name.
|
||
function detectPerfAnomalies(history, current, opts) {
|
||
opts = opts || {};
|
||
const windowMs = opts.windowMs || (5 * 60 * 1000);
|
||
const factor = opts.factor || 10;
|
||
const minHistorySec = opts.minHistorySec != null ? opts.minHistorySec : 30;
|
||
const out = { rates: {}, baselineRates: {}, flags: {} };
|
||
if (!current || !current.sources || !history || history.length === 0) return out;
|
||
const curT = Date.parse(current.sampleAt);
|
||
if (!isFinite(curT)) return out;
|
||
|
||
// Find the most recent prior sample (for the *current* per-second rate)
|
||
// and the oldest sample within the window (for the baseline).
|
||
const prior = history[history.length - 1];
|
||
const priorT = Date.parse(prior.sampleAt);
|
||
const curDt = (curT - priorT) / 1000;
|
||
if (!(curDt > 0)) return out;
|
||
|
||
// Baseline: oldest sample within window vs. prior (the snapshot just before
|
||
// `current`). Anything older than windowMs is excluded.
|
||
const cutoff = curT - windowMs;
|
||
let baseIdx = 0;
|
||
for (let i = history.length - 1; i >= 0; i--) {
|
||
if (Date.parse(history[i].sampleAt) < cutoff) { baseIdx = i + 1; break; }
|
||
}
|
||
if (baseIdx >= history.length) baseIdx = history.length - 1;
|
||
const baseSnap = history[baseIdx];
|
||
const baseT = Date.parse(baseSnap.sampleAt);
|
||
const baseDt = (priorT - baseT) / 1000;
|
||
|
||
// Compute rates for every source seen in current.
|
||
for (const k of Object.keys(current.sources)) {
|
||
const cur = current.sources[k] || 0;
|
||
const prev = (prior.sources && prior.sources[k]) || 0;
|
||
const rate = (cur - prev) / curDt;
|
||
out.rates[k] = rate;
|
||
if (baseDt <= 0 || baseDt < minHistorySec) {
|
||
out.baselineRates[k] = null;
|
||
continue;
|
||
}
|
||
const baseStart = (baseSnap.sources && baseSnap.sources[k]) || 0;
|
||
const baseEnd = prev; // baseline window = [baseSnap .. prior]
|
||
const baseRate = (baseEnd - baseStart) / baseDt;
|
||
out.baselineRates[k] = baseRate;
|
||
// Guard floor to avoid 0-baseline → infinite ratio false positives.
|
||
const floor = 0.05; // 1 event per 20s minimum baseline
|
||
if (rate > factor * Math.max(baseRate, floor) && rate > factor * floor) {
|
||
out.flags[k] = true;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
if (typeof window !== 'undefined') {
|
||
window.detectPerfAnomalies = detectPerfAnomalies;
|
||
}
|
||
|
||
function renderVersionCard(health) {
|
||
if (!health || (!health.version && !health.commit)) return '';
|
||
var ver = health.version && health.version !== 'unknown' ? health.version : null;
|
||
var sha = health.commit && health.commit !== 'unknown' ? health.commit : null;
|
||
if (!ver && !sha) return '';
|
||
var vTag = ver ? (ver.charAt(0) === 'v' ? ver : 'v' + ver) : null;
|
||
var parts = [];
|
||
if (vTag) parts.push('<a href="' + GH + '/releases/tag/' + vTag + '" target="_blank" rel="noopener">' + vTag + '</a>');
|
||
if (sha) parts.push('<a href="' + GH + '/commit/' + sha + '" target="_blank" rel="noopener">' + sha.slice(0, 7) + '</a>');
|
||
return '<div class="perf-card"><div class="perf-num perf-num--small">' + parts.join(' · ') + '</div><div class="perf-label">Version</div></div>';
|
||
}
|
||
|
||
(function () {
|
||
let interval = null;
|
||
|
||
async function render(app) {
|
||
app.innerHTML = '<div id="perfWrapper" style="padding:16px 24px;"><h2><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lightning"/></svg> Performance Dashboard</h2><div id="perfContent">Loading...</div></div>';
|
||
await refresh();
|
||
}
|
||
|
||
async function refresh() {
|
||
const el = document.getElementById('perfContent');
|
||
if (!el) return;
|
||
try {
|
||
// #1258: /api/health was awaited AFTER Promise.all, adding a full RTT
|
||
// (~50-200ms) on every 5s refresh. Issue it in parallel with the rest.
|
||
const [server, client, ioStats, sqliteStats, writeSources, health] = await Promise.all([
|
||
fetch('/api/perf').then(r => r.json()),
|
||
Promise.resolve(window.apiPerf ? window.apiPerf() : null),
|
||
fetch('/api/perf/io').then(r => r.json()).catch(() => null),
|
||
fetch('/api/perf/sqlite').then(r => r.json()).catch(() => null),
|
||
fetch('/api/perf/write-sources').then(r => r.json()).catch(() => null),
|
||
fetch('/api/health').then(r => r.json()).catch(() => null)
|
||
]);
|
||
|
||
let html = '';
|
||
|
||
// Server overview
|
||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:16px 0;">
|
||
<div class="perf-card"><div class="perf-num">${server.totalRequests}</div><div class="perf-label">Total Requests</div></div>
|
||
<div class="perf-card"><div class="perf-num">${server.avgMs}ms</div><div class="perf-label">Avg Response</div></div>
|
||
<div class="perf-card"><div class="perf-num">${health ? health.uptimeHuman : Math.round(server.uptime / 60) + 'm'}</div><div class="perf-label">Uptime</div></div>
|
||
<div class="perf-card"><div class="perf-num">${server.slowQueries.length}</div><div class="perf-label">Slow (>100ms)</div></div>
|
||
${renderVersionCard(health)}
|
||
</div>`;
|
||
|
||
// System health (memory, event loop / go runtime, WS)
|
||
if (health) {
|
||
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><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-wrench"/></svg> 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).toFixed(1)}ms</div><div class="perf-label">GC Pause Total</div></div>
|
||
<div class="perf-card"><div class="perf-num">${(+gr.lastPauseMs).toFixed(1)}ms</div><div class="perf-label">Last GC Pause</div></div>
|
||
<div class="perf-card"><div class="perf-num">${(+gr.heapAllocMB).toFixed(1)}MB</div><div class="perf-label">Heap Alloc</div></div>
|
||
<div class="perf-card"><div class="perf-num">${(+gr.heapSysMB).toFixed(1)}MB</div><div class="perf-label">Heap Sys</div></div>
|
||
<div class="perf-card"><div class="perf-num">${(+gr.heapInuseMB).toFixed(1)}MB</div><div class="perf-label">Heap Inuse</div></div>
|
||
<div class="perf-card"><div class="perf-num">${(+gr.heapIdleMB).toFixed(1)}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>`;
|
||
}
|
||
}
|
||
|
||
// Disk I/O (#1120)
|
||
if (ioStats) {
|
||
const fmtRate = (bps) => {
|
||
if (bps >= 1048576) return (bps / 1048576).toFixed(1) + ' MB/s';
|
||
if (bps >= 1024) return (bps / 1024).toFixed(1) + ' KB/s';
|
||
return Math.round(bps) + ' B/s';
|
||
};
|
||
const writeWarn = ioStats.writeBytesPerSec > 10 * 1048576 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
|
||
const cancelled = ioStats.cancelledWriteBytesPerSec || 0;
|
||
// Cancelled writes warn at >1 MB/s — sustained cancellation usually
|
||
// means truncate/unlink racing with active writers (#1119-shaped bug).
|
||
const cancelledWarn = cancelled > 1048576 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
|
||
html += `<h3>Disk I/O (server process)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||
<div class="perf-card"><div class="perf-num">${fmtRate(ioStats.readBytesPerSec || 0)}</div><div class="perf-label">Read</div></div>
|
||
<div class="perf-card"><div class="perf-num">${fmtRate(ioStats.writeBytesPerSec || 0)}${writeWarn}</div><div class="perf-label">Write</div></div>
|
||
<div class="perf-card"><div class="perf-num">${fmtRate(cancelled)}${cancelledWarn}</div><div class="perf-label">Cancelled Write</div></div>
|
||
<div class="perf-card"><div class="perf-num">${Math.round(ioStats.syscallsRead || 0)}/s</div><div class="perf-label">Syscalls Read</div></div>
|
||
<div class="perf-card"><div class="perf-num">${Math.round(ioStats.syscallsWrite || 0)}/s</div><div class="perf-label">Syscalls Write</div></div>
|
||
</div>`;
|
||
|
||
// Ingestor row — sourced from ingestor's own /proc/self/io snapshot
|
||
// surfaced via the stats file (#1120: "Both ingestor and server").
|
||
if (ioStats.ingestor) {
|
||
const ing = ioStats.ingestor;
|
||
const ingWriteWarn = (ing.writeBytesPerSec || 0) > 10 * 1048576 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
|
||
const ingCancelled = ing.cancelledWriteBytesPerSec || 0;
|
||
const ingCancelledWarn = ingCancelled > 1048576 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
|
||
html += `<h3>Disk I/O (Ingestor process)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||
<div class="perf-card"><div class="perf-num">${fmtRate(ing.readBytesPerSec || 0)}</div><div class="perf-label">Read</div></div>
|
||
<div class="perf-card"><div class="perf-num">${fmtRate(ing.writeBytesPerSec || 0)}${ingWriteWarn}</div><div class="perf-label">Write</div></div>
|
||
<div class="perf-card"><div class="perf-num">${fmtRate(ingCancelled)}${ingCancelledWarn}</div><div class="perf-label">Cancelled Write</div></div>
|
||
<div class="perf-card"><div class="perf-num">${Math.round(ing.syscallsRead || 0)}/s</div><div class="perf-label">Syscalls Read</div></div>
|
||
<div class="perf-card"><div class="perf-num">${Math.round(ing.syscallsWrite || 0)}/s</div><div class="perf-label">Syscalls Write</div></div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
// Write Sources (#1120) — per-component counters from ingestor
|
||
if (writeSources && writeSources.sources) {
|
||
const src = writeSources.sources;
|
||
const keys = Object.keys(src).sort((a, b) => (src[b] || 0) - (src[a] || 0));
|
||
html += '<h3>Write Sources</h3>';
|
||
if (keys.length === 0) {
|
||
html += '<p style="color:var(--text-muted)">No ingestor stats yet (waiting for /tmp/corescope-ingestor-stats.json)</p>';
|
||
} else {
|
||
// Anomaly detection (#1120 acceptance): flag any component whose
|
||
// per-second write rate exceeds 10× its 5-minute rolling baseline.
|
||
// History is stashed on window so the detector has multi-sample
|
||
// context across the 5s refresh tick.
|
||
if (!window._perfWriteSourcesHistory) window._perfWriteSourcesHistory = [];
|
||
const history = window._perfWriteSourcesHistory;
|
||
const current = { sampleAt: writeSources.sampleAt || new Date().toISOString(), sources: { ...src } };
|
||
const anom = detectPerfAnomalies(history, current, { windowMs: 5 * 60 * 1000, factor: 10 });
|
||
// Append current and prune anything older than 6 minutes (keeps a
|
||
// little headroom past the 5-min window, bounded memory).
|
||
history.push(current);
|
||
const cutoff = Date.parse(current.sampleAt) - (6 * 60 * 1000);
|
||
while (history.length > 1 && Date.parse(history[0].sampleAt) < cutoff) history.shift();
|
||
|
||
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th scope="col">Source</th><th scope="col">Total</th><th scope="col">Rate/s</th><th scope="col">Baseline/s</th><th scope="col">Anomaly</th></tr></thead><tbody>';
|
||
for (const k of keys) {
|
||
const v = src[k] || 0;
|
||
const rate = anom.rates[k];
|
||
const base = anom.baselineRates[k];
|
||
const flag = anom.flags[k] ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
|
||
const rateStr = (rate != null && isFinite(rate)) ? rate.toFixed(2) : '—';
|
||
const baseStr = (base != null && isFinite(base)) ? base.toFixed(2) : '—';
|
||
html += `<tr><td><code>${k}</code></td><td>${v.toLocaleString()}</td><td>${rateStr}</td><td>${baseStr}</td><td>${flag}</td></tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
if (writeSources.sampleAt) {
|
||
html += `<div style="font-size:11px;color:var(--text-muted);margin-top:4px">Sampled: ${writeSources.sampleAt} · baseline window: 5 min · threshold: 10×</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// SQLite perf (separate from existing SQLite block — focused on WAL + cache hit) (#1120)
|
||
if (sqliteStats) {
|
||
const walMB = sqliteStats.walSizeMB || 0;
|
||
const walFlag = walMB > 100 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
|
||
const hitRate = (sqliteStats.cacheHitRate || 0) * 100;
|
||
const hitFlag = hitRate > 0 && hitRate < 90 ? ' <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg>' : '';
|
||
html += `<h3>SQLite (WAL + Cache Hit)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||
<div class="perf-card"><div class="perf-num">${walMB.toFixed(1)}MB${walFlag}</div><div class="perf-label">WAL Size</div></div>
|
||
<div class="perf-card"><div class="perf-num">${(sqliteStats.pageCount || 0).toLocaleString()}</div><div class="perf-label">Page Count</div></div>
|
||
<div class="perf-card"><div class="perf-num">${sqliteStats.pageSize || 0}</div><div class="perf-label">Page Size</div></div>
|
||
<div class="perf-card"><div class="perf-num">${hitRate.toFixed(1)}%${hitFlag}</div><div class="perf-label">Cache Hit Rate</div></div>
|
||
</div>`;
|
||
}
|
||
|
||
// Cache stats
|
||
if (server.cache) {
|
||
const c = server.cache;
|
||
const clientCache = _apiCache ? _apiCache.size : 0;
|
||
html += `<h3>Cache</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||
<div class="perf-card"><div class="perf-num">${c.size}</div><div class="perf-label">Server Entries</div></div>
|
||
<div class="perf-card"><div class="perf-num">${c.hits}</div><div class="perf-label">Server Hits</div></div>
|
||
<div class="perf-card"><div class="perf-num">${c.misses}</div><div class="perf-label">Server Misses</div></div>
|
||
<div class="perf-card"><div class="perf-num" style="color:${c.hitRate > 50 ? 'var(--status-green)' : c.hitRate > 20 ? 'var(--status-yellow)' : 'var(--status-red)'}">${c.hitRate}%</div><div class="perf-label">Server Hit Rate</div></div>
|
||
<div class="perf-card"><div class="perf-num">${c.staleHits || 0}</div><div class="perf-label">Stale Hits (SWR)</div></div>
|
||
<div class="perf-card"><div class="perf-num">${c.recomputes || 0}</div><div class="perf-label">Recomputes</div></div>
|
||
<div class="perf-card"><div class="perf-num">${clientCache}</div><div class="perf-label">Client Entries</div></div>
|
||
</div>`;
|
||
if (client) {
|
||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||
<div class="perf-card"><div class="perf-num">${client.cacheHits || 0}</div><div class="perf-label">Client Hits</div></div>
|
||
<div class="perf-card"><div class="perf-num">${client.cacheMisses || 0}</div><div class="perf-label">Client Misses</div></div>
|
||
<div class="perf-card"><div class="perf-num" style="color:${(client.cacheHitRate||0) > 50 ? 'var(--status-green)' : 'var(--status-yellow)'}">${client.cacheHitRate || 0}%</div><div class="perf-label">Client Hit Rate</div></div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
// Packet Store stats
|
||
if (server.packetStore) {
|
||
const ps = server.packetStore;
|
||
html += `<h3>In-Memory Packet Store</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||
<div class="perf-card"><div class="perf-num">${ps.inMemory.toLocaleString()}</div><div class="perf-label">Packets in RAM</div></div>
|
||
<div class="perf-card"><div class="perf-num">${ps.trackedMB}MB</div><div class="perf-label">Tracked Memory</div></div>
|
||
<div class="perf-card"><div class="perf-num">${ps.maxMB}MB</div><div class="perf-label">Memory Limit</div></div>
|
||
<div class="perf-card"><div class="perf-num">${ps.estimatedMB}MB</div><div class="perf-label">Heap (debug)</div></div>
|
||
<div class="perf-card"><div class="perf-num">${ps.queries.toLocaleString()}</div><div class="perf-label">Queries Served</div></div>
|
||
<div class="perf-card"><div class="perf-num">${ps.inserts.toLocaleString()}</div><div class="perf-label">Live Inserts</div></div>
|
||
<div class="perf-card"><div class="perf-num">${ps.evicted.toLocaleString()}</div><div class="perf-label">Evicted</div></div>
|
||
<div class="perf-card"><div class="perf-num">${ps.indexes.byHash.toLocaleString()}</div><div class="perf-label">Unique Hashes</div></div>
|
||
<div class="perf-card"><div class="perf-num">${ps.indexes.byObserver}</div><div class="perf-label">Observers</div></div>
|
||
<div class="perf-card"><div class="perf-num">${ps.indexes.byNode.toLocaleString()}</div><div class="perf-label">Indexed Nodes</div></div>
|
||
</div>`;
|
||
}
|
||
|
||
// SQLite stats
|
||
if (server.sqlite && !server.sqlite.error) {
|
||
const sq = server.sqlite;
|
||
const walColor = sq.walSizeMB > 50 ? 'var(--status-red)' : sq.walSizeMB > 10 ? 'var(--status-yellow)' : 'var(--status-green)';
|
||
const freelistColor = sq.freelistMB > 10 ? 'var(--status-yellow)' : 'var(--status-green)';
|
||
html += `<h3>SQLite</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||
<div class="perf-card"><div class="perf-num">${sq.dbSizeMB}MB</div><div class="perf-label">DB Size</div></div>
|
||
<div class="perf-card"><div class="perf-num" style="color:${walColor}">${sq.walSizeMB}MB</div><div class="perf-label">WAL Size</div></div>
|
||
<div class="perf-card"><div class="perf-num" style="color:${freelistColor}">${sq.freelistMB}MB</div><div class="perf-label">Freelist</div></div>
|
||
<div class="perf-card"><div class="perf-num">${(sq.rows.transmissions || 0).toLocaleString()}</div><div class="perf-label">Transmissions</div></div>
|
||
<div class="perf-card"><div class="perf-num">${(sq.rows.observations || 0).toLocaleString()}</div><div class="perf-label">Observations</div></div>
|
||
<div class="perf-card"><div class="perf-num">${sq.rows.nodes || 0}</div><div class="perf-label">Nodes</div></div>
|
||
<div class="perf-card"><div class="perf-num">${sq.rows.observers || 0}</div><div class="perf-label">Observers</div></div>`;
|
||
if (sq.walPages) {
|
||
html += `<div class="perf-card"><div class="perf-num">${sq.walPages.busy}</div><div class="perf-label">WAL Busy Pages</div></div>`;
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
|
||
// Server endpoints table — sort by total time (count * avg) DESC.
|
||
// #1258: header claimed "sorted by total time" but JSON map order is
|
||
// undefined and the frontend was not sorting. Slow endpoints could
|
||
// appear anywhere in the table, defeating the section's whole purpose.
|
||
const eps = Object.entries(server.endpoints).sort((a, b) => {
|
||
const ta = (a[1].count || 0) * (a[1].avgMs || 0);
|
||
const tb = (b[1].count || 0) * (b[1].avgMs || 0);
|
||
return tb - ta;
|
||
});
|
||
if (eps.length) {
|
||
html += '<h3>Server Endpoints (sorted by total time)</h3>';
|
||
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th scope="col">Endpoint</th><th scope="col">Count</th><th scope="col">Avg</th><th scope="col">P50</th><th scope="col">P95</th><th scope="col">Max</th><th scope="col">Total</th></tr></thead><tbody>';
|
||
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 += `<tr${cls}><td><code>${path}</code></td><td>${s.count}</td><td>${s.avgMs}ms</td><td>${s.p50Ms}ms</td><td>${s.p95Ms}ms</td><td>${s.maxMs}ms</td><td>${total}ms</td></tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
// Client API calls
|
||
if (client && client.endpoints.length) {
|
||
html += '<h3>Client API Calls (this session)</h3>';
|
||
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th scope="col">Endpoint</th><th scope="col">Count</th><th scope="col">Avg</th><th scope="col">Max</th><th scope="col">Total</th></tr></thead><tbody>';
|
||
for (const s of client.endpoints) {
|
||
const cls = s.maxMs > 500 ? ' class="perf-slow"' : s.avgMs > 200 ? ' class="perf-warn"' : '';
|
||
html += `<tr${cls}><td><code>${s.path}</code></td><td>${s.count}</td><td>${s.avgMs}ms</td><td>${s.maxMs}ms</td><td>${s.totalMs}ms</td></tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
// Slow queries
|
||
if (server.slowQueries.length) {
|
||
html += '<h3>Recent Slow Queries (>100ms)</h3>';
|
||
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th scope="col">Time</th><th scope="col">Path</th><th scope="col">Duration</th><th scope="col">Status</th></tr></thead><tbody>';
|
||
for (const q of server.slowQueries.slice().reverse()) {
|
||
html += `<tr class="perf-slow"><td>${new Date(q.time).toLocaleTimeString()}</td><td><code>${q.path}</code></td><td>${q.ms}ms</td><td>${q.status}</td></tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
html += `<div style="margin-top:16px"><button id="perfReset" style="padding:8px 16px;cursor:pointer">Reset Stats</button> <button id="perfRefresh" style="padding:8px 16px;cursor:pointer">Refresh</button></div>`;
|
||
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 = `<p style="color:red">Error: ${err.message}</p>`;
|
||
}
|
||
}
|
||
|
||
registerPage('perf', {
|
||
init(app) {
|
||
render(app);
|
||
// #1258: don't burn CPU/network rebuilding the page (and its many cards
|
||
// + 3 large tables) every 5s while the tab is hidden. Pause polling on
|
||
// visibilitychange and resume on focus. Reduces background fetch traffic
|
||
// to zero and prevents a returning user from seeing a 100+ms thrash as
|
||
// a backlog of refreshes flush.
|
||
const tick = () => {
|
||
if (document.hidden) return;
|
||
refresh();
|
||
};
|
||
interval = setInterval(tick, 5000);
|
||
const onVis = () => {
|
||
if (!document.hidden) refresh();
|
||
};
|
||
document.addEventListener('visibilitychange', onVis);
|
||
this._onVis = onVis;
|
||
},
|
||
destroy() {
|
||
if (interval) { clearInterval(interval); interval = null; }
|
||
if (this._onVis) {
|
||
document.removeEventListener('visibilitychange', this._onVis);
|
||
this._onVis = null;
|
||
}
|
||
}
|
||
});
|
||
})();
|