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