Files
heartbeat/hbd/server/templates/plugins.html
T
andreas 3301dbfe34 feat: owner Update/Delete buttons on Host Overview; purge stale alerts on reload
Host Overview (plugins.html): show Update and Delete buttons in the
host-right zone when the logged-in user is the host owner (or admin /
unauthenticated mode). Buttons link to /u?h=<host> and /d?h=<host>
with stopPropagation so they don't toggle the accordion; Delete prompts
for confirmation first.

ThresholdChecker.purge_stale_alerts(): removes alert states whose
metric_path has no matching threshold in the current config. Called
after startup pickle restore and after every SIGHUP config reload so
alerts orphaned by upgrades or config changes do not persist
indefinitely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 08:03:46 -04:00

1210 lines
47 KiB
HTML

<!DOCTYPE html>
<html>
{% include 'head.html' %}
<style>
body { overflow: hidden; }
.container {
max-width: 1400px;
margin: 0 auto;
max-height: calc(100vh - 120px);
overflow-y: auto;
padding-right: 10px;
}
h1 {
color: #333;
margin-bottom: 5px;
margin-top: 15px;
font-size: 1.5em;
}
.subtitle {
color: #666;
margin-bottom: 15px;
font-size: 0.9em;
}
/* ── Host cards ─────────────────────────────────────────────── */
.host-card {
background: white;
border-radius: 6px;
padding: 0;
margin-bottom: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.host-header {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
padding: 10px 15px;
border-radius: 6px;
}
.host-header:hover { background: #f9f9f9; border-radius: 6px 6px 0 0; }
.host-card.collapsed .host-header:hover { border-radius: 6px; }
.host-left {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.collapse-icon {
font-size: 1em;
color: #888;
transition: transform 0.2s;
min-width: 16px;
}
.host-card.collapsed .collapse-icon { transform: rotate(-90deg); }
.host-name {
font-size: 1.05em;
font-weight: bold;
color: #333;
white-space: nowrap;
}
/* ── Glance strip ───────────────────────────────────────────── */
.glance-strip {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
flex-wrap: wrap;
padding: 0 12px;
}
.glance-chip {
font-size: 0.78em;
padding: 2px 9px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap;
background: #e8f5e9;
color: #2e7d32;
}
.glance-chip.warn { background: #fff3e0; color: #e65100; }
.glance-chip.crit { background: #ffebee; color: #b71c1c; }
.glance-chip.neutral { background: #f5f5f5; color: #555; }
.glance-loading { font-size: 0.8em; color: #bbb; font-style: italic; }
/* ── Host right zone ────────────────────────────────────────── */
.host-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.nagios-badge {
font-size: 0.75em;
font-weight: bold;
padding: 2px 10px;
border-radius: 10px;
background: #9e9e9e;
color: white;
text-transform: uppercase;
white-space: nowrap;
}
.nagios-badge.ok { background: #4caf50; }
.nagios-badge.warning { background: #ff9800; }
.nagios-badge.critical { background: #f44336; }
.os-label {
font-size: 0.75em;
color: #999;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.host-action-btn {
font-size: 0.75em;
font-weight: bold;
padding: 3px 10px;
border-radius: 4px;
border: none;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.host-action-btn.update-btn {
background: #e3f2fd;
color: #1565c0;
}
.host-action-btn.update-btn:hover { background: #bbdefb; }
.host-action-btn.delete-btn {
background: #ffebee;
color: #c62828;
}
.host-action-btn.delete-btn:hover { background: #ffcdd2; }
/* ── Host body ──────────────────────────────────────────────── */
.host-body {
padding: 8px 15px 12px;
border-top: 1px solid #f0f0f0;
}
.host-card.collapsed .host-body { display: none; }
/* ── Plugin accordions ──────────────────────────────────────── */
.plugin-accordion {
border: 1px solid #e8e8e8;
border-radius: 4px;
margin-bottom: 5px;
overflow: hidden;
}
.plugin-acc-header {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px;
cursor: pointer;
background: #fafafa;
user-select: none;
}
.plugin-acc-header:hover { background: #f0f4ff; }
.acc-icon {
font-size: 0.7em;
color: #999;
transition: transform 0.15s;
min-width: 12px;
}
.plugin-accordion:not(.collapsed) .acc-icon { transform: rotate(90deg); }
.plugin-label {
font-weight: 600;
font-size: 0.85em;
color: #444;
min-width: 140px;
}
.plugin-summary {
font-size: 0.82em;
color: #888;
flex: 1;
}
.plugin-accordion.collapsed .plugin-acc-body { display: none; }
.plugin-acc-body { padding: 10px 12px; }
/* ── Tables ─────────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85em;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
border-radius: 4px;
overflow: hidden;
margin-bottom: 10px;
}
.data-table thead { background: #2196f3; color: white; }
.data-table th {
padding: 7px 10px;
text-align: left;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75em;
letter-spacing: 0.4px;
}
.data-table th.num { text-align: right; }
.data-table th.center { text-align: center; }
.data-table td {
padding: 6px 10px;
border-top: 1px solid #e8e8e8;
color: #333;
}
.data-table td.num {
text-align: right;
font-family: 'Courier New', monospace;
font-size: 0.95em;
}
.data-table td.center { text-align: center; }
.data-table td.key { color: #666; font-weight: 500; width: 38%; }
.data-table tbody tr:nth-child(even) { background: #fafafa; }
.data-table tbody tr:hover { background: #f0f4ff; }
.iface-name { font-weight: bold; color: #2196f3; }
/* ── Percent bars ───────────────────────────────────────────── */
.bar-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.bar-track {
display: inline-block;
width: 70px;
height: 6px;
background: #e0e0e0;
border-radius: 3px;
vertical-align: middle;
flex-shrink: 0;
}
.bar-fill {
height: 6px;
border-radius: 3px;
background: #4caf50;
max-width: 100%;
}
.bar-fill.warn { background: #ff9800; }
.bar-fill.crit { background: #f44336; }
/* ── Disk two-table layout ──────────────────────────────────── */
.flex-tables {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.flex-tables > div { flex: 1 1 380px; }
.table-section-label {
font-size: 0.78em;
font-weight: 600;
text-transform: uppercase;
color: #888;
letter-spacing: 0.4px;
margin-bottom: 4px;
}
/* ── Status / misc ──────────────────────────────────────────── */
.status-up { color: #4caf50; font-weight: bold; }
.status-down { color: #f44336; font-weight: bold; }
.pct-ok { color: #2e7d32; font-weight: bold; }
.pct-warn { color: #e65100; font-weight: bold; }
.pct-crit { color: #b71c1c; font-weight: bold; }
.check-ok { background: #e8f5e9; }
.check-warning { background: #fff3e0; }
.check-critical { background: #ffebee; }
.check-unknown { background: #f5f5f5; }
.check-status-ok { color: #2e7d32; font-weight: bold; }
.check-status-warning { color: #e65100; font-weight: bold; }
.check-status-critical { color: #b71c1c; font-weight: bold; }
.check-status-unknown { color: #777; font-weight: bold; }
.check-output { font-size: 0.9em; color: #555; }
.timestamp {
color: #bbb;
font-size: 0.75em;
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
.no-data {
text-align: center;
padding: 20px;
color: #aaa;
font-style: italic;
font-size: 0.9em;
}
.loading {
text-align: center;
padding: 12px;
color: #aaa;
font-size: 0.85em;
}
.error {
background: #ffebee;
border-left: 3px solid #f44336;
padding: 8px 12px;
margin: 8px 0;
border-radius: 3px;
color: #c62828;
font-size: 0.85em;
}
/* ── Scrollbar ──────────────────────────────────────────────── */
.container::-webkit-scrollbar { width: 8px; }
.container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
.container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
.container::-webkit-scrollbar-thumb:hover { background: #999; }
</style>
<body>
{% include 'nav.html' %}
<div class="container">
<h1>{{ header }}</h1>
<p class="subtitle">Per-host system metrics — expand a host to see plugin details</p>
{% if not hosts %}
<div class="no-data">
<p>No hosts with plugin data available</p>
<p style="font-size:0.9em;margin-top:10px;">Hosts will appear here once they start sending plugin metrics</p>
</div>
{% else %}
<div id="hosts-container">
{% for host in hosts %}
{% set plugins_csv = host.plugins | join(',') %}
<div class="host-card collapsed"
data-hostname="{{ host.name }}"
data-plugins="{{ plugins_csv }}">
<div class="host-header" onclick="toggleHost('{{ host.name }}')">
<div class="host-left">
<span class="collapse-icon"></span>
<span class="host-name">{{ host.name }}</span>
</div>
<div class="glance-strip" id="glance-{{ host.name }}">
<span class="glance-loading"></span>
</div>
<div class="host-right">
{% if 'nagios_runner' in host.plugins %}
<span class="nagios-badge" id="nagios-badge-{{ host.name }}"></span>
{% endif %}
<span class="os-label" id="os-label-{{ host.name }}"></span>
{% if host.is_owner %}
<a class="host-action-btn update-btn"
href="/u?h={{ host.name }}"
onclick="event.stopPropagation()">Update</a>
<a class="host-action-btn delete-btn"
href="/d?h={{ host.name }}"
onclick="event.stopPropagation(); return confirm('Delete host {{ host.name }}?')">Delete</a>
{% endif %}
</div>
</div>
<div class="host-body">
{% set plugin_order = ['os_info','cpu_monitor','memory_monitor','disk_monitor','network_monitor','zfs_monitor','nagios_runner','filesystem_info'] %}
{% for plugin in plugin_order if plugin in host.plugins %}
<div class="plugin-accordion collapsed"
data-hostname="{{ host.name }}"
data-plugin="{{ plugin }}">
<div class="plugin-acc-header"
onclick="togglePlugin('{{ host.name }}', '{{ plugin }}')">
<span class="acc-icon"></span>
<span class="plugin-label">{{ plugin | replace('_',' ') | title }}</span>
<span class="plugin-summary" id="summary-{{ host.name }}-{{ plugin }}">Not loaded</span>
</div>
<div class="plugin-acc-body" id="body-{{ host.name }}-{{ plugin }}">
<div class="loading">Loading…</div>
</div>
</div>
{% endfor %}
{% for plugin in host.plugins | sort if plugin not in plugin_order %}
<div class="plugin-accordion collapsed"
data-hostname="{{ host.name }}"
data-plugin="{{ plugin }}">
<div class="plugin-acc-header"
onclick="togglePlugin('{{ host.name }}', '{{ plugin }}')">
<span class="acc-icon"></span>
<span class="plugin-label">{{ plugin | replace('_',' ') | title }}</span>
<span class="plugin-summary" id="summary-{{ host.name }}-{{ plugin }}">Not loaded</span>
</div>
<div class="plugin-acc-body" id="body-{{ host.name }}-{{ plugin }}">
<div class="loading">Loading…</div>
</div>
</div>
{% endfor %}
</div><!-- .host-body -->
</div><!-- .host-card -->
{% endfor %}
</div>
{% endif %}
</div>
<script>
// ── Constants ───────────────────────────────────────────────────────────
const GLANCE_PLUGINS = ['cpu_monitor','memory_monitor','disk_monitor',
'network_monitor','nagios_runner','os_info'];
const SKIP_FIELDS = new Set(['id','name']);
// ── Cache ───────────────────────────────────────────────────────────────
// pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt }
const pluginCache = {};
function setCache(hostname, pluginName, sample) {
if (!pluginCache[hostname]) pluginCache[hostname] = {};
pluginCache[hostname][pluginName] = {
data: sample.data,
timestamp: sample.timestamp,
fetchedAt: Date.now(),
};
}
function getCache(hostname, pluginName) {
return pluginCache[hostname]?.[pluginName] ?? null;
}
// ── Fetch helpers ───────────────────────────────────────────────────────
async function fetchPlugin(hostname, pluginName) {
const r = await fetch(`/api/0/hosts/${hostname}/plugins/${pluginName}?limit=1`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const json = await r.json();
return json.samples?.[0] ?? null;
}
async function fetchHostGlance(hostname) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
const availablePlugins = (card?.dataset.plugins || '').split(',').filter(Boolean);
const toFetch = GLANCE_PLUGINS.filter(p => availablePlugins.includes(p));
const results = await Promise.allSettled(
toFetch.map(p => fetchPlugin(hostname, p))
);
results.forEach((r, i) => {
if (r.status === 'fulfilled' && r.value) {
setCache(hostname, toFetch[i], r.value);
}
});
updateHostHeader(hostname);
// Update any open accordion bodies
if (card && !card.classList.contains('collapsed')) {
card.querySelectorAll('.plugin-accordion').forEach(acc => {
const pname = acc.dataset.plugin;
if (!acc.classList.contains('collapsed') && getCache(hostname, pname)) {
renderPluginBody(hostname, pname);
}
updateAccordionSummary(hostname, pname);
});
}
}
// ── Host header ─────────────────────────────────────────────────────────
function updateHostHeader(hostname) {
const strip = document.getElementById(`glance-${hostname}`);
const nagosBadge = document.getElementById(`nagios-badge-${hostname}`);
const osLabel = document.getElementById(`os-label-${hostname}`);
if (!strip) return;
const chips = [];
// CPU
const cpu = getCache(hostname, 'cpu_monitor');
if (cpu) {
const pct = cpu.data.cpu_percent ?? null;
if (pct !== null) {
const cls = pct > 90 ? 'crit' : pct > 70 ? 'warn' : '';
chips.push(`<span class="glance-chip ${cls}">CPU ${pct.toFixed(0)}%</span>`);
}
}
// MEM
const mem = getCache(hostname, 'memory_monitor');
if (mem) {
const pct = mem.data.memory_percent ?? null;
if (pct !== null) {
const cls = pct > 95 ? 'crit' : pct > 80 ? 'warn' : '';
chips.push(`<span class="glance-chip ${cls}">MEM ${pct.toFixed(0)}%</span>`);
}
}
// Top disk partition
const disk = getCache(hostname, 'disk_monitor');
if (disk && disk.data.partitions) {
let topMount = null, topPct = -1;
for (const [mp, pd] of Object.entries(disk.data.partitions)) {
if ((pd.percent ?? 0) > topPct) { topPct = pd.percent; topMount = mp; }
}
if (topMount !== null) {
const cls = topPct > 90 ? 'crit' : topPct > 75 ? 'warn' : '';
chips.push(`<span class="glance-chip ${cls}">${topMount} ${topPct.toFixed(0)}%</span>`);
}
}
// Net delta (most active interface by recv+sent delta)
const net = getCache(hostname, 'network_monitor');
if (net && net.data.interfaces) {
let bestIface = null, bestTotal = -1;
for (const [iface, idata] of Object.entries(net.data.interfaces)) {
const total = (idata.bytes_recv_delta ?? 0) + (idata.bytes_sent_delta ?? 0);
if (total > bestTotal) { bestTotal = total; bestIface = iface; }
}
if (bestIface) {
const idata = net.data.interfaces[bestIface];
const up = formatBytes(idata.bytes_sent_delta ?? 0) + '/s';
const dn = formatBytes(idata.bytes_recv_delta ?? 0) + '/s';
chips.push(`<span class="glance-chip neutral">${bestIface}${up}${dn}</span>`);
}
}
strip.innerHTML = chips.length
? chips.join('')
: '<span class="glance-loading">—</span>';
// Nagios badge
const nagios = getCache(hostname, 'nagios_runner');
if (nagosBadge && nagios) {
const status = (nagios.data.overall_status || '—').toUpperCase();
const cls = status === 'OK' ? 'ok'
: status === 'WARNING' ? 'warning'
: status === 'CRITICAL' ? 'critical' : '';
nagosBadge.className = `nagios-badge ${cls}`;
nagosBadge.textContent = status;
}
// OS label
const osData = getCache(hostname, 'os_info');
if (osLabel && osData) {
const d = osData.data;
osLabel.textContent = d.distro_pretty_name || `${d.system || ''} ${d.machine || ''}`.trim();
}
}
// ── Toggle host ─────────────────────────────────────────────────────────
function toggleHost(hostname) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
const wasCollapsed = card.classList.contains('collapsed');
card.classList.toggle('collapsed');
if (wasCollapsed && !pluginCache[hostname]) {
fetchHostGlance(hostname);
}
}
// ── Toggle plugin accordion ─────────────────────────────────────────────
function togglePlugin(hostname, pluginName) {
const acc = document.querySelector(
`.plugin-accordion[data-hostname="${hostname}"][data-plugin="${pluginName}"]`
);
if (!acc) return;
const wasCollapsed = acc.classList.contains('collapsed');
acc.classList.toggle('collapsed');
if (wasCollapsed) {
const cached = getCache(hostname, pluginName);
if (cached) {
renderPluginBody(hostname, pluginName);
} else {
// Wave 3: fetch on-demand for non-glance plugins
const body = document.getElementById(`body-${hostname}-${pluginName}`);
if (body) body.innerHTML = '<div class="loading">Loading…</div>';
fetchPlugin(hostname, pluginName).then(sample => {
if (sample) {
setCache(hostname, pluginName, sample);
renderPluginBody(hostname, pluginName);
updateAccordionSummary(hostname, pluginName);
} else {
const b = document.getElementById(`body-${hostname}-${pluginName}`);
if (b) b.innerHTML = '<div class="no-data">No data available</div>';
}
}).catch(err => {
const b = document.getElementById(`body-${hostname}-${pluginName}`);
if (b) b.innerHTML = `<div class="error">Failed to load: ${err.message}</div>`;
});
}
}
}
// ── Accordion summaries ─────────────────────────────────────────────────
function updateAccordionSummary(hostname, pluginName) {
const el = document.getElementById(`summary-${hostname}-${pluginName}`);
if (!el) return;
const cached = getCache(hostname, pluginName);
if (!cached) return;
const d = cached.data;
let text = '';
switch (pluginName) {
case 'os_info':
text = d.distro_pretty_name || `${d.system || '?'} ${d.machine || ''}`.trim();
break;
case 'cpu_monitor': {
const pct = d.cpu_percent != null ? d.cpu_percent.toFixed(1) + '%' : '?';
const load = [d.load_1min, d.load_5min, d.load_15min]
.map(v => v != null ? v.toFixed(2) : '?').join(' / ');
const cores = d.cpu_core_count != null ? `${d.cpu_core_count} cores` : '';
text = `CPU: ${pct} · Load: ${load}${cores ? ' · ' + cores : ''}`;
break;
}
case 'memory_monitor': {
const pct = d.memory_percent != null ? d.memory_percent.toFixed(0) + '%' : '?';
const free = d.memory_available != null ? formatBytes(d.memory_available) : '?';
const total = d.memory_total != null ? formatBytes(d.memory_total) : '?';
const swap = d.swap_percent != null ? ` · Swap: ${d.swap_percent.toFixed(0)}%` : '';
text = `${pct} used · ${free} / ${total} free${swap}`;
break;
}
case 'disk_monitor': {
const parts = d.partitions || {};
const summary = Object.entries(parts)
.sort((a, b) => (b[1].percent || 0) - (a[1].percent || 0))
.slice(0, 3)
.map(([mp, pd]) => `${mp} ${(pd.percent || 0).toFixed(0)}%`)
.join(' · ');
text = summary || 'No partitions';
break;
}
case 'network_monitor': {
const stats = d.interface_stats || {};
const upCount = Object.values(stats).filter(s => s.isup).length;
const total = Object.keys(stats).length;
const established = d.connections?.ESTABLISHED ?? d.connections?.established ?? null;
text = total ? `${upCount}/${total} ifaces up` : '';
if (established !== null) text += ` · ${established} TCP ESTABLISHED`;
break;
}
case 'nagios_runner': {
const status = (d.overall_status || '?').toUpperCase();
const count = d.plugin_count;
text = status + (count != null ? `${count} checks` : '');
break;
}
case 'filesystem_info': {
const count = (d.filesystems || []).length;
text = `${count} filesystem${count !== 1 ? 's' : ''}`;
break;
}
case 'zfs_monitor': {
const pools = d.pools || {};
const names = Object.keys(pools);
if (names.length === 0) { text = 'No pools'; break; }
const degraded = names.filter(n => pools[n].health && pools[n].health !== 'ONLINE');
text = names.map(n => {
const p = pools[n];
const cap = p.capacity != null ? ` ${p.capacity.toFixed(0)}%` : '';
return `${n}${cap}`;
}).join(' · ');
if (degraded.length) text += `${degraded.map(n => pools[n].health).join(',')}`;
break;
}
default:
text = 'Loaded';
}
el.textContent = text;
}
// ── Render dispatcher ───────────────────────────────────────────────────
function renderPluginBody(hostname, pluginName) {
const body = document.getElementById(`body-${hostname}-${pluginName}`);
if (!body) return;
const cached = getCache(hostname, pluginName);
if (!cached) return;
let html = '';
switch (pluginName) {
case 'os_info': html = renderOsInfoTable(cached.data); break;
case 'cpu_monitor': html = renderCpuTable(cached.data); break;
case 'memory_monitor': html = renderMemoryTable(cached.data); break;
case 'disk_monitor': html = renderDiskTables(cached.data); break;
case 'network_monitor':html = renderNetworkTables(cached.data); break;
case 'zfs_monitor': html = renderZfsTables(cached.data); break;
case 'nagios_runner': html = renderNagiosTable(cached.data); break;
case 'filesystem_info':html = renderFilesystemTable(cached.data); break;
default: html = renderGenericTable(cached.data); break;
}
html += `<div class="timestamp">Last updated: ${new Date(cached.timestamp * 1000).toLocaleString()}</div>`;
body.innerHTML = html;
}
// ── Per-plugin renderers ────────────────────────────────────────────────
function renderOsInfoTable(d) {
const ORDER = ['distro_pretty_name','system','release','version','machine',
'processor','architecture','node','python_version',
'python_implementation','hbc_version',
'distro_name','distro_version','distro_id','distro_version_id'];
const shown = new Set(ORDER);
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k))];
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
for (const k of keys) {
if (SKIP_FIELDS.has(k) || !(k in d)) continue;
html += `<tr><td class="key">${formatLabel(k)}</td><td>${escHtml(String(d[k]))}</td></tr>`;
}
html += '</tbody></table>';
return html;
}
function renderCpuTable(d) {
const KEYS = [
['cpu_percent', 'CPU Usage', 'bar'],
['load_1min', 'Load (1 min)', 'num'],
['load_5min', 'Load (5 min)', 'num'],
['load_15min', 'Load (15 min)', 'num'],
['cpu_core_count', 'Core Count', 'int'],
['process_count', 'Process Count', 'int'],
['cpu_freq_current', 'CPU Freq (current)', 'num'],
['cpu_freq_min', 'CPU Freq (min)', 'num'],
['cpu_freq_max', 'CPU Freq (max)', 'num'],
['cpu_user', 'CPU User %', 'bar'],
['cpu_system', 'CPU System %', 'bar'],
['cpu_idle', 'CPU Idle %', 'pct'],
['cpu_iowait', 'CPU I/O Wait %', 'bar'],
];
const handled = new Set(KEYS.map(r => r[0]));
let html = '<table class="data-table"><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>';
for (const [k, label, fmt] of KEYS) {
if (!(k in d)) continue;
const v = d[k];
let cell;
if (fmt === 'bar' && typeof v === 'number') {
const cls = v > 90 ? 'crit' : v > 70 ? 'warn' : '';
cell = `<div class="bar-wrap">${v.toFixed(1)}%
<div class="bar-track"><div class="bar-fill ${cls}" style="width:${Math.min(v,100).toFixed(1)}%"></div></div>
</div>`;
} else if (fmt === 'pct') {
cell = typeof v === 'number' ? v.toFixed(1) + '%' : escHtml(String(v));
} else if (fmt === 'int') {
cell = typeof v === 'number' ? v.toLocaleString() : escHtml(String(v));
} else {
cell = typeof v === 'number' ? v.toFixed(2) : escHtml(String(v));
}
html += `<tr><td class="key">${label}</td><td>${cell}</td></tr>`;
}
// Any remaining scalar fields
for (const [k, v] of Object.entries(d)) {
if (handled.has(k) || SKIP_FIELDS.has(k)) continue;
if (typeof v === 'object' || Array.isArray(v)) continue;
html += `<tr><td class="key">${formatLabel(k)}</td><td>${escHtml(String(v))}</td></tr>`;
}
html += '</tbody></table>';
return html;
}
function renderMemoryTable(d) {
const RAM_KEYS = [
['memory_percent', 'Usage', 'bar'],
['memory_total', 'Total', 'bytes'],
['memory_available','Available', 'bytes'],
['memory_used', 'Used', 'bytes'],
['memory_free', 'Free', 'bytes'],
['memory_active', 'Active', 'bytes'],
['memory_inactive', 'Inactive', 'bytes'],
['memory_buffers', 'Buffers', 'bytes'],
['memory_cached', 'Cached', 'bytes'],
['memory_shared', 'Shared', 'bytes'],
];
const SWAP_KEYS = [
['swap_percent','Usage', 'bar'],
['swap_total', 'Total', 'bytes'],
['swap_used', 'Used', 'bytes'],
['swap_free', 'Free', 'bytes'],
['swap_sin', 'Swapped In', 'bytes'],
['swap_sout', 'Swapped Out','bytes'],
];
function buildSection(keys, warnAt=80, critAt=95) {
let t = '<table class="data-table"><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>';
for (const [k, label, fmt] of keys) {
if (!(k in d)) continue;
const v = d[k];
let cell;
if (fmt === 'bar' && typeof v === 'number') {
const cls = v > critAt ? 'crit' : v > warnAt ? 'warn' : '';
cell = `<div class="bar-wrap">${v.toFixed(1)}%
<div class="bar-track"><div class="bar-fill ${cls}" style="width:${Math.min(v,100).toFixed(1)}%"></div></div>
</div>`;
} else if (fmt === 'bytes') {
cell = formatBytes(v);
} else {
cell = escHtml(String(v));
}
t += `<tr><td class="key">${label}</td><td>${cell}</td></tr>`;
}
t += '</tbody></table>';
return t;
}
const hasSwap = SWAP_KEYS.some(([k]) => k in d);
if (!hasSwap) return buildSection(RAM_KEYS);
return `<div class="flex-tables">
<div><div class="table-section-label">RAM</div>${buildSection(RAM_KEYS)}</div>
<div><div class="table-section-label">Swap</div>${buildSection(SWAP_KEYS, 50, 80)}</div>
</div>`;
}
function renderDiskTables(d) {
let html = '';
if (d.partitions && Object.keys(d.partitions).length) {
let pt = '<table class="data-table"><thead><tr>'
+ '<th>Mount</th><th>Device</th><th>Type</th>'
+ '<th class="num">Total</th><th class="num">Used</th>'
+ '<th class="num">Free</th><th class="num">Use %</th>'
+ '</tr></thead><tbody>';
for (const [mp, pd] of Object.entries(d.partitions)) {
const pct = pd.percent || 0;
const pctCls = pct > 90 ? 'pct-crit' : pct > 75 ? 'pct-warn' : 'pct-ok';
pt += `<tr>
<td class="iface-name">${escHtml(mp)}</td>
<td>${escHtml(pd.device || '—')}</td>
<td>${escHtml(pd.fstype || '—')}</td>
<td class="num">${formatBytes(pd.total || 0)}</td>
<td class="num">${formatBytes(pd.used || 0)}</td>
<td class="num">${formatBytes(pd.free || 0)}</td>
<td class="num ${pctCls}">${pct.toFixed(1)}%</td>
</tr>`;
}
pt += '</tbody></table>';
let iot = '';
if (d.io_counters && Object.keys(d.io_counters).length) {
const sample = Object.values(d.io_counters)[0];
const hasDelta = 'read_bytes_delta' in sample;
iot = '<table class="data-table"><thead><tr><th>Disk</th>'
+ '<th class="num">Read</th><th class="num">Write</th>';
if (hasDelta) iot += '<th class="num">Δ Read</th><th class="num">Δ Write</th>';
iot += '<th class="num">Reads</th><th class="num">Writes</th>'
+ '</tr></thead><tbody>';
for (const [disk, dd] of Object.entries(d.io_counters)) {
iot += `<tr>
<td class="iface-name">${escHtml(disk)}</td>
<td class="num">${formatBytes(dd.read_bytes || 0)}</td>
<td class="num">${formatBytes(dd.write_bytes || 0)}</td>`;
if (hasDelta) {
iot += `<td class="num">${formatBytes(dd.read_bytes_delta || 0)}</td>
<td class="num">${formatBytes(dd.write_bytes_delta || 0)}</td>`;
}
iot += `<td class="num">${(dd.read_count || 0).toLocaleString()}</td>
<td class="num">${(dd.write_count || 0).toLocaleString()}</td>
</tr>`;
}
iot += '</tbody></table>';
}
html += iot
? `<div class="flex-tables">
<div><div class="table-section-label">Partitions</div>${pt}</div>
<div><div class="table-section-label">I/O Counters</div>${iot}</div>
</div>`
: `<div class="table-section-label">Partitions</div>${pt}`;
}
return html || '<div class="no-data">No disk data available</div>';
}
function renderNetworkTables(d) {
let html = '';
if (d.interfaces && Object.keys(d.interfaces).length) {
const sample = Object.values(d.interfaces)[0];
const hasDelta = 'bytes_sent_delta' in sample;
let t = '<table class="data-table"><thead><tr><th>Interface</th>'
+ '<th class="num">Sent</th><th class="num">Recv</th>';
if (hasDelta) t += '<th class="num">Δ Sent/s</th><th class="num">Δ Recv/s</th>';
t += '<th class="num">Pkts Sent</th><th class="num">Pkts Recv</th>'
+ '</tr></thead><tbody>';
for (const [iface, idata] of Object.entries(d.interfaces)) {
t += `<tr>
<td class="iface-name">${escHtml(iface)}</td>
<td class="num">${formatBytes(idata.bytes_sent || 0)}</td>
<td class="num">${formatBytes(idata.bytes_recv || 0)}</td>`;
if (hasDelta) {
t += `<td class="num"><strong>${formatBytes(idata.bytes_sent_delta || 0)}/s</strong></td>
<td class="num"><strong>${formatBytes(idata.bytes_recv_delta || 0)}/s</strong></td>`;
}
t += `<td class="num">${(idata.packets_sent || 0).toLocaleString()}</td>
<td class="num">${(idata.packets_recv || 0).toLocaleString()}</td>
</tr>`;
}
t += '</tbody></table>';
html += `<div class="table-section-label">Traffic</div>${t}`;
}
if (d.interface_stats && Object.keys(d.interface_stats).length) {
let t = '<table class="data-table"><thead><tr>'
+ '<th>Interface</th><th>Status</th>'
+ '<th class="num">Speed</th><th>Duplex</th><th class="num">MTU</th>'
+ '</tr></thead><tbody>';
for (const [iface, idata] of Object.entries(d.interface_stats)) {
const isUp = idata.isup;
const statusCls = isUp ? 'status-up' : 'status-down';
const statusTxt = isUp ? '✓ UP' : '✗ DOWN';
const speed = idata.speed || 0;
const speedTxt = speed <= 0 ? '—'
: speed >= 1000 ? (speed/1000).toFixed(1) + ' Gbps'
: speed + ' Mbps';
let duplex = idata.duplex || '—';
duplex = duplex.replace('NicDuplex.', '');
if (duplex === '2') duplex = 'FULL';
else if (duplex === '1') duplex = 'HALF';
else if (duplex === '0') duplex = 'UNKNOWN';
let addrTitle = '';
if (d.addresses?.[iface]) {
addrTitle = d.addresses[iface]
.filter(a => a.family && a.family.includes('AF_INET'))
.map(a => a.address).join(', ');
}
t += `<tr>
<td class="iface-name" title="${escHtml(addrTitle)}">${escHtml(iface)}</td>
<td class="${statusCls}">${statusTxt}</td>
<td class="num">${speedTxt}</td>
<td>${escHtml(duplex)}</td>
<td class="num">${idata.mtu || '—'}</td>
</tr>`;
}
t += '</tbody></table>';
html += `<div class="table-section-label" style="margin-top:10px;">Interface Status</div>${t}`;
}
if (d.connections && Object.keys(d.connections).length) {
const states = Object.entries(d.connections).filter(([, v]) => v > 0);
if (states.length) {
let t = '<table class="data-table"><thead><tr>';
states.forEach(([s]) => t += `<th class="center">${escHtml(s)}</th>`);
t += '</tr></thead><tbody><tr>';
states.forEach(([, v]) => t += `<td class="center">${v}</td>`);
t += '</tr></tbody></table>';
html += `<div class="table-section-label" style="margin-top:10px;">TCP Connections</div>${t}`;
}
}
return html || '<div class="no-data">No network data available</div>';
}
function renderNagiosTable(d) {
const checkNames = new Set();
for (const k of Object.keys(d)) {
const m = k.match(/^(.+)_status_code$/);
if (m) checkNames.add(m[1]);
}
if (checkNames.size === 0) return renderGenericTable(d);
let html = '<table class="data-table"><thead><tr>'
+ '<th>Check</th><th class="center">Status</th><th>Output</th>'
+ '</tr></thead><tbody>';
for (const check of [...checkNames].sort()) {
const status = (d[`${check}_status`] || '?').toUpperCase();
const output = d[`${check}_output`] || '';
const rowCls = status === 'OK' ? 'check-ok'
: status === 'WARNING' ? 'check-warning'
: status === 'CRITICAL' ? 'check-critical' : 'check-unknown';
const statusCls = status === 'OK' ? 'check-status-ok'
: status === 'WARNING' ? 'check-status-warning'
: status === 'CRITICAL' ? 'check-status-critical' : 'check-status-unknown';
const label = check.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const truncated = output.length > 120 ? output.slice(0, 117) + '…' : output;
html += `<tr class="${rowCls}">
<td>${escHtml(label)}</td>
<td class="center ${statusCls}">${escHtml(status)}</td>
<td class="check-output" title="${escHtml(output)}">${escHtml(truncated)}</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
function renderFilesystemTable(d) {
const fs = d.filesystems;
if (!fs || !fs.length) return '<div class="no-data">No filesystem data</div>';
let html = '<table class="data-table"><thead><tr>'
+ '<th>Device</th><th>Mount</th><th>Type</th>'
+ '<th>Options</th><th class="num">Max File</th><th class="num">Max Path</th>'
+ '</tr></thead><tbody>';
for (const f of fs) {
const opts = f.opts || '—';
const display = opts.length > 50 ? opts.slice(0, 47) + '…' : opts;
html += `<tr>
<td class="iface-name">${escHtml(f.device || '—')}</td>
<td>${escHtml(f.mountpoint || '—')}</td>
<td>${escHtml(f.fstype || '—')}</td>
<td style="font-size:0.82em;" title="${escHtml(opts)}">${escHtml(display)}</td>
<td class="num">${f.maxfile || '—'}</td>
<td class="num">${f.maxpath || '—'}</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
function renderZfsTables(d) {
const pools = d.pools || {};
const names = Object.keys(pools);
if (names.length === 0) return '<div class="no-data">No ZFS pools found</div>';
const healthCls = h => {
if (!h || h === 'ONLINE') return 'pct-ok';
if (h === 'DEGRADED') return 'pct-warn';
return 'pct-crit';
};
let pt = '<table class="data-table"><thead><tr>'
+ '<th>Pool</th><th>Health</th>'
+ '<th class="num">Size</th><th class="num">Used</th>'
+ '<th class="num">Free</th><th class="num">Cap %</th>'
+ '<th class="num">Frag %</th><th class="num">Dedup</th>'
+ '</tr></thead><tbody>';
for (const name of names) {
const p = pools[name];
const cap = p.capacity != null ? p.capacity : 0;
const capCls = cap > 90 ? 'pct-crit' : cap > 75 ? 'pct-warn' : 'pct-ok';
pt += `<tr>
<td class="iface-name">${escHtml(name)}</td>
<td class="${healthCls(p.health)}">${escHtml(p.health || '—')}</td>
<td class="num">${formatBytes(p.size || 0)}</td>
<td class="num">${formatBytes(p.alloc || 0)}</td>
<td class="num">${formatBytes(p.free || 0)}</td>
<td class="num ${capCls}">${cap.toFixed(1)}%</td>
<td class="num">${p.frag != null ? p.frag.toFixed(1) + '%' : '—'}</td>
<td class="num">${p.dedup != null ? p.dedup.toFixed(2) + 'x' : '—'}</td>
</tr>`;
}
pt += '</tbody></table>';
const hasIo = names.some(n => pools[n].read_ops != null);
if (!hasIo) return pt;
let iot = '<table class="data-table"><thead><tr>'
+ '<th>Pool</th>'
+ '<th class="num">Read ops</th><th class="num">Write ops</th>'
+ '<th class="num">Read BW</th><th class="num">Write BW</th>'
+ '</tr></thead><tbody>';
for (const name of names) {
const p = pools[name];
iot += `<tr>
<td class="iface-name">${escHtml(name)}</td>
<td class="num">${p.read_ops != null ? p.read_ops.toLocaleString() : '—'}</td>
<td class="num">${p.write_ops != null ? p.write_ops.toLocaleString() : '—'}</td>
<td class="num">${p.read_bw != null ? formatBytes(p.read_bw) : '—'}</td>
<td class="num">${p.write_bw != null ? formatBytes(p.write_bw) : '—'}</td>
</tr>`;
}
iot += '</tbody></table>';
return `<div class="flex-tables">
<div><div class="table-section-label">Pools</div>${pt}</div>
<div><div class="table-section-label">I/O (cumulative)</div>${iot}</div>
</div>`;
}
function renderGenericTable(d) {
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
for (const [k, v] of Object.entries(d)) {
if (SKIP_FIELDS.has(k) || typeof v === 'object') continue;
html += `<tr><td class="key">${formatLabel(k)}</td><td>${escHtml(String(v))}</td></tr>`;
}
html += '</tbody></table>';
return html;
}
// ── Utilities ───────────────────────────────────────────────────────────
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(2) + ' GB';
}
function formatLabel(key) {
if (key === 'time') return 'Collected At';
return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── Auto-refresh (30 s) ─────────────────────────────────────────────────
setInterval(() => {
document.querySelectorAll('.host-card:not(.collapsed)').forEach(card => {
const hostname = card.dataset.hostname;
fetchHostGlance(hostname);
card.querySelectorAll('.plugin-accordion:not(.collapsed)').forEach(acc => {
const pname = acc.dataset.plugin;
if (!GLANCE_PLUGINS.includes(pname)) {
fetchPlugin(hostname, pname).then(sample => {
if (sample) {
setCache(hostname, pname, sample);
renderPluginBody(hostname, pname);
updateAccordionSummary(hostname, pname);
}
}).catch(() => {});
}
});
});
}, 30000);
// ── Init ────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// If a host fragment is in the URL, expand and scroll to that host;
// otherwise expand the first host as before.
const hash = window.location.hash;
if (hash) {
const hostname = decodeURIComponent(hash.slice(1));
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (card) {
card.classList.remove('collapsed');
fetchHostGlance(hostname);
setTimeout(() => card.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
return;
}
}
const first = document.querySelector('.host-card');
if (first) {
first.classList.remove('collapsed');
fetchHostGlance(first.dataset.hostname);
}
});
</script>
</body>
</html>