Files
heartbeat/hbd/server/templates/plugins.html
T
andreasandClaude Sonnet 5 18e33656c0 fix: preserve chart history across reconnects, show gaps for missing data
Only wipe real (non-RTT) plugin data on an actual client reboot (boot
flag), not on every ordinary OVERDUE/DOWN -> UP recovery. A transient
network blip no longer erases CPU/memory/etc. history.

Also split the shared time-series chart into separate line/area segments
wherever the gap between samples is much larger than the typical spacing,
so missing data (host overdue, or history that simply hasn't accumulated
across a drop) renders as a visual gap instead of an interpolated line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEimzMv4Q5EjFg3hoiZ69T
2026-08-17 09:13:49 -04:00

1475 lines
65 KiB
HTML

<!DOCTYPE html>
<html>
{% include 'head.html' %}
<link rel="stylesheet" href="/static/hbd-ui.css">
<script src="/static/hbd-ui.js"></script>
<style>
body { overflow: hidden; background: var(--st-paper); }
/* This page scrolls inside .container, not the body — the toolbar is
always in view, and sticky misbehaves inside an overflow:hidden body. */
.st-toolbar { position: static; }
.container {
max-width: 1400px;
margin: 0 auto;
max-height: calc(100vh - var(--bars-h, 94px) - 30px);
overflow-y: auto;
padding: 0 10px 20px;
}
/* ── Host cards: one record list per host ───────────────────── */
.host-card {
background: var(--st-surface);
border: 1px solid var(--st-line);
border-radius: 8px;
margin-bottom: 8px;
}
.host-header {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
padding: 8px 14px;
border-radius: 8px;
}
.host-header:hover { background: var(--st-surface-2); border-radius: 8px 8px 0 0; }
.host-card.collapsed .host-header:hover { border-radius: 8px; }
.host-left { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.collapse-icon {
font-size: .85em;
color: var(--st-faint);
transition: transform 0.2s;
min-width: 14px;
}
@media (prefers-reduced-motion: reduce) { .collapse-icon, .acc-icon { transition: none; } }
.host-card.collapsed .collapse-icon { transform: rotate(-90deg); }
.host-name {
font-family: var(--st-mono);
font-size: 13.5px;
font-weight: 600;
color: var(--st-ink);
white-space: nowrap;
}
/* ── Glance strip ───────────────────────────────────────────── */
.glance-strip { display: flex; align-items: center; gap: 5px; flex: 1; flex-wrap: wrap; padding: 0 8px; }
.glance-chip {
display: inline-flex; align-items: center;
font-family: var(--st-mono); font-size: 11px; line-height: 1.3;
padding: 3px 7px; border-radius: 4px; white-space: nowrap;
background: var(--st-ok-soft); color: var(--st-ok);
}
.glance-chip.warn { background: var(--st-warn-soft); color: var(--st-warn); }
.glance-chip.crit { background: var(--st-crit-soft); color: var(--st-crit); }
.glance-chip.neutral { background: var(--st-surface-2); color: var(--st-muted); }
.glance-loading { font-size: 11.5px; color: var(--st-faint); font-style: italic; }
/* ── Host right zone ────────────────────────────────────────── */
.host-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.nagios-badge {
font-family: var(--st-mono); font-size: 10.5px; font-weight: 700;
padding: 3px 9px; border-radius: 4px;
background: var(--st-surface-2); color: var(--st-muted);
text-transform: uppercase; white-space: nowrap;
}
.nagios-badge.ok { background: var(--st-ok-soft); color: var(--st-ok); }
.nagios-badge.warning { background: var(--st-warn-soft); color: var(--st-warn); }
.nagios-badge.critical { background: var(--st-crit-soft); color: var(--st-crit); }
.os-label {
font-size: 11px; color: var(--st-faint); white-space: nowrap;
max-width: 200px; overflow: hidden; text-overflow: ellipsis;
}
.host-action-btn {
font: 600 12px/1 inherit;
padding: 4px 10px; border-radius: 6px;
border: 1px solid var(--st-line);
background: var(--st-surface);
cursor: pointer; text-decoration: none; white-space: nowrap;
}
.host-action-btn.update-btn { color: var(--st-accent); }
.host-action-btn.update-btn:hover { background: var(--st-accent-soft); border-color: var(--st-accent); }
.host-action-btn.delete-btn { color: var(--st-crit); }
.host-action-btn.delete-btn:hover { background: var(--st-crit-soft); border-color: var(--st-crit); }
/* ── Action result toast ───────────────────────────────────── */
#action-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--st-ink);
color: var(--st-paper);
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: var(--st-crit); color: #fff; }
/* ── Host body ──────────────────────────────────────────────── */
.host-body { padding: 8px 14px 12px; border-top: 1px solid var(--st-line-soft); }
.host-card.collapsed .host-body { display: none; }
/* ── Plugin accordions ──────────────────────────────────────── */
.plugin-accordion {
border: 1px solid var(--st-line-soft);
border-radius: 6px;
margin-bottom: 5px;
overflow: hidden;
}
.plugin-acc-header {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px; cursor: pointer;
background: var(--st-surface-2);
user-select: none;
}
.plugin-acc-header:hover { background: var(--st-accent-soft); }
.acc-icon { font-size: 0.7em; color: var(--st-faint); transition: transform 0.15s; min-width: 12px; }
.plugin-accordion:not(.collapsed) .acc-icon { transform: rotate(90deg); }
.plugin-label {
font-family: var(--st-mono);
font-weight: 600; font-size: 12.5px;
color: var(--st-ink);
min-width: 140px;
}
.plugin-summary { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-faint); 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: 12.5px;
background: var(--st-surface);
border: 1px solid var(--st-line-soft);
border-radius: 6px;
overflow: hidden;
margin-bottom: 10px;
font-variant-numeric: tabular-nums;
}
.data-table thead { background: var(--st-surface-2); }
.data-table th {
font-family: var(--st-mono);
font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em;
color: var(--st-faint); font-weight: 600;
text-align: left; padding: 5px 8px;
border-bottom: 1px solid var(--st-line);
}
.data-table th.num { text-align: right; }
.data-table th.center { text-align: center; }
.data-table td {
padding: 4px 8px;
border-top: 1px solid var(--st-line-soft);
color: var(--st-ink);
}
.data-table td.num { text-align: right; font-family: var(--st-mono); }
.data-table td.center { text-align: center; }
.data-table td.key { color: var(--st-muted); font-weight: 500; width: 38%; }
.data-table tbody tr:hover { background: var(--st-surface-2); }
.iface-name { font-family: var(--st-mono); font-weight: 600; color: var(--st-accent); }
/* ── Percent bars ───────────────────────────────────────────── */
.bar-wrap { display: flex; align-items: center; gap: 8px; min-width: 120px; }
.bar-track {
flex: 1; height: 7px; border-radius: 4px;
background: var(--st-line-soft); overflow: hidden; min-width: 60px;
}
.bar-fill { height: 100%; border-radius: 4px; background: var(--st-ok); }
.bar-fill.warn { background: var(--st-warn); }
.bar-fill.crit { background: var(--st-crit); }
/* ── Disk two-table layout ──────────────────────────────────── */
.flex-tables { display: flex; gap: 14px; flex-wrap: wrap; }
.flex-tables > div { flex: 1 1 380px; }
.table-section-label {
font-family: var(--st-mono);
font-size: 11px; font-weight: 600; color: var(--st-faint);
text-transform: uppercase; letter-spacing: .05em;
margin: 4px 0 6px;
}
/* ── Status / misc ──────────────────────────────────────────── */
.status-up { color: var(--st-ok); font-weight: bold; }
.status-down { color: var(--st-crit); font-weight: bold; }
.pct-ok { color: var(--st-ok); font-weight: bold; }
.pct-warn { color: var(--st-warn); font-weight: bold; }
.pct-crit { color: var(--st-crit); font-weight: bold; }
.check-ok { background: var(--st-ok-soft); }
.check-warning { background: var(--st-warn-soft); }
.check-critical { background: var(--st-crit-soft); }
.check-unknown { background: var(--st-surface-2); }
.check-status-ok { color: var(--st-ok); font-weight: bold; }
.check-status-warning { color: var(--st-warn); font-weight: bold; }
.check-status-critical { color: var(--st-crit); font-weight: bold; }
.check-status-unknown { color: var(--st-faint); font-weight: bold; }
.check-output { font-size: 0.9em; color: var(--st-muted); }
.timestamp {
font-size: 11px; color: var(--st-faint);
margin-top: 6px; padding-top: 6px;
border-top: 1px solid var(--st-line-soft);
}
.no-data { text-align: center; padding: 40px; color: var(--st-faint); }
.loading { text-align: center; padding: 12px; color: var(--st-faint); font-style: italic; }
.error {
background: var(--st-crit-soft);
border-left: 3px solid var(--st-crit);
padding: 12px 16px; margin: 10px 0;
border-radius: 6px; color: var(--st-crit);
}
/* ── Scrollbar ──────────────────────────────────────────────── */
.container::-webkit-scrollbar { width: 8px; }
.container::-webkit-scrollbar-track { background: var(--st-surface-2); border-radius: 4px; }
.container::-webkit-scrollbar-thumb { background: var(--st-line); border-radius: 4px; }
.container::-webkit-scrollbar-thumb:hover { background: var(--st-faint); }
/* ── Host info section ──────────────────────────────────────── */
.host-info-section {
background: var(--st-surface-2);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 8px;
font-size: 12.5px;
}
.info-meta { display: flex; flex-wrap: wrap; gap: 4px 18px; }
.info-label { font-family: var(--st-mono); font-size: 11px; font-weight: 600; color: var(--st-faint); white-space: nowrap; }
.info-value { color: var(--st-ink); }
.info-thresholds-title {
font-family: var(--st-mono);
font-size: 11px; font-weight: 600; color: var(--st-faint);
text-transform: uppercase; letter-spacing: .05em;
margin: 8px 0 4px;
}
.info-note { color: var(--st-faint); font-style: italic; }
.info-loading { color: var(--st-faint); font-style: italic; }
.threshold-covers { font-size: 11.5px; color: var(--st-faint); font-style: italic; }
</style>
<body>
{% include 'nav.html' %}
<div class="st-toolbar">
<span class="brand">hbd<span class="tld">·overview</span></span>
<span class="hintline">per-host metrics — expand a host for plugin details</span>
</div>
<svg class="pulse" viewBox="0 0 1200 14" preserveAspectRatio="none" aria-hidden="true">
<polyline points="0,10 340,10 352,10 358,3 364,13 370,1 378,12 384,10 560,10 572,10 578,3 584,13 590,1 598,12 604,10 1200,10"/>
</svg>
<div class="container">
{% if not hosts %}
<div class="no-data">
<p>No hosts have connected yet</p>
<p style="font-size:0.9em;margin-top:10px;">Hosts will appear here once they start sending heartbeats</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.connections && data.connections.length) {
html += `<div class="info-thresholds-title">Connectivity</div>`;
for (const c of data.connections) {
html += `<div class="info-note">${escHtml(c.family)} &middot; ${escHtml(c.addr || '—')} RTT</div>
<div id="rtt-chart-${hostname}-${escHtml(c.family)}" style="margin-bottom:8px;"></div>`;
}
html += `<table class="data-table"><thead><tr>
<th>Family</th><th>Address</th><th>State</th>
<th class="num">RTT</th><th>Last Change</th><th>Last Packet</th>
</tr></thead><tbody>`;
for (const c of data.connections) {
const st = escHtml(c.state || '—');
const stCls = c.state === 'up' ? 'status-up' : (c.state ? 'status-down' : '');
const rtt = (c.state === 'up' && c.rtt) ? Math.round(c.rtt) + ' ms' : '—';
const chg = c.statetime ? new Date(c.statetime * 1000).toLocaleString() : '—';
const seen = c.lastbeat ? new Date(c.lastbeat * 1000).toLocaleString() : '—';
html += `<tr>
<td class="key">${escHtml(c.family)}</td>
<td>${escHtml(c.addr || '—')}</td>
<td><span class="${stCls}">${st}</span></td>
<td class="num">${rtt}</td>
<td>${chg}</td>
<td>${seen}</td>
</tr>`;
}
html += `</tbody></table>`;
}
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;
if (data.connections && data.connections.length) {
const rttThresholds = (data.thresholds || []).find(t => t.metric === 'rtt') || null;
for (const c of data.connections) {
fetchRttHistory(hostname, c.family)
.then(samples => renderRttChart(hostname, c.family, samples, rttThresholds))
.catch(() => {});
}
}
}
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 renderTimeSeriesChart(elId, pts, opts) {
const el = document.getElementById(elId);
if (!el || pts.length < 2) { if (el) el.style.display = 'none'; return; }
const unitSuffix = opts.unitSuffix || '';
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;
// Auto-scale Y axis with 10% padding, optionally clamped to opts.yDomain
const vMin = Math.min(...pts.map(p => p.v));
const vMax = Math.max(...pts.map(p => p.v));
const vRange = vMax - vMin || 1;
const vPad = Math.max(vRange * 0.1, 1);
const domainLow = Array.isArray(opts.yDomain) ? opts.yDomain[0] : 0;
const domainHigh = Array.isArray(opts.yDomain) ? opts.yDomain[1] : Infinity;
const yLow = Math.max(domainLow, vMin - vPad);
const yHigh = Math.min(domainHigh, vMax + vPad);
const yRange = yHigh - yLow || 1;
const y = v => PAD.top + cH - ((v - yLow) / yRange) * cH;
// Color based on latest value
const latest = pts[pts.length - 1].v;
const { stroke: strokeColor, fill: fillColor } = opts.colorFor(latest);
// Split into segments wherever the gap between consecutive samples is
// much larger than the typical spacing (e.g. the host was overdue/down
// for a while) — draw each segment separately so missing data reads as
// a visual gap instead of an interpolated line across dead time.
const deltas = [];
for (let i = 1; i < pts.length; i++) deltas.push(pts[i].t - pts[i - 1].t);
deltas.sort((a, b) => a - b);
const medianDelta = deltas[Math.floor(deltas.length / 2)];
const gapThreshold = medianDelta * 2.5;
const segments = [[pts[0]]];
for (let i = 1; i < pts.length; i++) {
if (pts[i].t - pts[i - 1].t > gapThreshold) segments.push([]);
segments[segments.length - 1].push(pts[i]);
}
let linePolylines = '';
let areaPaths = '';
for (const seg of segments) {
if (seg.length < 2) continue;
const segPoints = seg.map(p => `${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ');
linePolylines += `<polyline points="${segPoints}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linejoin="round"/>`;
const segArea = `M${x(seg[0].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} ` +
seg.map(p => `L${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') +
` L${x(seg[seg.length-1].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} Z`;
areaPaths += `<path d="${segArea}" fill="${fillColor}" opacity="0.6"/>`;
}
// Compute nice tick step for ~3-5 grid lines
const rawStep = yRange / 4;
const mag = Math.pow(10, Math.floor(Math.log10(rawStep || 1)));
const niceStep = [1, 2, 5, 10].map(f => f * mag).find(s => yRange / s <= 5) || mag * 10;
const tickStart = Math.ceil(yLow / niceStep) * niceStep;
let gridLines = '';
for (let v = tickStart; v <= yHigh + 0.001; v += niceStep) {
const yy = y(v).toFixed(1);
const label = (Number.isInteger(v) ? v : v.toFixed(1)) + unitSuffix;
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">${label}</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="${opts.clipId}">
<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(#${opts.clipId})">
${areaPaths}
${linePolylines}
</g>
${xLabels}
</svg>`;
}
function renderCpuChart(hostname, samples) {
const pts = samples
.filter(s => s.data.cpu_percent != null)
.map(s => ({ t: s.timestamp, v: s.data.cpu_percent }));
renderTimeSeriesChart(`cpu-chart-${hostname}`, pts, {
yDomain: [0, 100],
clipId: `cpu-clip-${hostname}`,
colorFor: (latest) => ({
stroke: latest > 90 ? '#e53935' : latest > 70 ? '#fb8c00' : '#43a047',
fill: latest > 90 ? '#ffcdd2' : latest > 70 ? '#ffe0b2' : '#c8e6c9',
}),
});
}
async function fetchRttHistory(hostname, family) {
const plugin = `rtt_${family.toLowerCase()}`;
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/plugins/${plugin}?limit=100`);
if (!r.ok) return [];
const json = await r.json();
return json.samples || [];
}
function renderRttChart(hostname, family, samples, rttThresholds) {
const pts = samples
.filter(s => s.data.rtt != null && s.data.rtt > 0)
.map(s => ({ t: s.timestamp, v: s.data.rtt }));
renderTimeSeriesChart(`rtt-chart-${hostname}-${family}`, pts, {
yDomain: 'auto',
clipId: `rtt-clip-${hostname}-${family}`,
unitSuffix: ' ms',
colorFor: (latest) => {
if (rttThresholds?.critical != null && latest > rttThresholds.critical) {
return { stroke: '#e53935', fill: '#ffcdd2' };
}
if (rttThresholds?.warning != null && latest > rttThresholds.warning) {
return { stroke: '#fb8c00', fill: '#ffe0b2' };
}
return { stroke: '#1976d2', fill: '#bbdefb' };
},
});
}
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;
fetchHostInfo(hostname).then(data => {
infoCache[hostname] = data;
renderInfoSection(hostname, data);
}).catch(() => {});
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>