Files
sysmon/templates/index.html
kamaji 695f4ef6dc Add guest disk usage via virsh guestinfo --filesystem
Query each running VM's filesystem stats through the guest agent.
Show root filesystem used/total GB with color-coded bar in VM table.
Cached at 30s TTL since disk usage changes slowly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:56:25 -06:00

430 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Monitor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d1117; color: #c9d1d9;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 14px; padding: 20px;
}
a { color: inherit; text-decoration: none; }
h1 { font-size: 18px; color: #58a6ff; margin-bottom: 4px; cursor: pointer; }
.subtitle { color: #484f58; font-size: 12px; margin-bottom: 20px; }
.section { margin-bottom: 24px; }
.section-title {
font-size: 13px; color: #8b949e; text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 10px; border-bottom: 1px solid #21262d;
padding-bottom: 6px;
}
/* Status indicator */
.status {
position: fixed; top: 12px; right: 20px; font-size: 11px; color: #484f58;
}
.status-dot {
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
background: #238636; margin-right: 4px; vertical-align: middle;
}
/* Dashboard cards */
.card-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
.server-card {
background: #161b22; border: 1px solid #21262d; border-radius: 8px;
padding: 16px; cursor: pointer; transition: border-color 0.2s;
}
.server-card:hover { border-color: #58a6ff; }
.server-card.unreachable { opacity: 0.5; cursor: default; }
.card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px;
}
.card-name { font-size: 16px; color: #58a6ff; font-weight: bold; }
.badge {
font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: bold;
}
.badge.online { background: #238636; color: #fff; }
.badge.unreachable { background: #da3633; color: #fff; }
.card-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.card-stat {
background: #0d1117; border-radius: 4px; padding: 8px;
}
.card-stat-label { font-size: 10px; color: #484f58; text-transform: uppercase; }
.card-stat-value { font-size: 16px; font-weight: bold; margin-top: 2px; }
.card-mem-bar {
height: 6px; background: #21262d; border-radius: 3px; overflow: hidden;
margin-top: 4px;
}
.card-mem-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
/* Back link */
.back-link {
display: inline-flex; align-items: center; gap: 6px;
color: #8b949e; font-size: 13px; margin-bottom: 16px; cursor: pointer;
}
.back-link:hover { color: #58a6ff; }
/* Detail view server header */
.detail-header {
display: flex; align-items: center; gap: 10px; margin-bottom: 20px;
}
.detail-name { font-size: 20px; color: #58a6ff; font-weight: bold; }
/* CPU grid */
.cpu-grid { display: grid; gap: 4px; }
.core-cell {
background: #161b22; border: 1px solid #21262d; border-radius: 4px;
padding: 4px; text-align: center; position: relative; overflow: hidden;
min-height: 44px; display: flex; flex-direction: column; justify-content: center;
}
.core-id { font-size: 10px; color: #484f58; }
.core-pct { font-size: 13px; font-weight: bold; }
.core-bar {
position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
background: #21262d; border-radius: 0 0 3px 3px;
}
.core-bar-fill {
height: 100%; border-radius: 0 0 3px 3px;
transition: width 0.3s ease, background-color 0.3s ease;
}
/* Bars */
.bar-container {
background: #161b22; border: 1px solid #21262d; border-radius: 6px;
height: 32px; position: relative; overflow: hidden;
}
.mem-bar { display: flex; height: 100%; }
.mem-bar > div {
height: 100%; display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: bold; white-space: nowrap; overflow: hidden;
transition: width 0.3s ease;
}
.mem-used { background: #da3633; }
.mem-cached { background: #d29922; }
.mem-free { background: #238636; }
.mem-details {
display: flex; gap: 20px; margin-top: 6px; font-size: 12px; color: #8b949e;
flex-wrap: wrap;
}
.mem-details span { display: flex; align-items: center; gap: 4px; }
.mem-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
/* Load */
.load-row { display: flex; gap: 16px; flex-wrap: wrap; }
.load-item {
background: #161b22; border: 1px solid #21262d; border-radius: 6px;
padding: 12px 20px; text-align: center; flex: 1; min-width: 120px;
}
.load-label { font-size: 11px; color: #484f58; margin-bottom: 4px; }
.load-value { font-size: 24px; font-weight: bold; }
/* Uptime */
.uptime {
background: #161b22; border: 1px solid #21262d; border-radius: 6px;
padding: 10px 16px; display: inline-block; font-size: 15px;
}
/* VM table */
.vm-table {
width: 100%; border-collapse: collapse;
background: #161b22; border: 1px solid #21262d; border-radius: 6px;
overflow: hidden;
}
.vm-table th {
text-align: left; padding: 8px 12px; font-size: 11px; color: #484f58;
text-transform: uppercase; letter-spacing: 0.5px;
border-bottom: 1px solid #21262d; background: #0d1117;
}
.vm-table td {
padding: 8px 12px; border-bottom: 1px solid #21262d; font-size: 13px;
}
.vm-table tr:last-child td { border-bottom: none; }
.vm-state {
font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: bold;
display: inline-block;
}
.vm-state.running { background: #238636; color: #fff; }
.vm-state.shut-off { background: #484f58; color: #c9d1d9; }
.vm-state.other { background: #d29922; color: #fff; }
.vm-none { color: #484f58; font-style: italic; padding: 16px; }
.vm-mem-bar {
display: inline-block; width: 60px; height: 8px; background: #21262d;
border-radius: 4px; overflow: hidden; vertical-align: middle; margin-left: 6px;
}
.vm-mem-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
.vm-table td.vm-cpu { font-weight: bold; }
.vm-table td.vm-mem { white-space: nowrap; }
.vm-table td.vm-disk { white-space: nowrap; }
@media (max-width: 700px) {
body { padding: 12px; }
.card-grid { grid-template-columns: 1fr; }
.card-stats { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<h1 onclick="navigateTo('dashboard')">System Monitor</h1>
<div class="subtitle" id="subtitle">connecting...</div>
<div class="status"><span class="status-dot" id="status-dot"></span><span id="status-text">connecting...</span></div>
<div id="app"></div>
<script>
const appEl = document.getElementById('app');
let latestData = null;
let currentView = 'dashboard';
function usageColor(pct) {
if (pct < 30) return '#238636';
if (pct < 60) return '#d29922';
if (pct < 85) return '#da3633';
return '#f85149';
}
function loadColor(val, numCores) {
const ratio = val / numCores;
if (ratio < 0.3) return '#238636';
if (ratio < 0.6) return '#d29922';
if (ratio < 0.9) return '#da3633';
return '#f85149';
}
function formatMB(mb) {
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB';
return mb + ' MB';
}
function gridCols(numCores) {
if (numCores <= 4) return 2;
if (numCores <= 8) return 4;
if (numCores <= 16) return 4;
return 8;
}
function vmStateClass(state) {
if (state === 'running') return 'running';
if (state === 'shut off') return 'shut-off';
return 'other';
}
function navigateTo(view) {
if (view === 'dashboard') {
window.location.hash = '';
} else {
window.location.hash = view;
}
}
function getView() {
const hash = window.location.hash.replace('#', '');
if (!hash || hash === 'dashboard') return 'dashboard';
return hash;
}
// ---- Dashboard View ----
function renderDashboard(servers) {
let html = '<div class="card-grid">';
for (const srv of servers) {
if (srv.status === 'unreachable') {
html += '<div class="server-card unreachable">' +
'<div class="card-header">' +
'<span class="card-name">' + srv.name + '</span>' +
'<span class="badge unreachable">unreachable</span>' +
'</div>' +
'<div style="color:#484f58;font-style:italic;text-align:center;padding:12px">Server unreachable</div>' +
'</div>';
continue;
}
const mem = srv.memory;
const vmCount = (srv.vms || []).length;
const runningVms = (srv.vms || []).filter(v => v.state === 'running').length;
html += '<div class="server-card" onclick="navigateTo(\'' + srv.name + '\')">' +
'<div class="card-header">' +
'<span class="card-name">' + srv.name + '</span>' +
'<span class="badge online">online</span>' +
'</div>' +
'<div class="card-stats">' +
'<div class="card-stat">' +
'<div class="card-stat-label">CPU</div>' +
'<div class="card-stat-value" style="color:' + usageColor(srv.overall_cpu) + '">' + srv.overall_cpu + '%</div>' +
'</div>' +
'<div class="card-stat">' +
'<div class="card-stat-label">Memory</div>' +
'<div class="card-stat-value" style="color:' + usageColor(mem.percent) + '">' + mem.percent + '%</div>' +
'<div class="card-mem-bar"><div class="card-mem-fill" style="width:' + mem.percent + '%;background:' + usageColor(mem.percent) + '"></div></div>' +
'</div>' +
'<div class="card-stat">' +
'<div class="card-stat-label">Load (1m)</div>' +
'<div class="card-stat-value" style="color:' + loadColor(srv.load.load1, srv.num_cores) + '">' + srv.load.load1.toFixed(2) + '</div>' +
'</div>' +
'<div class="card-stat">' +
'<div class="card-stat-label">Uptime</div>' +
'<div class="card-stat-value">' + srv.uptime.human + '</div>' +
'</div>' +
'</div>' +
(vmCount > 0 ? (function() {
const running = (srv.vms || []).filter(v => v.state === 'running');
const vmMemUsed = running.reduce((s, v) => s + (v.memory_used_mb || 0), 0);
const vmMemTotal = running.reduce((s, v) => s + (v.memory_total_mb || v.memory_mb || 0), 0);
return '<div style="margin-top:10px;font-size:12px;color:#8b949e">' +
'VMs: ' + runningVms + ' running / ' + vmCount + ' total' +
(vmMemTotal > 0 ? ' &middot; Mem: ' + formatMB(vmMemUsed) + ' / ' + formatMB(vmMemTotal) : '') +
'</div>';
})() : '') +
'</div>';
}
html += '</div>';
return html;
}
// ---- Detail View ----
function renderDetail(srv) {
if (!srv || srv.status === 'unreachable') {
return '<div class="back-link" onclick="navigateTo(\'dashboard\')">&larr; Dashboard</div>' +
'<div style="color:#484f58;font-style:italic;text-align:center;padding:40px">Server unreachable</div>';
}
const numCores = srv.num_cores || srv.cores.length;
let html = '<div class="back-link" onclick="navigateTo(\'dashboard\')">&larr; Dashboard</div>';
html += '<div class="detail-header"><span class="detail-name">' + srv.name + '</span><span class="badge online">online</span></div>';
// CPU
html += '<div class="section"><div class="section-title">CPU Cores &mdash; ' + srv.overall_cpu + '% overall</div>';
html += '<div class="cpu-grid" style="grid-template-columns:repeat(' + gridCols(numCores) + ',1fr)">';
for (const core of srv.cores) {
const pct = core.usage_percent;
const color = usageColor(pct);
html += '<div class="core-cell">' +
'<div class="core-id">' + core.id + '</div>' +
'<div class="core-pct" style="color:' + color + '">' + pct + '%</div>' +
'<div class="core-bar"><div class="core-bar-fill" style="width:' + pct + '%;background:' + color + '"></div></div>' +
'</div>';
}
html += '</div></div>';
// Memory
const mem = srv.memory;
const usedPct = (mem.used_mb - mem.cached_mb) / mem.total_mb * 100;
const cachedPct = mem.cached_mb / mem.total_mb * 100;
const freePct = 100 - usedPct - cachedPct;
html += '<div class="section"><div class="section-title">Memory</div>' +
'<div class="bar-container"><div class="mem-bar">' +
'<div class="mem-used" style="width:' + usedPct + '%">' + (usedPct > 5 ? formatMB(mem.used_mb - mem.cached_mb) : '') + '</div>' +
'<div class="mem-cached" style="width:' + cachedPct + '%">' + (cachedPct > 5 ? formatMB(mem.cached_mb) : '') + '</div>' +
'<div class="mem-free" style="width:' + freePct + '%">' + (freePct > 5 ? formatMB(mem.available_mb) : '') + '</div>' +
'</div></div>' +
'<div class="mem-details">' +
'<span><span class="mem-dot" style="background:#da3633"></span>Used: ' + formatMB(mem.used_mb - mem.cached_mb) + '</span>' +
'<span><span class="mem-dot" style="background:#d29922"></span>Cached: ' + formatMB(mem.cached_mb) + '</span>' +
'<span><span class="mem-dot" style="background:#238636"></span>Available: ' + formatMB(mem.available_mb) + '</span>' +
'<span style="color:#484f58">Total: ' + formatMB(mem.total_mb) + '</span>' +
'<span style="color:#484f58">' + mem.percent + '%</span>' +
'</div></div>';
// Load
const load = srv.load;
html += '<div class="section"><div class="section-title">Load Average</div>' +
'<div class="load-row">' +
'<div class="load-item"><div class="load-label">1 min</div><div class="load-value" style="color:' + loadColor(load.load1, numCores) + '">' + load.load1.toFixed(2) + '</div></div>' +
'<div class="load-item"><div class="load-label">5 min</div><div class="load-value" style="color:' + loadColor(load.load5, numCores) + '">' + load.load5.toFixed(2) + '</div></div>' +
'<div class="load-item"><div class="load-label">15 min</div><div class="load-value" style="color:' + loadColor(load.load15, numCores) + '">' + load.load15.toFixed(2) + '</div></div>' +
'</div></div>';
// Uptime
html += '<div class="section"><div class="section-title">Uptime</div>' +
'<div class="uptime">' + srv.uptime.human + '</div></div>';
// VMs
const vms = srv.vms || [];
html += '<div class="section"><div class="section-title">Virtual Machines (' + vms.length + ')</div>';
if (vms.length === 0) {
html += '<div class="vm-none">No VMs found (virsh not available or no VMs defined)</div>';
} else {
// Sort: running first, then by name
const sorted = [...vms].sort((a, b) => {
if (a.state === 'running' && b.state !== 'running') return -1;
if (a.state !== 'running' && b.state === 'running') return 1;
return a.name.localeCompare(b.name);
});
html += '<table class="vm-table"><thead><tr>' +
'<th>Name</th><th>State</th><th>CPU</th><th>Memory</th><th>Disk</th><th>vCPUs</th><th>Autostart</th>' +
'</tr></thead><tbody>';
for (const vm of sorted) {
const isRunning = vm.state === 'running';
const cpuPct = vm.cpu_percent || 0;
const memUsed = vm.memory_used_mb || 0;
const memTotal = vm.memory_total_mb || vm.memory_mb || 0;
const memPct = memTotal > 0 ? (memUsed / memTotal * 100) : 0;
const vmDisks = vm.disks || [];
const rootDisk = vmDisks.find(d => d.mountpoint === '/') || vmDisks[0];
let diskHtml = '&mdash;';
if (isRunning && rootDisk) {
const diskPct = rootDisk.total_gb > 0 ? (rootDisk.used_gb / rootDisk.total_gb * 100) : 0;
diskHtml = rootDisk.used_gb + ' / ' + rootDisk.total_gb + ' GB' +
'<div class="vm-mem-bar"><div class="vm-mem-fill" style="width:' + diskPct + '%;background:' + usageColor(diskPct) + '"></div></div>';
}
html += '<tr>' +
'<td>' + vm.name + '</td>' +
'<td><span class="vm-state ' + vmStateClass(vm.state) + '">' + vm.state + '</span></td>' +
'<td class="vm-cpu" style="color:' + (isRunning ? usageColor(cpuPct) : '#484f58') + '">' + (isRunning ? cpuPct + '%' : '&mdash;') + '</td>' +
'<td class="vm-mem">' + (isRunning && memTotal > 0 ?
formatMB(memUsed) + ' / ' + formatMB(memTotal) +
'<div class="vm-mem-bar"><div class="vm-mem-fill" style="width:' + memPct + '%;background:' + usageColor(memPct) + '"></div></div>'
: (isRunning ? formatMB(vm.memory_mb) : '&mdash;')) +
'</td>' +
'<td class="vm-disk">' + diskHtml + '</td>' +
'<td>' + vm.vcpus + '</td>' +
'<td>' + (vm.autostart ? 'yes' : 'no') + '</td>' +
'</tr>';
}
html += '</tbody></table>';
}
html += '</div>';
return html;
}
function render() {
if (!latestData) return;
const view = getView();
if (view === 'dashboard') {
appEl.innerHTML = renderDashboard(latestData.servers);
} else {
const srv = latestData.servers.find(s => s.name === view);
appEl.innerHTML = renderDetail(srv);
}
}
window.addEventListener('hashchange', render);
let fails = 0;
async function poll() {
try {
const r = await fetch('/api/servers');
latestData = await r.json();
render();
const online = latestData.servers.filter(s => s.status === 'online').length;
document.getElementById('subtitle').textContent = online + '/' + latestData.servers.length + ' servers online';
fails = 0;
document.getElementById('status-dot').style.background = '#238636';
document.getElementById('status-text').textContent = 'live';
} catch (e) {
fails++;
document.getElementById('status-dot').style.background = '#da3633';
document.getElementById('status-text').textContent = 'error (' + fails + ')';
}
}
poll();
setInterval(poll, 2000);
</script>
</body>
</html>