@@ -7,14 +7,25 @@
//
//
// DESIGN PRINCIPLES
// DESIGN PRINCIPLES
// Two poll rates, deliberately split.
// Two poll rates, deliberately split.
// api/monitor_fast.php carries the cheap, fast-moving values (1– 2s) ; api/monitor.php
// api/monitor_fast.php carries the cheap, fast-moving values at 1s ; api/monitor.php
// carries the full payload on a slower cycle . Everything refreshing at the fast rate
// carries the full payload at 5s . Everything refreshing at the fast rate would put real
// would put real load on the WebGUI this page exists to watch.
// load on the WebGUI this page exists to watch.
//
//
// Served from the tmpfs cache, not live calls.
// Served from the tmpfs cache, not live calls.
// api_cache_writer.sh refreshes the payload every minute and the endpoint serves that.
// api_cache_writer.sh refreshes the payload every minute and the endpoint serves that.
// ?live=1 bypasses it. A missing cache always falls back to a live call, so the cache
// ?live=1 bypasses it, and is used for exactly one thing: the poll that follows a
// can never be why the dashboard fails to load.
// container action, where the point is to see the result. A missing cache always falls
// back to a live call, so the cache can never be why the dashboard fails to load.
//
// This principle was written before it was true. The page sent ?live=1 on every poll but
// the first, so it paid a full collection — partner SSH timeouts included, per that
// endpoint's own warning — every two seconds, and the cache it describes was used once
// per page load. Fixed 2026-08-07. If this page ever feels heavy, check here first.
//
// Polls are guarded, not merely scheduled.
// vvPollRunner() drops a tick while the previous request is still open and stops
// entirely while the tab is hidden. Both polls ran unconditionally before, so a slow
// collection stacked requests behind itself and a background tab polled forever.
//
//
// Missing subsystems simply do not render.
// Missing subsystems simply do not render.
// No GPU, no UPS, no VMs — the corresponding card is absent rather than showing zeros
// No GPU, no UPS, no VMs — the corresponding card is absent rather than showing zeros
@@ -27,10 +38,27 @@
// the page reported healthy unconditionally (fixed 2026-08-02). If this panel looks
// the page reported healthy unconditionally (fixed 2026-08-02). If this panel looks
// suspiciously green, verify the paths before believing it.
// suspiciously green, verify the paths before believing it.
//
//
// Container actions are confirmed and routed through the action endpoint, which validates
// Stopping a container is confirmed; starting one is not, and the endpoint validates every
// against real inventory.
// action against real inventory regardless. Failures are surfaced rather than swallowed — the
// response used to be discarded, which made a refused stop indistinguishable from a completed
// one.
//
// An action that changes container state clears the monitor cache.
// api/docker_action.php and the pull worker both drop it, so the next poll collects
// instead of re-reading a payload written before the action. Without that the card
// contradicted the button for up to a minute.
//
//
// All remote and container-supplied strings render escaped.
// All remote and container-supplied strings render escaped.
// Through vvEscHtml()/vvEscAttr() from Varaverk.page — media titles and usernames from
// Emby/Jellyfin/Plex, partner hostnames and versions read over the mesh, docker folder
// names, VM names, UPS and GPU model strings. This line claimed to be true before any of
// it was: the page had no escaping at all, and the helpers it needed lived in a page it
// never loads.
//
// A container WebUI value is scheme-checked before it is a link.
// include/docker_folders.php allows only http/https out of the template XML, and
// vvSafeUrl() filters again at window.open(), where a javascript: URL would run with
// this page's origin.
//
//
// RENDERS
// RENDERS
// System header, CPU per core, memory breakdown, GPU cards, storage pools and array disks,
// System header, CPU per core, memory breakdown, GPU cards, storage pools and array disks,
@@ -40,8 +68,7 @@
// DEPENDS ON
// DEPENDS ON
// include/monitor.php required directly for initial render
// include/monitor.php required directly for initial render
// api/monitor.php full payload, slower cycle
// api/monitor.php full payload, slower cycle
// api/monitor_fast.php fast-moving values, 1– 2 s
// api/monitor_fast.php fast-moving values, 1s
// api/system.php system info
// api/media.php now-playing sessions
// api/media.php now-playing sessions
// api/docker_action.php container actions
// api/docker_action.php container actions
// api/flag_toggle.php toggles
// api/flag_toggle.php toggles
@@ -395,14 +422,6 @@ function vvDrawNetChart(canvas, rxData, txData, maxBps) {
// ── CPU helpers ───────────────────────────────────────────────────────────────
// ── CPU helpers ───────────────────────────────────────────────────────────────
function vvCoreColor(freqMhz, maxMhz, minMhz) {
if (!freqMhz || !maxMhz || maxMhz === minMhz) return '#4caf50';
const t = Math.max(0, Math.min(1, (freqMhz - minMhz) / (maxMhz - minMhz)));
// blue(220°) → green(120°) → red(0°) as t goes 0→1
const hue = Math.round((1 - t) * 220);
return `hsl(${hue},70%,45%)`;
}
function vvRenderCpu(cpu) {
function vvRenderCpu(cpu) {
const overall = cpu.overall ?? 0;
const overall = cpu.overall ?? 0;
const cores = cpu.cores ?? [];
const cores = cpu.cores ?? [];
@@ -477,7 +496,7 @@ function vvRenderMemory(mem) {
const procs = mem.top_procs ?? [];
const procs = mem.top_procs ?? [];
const procStrip = procs.map(p =>
const procStrip = procs.map(p =>
`<span style="white-space:nowrap;">${p.name} <span style="color:#aaa;font-weight:600;">${vvFmtGib(p.kb)}</span></span>`
`<span style="white-space:nowrap;">${vvEscHtml( p.name) } <span style="color:#aaa;font-weight:600;">${vvFmtGib(p.kb)}</span></span>`
).join('<span style="color:#333;margin:0 5px;">·</span>');
).join('<span style="color:#333;margin:0 5px;">·</span>');
let html = `<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px;">
let html = `<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px;">
@@ -664,7 +683,7 @@ function vvDiskRow(disk) {
: `<span style="color:#555;font-size:10px;">${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}</span>`;
: `<span style="color:#555;font-size:10px;">${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}</span>`;
return `<div style="margin-bottom:7px;">
return `<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;">
<span style="color:${nameColor};display:flex;align-items:center;">${disk.name}${spinLabel}${failLabel}${vvIoChip(disk.device)}</span>
<span style="color:${nameColor};display:flex;align-items:center;">${vvEscHtml( disk.name) }${spinLabel}${failLabel}${vvIoChip(disk.device)}</span>
${right}
${right}
<span style="color:${tempColor};font-size:10px;margin-left:6px;flex-shrink:0;">${tempStr}</span>
<span style="color:${tempColor};font-size:10px;margin-left:6px;flex-shrink:0;">${tempStr}</span>
</div>
</div>
@@ -680,14 +699,54 @@ function vvDiskCol(disks) {
// ── Poll ──────────────────────────────────────────────────────────────────────
// ── Poll ──────────────────────────────────────────────────────────────────────
let _vvFirstPoll = true;
// Runs fn on an interval under three rules, because a dashboard that polls harder than its data
// changes is load on the machine it exists to watch:
//
// in-flight a tick arriving while the previous request is still open is dropped rather than
// queued. A slow collection used to stack requests behind itself at 1– 2s intervals,
// and the slow case is precisely the loaded one.
// hidden nothing polls while the tab is not visible. This page ran 1s and 2s timers
// forever in a background tab.
// resume one immediate tick when the tab comes back, so returning to it does not show a
// frozen dashboard for a full interval.
//
// fn must return the fetch promise, or the in-flight flag can never clear.
function vvPollRunner(fn, ms) {
let busy = false;
const tick = () => {
if (busy || document.hidden) return;
busy = true;
Promise.resolve(fn()).catch(() => {}).finally(() => { busy = false; });
};
tick();
setInterval(tick, ms);
document.addEventListener('visibilitychange', () => { if (!document.hidden) tick(); });
}
function vvPollMonitor() {
// Consecutive poll failures. A dashboard whose endpoint has died looks exactly like one where
const _url = _vvFirstPoll ? '/plugins/varaverk/api/monitor.php' : '/plugins/varaverk/api/monitor.php?live=1';
// nothing is happening, which is the worst way for it to fail — every number on screen stays at
_vvFirstPoll = false;
// its last good value and nothing says so. Three in a row rather than one, so a single blip
fetch(_url)
// during a restart does not throw a banner.
let vvPollFails = 0;
function vvPollFailed() {
if (++vvPollFails < 3) return;
const banner = document.getElementById('vv-api-banner');
if (!banner) return;
Object.assign(banner.style, {display:'', background:'#1a0d0d', border:'1px solid #4a1f1f', color:'#ef5350'});
banner.textContent = 'Monitor data is not updating — ' + vvPollFails
+ ' consecutive failed polls. Values below are the last good reading.';
}
// live=1 bypasses the endpoint's cache and collects everything fresh. Reserved for the poll that
// follows an action, where the cache has just been dropped and the point is to see the result.
// Every ordinary poll reads the cache, which is what the endpoint was built for and what its own
// design principles describe — this used to send live=1 on every poll after the first, so the
// page paid a full collection, partner SSH timeouts included, every two seconds.
function vvPollMonitor(live) {
return fetch('/plugins/varaverk/api/monitor.php' + (live ? '?live=1' : ''))
.then(r => r.json())
.then(r => r.json())
.then(d => {
.then(d => {
vvPollFails = 0;
// ── API status banner ────────────────────────────────────────────────────
// ── API status banner ────────────────────────────────────────────────────
const apiStatus = d._api_status ?? {};
const apiStatus = d._api_status ?? {};
@@ -741,7 +800,7 @@ function vvPollMonitor() {
document.getElementById('vv-system-body').innerHTML =
document.getElementById('vv-system-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div style="min-width:0;">
<div style="min-width:0;">
<div style="font-size:14px;font-weight:700;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.name}</div>
<div style="font-size:14px;font-weight:700;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${vvEscHtml( sys.name) }</div>
<div style="font-size:10px;color:#555;margin-top:2px;">${sys.comment || ' '}</div>
<div style="font-size:10px;color:#555;margin-top:2px;">${sys.comment || ' '}</div>
</div>
</div>
<div style="display:flex;align-items:flex-start;gap:6px;flex-shrink:0;margin-left:6px;">
<div style="display:flex;align-items:flex-start;gap:6px;flex-shrink:0;margin-left:6px;">
@@ -771,9 +830,9 @@ function vvPollMonitor() {
<div style="font-size:20px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div>
<div style="font-size:20px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div>
<div style="font-size:10px;color:#555;margin-bottom:10px;">${dateStr} · ${tz}</div>
<div style="font-size:10px;color:#555;margin-bottom:10px;">${dateStr} · ${tz}</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11px;">
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11px;">
<span style="color:#444;">Model</span> <span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.cpu_model}${_coreMeta}</span>
<span style="color:#444;">Model</span> <span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${vvEscHtml( sys.cpu_model) }${_coreMeta}</span>
<span style="color:#444;">Array</span> <span style="color:${arrayColor};font-weight:600;">${sys.array_state}</span>
<span style="color:#444;">Array</span> <span style="color:${arrayColor};font-weight:600;">${vvEscHtml( sys.array_state) }</span>
<span style="color:#444;">Uptime</span> <span style="color:#888;">${sys.uptime}</span>
<span style="color:#444;">Uptime</span> <span style="color:#888;">${vvEscHtml( sys.uptime) }</span>
<span style="color:#444;">Load</span> <span style="color:${_loadColor};">${_loadStr}</span>
<span style="color:#444;">Load</span> <span style="color:${_loadColor};">${_loadStr}</span>
<span style="color:#444;">Running</span> <span style="color:#888;">${_runningCtrs} ctr${_runningCtrs !== 1 ? 's' : ''}${_runningVMs > 0 ? ` · ${_runningVMs} VM` : ''}</span>
<span style="color:#444;">Running</span> <span style="color:#888;">${_runningCtrs} ctr${_runningCtrs !== 1 ? 's' : ''}${_runningVMs > 0 ? ` · ${_runningVMs} VM` : ''}</span>
<span style="color:#444;">Version</span> <span style="color:#3a3a3a;">${ver}</span>
<span style="color:#444;">Version</span> <span style="color:#3a3a3a;">${ver}</span>
@@ -812,8 +871,8 @@ function vvPollMonitor() {
const ramAvail = rs.mem_used_pct > 0;
const ramAvail = rs.mem_used_pct > 0;
const cpuHue = cpuAvail ? Math.round(120 * (1 - rs.cpu_load / 100)) : 0;
const cpuHue = cpuAvail ? Math.round(120 * (1 - rs.cpu_load / 100)) : 0;
const memHue = ramAvail ? Math.round(120 * (1 - rs.mem_used_pct / 100)) : 0;
const memHue = ramAvail ? Math.round(120 * (1 - rs.mem_used_pct / 100)) : 0;
const cpuStr = cpuAvail ? `<span style="color:hsl(${cpuHue},70%,45%);font-weight:600;">${rs.cpu_load}%${rs.cpu_threads ? `<span style="color:#333;font-weight:400;"> · ${rs.cpu_threads}t</span>` : ''}</span>` : `<span style="color:#333;">—</span>`;
const cpuStr = cpuAvail ? `<span style="color:hsl(${cpuHue},70%,45%);font-weight:600;">${vvEscHtml( rs.cpu_load) }%${rs.cpu_threads ? `<span style="color:#333;font-weight:400;"> · ${vvEscHtml( rs.cpu_threads) }t</span>` : ''}</span>` : `<span style="color:#333;">—</span>`;
const ramStr = ramAvail ? `<span style="color:hsl(${memHue},70%,45%);font-weight:600;">${rs.mem_used_pct}%${rs.mem_total_gb ? `<span style="color:#333;font-weight:400;"> · ${rs.mem_total_gb}G</span>` : ''}</span>` : `<span style="color:#333;">—</span>`;
const ramStr = ramAvail ? `<span style="color:hsl(${memHue},70%,45%);font-weight:600;">${vvEscHtml( rs.mem_used_pct) }%${rs.mem_total_gb ? `<span style="color:#333;font-weight:400;"> · ${vvEscHtml( rs.mem_total_gb) }G</span>` : ''}</span>` : `<span style="color:#333;">—</span>`;
const arrColor = rs.array_state === 'Started' || rs.array_state === 'STARTED' ? '#4caf50' : '#f44336';
const arrColor = rs.array_state === 'Started' || rs.array_state === 'STARTED' ? '#4caf50' : '#f44336';
const uptimeStr = rs.uptime && rs.uptime !== '—' ? rs.uptime : '—';
const uptimeStr = rs.uptime && rs.uptime !== '—' ? rs.uptime : '—';
@@ -823,18 +882,18 @@ function vvPollMonitor() {
const verMismatch = myVer && remoteVer && myVer !== remoteVer;
const verMismatch = myVer && remoteVer && myVer !== remoteVer;
const verWarn = verMismatch
const verWarn = verMismatch
? `<div style="font-size:10px;color:#ff9800;margin-top:4px;padding:3px 6px;background:#1a1000;border:1px solid #3a2800;border-radius:3px;">
? `<div style="font-size:10px;color:#ff9800;margin-top:4px;padding:3px 6px;background:#1a1000;border:1px solid #3a2800;border-radius:3px;">
⚠ Version mismatch: local ${myVer} · remote ${remoteVer}<br>
⚠ Version mismatch: local ${vvEscHtml( myVer) } · remote ${vvEscHtml( remoteVer) }<br>
<span style="color:#555;">Script sync ops are gated until versions match</span>
<span style="color:#555;">Script sync ops are gated until versions match</span>
</div>` : '';
</div>` : '';
const verRow = remoteVer ? `<span style="color:#444;">unRAID</span><span style="color:#3a3a3a;grid-column:span 3;">${remoteVer}</span>` : '';
const verRow = remoteVer ? `<span style="color:#444;">unRAID</span><span style="color:#3a3a3a;grid-column:span 3;">${vvEscHtml( remoteVer) }</span>` : '';
statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:10px;margin-top:5px;margin-bottom:2px;">
statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:10px;margin-top:5px;margin-bottom:2px;">
<span style="color:#444;">CPU</span>${cpuStr}
<span style="color:#444;">CPU</span>${cpuStr}
<span style="color:#444;">RAM</span>${ramStr}
<span style="color:#444;">RAM</span>${ramStr}
<span style="color:#444;">Array</span>
<span style="color:#444;">Array</span>
<span style="color:${arrColor};font-weight:600;">${rs.array_state}</span>
<span style="color:${arrColor};font-weight:600;">${vvEscHtml( rs.array_state) }</span>
<span style="color:#444;">Uptime</span>
<span style="color:#444;">Uptime</span>
<span style="color:#555;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${uptimeStr}</span>
<span style="color:#555;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${vvEscHtml( uptimeStr) }</span>
${verRow}
${verRow}
</div>${verWarn}`;
</div>${verWarn}`;
} else if (rs && rs.no_api_key) {
} else if (rs && rs.no_api_key) {
@@ -846,9 +905,9 @@ function vvPollMonitor() {
ptHtml += `<div style="margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid #222;">
ptHtml += `<div style="margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid #222;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<div>
<span style="font-size:10px;color:#555;margin-right:4px;">${h.id}</span>
<span style="font-size:10px;color:#555;margin-right:4px;">${vvEscHtml( h.id) }</span>
<span style="font-size:12px;color:#ccc;font-weight:500;">${h.owner || h.hostname}</span>${tags}
<span style="font-size:12px;color:#ccc;font-weight:500;">${vvEscHtml( h.owner || h.hostname) }</span>${tags}
<div style="font-size:10px;color:#555;margin-top:1px;">${h.hostname}</div>
<div style="font-size:10px;color:#555;margin-top:1px;">${vvEscHtml( h.hostname) }</div>
${onboardBadge}
${onboardBadge}
</div>
</div>
<span style="color:${dot};font-size:10px;white-space:nowrap;">● ${label}</span>
<span style="color:${dot};font-size:10px;white-space:nowrap;">● ${label}</span>
@@ -952,13 +1011,13 @@ function vvPollMonitor() {
if (fbActive.length) {
if (fbActive.length) {
fbActive.forEach(group => {
fbActive.forEach(group => {
fbHtml += `<div style="font-size:10px;color:#555;margin-bottom:4px;letter-spacing:.03em;">
fbHtml += `<div style="font-size:10px;color:#555;margin-bottom:4px;letter-spacing:.03em;">
COVERING ${group.hostname}
COVERING ${vvEscHtml( group.hostname) }
</div>`;
</div>`;
group.containers.forEach(c => {
group.containers.forEach(c => {
const img = c.image.includes('/') ? c.image.split('/').pop() : c.image;
const img = c.image.includes('/') ? c.image.split('/').pop() : c.image;
fbHtml += `<div style="display:flex;justify-content:space-between;align-items:center;
fbHtml += `<div style="display:flex;justify-content:space-between;align-items:center;
background:#1a1a1a;border-radius:4px;padding:4px 8px;margin-bottom:3px;">
background:#1a1a1a;border-radius:4px;padding:4px 8px;margin-bottom:3px;">
<span style="color:#ccc;font-size:11px;font-weight:500;">${c.name}</span>
<span style="color:#ccc;font-size:11px;font-weight:500;">${vvEscHtml( c.name) }</span>
<span style="color:#444;font-size:9px;margin-left:8px;white-space:nowrap;">${img}</span>
<span style="color:#444;font-size:9px;margin-left:8px;white-space:nowrap;">${img}</span>
</div>`;
</div>`;
});
});
@@ -1022,8 +1081,8 @@ function vvPollMonitor() {
document.getElementById('vv-ups-body').innerHTML =
document.getElementById('vv-ups-body').innerHTML =
`<div class="vv-banner ${statCls}" style="margin-bottom:8px;">
`<div class="vv-banner ${statCls}" style="margin-bottom:8px;">
<span>${ups.status}${onBatt ? ' — ON BATTERY' : ''}</span>
<span>${vvEscHtml( ups.status) }${onBatt ? ' — ON BATTERY' : ''}</span>
<span style="font-size:11px;font-weight:400;opacity:0.8;">${ups.model}</span>
<span style="font-size:11px;font-weight:400;opacity:0.8;">${vvEscHtml( ups.model) }</span>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;">
<div>
<div>
@@ -1293,7 +1352,7 @@ function vvPollMonitor() {
const diff = now - r.ts;
const diff = now - r.ts;
const ago = diff < 3600 ? Math.floor(diff / 60) + 'm' : Math.floor(diff / 3600) + 'h';
const ago = diff < 3600 ? Math.floor(diff / 60) + 'm' : Math.floor(diff / 3600) + 'h';
html += `<div style="display:flex;justify-content:space-between;font-size:10px;color:#666;margin-bottom:2px;">
html += `<div style="display:flex;justify-content:space-between;font-size:10px;color:#666;margin-bottom:2px;">
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${r.name}</span>
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${vvEscHtml( r.name) }</span>
<span style="flex-shrink:0;margin-left:6px;color:#444;">${ago}</span>
<span style="flex-shrink:0;margin-left:6px;color:#444;">${ago}</span>
</div>`;
</div>`;
});
});
@@ -1494,7 +1553,7 @@ function vvPollMonitor() {
html += `<div style="background:#0d1a0a;border:1px solid #1a3a0a;border-radius:3px;
html += `<div style="background:#0d1a0a;border:1px solid #1a3a0a;border-radius:3px;
padding:4px 8px;margin-bottom:5px;">
padding:4px 8px;margin-bottom:5px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;">
<span style="font-size:10px;color:#8bc34a;font-weight:600;">⟳ ${a.profile}</span>
<span style="font-size:10px;color:#8bc34a;font-weight:600;">⟳ ${vvEscHtml( a.profile) }</span>
<span style="font-size:10px;color:#6a8a4a;">${_dur(sec)}</span>
<span style="font-size:10px;color:#6a8a4a;">${_dur(sec)}</span>
</div>
</div>
<div style="height:3px;background:#0a0a0a;border-radius:2px;overflow:hidden;">
<div style="height:3px;background:#0a0a0a;border-radius:2px;overflow:hidden;">
@@ -1529,7 +1588,7 @@ function vvPollMonitor() {
html += `<div style="margin-bottom:4px;">
html += `<div style="margin-bottom:4px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:2px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:2px;">
<span style="font-size:10px;color:#555;width:46px;flex-shrink:0;">${meta.label}</span>
<span style="font-size:10px;color:#555;width:46px;flex-shrink:0;">${vvEscHtml( meta.label) }</span>
<span style="font-size:9px;color:#333;flex:1;text-align:right;margin-right:6px;">
<span style="font-size:9px;color:#333;flex:1;text-align:right;margin-right:6px;">
${s?.duration ? _dur(s.duration) : ''}</span>
${s?.duration ? _dur(s.duration) : ''}</span>
<span style="font-size:9px;color:#2a2a2a;width:28px;text-align:right;margin-right:5px;">
<span style="font-size:9px;color:#2a2a2a;width:28px;text-align:right;margin-right:5px;">
@@ -1607,7 +1666,7 @@ function vvPollMonitor() {
bodyEl.innerHTML =
bodyEl.innerHTML =
// header row: name + process count pill
// header row: name + process count pill
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="font-size:11px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${gpu.name}</div>
<div style="font-size:11px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${vvEscHtml( gpu.name) }</div>
<div style="margin-left:8px;background:${procColor}22;border:1px solid ${procColor};color:${procColor};
<div style="margin-left:8px;background:${procColor}22;border:1px solid ${procColor};color:${procColor};
padding:1px 8px;border-radius:10px;font-size:11px;white-space:nowrap;">${procCount} proc${procCount !== 1 ? 's' : ''}</div>
padding:1px 8px;border-radius:10px;font-size:11px;white-space:nowrap;">${procCount} proc${procCount !== 1 ? 's' : ''}</div>
</div>` +
</div>` +
@@ -1699,7 +1758,7 @@ function vvPollMonitor() {
const meth = s.method.replace('Transcode', 'TC').replace('Direct ', '');
const meth = s.method.replace('Transcode', 'TC').replace('Direct ', '');
return `<div style="display:flex;align-items:center;gap:5px;margin-bottom:4px;min-width:0;overflow:hidden;">
return `<div style="display:flex;align-items:center;gap:5px;margin-bottom:4px;min-width:0;overflow:hidden;">
${vvSrvIcon(s.server_type, s.server)}
${vvSrvIcon(s.server_type, s.server)}
<span style="font-size:10px;color:#888;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${s.title}</span>
<span style="font-size:10px;color:#888;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${vvEscHtml( s.title) }</span>
<span style="font-size:9px;color:#555;flex-shrink:0;">${typeLabel(s.type)}</span>
<span style="font-size:9px;color:#555;flex-shrink:0;">${typeLabel(s.type)}</span>
<span style="font-size:9px;color:#ff9800;flex-shrink:0;">${meth}</span>
<span style="font-size:9px;color:#ff9800;flex-shrink:0;">${meth}</span>
</div>`;
</div>`;
@@ -1759,15 +1818,16 @@ function vvPollMonitor() {
vvRenderDockerFolders(dfData);
vvRenderDockerFolders(dfData);
})
})
.catch(() => {} );
.catch(vvPollFailed );
}
}
vvPollMonitor();
// 5s against a payload the cache writer refreshes once a minute. Polling faster cannot make the
setInterval(vvPollMonitor, 2000);
// data newer — it only decides how soon the page notices the writer's update.
vvPollRunner(vvPollMonitor, 5000);
// ── Fast poll: CPU, memory, network — 1-second live updates ──────────────────
// ── Fast poll: CPU, memory, network — 1-second live updates ──────────────────
function vvPollFast() {
function vvPollFast() {
fetch('/plugins/varaverk/api/monitor_fast.php')
return fetch('/plugins/varaverk/api/monitor_fast.php')
.then(r => r.json())
.then(r => r.json())
.then(d => {
.then(d => {
// CPU
// CPU
@@ -1799,15 +1859,15 @@ function vvPollFast() {
const peakTx = Math.max(...vvNetTxHistory, 0);
const peakTx = Math.max(...vvNetTxHistory, 0);
const ipRows = [
const ipRows = [
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN </span>${net.local_ip}</div>` : '',
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN </span>${vvEscHtml( net.local_ip) }</div>` : '',
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT </span>${net.ext_ip}</div>` : '',
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT </span>${vvEscHtml( net.ext_ip) }</div>` : '',
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS </span>${net.ts_ip}</div>` : '',
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS </span>${vvEscHtml( net.ts_ip) }</div>` : '',
].filter(Boolean).join('');
].filter(Boolean).join('');
document.getElementById('vv-network-body').innerHTML =
document.getElementById('vv-network-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div>
<div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} · ${linkLabel}</div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">${vvEscHtml( net.iface) } · ${linkLabel}</div>
<div style="display:flex;gap:16px;font-size:13px;font-weight:600;">
<div style="display:flex;gap:16px;font-size:13px;font-weight:600;">
<span><span style="color:#4caf50;font-size:9px;margin-right:4px;">━ IN (RX)</span><span style="color:#4caf50;">${vvFmtBps(rx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakRx)}</span></span>
<span><span style="color:#4caf50;font-size:9px;margin-right:4px;">━ IN (RX)</span><span style="color:#4caf50;">${vvFmtBps(rx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakRx)}</span></span>
<span><span style="color:#ff9800;font-size:9px;margin-right:4px;">━ OUT (TX)</span><span style="color:#ff9800;">${vvFmtBps(tx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakTx)}</span></span>
<span><span style="color:#ff9800;font-size:9px;margin-right:4px;">━ OUT (TX)</span><span style="color:#ff9800;">${vvFmtBps(tx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakTx)}</span></span>
@@ -1825,8 +1885,9 @@ function vvPollFast() {
.catch(() => {});
.catch(() => {});
}
}
vvPollFast();
// Stays at 1s — this endpoint reads /proc and borrows its slow fields from the full cache, so it
setInterval(vvPollFast, 1000);
// is cheap enough to be the one thing that genuinely updates live.
vvPollRunner(vvPollFast, 1000);
// Pin pools card width to CPU card width across rows
// Pin pools card width to CPU card width across rows
function vvSyncCardWidths() {
function vvSyncCardWidths() {
@@ -1886,8 +1947,8 @@ function vvRenderStreams() {
const badges = names.map(n => {
const badges = names.map(n => {
const cnt = serverCounts[n] ?? 0;
const cnt = serverCounts[n] ?? 0;
return cnt > 0
return cnt > 0
? `<span class="vv-server-badge">${n } <b style="color:#ccc;">${cnt}</b></span>`
? `<span class="vv-server-badge">${vvEscHtml(n) } <b style="color:#ccc;">${cnt}</b></span>`
: `<span class="vv-server-badge" style="color:#444;">${n }</span>`;
: `<span class="vv-server-badge" style="color:#444;">${vvEscHtml(n) }</span>`;
}).join('');
}).join('');
// Per-chip shade: alternate bg brightness within a group to visually separate chips
// Per-chip shade: alternate bg brightness within a group to visually separate chips
@@ -1902,8 +1963,11 @@ function vvRenderStreams() {
const bg = (shades[cls] ?? ['',''])[i % 2];
const bg = (shades[cls] ?? ['',''])[i % 2];
return bg ? ` style="background:${bg};"` : '';
return bg ? ` style="background:${bg};"` : '';
}
}
// Escapes here rather than at the four call sites: every one passes plain text, and the labels
// are not all ours — an unrecognised codec falls through vvCodecLabel() as the media server
// spelled it, and the device type is derived from the client string the player reports.
function vvChip(cls, label, i) {
function vvChip(cls, label, i) {
return `<span class="${cls}"${vvChipShade(cls, i)}>${label}</span>`;
return `<span class="${cls}"${vvChipShade(cls, i)}>${vvEscHtml( label) }</span>`;
}
}
// Device type summary — e.g. "3 Android 1 iOS 2 Roku"
// Device type summary — e.g. "3 Android 1 iOS 2 Roku"
@@ -2008,11 +2072,11 @@ function vvRenderStreams() {
return `<div>
return `<div>
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:2px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:2px;">
<span style="color:${s.paused ? '#fdd835' : '#aaa'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">
<span style="color:${s.paused ? '#fdd835' : '#aaa'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">
<span style="color:${iconColor};">${icon}</span> ${s.title}</span>
<span style="color:${iconColor};">${icon}</span> ${vvEscHtml( s.title) }</span>
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${s.server}</span>
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${vvEscHtml( s.server) }</span>
</div>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:#555;margin-bottom:3px;">
<div style="display:flex;justify-content:space-between;font-size:10px;color:#555;margin-bottom:3px;">
<span>${s.user}</span>
<span>${vvEscHtml( s.user) }</span>
${timeStr}
${timeStr}
</div>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
@@ -2044,7 +2108,7 @@ function vvRenderStreams() {
}
}
function vvPollStreams() {
function vvPollStreams() {
fetch('/plugins/varaverk/api/media.php')
return fetch('/plugins/varaverk/api/media.php')
.then(r => r.json())
.then(r => r.json())
.then(d => {
.then(d => {
vvLastSessions = d.sessions ?? [];
vvLastSessions = d.sessions ?? [];
@@ -2063,8 +2127,11 @@ function vvPollStreams() {
.catch(() => {});
.catch(() => {});
}
}
vvPollStreams();
// Guarded like the others — this one reaches out to every configured media server, so a wedged
setInterval(vvPollStreams, 12000);
// Emby is exactly the case where unguarded ticks would stack.
vvPollRunner(vvPollStreams, 12000);
// Local only: re-renders the rows already held, advancing each progress bar between polls. No
// request, so it stays a plain interval.
setInterval(vvRenderStreams, 1000);
setInterval(vvRenderStreams, 1000);
// ── Pools card ────────────────────────────────────────────────────────────────
// ── Pools card ────────────────────────────────────────────────────────────────
@@ -2223,19 +2290,41 @@ function vvToggleContainer(name) {
}
}
function vvDockerAction(action, name, webui) {
function vvDockerAction(action, name, webui) {
if (action === 'webui') { window.open(webui, '_blank'); return; }
// Filtered again at the point of use, not only where the button was built. This value originates
// in a container's template XML, and window.open() on a javascript: URL runs it with this page's
// origin — the one sink where an unchecked scheme is not merely a broken link.
if (action === 'webui') {
const u = vvSafeUrl(webui);
if (u) window.open(u, '_blank', 'noopener');
return;
}
if (action === 'edit') {
if (action === 'edit') {
window.location.href = '/Docker?action=template&xmlTemplate=' +
window.location.href = '/Docker?action=template&xmlTemplate=' +
encodeURIComponent('/boot/config/plugins/dockerMan/templates-user/my-' + name + '.xml') + '&update=true';
encodeURIComponent('/boot/config/plugins/dockerMan/templates-user/my-' + name + '.xml') + '&update=true';
return;
return;
}
}
// Stopping is confirmed; starting is not. The asymmetry is the point — start is recoverable by
// clicking the other button, stop takes a service away from whoever is using it, and these
// buttons sit inside a dense grid where the row under the cursor is easy to misjudge.
if (action === 'stop' && !confirm('Stop ' + name + '?')) return;
const fd = new URLSearchParams();
const fd = new URLSearchParams();
fd.set('action', action);
fd.set('action', action);
fd.set('name', name);
fd.set('name', name);
fetch('/plugins/varaverk/api/docker_action.php', { method: 'POST', body: fd })
fetch('/plugins/varaverk/api/docker_action.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(r => r.json())
.then(() => { vvDfActive = null; setTimeout(vvPollMonitor, 1500); })
.then(d => {
.catch(() => {});
// The endpoint reports refusals as ok:false with a reason — container not found, a non-zero
// docker exit. Discarding that made a failed stop look exactly like a successful one, since
// the card it would have changed is redrawn from a payload either way.
if (!d || !d.ok) alert('Container ' + action + ' failed: ' + ((d && (d.error || d.output)) || 'unknown error'));
vvDfActive = null;
// The endpoint drops the monitor cache on success, and this poll asks for a live collection
// besides — either alone is enough, but between them the card cannot redraw itself from a
// payload assembled before the action happened.
setTimeout(() => vvPollMonitor(true), 1500);
})
.catch(() => alert('Container ' + action + ' failed: request error'));
}
}
function vvRenderDockerFolders(data) {
function vvRenderDockerFolders(data) {
@@ -2270,7 +2359,7 @@ function vvRenderDockerFolders(data) {
html += `<div class="vv-df-vm-row">
html += `<div class="vv-df-vm-row">
<span class="vv-df-vm-icon">${osIcon(vm.os)}</span>
<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 style="width:7px;height:7px;border-radius:50%;background:${sc};flex-shrink:0;${pulse}"></span>
<span class="vv-df-cname">${vm.name}</span>
<span class="vv-df-cname">${vvEscHtml( vm.name) }</span>
<span style="font-size:11px;font-weight:600;color:${sc};flex-shrink:0;">${stateLabel(vm.state)}</span>
<span style="font-size:11px;font-weight:600;color:${sc};flex-shrink:0;">${stateLabel(vm.state)}</span>
${meta}
${meta}
</div>`;
</div>`;
@@ -2288,8 +2377,14 @@ function vvRenderDockerFolders(data) {
const pulse = c.running ? 'animation:vv-pulse-dot 1s ease-in-out infinite;' : '';
const pulse = c.running ? 'animation:vv-pulse-dot 1s ease-in-out infinite;' : '';
const active = vvDfActive === c.name;
const active = vvDfActive === c.name;
const sShort = c.status ? c.status.replace(/^Up\s+/, '').split(' ').slice(0,2).join(' ') : '—';
const sShort = c.status ? c.status.replace(/^Up\s+/, '').split(' ').slice(0,2).join(' ') : '—';
const sn = c.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
// Escaped for two nested contexts at once: a JS string literal, and the double-quoted onclick
const sw = (c.webui||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'");
// attribute holding it. The previous version did the first half only — \ and ' — which leaves
// a " free to close the attribute and destroy every handler after it. Docker's own charset
// makes that unreachable through a container name, but the WebUI value comes from template
// XML and is under no such constraint.
const jsq = v => vvEscAttr(String(v ?? '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
const sn = jsq(c.name);
const sw = jsq(vvSafeUrl(c.webui));
let actionBar = '';
let actionBar = '';
if (active) {
if (active) {
@@ -2309,8 +2404,8 @@ function vvRenderDockerFolders(data) {
return `<div class="vv-df-container${active ? ' vv-df-active' : ''}"
return `<div class="vv-df-container${active ? ' vv-df-active' : ''}"
onclick="event.stopPropagation();vvToggleContainer('${sn}')">
onclick="event.stopPropagation();vvToggleContainer('${sn}')">
<span class="vv-df-dot" style="background:${dot};${pulse}"></span>
<span class="vv-df-dot" style="background:${dot};${pulse}"></span>
<span class="vv-df-cname">${c.name}</span>
<span class="vv-df-cname">${vvEscHtml( c.name) }</span>
<span class="vv-df-status">${sShort}</span>
<span class="vv-df-status">${vvEscHtml( sShort) }</span>
</div>${actionBar}`;
</div>${actionBar}`;
}
}
@@ -2320,20 +2415,20 @@ function vvRenderDockerFolders(data) {
const running = f.containers.filter(c => c.running).length;
const running = f.containers.filter(c => c.running).length;
const bColor = running === total ? '#4caf50' : running === 0 ? '#555' : '#ff9800';
const bColor = running === total ? '#4caf50' : running === 0 ? '#555' : '#ff9800';
const badge = `<span style="font-size:10px;color:${bColor};flex-shrink:0;margin-left:auto;">${running}/${total}</span>`;
const badge = `<span style="font-size:10px;color:${bColor};flex-shrink:0;margin-left:auto;">${running}/${total}</span>`;
const sid = f.id .replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const sid = vvEscAttr(String(f.id ?? '') .replace(/\\/g,'\\\\').replace(/'/g,"\\'")) ;
let iconHtml = '';
let iconHtml = '';
if (f.isEmoji) {
if (f.isEmoji) {
iconHtml = `<span style="font-size:11px;flex-shrink:0;">${f.icon}</span>`;
iconHtml = `<span style="font-size:11px;flex-shrink:0;">${vvEscHtml( f.icon) }</span>`;
} else if (f.icon) {
} else if (vvSafeUrl (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'">`;
iconHtml = `<img src="${vvEscAttr(vvSafeUrl( 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">
let out = `<div class="vv-df-folder">
<div class="vv-df-folder-hdr" onclick="vvToggleFolder('${sid}')">
<div class="vv-df-folder-hdr" onclick="vvToggleFolder('${sid}')">
<span class="vv-df-chevron">${open ? '▾' : '▸'}</span>
<span class="vv-df-chevron">${open ? '▾' : '▸'}</span>
${iconHtml}
${iconHtml}
<span class="vv-df-fname">${f.name}</span>
<span class="vv-df-fname">${vvEscHtml( f.name) }</span>
${badge}
${badge}
</div>`;
</div>`;
if (open) {
if (open) {
@@ -2382,16 +2477,8 @@ document.addEventListener('click', () => {
if (vvDfActive !== null) { vvDfActive = null; vvRenderDockerFolders(vvDfData); }
if (vvDfActive !== null) { vvDfActive = null; vvRenderDockerFolders(vvDfData); }
});
});
// ── Array actions ─────────────────────────────────────────────────────────────
// Array power actions (stop / shutdown / restart) were wired here to api/system.php, but nothing
function vvArrayAction(action) {
// on this page ever called the function — there are no such buttons, on this or any other tab.
const labels = {stop: 'Stop Array', shutdown: 'Shutdown', restart: 'Restart'};
// Removed rather than left as an unreachable handler for the platform's three most destructive
if (!confirm(`${labels[action] ?? action} — are you sure?`)) return;
// operations. The endpoint stays; see its header for why it is kept unwired.
fetch('/plugins/varaverk/api/system.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action}),
}).then(r => r.json()).then(d => {
if (!d.ok) alert('Error: ' + (d.error ?? 'unknown'));
}).catch(() => alert('Request failed'));
}
</script>
</script>