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'};
@@ -805,9 +805,25 @@ Still the same two servers, two households, the same media stack running itself.
</div>
</div>
</div>
<div class="vv-sched-footer vv-sched-info" id="vv-sched-info-footer">
<span id="vv-footer-scheduled"><?= $scheduledJobs ?> of <?= $totalJobs ?> job<?= $totalJobs !== 1 ? 's' : '' ?> scheduled</span>
<span id="vv-footer-running"><?= !empty($runningScripts) ? 'Running: ' . htmlspecialchars(implode(', ', $runningScripts)) : 'Idle' ?></span>
<div class="vv-sched-footer vv-sched-info vv-snap-footer" id="vv-sched-info-footer">
<span class="vv-snap-item">
<span class="vv-snap-label">CPU</span>
<span class="vv-snap-bar"><span id="vv-snap-cpu-bar"></span></span>
<span id="vv-snap-cpu-val" class="vv-snap-val">—</span>
</span>
<span class="vv-snap-item">
<span class="vv-snap-label">RAM</span>
<span class="vv-snap-bar"><span id="vv-snap-ram-bar"></span></span>
<span id="vv-snap-ram-val" class="vv-snap-val">—</span>
</span>
<span class="vv-snap-div">·</span>
<span id="vv-snap-fallback" class="vv-snap-state">—</span>
<span class="vv-snap-div">·</span>
<span id="vv-snap-partner" class="vv-snap-partner">—</span>
<span class="vv-snap-div">·</span>
<span id="vv-snap-streams" class="vv-snap-media">—</span>
<span class="vv-snap-div">·</span>
<span id="vv-snap-transcodes" class="vv-snap-media">—</span>
</div>
</div>
@@ -1284,32 +1300,30 @@ function vvSaveChild(el) {
function vvSaveAll() {
const status = document.getElementById('vv-save-all-status');
// Collect jobs to save: orchs always; children only when their orch is OFF (orch manages them when on).
// conf_flag children are always immediate-save (flag_toggle.php), never batch-saved.
const toSave = [];
document.querySelectorAll('#vv-sched-left [data-id]').forEach(job => {
if (job.classList.contains('vv-script')) {
if (job.dataset.type === 'conf_flag') return; // immediate-save only
if (job.dataset.type === 'conf_flag') return;
const orchCard = job.closest('.vv-sched-card');
const orchOn = orchCard?.querySelector('.vv-orch-row .vv-enabled')?.checked ?? false;
if (orchOn) return; // child under active orch — cron suppressed, skip
if (orchOn) return;
}
toSave.push(job);
});
let pending = toSave.length, allOk = true;
if (!pending) { vvFlashStatus(status, '✓ Saved', true); return; }
if (!toSave.length) { vvFlashStatus(status, '✓ Saved', true); return; }
status.textContent = 'Saving…';
toSave.forEach(job => {
const id = job.dataset.id;
const enabled = job.querySelector('.vv-enabled').checked ? '1' : '0';
const cron = job.querySelector('.vv-cron')?.value.trim() ?? '';
const log_enabled = job.querySelector('.vv-log-enabled')?.checked ? '1' : '0';
vvPost('/plugins/varaverk/api/scheduler.php', {id, enabled, cron, log_enabled})
.then(d => {
if (d.ok) vvFlashSaved(job); else allOk = false;
if (--pending === 0) vvFlashStatus(status, allOk ? '✓ All saved' : '✗ Some failed', allOk);
});
});
const batch = toSave.map(job => ({
id: job.dataset.id,
enabled: job.querySelector('.vv-enabled').checked ? '1' : '0',
cron: job.querySelector('.vv-cron')?.value.trim() ?? '',
log_enabled: job.querySelector('.vv-log-enabled')?.checked ? '1' : '0',
}));
vvPost('/plugins/varaverk/api/scheduler.php', {batch: JSON.stringify(batch)})
.then(d => {
if (d.ok) toSave.forEach(job => vvFlashSaved(job));
vvFlashStatus(status, d.ok ? '✓ All saved' : '✗ ' + (d.error ?? 'Failed'), d.ok);
})
.catch(() => vvFlashStatus(status, '✗ Request failed', false));
}
function vvToggleAdvanced(btn) {
@@ -2603,6 +2617,78 @@ function vvToggleFolder(header) {
if (chevron) chevron.textContent = open ? '▾' : '▸';
}
// ── Snapshot footer poll ──────────────────────────────────────────────────────
let vvSnapTimer = null;
function vvPollSnapshot() {
fetch('/plugins/varaverk/api/snapshot.php')
.then(r => r.json())
.then(d => {
const cpuBar = document.getElementById('vv-snap-cpu-bar');
const cpuVal = document.getElementById('vv-snap-cpu-val');
if (cpuBar && cpuVal) {
const p = d.cpu_pct ?? 0;
cpuBar.style.width = p + '%';
cpuBar.style.backgroundColor = p > 85 ? '#f44336' : p > 60 ? '#ff9800' : '#4caf50';
cpuVal.textContent = p + '%';
}
const ramBar = document.getElementById('vv-snap-ram-bar');
const ramVal = document.getElementById('vv-snap-ram-val');
if (ramBar && ramVal) {
const p = d.ram_pct ?? 0;
ramBar.style.width = p + '%';
ramBar.style.backgroundColor = p > 85 ? '#f44336' : p > 70 ? '#ff9800' : '#4caf50';
ramVal.textContent = p + '%';
}
const fb = document.getElementById('vv-snap-fallback');
if (fb) {
const s = (d.fallback ?? 'UNKNOWN').toUpperCase();
fb.textContent = s;
fb.style.color = s === 'NORMAL' ? '#4caf50'
: s === 'FAILOVER' ? '#f44336'
: s === 'NO_INTERNET' ? '#ff9800' : '#666';
}
const pt = document.getElementById('vv-snap-partner');
if (pt) {
if (!d.partner_enabled) {
pt.textContent = 'No partnership'; pt.style.color = '#444';
} else {
const peers = d.peers ?? [];
const on = peers.filter(p => p.online === true);
const off = peers.filter(p => p.online !== true);
if (off.length > 0 && peers.length === 1) {
pt.textContent = off[0].hostname + ' ✕'; pt.style.color = '#f44336';
} else if (off.length > 0) {
pt.textContent = off.length + ' peer' + (off.length > 1 ? 's' : '') + ' down'; pt.style.color = '#f44336';
} else if (on.length === 1) {
pt.textContent = on[0].hostname + ' ●'; pt.style.color = '#4caf50';
} else if (on.length > 1) {
pt.textContent = on.length + ' peers ●'; pt.style.color = '#4caf50';
} else {
pt.textContent = '—'; pt.style.color = '#555';
}
}
}
const sm = document.getElementById('vv-snap-streams');
if (sm) {
const sc = d.stream_count ?? 0;
sm.textContent = sc + (sc === 1 ? ' stream' : ' streams');
sm.style.color = sc > 0 ? '#aaa' : '#444';
}
const tc = document.getElementById('vv-snap-transcodes');
if (tc) {
const n = d.transcode_count ?? 0;
tc.textContent = n + (n === 1 ? ' transcode' : ' transcodes');
tc.style.color = n > 0 ? '#ff9800' : '#444';
}
})
.catch(() => {});
}
function vvStartSnapPoll() {
if (vvSnapTimer) return;
vvPollSnapshot();
vvSnapTimer = setInterval(vvPollSnapshot, 10000);
}
function vvGetCurrentFolders() {
const folders = {};
document.querySelectorAll('.vv-folder-group').forEach(fg => {
@@ -2701,5 +2787,6 @@ requestAnimationFrame(function() {
}
vvFitRight();
vvStartStatusPoll();
vvStartSnapPoll();
});
</script>