Compare commits

..
3 Commits
Author SHA1 Message Date
Gmer4Lfe ab794db0ab Stop the dashboard polling harder than its data can change
Every poll after the first asked for ?live=1, so the page paid a full collection — partner SSH
timeouts included — every two seconds while the tmpfs cache it was built around went unused.
Polls are now guarded against overlap and stop while the tab is hidden, repeated failures say so
instead of leaving the last good reading on screen, and two functions nothing called are gone.
2026-08-07 10:23:13 -04:00
Gmer4Lfe b445ddc2c8 Make a container action tell the truth about what happened
The response was discarded, so a refused stop looked like a completed one, and the payload the
card redraws from is cached for 300s with no invalidation — the container carried on showing as
running until the once-a-minute writer caught up. Stop now confirms; start still does not.
2026-08-07 10:20:11 -04:00
Gmer4Lfe fb50f94ed8 Escape what the monitor page renders, and put the helpers where every page can reach them
The page interpolated media titles, partner hostnames read over the mesh, and docker folder
names straight into innerHTML — its own header claimed otherwise, and the helpers that would
have fixed it were defined in a page it never loads. Container WebUI values now get a scheme
check before they reach window.open().
2026-08-07 10:17:45 -04:00
8 changed files with 266 additions and 102 deletions
+44
View File
@@ -54,6 +54,50 @@ require_once "$pluginDir/include/config.php";
return nativeFetch(input, init);
};
})();
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Escaping helpers — shared, because every page builds HTML strings and assigns them to innerHTML.
//
// Defined here rather than per page for the reason the CSRF shim is: only one pages/*.php is ever
// included per request, so a helper defined inside one page does not exist for any other. That is
// not a hypothetical — these lived in pages/scheduler.php, and pages/monitor.php rendered media
// titles, partner hostnames and docker folder names straight into innerHTML with no escaping
// available to it at all.
//
// Not in js/varaverk.js, which would otherwise be the obvious home: that file is loaded by a
// <script src> *below* the tab include, so it is not defined yet while a page's inline script is
// running. The shared formatters there survive only because every caller is inside a fetch
// callback. An escaping helper must be callable from the first synchronous line of a page.
//
// String() rather than assuming a string: these are fed payload fields that are frequently
// numbers, and sometimes null or undefined. A helper that throws on a number is a helper call
// sites will skip.
//
// Two functions because the contexts differ, and using the wrong one is silent:
// vvEscHtml text between tags. Leaves " alone — harmless there.
// vvEscAttr text inside an attribute. Escapes " as well, because a quote inside a
// double-quoted attribute ends it early and destroys the rest of the handler.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
function vvEscHtml(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function vvEscAttr(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/"/g,'&quot;')
.replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// A URL about to be put in href/src or handed to window.open. Anything that is not plainly http,
// https or a site-relative path becomes empty — javascript: is the one that matters, and an
// allowlist is the only way to say that without chasing encodings. include/docs.php applies the
// same rule to markdown links; container WebUI values, which come from template XML, had no such
// check before reaching window.open().
function vvSafeUrl(u) {
const s = String(u ?? '').trim();
if (s === '') return '';
if (/^https?:\/\//i.test(s)) return s;
if (/^\/(?!\/)/.test(s)) return s;
return '';
}
</script>
<?php
+8
View File
@@ -127,8 +127,15 @@ if ($check !== $name) {
echo json_encode(['ok' => false, 'error' => 'Container not found']); exit;
}
// Every arm below drops the monitor cache once the container state has actually changed. That
// payload carries the container list the dashboard draws, is served with a 300s window, and is
// otherwise only rewritten by the once-a-minute cache writer — so without this the operator stops
// a container, watches the card, and sees it running for up to a minute with no hint as to why.
// Cleared after the command rather than before, so a failed action does not throw away a payload
// that is still accurate.
if ($action === 'start' || $action === 'stop') {
exec(($action === 'start' ? 'docker start' : 'docker stop') . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
if ($rc === 0) vv_cache_clear('monitor');
echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]); exit;
}
@@ -142,6 +149,7 @@ if ($action === 'restart') {
$out = array_merge($o1, $o2);
$rc = ($rc1 === 0 && $rc2 === 0) ? 0 : 1;
}
if ($rc === 0) vv_cache_clear('monitor');
echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]); exit;
}
+8
View File
@@ -92,6 +92,10 @@ if (PHP_SAPI !== 'cli') {
if (!$name || !$jobFile || !$image) exit(1);
// Only for vv_cache_clear() below. Required after the CLI guard, so a stray web request is turned
// away before this process loads anything at all.
require_once dirname(__DIR__) . '/include/config.php';
function jw(string $f, array $d): void { file_put_contents($f, json_encode($d)); }
shell_exec('docker pull ' . escapeshellarg($image) . ' 2>&1');
@@ -115,6 +119,10 @@ if ($rebuild && is_executable($rebuild)) {
$rc = ($rc1 === 0 && $rc2 === 0) ? 0 : 1;
}
// The container was stopped and started to get here whether or not the rebuild reported success,
// so the cached container list is out of date either way.
vv_cache_clear('monitor');
jw($jobFile, $rc === 0
? ['ok' => true, 'status' => 'done', 'updated' => true, 'message' => 'Updated and rebuilt']
: ['ok' => false, 'status' => 'done', 'error' => 'Rebuild failed after pull']
+7
View File
@@ -5,6 +5,13 @@
// most destructive operations the plugin can perform, deliberately isolated in one small
// file rather than folded into a general-purpose action endpoint.
//
// STATUS
// No UI caller. pages/monitor.php held the only one — a vvArrayAction() that no button on any
// tab invoked — and it was removed 2026-08-07 rather than left as an unreachable handler for
// these three commands. The endpoint is kept because it is complete and correct, and because
// power control is a thing this plugin will plausibly want; wiring it up is adding buttons, not
// writing an endpoint. Anything added here must keep the guarantees below intact.
//
// OPERATIONAL MODEL
// The command is detached and the response returns immediately. A shutdown kills the web
// server that is serving this request, so waiting on the child would mean the browser sees
+11
View File
@@ -456,6 +456,17 @@ function vv_cache_write(string $key, array $data): void {
rename($tmp, $f);
}
// Drop a cached payload so the next read collects fresh. For use by endpoints that change the
// very state a cache describes: without it the UI polls a payload that cannot yet know about the
// action it just took, and the operator sees a container they stopped still running until the
// background writer next comes round.
//
// Best-effort by design. A cache that could not be removed is a stale read, which is what would
// have happened anyway — never a reason to fail the action that was actually requested.
function vv_cache_clear(string $key): void {
@unlink(VV_CACHE_DIR . '/' . $key . '.json');
}
// ── Shared utility functions (used across include/ and api/ files) ────────────
// Format seconds into "2d 3h 15m".
+7
View File
@@ -44,6 +44,13 @@ function vv_container_webui(string $name, array $portMap): string {
return $portMap[$name][$pm[1]] ?? $pm[1];
}, $url);
// Scheme allowlist, applied here so a bad value never reaches the page rather than being
// filtered at each sink. A WebUI entry is http or https in every real template; anything else
// is either broken or a javascript: URL aimed at whoever clicks it. These files come from
// Community Applications and hand edits, so they are not ours to trust. include/docs.php
// applies the same rule to markdown links for the same reason.
if (!preg_match('#^https?://#i', $url)) return '';
return $url;
}
+177 -90
View File
@@ -7,14 +7,25 @@
//
// DESIGN PRINCIPLES
// Two poll rates, deliberately split.
// api/monitor_fast.php carries the cheap, fast-moving values (12s); api/monitor.php
// carries the full payload on a slower cycle. Everything refreshing at the fast rate
// would put real load on the WebGUI this page exists to watch.
// api/monitor_fast.php carries the cheap, fast-moving values at 1s; api/monitor.php
// carries the full payload at 5s. Everything refreshing at the fast rate would put real
// load on the WebGUI this page exists to watch.
//
// Served from the tmpfs cache, not live calls.
// 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
// can never be why the dashboard fails to load.
// ?live=1 bypasses it, and is used for exactly one thing: the poll that follows a
// 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.
// 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
// suspiciously green, verify the paths before believing it.
//
// Container actions are confirmed and routed through the action endpoint, which validates
// against real inventory.
// Stopping a container is confirmed; starting one is not, and the endpoint validates every
// 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.
// 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
// System header, CPU per core, memory breakdown, GPU cards, storage pools and array disks,
@@ -40,8 +68,7 @@
// DEPENDS ON
// include/monitor.php required directly for initial render
// api/monitor.php full payload, slower cycle
// api/monitor_fast.php fast-moving values, 12s
// api/system.php system info
// api/monitor_fast.php fast-moving values, 1s
// api/media.php now-playing sessions
// api/docker_action.php container actions
// api/flag_toggle.php toggles
@@ -395,14 +422,6 @@ function vvDrawNetChart(canvas, rxData, txData, maxBps) {
// ── 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) {
const overall = cpu.overall ?? 0;
const cores = cpu.cores ?? [];
@@ -477,7 +496,7 @@ function vvRenderMemory(mem) {
const procs = mem.top_procs ?? [];
const procStrip = procs.map(p =>
`<span style="white-space:nowrap;">${p.name}&nbsp;<span style="color:#aaa;font-weight:600;">${vvFmtGib(p.kb)}</span></span>`
`<span style="white-space:nowrap;">${vvEscHtml(p.name)}&nbsp;<span style="color:#aaa;font-weight:600;">${vvFmtGib(p.kb)}</span></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;">
@@ -664,7 +683,7 @@ function vvDiskRow(disk) {
: `<span style="color:#555;font-size:10px;">${vvFmt(disk.used_gb)} / ${vvFmt(disk.size_gb)}</span>`;
return `<div style="margin-bottom:7px;">
<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}
<span style="color:${tempColor};font-size:10px;margin-left:6px;flex-shrink:0;">${tempStr}</span>
</div>
@@ -680,14 +699,54 @@ function vvDiskCol(disks) {
// ── 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 12s 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() {
const _url = _vvFirstPoll ? '/plugins/varaverk/api/monitor.php' : '/plugins/varaverk/api/monitor.php?live=1';
_vvFirstPoll = false;
fetch(_url)
// Consecutive poll failures. A dashboard whose endpoint has died looks exactly like one where
// nothing is happening, which is the worst way for it to fail — every number on screen stays at
// its last good value and nothing says so. Three in a row rather than one, so a single blip
// 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(d => {
vvPollFails = 0;
// ── API status banner ────────────────────────────────────────────────────
const apiStatus = d._api_status ?? {};
@@ -741,7 +800,7 @@ function vvPollMonitor() {
document.getElementById('vv-system-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<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 || '&nbsp;'}</div>
</div>
<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:10px;color:#555;margin-bottom:10px;">${dateStr} &middot; ${tz}</div>
<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;">Array</span> <span style="color:${arrayColor};font-weight:600;">${sys.array_state}</span>
<span style="color:#444;">Uptime</span> <span style="color:#888;">${sys.uptime}</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;">${vvEscHtml(sys.array_state)}</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;">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>
@@ -812,8 +871,8 @@ function vvPollMonitor() {
const ramAvail = rs.mem_used_pct > 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 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 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 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;">${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 uptimeStr = rs.uptime && rs.uptime !== '—' ? rs.uptime : '—';
@@ -823,18 +882,18 @@ function vvPollMonitor() {
const verMismatch = myVer && remoteVer && myVer !== remoteVer;
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;">
⚠ 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>
</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;">
<span style="color:#444;">CPU</span>${cpuStr}
<span style="color:#444;">RAM</span>${ramStr}
<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:#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}
</div>${verWarn}`;
} 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;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<span style="font-size:10px;color:#555;margin-right:4px;">${h.id}</span>
<span style="font-size:12px;color:#ccc;font-weight:500;">${h.owner || h.hostname}</span>${tags}
<div style="font-size:10px;color:#555;margin-top:1px;">${h.hostname}</div>
<span style="font-size:10px;color:#555;margin-right:4px;">${vvEscHtml(h.id)}</span>
<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;">${vvEscHtml(h.hostname)}</div>
${onboardBadge}
</div>
<span style="color:${dot};font-size:10px;white-space:nowrap;">● ${label}</span>
@@ -952,13 +1011,13 @@ function vvPollMonitor() {
if (fbActive.length) {
fbActive.forEach(group => {
fbHtml += `<div style="font-size:10px;color:#555;margin-bottom:4px;letter-spacing:.03em;">
COVERING ${group.hostname}
COVERING ${vvEscHtml(group.hostname)}
</div>`;
group.containers.forEach(c => {
const img = c.image.includes('/') ? c.image.split('/').pop() : c.image;
fbHtml += `<div style="display:flex;justify-content:space-between;align-items:center;
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>
</div>`;
});
@@ -1022,8 +1081,8 @@ function vvPollMonitor() {
document.getElementById('vv-ups-body').innerHTML =
`<div class="vv-banner ${statCls}" style="margin-bottom:8px;">
<span>${ups.status}${onBatt ? ' — ON BATTERY' : ''}</span>
<span style="font-size:11px;font-weight:400;opacity:0.8;">${ups.model}</span>
<span>${vvEscHtml(ups.status)}${onBatt ? ' — ON BATTERY' : ''}</span>
<span style="font-size:11px;font-weight:400;opacity:0.8;">${vvEscHtml(ups.model)}</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;">
<div>
@@ -1293,7 +1352,7 @@ function vvPollMonitor() {
const diff = now - r.ts;
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;">
<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>
</div>`;
});
@@ -1494,7 +1553,7 @@ function vvPollMonitor() {
html += `<div style="background:#0d1a0a;border:1px solid #1a3a0a;border-radius:3px;
padding:4px 8px;margin-bottom:5px;">
<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>
</div>
<div style="height:3px;background:#0a0a0a;border-radius:2px;overflow:hidden;">
@@ -1529,7 +1588,7 @@ function vvPollMonitor() {
html += `<div style="margin-bottom:4px;">
<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;">
${s?.duration ? _dur(s.duration) : ''}</span>
<span style="font-size:9px;color:#2a2a2a;width:28px;text-align:right;margin-right:5px;">
@@ -1607,7 +1666,7 @@ function vvPollMonitor() {
bodyEl.innerHTML =
// header row: name + process count pill
`<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};
padding:1px 8px;border-radius:10px;font-size:11px;white-space:nowrap;">${procCount} proc${procCount !== 1 ? 's' : ''}</div>
</div>` +
@@ -1699,7 +1758,7 @@ function vvPollMonitor() {
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;">
${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:#ff9800;flex-shrink:0;">${meth}</span>
</div>`;
@@ -1759,15 +1818,16 @@ function vvPollMonitor() {
vvRenderDockerFolders(dfData);
})
.catch(() => {});
.catch(vvPollFailed);
}
vvPollMonitor();
setInterval(vvPollMonitor, 2000);
// 5s against a payload the cache writer refreshes once a minute. Polling faster cannot make the
// 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 ──────────────────
function vvPollFast() {
fetch('/plugins/varaverk/api/monitor_fast.php')
return fetch('/plugins/varaverk/api/monitor_fast.php')
.then(r => r.json())
.then(d => {
// CPU
@@ -1799,15 +1859,15 @@ function vvPollFast() {
const peakTx = Math.max(...vvNetTxHistory, 0);
const ipRows = [
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN&nbsp;&nbsp;</span>${net.local_ip}</div>` : '',
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT&nbsp;&nbsp;</span>${net.ext_ip}</div>` : '',
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS&nbsp;&nbsp;&nbsp;</span>${net.ts_ip}</div>` : '',
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN&nbsp;&nbsp;</span>${vvEscHtml(net.local_ip)}</div>` : '',
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT&nbsp;&nbsp;</span>${vvEscHtml(net.ext_ip)}</div>` : '',
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS&nbsp;&nbsp;&nbsp;</span>${vvEscHtml(net.ts_ip)}</div>` : '',
].filter(Boolean).join('');
document.getElementById('vv-network-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} &nbsp;·&nbsp; ${linkLabel}</div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">${vvEscHtml(net.iface)} &nbsp;·&nbsp; ${linkLabel}</div>
<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:#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(() => {});
}
vvPollFast();
setInterval(vvPollFast, 1000);
// Stays at 1s — this endpoint reads /proc and borrows its slow fields from the full cache, so it
// 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
function vvSyncCardWidths() {
@@ -1886,8 +1947,8 @@ function vvRenderStreams() {
const badges = names.map(n => {
const cnt = serverCounts[n] ?? 0;
return cnt > 0
? `<span class="vv-server-badge">${n} <b style="color:#ccc;">${cnt}</b></span>`
: `<span class="vv-server-badge" style="color:#444;">${n}</span>`;
? `<span class="vv-server-badge">${vvEscHtml(n)} <b style="color:#ccc;">${cnt}</b></span>`
: `<span class="vv-server-badge" style="color:#444;">${vvEscHtml(n)}</span>`;
}).join('');
// 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];
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) {
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"
@@ -2008,11 +2072,11 @@ function vvRenderStreams() {
return `<div>
<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:${iconColor};">${icon}</span> ${s.title}</span>
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${s.server}</span>
<span style="color:${iconColor};">${icon}</span> ${vvEscHtml(s.title)}</span>
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${vvEscHtml(s.server)}</span>
</div>
<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}
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
@@ -2044,7 +2108,7 @@ function vvRenderStreams() {
}
function vvPollStreams() {
fetch('/plugins/varaverk/api/media.php')
return fetch('/plugins/varaverk/api/media.php')
.then(r => r.json())
.then(d => {
vvLastSessions = d.sessions ?? [];
@@ -2063,8 +2127,11 @@ function vvPollStreams() {
.catch(() => {});
}
vvPollStreams();
setInterval(vvPollStreams, 12000);
// Guarded like the others — this one reaches out to every configured media server, so a wedged
// 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);
// ── Pools card ────────────────────────────────────────────────────────────────
@@ -2223,19 +2290,41 @@ function vvToggleContainer(name) {
}
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') {
window.location.href = '/Docker?action=template&xmlTemplate=' +
encodeURIComponent('/boot/config/plugins/dockerMan/templates-user/my-' + name + '.xml') + '&update=true';
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();
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(() => {});
.then(d => {
// 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) {
@@ -2270,7 +2359,7 @@ function vvRenderDockerFolders(data) {
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 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>
${meta}
</div>`;
@@ -2288,8 +2377,14 @@ function vvRenderDockerFolders(data) {
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,"\\'");
// Escaped for two nested contexts at once: a JS string literal, and the double-quoted onclick
// 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 = '';
if (active) {
@@ -2309,8 +2404,8 @@ function vvRenderDockerFolders(data) {
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>
<span class="vv-df-cname">${vvEscHtml(c.name)}</span>
<span class="vv-df-status">${vvEscHtml(sShort)}</span>
</div>${actionBar}`;
}
@@ -2320,20 +2415,20 @@ function vvRenderDockerFolders(data) {
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;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 = '';
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'">`;
iconHtml = `<span style="font-size:11px;flex-shrink:0;">${vvEscHtml(f.icon)}</span>`;
} else if (vvSafeUrl(f.icon)) {
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">
<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>
<span class="vv-df-fname">${vvEscHtml(f.name)}</span>
${badge}
</div>`;
if (open) {
@@ -2382,16 +2477,8 @@ document.addEventListener('click', () => {
if (vvDfActive !== null) { vvDfActive = null; vvRenderDockerFolders(vvDfData); }
});
// ── Array actions ─────────────────────────────────────────────────────────────
function vvArrayAction(action) {
const labels = {stop: 'Stop Array', shutdown: 'Shutdown', restart: 'Restart'};
if (!confirm(`${labels[action] ?? action} — are you sure?`)) return;
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'));
}
// Array power actions (stop / shutdown / restart) were wired here to api/system.php, but nothing
// on this page ever called the function — there are no such buttons, on this or any other tab.
// Removed rather than left as an unreachable handler for the platform's three most destructive
// operations. The endpoint stays; see its header for why it is kept unwired.
</script>
+4 -12
View File
@@ -1158,18 +1158,10 @@ if (vvOpenScriptId) {
});
}
function vvEscHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// For text going INSIDE a double-quoted attribute, which vvEscHtml does not cover: it leaves "
// alone, and a quote there ends the attribute early and silently destroys the handler after it.
// That is not a theoretical hazard — it shipped, and an onclick built from JSON.stringify() output
// was truncated to "vvErrOpenAtLine(" and did nothing at all when clicked.
function vvEscAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;')
.replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// vvEscHtml() and vvEscAttr() moved to Varaverk.page, which every tab loads — they were needed on
// pages that never include this one. The attribute variant exists because a " inside a
// double-quoted attribute ends it early and silently destroys the handler after it: that shipped
// once, truncating an onclick to "vvErrOpenAtLine(" so it did nothing at all when clicked.
function vvPost(url, data) {
const params = new URLSearchParams({csrf_token, ...data});