From 936609f72933b2dd513bd9377e2b91c41b8ad0dd Mon Sep 17 00:00:00 2001 From: you Date: Fri, 20 Mar 2026 02:03:25 +0000 Subject: [PATCH] feat: add frontend API response caching with TTL, in-flight dedup, and WebSocket invalidation - Replace api() with caching version supporting TTL and request deduplication - Add appropriate TTLs to all api() call sites across all frontend JS files: - /stats: 5s TTL (was called 962 times in 3 min) - /nodes/:pubkey: 15s, /health: 30s, /observers: 30s - /channels: 15s, messages: 10s - /analytics/*: 60s, /bulk-health: 60s, /network-status: 60s - /nodes?*: 10s - Skip caching for real-time endpoints (/packets, /resolve-hops, /perf) - Invalidate /stats, /nodes, /channels caches on WebSocket messages - Deduplicate in-flight requests (same path returns same promise) - Add cache hit rate to window.apiPerf() console debugging - Update all cache busters in index.html --- public/analytics.js | 26 ++++++++--------- public/app.js | 62 ++++++++++++++++++++++++++++++---------- public/channels.js | 10 +++---- public/home.js | 8 +++--- public/index.html | 20 ++++++------- public/map.js | 4 +-- public/node-analytics.js | 2 +- public/nodes.js | 12 ++++---- public/observers.js | 2 +- public/packets.js | 2 +- 10 files changed, 90 insertions(+), 58 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 23df9649..822ecc3e 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -101,10 +101,10 @@ try { _analyticsData = {}; const [hashData, rfData, topoData, chanData] = await Promise.all([ - api('/analytics/hash-sizes'), - api('/analytics/rf'), - api('/analytics/topology'), - api('/analytics/channels'), + api('/analytics/hash-sizes', { ttl: 60000 }), + api('/analytics/rf', { ttl: 60000 }), + api('/analytics/topology', { ttl: 60000 }), + api('/analytics/channels', { ttl: 60000 }), ]); _analyticsData = { hashData, rfData, topoData, chanData }; renderTab('overview'); @@ -747,7 +747,7 @@ `; let allNodes = []; - try { const nd = await api('/nodes?limit=2000'); allNodes = nd.nodes || []; } catch {} + try { const nd = await api('/nodes?limit=2000', { ttl: 10000 }); allNodes = nd.nodes || []; } catch {} renderHashMatrix(data.topHops, allNodes); renderCollisions(data.topHops, allNodes); } @@ -938,10 +938,10 @@ el.innerHTML = '
Analyzing route patterns…
'; try { const [d2, d3, d4, d5] = await Promise.all([ - api('/analytics/subpaths?minLen=2&maxLen=2&limit=50'), - api('/analytics/subpaths?minLen=3&maxLen=3&limit=30'), - api('/analytics/subpaths?minLen=4&maxLen=4&limit=20'), - api('/analytics/subpaths?minLen=5&maxLen=8&limit=15') + api('/analytics/subpaths?minLen=2&maxLen=2&limit=50', { ttl: 60000 }), + api('/analytics/subpaths?minLen=3&maxLen=3&limit=30', { ttl: 60000 }), + api('/analytics/subpaths?minLen=4&maxLen=4&limit=20', { ttl: 60000 }), + api('/analytics/subpaths?minLen=5&maxLen=8&limit=15', { ttl: 60000 }) ]); function renderTable(data, title) { @@ -1032,7 +1032,7 @@ panel.classList.remove('collapsed'); panel.innerHTML = '
Loading…
'; try { - const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr)); + const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: 60000 }); renderSubpathDetail(panel, data); } catch (e) { panel.innerHTML = `
Error: ${e.message}
`; @@ -1141,9 +1141,9 @@ el.innerHTML = '
Loading node analytics…
'; try { const [nodesResp, bulkHealth, netStatus] = await Promise.all([ - api('/nodes?limit=200&sortBy=lastSeen'), - api('/nodes/bulk-health?limit=50'), - api('/nodes/network-status') + api('/nodes?limit=200&sortBy=lastSeen', { ttl: 10000 }), + api('/nodes/bulk-health?limit=50', { ttl: 60000 }), + api('/nodes/network-status', { ttl: 60000 }) ]); const nodes = nodesResp.nodes || nodesResp; const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]'); diff --git a/public/app.js b/public/app.js index 58661372..6bd1c1da 100644 --- a/public/app.js +++ b/public/app.js @@ -11,19 +11,45 @@ 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 _apiPerf = { calls: 0, totalMs: 0, log: [], cacheHits: 0 }; +const _apiCache = new Map(); +const _inflight = new Map(); +async function api(path, { ttl = 0, bust = false } = {}) { const t0 = performance.now(); - const res = await fetch('/api' + path); - if (!res.ok) throw new Error(`API ${res.status}: ${path}`); - 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; + if (!bust && ttl > 0) { + const cached = _apiCache.get(path); + if (cached && Date.now() < cached.expires) { + _apiPerf.calls++; + _apiPerf.cacheHits++; + _apiPerf.log.push({ path, ms: 0, time: Date.now(), cached: true }); + if (_apiPerf.log.length > 200) _apiPerf.log.shift(); + return cached.data; + } + } + // Deduplicate in-flight requests + if (_inflight.has(path)) return _inflight.get(path); + const promise = (async () => { + const res = await fetch('/api' + path); + if (!res.ok) throw new Error(`API ${res.status}: ${path}`); + 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`); + if (ttl > 0) _apiCache.set(path, { data, expires: Date.now() + ttl }); + return data; + })(); + _inflight.set(path, promise); + promise.finally(() => _inflight.delete(path)); + return promise; +} + +function invalidateApiCache(prefix) { + for (const key of _apiCache.keys()) { + if (key.startsWith(prefix || '')) _apiCache.delete(key); + } } // Expose for console debugging: apiPerf() window.apiPerf = function() { @@ -39,7 +65,9 @@ window.apiPerf = function() { 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 }; + const hitRate = _apiPerf.calls ? Math.round(_apiPerf.cacheHits / _apiPerf.calls * 100) : 0; + console.log(`Cache: ${_apiPerf.cacheHits} hits / ${_apiPerf.calls} calls (${hitRate}% hit rate)`); + return { calls: _apiPerf.calls, avgMs: Math.round(_apiPerf.totalMs / (_apiPerf.calls - _apiPerf.cacheHits || 1)), cacheHits: _apiPerf.cacheHits, hitRate: hitRate + '%', endpoints: rows }; }; function timeAgo(iso) { @@ -165,6 +193,10 @@ function connectWS() { ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); + // Invalidate caches when new data arrives + invalidateApiCache('/stats'); + invalidateApiCache('/nodes'); + invalidateApiCache('/channels'); wsListeners.forEach(fn => fn(msg)); } catch {} }; @@ -318,7 +350,7 @@ window.addEventListener('DOMContentLoaded', () => { favDropdown.innerHTML = '
Loading...
'; const items = await Promise.all(favs.map(async (pk) => { try { - const h = await api('/nodes/' + pk + '/health'); + const h = await api('/nodes/' + pk + '/health', { ttl: 30000 }); const age = h.stats.lastHeard ? Date.now() - new Date(h.stats.lastHeard).getTime() : null; const status = age === null ? '🔴' : age < 3600000 ? '🟢' : age < 86400000 ? '🟡' : '🔴'; return '' @@ -422,7 +454,7 @@ window.addEventListener('DOMContentLoaded', () => { // --- Nav Stats --- async function updateNavStats() { try { - const stats = await api('/stats'); + const stats = await api('/stats', { ttl: 5000 }); const el = document.getElementById('navStats'); if (el) { el.innerHTML = `${stats.totalPackets} pkts · ${stats.totalNodes} nodes · ${stats.totalObservers} obs`; diff --git a/public/channels.js b/public/channels.js index 2970d763..75d6f42a 100644 --- a/public/channels.js +++ b/public/channels.js @@ -18,7 +18,7 @@ if (cached && !cached.fetchedAt) return cached; // legacy null entries } try { - const data = await api('/nodes/search?q=' + encodeURIComponent(name)); + const data = await api('/nodes/search?q=' + encodeURIComponent(name), { ttl: 10000 }); // Try exact match first, then case-insensitive, then contains const nodes = data.nodes || []; const match = nodes.find(n => n.name === name) @@ -110,7 +110,7 @@ } try { - const detail = await api('/nodes/' + encodeURIComponent(node.public_key)); + const detail = await api('/nodes/' + encodeURIComponent(node.public_key), { ttl: 15000 }); const n = detail.node; const adverts = detail.recentAdverts || []; const role = n.is_repeater ? '📡 Repeater' : n.is_room ? '🏠 Room' : n.is_sensor ? '🌡 Sensor' : '📻 Companion'; @@ -389,7 +389,7 @@ async function loadChannels(silent) { try { - const data = await api('/channels'); + const data = await api('/channels', { ttl: 15000 }); channels = (data.channels || []).sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || '')); renderChannelList(); } catch (e) { @@ -451,7 +451,7 @@ msgEl.innerHTML = '
Loading messages…
'; try { - const data = await api(`/channels/${hash}/messages?limit=200`); + const data = await api(`/channels/${hash}/messages?limit=200`, { ttl: 10000 }); messages = data.messages || []; renderMessages(); scrollToBottom(); @@ -466,7 +466,7 @@ if (!msgEl) return; const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60; try { - const data = await api(`/channels/${selectedHash}/messages?limit=200`); + const data = await api(`/channels/${selectedHash}/messages?limit=200`, { ttl: 10000 }); const newMsgs = data.messages || []; // #92: Use message ID/hash for change detection instead of count + timestamp var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; }; diff --git a/public/home.js b/public/home.js index 6d9eb386..1c83cf14 100644 --- a/public/home.js +++ b/public/home.js @@ -146,7 +146,7 @@ if (!q) { suggest.classList.remove('open'); input.setAttribute('aria-expanded', 'false'); input.setAttribute('aria-activedescendant', ''); return; } searchTimeout = setTimeout(async () => { try { - const data = await api('/nodes/search?q=' + encodeURIComponent(q)); + const data = await api('/nodes/search?q=' + encodeURIComponent(q), { ttl: 10000 }); const nodes = data.nodes || []; if (!nodes.length) { suggest.innerHTML = '
No nodes found
'; @@ -247,7 +247,7 @@ const cards = await Promise.all(myNodes.map(async (mn) => { try { - const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health'); + const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health', { ttl: 30000 }); const node = h.node || {}; const stats = h.stats || {}; const obs = h.observers || []; @@ -369,7 +369,7 @@ // ==================== STATS ==================== async function loadStats() { try { - const s = await api('/stats'); + const s = await api('/stats', { ttl: 5000 }); const el = document.getElementById('homeStats'); if (!el) return; el.innerHTML = ` @@ -391,7 +391,7 @@ if (journey) journey.classList.remove('visible'); try { - const h = await api('/nodes/' + encodeURIComponent(pubkey) + '/health'); + const h = await api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: 30000 }); const node = h.node || {}; const stats = h.stats || {}; const packets = h.recentPackets || []; diff --git a/public/index.html b/public/index.html index 4c109c76..61920a01 100644 --- a/public/index.html +++ b/public/index.html @@ -76,17 +76,17 @@
- - - - - - - - + + + + + + + + - - + + diff --git a/public/map.js b/public/map.js index a16a3b00..8db793a0 100644 --- a/public/map.js +++ b/public/map.js @@ -245,12 +245,12 @@ async function loadNodes() { try { - const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`); + const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`, { ttl: 10000 }); nodes = data.nodes || []; buildRoleChecks(data.counts || {}); // Load observers for jump buttons - const obsData = await api('/observers'); + const obsData = await api('/observers', { ttl: 30000 }); observers = obsData.observers || []; buildJumpButtons(); diff --git a/public/node-analytics.js b/public/node-analytics.js index e55641ce..d3d0bfc0 100644 --- a/public/node-analytics.js +++ b/public/node-analytics.js @@ -40,7 +40,7 @@ let data; try { - data = await api('/nodes/' + encodeURIComponent(pubkey) + '/analytics?days=' + days); + data = await api('/nodes/' + encodeURIComponent(pubkey) + '/analytics?days=' + days, { ttl: 60000 }); } catch (e) { container.innerHTML = '
Failed to load analytics: ' + escapeHtml(e.message) + '
'; return; diff --git a/public/nodes.js b/public/nodes.js index cdf2b202..488bec4a 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -85,8 +85,8 @@ const body = document.getElementById('nodeFullBody'); try { const [nodeData, healthData] = await Promise.all([ - api('/nodes/' + encodeURIComponent(pubkey)), - api('/nodes/' + encodeURIComponent(pubkey) + '/health').catch(() => null) + api('/nodes/' + encodeURIComponent(pubkey), { ttl: 15000 }), + api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: 30000 }).catch(() => null) ]); const n = nodeData.node; const adverts = nodeData.recentAdverts || []; @@ -228,7 +228,7 @@ if (activeTab !== 'all') params.set('role', activeTab); if (search) params.set('search', search); if (lastHeard) params.set('lastHeard', lastHeard); - const data = await api('/nodes?' + params); + const data = await api('/nodes?' + params, { ttl: 10000 }); nodes = data.nodes || []; counts = data.counts || {}; @@ -238,7 +238,7 @@ const missing = myNodes.filter(mn => !existingKeys.has(mn.pubkey)); if (missing.length) { const fetched = await Promise.allSettled( - missing.map(mn => api('/nodes/' + encodeURIComponent(mn.pubkey))) + missing.map(mn => api('/nodes/' + encodeURIComponent(mn.pubkey), { ttl: 15000 })) ); fetched.forEach(r => { if (r.status === 'fulfilled' && r.value && r.value.public_key) nodes.push(r.value); @@ -401,8 +401,8 @@ try { const [data, healthData] = await Promise.all([ - api('/nodes/' + encodeURIComponent(pubkey)), - api('/nodes/' + encodeURIComponent(pubkey) + '/health').catch(() => null) + api('/nodes/' + encodeURIComponent(pubkey), { ttl: 15000 }), + api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: 30000 }).catch(() => null) ]); data.healthData = healthData; renderDetail(panel, data); diff --git a/public/observers.js b/public/observers.js index 3e42dbaa..3448e48b 100644 --- a/public/observers.js +++ b/public/observers.js @@ -38,7 +38,7 @@ async function loadObservers() { try { - const data = await api('/observers'); + const data = await api('/observers', { ttl: 30000 }); observers = data.observers || []; render(); } catch (e) { diff --git a/public/packets.js b/public/packets.js index b5a68e91..120603bb 100644 --- a/public/packets.js +++ b/public/packets.js @@ -207,7 +207,7 @@ async function loadObservers() { try { - const data = await api('/observers'); + const data = await api('/observers', { ttl: 30000 }); observers = data.observers || []; } catch {} }