272 lines
10 KiB
JavaScript
272 lines
10 KiB
JavaScript
/* ==============================================================
|
||
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');
|
||
const current = Array.from(ul.children).map(li => li.dataset.id);
|
||
const newIds = systemList.map(s => s.short_id);
|
||
if (arraysEqual(current, newIds)) return;
|
||
const selected = getSelectedId().toLowerCase();
|
||
ul.innerHTML = ''; // reset list
|
||
systemList.forEach(item => {
|
||
const li = document.createElement('li');
|
||
// status dot - keyed by short_id
|
||
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;
|
||
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 <span> 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)))
|
||
);
|
||
});
|
||
})(); |