/* system_metrics.js */ (() => { /* Socket.IO setup */ const socket = io({ transports: ['websocket', 'polling'], reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 3000, reconnectionDelayMax: 60000, timeout: 60000, pingTimeout: 5000, pingInterval: 25000, }); /* Color constants */ const GREEN = [ 39, 174, 96]; // #27ae60 const YELLOW = [243, 156, 18]; // #f39c12 const RED = [192, 57, 43]; // #c0392b /* Helpers */ const hostTimestamps = {}; // keyed by short_id const toRgb = (r, g, b) => `rgb(${r},${g},${b})`; const T20 = 20 * 1000; const T40 = 40 * 1000; const T60 = 60 * 1000; function getFreshnessColor(ageMs) { if (ageMs <= T20) { return toRgb(...GREEN); } if (ageMs <= T40) { const t = (ageMs - T20) / (T40 - T20); const r = Math.round(GREEN[0] + t * (YELLOW[0] - GREEN[0])); const g = Math.round(GREEN[1] + t * (YELLOW[1] - GREEN[1])); const b = Math.round(GREEN[2] + t * (YELLOW[2] - GREEN[2])); return toRgb(r, g, b); } if (ageMs <= T60) { const t = (ageMs - T40) / (T60 - T40); const r = Math.round(YELLOW[0] + t * (RED[0] - YELLOW[0])); const g = Math.round(YELLOW[1] + t * (RED[1] - YELLOW[1])); const b = Math.round(YELLOW[2] + t * (RED[2] - YELLOW[2])); return toRgb(r, g, b); } return toRgb(...RED); } function safeSetText(id, txt) { const el = document.getElementById(id); if (el) el.textContent = txt; } /* Get the short_id from the query string */ function getSelectedId() { return new URLSearchParams(window.location.search).get('host') || ''; } /* Sidebar building - uses short_id for status key */ function buildList(systemList) { const ul = document.getElementById('endpointList'); if (!Array.isArray(systemList)) { ul.innerHTML = ''; // nothing to show return; } /* Sort: servers first, then by IP */ const toInt = ip => { // guard against undefined / empty string if (!ip) return 0; return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0); }; const sorted = [...systemList].sort((a, b) => { // a. Servers go before non-servers const aServer = !!a.is_server; const bServer = !!b.is_server; if (aServer !== bServer) return aServer ? -1 : 1; // true < false // b. Same “is_server” status - fall back to IP sorting const aIp = a.active_ip ?? ''; const bIp = b.active_ip ?? ''; if (!aIp) return 1; // push empty IPs to the end if (!bIp) return -1; return toInt(aIp) - toInt(bIp); }); /* Bail if nothing actually changed */ const current = Array.from(ul.children).map(li => li.dataset.id); const newIds = sorted.map(s => s.short_id); if (arraysEqual(current, newIds)) return; // no visual change needed /* Build the DOM */ const selected = getSelectedId().toLowerCase(); ul.innerHTML = ''; // reset sorted.forEach(item => { const li = document.createElement('li'); // • Status dot const status = document.createElement('span'); status.className = 'host-status'; status.dataset.id = item.short_id; // • Link - display hostname, encode short_id in URL const a = document.createElement('a'); a.href = '?host=' + encodeURIComponent(item.short_id); a.textContent = item.hostname; a.title = item.active_ip ? `Active IP: ${item.active_ip}` : ''; if (item.short_id.toLowerCase() === selected) a.classList.add('active'); li.appendChild(status); li.appendChild(a); ul.appendChild(li); }); } /* Update status colors every second */ function updateStatusColors() { const nowSec = Date.now() / 1000; Object.entries(hostTimestamps).forEach(([id, ts]) => { const ageMs = (nowSec - ts) * 1000; const color = getFreshnessColor(ageMs); const span = document.querySelector( `.host-status[data-id="${id}"]` ); if (span) span.style.backgroundColor = color; }); } setInterval(updateStatusColors, 1000); /* Utility helpers */ function arraysEqual(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } function renderGenericTable(containerId, data, emptyMsg) { const container = document.getElementById(containerId); if (!Array.isArray(data) || !data.length) { container.textContent = emptyMsg; return; } const merged = mergeRowsByName(data); const ordered = orderRows(merged); const table = buildTable(ordered); table.id = 'host_metrics_table'; container.innerHTML = ''; container.appendChild(table); } function mergeRowsByName(rows) { const groups = {}; // { Source: { Metric: [], Data: [] } } rows.forEach(r => { const src = r.Source; if (!src) return; if (!groups[src]) groups[src] = { Metric: [], Data: [] }; if ('Metric' in r && 'Data' in r) { groups[src].Metric.push(r.Metric); groups[src].Data.push(r.Data); } }); return Object.entries(groups).map(([src, g]) => ({ Source: src, Metric: g.Metric, Data: g.Data, })); } function orderRows(rows) { const priority = ['System', 'CPU', 'RAM']; const map = {}; priority.forEach((s, i) => map[s] = i); return [...rows].sort((a, b) => { const ai = map.hasOwnProperty(a.Source) ? map[a.Source] : Infinity; const bi = map.hasOwnProperty(b.Source) ? map[b.Source] : Infinity; return ai - bi; }); } function buildTable(rows) { const cols = ['Source', 'Metric', 'Data']; const table = document.createElement('table'); // Header const thead = table.createTHead(); const headerRow = thead.insertRow(); cols.forEach(col => { const th = document.createElement('th'); th.textContent = col; headerRow.appendChild(th); }); // Body const tbody = table.createTBody(); rows.forEach(item => { const tr = tbody.insertRow(); cols.forEach(col => { const td = tr.insertCell(); const val = item[col]; if (Array.isArray(val)) { val.forEach((v, i) => { const span = document.createElement('span'); span.textContent = v; td.appendChild(span); if (i < val.length - 1) td.appendChild(document.createElement('br')); }); } else { td.textContent = val !== undefined ? val : ''; } }); }); return table; } /* Handle incoming data */ let lastUpdate = Date.now(); function handleSummary(raw) { lastUpdate = Date.now(); // reset watchdog let payload; if (typeof raw === 'string') { try { payload = JSON.parse(raw); } catch (e) { safeSetText('client_summary', 'Invalid data received'); return; } } else payload = raw; if (!Array.isArray(payload) || !payload.length) { safeSetText('client_summary', 'No data available'); return; } // Build the list first (so elements exist) buildList(payload); // Store the timestamp for every short_id payload.forEach(hostObj => { if (hostObj.short_id && hostObj.data_timestamp) { hostTimestamps[hostObj.short_id] = hostObj.data_timestamp; // seconds } }); // Immediately update colors for the current view updateStatusColors(); // Metric table for selected host const selectedId = getSelectedId(); const hostObj = payload.find(h => h.short_id === selectedId) || payload[0]; const hostData = hostObj && Array.isArray(hostObj.redis_data) ? hostObj.redis_data : []; renderGenericTable('host_metrics', hostData, 'No Stats available'); } /* Socket event wiring */ socket.on('client_summary', handleSummary); socket.on('connect', () => { safeSetText('client_summary', 'Connected'); requestSummary(); }); socket.on('disconnect', () => { safeSetText('client_summary', 'Disconnected - retrying...'); }); socket.on('reconnect', attempt => { safeSetText('client_summary', `Re-connected (attempt ${attempt})`); requestSummary(); }); /* Request logic */ function requestSummary() { if (!socket.connected) return; // guard against stale emits socket.emit('get_client_summary'); // server will reply via client_summary } /* Recursive polling */ let pollTimer = null; function pollLoop() { if (!socket.connected) return; requestSummary(); pollTimer = setTimeout(pollLoop, 5000); } socket.on('connect', () => { if (!pollTimer) pollLoop(); }); /* Watchdog - force reconnect if no data for 15 s */ function watchdog() { if (Date.now() - lastUpdate > 15000 && socket.connected) { safeSetText('client_summary', 'No updates - reconnecting...'); socket.disconnect(); // forces a reconnect cycle } setTimeout(watchdog, 5000); } watchdog(); /* Keep the 'active' link in sync when the URL changes */ window.addEventListener('popstate', () => { const selected = getSelectedId().toLowerCase(); document.querySelectorAll('#endpointList a').forEach(a => a.classList.toggle('active', a.href.includes('host=' + encodeURIComponent(selected))) ); }); })();