2ddba203df
Renders an SVG line chart above the CPU Usage row using all available history samples (up to 100). Color adapts green/orange/red by load level. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1489 lines
59 KiB
HTML
1489 lines
59 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
{% include 'head.html' %}
|
|
|
|
<style>
|
|
body { overflow: hidden; }
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
max-height: calc(100vh - 120px);
|
|
overflow-y: auto;
|
|
padding-right: 10px;
|
|
}
|
|
|
|
h1 {
|
|
color: #333;
|
|
margin-bottom: 5px;
|
|
margin-top: 15px;
|
|
font-size: 1.5em;
|
|
}
|
|
|
|
.subtitle {
|
|
color: #666;
|
|
margin-bottom: 15px;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
/* ── Host cards ─────────────────────────────────────────────── */
|
|
|
|
.host-card {
|
|
background: white;
|
|
border-radius: 6px;
|
|
padding: 0;
|
|
margin-bottom: 10px;
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.host-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
padding: 10px 15px;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.host-header:hover { background: #f9f9f9; border-radius: 6px 6px 0 0; }
|
|
.host-card.collapsed .host-header:hover { border-radius: 6px; }
|
|
|
|
.host-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.collapse-icon {
|
|
font-size: 1em;
|
|
color: #888;
|
|
transition: transform 0.2s;
|
|
min-width: 16px;
|
|
}
|
|
|
|
.host-card.collapsed .collapse-icon { transform: rotate(-90deg); }
|
|
|
|
.host-name {
|
|
font-size: 1.05em;
|
|
font-weight: bold;
|
|
color: #333;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Glance strip ───────────────────────────────────────────── */
|
|
|
|
.glance-strip {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
flex-wrap: wrap;
|
|
padding: 0 12px;
|
|
}
|
|
|
|
.glance-chip {
|
|
font-size: 0.78em;
|
|
padding: 2px 9px;
|
|
border-radius: 10px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
background: #e8f5e9;
|
|
color: #2e7d32;
|
|
}
|
|
|
|
.glance-chip.warn { background: #fff3e0; color: #e65100; }
|
|
.glance-chip.crit { background: #ffebee; color: #b71c1c; }
|
|
.glance-chip.neutral { background: #f5f5f5; color: #555; }
|
|
.glance-loading { font-size: 0.8em; color: #bbb; font-style: italic; }
|
|
|
|
/* ── Host right zone ────────────────────────────────────────── */
|
|
|
|
.host-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.nagios-badge {
|
|
font-size: 0.75em;
|
|
font-weight: bold;
|
|
padding: 2px 10px;
|
|
border-radius: 10px;
|
|
background: #9e9e9e;
|
|
color: white;
|
|
text-transform: uppercase;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.nagios-badge.ok { background: #4caf50; }
|
|
.nagios-badge.warning { background: #ff9800; }
|
|
.nagios-badge.critical { background: #f44336; }
|
|
|
|
.os-label {
|
|
font-size: 0.75em;
|
|
color: #999;
|
|
white-space: nowrap;
|
|
max-width: 200px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.host-action-btn {
|
|
font-size: 0.75em;
|
|
font-weight: bold;
|
|
padding: 3px 10px;
|
|
border-radius: 4px;
|
|
border: none;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
}
|
|
.host-action-btn.update-btn {
|
|
background: #e3f2fd;
|
|
color: #1565c0;
|
|
}
|
|
.host-action-btn.update-btn:hover { background: #bbdefb; }
|
|
.host-action-btn.delete-btn {
|
|
background: #ffebee;
|
|
color: #c62828;
|
|
}
|
|
.host-action-btn.delete-btn:hover { background: #ffcdd2; }
|
|
|
|
/* ── Action result toast ───────────────────────────────────── */
|
|
#action-toast {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
left: 50%;
|
|
transform: translateX(-50%) translateY(20px);
|
|
background: #323232;
|
|
color: #fff;
|
|
padding: 12px 22px;
|
|
border-radius: 6px;
|
|
font-size: 0.9em;
|
|
max-width: 480px;
|
|
text-align: center;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.25s, transform 0.25s;
|
|
z-index: 9000;
|
|
white-space: pre-wrap;
|
|
}
|
|
#action-toast.show {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0);
|
|
}
|
|
#action-toast.error { background: #c62828; }
|
|
|
|
/* ── Host body ──────────────────────────────────────────────── */
|
|
|
|
.host-body {
|
|
padding: 8px 15px 12px;
|
|
border-top: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.host-card.collapsed .host-body { display: none; }
|
|
|
|
/* ── Plugin accordions ──────────────────────────────────────── */
|
|
|
|
.plugin-accordion {
|
|
border: 1px solid #e8e8e8;
|
|
border-radius: 4px;
|
|
margin-bottom: 5px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.plugin-acc-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 7px 12px;
|
|
cursor: pointer;
|
|
background: #fafafa;
|
|
user-select: none;
|
|
}
|
|
|
|
.plugin-acc-header:hover { background: #f0f4ff; }
|
|
|
|
.acc-icon {
|
|
font-size: 0.7em;
|
|
color: #999;
|
|
transition: transform 0.15s;
|
|
min-width: 12px;
|
|
}
|
|
|
|
.plugin-accordion:not(.collapsed) .acc-icon { transform: rotate(90deg); }
|
|
|
|
.plugin-label {
|
|
font-weight: 600;
|
|
font-size: 1.00em;
|
|
color: #444;
|
|
min-width: 140px;
|
|
}
|
|
|
|
.plugin-summary {
|
|
font-size: 0.82em;
|
|
color: #888;
|
|
flex: 1;
|
|
}
|
|
|
|
.plugin-accordion.collapsed .plugin-acc-body { display: none; }
|
|
|
|
.plugin-acc-body { padding: 10px 12px; }
|
|
|
|
/* ── Tables ─────────────────────────────────────────────────── */
|
|
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 1.00em;
|
|
background: #fff;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.data-table thead { background: #2196f3; color: white; }
|
|
|
|
.data-table th {
|
|
padding: 7px 10px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
font-size: 0.75em;
|
|
letter-spacing: 0.4px;
|
|
}
|
|
|
|
.data-table th.num { text-align: right; }
|
|
.data-table th.center { text-align: center; }
|
|
|
|
.data-table td {
|
|
/* padding: 6px 10px; */
|
|
border-top: 1px solid #e8e8e8;
|
|
color: #333;
|
|
}
|
|
|
|
.data-table td.num {
|
|
text-align: right;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.95em;
|
|
}
|
|
|
|
.data-table td.center { text-align: center; }
|
|
.data-table td.key { color: #666; font-weight: 500; width: 38%; }
|
|
|
|
.data-table tbody tr:nth-child(even) { background: #fafafa; }
|
|
.data-table tbody tr:hover { background: #f0f4ff; }
|
|
|
|
.iface-name { font-weight: bold; color: #2196f3; }
|
|
|
|
/* ── Percent bars ───────────────────────────────────────────── */
|
|
|
|
.bar-wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.bar-track {
|
|
display: inline-block;
|
|
width: 70px;
|
|
height: 6px;
|
|
background: #e0e0e0;
|
|
border-radius: 3px;
|
|
vertical-align: middle;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.bar-fill {
|
|
height: 6px;
|
|
border-radius: 3px;
|
|
background: #4caf50;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.bar-fill.warn { background: #ff9800; }
|
|
.bar-fill.crit { background: #f44336; }
|
|
|
|
/* ── Disk two-table layout ──────────────────────────────────── */
|
|
|
|
.flex-tables {
|
|
display: flex;
|
|
gap: 14px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.flex-tables > div { flex: 1 1 380px; }
|
|
|
|
.table-section-label {
|
|
font-size: 0.78em;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
color: #888;
|
|
letter-spacing: 0.4px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
/* ── Status / misc ──────────────────────────────────────────── */
|
|
|
|
.status-up { color: #4caf50; font-weight: bold; }
|
|
.status-down { color: #f44336; font-weight: bold; }
|
|
|
|
.pct-ok { color: #2e7d32; font-weight: bold; }
|
|
.pct-warn { color: #e65100; font-weight: bold; }
|
|
.pct-crit { color: #b71c1c; font-weight: bold; }
|
|
|
|
.check-ok { background: #e8f5e9; }
|
|
.check-warning { background: #fff3e0; }
|
|
.check-critical { background: #ffebee; }
|
|
.check-unknown { background: #f5f5f5; }
|
|
|
|
.check-status-ok { color: #2e7d32; font-weight: bold; }
|
|
.check-status-warning { color: #e65100; font-weight: bold; }
|
|
.check-status-critical { color: #b71c1c; font-weight: bold; }
|
|
.check-status-unknown { color: #777; font-weight: bold; }
|
|
|
|
.check-output { font-size: 0.9em; color: #555; }
|
|
|
|
.timestamp {
|
|
color: #bbb;
|
|
font-size: 0.75em;
|
|
margin-top: 8px;
|
|
padding-top: 6px;
|
|
border-top: 1px solid #f0f0f0;
|
|
text-align: right;
|
|
}
|
|
|
|
.no-data {
|
|
text-align: center;
|
|
padding: 20px;
|
|
color: #aaa;
|
|
font-style: italic;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 12px;
|
|
color: #aaa;
|
|
font-size: 1.00em;
|
|
}
|
|
|
|
.error {
|
|
background: #ffebee;
|
|
border-left: 3px solid #f44336;
|
|
padding: 8px 12px;
|
|
margin: 8px 0;
|
|
border-radius: 3px;
|
|
color: #c62828;
|
|
font-size: 1.00em;
|
|
}
|
|
|
|
/* ── Scrollbar ──────────────────────────────────────────────── */
|
|
|
|
.container::-webkit-scrollbar { width: 8px; }
|
|
.container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
|
|
.container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
|
|
.container::-webkit-scrollbar-thumb:hover { background: #999; }
|
|
|
|
/* ── Host info section ──────────────────────────────────────────────────── */
|
|
.host-info-section {
|
|
padding: 12px 16px;
|
|
background: #fafafa;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
font-size: 1.00em;
|
|
}
|
|
.info-meta {
|
|
display: grid;
|
|
grid-template-columns: max-content 1fr;
|
|
gap: 3px 14px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.info-label { font-weight: 600; color: #555; white-space: nowrap; }
|
|
.info-value { color: #222; }
|
|
.info-thresholds-title {
|
|
font-weight: 600;
|
|
color: #555;
|
|
margin-bottom: 6px;
|
|
}
|
|
.info-note { color: #888; font-style: italic; }
|
|
.info-loading { color: #bbb; font-style: italic; }
|
|
.threshold-covers { font-size: 1.00em; color: #777; font-style: italic; }
|
|
</style>
|
|
|
|
<body>
|
|
{% include 'nav.html' %}
|
|
|
|
<div class="container">
|
|
<h1>{{ header }}</h1>
|
|
<p class="subtitle">Per-host system metrics — expand a host to see plugin details</p>
|
|
|
|
{% if not hosts %}
|
|
<div class="no-data">
|
|
<p>No hosts with plugin data available</p>
|
|
<p style="font-size:0.9em;margin-top:10px;">Hosts will appear here once they start sending plugin metrics</p>
|
|
</div>
|
|
{% else %}
|
|
<div id="hosts-container">
|
|
{% for host in hosts %}
|
|
{% set plugins_csv = host.plugins | join(',') %}
|
|
<div class="host-card collapsed"
|
|
data-hostname="{{ host.name }}"
|
|
data-plugins="{{ plugins_csv }}">
|
|
|
|
<div class="host-header" onclick="toggleHost('{{ host.name }}')">
|
|
<div class="host-left">
|
|
<span class="collapse-icon">▼</span>
|
|
<span class="host-name">{{ host.name }}</span>
|
|
</div>
|
|
|
|
<div class="glance-strip" id="glance-{{ host.name }}" data-owner="{{ host.owner or '' }}">
|
|
{% if current_user and current_user.admin and host.owner %}<span class="glance-chip neutral">{{ host.owner }}</span>{% endif %}
|
|
<span class="glance-loading">—</span>
|
|
</div>
|
|
|
|
<div class="host-right">
|
|
{% if 'nagios_runner' in host.plugins %}
|
|
<span class="nagios-badge" id="nagios-badge-{{ host.name }}">—</span>
|
|
{% endif %}
|
|
<span class="os-label" id="os-label-{{ host.name }}"></span>
|
|
{% if host.is_owner %}
|
|
<button class="host-action-btn update-btn"
|
|
onclick="event.stopPropagation(); hostAction(this, '/u?h={{ host.name }}')">Update</button>
|
|
<button class="host-action-btn delete-btn"
|
|
onclick="event.stopPropagation(); hostDelete(this, '{{ host.name }}')">Delete</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="host-body">
|
|
<div class="host-info-section" id="info-{{ host.name }}">
|
|
<div class="info-loading">Loading…</div>
|
|
</div>
|
|
{% set plugin_order = ['os_info','cpu_monitor','memory_monitor','disk_monitor','network_monitor','zfs_monitor','nagios_runner','filesystem_info'] %}
|
|
{% for plugin in plugin_order if plugin in host.plugins %}
|
|
<div class="plugin-accordion collapsed"
|
|
data-hostname="{{ host.name }}"
|
|
data-plugin="{{ plugin }}">
|
|
<div class="plugin-acc-header"
|
|
onclick="togglePlugin('{{ host.name }}', '{{ plugin }}')">
|
|
<span class="acc-icon">▶</span>
|
|
<span class="plugin-label">{{ plugin | replace('_',' ') | title }}</span>
|
|
<span class="plugin-summary" id="summary-{{ host.name }}-{{ plugin }}">Not loaded</span>
|
|
</div>
|
|
<div class="plugin-acc-body" id="body-{{ host.name }}-{{ plugin }}">
|
|
<div class="loading">Loading…</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
{% for plugin in host.plugins | sort if plugin not in plugin_order %}
|
|
<div class="plugin-accordion collapsed"
|
|
data-hostname="{{ host.name }}"
|
|
data-plugin="{{ plugin }}">
|
|
<div class="plugin-acc-header"
|
|
onclick="togglePlugin('{{ host.name }}', '{{ plugin }}')">
|
|
<span class="acc-icon">▶</span>
|
|
<span class="plugin-label">{{ plugin | replace('_',' ') | title }}</span>
|
|
<span class="plugin-summary" id="summary-{{ host.name }}-{{ plugin }}">Not loaded</span>
|
|
</div>
|
|
<div class="plugin-acc-body" id="body-{{ host.name }}-{{ plugin }}">
|
|
<div class="loading">Loading…</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div><!-- .host-body -->
|
|
|
|
</div><!-- .host-card -->
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<script>
|
|
// ── Constants ───────────────────────────────────────────────────────────
|
|
|
|
const GLANCE_PLUGINS = ['cpu_monitor','memory_monitor','disk_monitor',
|
|
'network_monitor','nagios_runner','os_info'];
|
|
const SKIP_FIELDS = new Set(['id','name']);
|
|
const CURRENT_USER_ADMIN = {{ 'true' if current_user and current_user.admin else 'false' }};
|
|
|
|
// ── Cache ───────────────────────────────────────────────────────────────
|
|
|
|
// pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt }
|
|
const pluginCache = {};
|
|
|
|
// infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
|
|
const infoCache = {};
|
|
|
|
function setCache(hostname, pluginName, sample) {
|
|
if (!pluginCache[hostname]) pluginCache[hostname] = {};
|
|
pluginCache[hostname][pluginName] = {
|
|
data: sample.data,
|
|
timestamp: sample.timestamp,
|
|
fetchedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
function getCache(hostname, pluginName) {
|
|
return pluginCache[hostname]?.[pluginName] ?? null;
|
|
}
|
|
|
|
// Return worst nagios exit code (0-3) found in a nagios_runner data object.
|
|
function nagiosWorstStatus(data) {
|
|
let worst = 0;
|
|
for (const [k, v] of Object.entries(data || {})) {
|
|
if (k.endsWith('_status_code') && typeof v === 'number' && v > worst) {
|
|
worst = v;
|
|
}
|
|
}
|
|
return worst;
|
|
}
|
|
|
|
// ── Fetch helpers ───────────────────────────────────────────────────────
|
|
|
|
async function fetchPlugin(hostname, pluginName) {
|
|
const r = await fetch(`/api/0/hosts/${hostname}/plugins/${pluginName}?limit=1`);
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
const json = await r.json();
|
|
return json.samples?.[0] ?? null;
|
|
}
|
|
|
|
async function fetchHostInfo(hostname) {
|
|
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
return await r.json();
|
|
}
|
|
|
|
function renderInfoSection(hostname, data) {
|
|
const el = document.getElementById(`info-${hostname}`);
|
|
if (!el) return;
|
|
|
|
const owner = data.owner ? escHtml(data.owner) : '—';
|
|
const managers = data.managers && data.managers.length
|
|
? data.managers.map(escHtml).join(', ') : '—';
|
|
const hbcVer = data.hbc_version ? escHtml(String(data.hbc_version)) : '—';
|
|
const hbcType = data.hbc_type ? escHtml(String(data.hbc_type)) : '—';
|
|
const lastPkt = data.last_packet != null
|
|
? new Date(data.last_packet * 1000).toLocaleString() : '—';
|
|
|
|
let html = `<div class="info-meta">
|
|
<span class="info-label">Owner</span><span class="info-value">${owner}</span>
|
|
<span class="info-label">Managers</span><span class="info-value">${managers}</span>
|
|
<span class="info-label">Agent Version</span><span class="info-value">${hbcVer}</span>
|
|
<span class="info-label">Agent Type</span><span class="info-value">${hbcType}</span>
|
|
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
|
|
</div>`;
|
|
|
|
if (data.thresholds === null) {
|
|
html += `<div class="info-note">Threshold alerting not configured.</div>`;
|
|
} else if (data.thresholds.length === 0) {
|
|
html += `<div class="info-note">No thresholds defined.</div>`;
|
|
} else {
|
|
html += `<div class="info-thresholds-title">Effective Thresholds</div>
|
|
<table class="data-table"><thead><tr>
|
|
<th>Metric</th><th>Op</th><th>Warning</th><th>Critical</th>
|
|
</tr></thead><tbody>`;
|
|
for (const t of data.thresholds) {
|
|
const w = t.warning != null ? escHtml(String(t.warning)) : '—';
|
|
const c = t.critical != null ? escHtml(String(t.critical)) : '—';
|
|
let metricCell = escHtml(t.metric);
|
|
if (t.covers && t.covers.length > 0) {
|
|
metricCell += `<br><span class="threshold-covers">↳ ${t.covers.map(escHtml).join(', ')}</span>`;
|
|
}
|
|
html += `<tr>
|
|
<td class="key">${metricCell}</td>
|
|
<td>${escHtml(t.operator)}</td>
|
|
<td>${w}</td>
|
|
<td>${c}</td>
|
|
</tr>`;
|
|
}
|
|
html += `</tbody></table>`;
|
|
}
|
|
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
async function fetchHostGlance(hostname) {
|
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
|
const availablePlugins = (card?.dataset.plugins || '').split(',').filter(Boolean);
|
|
const toFetch = GLANCE_PLUGINS.filter(p => availablePlugins.includes(p));
|
|
|
|
const results = await Promise.allSettled(
|
|
toFetch.map(p => fetchPlugin(hostname, p))
|
|
);
|
|
|
|
results.forEach((r, i) => {
|
|
if (r.status === 'fulfilled' && r.value) {
|
|
setCache(hostname, toFetch[i], r.value);
|
|
}
|
|
});
|
|
|
|
updateHostHeader(hostname);
|
|
|
|
// Update any open accordion bodies
|
|
if (card && !card.classList.contains('collapsed')) {
|
|
card.querySelectorAll('.plugin-accordion').forEach(acc => {
|
|
const pname = acc.dataset.plugin;
|
|
if (!acc.classList.contains('collapsed') && getCache(hostname, pname)) {
|
|
renderPluginBody(hostname, pname);
|
|
}
|
|
updateAccordionSummary(hostname, pname);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Host header ─────────────────────────────────────────────────────────
|
|
|
|
function updateHostHeader(hostname) {
|
|
const strip = document.getElementById(`glance-${hostname}`);
|
|
const nagosBadge = document.getElementById(`nagios-badge-${hostname}`);
|
|
const osLabel = document.getElementById(`os-label-${hostname}`);
|
|
if (!strip) return;
|
|
|
|
const chips = [];
|
|
|
|
// Owner (admin only, static from server)
|
|
const owner = strip.dataset.owner;
|
|
if (CURRENT_USER_ADMIN && owner) {
|
|
chips.push(`<span class="glance-chip neutral">${owner}</span>`);
|
|
}
|
|
|
|
// CPU
|
|
const cpu = getCache(hostname, 'cpu_monitor');
|
|
if (cpu) {
|
|
const pct = cpu.data.cpu_percent ?? null;
|
|
if (pct !== null) {
|
|
const cls = pct > 90 ? 'crit' : pct > 70 ? 'warn' : '';
|
|
chips.push(`<span class="glance-chip ${cls}">CPU ${pct.toFixed(0)}%</span>`);
|
|
}
|
|
}
|
|
|
|
// MEM
|
|
const mem = getCache(hostname, 'memory_monitor');
|
|
if (mem) {
|
|
const pct = mem.data.memory_percent ?? null;
|
|
if (pct !== null) {
|
|
const cls = pct > 95 ? 'crit' : pct > 80 ? 'warn' : '';
|
|
chips.push(`<span class="glance-chip ${cls}">MEM ${pct.toFixed(0)}%</span>`);
|
|
}
|
|
}
|
|
|
|
// Top disk partition
|
|
const disk = getCache(hostname, 'disk_monitor');
|
|
if (disk && disk.data.partitions) {
|
|
let topMount = null, topPct = -1;
|
|
for (const [mp, pd] of Object.entries(disk.data.partitions)) {
|
|
if ((pd.percent ?? 0) > topPct) { topPct = pd.percent; topMount = mp; }
|
|
}
|
|
if (topMount !== null) {
|
|
const cls = topPct > 90 ? 'crit' : topPct > 75 ? 'warn' : '';
|
|
chips.push(`<span class="glance-chip ${cls}">${topMount} ${topPct.toFixed(0)}%</span>`);
|
|
}
|
|
}
|
|
|
|
// Net delta (most active interface by recv+sent delta)
|
|
const net = getCache(hostname, 'network_monitor');
|
|
if (net && net.data.interfaces) {
|
|
let bestIface = null, bestTotal = -1;
|
|
for (const [iface, idata] of Object.entries(net.data.interfaces)) {
|
|
const total = (idata.bytes_recv_delta ?? 0) + (idata.bytes_sent_delta ?? 0);
|
|
if (total > bestTotal) { bestTotal = total; bestIface = iface; }
|
|
}
|
|
if (bestIface) {
|
|
const idata = net.data.interfaces[bestIface];
|
|
const up = formatBytes(idata.bytes_sent_delta ?? 0) + '/s';
|
|
const dn = formatBytes(idata.bytes_recv_delta ?? 0) + '/s';
|
|
chips.push(`<span class="glance-chip neutral">${bestIface} ↑${up} ↓${dn}</span>`);
|
|
}
|
|
}
|
|
|
|
strip.innerHTML = chips.length
|
|
? chips.join('')
|
|
: '<span class="glance-loading">—</span>';
|
|
|
|
// Nagios badge — derive worst status from individual check codes
|
|
const nagios = getCache(hostname, 'nagios_runner');
|
|
if (nagosBadge && nagios) {
|
|
const worst = nagiosWorstStatus(nagios.data);
|
|
const names = {0:'OK', 1:'WARNING', 2:'CRITICAL', 3:'UNKNOWN'};
|
|
const status = names[worst] || '—';
|
|
const cls = worst === 0 ? 'ok' : worst === 1 ? 'warning' : worst >= 2 ? 'critical' : '';
|
|
nagosBadge.className = `nagios-badge ${cls}`;
|
|
nagosBadge.textContent = status;
|
|
}
|
|
|
|
// OS label
|
|
const osData = getCache(hostname, 'os_info');
|
|
if (osLabel && osData) {
|
|
const d = osData.data;
|
|
osLabel.textContent = d.distro_pretty_name || `${d.system || ''} ${d.machine || ''}`.trim();
|
|
}
|
|
}
|
|
|
|
// ── Toggle host ─────────────────────────────────────────────────────────
|
|
|
|
function toggleHost(hostname) {
|
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
|
const wasCollapsed = card.classList.contains('collapsed');
|
|
card.classList.toggle('collapsed');
|
|
if (wasCollapsed) {
|
|
if (!pluginCache[hostname]) {
|
|
fetchHostGlance(hostname);
|
|
}
|
|
if (!infoCache[hostname]) {
|
|
const infoEl = document.getElementById(`info-${hostname}`);
|
|
if (infoEl) infoEl.innerHTML = '<div class="info-loading">Loading…</div>';
|
|
fetchHostInfo(hostname).then(data => {
|
|
infoCache[hostname] = data;
|
|
renderInfoSection(hostname, data);
|
|
}).catch(() => {
|
|
const el = document.getElementById(`info-${hostname}`);
|
|
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Toggle plugin accordion ─────────────────────────────────────────────
|
|
|
|
function togglePlugin(hostname, pluginName) {
|
|
const acc = document.querySelector(
|
|
`.plugin-accordion[data-hostname="${hostname}"][data-plugin="${pluginName}"]`
|
|
);
|
|
if (!acc) return;
|
|
const wasCollapsed = acc.classList.contains('collapsed');
|
|
acc.classList.toggle('collapsed');
|
|
|
|
if (wasCollapsed) {
|
|
const cached = getCache(hostname, pluginName);
|
|
if (cached) {
|
|
renderPluginBody(hostname, pluginName);
|
|
} else {
|
|
// Wave 3: fetch on-demand for non-glance plugins
|
|
const body = document.getElementById(`body-${hostname}-${pluginName}`);
|
|
if (body) body.innerHTML = '<div class="loading">Loading…</div>';
|
|
fetchPlugin(hostname, pluginName).then(sample => {
|
|
if (sample) {
|
|
setCache(hostname, pluginName, sample);
|
|
renderPluginBody(hostname, pluginName);
|
|
updateAccordionSummary(hostname, pluginName);
|
|
} else {
|
|
const b = document.getElementById(`body-${hostname}-${pluginName}`);
|
|
if (b) b.innerHTML = '<div class="no-data">No data available</div>';
|
|
}
|
|
}).catch(err => {
|
|
const b = document.getElementById(`body-${hostname}-${pluginName}`);
|
|
if (b) b.innerHTML = `<div class="error">Failed to load: ${err.message}</div>`;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Accordion summaries ─────────────────────────────────────────────────
|
|
|
|
function updateAccordionSummary(hostname, pluginName) {
|
|
const el = document.getElementById(`summary-${hostname}-${pluginName}`);
|
|
if (!el) return;
|
|
const cached = getCache(hostname, pluginName);
|
|
if (!cached) return;
|
|
const d = cached.data;
|
|
|
|
let text = '';
|
|
switch (pluginName) {
|
|
case 'os_info':
|
|
text = d.distro_pretty_name || `${d.system || '?'} ${d.machine || ''}`.trim();
|
|
break;
|
|
case 'cpu_monitor': {
|
|
const pct = d.cpu_percent != null ? d.cpu_percent.toFixed(1) + '%' : '?';
|
|
const load = [d.load_1min, d.load_5min, d.load_15min]
|
|
.map(v => v != null ? v.toFixed(2) : '?').join(' / ');
|
|
const cores = d.cpu_core_count != null ? `${d.cpu_core_count} cores` : '';
|
|
text = `CPU: ${pct} · Load: ${load}${cores ? ' · ' + cores : ''}`;
|
|
break;
|
|
}
|
|
case 'memory_monitor': {
|
|
const pct = d.memory_percent != null ? d.memory_percent.toFixed(0) + '%' : '?';
|
|
const free = d.memory_available != null ? formatBytes(d.memory_available) : '?';
|
|
const total = d.memory_total != null ? formatBytes(d.memory_total) : '?';
|
|
const swap = d.swap_percent != null ? ` · Swap: ${d.swap_percent.toFixed(0)}%` : '';
|
|
text = `${pct} used · ${free} / ${total} free${swap}`;
|
|
break;
|
|
}
|
|
case 'disk_monitor': {
|
|
const parts = d.partitions || {};
|
|
const summary = Object.entries(parts)
|
|
.sort((a, b) => (b[1].percent || 0) - (a[1].percent || 0))
|
|
.slice(0, 3)
|
|
.map(([mp, pd]) => `${mp} ${(pd.percent || 0).toFixed(0)}%`)
|
|
.join(' · ');
|
|
text = summary || 'No partitions';
|
|
break;
|
|
}
|
|
case 'network_monitor': {
|
|
const stats = d.interface_stats || {};
|
|
const upCount = Object.values(stats).filter(s => s.isup).length;
|
|
const total = Object.keys(stats).length;
|
|
const established = d.connections?.ESTABLISHED ?? d.connections?.established ?? null;
|
|
text = total ? `${upCount}/${total} ifaces up` : '';
|
|
if (established !== null) text += ` · ${established} TCP ESTABLISHED`;
|
|
break;
|
|
}
|
|
case 'nagios_runner': {
|
|
const worst = nagiosWorstStatus(d);
|
|
const names = {0:'OK', 1:'WARNING', 2:'CRITICAL', 3:'UNKNOWN'};
|
|
const codes = Object.keys(d).filter(k => k.endsWith('_status_code'));
|
|
text = (names[worst] || '?') + (codes.length ? ` — ${codes.length} checks` : '');
|
|
break;
|
|
}
|
|
case 'filesystem_info': {
|
|
const count = (d.filesystems || []).length;
|
|
text = `${count} filesystem${count !== 1 ? 's' : ''}`;
|
|
break;
|
|
}
|
|
case 'zfs_monitor': {
|
|
const pools = d.pools || {};
|
|
const names = Object.keys(pools);
|
|
if (names.length === 0) { text = 'No pools'; break; }
|
|
const degraded = names.filter(n => pools[n].health && pools[n].health !== 'ONLINE');
|
|
text = names.map(n => {
|
|
const p = pools[n];
|
|
const cap = p.capacity != null ? ` ${p.capacity.toFixed(0)}%` : '';
|
|
return `${n}${cap}`;
|
|
}).join(' · ');
|
|
if (degraded.length) text += ` ⚠ ${degraded.map(n => pools[n].health).join(',')}`;
|
|
break;
|
|
}
|
|
default:
|
|
text = 'Loaded';
|
|
}
|
|
el.textContent = text;
|
|
}
|
|
|
|
// ── Render dispatcher ───────────────────────────────────────────────────
|
|
|
|
function renderPluginBody(hostname, pluginName) {
|
|
const body = document.getElementById(`body-${hostname}-${pluginName}`);
|
|
if (!body) return;
|
|
const cached = getCache(hostname, pluginName);
|
|
if (!cached) return;
|
|
|
|
let html = '';
|
|
switch (pluginName) {
|
|
case 'os_info': html = renderOsInfoTable(cached.data); break;
|
|
case 'cpu_monitor': html = renderCpuTable(hostname, cached.data); break;
|
|
case 'memory_monitor': html = renderMemoryTable(cached.data); break;
|
|
case 'disk_monitor': html = renderDiskTables(cached.data); break;
|
|
case 'network_monitor':html = renderNetworkTables(cached.data); break;
|
|
case 'zfs_monitor': html = renderZfsTables(cached.data); break;
|
|
case 'nagios_runner': html = renderNagiosTable(cached.data); break;
|
|
case 'filesystem_info':html = renderFilesystemTable(cached.data); break;
|
|
default: html = renderGenericTable(cached.data); break;
|
|
}
|
|
|
|
html += `<div class="timestamp">Last updated: ${new Date(cached.timestamp * 1000).toLocaleString()}</div>`;
|
|
body.innerHTML = html;
|
|
|
|
if (pluginName === 'cpu_monitor') {
|
|
fetchCpuHistory(hostname).then(samples => renderCpuChart(hostname, samples)).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── Per-plugin renderers ────────────────────────────────────────────────
|
|
|
|
function renderOsInfoTable(d) {
|
|
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
|
'processor','architecture','node','python_version',
|
|
'python_implementation',
|
|
'distro_name','distro_version','distro_id','distro_version_id'];
|
|
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
|
|
const shown = new Set(ORDER);
|
|
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
|
|
|
|
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
|
|
for (const k of keys) {
|
|
if (SKIP_FIELDS.has(k) || !(k in d)) continue;
|
|
html += `<tr><td class="key">${formatLabel(k)}</td><td>${escHtml(String(d[k]))}</td></tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
return html;
|
|
}
|
|
|
|
async function fetchCpuHistory(hostname) {
|
|
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/plugins/cpu_monitor?limit=100`);
|
|
if (!r.ok) return [];
|
|
const json = await r.json();
|
|
return json.samples || [];
|
|
}
|
|
|
|
function renderCpuChart(hostname, samples) {
|
|
const el = document.getElementById(`cpu-chart-${hostname}`);
|
|
if (!el || !samples.length) return;
|
|
|
|
const pts = samples
|
|
.filter(s => s.data.cpu_percent != null)
|
|
.map(s => ({ t: s.timestamp, v: s.data.cpu_percent }));
|
|
if (pts.length < 2) { el.style.display = 'none'; return; }
|
|
|
|
const W = 600, H = 80, PAD = { top: 6, right: 8, bottom: 18, left: 28 };
|
|
const cW = W - PAD.left - PAD.right;
|
|
const cH = H - PAD.top - PAD.bottom;
|
|
|
|
const tMin = pts[0].t, tMax = pts[pts.length - 1].t;
|
|
const tRange = tMax - tMin || 1;
|
|
const x = t => PAD.left + ((t - tMin) / tRange) * cW;
|
|
const y = v => PAD.top + cH - (Math.min(v, 100) / 100) * cH;
|
|
|
|
// Build polyline points and filled area path
|
|
const linePoints = pts.map(p => `${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ');
|
|
const areaPath = `M${x(pts[0].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} ` +
|
|
pts.map(p => `L${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') +
|
|
` L${x(pts[pts.length-1].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} Z`;
|
|
|
|
// Color based on latest value
|
|
const latest = pts[pts.length - 1].v;
|
|
const strokeColor = latest > 90 ? '#e53935' : latest > 70 ? '#fb8c00' : '#43a047';
|
|
const fillColor = latest > 90 ? '#ffcdd2' : latest > 70 ? '#ffe0b2' : '#c8e6c9';
|
|
|
|
// Y-axis grid lines at 25, 50, 75, 100
|
|
let gridLines = '';
|
|
for (const pct of [25, 50, 75, 100]) {
|
|
const yy = y(pct).toFixed(1);
|
|
gridLines += `<line x1="${PAD.left}" y1="${yy}" x2="${PAD.left + cW}" y2="${yy}" stroke="#e0e0e0" stroke-width="1"/>`;
|
|
gridLines += `<text x="${(PAD.left - 3).toFixed(1)}" y="${yy}" text-anchor="end" dominant-baseline="middle" font-size="8" fill="#999">${pct}</text>`;
|
|
}
|
|
|
|
// X-axis time labels
|
|
const fmt = ts => {
|
|
const d = new Date(ts * 1000);
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
};
|
|
const xLabels = `
|
|
<text x="${PAD.left}" y="${H - 2}" text-anchor="start" font-size="8" fill="#999">${fmt(pts[0].t)}</text>
|
|
<text x="${PAD.left + cW}" y="${H - 2}" text-anchor="end" font-size="8" fill="#999">${fmt(pts[pts.length-1].t)}</text>`;
|
|
|
|
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"
|
|
style="width:100%;height:${H}px;display:block;">
|
|
<defs>
|
|
<clipPath id="cpu-clip-${hostname}">
|
|
<rect x="${PAD.left}" y="${PAD.top}" width="${cW}" height="${cH}"/>
|
|
</clipPath>
|
|
</defs>
|
|
${gridLines}
|
|
<line x1="${PAD.left}" y1="${PAD.top}" x2="${PAD.left}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
|
|
<line x1="${PAD.left}" y1="${PAD.top + cH}" x2="${PAD.left + cW}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
|
|
<g clip-path="url(#cpu-clip-${hostname})">
|
|
<path d="${areaPath}" fill="${fillColor}" opacity="0.6"/>
|
|
<polyline points="${linePoints}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linejoin="round"/>
|
|
</g>
|
|
${xLabels}
|
|
</svg>`;
|
|
}
|
|
|
|
function renderCpuTable(hostname, d) {
|
|
const KEYS = [
|
|
['cpu_percent', 'CPU Usage', 'bar'],
|
|
['load_1min', 'Load (1 min)', 'num'],
|
|
['load_5min', 'Load (5 min)', 'num'],
|
|
['load_15min', 'Load (15 min)', 'num'],
|
|
['cpu_core_count', 'Core Count', 'int'],
|
|
['process_count', 'Process Count', 'int'],
|
|
['cpu_freq_current', 'CPU Freq (current)', 'num'],
|
|
['cpu_freq_min', 'CPU Freq (min)', 'num'],
|
|
['cpu_freq_max', 'CPU Freq (max)', 'num'],
|
|
['cpu_user', 'CPU User %', 'bar'],
|
|
['cpu_system', 'CPU System %', 'bar'],
|
|
['cpu_idle', 'CPU Idle %', 'pct'],
|
|
['cpu_iowait', 'CPU I/O Wait %', 'bar'],
|
|
];
|
|
|
|
const handled = new Set(KEYS.map(r => r[0]));
|
|
let html = `<div id="cpu-chart-${hostname}" style="margin-bottom:8px;"></div>`;
|
|
html += '<table class="data-table"><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>';
|
|
for (const [k, label, fmt] of KEYS) {
|
|
if (!(k in d)) continue;
|
|
const v = d[k];
|
|
let cell;
|
|
if (fmt === 'bar' && typeof v === 'number') {
|
|
const cls = v > 90 ? 'crit' : v > 70 ? 'warn' : '';
|
|
cell = `<div class="bar-wrap">${v.toFixed(1)}%
|
|
<div class="bar-track"><div class="bar-fill ${cls}" style="width:${Math.min(v,100).toFixed(1)}%"></div></div>
|
|
</div>`;
|
|
} else if (fmt === 'pct') {
|
|
cell = typeof v === 'number' ? v.toFixed(1) + '%' : escHtml(String(v));
|
|
} else if (fmt === 'int') {
|
|
cell = typeof v === 'number' ? v.toLocaleString() : escHtml(String(v));
|
|
} else {
|
|
cell = typeof v === 'number' ? v.toFixed(2) : escHtml(String(v));
|
|
}
|
|
html += `<tr><td class="key">${label}</td><td>${cell}</td></tr>`;
|
|
}
|
|
// Any remaining scalar fields
|
|
for (const [k, v] of Object.entries(d)) {
|
|
if (handled.has(k) || SKIP_FIELDS.has(k)) continue;
|
|
if (typeof v === 'object' || Array.isArray(v)) continue;
|
|
html += `<tr><td class="key">${formatLabel(k)}</td><td>${escHtml(String(v))}</td></tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
return html;
|
|
}
|
|
|
|
function renderMemoryTable(d) {
|
|
const RAM_KEYS = [
|
|
['memory_percent', 'Usage', 'bar'],
|
|
['memory_total', 'Total', 'bytes'],
|
|
['memory_available','Available', 'bytes'],
|
|
['memory_used', 'Used', 'bytes'],
|
|
['memory_free', 'Free', 'bytes'],
|
|
['memory_active', 'Active', 'bytes'],
|
|
['memory_inactive', 'Inactive', 'bytes'],
|
|
['memory_buffers', 'Buffers', 'bytes'],
|
|
['memory_cached', 'Cached', 'bytes'],
|
|
['memory_shared', 'Shared', 'bytes'],
|
|
];
|
|
const SWAP_KEYS = [
|
|
['swap_percent','Usage', 'bar'],
|
|
['swap_total', 'Total', 'bytes'],
|
|
['swap_used', 'Used', 'bytes'],
|
|
['swap_free', 'Free', 'bytes'],
|
|
['swap_sin', 'Swapped In', 'bytes'],
|
|
['swap_sout', 'Swapped Out','bytes'],
|
|
];
|
|
|
|
function buildSection(keys, warnAt=80, critAt=95) {
|
|
let t = '<table class="data-table"><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>';
|
|
for (const [k, label, fmt] of keys) {
|
|
if (!(k in d)) continue;
|
|
const v = d[k];
|
|
let cell;
|
|
if (fmt === 'bar' && typeof v === 'number') {
|
|
const cls = v > critAt ? 'crit' : v > warnAt ? 'warn' : '';
|
|
cell = `<div class="bar-wrap">${v.toFixed(1)}%
|
|
<div class="bar-track"><div class="bar-fill ${cls}" style="width:${Math.min(v,100).toFixed(1)}%"></div></div>
|
|
</div>`;
|
|
} else if (fmt === 'bytes') {
|
|
cell = formatBytes(v);
|
|
} else {
|
|
cell = escHtml(String(v));
|
|
}
|
|
t += `<tr><td class="key">${label}</td><td>${cell}</td></tr>`;
|
|
}
|
|
t += '</tbody></table>';
|
|
return t;
|
|
}
|
|
|
|
const hasSwap = SWAP_KEYS.some(([k]) => k in d);
|
|
if (!hasSwap) return buildSection(RAM_KEYS);
|
|
return `<div class="flex-tables">
|
|
<div><div class="table-section-label">RAM</div>${buildSection(RAM_KEYS)}</div>
|
|
<div><div class="table-section-label">Swap</div>${buildSection(SWAP_KEYS, 50, 80)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderDiskTables(d) {
|
|
let html = '';
|
|
|
|
if (d.partitions && Object.keys(d.partitions).length) {
|
|
let pt = '<table class="data-table"><thead><tr>'
|
|
+ '<th>Mount</th><th>Device</th><th>Type</th>'
|
|
+ '<th class="num">Total</th><th class="num">Used</th>'
|
|
+ '<th class="num">Free</th><th class="num">Use %</th>'
|
|
+ '</tr></thead><tbody>';
|
|
for (const [mp, pd] of Object.entries(d.partitions)) {
|
|
const pct = pd.percent || 0;
|
|
const pctCls = pct > 90 ? 'pct-crit' : pct > 75 ? 'pct-warn' : 'pct-ok';
|
|
pt += `<tr>
|
|
<td class="iface-name">${escHtml(mp)}</td>
|
|
<td>${escHtml(pd.device || '—')}</td>
|
|
<td>${escHtml(pd.fstype || '—')}</td>
|
|
<td class="num">${formatBytes(pd.total || 0)}</td>
|
|
<td class="num">${formatBytes(pd.used || 0)}</td>
|
|
<td class="num">${formatBytes(pd.free || 0)}</td>
|
|
<td class="num ${pctCls}">${pct.toFixed(1)}%</td>
|
|
</tr>`;
|
|
}
|
|
pt += '</tbody></table>';
|
|
|
|
let iot = '';
|
|
if (d.io_counters && Object.keys(d.io_counters).length) {
|
|
const sample = Object.values(d.io_counters)[0];
|
|
const hasDelta = 'read_bytes_delta' in sample;
|
|
iot = '<table class="data-table"><thead><tr><th>Disk</th>'
|
|
+ '<th class="num">Read</th><th class="num">Write</th>';
|
|
if (hasDelta) iot += '<th class="num">Δ Read</th><th class="num">Δ Write</th>';
|
|
iot += '<th class="num">Reads</th><th class="num">Writes</th>'
|
|
+ '</tr></thead><tbody>';
|
|
for (const [disk, dd] of Object.entries(d.io_counters)) {
|
|
iot += `<tr>
|
|
<td class="iface-name">${escHtml(disk)}</td>
|
|
<td class="num">${formatBytes(dd.read_bytes || 0)}</td>
|
|
<td class="num">${formatBytes(dd.write_bytes || 0)}</td>`;
|
|
if (hasDelta) {
|
|
iot += `<td class="num">${formatBytes(dd.read_bytes_delta || 0)}</td>
|
|
<td class="num">${formatBytes(dd.write_bytes_delta || 0)}</td>`;
|
|
}
|
|
iot += `<td class="num">${(dd.read_count || 0).toLocaleString()}</td>
|
|
<td class="num">${(dd.write_count || 0).toLocaleString()}</td>
|
|
</tr>`;
|
|
}
|
|
iot += '</tbody></table>';
|
|
}
|
|
|
|
html += iot
|
|
? `<div class="flex-tables">
|
|
<div><div class="table-section-label">Partitions</div>${pt}</div>
|
|
<div><div class="table-section-label">I/O Counters</div>${iot}</div>
|
|
</div>`
|
|
: `<div class="table-section-label">Partitions</div>${pt}`;
|
|
}
|
|
|
|
return html || '<div class="no-data">No disk data available</div>';
|
|
}
|
|
|
|
function renderNetworkTables(d) {
|
|
let html = '';
|
|
|
|
if (d.interfaces && Object.keys(d.interfaces).length) {
|
|
const sample = Object.values(d.interfaces)[0];
|
|
const hasDelta = 'bytes_sent_delta' in sample;
|
|
let t = '<table class="data-table"><thead><tr><th>Interface</th>'
|
|
+ '<th class="num">Sent</th><th class="num">Recv</th>';
|
|
if (hasDelta) t += '<th class="num">Δ Sent/s</th><th class="num">Δ Recv/s</th>';
|
|
t += '<th class="num">Pkts Sent</th><th class="num">Pkts Recv</th>'
|
|
+ '</tr></thead><tbody>';
|
|
for (const [iface, idata] of Object.entries(d.interfaces)) {
|
|
t += `<tr>
|
|
<td class="iface-name">${escHtml(iface)}</td>
|
|
<td class="num">${formatBytes(idata.bytes_sent || 0)}</td>
|
|
<td class="num">${formatBytes(idata.bytes_recv || 0)}</td>`;
|
|
if (hasDelta) {
|
|
t += `<td class="num"><strong>${formatBytes(idata.bytes_sent_delta || 0)}/s</strong></td>
|
|
<td class="num"><strong>${formatBytes(idata.bytes_recv_delta || 0)}/s</strong></td>`;
|
|
}
|
|
t += `<td class="num">${(idata.packets_sent || 0).toLocaleString()}</td>
|
|
<td class="num">${(idata.packets_recv || 0).toLocaleString()}</td>
|
|
</tr>`;
|
|
}
|
|
t += '</tbody></table>';
|
|
html += `<div class="table-section-label">Traffic</div>${t}`;
|
|
}
|
|
|
|
if (d.interface_stats && Object.keys(d.interface_stats).length) {
|
|
let t = '<table class="data-table"><thead><tr>'
|
|
+ '<th>Interface</th><th>Status</th>'
|
|
+ '<th class="num">Speed</th><th>Duplex</th><th class="num">MTU</th>'
|
|
+ '</tr></thead><tbody>';
|
|
for (const [iface, idata] of Object.entries(d.interface_stats)) {
|
|
const isUp = idata.isup;
|
|
const statusCls = isUp ? 'status-up' : 'status-down';
|
|
const statusTxt = isUp ? '✓ UP' : '✗ DOWN';
|
|
|
|
const speed = idata.speed || 0;
|
|
const speedTxt = speed <= 0 ? '—'
|
|
: speed >= 1000 ? (speed/1000).toFixed(1) + ' Gbps'
|
|
: speed + ' Mbps';
|
|
|
|
let duplex = idata.duplex || '—';
|
|
duplex = duplex.replace('NicDuplex.', '');
|
|
if (duplex === '2') duplex = 'FULL';
|
|
else if (duplex === '1') duplex = 'HALF';
|
|
else if (duplex === '0') duplex = 'UNKNOWN';
|
|
|
|
let addrTitle = '';
|
|
if (d.addresses?.[iface]) {
|
|
addrTitle = d.addresses[iface]
|
|
.filter(a => a.family && a.family.includes('AF_INET'))
|
|
.map(a => a.address).join(', ');
|
|
}
|
|
|
|
t += `<tr>
|
|
<td class="iface-name" title="${escHtml(addrTitle)}">${escHtml(iface)}</td>
|
|
<td class="${statusCls}">${statusTxt}</td>
|
|
<td class="num">${speedTxt}</td>
|
|
<td>${escHtml(duplex)}</td>
|
|
<td class="num">${idata.mtu || '—'}</td>
|
|
</tr>`;
|
|
}
|
|
t += '</tbody></table>';
|
|
html += `<div class="table-section-label" style="margin-top:10px;">Interface Status</div>${t}`;
|
|
}
|
|
|
|
if (d.connections && Object.keys(d.connections).length) {
|
|
const states = Object.entries(d.connections).filter(([, v]) => v > 0);
|
|
if (states.length) {
|
|
let t = '<table class="data-table"><thead><tr>';
|
|
states.forEach(([s]) => t += `<th class="center">${escHtml(s)}</th>`);
|
|
t += '</tr></thead><tbody><tr>';
|
|
states.forEach(([, v]) => t += `<td class="center">${v}</td>`);
|
|
t += '</tr></tbody></table>';
|
|
html += `<div class="table-section-label" style="margin-top:10px;">TCP Connections</div>${t}`;
|
|
}
|
|
}
|
|
|
|
return html || '<div class="no-data">No network data available</div>';
|
|
}
|
|
|
|
function renderNagiosTable(d) {
|
|
const checkNames = new Set();
|
|
for (const k of Object.keys(d)) {
|
|
const m = k.match(/^(.+)_status_code$/);
|
|
if (m) checkNames.add(m[1]);
|
|
}
|
|
|
|
if (checkNames.size === 0) return renderGenericTable(d);
|
|
|
|
let html = '<table class="data-table"><thead><tr>'
|
|
+ '<th>Check</th><th class="center">Status</th><th>Output</th>'
|
|
+ '</tr></thead><tbody>';
|
|
|
|
for (const check of [...checkNames].sort()) {
|
|
const status = (d[`${check}_status`] || '?').toUpperCase();
|
|
const output = d[`${check}_output`] || '';
|
|
const rowCls = status === 'OK' ? 'check-ok'
|
|
: status === 'WARNING' ? 'check-warning'
|
|
: status === 'CRITICAL' ? 'check-critical' : 'check-unknown';
|
|
const statusCls = status === 'OK' ? 'check-status-ok'
|
|
: status === 'WARNING' ? 'check-status-warning'
|
|
: status === 'CRITICAL' ? 'check-status-critical' : 'check-status-unknown';
|
|
const label = check.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
const truncated = output.length > 120 ? output.slice(0, 117) + '…' : output;
|
|
|
|
html += `<tr class="${rowCls}">
|
|
<td>${escHtml(label)}</td>
|
|
<td class="center ${statusCls}">${escHtml(status)}</td>
|
|
<td class="check-output" title="${escHtml(output)}">${escHtml(truncated)}</td>
|
|
</tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
return html;
|
|
}
|
|
|
|
function renderFilesystemTable(d) {
|
|
const fs = d.filesystems;
|
|
if (!fs || !fs.length) return '<div class="no-data">No filesystem data</div>';
|
|
|
|
let html = '<table class="data-table"><thead><tr>'
|
|
+ '<th>Device</th><th>Mount</th><th>Type</th>'
|
|
+ '<th>Options</th><th class="num">Max File</th><th class="num">Max Path</th>'
|
|
+ '</tr></thead><tbody>';
|
|
|
|
for (const f of fs) {
|
|
const opts = f.opts || '—';
|
|
const display = opts.length > 50 ? opts.slice(0, 47) + '…' : opts;
|
|
html += `<tr>
|
|
<td class="iface-name">${escHtml(f.device || '—')}</td>
|
|
<td>${escHtml(f.mountpoint || '—')}</td>
|
|
<td>${escHtml(f.fstype || '—')}</td>
|
|
<td style="font-size:0.82em;" title="${escHtml(opts)}">${escHtml(display)}</td>
|
|
<td class="num">${f.maxfile || '—'}</td>
|
|
<td class="num">${f.maxpath || '—'}</td>
|
|
</tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
return html;
|
|
}
|
|
|
|
function renderZfsTables(d) {
|
|
const pools = d.pools || {};
|
|
const names = Object.keys(pools);
|
|
if (names.length === 0) return '<div class="no-data">No ZFS pools found</div>';
|
|
|
|
const healthCls = h => {
|
|
if (!h || h === 'ONLINE') return 'pct-ok';
|
|
if (h === 'DEGRADED') return 'pct-warn';
|
|
return 'pct-crit';
|
|
};
|
|
|
|
let pt = '<table class="data-table"><thead><tr>'
|
|
+ '<th>Pool</th><th>Health</th>'
|
|
+ '<th class="num">Size</th><th class="num">Used</th>'
|
|
+ '<th class="num">Free</th><th class="num">Cap %</th>'
|
|
+ '<th class="num">Frag %</th><th class="num">Dedup</th>'
|
|
+ '</tr></thead><tbody>';
|
|
for (const name of names) {
|
|
const p = pools[name];
|
|
const cap = p.capacity != null ? p.capacity : 0;
|
|
const capCls = cap > 90 ? 'pct-crit' : cap > 75 ? 'pct-warn' : 'pct-ok';
|
|
pt += `<tr>
|
|
<td class="iface-name">${escHtml(name)}</td>
|
|
<td class="${healthCls(p.health)}">${escHtml(p.health || '—')}</td>
|
|
<td class="num">${formatBytes(p.size || 0)}</td>
|
|
<td class="num">${formatBytes(p.alloc || 0)}</td>
|
|
<td class="num">${formatBytes(p.free || 0)}</td>
|
|
<td class="num ${capCls}">${cap.toFixed(1)}%</td>
|
|
<td class="num">${p.frag != null ? p.frag.toFixed(1) + '%' : '—'}</td>
|
|
<td class="num">${p.dedup != null ? p.dedup.toFixed(2) + 'x' : '—'}</td>
|
|
</tr>`;
|
|
}
|
|
pt += '</tbody></table>';
|
|
|
|
const hasIo = names.some(n => pools[n].read_ops != null);
|
|
if (!hasIo) return pt;
|
|
|
|
let iot = '<table class="data-table"><thead><tr>'
|
|
+ '<th>Pool</th>'
|
|
+ '<th class="num">Read ops</th><th class="num">Write ops</th>'
|
|
+ '<th class="num">Read BW</th><th class="num">Write BW</th>'
|
|
+ '</tr></thead><tbody>';
|
|
for (const name of names) {
|
|
const p = pools[name];
|
|
iot += `<tr>
|
|
<td class="iface-name">${escHtml(name)}</td>
|
|
<td class="num">${p.read_ops != null ? p.read_ops.toLocaleString() : '—'}</td>
|
|
<td class="num">${p.write_ops != null ? p.write_ops.toLocaleString() : '—'}</td>
|
|
<td class="num">${p.read_bw != null ? formatBytes(p.read_bw) : '—'}</td>
|
|
<td class="num">${p.write_bw != null ? formatBytes(p.write_bw) : '—'}</td>
|
|
</tr>`;
|
|
}
|
|
iot += '</tbody></table>';
|
|
|
|
return `<div class="flex-tables">
|
|
<div><div class="table-section-label">Pools</div>${pt}</div>
|
|
<div><div class="table-section-label">I/O (cumulative)</div>${iot}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderGenericTable(d) {
|
|
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
|
|
for (const [k, v] of Object.entries(d)) {
|
|
if (SKIP_FIELDS.has(k) || typeof v === 'object') continue;
|
|
html += `<tr><td class="key">${formatLabel(k)}</td><td>${escHtml(String(v))}</td></tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
return html;
|
|
}
|
|
|
|
// ── Utilities ───────────────────────────────────────────────────────────
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes || bytes === 0) return '0 B';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
return (bytes / 1073741824).toFixed(2) + ' GB';
|
|
}
|
|
|
|
function formatLabel(key) {
|
|
if (key === 'time') return 'Collected At';
|
|
return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
}
|
|
|
|
function escHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// ── Auto-refresh (30 s) ─────────────────────────────────────────────────
|
|
|
|
setInterval(() => {
|
|
document.querySelectorAll('.host-card').forEach(card => {
|
|
fetchHostGlance(card.dataset.hostname);
|
|
});
|
|
|
|
document.querySelectorAll('.host-card:not(.collapsed)').forEach(card => {
|
|
const hostname = card.dataset.hostname;
|
|
|
|
card.querySelectorAll('.plugin-accordion:not(.collapsed)').forEach(acc => {
|
|
const pname = acc.dataset.plugin;
|
|
if (!GLANCE_PLUGINS.includes(pname)) {
|
|
fetchPlugin(hostname, pname).then(sample => {
|
|
if (sample) {
|
|
setCache(hostname, pname, sample);
|
|
renderPluginBody(hostname, pname);
|
|
updateAccordionSummary(hostname, pname);
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
});
|
|
});
|
|
}, 30000);
|
|
|
|
// ── Init ────────────────────────────────────────────────────────────────
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Fetch glance data for every host immediately so the strip is always populated.
|
|
document.querySelectorAll('.host-card').forEach(card => {
|
|
fetchHostGlance(card.dataset.hostname);
|
|
});
|
|
|
|
// Expand and load info for the target host (URL hash or first host).
|
|
function expandHost(hostname) {
|
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
|
if (!card) return false;
|
|
card.classList.remove('collapsed');
|
|
fetchHostInfo(hostname).then(data => {
|
|
infoCache[hostname] = data;
|
|
renderInfoSection(hostname, data);
|
|
}).catch(() => {
|
|
const el = document.getElementById(`info-${hostname}`);
|
|
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
|
|
});
|
|
return true;
|
|
}
|
|
|
|
const hash = window.location.hash;
|
|
if (hash) {
|
|
const hostname = decodeURIComponent(hash.slice(1));
|
|
if (expandHost(hostname)) {
|
|
setTimeout(() => {
|
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}, 150);
|
|
return;
|
|
}
|
|
}
|
|
const first = document.querySelector('.host-card');
|
|
if (first) expandHost(first.dataset.hostname);
|
|
});
|
|
// ── Host action helpers ──────────────────────────────────────
|
|
|
|
let _toastTimer = null;
|
|
function showToast(msg, isError) {
|
|
const t = document.getElementById('action-toast');
|
|
t.textContent = msg;
|
|
t.classList.toggle('error', !!isError);
|
|
t.classList.add('show');
|
|
clearTimeout(_toastTimer);
|
|
_toastTimer = setTimeout(() => t.classList.remove('show'), 4000);
|
|
}
|
|
|
|
async function hostAction(btn, url) {
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await fetch(url);
|
|
const text = await res.text();
|
|
showToast(text, !res.ok);
|
|
} catch (e) {
|
|
showToast('Request failed: ' + e.message, true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function hostDelete(btn, hostname) {
|
|
if (!confirm('Delete host ' + hostname + '?')) return;
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await fetch('/d?h=' + encodeURIComponent(hostname));
|
|
const text = await res.text();
|
|
showToast(text, !res.ok);
|
|
if (res.ok) {
|
|
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
|
if (card) card.remove();
|
|
}
|
|
} catch (e) {
|
|
showToast('Request failed: ' + e.message, true);
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div id="action-toast"></div>
|
|
</body>
|
|
</html>
|