diff --git a/public/channels.js b/public/channels.js index 6e6c8fbe..41f8236b 100644 --- a/public/channels.js +++ b/public/channels.js @@ -9,9 +9,14 @@ let autoScroll = true; let nodeCache = {}; let selectedNode = null; + var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes async function lookupNode(name) { - if (nodeCache[name] !== undefined) return nodeCache[name]; + var cached = nodeCache[name]; + if (cached !== undefined) { + if (cached && cached.fetchedAt && (Date.now() - cached.fetchedAt < _nodeCacheTTL)) return cached.data; + if (cached && !cached.fetchedAt) return cached; // legacy null entries + } try { const data = await api('/nodes/search?q=' + encodeURIComponent(name)); // Try exact match first, then case-insensitive, then contains @@ -20,7 +25,7 @@ || nodes.find(n => n.name && n.name.toLowerCase() === name.toLowerCase()) || nodes.find(n => n.name && n.name.toLowerCase().includes(name.toLowerCase())) || nodes[0] || null; - nodeCache[name] = match; + nodeCache[name] = { data: match, fetchedAt: Date.now() }; return match; } catch { nodeCache[name] = null; return null; } } diff --git a/public/observers.js b/public/observers.js index 8c3ce74c..0aa72bc8 100644 --- a/public/observers.js +++ b/public/observers.js @@ -11,7 +11,7 @@
Loading…
`; @@ -47,11 +47,14 @@ } } + // NOTE: Comparing server timestamps to Date.now() can skew if client/server + // clocks differ. We add ±30s tolerance to thresholds to reduce false positives. function healthStatus(lastSeen) { if (!lastSeen) return { cls: 'health-red', label: 'Unknown' }; const ago = Date.now() - new Date(lastSeen).getTime(); - if (ago < 600000) return { cls: 'health-green', label: 'Online' }; // < 10 min - if (ago < 3600000) return { cls: 'health-yellow', label: 'Stale' }; // < 1 hour + const tolerance = 30000; // 30s tolerance for clock skew + if (ago < 600000 + tolerance) return { cls: 'health-green', label: 'Online' }; // < 10 min + tolerance + if (ago < 3600000 + tolerance) return { cls: 'health-yellow', label: 'Stale' }; // < 1 hour + tolerance return { cls: 'health-red', label: 'Offline' }; } @@ -66,9 +69,10 @@ } function sparkBar(count, max) { - if (max === 0) return '
'; + const aria = `role="meter" aria-valuenow="${count}" aria-valuemin="0" aria-valuemax="${max}" aria-label="Packet rate"`; + if (max === 0) return `
`; const pct = Math.min(100, Math.round((count / max) * 100)); - return `
${count}/hr
`; + return `
${count}/hr
`; } function render() { @@ -95,6 +99,7 @@ 📡 ${observers.length} Total + diff --git a/public/style.css b/public/style.css index 152670f4..f467f00e 100644 --- a/public/style.css +++ b/public/style.css @@ -672,7 +672,8 @@ button.ch-item.selected { background: var(--selected-bg); } .health-dot.health-yellow { background: #eab308; box-shadow: 0 0 6px #eab30880; } .health-dot.health-red { background: #ef4444; box-shadow: 0 0 6px #ef444480; } .obs-table td:first-child { white-space: nowrap; } -.spark-bar { position: relative; width: 100px; height: 18px; background: var(--border); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; } +.spark-bar { position: relative; min-width: 60px; max-width: 100px; flex: 1; height: 18px; background: var(--border); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; } +@media (max-width: 640px) { .spark-bar { max-width: 60px; } } .spark-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #60a5fa); border-radius: 4px; transition: width 0.3s; } .spark-label { position: absolute; right: 4px; top: 0; line-height: 18px; font-size: 11px; color: var(--text); font-weight: 500; } diff --git a/server.js b/server.js index 1ca36d11..ffe981bc 100644 --- a/server.js +++ b/server.js @@ -1250,7 +1250,7 @@ app.get('/api/observers', (req, res) => { const lastHour = db.db.prepare(`SELECT COUNT(*) as count FROM packets WHERE observer_id = ? AND timestamp > ?`).get(o.id, oneHourAgo); return { ...o, packetsLastHour: lastHour.count }; }); - res.json({ observers: result }); + res.json({ observers: result, server_time: new Date().toISOString() }); }); app.get('/api/traces/:hash', (req, res) => {
Observer status and statistics
StatusNameRegionLast Seen PacketsPackets/HourUptime