/* === CoreScope β€” observers.js === */ 'use strict'; (function () { let observers = []; let obsSkewMap = {}; // observerID β†’ {offsetSec, samples} let wsHandler = null; let refreshTimer = null; let regionChangeHandler = null; function init(app) { app.innerHTML = `
Loading…
`; RegionFilter.init(document.getElementById('obsRegionFilter')); regionChangeHandler = RegionFilter.onChange(function () { render(); }); loadObservers(); // Event delegation for data-action buttons app.addEventListener('click', function (e) { var btn = e.target.closest('[data-action]'); if (btn && btn.dataset.action === 'obs-refresh') loadObservers(); var row = e.target.closest('tr[data-action="navigate"]'); if (row) { // #1056 AC#4: at narrow widths, open detail in slide-over instead of // navigating to a separate page. if (window.SlideOver && window.SlideOver.shouldUse()) { e.preventDefault(); openObserverSlideOver(row.dataset.value); return; } location.hash = row.dataset.value; } }); // #209 β€” Keyboard accessibility for observer rows app.addEventListener('keydown', function (e) { var row = e.target.closest('tr[data-action="navigate"]'); if (!row) return; if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); if (window.SlideOver && window.SlideOver.shouldUse()) { openObserverSlideOver(row.dataset.value); return; } location.hash = row.dataset.value; }); // Auto-refresh every 30s refreshTimer = setInterval(loadObservers, 30000); wsHandler = debouncedOnWS(function (msgs) { if (msgs.some(function (m) { return m.type === 'packet'; })) loadObservers(); }); } function destroy() { if (wsHandler) offWS(wsHandler); wsHandler = null; if (refreshTimer) clearInterval(refreshTimer); refreshTimer = null; if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler); regionChangeHandler = null; observers = []; obsSkewMap = {}; } async function loadObservers() { try { const [data, skewData] = await Promise.all([ api('/observers', { ttl: CLIENT_TTL.observers }), api('/observers/clock-skew', { ttl: 30000 }).catch(function() { return []; }) ]); observers = data.observers || []; obsSkewMap = {}; (Array.isArray(skewData) ? skewData : []).forEach(function(s) { if (s && s.observerID) obsSkewMap[s.observerID] = s; }); render(); } catch (e) { document.getElementById('obsContent').innerHTML = ``; } } // 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(); 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' }; } function packetBadge(o) { if (!o.last_packet_at) return 'πŸ“‘βš  never'; const pktAgo = Date.now() - new Date(o.last_packet_at).getTime(); const statusAgo = o.last_seen ? Date.now() - new Date(o.last_seen).getTime() : Infinity; const gap = pktAgo - statusAgo; if (gap > 600000) { return `πŸ“‘βš  ${timeAgo(o.last_packet_at)}`; } return timeAgo(o.last_packet_at); } function uptimeStr(firstSeen) { if (!firstSeen) return 'β€”'; const ms = Date.now() - new Date(firstSeen).getTime(); const d = Math.floor(ms / 86400000); const h = Math.floor((ms % 86400000) / 3600000); if (d > 0) return `${d}d ${h}h`; const m = Math.floor((ms % 3600000) / 60000); return h > 0 ? `${h}h ${m}m` : `${m}m`; } function sparkBar(count, max) { if (max === 0) return `0/hr`; const pct = Math.min(100, Math.round((count / max) * 100)); return `${count}/hr`; } function render() { const el = document.getElementById('obsContent'); if (!el) return; // Apply region filter const selectedRegions = RegionFilter.getSelected(); const filtered = selectedRegions ? observers.filter(o => o.iata && selectedRegions.includes(o.iata)) : observers; if (filtered.length === 0) { el.innerHTML = '
No observers found.
'; return; } const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0)); // Summary counts const online = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-green').length; const stale = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length; const offline = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-red').length; el.innerHTML = `
● ${online} Online β–² ${stale} Stale βœ• ${offline} Offline πŸ“‘ ${filtered.length} Total
${filtered.map(o => { const h = healthStatus(o.last_seen); const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? 'β–²' : 'βœ•'; return ``; }).join('')}
Observer status and statistics
StatusNameRegionLast StatusLast Packet Packet HealthTotal PacketsPackets/HourClock OffsetUptime
${shape} ${h.label} ${o.name || o.id} ${o.iata ? `${o.iata}` : 'β€”'} ${timeAgo(o.last_seen)} ${o.last_packet_at ? timeAgo(o.last_packet_at) : 'β€”'} ${packetBadge(o)} ${(o.packet_count || 0).toLocaleString()} ${sparkBar(o.packetsLastHour || 0, maxPktsHr)} ${(function() { var sk = obsSkewMap[o.id]; if (!sk || sk.samples == null || sk.samples === 0) return 'β€”'; var sev = observerSkewSeverity(sk.offsetSec); return renderSkewBadge(sev, sk.offsetSec) + ' (' + sk.samples + ')'; })()} ${uptimeStr(o.first_seen)}
`; makeColumnsResizable('#obsTable', 'meshcore-obs-col-widths'); // #1056: fluid columns + +N hidden pill if (window.TableResponsive) { var _obsTbl = document.getElementById('obsTable'); if (_obsTbl) window.TableResponsive.register(_obsTbl); } } registerPage('observers', { init, destroy }); // #1056 AC#4: row-detail slide-over (narrow viewports). Renders a compact // summary from the in-memory observer + a link to the full page. function openObserverSlideOver(hashHref) { if (!window.SlideOver) return; var m = String(hashHref || '').match(/#\/observers\/(.+)$/); if (!m) return; var id = decodeURIComponent(m[1]); var o = (observers || []).find(function (x) { return String(x.id) === id; }); if (!o) return; var h = healthStatus(o.last_seen); var sk = obsSkewMap[o.id]; var skewLine = (sk && sk.samples) ? renderSkewBadge(observerSkewSeverity(sk.offsetSec), sk.offsetSec) + ' (' + sk.samples + ' samples)' : 'β€”'; var pkts = sparkBar(o.packetsLastHour || 0, Math.max(1, o.packetsLastHour || 1)); var content = window.SlideOver.open({ title: o.name || o.id }); content.innerHTML = '
' + '
Status
● ' + h.label + '
' + '
Region
' + (o.iata ? '' + o.iata + '' : 'β€”') + '
' + '
Last status
' + timeAgo(o.last_seen) + '
' + '
Last packet
' + (o.last_packet_at ? timeAgo(o.last_packet_at) : 'β€”') + '
' + '
Total packets
' + (o.packet_count || 0).toLocaleString() + '
' + '
Packets/hr
' + pkts + '
' + '
Clock offset
' + skewLine + '
' + '
Uptime
' + uptimeStr(o.first_seen) + '
' + '
' + '

Open full detail β†’

'; } })();