Varaverk: monitor grid overhaul + Containers & VMs card

Monitor layout (8-col grid):
- Row 1: System | Power | CPU | Memory | Network
- Row 2: Scripts | Partner & Fallback (span 3) | Containers & VMs (span 4)
- Row 3: GPU (span 2) | Transcode (span 2) | Streams (span 4)
- Row 4: Parity (cols 1-2) | Pools (cols 3-4) | Array (cols 5-8)

New Containers & VMs card:
- Reads FolderView3 folders from docker.json; expand/collapse per folder
- VMs listed first (virsh); containers show start/stop/webui/edit actions
- 2-column balanced layout; collapses to 1 column below 900px
- Docker WebUI URLs resolved from dockerMan template XMLs

New files: include/vms.php, include/docker_folders.php, api/docker_action.php, api/snapshot.php
Responsive: explicit grid-column placements reset at 1024px breakpoint
This commit is contained in:
Gmer4Lfe
2026-05-26 18:59:19 -04:00
parent 6078af0dbb
commit 072c8b720d
12 changed files with 689 additions and 83 deletions
@@ -2,11 +2,38 @@
<div id="vv-monitor" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<!-- Row 1: System | Power | CPU | Memory | Network -->
<div class="vv-card" id="vv-system" style="grid-column:span 1;position:relative;overflow:hidden;">
<div id="vv-system-body">Loading...</div>
</div>
<div class="vv-card" id="vv-partner" style="grid-column:span 1;">
<div class="vv-card" id="vv-ups-card" style="grid-column:span 1;">
<h3>Power</h3>
<div id="vv-ups-body">Loading...</div>
</div>
<div class="vv-card" id="vv-cpu" style="grid-column:span 2;">
<h3>CPU</h3>
<div id="vv-cpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-memory" style="grid-column:span 2;">
<h3>Memory</h3>
<div id="vv-memory-body">Loading...</div>
</div>
<div class="vv-card" id="vv-network" style="grid-column:span 2;">
<h3>Network</h3>
<div id="vv-network-body">Loading...</div>
</div>
<!-- Row 2: Scripts | Partner & Fallback (span 3) | Containers & VMs (span 4) -->
<div class="vv-card" id="vv-scripts-card" style="grid-column:span 1;">
<h3>Scripts</h3>
<div id="vv-scripts-body">Loading...</div>
</div>
<div class="vv-card" id="vv-partner" style="grid-column:span 3;">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
Partner &amp; Fallback
<svg width="32" height="18" viewBox="0 0 32 18" fill="none" xmlns="http://www.w3.org/2000/svg" style="opacity:0.45;flex-shrink:0;">
@@ -33,47 +60,13 @@
<div id="vv-partner-body">Loading...</div>
</div>
<div class="vv-card" id="vv-cpu" style="grid-column:span 2;">
<h3>CPU</h3>
<div id="vv-cpu-body">Loading...</div>
<div class="vv-card" id="vv-docker-folders" style="grid-column:span 4;">
<h3>Containers and VMs</h3>
<div id="vv-docker-folders-body">Loading...</div>
</div>
<div class="vv-card" id="vv-memory" style="grid-column:span 2;">
<h3>Memory</h3>
<div id="vv-memory-body">Loading...</div>
</div>
<div class="vv-card" id="vv-network" style="grid-column:span 2;">
<h3>Network</h3>
<div id="vv-network-body">Loading...</div>
</div>
<div class="vv-card" id="vv-ups-card" style="grid-column:span 1;">
<h3>Power</h3>
<div id="vv-ups-body">Loading...</div>
</div>
<div class="vv-card" id="vv-parity-card" style="grid-column:span 1;">
<h3>Parity</h3>
<div id="vv-parity-body">Loading...</div>
</div>
<div class="vv-card" id="vv-storage-card" style="grid-column:span 2;">
<h3>Pools</h3>
<div id="vv-storage-body">Loading...</div>
</div>
<div class="vv-card" id="vv-array-card" style="grid-column:span 4;">
<h3>Array</h3>
<div id="vv-array-body">Loading...</div>
</div>
<div class="vv-card" id="vv-scripts-card" style="grid-column:span 1;">
<h3>Scripts</h3>
<div id="vv-scripts-body">Loading...</div>
</div>
<div class="vv-card" id="vv-gpu-card" style="grid-column:span 1;">
<!-- Row 3: GPU | Transcode | Streams -->
<div class="vv-card" id="vv-gpu-card" style="grid-column:span 2;">
<h3>GPU</h3>
<div id="vv-gpu-body">Loading...</div>
</div>
@@ -88,14 +81,23 @@
<div id="vv-streams-body">Loading...</div>
</div>
<div class="vv-card" id="vv-docker" style="grid-column:span 8;">
<h3>Containers</h3>
<table id="vv-docker-table">
<thead><tr><th>Name</th><th>Status</th><th>Image</th></tr></thead>
<tbody id="vv-docker-body"><tr><td colspan="3">Loading...</td></tr></tbody>
</table>
<!-- Row 4: Parity (cols 1-2) | Pools (col 3, span 2) | Array (col 5, span 4) -->
<div class="vv-card" id="vv-parity-card" style="grid-column:1/span 2;">
<h3>Parity</h3>
<div id="vv-parity-body">Loading...</div>
</div>
<div class="vv-card" id="vv-storage-card" style="grid-column:3/span 2;">
<h3>Pools</h3>
<div id="vv-storage-body">Loading...</div>
</div>
<div class="vv-card" id="vv-array-card" style="grid-column:5/span 4;">
<h3>Array</h3>
<div id="vv-array-body">Loading...</div>
</div>
</div>
<script>
@@ -251,14 +253,14 @@ function vvRenderCpu(cpu) {
// Per-core vertical bars
if (cores.length) {
html += `<div style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;">`;
html += `<div class="vv-cpu-cores" style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;">`;
cores.forEach(c => {
const usePct = c.usage_pct ?? 0;
const hue = Math.round(120 * (1 - usePct / 100));
const color = `hsl(${hue},70%,45%)`;
const barH = Math.max(2, Math.round(usePct * 0.46)); // max ~46px at 100%
const label = c.freq_mhz ? (c.freq_mhz >= 1000 ? (c.freq_mhz/1000).toFixed(1)+'G' : c.freq_mhz+'M') : '';
html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;min-width:10px;">
html += `<div class="vv-cpu-core" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;min-width:10px;">
<div style="font-size:8px;color:#555;line-height:1;">${label}</div>
<div style="width:100%;height:46px;background:#1a1a1a;border-radius:2px;display:flex;align-items:flex-end;overflow:hidden;">
<div style="width:100%;height:${barH}px;background:${color};border-radius:2px 2px 0 0;transition:height 0.4s;"></div>
@@ -883,14 +885,11 @@ function vvPollMonitor() {
</div>${sessHtml}`;
}
// ── Docker ──────────────────────────────────────────────────────────────
const containers = d.containers ?? [];
const tbody = document.getElementById('vv-docker-body');
tbody.innerHTML = containers.length
? containers.map(c =>
`<tr><td>${c.name}</td><td class="vv-status-${c.status.startsWith('Up') ? 'up' : 'down'}">${c.status}</td><td>${c.image}</td></tr>`
).join('')
: '<tr><td colspan="3">No running containers</td></tr>';
// ── Containers and VMs ──────────────────────────────────────────────────
const dfData = d.docker_folders ?? { available: false, folders: [], ungrouped: [] };
dfData.vms = d.vms ?? { available: false, vms: [] };
vvRenderDockerFolders(dfData);
})
.catch(() => {});
}
@@ -1038,6 +1037,167 @@ function vvTickClock() {
}
setInterval(vvTickClock, 30000);
// ── Containers and VMs card ───────────────────────────────────────────────────
let vvDfFolderOpen = {};
let vvDfActive = null;
let vvDfData = null;
function vvToggleFolder(id) {
vvDfFolderOpen[id] = !vvDfFolderOpen[id];
vvRenderDockerFolders(vvDfData);
}
function vvToggleContainer(name) {
vvDfActive = vvDfActive === name ? null : name;
vvRenderDockerFolders(vvDfData);
}
function vvDockerAction(action, name, webui) {
if (action === 'webui') { window.open(webui, '_blank'); return; }
if (action === 'edit') {
window.location.href = '/Docker?action=template&xmlTemplate=' +
encodeURIComponent('/boot/config/plugins/dockerMan/templates-user/my-' + name + '.xml') + '&update=true';
return;
}
const fd = new FormData();
fd.set('action', action);
fd.set('name', name);
fetch('/plugins/varaverk/api/docker_action.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(() => { vvDfActive = null; setTimeout(vvPollMonitor, 1500); })
.catch(() => {});
}
function vvRenderDockerFolders(data) {
vvDfData = data;
const el = document.getElementById('vv-docker-folders-body');
if (!el || !data) return;
const osIcon = os => ({ windows:'🪟', macos:'🍎', bsd:'🦬' })[os] ?? '🐧';
const stateColor = s => ({ running:'#4caf50', paused:'#ff9800' })[s] ?? '#444';
const stateLabel = s => ({ running:'Running', paused:'Paused', 'shut off':'Off', crashed:'Crashed' })[s] ?? s;
// ── VMs section ─────────────────────────────────────────────────────────────
let html = '';
const vms = data.vms?.vms ?? [];
if (!vms.length) {
html += '<div class="vv-df-empty">No VMs configured</div>';
} else {
vms.forEach(vm => {
const sc = stateColor(vm.state);
const pulse = vm.state === 'running' ? 'animation:vv-pulse-dot 1s ease-in-out infinite;' : '';
const parts = [];
if (vm.vcpus) parts.push(vm.vcpus + ' vCPU');
if (vm.mem_mb) parts.push(vm.mem_mb >= 1024 ? (vm.mem_mb/1024).toFixed(0)+' GB' : vm.mem_mb+' MB');
const meta = parts.length ? `<span class="vv-df-vm-meta"> · ${parts.join(' · ')}</span>` : '';
html += `<div class="vv-df-vm-row">
<span class="vv-df-vm-icon">${osIcon(vm.os)}</span>
<span style="width:7px;height:7px;border-radius:50%;background:${sc};flex-shrink:0;${pulse}"></span>
<span class="vv-df-cname">${vm.name}</span>
<span style="font-size:11px;font-weight:600;color:${sc};flex-shrink:0;">${stateLabel(vm.state)}</span>
${meta}
</div>`;
});
}
if (!data.available) {
html += '<div class="vv-df-empty">Docker not available</div>';
el.innerHTML = html;
return;
}
function renderContainer(c) {
const dot = c.running ? '#4caf50' : '#555';
const pulse = c.running ? 'animation:vv-pulse-dot 1s ease-in-out infinite;' : '';
const active = vvDfActive === c.name;
const sShort = c.status ? c.status.replace(/^Up\s+/, '').split(' ').slice(0,2).join(' ') : '—';
const sn = c.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const sw = (c.webui||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'");
let actionBar = '';
if (active) {
const ta = c.running ? 'stop' : 'start';
const tl = c.running ? '⏹ Stop' : '▶ Start';
const tc = c.running ? '#f44336' : '#4caf50';
const ws = c.webui ? '' : 'opacity:0.3;pointer-events:none;';
actionBar = `<div class="vv-df-actions">
<button onclick="event.stopPropagation();vvDockerAction('${ta}','${sn}','')"
class="vv-btn-sm" style="border-color:${tc};color:${tc};">${tl}</button>
<button onclick="event.stopPropagation();vvDockerAction('webui','${sn}','${sw}')"
class="vv-btn-sm vv-run-btn" style="${ws}">🌐 WebUI</button>
<button onclick="event.stopPropagation();vvDockerAction('edit','${sn}','')"
class="vv-btn-sm vv-edit-btn">✎ Edit</button>
</div>`;
}
return `<div class="vv-df-container${active ? ' vv-df-active' : ''}"
onclick="event.stopPropagation();vvToggleContainer('${sn}')">
<span class="vv-df-dot" style="background:${dot};${pulse}"></span>
<span class="vv-df-cname">${c.name}</span>
<span class="vv-df-status">${sShort}</span>
</div>${actionBar}`;
}
function renderFolder(f) {
const open = !!vvDfFolderOpen[f.id];
const total = f.containers.length;
const running = f.containers.filter(c => c.running).length;
const bColor = running === total ? '#4caf50' : running === 0 ? '#555' : '#ff9800';
const badge = `<span style="font-size:10px;color:${bColor};flex-shrink:0;">${running}/${total}</span>`;
const sid = f.id.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
let iconHtml = '';
if (f.isEmoji) {
iconHtml = `<span style="font-size:11px;flex-shrink:0;">${f.icon}</span>`;
} else if (f.icon) {
iconHtml = `<img src="${f.icon}" style="width:13px;height:13px;object-fit:contain;border-radius:2px;flex-shrink:0;" onerror="this.style.display='none'">`;
}
let out = `<div class="vv-df-folder">
<div class="vv-df-folder-hdr" onclick="vvToggleFolder('${sid}')">
<span class="vv-df-chevron">${open ? '▾' : '▸'}</span>
${iconHtml}
<span class="vv-df-fname">${f.name}</span>
${badge}
</div>`;
if (open) {
out += '<div class="vv-df-folder-body">';
f.containers.forEach(c => { out += renderContainer(c); });
out += '</div>';
}
out += '</div>';
return out;
}
// Build folder list — ungrouped gets a 📁 emoji icon
const allFolders = (data.folders ?? []).map(f => ({ ...f, isEmoji: false }));
const ug = data.ungrouped ?? [];
if (ug.length) allFolders.push({ id:'__ungrouped__', name:'Ungrouped', icon:'📁', isEmoji:true, containers:ug });
if (!allFolders.length) {
html += '<div class="vv-df-empty">No containers found</div>';
el.innerHTML = html;
return;
}
// 2-column balanced split
const half = Math.ceil(allFolders.length / 2);
const left = allFolders.slice(0, half);
const right = allFolders.slice(half);
html += `<div class="vv-df-cols">
<div class="vv-df-col">${left.map(renderFolder).join('')}</div>
<div class="vv-df-col">${right.map(renderFolder).join('')}</div>
</div>`;
el.innerHTML = html;
}
// Close action bar when clicking outside the card
document.addEventListener('click', () => {
if (vvDfActive !== null) { vvDfActive = null; vvRenderDockerFolders(vvDfData); }
});
// ── Array actions ─────────────────────────────────────────────────────────────
function vvArrayAction(action) {
const labels = {stop: 'Stop Array', shutdown: 'Shutdown', restart: 'Restart'};