perf: configurable cache TTLs via config.json — server + client fetch from /api/config/cache

All cache TTLs now read from config.json cacheTTL section (seconds).
Client fetches config on load via GET /api/config/cache.
config.example.json updated with defaults.
Edit config.json, restart server — no code changes needed to tweak TTLs.
This commit is contained in:
you
2026-03-20 03:23:58 +00:00
parent 720d019a28
commit de658bfb0d
12 changed files with 123 additions and 60 deletions
+21
View File
@@ -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."
}
}
+13 -13
View File
@@ -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 @@
</div>
`;
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 = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
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 = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
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 = `<div class="text-muted">Error: ${e.message}</div>`;
@@ -1141,9 +1141,9 @@
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading node analytics…</div>';
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') || '[]');
+17 -2
View File
@@ -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 = '<div class="fav-dd-loading">Loading...</div>';
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 '<a href="#/nodes/' + pk + '" class="fav-dd-item" data-key="' + pk + '">'
@@ -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 = `<span class="stat-val">${stats.totalPackets}</span> pkts · <span class="stat-val">${stats.totalNodes}</span> nodes · <span class="stat-val">${stats.totalObservers}</span> obs`;
+5 -5
View File
@@ -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 = '<div class="ch-loading">Loading messages…</div>';
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 || '') : ''; };
+4 -4
View File
@@ -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 = '<div class="suggest-empty">No nodes found</div>';
@@ -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 || [];
+9 -9
View File
@@ -76,17 +76,17 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="app.js?v=1773976827"></script>
<script src="home.js?v=1773976827"></script>
<script src="packets.js?v=1773976827"></script>
<script src="map.js?v=1773976827" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1773976827" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1773976827" onerror="console.error('Failed to load:', this.src)"></script>
<script src="app.js?v=1773977027"></script>
<script src="home.js?v=1773977027"></script>
<script src="packets.js?v=1773977027"></script>
<script src="map.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1773972187" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1773976827" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1773964458" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1773976827" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1773976827" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1773977027" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1773972627" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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 = '<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load analytics: ' + escapeHtml(e.message) + '</div>';
return;
+6 -6
View File
@@ -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);
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 {}
}
+43 -16
View File
@@ -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);
});