diff --git a/config.example.json b/config.example.json index b26e5ac2..6774ce00 100644 --- a/config.example.json +++ b/config.example.json @@ -22,5 +22,26 @@ "OAK": "Oakland, US", "MRY": "Monterey, US", "LAR": "Los Angeles, US" + }, + "cacheTTL": { + "stats": 10, + "nodeDetail": 300, + "nodeHealth": 300, + "nodeList": 90, + "bulkHealth": 600, + "networkStatus": 600, + "observers": 300, + "channels": 15, + "channelMessages": 10, + "analyticsRF": 1800, + "analyticsTopology": 1800, + "analyticsChannels": 1800, + "analyticsHashSizes": 3600, + "analyticsSubpaths": 3600, + "analyticsSubpathDetail": 3600, + "nodeAnalytics": 60, + "nodeSearch": 10, + "invalidationDebounce": 30, + "_comment": "All values in seconds. Server uses these directly. Client fetches via /api/config/cache." } } \ No newline at end of file diff --git a/public/analytics.js b/public/analytics.js index 6e689f2c..6263da1c 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', { ttl: 300000 }), - api('/analytics/rf', { ttl: 300000 }), - api('/analytics/topology', { ttl: 300000 }), - api('/analytics/channels', { ttl: 300000 }), + api('/analytics/hash-sizes', { ttl: CLIENT_TTL.analyticsRF }), + api('/analytics/rf', { ttl: CLIENT_TTL.analyticsRF }), + api('/analytics/topology', { ttl: CLIENT_TTL.analyticsRF }), + api('/analytics/channels', { ttl: CLIENT_TTL.analyticsRF }), ]); _analyticsData = { hashData, rfData, topoData, chanData }; renderTab('overview'); @@ -747,7 +747,7 @@ `; let allNodes = []; - try { const nd = await api('/nodes?limit=2000', { ttl: 90000 }); allNodes = nd.nodes || []; } catch {} + try { const nd = await api('/nodes?limit=2000', { ttl: CLIENT_TTL.nodeList }); 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', { ttl: 300000 }), - api('/analytics/subpaths?minLen=3&maxLen=3&limit=30', { ttl: 300000 }), - api('/analytics/subpaths?minLen=4&maxLen=4&limit=20', { ttl: 300000 }), - api('/analytics/subpaths?minLen=5&maxLen=8&limit=15', { ttl: 300000 }) + api('/analytics/subpaths?minLen=2&maxLen=2&limit=50', { ttl: CLIENT_TTL.analyticsRF }), + api('/analytics/subpaths?minLen=3&maxLen=3&limit=30', { ttl: CLIENT_TTL.analyticsRF }), + api('/analytics/subpaths?minLen=4&maxLen=4&limit=20', { ttl: CLIENT_TTL.analyticsRF }), + api('/analytics/subpaths?minLen=5&maxLen=8&limit=15', { ttl: CLIENT_TTL.analyticsRF }) ]); 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), { ttl: 300000 }); + const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: CLIENT_TTL.analyticsRF }); 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', { ttl: 90000 }), - api('/nodes/bulk-health?limit=50', { ttl: 300000 }), - api('/nodes/network-status', { ttl: 300000 }) + api('/nodes?limit=200&sortBy=lastSeen', { ttl: CLIENT_TTL.nodeList }), + api('/nodes/bulk-health?limit=50', { ttl: CLIENT_TTL.analyticsRF }), + api('/nodes/network-status', { ttl: CLIENT_TTL.analyticsRF }) ]); 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 40a3d8cb..08d570f0 100644 --- a/public/app.js +++ b/public/app.js @@ -14,6 +14,21 @@ function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; } const _apiPerf = { calls: 0, totalMs: 0, log: [], cacheHits: 0 }; const _apiCache = new Map(); const _inflight = new Map(); +// Client-side TTLs (ms) — loaded from server config, with defaults +const CLIENT_TTL = { + stats: 10000, nodeDetail: 240000, nodeHealth: 240000, nodeList: 90000, + bulkHealth: 300000, networkStatus: 300000, observers: 120000, + channels: 15000, channelMessages: 10000, analyticsRF: 300000, + analyticsTopology: 300000, analyticsChannels: 300000, analyticsHashSizes: 300000, + analyticsSubpaths: 300000, analyticsSubpathDetail: 300000, + nodeAnalytics: 60000, nodeSearch: 10000 +}; +// Fetch server cache config and use as client TTLs (server values are in seconds) +fetch('/api/config/cache').then(r => r.json()).then(cfg => { + for (const [k, v] of Object.entries(cfg)) { + if (k in CLIENT_TTL && typeof v === 'number') CLIENT_TTL[k] = v * 1000; + } +}).catch(() => {}); async function api(path, { ttl = 0, bust = false } = {}) { const t0 = performance.now(); if (!bust && ttl > 0) { @@ -356,7 +371,7 @@ window.addEventListener('DOMContentLoaded', () => { favDropdown.innerHTML = '
Loading...
'; const items = await Promise.all(favs.map(async (pk) => { try { - const h = await api('/nodes/' + pk + '/health', { ttl: 240000 }); + const h = await api('/nodes/' + pk + '/health', { ttl: CLIENT_TTL.nodeHealth }); const age = h.stats.lastHeard ? Date.now() - new Date(h.stats.lastHeard).getTime() : null; const status = age === null ? '🔴' : age < 3600000 ? '🟢' : age < 86400000 ? '🟡' : '🔴'; return '' @@ -460,7 +475,7 @@ window.addEventListener('DOMContentLoaded', () => { // --- Nav Stats --- async function updateNavStats() { try { - const stats = await api('/stats', { ttl: 10000 }); + const stats = await api('/stats', { ttl: CLIENT_TTL.stats }); 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 a791def2..5731a9d8 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), { ttl: 10000 }); + const data = await api('/nodes/search?q=' + encodeURIComponent(name), { ttl: CLIENT_TTL.channelMessages }); // 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), { ttl: 240000 }); + const detail = await api('/nodes/' + encodeURIComponent(node.public_key), { ttl: CLIENT_TTL.nodeDetail }); 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', { ttl: 15000 }); + const data = await api('/channels', { ttl: CLIENT_TTL.channels }); 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`, { ttl: 10000 }); + const data = await api(`/channels/${hash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages }); 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`, { ttl: 10000 }); + const data = await api(`/channels/${selectedHash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages }); 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 98dd51a1..c3d5bac7 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), { ttl: 10000 }); + const data = await api('/nodes/search?q=' + encodeURIComponent(q), { ttl: CLIENT_TTL.nodeSearch }); 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', { ttl: 240000 }); + const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth }); 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', { ttl: 10000 }); + const s = await api('/stats', { ttl: CLIENT_TTL.nodeSearch }); 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', { ttl: 240000 }); + const h = await api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth }); const node = h.node || {}; const stats = h.stats || {}; const packets = h.recentPackets || []; diff --git a/public/index.html b/public/index.html index 32b5a90c..ea4c1657 100644 --- a/public/index.html +++ b/public/index.html @@ -76,17 +76,17 @@
- - - - - - + + + + + + - + - - + + diff --git a/public/map.js b/public/map.js index 8d1f4455..40511311 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}`, { ttl: 10000 }); + const data = await api(`/nodes?limit=10000&lastHeard=${filters.lastHeard}`, { ttl: CLIENT_TTL.nodeList }); nodes = data.nodes || []; buildRoleChecks(data.counts || {}); // Load observers for jump buttons - const obsData = await api('/observers', { ttl: 240000 }); + const obsData = await api('/observers', { ttl: CLIENT_TTL.observers }); observers = obsData.observers || []; buildJumpButtons(); diff --git a/public/node-analytics.js b/public/node-analytics.js index d3d0bfc0..20d9a8e6 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, { ttl: 60000 }); + data = await api('/nodes/' + encodeURIComponent(pubkey) + '/analytics?days=' + days, { ttl: CLIENT_TTL.nodeAnalytics }); } catch (e) { container.innerHTML = '
Failed to load analytics: ' + escapeHtml(e.message) + '
'; return; diff --git a/public/nodes.js b/public/nodes.js index c784fd61..12c87c06 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), { ttl: 240000 }), - api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: 240000 }).catch(() => null) + api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }), + api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).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, { ttl: 90000 }); + const data = await api('/nodes?' + params, { ttl: CLIENT_TTL.nodeList }); 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), { ttl: 240000 })) + missing.map(mn => api('/nodes/' + encodeURIComponent(mn.pubkey), { ttl: CLIENT_TTL.nodeDetail })) ); 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), { ttl: 240000 }), - api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: 240000 }).catch(() => null) + api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }), + api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null) ]); data.healthData = healthData; renderDetail(panel, data); diff --git a/public/observers.js b/public/observers.js index 2c62a9a4..ed7a50dd 100644 --- a/public/observers.js +++ b/public/observers.js @@ -38,7 +38,7 @@ async function loadObservers() { try { - const data = await api('/observers', { ttl: 120000 }); + const data = await api('/observers', { ttl: CLIENT_TTL.observers }); observers = data.observers || []; render(); } catch (e) { diff --git a/public/packets.js b/public/packets.js index 5a2aa346..44d8e2c3 100644 --- a/public/packets.js +++ b/public/packets.js @@ -207,7 +207,7 @@ async function loadObservers() { try { - const data = await api('/observers', { ttl: 240000 }); + const data = await api('/observers', { ttl: CLIENT_TTL.observers }); observers = data.observers || []; } catch {} } diff --git a/server.js b/server.js index 48e8c953..7e8cf0a6 100644 --- a/server.js +++ b/server.js @@ -28,6 +28,29 @@ function computeContentHash(rawHex) { const db = require('./db'); const channelKeys = require("./config.json").channelKeys || {}; +// --- Cache TTL config (seconds → ms) --- +const _ttlCfg = config.cacheTTL || {}; +const TTL = { + stats: (_ttlCfg.stats || 10) * 1000, + nodeDetail: (_ttlCfg.nodeDetail || 300) * 1000, + nodeHealth: (_ttlCfg.nodeHealth || 300) * 1000, + nodeList: (_ttlCfg.nodeList || 90) * 1000, + bulkHealth: (_ttlCfg.bulkHealth || 600) * 1000, + networkStatus: (_ttlCfg.networkStatus || 600) * 1000, + observers: (_ttlCfg.observers || 300) * 1000, + channels: (_ttlCfg.channels || 15) * 1000, + channelMessages: (_ttlCfg.channelMessages || 10) * 1000, + analyticsRF: (_ttlCfg.analyticsRF || 1800) * 1000, + analyticsTopology: (_ttlCfg.analyticsTopology || 1800) * 1000, + analyticsChannels: (_ttlCfg.analyticsChannels || 1800) * 1000, + analyticsHashSizes: (_ttlCfg.analyticsHashSizes || 3600) * 1000, + analyticsSubpaths: (_ttlCfg.analyticsSubpaths || 3600) * 1000, + analyticsSubpathDetail: (_ttlCfg.analyticsSubpathDetail || 3600) * 1000, + nodeAnalytics: (_ttlCfg.nodeAnalytics || 60) * 1000, + nodeSearch: (_ttlCfg.nodeSearch || 10) * 1000, + invalidationDebounce: (_ttlCfg.invalidationDebounce || 30) * 1000, +}; + // --- TTL Cache --- class TTLCache { constructor() { this.store = new Map(); this.hits = 0; this.misses = 0; } @@ -46,9 +69,8 @@ class TTLCache { if (key.startsWith(prefix)) this.store.delete(key); } } - // Debounced invalidation — wait for burst of packets to settle debouncedInvalidateAll() { - if (this._debounceTimer) return; // already scheduled + if (this._debounceTimer) return; this._debounceTimer = setTimeout(() => { this._debounceTimer = null; this.invalidate('analytics:'); @@ -57,7 +79,7 @@ class TTLCache { this.invalidate('health:'); this.invalidate('observers'); this.invalidate('bulk-health'); - }, 30000); // batch invalidations over 30s window + }, TTL.invalidationDebounce); } clear() { this.store.clear(); } get size() { return this.store.size; } @@ -109,6 +131,11 @@ app.use((req, res, next) => { next(); }); +// Expose cache TTL config to frontend +app.get('/api/config/cache', (req, res) => { + res.json(config.cacheTTL || {}); +}); + app.get('/api/perf', (req, res) => { const summary = {}; for (const [path, ep] of Object.entries(perfStats.endpoints)) { @@ -700,7 +727,7 @@ app.get('/api/nodes/bulk-health', (req, res) => { todayStart.setUTCHours(0, 0, 0, 0); const todayISO = todayStart.toISOString(); - if (nodes.length === 0) { cache.set(_ck, [], 600000); return res.json([]); } + if (nodes.length === 0) { cache.set(_ck, [], TTL.bulkHealth); return res.json([]); } // Build OR conditions for all nodes to fetch matching packets in ONE query const likeConditions = []; @@ -770,7 +797,7 @@ app.get('/api/nodes/bulk-health', (req, res) => { }); } - cache.set(_ck, results, 600000); + cache.set(_ck, results, TTL.bulkHealth); res.json(results); }); @@ -802,7 +829,7 @@ app.get('/api/nodes/:pubkey', (req, res) => { const recentAdverts = node.recentPackets || []; delete node.recentPackets; const _nResult = { node, recentAdverts }; - cache.set(_ck, _nResult, 300000); + cache.set(_ck, _nResult, TTL.nodeDetail); res.json(_nResult); }); @@ -880,7 +907,7 @@ app.get('/api/analytics/rf', (req, res) => { avgPacketSize: packetSizes.length ? Math.round(packetSizes.reduce((a, b) => a + b, 0) / packetSizes.length) : 0, packetsPerHour, payloadTypes, snrByType: snrByTypeArr, signalOverTime, scatterData, timeSpanHours }; - cache.set('analytics:rf', _rfResult, 1800000); + cache.set('analytics:rf', _rfResult, TTL.analyticsRF); res.json(_rfResult); }); @@ -1046,7 +1073,7 @@ app.get('/api/analytics/topology', (req, res) => { multiObsNodes, bestPathList }; - cache.set('analytics:topology', _topoResult, 1800000); + cache.set('analytics:topology', _topoResult, TTL.analyticsTopology); res.json(_topoResult); }); @@ -1109,7 +1136,7 @@ app.get('/api/analytics/channels', (req, res) => { channelTimeline, msgLengths }; - cache.set('analytics:channels', _chanResult, 1800000); + cache.set('analytics:channels', _chanResult, TTL.analyticsChannels); res.json(_chanResult); }); @@ -1201,7 +1228,7 @@ app.get('/api/analytics/hash-sizes', (req, res) => { topHops, multiByteNodes }; - cache.set('analytics:hash-sizes', _hsResult, 3600000); + cache.set('analytics:hash-sizes', _hsResult, TTL.analyticsHashSizes); res.json(_hsResult); }); @@ -1412,7 +1439,7 @@ app.get('/api/channels', (req, res) => { } const _chResult = { channels: Object.values(channelMap) }; - cache.set('channels', _chResult, 30000); + cache.set('channels', _chResult, TTL.channels); res.json(_chResult); }); @@ -1479,7 +1506,7 @@ app.get('/api/channels/:hash/messages', (req, res) => { const end = total - Number(offset); const messages = allMessages.slice(Math.max(0, start), Math.max(0, end)); const _msgResult = { messages, total }; - cache.set(_ck, _msgResult, 15000); + cache.set(_ck, _msgResult, TTL.channelMessages); res.json(_msgResult); }); @@ -1492,7 +1519,7 @@ app.get('/api/observers', (req, res) => { return { ...o, packetsLastHour: lastHour.count }; }); const _oResult = { observers: result, server_time: new Date().toISOString() }; - cache.set('observers', _oResult, 300000); + cache.set('observers', _oResult, TTL.observers); res.json(_oResult); }); @@ -1507,7 +1534,7 @@ app.get('/api/nodes/:pubkey/health', (req, res) => { const _c = cache.get(_ck); if (_c) return res.json(_c); const health = db.getNodeHealth(req.params.pubkey); if (!health) return res.status(404).json({ error: 'Not found' }); - cache.set(_ck, health, 300000); + cache.set(_ck, health, TTL.nodeHealth); res.json(health); }); @@ -1574,7 +1601,7 @@ app.get('/api/analytics/subpaths', (req, res) => { .slice(0, limit); const _spResult = { subpaths: ranked, totalPaths }; - cache.set(_ck, _spResult, 3600000); + cache.set(_ck, _spResult, TTL.analyticsSubpaths); res.json(_spResult); }); @@ -1656,7 +1683,7 @@ app.get('/api/analytics/subpath-detail', (req, res) => { parentPaths: topParents, observers: topObservers }; - cache.set(_sdck, _sdResult, 3600000); + cache.set(_sdck, _sdResult, TTL.analyticsSubpathDetail); res.json(_sdResult); });