Files
heartbeat/hbd/server/templates/plugins.html
T
andreas 2ddba203df feat: add CPU usage history graph to CPU Monitor section
Renders an SVG line chart above the CPU Usage row using all available
history samples (up to 100). Color adapts green/orange/red by load level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:55:55 -04:00

1489 lines
59 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; }
/* ── Action result toast ───────────────────────────────────── */
#action-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: #323232;
color: #fff;
padding: 12px 22px;
border-radius: 6px;
font-size: 0.9em;
max-width: 480px;
text-align: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 9000;
white-space: pre-wrap;
}
#action-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#action-toast.error { background: #c62828; }
/* ── 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: 1.00em;
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: 1.00em;
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: 1.00em;
}
.error {
background: #ffebee;
border-left: 3px solid #f44336;
padding: 8px 12px;
margin: 8px 0;
border-radius: 3px;
color: #c62828;
font-size: 1.00em;
}
/* ── 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; }
/* ── Host info section ──────────────────────────────────────────────────── */
.host-info-section {
padding: 12px 16px;
background: #fafafa;
border-bottom: 1px solid #e0e0e0;
font-size: 1.00em;
}
.info-meta {
display: grid;
grid-template-columns: max-content 1fr;
gap: 3px 14px;
margin-bottom: 10px;
}
.info-label { font-weight: 600; color: #555; white-space: nowrap; }
.info-value { color: #222; }
.info-thresholds-title {
font-weight: 600;
color: #555;
margin-bottom: 6px;
}
.info-note { color: #888; font-style: italic; }
.info-loading { color: #bbb; font-style: italic; }
.threshold-covers { font-size: 1.00em; color: #777; font-style: italic; }
</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 }}" data-owner="{{ host.owner or '' }}">
{% if current_user and current_user.admin and host.owner %}<span class="glance-chip neutral">{{ host.owner }}</span>{% endif %}
<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 %}
<button class="host-action-btn update-btn"
onclick="event.stopPropagation(); hostAction(this, '/u?h={{ host.name }}')">Update</button>
<button class="host-action-btn delete-btn"
onclick="event.stopPropagation(); hostDelete(this, '{{ host.name }}')">Delete</button>
{% endif %}
</div>
</div>
<div class="host-body">
<div class="host-info-section" id="info-{{ host.name }}">
<div class="info-loading">Loading…</div>
</div>
{% 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']);
const CURRENT_USER_ADMIN = {{ 'true' if current_user and current_user.admin else 'false' }};
// ── Cache ───────────────────────────────────────────────────────────────
// pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt }
const pluginCache = {};
// infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
const infoCache = {};
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;
}
// Return worst nagios exit code (0-3) found in a nagios_runner data object.
function nagiosWorstStatus(data) {
let worst = 0;
for (const [k, v] of Object.entries(data || {})) {
if (k.endsWith('_status_code') && typeof v === 'number' && v > worst) {
worst = v;
}
}
return worst;
}
// ── 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 fetchHostInfo(hostname) {
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.json();
}
function renderInfoSection(hostname, data) {
const el = document.getElementById(`info-${hostname}`);
if (!el) return;
const owner = data.owner ? escHtml(data.owner) : '—';
const managers = data.managers && data.managers.length
? data.managers.map(escHtml).join(', ') : '—';
const hbcVer = data.hbc_version ? escHtml(String(data.hbc_version)) : '—';
const hbcType = data.hbc_type ? escHtml(String(data.hbc_type)) : '—';
const lastPkt = data.last_packet != null
? new Date(data.last_packet * 1000).toLocaleString() : '—';
let html = `<div class="info-meta">
<span class="info-label">Owner</span><span class="info-value">${owner}</span>
<span class="info-label">Managers</span><span class="info-value">${managers}</span>
<span class="info-label">Agent Version</span><span class="info-value">${hbcVer}</span>
<span class="info-label">Agent Type</span><span class="info-value">${hbcType}</span>
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
</div>`;
if (data.thresholds === null) {
html += `<div class="info-note">Threshold alerting not configured.</div>`;
} else if (data.thresholds.length === 0) {
html += `<div class="info-note">No thresholds defined.</div>`;
} else {
html += `<div class="info-thresholds-title">Effective Thresholds</div>
<table class="data-table"><thead><tr>
<th>Metric</th><th>Op</th><th>Warning</th><th>Critical</th>
</tr></thead><tbody>`;
for (const t of data.thresholds) {
const w = t.warning != null ? escHtml(String(t.warning)) : '—';
const c = t.critical != null ? escHtml(String(t.critical)) : '—';
let metricCell = escHtml(t.metric);
if (t.covers && t.covers.length > 0) {
metricCell += `<br><span class="threshold-covers">↳ ${t.covers.map(escHtml).join(', ')}</span>`;
}
html += `<tr>
<td class="key">${metricCell}</td>
<td>${escHtml(t.operator)}</td>
<td>${w}</td>
<td>${c}</td>
</tr>`;
}
html += `</tbody></table>`;
}
el.innerHTML = html;
}
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 = [];
// Owner (admin only, static from server)
const owner = strip.dataset.owner;
if (CURRENT_USER_ADMIN && owner) {
chips.push(`<span class="glance-chip neutral">${owner}</span>`);
}
// 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 — derive worst status from individual check codes
const nagios = getCache(hostname, 'nagios_runner');
if (nagosBadge && nagios) {
const worst = nagiosWorstStatus(nagios.data);
const names = {0:'OK', 1:'WARNING', 2:'CRITICAL', 3:'UNKNOWN'};
const status = names[worst] || '—';
const cls = worst === 0 ? 'ok' : worst === 1 ? 'warning' : worst >= 2 ? '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) {
if (!pluginCache[hostname]) {
fetchHostGlance(hostname);
}
if (!infoCache[hostname]) {
const infoEl = document.getElementById(`info-${hostname}`);
if (infoEl) infoEl.innerHTML = '<div class="info-loading">Loading…</div>';
fetchHostInfo(hostname).then(data => {
infoCache[hostname] = data;
renderInfoSection(hostname, data);
}).catch(() => {
const el = document.getElementById(`info-${hostname}`);
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
});
}
}
}
// ── 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 worst = nagiosWorstStatus(d);
const names = {0:'OK', 1:'WARNING', 2:'CRITICAL', 3:'UNKNOWN'};
const codes = Object.keys(d).filter(k => k.endsWith('_status_code'));
text = (names[worst] || '?') + (codes.length ? `${codes.length} 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(hostname, 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;
if (pluginName === 'cpu_monitor') {
fetchCpuHistory(hostname).then(samples => renderCpuChart(hostname, samples)).catch(() => {});
}
}
// ── Per-plugin renderers ────────────────────────────────────────────────
function renderOsInfoTable(d) {
const ORDER = ['distro_pretty_name','system','release','version','machine',
'processor','architecture','node','python_version',
'python_implementation',
'distro_name','distro_version','distro_id','distro_version_id'];
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
const shown = new Set(ORDER);
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_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;
}
async function fetchCpuHistory(hostname) {
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/plugins/cpu_monitor?limit=100`);
if (!r.ok) return [];
const json = await r.json();
return json.samples || [];
}
function renderCpuChart(hostname, samples) {
const el = document.getElementById(`cpu-chart-${hostname}`);
if (!el || !samples.length) return;
const pts = samples
.filter(s => s.data.cpu_percent != null)
.map(s => ({ t: s.timestamp, v: s.data.cpu_percent }));
if (pts.length < 2) { el.style.display = 'none'; return; }
const W = 600, H = 80, PAD = { top: 6, right: 8, bottom: 18, left: 28 };
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const tMin = pts[0].t, tMax = pts[pts.length - 1].t;
const tRange = tMax - tMin || 1;
const x = t => PAD.left + ((t - tMin) / tRange) * cW;
const y = v => PAD.top + cH - (Math.min(v, 100) / 100) * cH;
// Build polyline points and filled area path
const linePoints = pts.map(p => `${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ');
const areaPath = `M${x(pts[0].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} ` +
pts.map(p => `L${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') +
` L${x(pts[pts.length-1].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} Z`;
// Color based on latest value
const latest = pts[pts.length - 1].v;
const strokeColor = latest > 90 ? '#e53935' : latest > 70 ? '#fb8c00' : '#43a047';
const fillColor = latest > 90 ? '#ffcdd2' : latest > 70 ? '#ffe0b2' : '#c8e6c9';
// Y-axis grid lines at 25, 50, 75, 100
let gridLines = '';
for (const pct of [25, 50, 75, 100]) {
const yy = y(pct).toFixed(1);
gridLines += `<line x1="${PAD.left}" y1="${yy}" x2="${PAD.left + cW}" y2="${yy}" stroke="#e0e0e0" stroke-width="1"/>`;
gridLines += `<text x="${(PAD.left - 3).toFixed(1)}" y="${yy}" text-anchor="end" dominant-baseline="middle" font-size="8" fill="#999">${pct}</text>`;
}
// X-axis time labels
const fmt = ts => {
const d = new Date(ts * 1000);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const xLabels = `
<text x="${PAD.left}" y="${H - 2}" text-anchor="start" font-size="8" fill="#999">${fmt(pts[0].t)}</text>
<text x="${PAD.left + cW}" y="${H - 2}" text-anchor="end" font-size="8" fill="#999">${fmt(pts[pts.length-1].t)}</text>`;
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"
style="width:100%;height:${H}px;display:block;">
<defs>
<clipPath id="cpu-clip-${hostname}">
<rect x="${PAD.left}" y="${PAD.top}" width="${cW}" height="${cH}"/>
</clipPath>
</defs>
${gridLines}
<line x1="${PAD.left}" y1="${PAD.top}" x2="${PAD.left}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
<line x1="${PAD.left}" y1="${PAD.top + cH}" x2="${PAD.left + cW}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
<g clip-path="url(#cpu-clip-${hostname})">
<path d="${areaPath}" fill="${fillColor}" opacity="0.6"/>
<polyline points="${linePoints}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linejoin="round"/>
</g>
${xLabels}
</svg>`;
}
function renderCpuTable(hostname, 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 = `<div id="cpu-chart-${hostname}" style="margin-bottom:8px;"></div>`;
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').forEach(card => {
fetchHostGlance(card.dataset.hostname);
});
document.querySelectorAll('.host-card:not(.collapsed)').forEach(card => {
const hostname = card.dataset.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', () => {
// Fetch glance data for every host immediately so the strip is always populated.
document.querySelectorAll('.host-card').forEach(card => {
fetchHostGlance(card.dataset.hostname);
});
// Expand and load info for the target host (URL hash or first host).
function expandHost(hostname) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (!card) return false;
card.classList.remove('collapsed');
fetchHostInfo(hostname).then(data => {
infoCache[hostname] = data;
renderInfoSection(hostname, data);
}).catch(() => {
const el = document.getElementById(`info-${hostname}`);
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
});
return true;
}
const hash = window.location.hash;
if (hash) {
const hostname = decodeURIComponent(hash.slice(1));
if (expandHost(hostname)) {
setTimeout(() => {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 150);
return;
}
}
const first = document.querySelector('.host-card');
if (first) expandHost(first.dataset.hostname);
});
// ── Host action helpers ──────────────────────────────────────
let _toastTimer = null;
function showToast(msg, isError) {
const t = document.getElementById('action-toast');
t.textContent = msg;
t.classList.toggle('error', !!isError);
t.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => t.classList.remove('show'), 4000);
}
async function hostAction(btn, url) {
btn.disabled = true;
try {
const res = await fetch(url);
const text = await res.text();
showToast(text, !res.ok);
} catch (e) {
showToast('Request failed: ' + e.message, true);
} finally {
btn.disabled = false;
}
}
async function hostDelete(btn, hostname) {
if (!confirm('Delete host ' + hostname + '?')) return;
btn.disabled = true;
try {
const res = await fetch('/d?h=' + encodeURIComponent(hostname));
const text = await res.text();
showToast(text, !res.ok);
if (res.ok) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (card) card.remove();
}
} catch (e) {
showToast('Request failed: ' + e.message, true);
btn.disabled = false;
}
}
</script>
<div id="action-toast"></div>
</body>
</html>