Files
Varaverk/Plugin/unraid/pages/monitor.php
T
Gmer4Lfe c3ed7262cd Separate whether the chat is expanded from how big expanded is
One is an action taken constantly and the other a preference set once, so the banner names the
size and the composer keeps the toggle.
2026-08-09 12:59:30 -04:00

2880 lines
159 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Monitor tab. The main dashboard — CPU per core, memory, GPUs, disks and pools, network,
// UPS, VMs, containers, transcodes, media sessions, and the watchdog health roll-up, for
// this host and every partner.
//
// DESIGN PRINCIPLES
// Two poll rates, deliberately split.
// 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, 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
// or an error. The page is built to be correct on hardware lacking any given part. The
// AI row is the same rule applied to a subsystem rather than a device: it exists only
// where vv_ai_ui_on() is true, which is the AI host with AI_ENABLED set.
//
// The assistant here starts on General Chat, where the AI tab starts on Varaverk Assistant.
// Different jobs. The tab is where you go to interrogate the installation; this is the
// box you type an idle question into while watching the dashboard. The worker escalates
// anything genuinely about this install to the strict profile on its own, so starting
// loose costs nothing and starting strict would refuse ordinary questions.
//
// OPERATIONAL SAFEGUARDS
// The health roll-up must not default to healthy.
// vv_watchdog_summary() is conjunctive across every strike set, and it reads state from
// STATE_DIR. Six of those paths once pointed at /tmp, every read returned empty, and
// the page reported healthy unconditionally (fixed 2026-08-02). If this panel looks
// suspiciously green, verify the paths before believing it.
//
// 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,
// network, UPS, VMs, containers, transcode sessions, media now-playing, watchdog summary,
// partner node cards, and — on the AI host only — model residency, saved conversations and
// an assistant
//
// DEPENDS ON
// include/monitor.php required directly for initial render
// include/ai_chat.php the AI row's chat and conversation list, shared with the AI tab
// api/monitor.php full payload, slower cycle
// 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
// api/ai.php the AI row's turns and conversation store
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/monitor.php';
require_once dirname(__DIR__) . '/include/ai_chat.php';
require_once dirname(__DIR__) . '/include/docs.php';
if (vv_ai_ui_on()) vv_ai_chat_assets();
// Live values for the `$VAR` markers in pages/readme/monitor-readme.md. Same arrangement the
// Scheduler tab uses: the doc names a conf variable and the reader sees this host's value.
$_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
?>
<style>
@keyframes vvRsPulse {
0%,100% { opacity:.5; transform:scaleX(.9); }
50% { opacity:1; transform:scaleX(1.05); }
}
</style>
<div id="vv-api-banner" style="display:none;border-radius:4px;padding:5px 10px;margin-bottom:8px;font-size:11px;"></div>
<div id="vv-monitor" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;width:100%;box-sizing:border-box;">
<!-- Row 1: System | Power | CPU | Memory | Network -->
<div class="vv-card" id="vv-system" style="grid-column:span 1;">
<div id="vv-system-body">Loading...</div>
</div>
<div class="vv-card" id="vv-ups-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<svg width="9" height="14" viewBox="0 0 11 18" fill="none" style="opacity:0.4;flex-shrink:0;">
<polygon points="9,0 2,10 6,10 3,18 11,6 6,6 8,0" fill="#ff9800"/>
</svg>Power
</span>
<a href="/Settings/UPS" target="_blank" class="vv-card-cog" title="UPS Settings">⚙</a>
</h3>
<div id="vv-ups-body">Loading...</div>
</div>
<div class="vv-card" id="vv-cpu" style="grid-column:span 2;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><rect x="3" y="3" width="6" height="6" rx="0.8"/><line x1="4.5" y1="3" x2="4.5" y2="1.2"/><line x1="7.5" y1="3" x2="7.5" y2="1.2"/><line x1="4.5" y1="9" x2="4.5" y2="10.8"/><line x1="7.5" y1="9" x2="7.5" y2="10.8"/><line x1="3" y1="4.5" x2="1.2" y2="4.5"/><line x1="3" y1="7.5" x2="1.2" y2="7.5"/><line x1="9" y1="4.5" x2="10.8" y2="4.5"/><line x1="9" y1="7.5" x2="10.8" y2="7.5"/></svg></span>
<span id="vv-cpu-title">CPU</span>
</span>
<a href="/Settings/CPUset" target="_blank" class="vv-card-cog" title="CPU Settings">⚙</a>
</h3>
<div id="vv-cpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-memory" style="grid-column:span 2;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="8" viewBox="0 0 14 8" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><rect x="0.6" y="2.5" width="12.8" height="3" rx="0.5"/><line x1="3" y1="2.5" x2="3" y2="0.8"/><line x1="5.5" y1="2.5" x2="5.5" y2="0.8"/><line x1="8" y1="2.5" x2="8" y2="0.8"/><line x1="10.5" y1="2.5" x2="10.5" y2="0.8"/><line x1="3" y1="5.5" x2="3" y2="7.2"/><line x1="5.5" y1="5.5" x2="5.5" y2="7.2"/><line x1="8" y1="5.5" x2="8" y2="7.2"/><line x1="10.5" y1="5.5" x2="10.5" y2="7.2"/></svg></span>
Memory
</span>
<a href="/" target="_blank" class="vv-card-cog" title="Dashboard">⚙</a>
</h3>
<div id="vv-memory-body">Loading...</div>
</div>
<div class="vv-card" id="vv-network" style="grid-column:span 2;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="13" height="12" viewBox="0 0 13 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><rect x="4.5" y="0.5" width="4" height="3" rx="0.7"/><rect x="0.5" y="8" width="4" height="3" rx="0.7"/><rect x="8.5" y="8" width="4" height="3" rx="0.7"/><line x1="6.5" y1="3.5" x2="6.5" y2="6"/><line x1="6.5" y1="6" x2="2.5" y2="6"/><line x1="2.5" y1="6" x2="2.5" y2="8"/><line x1="6.5" y1="6" x2="10.5" y2="6"/><line x1="10.5" y1="6" x2="10.5" y2="8"/></svg></span>
Network
</span>
<a href="/Settings/NetworkSettings" target="_blank" class="vv-card-cog" title="Network Settings">⚙</a>
</h3>
<div id="vv-network-body">Loading...</div>
</div>
<!-- Row 2: Scripts | Fallback | Partner | Containers & VMs -->
<div class="vv-card" id="vv-scripts-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="1" width="10" height="10" rx="1.5"/><line x1="1" y1="4.5" x2="11" y2="4.5"/><polyline points="3.5,7 5,8 3.5,9"/><line x1="6" y1="9" x2="8.5" y2="9"/></svg></span>
Scripts
</span>
<a href="/Apps/plugin_userscripts" target="_blank" class="vv-card-cog" title="User Scripts">⚙</a>
</h3>
<div id="vv-scripts-body">Loading...</div>
</div>
<div class="vv-card" id="vv-fallback" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="10" height="12" viewBox="0 0 10 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><path d="M5 0.8L9.2 2.8V6.2C9.2 9 5 11.2 5 11.2C5 11.2 0.8 9 0.8 6.2V2.8Z"/></svg></span>
Fallback
</span>
</h3>
<div id="vv-fallback-body">Loading...</div>
</div>
<div class="vv-card" id="vv-partner" style="grid-column:span 2;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><rect x="0.5" y="1.5" width="4" height="7" rx="0.8"/><rect x="9.5" y="1.5" width="4" height="7" rx="0.8"/><line x1="4.5" y1="5" x2="9.5" y2="5"/><circle cx="7" cy="5" r="1" fill="#888" stroke="none"/></svg></span>
Partner
</span>
<img src="/plugins/varaverk/icons/varaverk.png" style="width:16px;height:16px;object-fit:contain;opacity:0.2;flex-shrink:0;" title="Varaverk">
</h3>
<div id="vv-partner-body">Loading...</div>
</div>
<div class="vv-card" id="vv-docker-folders" style="grid-column:span 4;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="0.5" width="12" height="2.5" rx="0.5"/><rect x="1" y="3.8" width="12" height="2.5" rx="0.5"/><rect x="1" y="7" width="12" height="2.5" rx="0.5"/></svg></span>
Containers &amp; VMs <span id="vv-docker-count" style="font-size:10px;color:#4a4a4a;font-weight:400;text-transform:none;letter-spacing:0;margin-left:2px;"></span>
</span>
<a href="/Docker" target="_blank" class="vv-card-cog" title="Docker">⚙</a>
</h3>
<div id="vv-docker-folders-body">Loading...</div>
</div>
<!-- Row 3: Rsync | GPU 0 | GPU 1 | Transcode | Streams -->
<div class="vv-card" id="vv-rsync-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><polyline points="2,4 10,4 8,2"/><polyline points="10,8 2,8 4,10"/></svg></span>
Rsync
</span>
</h3>
<div id="vv-rsync-body">Loading...</div>
</div>
<div class="vv-card" id="vv-gpu-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="15" height="10" viewBox="0 0 16 10" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="0.8" y="0.8" width="14.4" height="7" rx="1.2"/><rect x="2.5" y="2.5" width="3.5" height="3.5" rx="0.5"/><line x1="8" y1="3" x2="13" y2="3"/><line x1="8" y1="5" x2="11" y2="5"/><rect x="3" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/><rect x="6.5" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/><rect x="10" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/></svg></span>
<span id="vv-gpu-label">GPU</span>
</span>
<a href="/" target="_blank" class="vv-card-cog" title="Dashboard">⚙</a>
</h3>
<div id="vv-gpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-gpu1-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="15" height="10" viewBox="0 0 16 10" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="0.8" y="0.8" width="14.4" height="7" rx="1.2"/><rect x="2.5" y="2.5" width="3.5" height="3.5" rx="0.5"/><line x1="8" y1="3" x2="13" y2="3"/><line x1="8" y1="5" x2="11" y2="5"/><rect x="3" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/><rect x="6.5" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/><rect x="10" y="7.8" width="2" height="1.8" rx="0.3" stroke="none" fill="#666"/></svg></span>
<span id="vv-gpu1-label">GPU 1</span>
</span>
<a href="/" target="_blank" class="vv-card-cog" title="Dashboard">⚙</a>
</h3>
<div id="vv-gpu1-body">Loading...</div>
</div>
<div class="vv-card" id="vv-transcode" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="12" viewBox="0 0 14 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="0.5" y="1" width="4" height="10" rx="0.8"/><line x1="0.5" y1="3.5" x2="2.3" y2="3.5"/><line x1="0.5" y1="8.5" x2="2.3" y2="8.5"/><line x1="2.7" y1="3.5" x2="4.5" y2="3.5"/><line x1="2.7" y1="8.5" x2="4.5" y2="8.5"/><line x1="6" y1="6" x2="9.5" y2="6"/><polyline points="8.2,4.3 10.2,6 8.2,7.7"/><rect x="11" y="1" width="2.5" height="10" rx="0.5"/></svg></span>
Transcode
</span>
</h3>
<div id="vv-transcode-body">Loading...</div>
</div>
<div class="vv-card" id="vv-streams" style="grid-column:span 4;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><polygon points="1.5,1 1.5,11 10,6"/></svg></span>
Streams
</span>
</h3>
<div id="vv-streams-body">Loading...</div>
</div>
<!-- Row 4: Watchdog | Parity | Pools | Array -->
<div class="vv-card" id="vv-watchdog-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="9" viewBox="0 0 14 8" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><path d="M1 4C3.2 1 5 0.5 7 0.5C9 0.5 10.8 1 13 4C10.8 7 9 7.5 7 7.5C5 7.5 3.2 7 1 4Z"/><circle cx="7" cy="4" r="1.5"/></svg></span>
Watchdog
</span>
</h3>
<div id="vv-watchdog-body">Loading...</div>
</div>
<div class="vv-card" id="vv-parity-card" style="grid-column:span 1;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="5"/><polyline points="3.5,6 5.5,8 8.5,4"/></svg></span>
Parity
</span>
<a href="/Main" target="_blank" class="vv-card-cog" title="Array Management">⚙</a>
</h3>
<div id="vv-parity-body">Loading...</div>
</div>
<div class="vv-card" id="vv-storage-card" style="grid-column:3/span 2;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><ellipse cx="6" cy="3" rx="4.5" ry="1.5"/><line x1="1.5" y1="3" x2="1.5" y2="9"/><line x1="10.5" y1="3" x2="10.5" y2="9"/><ellipse cx="6" cy="9" rx="4.5" ry="1.5"/></svg></span>
<span id="vv-pools-title">Pools</span>
</span>
<span id="vv-pools-io-total" style="font-size:9px;font-weight:400;color:#333;text-transform:none;letter-spacing:0;"></span>
<a href="/Main" target="_blank" class="vv-card-cog" title="Array Management">⚙</a>
</h3>
<div id="vv-storage-body">Loading...</div>
</div>
<div class="vv-card" id="vv-array-card" style="grid-column:5/span 4;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="14" height="12" viewBox="0 0 14 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><rect x="0.7" y="0.7" width="12.6" height="3" rx="0.8"/><rect x="0.7" y="4.5" width="12.6" height="3" rx="0.8"/><rect x="0.7" y="8.3" width="12.6" height="3" rx="0.8"/><circle cx="11.5" cy="2.2" r="0.8" fill="#888" stroke="none"/><circle cx="11.5" cy="6" r="0.8" fill="#888" stroke="none"/><circle cx="11.5" cy="9.8" r="0.8" fill="#888" stroke="none"/></svg></span>
<span id="vv-array-title">Array</span>
</span>
<span id="vv-array-io-total" style="font-size:9px;font-weight:400;color:#333;text-transform:none;letter-spacing:0;"></span>
<a href="/Main" target="_blank" class="vv-card-cog" title="Array Management">⚙</a>
</h3>
<div id="vv-array-body">Loading...</div>
</div>
</div>
<?php if (vv_ai_ui_on()): ?>
<!-- ── AI row — its own grid, deliberately ──────────────────────────────────────
Same eight columns and the same gap, so it reads as the last row of the one above. It is a
separate container because #vv-monitor caps every row at minmax(0, ~25vh) and clips its
cards with overflow:hidden — which is right for a dashboard tile that must not push the
others off screen, and wrong for the one card you sit and read. Inside that grid the
expand control did nothing visible: the transcript grew and scrolled inside a box whose
height the row had already decided.
Cards here size to their content instead. -->
<div id="vv-monitor-ai">
<!-- AI residency | Saved conversations | Assistant -->
<!-- Present only on the AI host with AI_ENABLED true, on the same footing as the GPU and UPS
cards above: a subsystem that is not here does not render an empty card explaining that
it is not here. api/ai.php refuses every action independently, so this is presentation
rather than access control. -->
<!-- Placement for this row lives in css/varaverk.css, not on these cards. It is two rows
deep — AI over Tokens in the first column — and a grid-column span here cannot say which
row a card belongs to, so an inline span would only fight the stylesheet for half the
answer. -->
<div class="vv-card" id="vv-ai-stats-card">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="2.5" width="7" height="7" rx="1.2"/><circle cx="4.7" cy="5" r="0.7" fill="#888" stroke="none"/><circle cx="7.3" cy="5" r="0.7" fill="#888" stroke="none"/><line x1="4.5" y1="7.3" x2="7.5" y2="7.3"/><line x1="6" y1="2.5" x2="6" y2="0.8"/><line x1="2.5" y1="6" x2="0.8" y2="6"/><line x1="9.5" y1="6" x2="11.2" y2="6"/></svg></span>
AI
</span>
<a href="/plugins/varaverk/Varaverk.page?tab=ai" class="vv-card-cog" title="AI tab">⚙</a>
</h3>
<div id="vv-ai-stats-body">Loading...</div>
</div>
<!-- The token ledger, the same one the AI tab draws, under the card whose subsystem spends
them. Its own card rather than three more lines on the AI card above: that card answers
"is the model healthy right now", and cost accumulated over a month is a different
question that was crowding it out one line at a time. -->
<div class="vv-card" id="vv-ai-tokens-card">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round"><line x1="2" y1="10.3" x2="2" y2="6.5"/><line x1="6" y1="10.3" x2="6" y2="2.4"/><line x1="10" y1="10.3" x2="10" y2="7.6"/></svg></span>
Tokens
</span>
<a href="/plugins/varaverk/Varaverk.page?tab=ai" class="vv-card-cog" title="AI tab">⚙</a>
</h3>
<div id="vv-ai-tokens-body">Loading...</div>
</div>
<div class="vv-card" id="vv-ai-chats-card">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="13" height="12" viewBox="0 0 13 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><path d="M1 2.2C1 1.5 1.5 1 2.2 1H10.8C11.5 1 12 1.5 12 2.2V6.8C12 7.5 11.5 8 10.8 8H4.5L2 10.5V8H2.2C1.5 8 1 7.5 1 6.8Z"/></svg></span>
Conversations
</span>
</h3>
<?php vv_ai_chat_list_markup('vv-mon-ai', true); ?>
</div>
<!-- Named for what it is, not "dock". The Scheduler renders the same component inside a
wrapper that still carries the #vv-ai-dock id, and two elements answering to that name
on one page would be a trap the day anything queries it globally. -->
<div class="vv-card" id="vv-ai-assistant-card">
<?php
// No <h3> of its own, unlike every other card here. The component draws its own banner now
// — it carries the size control — and a card heading above that banner would be the same
// words twice. The banner is styled as a card heading so the row still reads level with the
// cards beside it.
//
// Starts on General Chat, unlike the AI tab. This is the box you type an idle question
// into while watching the dashboard, and the worker escalates anything about this install
// to the assistant on its own — so landing on the strict profile here would refuse
// ordinary questions to guard against a mistake the server already prevents.
vv_ai_chat_markup('vv-mon-ai', [
'profile' => 'chat',
'compact' => true,
'title' => 'Assistant',
'icon' => '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="5"/><path d="M4.4 4.6C4.4 3.7 5.1 3.1 6 3.1C6.9 3.1 7.6 3.7 7.6 4.5C7.6 5.9 6 5.7 6 7"/><circle cx="6" cy="8.8" r="0.6" fill="#888" stroke="none"/></svg>',
// Deliberately unchanged. The control is wired in so the card behaves like every other
// instance, but the numbers are the ones this card has always used: 300px collapsed and
// 750px expanded, which is now Large. Medium is provisional and sits between them until
// this card gets the same re-cut the Scheduler's shares just had.
'height' => '300px',
'tall' => '500px',
'tallLarge' => '750px',
'empty' => 'Ask anything. Questions about this installation are handed to the '
. 'Varaverk assistant automatically.',
'placeholder' => 'Ask the assistant…',
]);
?>
</div>
</div>
<?php endif; ?>
<!-- ── How to read this page ────────────────────────────────────────────────────
Rendered from pages/readme/monitor-readme.md, not maintained here. That file is also what
the AI tab retrieves, so the panel you read and the answer the assistant gives are the same
text and cannot drift.
Below the grid and collapsed by default: this page is watched, not read, and help that
pushed the cards down would be in the way every time you opened the tab for the reason you
usually open it. Two views of one file — the brief list is one line per card, "More info"
swaps in the full document for the parts that need explaining. -->
<div class="vv-sug-block" data-save-key="monitor-howto" style="margin-top:12px;">
<div class="vv-sug-header" onclick="vvMonToggleHelp(this)">
<span class="vv-sug-chevron">▸</span>
<span class="vv-sug-title">How to read this page</span>
<button class="vv-more-btn" id="vv-mon-howto-more" type="button"
onclick="event.stopPropagation(); vvMonToggleMore(this)">More info</button>
</div>
<div class="vv-sug-body vv-info-body" style="display:none">
<ul class="vv-info-cols" id="vv-mon-howto-brief">
<?= vv_docs_brief('Plugin/unraid/pages/readme/monitor-readme.md', $_vv_doc_vars,
fn($h) => is_string($h) && str_starts_with($h, 'Reference —')) ?>
</ul>
<div class="vv-doc" id="vv-mon-howto-full" style="display:none">
<?= vv_docs_render('Plugin/unraid/pages/readme/monitor-readme.md', $_vv_doc_vars) ?>
</div>
</div>
</div>
<script>
// ── Shared helpers ────────────────────────────────────────────────────────────
function vvMeter(label, pct, text) {
const color = `hsl(${Math.round(120 * (1 - pct / 100))},70%,45%)`;
return `<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;font-size:11px;color:#666;margin-bottom:3px;">
<span>${label}</span><span style="color:#999;">${text}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${color};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
}
// ── Rolling history ───────────────────────────────────────────────────────────
const VV_HIST_MAX = 24; // 24 × 5 s = 120 s
let vvCpuHistory = [];
let vvLastSessions = [];
let vvLastStreamPollAt = 0;
let vvLastStreamNames = [];
let vvStreamServerCount = 0;
let vvScriptsFilter = null;
let vvLastScripts = {};
let vvThresholds = {util_warn:70,util_crit:90,hdd_warn:45,hdd_crit:55,ssd_warn:60,ssd_crit:70};
let vvNetRxHistory = [];
let vvNetTxHistory = [];
let vvPoolsOpen = {};
let vvPoolGroupOpen = {};
let vvDiskIo = {}; // {device: [readMBs, writeMBs]}
let vvLastStorageDisks = [];
// ── Canvas chart ──────────────────────────────────────────────────────────────
function vvDrawChart(canvas, data, lineColor, fillColor, grid = false) {
if (!canvas) return;
const W = canvas.offsetWidth || 200;
const H = canvas.offsetHeight || 36;
if (canvas.width !== W) canvas.width = W;
if (canvas.height !== H) canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
// grid lines
if (grid) {
ctx.save();
ctx.setLineDash([2, 3]);
ctx.lineWidth = 0.5;
[25, 50, 75].forEach(pct => {
const y = H - (pct / 100) * (H - 2) - 1;
ctx.strokeStyle = '#2e2e2e';
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(W, y);
ctx.stroke();
ctx.fillStyle = '#3a3a3a';
ctx.font = '7px monospace';
ctx.textAlign = 'left';
ctx.fillText(pct + '%', 2, y - 2);
});
ctx.restore();
}
if (data.length < 2) return;
const step = W / (VV_HIST_MAX - 1);
const pts = data.map((v, i) => [i * step, H - (v / 100) * (H - 2) - 1]);
// fill
ctx.beginPath();
ctx.moveTo(0, H);
pts.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.lineTo((data.length - 1) * step, H);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
// line
ctx.beginPath();
pts.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y));
ctx.strokeStyle = lineColor;
ctx.lineWidth = 1.5;
ctx.stroke();
}
// ── Network helpers ───────────────────────────────────────────────────────────
function vvFmtBps(bps) {
if (bps >= 1e9) return (bps / 1e9).toFixed(2) + ' Gb/s';
if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' Mb/s';
if (bps >= 1e3) return (bps / 1e3).toFixed(0) + ' Kb/s';
return bps + ' B/s';
}
function vvDrawNetChart(canvas, rxData, txData, maxBps) {
if (!canvas) return;
const W = canvas.offsetWidth || 200;
const H = canvas.offsetHeight || 52;
if (canvas.width !== W) canvas.width = W;
if (canvas.height !== H) canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
const scale = maxBps > 0 ? maxBps : 1;
const toY = v => H - (v / scale) * (H - 2) - 1;
const step = W / (VV_HIST_MAX - 1);
// grid — labels show actual bandwidth at each line
ctx.save();
ctx.setLineDash([2, 3]);
ctx.lineWidth = 0.5;
[0.25, 0.5, 0.75].forEach(frac => {
const y = H - frac * (H - 2) - 1;
ctx.strokeStyle = '#2e2e2e';
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
ctx.fillStyle = '#3a3a3a'; ctx.font = '7px monospace'; ctx.textAlign = 'left';
ctx.fillText(vvFmtBps(maxBps * frac), 2, y - 2);
});
ctx.restore();
const drawLine = (data, lineColor, fillColor) => {
if (data.length < 2) return;
const pts = data.map((v, i) => [i * step, toY(v)]);
ctx.beginPath(); ctx.moveTo(0, H);
pts.forEach(([x, y]) => ctx.lineTo(x, y));
ctx.lineTo((data.length - 1) * step, H);
ctx.closePath(); ctx.fillStyle = fillColor; ctx.fill();
ctx.beginPath();
pts.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y));
ctx.strokeStyle = lineColor; ctx.lineWidth = 1.5; ctx.stroke();
};
drawLine(txData, '#ff9800', 'rgba(255,152,0,0.10)');
drawLine(rxData, '#4caf50', 'rgba(76,175,80,0.12)');
}
// ── CPU helpers ───────────────────────────────────────────────────────────────
function vvRenderCpu(cpu) {
const overall = cpu.overall ?? 0;
const cores = cpu.cores ?? [];
// Overall usage bar
let html = vvMeter('Overall', overall, overall + '%');
// Per-core vertical bars
if (cores.length) {
html += `<div class="vv-cpu-cores" style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;overflow:hidden;">`;
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 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>
</div>
<div style="font-size:8px;color:#555;line-height:1;">${c.core}</div>
</div>`;
});
html += `</div>`;
// Freq legend
html += `<div style="display:flex;justify-content:space-between;font-size:9px;color:#444;margin-bottom:8px;">
<span style="color:hsl(120,70%,45%)">Low</span>
<span style="color:hsl(60,70%,45%)">Med</span>
<span style="color:hsl(0,70%,45%)">High</span>
</div>`;
}
// Canvas chart
html += `<canvas id="vv-cpu-canvas" style="width:100%;height:60px;display:block;"></canvas>`;
return html;
}
// ── Memory helpers ────────────────────────────────────────────────────────────
const VV_MEM_COLORS = {
system: '#5c6bc0',
vm: '#f57c00',
zfs: '#0097a7',
docker: '#388e3c',
free: '#37474f',
};
function vvFmtGib(kb) {
return (kb / 1048576).toFixed(1) + ' GiB';
}
function vvMemRow(label, kb, totalKb, color) {
const pct = totalKb > 0 ? Math.round(kb / totalKb * 100) : 0;
return `<div style="margin-bottom:5px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:${color};font-weight:500;">${label}</span>
<span style="color:#777;">${vvFmtGib(kb)}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${color};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
}
function vvRenderMemory(mem) {
const total = mem.total_kb ?? 1;
const usedKb = total - (mem.free_kb ?? 0);
const usedPct = Math.round(usedKb / total * 100);
const totalColor = usedPct >= 85 ? '#f44336' : usedPct >= 65 ? '#ff9800' : '#ccc';
const procs = mem.top_procs ?? [];
const procStrip = procs.map(p =>
`<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;">
<div style="font-size:14px;font-weight:600;color:${totalColor};white-space:nowrap;">
${vvFmtGib(usedKb)} <span style="font-size:11px;color:#555;font-weight:400;">/ ${vvFmtGib(total)}</span>
</div>
<div style="font-size:10px;color:#666;text-align:right;margin-left:10px;overflow:hidden;min-width:0;white-space:nowrap;text-overflow:ellipsis;">${procStrip}</div>
</div>`;
html += vvMemRow('System', mem.system_kb ?? 0, total, VV_MEM_COLORS.system)
+ vvMemRow('VM', mem.vm_kb ?? 0, total, VV_MEM_COLORS.vm)
+ vvMemRow('ZFS', mem.arc_kb ?? 0, total, VV_MEM_COLORS.zfs)
+ vvMemRow('Docker', mem.docker_kb ?? 0, total, VV_MEM_COLORS.docker)
+ vvMemRow('Free', mem.free_kb ?? 0, total, VV_MEM_COLORS.free);
// Swap — only shown when swap is configured and has some usage
const swapTotal = mem.swap_total_kb ?? 0;
const swapUsed = mem.swap_used_kb ?? 0;
if (swapTotal > 0) {
const swapPct = Math.round(swapUsed / swapTotal * 100);
const swapColor = swapPct >= 50 ? '#f44336' : swapPct >= 20 ? '#ff9800' : '#607d8b';
html += `<div style="margin-top:6px;padding-top:6px;border-top:1px solid #1a1a1a;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:${swapColor};font-weight:500;">Swap</span>
<span style="color:#777;">${vvFmtGib(swapUsed)} / ${vvFmtGib(swapTotal)}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
<div style="width:${swapPct}%;height:100%;background:${swapColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
}
return html;
}
// ── Scripts render (filter-aware) ─────────────────────────────────────────────
function vvRenderScripts() {
const sc = vvLastScripts;
const scList = sc.scripts ?? [];
const running = sc.running_count ?? 0;
const ok = sc.ok_count ?? 0;
const warn = sc.warn_count ?? 0;
const errors = sc.error_count ?? 0;
const now = Math.floor(Date.now() / 1000);
function vvScriptPill(type, count, color, bg, border, icon) {
const active = vvScriptsFilter === type;
return `<span onclick="vvScriptsFilterSet('${type}')"
style="font-size:10px;color:${color};background:${bg};border:1px solid ${active ? color : border};
padding:1px 7px;border-radius:10px;cursor:pointer;font-weight:${active ? '700' : '400'};">${icon} ${count} ${type}</span>`;
}
let html = `<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px;">`;
if (running > 0) html += vvScriptPill('running', running, '#4fc3f7', '#0a2233', '#1e4060', '●');
if (ok > 0) html += vvScriptPill('ok', ok, '#4caf50', '#0a1f0a', '#1a3a1a', '✓');
if (warn > 0) html += vvScriptPill('warn', warn, '#ff9800', '#1f1200', '#3a2200', '!');
if (errors > 0) html += vvScriptPill('error', errors, '#f44336', '#2a0a0a', '#5a1a1a', '✗');
if (!running && !ok && !warn && !errors) html += `<span style="font-size:10px;color:#555;">No recent runs</span>`;
html += `</div>`;
const filtered = vvScriptsFilter ? scList.filter(s => s.status === vvScriptsFilter) : scList;
let listHtml = '';
filtered.forEach(s => {
const diff = now - (s.last_ts ?? now);
const ago = diff < 60 ? diff + 's' : diff < 3600 ? Math.floor(diff / 60) + 'm' : diff < 86400 ? Math.floor(diff / 3600) + 'h' : Math.floor(diff / 86400) + 'd';
const icon = s.status === 'running' ? '●' : s.status === 'ok' ? '✓' : s.status === 'warn' ? '!' : s.status === 'error' ? '✗' : '?';
const color = s.status === 'running' ? '#4fc3f7' : s.status === 'ok' ? '#4caf50' : s.status === 'warn' ? '#ff9800' : s.status === 'error' ? '#f44336' : '#555';
const name = s.name.length > 24 ? s.name.slice(0, 23) + '…' : s.name;
const dur = s.duration != null ? ` ${s.duration}s` : '';
const sid = (s.id || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'");
listHtml += `<div onclick="vvOpenScript('${sid}')" title="Open ${name} in Scheduler"
style="display:flex;align-items:center;gap:5px;margin-bottom:4px;font-size:11px;cursor:pointer;"
onmouseover="this.style.background='#161616'" onmouseout="this.style.background=''">
<span style="color:${color};flex-shrink:0;width:10px;text-align:center;">${icon}</span>
<span style="color:#888;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${name}</span>
<span style="color:#444;font-size:10px;flex-shrink:0;">${ago}${dur}</span>
</div>`;
});
if (!filtered.length) listHtml = `<p style="color:#555;font-style:italic;font-size:12px;">${vvScriptsFilter ? 'None in this group' : 'No script logs found'}</p>`;
html += `<div style="max-height:133px;overflow-y:auto;scrollbar-width:none;-ms-overflow-style:none;">${listHtml}</div>`;
const el = document.getElementById('vv-scripts-body');
if (el) el.innerHTML = html;
}
function vvScriptsFilterSet(type) {
vvScriptsFilter = vvScriptsFilter === type ? null : type;
vvRenderScripts();
}
// Deep-link a script row to the Scheduler tab (opens its settings/log panel there).
function vvOpenScript(id) {
if (!id) { window.location.href = '?tab=scheduler'; return; }
window.location.href = '?tab=scheduler&vv_script=' + encodeURIComponent(id);
}
// ── Disk / storage helpers (module-level so vvRenderPools can call them) ─────
function vvTempColor(tempC, transport) {
if (tempC === null) return '#444';
const isSsd = transport === 'nvme' || transport === 'ssd';
const warn = isSsd ? vvThresholds.ssd_warn : vvThresholds.hdd_warn;
const crit = isSsd ? vvThresholds.ssd_crit : vvThresholds.hdd_crit;
return tempC >= crit ? '#f44336' : tempC >= warn ? '#ff9800' : '#4caf50';
}
function vvFmt(v) { return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB'; }
function vvFmtRate(mbs) {
if (mbs >= 1000) return (mbs / 1024).toFixed(1) + ' GB/s';
if (mbs >= 100) return Math.round(mbs) + ' MB/s';
return mbs.toFixed(1) + ' MB/s';
}
function vvIoChip(dev) {
const io = vvDiskIo[dev];
if (!io) return '';
const r = io.r ?? 0, w = io.w ?? 0;
if (r < 0.05 && w < 0.05) return '';
const parts = [];
if (r >= 0.05) parts.push(`<span style="color:#3a7a3a;">↓${vvFmtRate(r)}</span>`);
if (w >= 0.05) parts.push(`<span style="color:#7a4a1a;">↑${vvFmtRate(w)}</span>`);
return `<span style="font-size:9px;margin-left:5px;">${parts.join(' ')}</span>`;
}
function vvIoSum(devices) {
let r = 0, w = 0;
devices.forEach(dev => { const io = vvDiskIo[dev]; if (io) { r += io.r ?? 0; w += io.w ?? 0; } });
return [r, w];
}
function vvWdRsyncToggle(el) {
const flag = el.dataset.flag;
const on = el.dataset.enabled !== '1';
el.dataset.enabled = on ? '1' : '0';
el.style.color = on ? '#4caf50' : '#333';
el.style.background = on ? '#0f1a0f' : '#111';
el.style.borderColor = on ? '#1a3a1a' : '#222';
const fd = new URLSearchParams();
fd.append('name', flag);
fd.append('enabled', on ? '1' : '0');
fetch('/plugins/varaverk/api/flag_toggle.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
if (!d.ok) {
el.dataset.enabled = on ? '0' : '1';
el.style.color = on ? '#333' : '#4caf50';
el.style.background = on ? '#111' : '#0f1a0f';
el.style.borderColor = on ? '#222' : '#1a3a1a';
}
})
.catch(() => {
el.dataset.enabled = on ? '0' : '1';
el.style.color = on ? '#333' : '#4caf50';
el.style.background = on ? '#111' : '#0f1a0f';
el.style.borderColor = on ? '#222' : '#1a3a1a';
});
}
function vvIoTotalSum(devices) {
let tr = 0, tw = 0;
devices.forEach(dev => { const io = vvDiskIo[dev]; if (io) { tr += io.tr ?? 0; tw += io.tw ?? 0; } });
return [tr, tw];
}
function vvFmtGb(gb) {
if (gb >= 1024) return (gb / 1024).toFixed(1) + ' TB';
if (gb >= 1) return gb.toFixed(1) + ' GB';
return (gb * 1024).toFixed(0) + ' MB';
}
function vvDiskRow(disk) {
const tempC = disk.temp;
const tempColor = vvTempColor(tempC, disk.transport);
const tempStr = tempC !== null ? `${tempC}°` : '—';
const isParity = disk.role === 'parity';
const failed = !isParity && disk.status && disk.status !== 'DISK_OK';
const nameColor = isParity ? '#6a8faf' : failed ? '#f44336' : '#aaa';
const pct = disk.pct ?? 0;
const barColor = isParity ? '#1e3a5a'
: failed ? '#f44336'
: pct >= vvThresholds.util_crit ? '#f44336'
: pct >= vvThresholds.util_warn ? '#ff9800'
: '#4caf50';
const barWidth = isParity ? '100' : pct;
const spinLabel = (!isParity && !disk.mounted) ? `<span style="color:#555;font-size:9px;margin-left:4px;">↓</span>` : '';
const failLabel = failed ? `<span style="color:#f44336;font-size:9px;margin-left:4px;">${disk.status === 'DISK_DSBL' ? 'emulated' : disk.status.replace('DISK_','').toLowerCase()}</span>` : '';
const right = isParity
? `<span style="color:#444;font-size:10px;">${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;">
<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;">${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>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${barWidth}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
}
function vvDiskCol(disks) {
return `<div style="flex:1;min-width:0;">${disks.map(vvDiskRow).join('')}</div>`;
}
// ── Poll ──────────────────────────────────────────────────────────────────────
// 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(); });
}
// 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 ?? {};
const banner = document.getElementById('vv-api-banner');
if (banner) {
const fallbacks = apiStatus.fallbacks ?? [];
if (fallbacks.length === 0) {
banner.style.display = 'none';
} else if (apiStatus.key_missing) {
// Key not configured — informational, not alarming
Object.assign(banner.style, {display:'', background:'#111', border:'1px solid #2a2a2a', color:'#555'});
banner.textContent = 'API key not configured — add HOST' +
(typeof vvLocalHostConf !== 'undefined' ? vvLocalHostConf.replace(/\D/g,'') : '1') +
'_UNRAID_API_KEY in Scheduler → host conf to enable enhanced monitoring.';
} else {
// Key present but API unreachable — genuine problem
Object.assign(banner.style, {display:'', background:'#1a1200', border:'1px solid #3a2800', color:'#ff9800'});
banner.innerHTML = '⚠ Unraid API unreachable — using local reads. Check API key in host conf.' +
(fallbacks.length ? ' <span style="color:#666">(' + fallbacks.join(', ') + ')</span>' : '');
}
}
// ── Thresholds (from dynamix.cfg via backend) ────────────────────────────
if (d.thresholds) vvThresholds = d.thresholds;
// ── System ──────────────────────────────────────────────────────────────
const sys = d.system ?? {};
const now = new Date();
const timeStr = now.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const dateStr = now.toLocaleDateString([], {weekday:'short', day:'numeric', month:'long', year:'numeric'});
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone.split('/').pop().replace('_',' ');
const arrayColor = (sys.array_state === 'STARTED') ? '#4caf50' : '#f44336';
const ver = (sys.version || '').replace('version=','').replace(/"/g,'');
// Running container + VM counts for system card
const _runningCtrs = [...(d.docker_folders?.folders ?? []), {containers: d.docker_folders?.ungrouped ?? []}]
.flatMap(f => f.containers).filter(c => c.running).length;
const _runningVMs = (d.vms?.vms ?? []).filter(v => v.state === 'running').length;
const _threadInfo = sys.cpu_threads ? `${sys.cpu_cores}c / ${sys.cpu_threads}t` : '';
// Load average (1/5/15m) — colour by load[0] vs core count
const _load = Array.isArray(sys.load_avg) ? sys.load_avg : null;
const _cores = sys.cpu_cores || 0;
const _loadColor = _load && _cores
? (_load[0] > _cores * 2 ? '#f44336' : _load[0] > _cores ? '#ff9800' : '#888') : '#888';
const _loadStr = _load
? `${_load[0].toFixed(2)} <span style="color:#444;">·</span> ${_load[1].toFixed(2)} <span style="color:#444;">·</span> ${_load[2].toFixed(2)}`
: '—';
const _coreMeta = _threadInfo ? ` <span style="color:#444;">(${_threadInfo})</span>` : '';
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;">${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;">
<svg width="32" height="54" viewBox="0 0 38 64" fill="none" style="opacity:0.15;pointer-events:none;">
<rect x="1" y="1" width="36" height="62" rx="3" stroke="#aaa" stroke-width="1.2" fill="#111"/>
<rect x="1" y="1" width="36" height="10" rx="3" fill="#1c1c1c" stroke="#aaa" stroke-width="1.2"/>
<circle cx="19" cy="6" r="2.5" stroke="#ff9800" stroke-width="1" fill="none"/>
<line x1="19" y1="3.8" x2="19" y2="2.2" stroke="#ff9800" stroke-width="1"/>
<rect x="26" y="4" width="4" height="2" rx="0.5" fill="#555"/>
<rect x="3" y="14" width="16" height="44" rx="1" fill="#0d0d0d" stroke="#444" stroke-width="0.6"/>
<line x1="3" y1="18" x2="19" y2="18" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="22" x2="19" y2="22" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="26" x2="19" y2="26" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="30" x2="19" y2="30" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="34" x2="19" y2="34" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="38" x2="19" y2="38" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="42" x2="19" y2="42" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="46" x2="19" y2="46" stroke="#333" stroke-width="0.6"/>
<line x1="3" y1="50" x2="19" y2="50" stroke="#333" stroke-width="0.6"/>
<rect x="21" y="14" width="14" height="44" rx="1" fill="#08080f" stroke="#444" stroke-width="0.6" opacity="0.7"/>
<rect x="5" y="60" width="5" height="2" rx="1" fill="#333"/>
<rect x="28" y="60" width="5" height="2" rx="1" fill="#333"/>
</svg>
<a href="/" target="_blank" class="vv-card-cog" title="Dashboard" style="margin-top:1px;">⚙</a>
</div>
</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} &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;">${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>
</div>`;
// ── Partner ──────────────────────────────────────────────────────────────
const pt = d.partner ?? {};
const ptHosts = pt.hosts ?? [];
const ptRemote = d.remote_hosts ?? {};
const ptStatus = pt.enabled
? `<span style="color:#4caf50;">enabled</span> · sync every ${pt.sync_min}min`
: `<span style="color:#555;">disabled</span>`;
let ptHtml = `<div style="font-size:10px;color:#666;margin-bottom:8px;">${ptStatus}</div>`;
ptHosts.forEach(h => {
const dot = h.online === null ? '#555' : h.online ? '#4caf50' : '#f44336';
const label = h.online === null ? 'unknown' : h.online ? 'online' : 'offline';
const tags = [
h.is_me ? `<span style="background:#1a3a1a;color:#4caf50;font-size:8px;padding:1px 5px;border-radius:3px;margin-left:4px;">US</span>` : '',
h.is_owner ? `<span style="background:#1a2a3a;color:#4a9eff;font-size:8px;padding:1px 5px;border-radius:3px;margin-left:4px;">OWNER</span>` : '',
].join('');
// Onboard phase badge for remote hosts not yet fully onboarded
const phase = h.onboard_phase ?? null;
const onboardBadge = (!h.is_me && phase !== null && phase < 2)
? (phase === 1
? `<div style="font-size:9px;color:#ff9800;margin-top:3px;padding:2px 5px;background:#1a1200;border:1px solid #3a2800;border-radius:3px;display:inline-block;">⏳ Awaiting onboard</div>`
: `<div style="font-size:9px;color:#444;margin-top:3px;padding:2px 5px;background:#111;border:1px solid #222;border-radius:3px;display:inline-block;">○ Not provisioned</div>`)
: '';
// Remote stats from Unraid API (only available for non-self hosts with an API key)
const rs = !h.is_me ? (ptRemote[h.id] ?? null) : null;
let statsHtml = '';
if (rs && rs.available) {
const cpuAvail = rs.cpu_load > 0;
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;">${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 : '—';
// Version mismatch warning — scripts will refuse sync operations until versions match
const myVer = (sys.version || '').replace('version=','').replace(/"/g,'').trim();
const remoteVer = (rs.version || '').trim();
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 ${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;">${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;">${vvEscHtml(rs.array_state)}</span>
<span style="color:#444;">Uptime</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) {
statsHtml = `<div style="font-size:10px;color:#444;margin-top:4px;">API key not configured — complete Onboard to enable</div>`;
} else if (rs && !rs.available) {
statsHtml = `<div style="font-size:10px;color:#444;margin-top:4px;">API unreachable</div>`;
}
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;">${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>
</div>
${statsHtml}
</div>`;
});
document.getElementById('vv-partner-body').innerHTML = ptHtml;
(function() {
const c = document.getElementById('vv-partner');
if (!c) return;
const remotes = ptHosts.filter(h => !h.is_me);
c.classList.remove('vv-accent-ok','vv-accent-warn');
if (remotes.length) {
if (remotes.some(h => h.online)) c.classList.add('vv-accent-ok');
else c.classList.add('vv-accent-warn');
}
})();
// ── Fallback ──────────────────────────────────────────────────────────────
(function() {
const fb = d.fallback ?? {};
const fbActive = d.fallback_active ?? [];
const state = (fb.state ?? 'UNKNOWN').toUpperCase();
const enabled = fb.enabled ?? false;
const strikes = fb.handback_strikes ?? 0;
const maxStrikes = fb.handback_strikes_required ?? 3;
const ptSuspended = fb.partnership_suspended ?? false;
const partnerLostAt = fb.partner_lost_at ?? 0;
const suspendAfter = fb.partnership_suspend_after ?? 120;
const interval = fb.check_interval ?? 30;
const ptRequired = fb.partnership_required ?? false;
const stateMap = {
NORMAL: ['#4caf50', '✓ Nominal'],
FALLBACK: ['#f44336', '⚠ Fallback active'],
NO_INTERNET: ['#ff9800', '⚡ Internet lost'],
DARK: ['#9e9e9e', '◌ Dark mode'],
UNKNOWN: ['#444', '— No state file'],
};
const [sColor, sLabel] = stateMap[state] ?? ['#444', state];
// ── Disabled ──────────────────────────────────────────────────────────
if (!enabled) {
document.getElementById('vv-fallback-body').innerHTML =
`<div style="font-size:11px;color:#444;margin-bottom:6px;">○ Disabled</div>
<div style="font-size:10px;color:#333;">Set FALLBACK_ENABLED=true in master.conf to activate</div>`;
return;
}
// ── Suspended (partnership gate) ───────────────────────────────────────
if (ptSuspended) {
const lostMin = partnerLostAt > 0 ? Math.floor((Date.now()/1000 - partnerLostAt) / 60) : '?';
document.getElementById('vv-fallback-body').innerHTML =
`<div style="font-size:11px;color:#555;margin-bottom:6px;">⊘ Suspended</div>
<div style="font-size:10px;color:#444;margin-bottom:3px;">Partnership inactive · ${lostMin}m elapsed</div>
<div style="font-size:10px;color:#333;">Resumes when partnership becomes active</div>`;
return;
}
let fbHtml = '';
// ── State label + outage duration ──────────────────────────────────────
let stateExtra = '';
if (state === 'FALLBACK' || state === 'DARK') {
const start = parseInt(fb.failover_start ?? 0);
if (start > 0) {
const sec = Math.floor(Date.now() / 1000) - start;
stateExtra = ` · ${Math.floor(sec/3600)}h ${Math.floor((sec%3600)/60)}m`;
}
}
fbHtml += `<div style="font-size:11px;color:${sColor};font-weight:500;margin-bottom:6px;">${sLabel}${stateExtra}</div>`;
// ── Handback strike progress ───────────────────────────────────────────
if (state === 'FALLBACK' && strikes > 0) {
const dots = Array.from({length: maxStrikes}, (_,i) =>
`<span style="color:${i < strikes ? '#4caf50' : '#2a2a2a'};font-size:14px;line-height:1;">●</span>`
).join('');
fbHtml += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
<span style="font-size:10px;color:#555;">Handback</span>
<span style="display:flex;gap:3px;">${dots}</span>
<span style="font-size:10px;color:#555;">${strikes}/${maxStrikes}</span>
</div>`;
}
// ── Tier badges (FAILOVER only) ────────────────────────────────────────
if (state === 'FALLBACK') {
const t2 = fb.tier2_started === 'true';
const t3 = fb.tier3_started === 'true';
const t4 = fb.tier4_started === 'true';
const tier = (n, on) => `<span style="font-size:9px;padding:1px 6px;border-radius:3px;
background:${on ? '#1a2a1a' : '#1a1a1a'};
border:1px solid ${on ? '#2a5a2a' : '#252525'};
color:${on ? '#4caf50' : '#333'};">T${n}</span>`;
fbHtml += `<div style="display:flex;gap:4px;margin-bottom:8px;">
${tier(1,true)} ${tier(2,t2)} ${tier(3,t3)} ${tier(4,t4)}
</div>`;
}
// ── Active containers ──────────────────────────────────────────────────
if (fbActive.length) {
fbActive.forEach(group => {
fbHtml += `<div style="font-size:10px;color:#555;margin-bottom:4px;letter-spacing:.03em;">
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;">${vvEscHtml(c.name)}</span>
<span style="color:#444;font-size:9px;margin-left:8px;white-space:nowrap;">${img}</span>
</div>`;
});
});
}
// ── NORMAL quiet state ─────────────────────────────────────────────────
if (state === 'NORMAL' && !fbActive.length) {
let meta = `monitoring · ${interval}s`;
if (ptRequired) meta += ' · partnership gated';
fbHtml += `<div style="font-size:10px;color:#2a2a2a;">${meta}</div>`;
}
// ── NO_INTERNET / DARK detail ──────────────────────────────────────────
if (state === 'NO_INTERNET') {
fbHtml += `<div style="font-size:10px;color:#555;">DDNS stopped · awaiting recovery</div>`;
} else if (state === 'DARK') {
fbHtml += `<div style="font-size:10px;color:#555;">Remote + internet both down</div>`;
}
document.getElementById('vv-fallback-body').innerHTML = fbHtml;
// Status accent border
const fbCard = document.getElementById('vv-fallback');
if (fbCard) {
fbCard.classList.remove('vv-accent-ok','vv-accent-warn','vv-accent-err');
if (!enabled || ptSuspended) fbCard.classList.add('vv-accent-warn');
else if (state === 'NORMAL') fbCard.classList.add('vv-accent-ok');
else if (state === 'FALLBACK') fbCard.classList.add('vv-accent-err');
else if (state !== 'UNKNOWN') fbCard.classList.add('vv-accent-warn');
}
})();
// ── CPU title (core count + temp — watchdog data only comes from full poll) ──
const _cpuTitleEl = document.getElementById('vv-cpu-title');
if (_cpuTitleEl) {
const _cpuTemp = d.watchdog?.stability?.cpu_temp ?? null;
const _tColor = _cpuTemp == null ? '#888' : _cpuTemp >= 88 ? '#f44336' : _cpuTemp >= 75 ? '#ff9800' : '#4caf50';
const _coreLbl = sys.cpu_threads ? `CPU · ${sys.cpu_cores}c/${sys.cpu_threads}t` : 'CPU';
_cpuTitleEl.innerHTML = _coreLbl
+ (_cpuTemp != null ? ` <span style="color:${_tColor};font-weight:400;">${_cpuTemp}°</span>` : '');
}
// ── UPS / Power ─────────────────────────────────────────────────────────
const ups = d.ups ?? {};
if (ups.available) {
const onBatt = ups.status === 'ONBATT';
const statCls = onBatt ? 'vv-banner-err' : ups.status === 'ONLINE' ? 'vv-banner-ok' : 'vv-banner-warn';
const statColor = onBatt ? '#f44336' : ups.status === 'ONLINE' ? '#4caf50' : '#ff9800';
const loadPct = ups.load_pct ?? 0;
const loadHue = Math.round(120 * (1 - loadPct / 100));
const bPct = ups.bcharge ?? 0;
const bHue = Math.round(120 * (bPct / 100));
const bColor = onBatt ? '#f44336' : `hsl(${bHue},70%,45%)`;
const timeLeft = ups.timeleft != null ? ups.timeleft.toFixed(1) + ' min' : '—';
const watts = ups.watts != null ? ups.watts + ' W' : '—';
const lineV = ups.line_v != null ? ups.line_v + ' V' : '—';
const outV = ups.output_v != null ? ups.output_v + ' V' : '—';
const xfers = ups.num_xfers ?? 0;
const batIcon = bPct >= 80 ? '▰▰▰▰' : bPct >= 60 ? '▰▰▰▱' : bPct >= 40 ? '▰▰▱▱' : bPct >= 20 ? '▰▱▱▱' : '▱▱▱▱';
document.getElementById('vv-ups-body').innerHTML =
`<div class="vv-banner ${statCls}" style="margin-bottom:8px;">
<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>
<div style="font-size:10px;color:#555;margin-bottom:3px;">Load</div>
<div style="font-size:15px;font-weight:600;color:hsl(${loadHue},70%,45%);line-height:1;">${loadPct.toFixed(0)}<span style="font-size:10px;font-weight:400;color:#555;">%</span></div>
<div style="font-size:10px;color:#444;margin-top:2px;">${watts}</div>
</div>
<div>
<div style="font-size:10px;color:#555;margin-bottom:3px;">Battery</div>
<div style="font-size:15px;font-weight:600;color:${bColor};line-height:1;">${bPct.toFixed(0)}<span style="font-size:10px;font-weight:400;color:#555;">%</span></div>
<div style="font-size:10px;color:#444;margin-top:2px;">${timeLeft}</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:8px;">
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
<div style="width:${loadPct}%;height:100%;background:hsl(${loadHue},70%,45%);transition:width 0.4s;"></div>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:5px;overflow:hidden;">
<div style="width:${bPct}%;height:100%;background:${bColor};transition:width 0.4s;"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:3px 8px;font-size:10px;">
<span style="color:#444;">In</span> <span style="color:#777;">${lineV}</span>
<span style="color:#444;">Out</span> <span style="color:#777;">${outV}</span>
<span style="color:#444;">Xfers</span> <span style="color:${xfers > 0 ? '#ff9800' : '#444'};">${xfers}</span>
<span style="color:#444;">Test</span> <span style="color:#555;">${ups.selftest || '—'}</span>
</div>`;
} else {
document.getElementById('vv-ups-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No UPS detected</p>';
}
// ── Parity ──────────────────────────────────────────────────────────────
const par = d.parity ?? {};
(function() {
const valid = par.valid !== false;
const inProg = par.in_progress;
function vvRelTime(ts) {
if (!ts) return '';
const diff = Math.floor(Date.now() / 1000) - ts;
const d = Math.floor(diff / 86400), h = Math.floor((diff % 86400) / 3600), m = Math.floor((diff % 3600) / 60);
const parts = [];
if (d) parts.push(d + ' day' + (d !== 1 ? 's' : ''));
if (h) parts.push(h + ' hour' + (h !== 1 ? 's' : ''));
if (!d && m) parts.push(m + ' minute' + (m !== 1 ? 's' : ''));
return parts.join(', ') + ' ago';
}
function vvDueIn(ts) {
if (!ts) return '—';
const diff = ts - Math.floor(Date.now() / 1000);
if (diff <= 0) return 'overdue';
const d = Math.floor(diff / 86400), h = Math.floor((diff % 86400) / 3600), m = Math.floor((diff % 3600) / 60);
const parts = [];
if (d) parts.push(d + ' day' + (d !== 1 ? 's' : ''));
if (h) parts.push(h + ' hour' + (h !== 1 ? 's' : ''));
if (!d && m) parts.push(m + ' minute' + (m !== 1 ? 's' : ''));
return 'Due in: ' + parts.join(', ');
}
function vvFmtDate(ts) {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString([], {weekday:'short',day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'});
}
const numDisabled = par.num_disabled ?? 0;
const numMissing = par.num_missing ?? 0;
const degraded = numDisabled > 0 || numMissing > 0;
const exitColor = par.exit_label === 'Completed' ? '#4caf50' : par.exit_label === 'Aborted' ? '#ff9800' : '#f44336';
const errColor = (par.errors ?? 0) > 0 ? '#f44336' : '#444';
const speedStr = par.last_speed_mb ? ` · ${par.last_speed_mb} MB/s` : '';
const nextDate = vvFmtDate(par.next_ts);
const dueIn = vvDueIn(par.next_ts);
const parBannerCls = !valid ? 'vv-banner-err' : degraded || (par.errors ?? 0) > 0 ? 'vv-banner-warn' : 'vv-banner-ok';
const emulLabel = numDisabled > 0 ? `<span style="font-size:11px;">Emulating ${numDisabled} disk${numDisabled !== 1 ? 's' : ''}</span>` : '';
const missingLabel = numMissing > 0 ? `<span style="font-size:11px;color:#f44336;">${numMissing} slot${numMissing !== 1 ? 's' : ''} missing</span>` : '';
let html = `<div class="vv-banner ${parBannerCls}">
<span>${valid ? '✓ Valid' : '✗ INVALID'}</span>
${emulLabel}${missingLabel}
${(par.errors ?? 0) > 0 ? `<span style="font-size:11px;">${par.errors} error${par.errors !== 1 ? 's' : ''}</span>` : ''}
</div>`;
if (inProg) {
const pct = par.resync_pct ?? 0;
const action = par.resync_action ?? '';
let opLabel;
if (/^recon/i.test(action)) {
const disk = action.replace(/^recon\s*/i, '').trim();
opLabel = 'Rebuilding' + (disk ? ' disk ' + disk : '');
} else if (/^check/i.test(action)) {
opLabel = 'Parity check';
} else if (/^sync/i.test(action)) {
opLabel = 'Parity sync';
} else if (/^clear/i.test(action)) {
const disk = action.replace(/^clear\s*/i, '').trim();
opLabel = 'Clearing' + (disk ? ' disk ' + disk : '');
} else {
opLabel = action || 'Operation';
}
html += `<div style="font-size:11px;color:#aaa;margin-bottom:4px;">${opLabel} — ${pct}%</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;margin-bottom:10px;">
<div style="width:${pct}%;height:100%;background:#4caf50;border-radius:3px;transition:width 2s;"></div>
</div>`;
}
html += `
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;">
<span style="color:#555;">Last check</span>
<span style="color:#888;">${par.last_date ?? '—'}${speedStr}</span>
<span style="color:#555;">Status</span>
<span style="color:${exitColor};">${par.exit_label ?? '—'}</span>
<span style="color:#555;">Errors</span>
<span style="color:${errColor};">${par.errors ?? 0}</span>
${numDisabled > 0 ? `<span style="color:#555;">Emulating</span><span style="color:#ff9800;">${numDisabled} disk${numDisabled !== 1 ? 's' : ''}</span>` : ''}
<span style="color:#555;">Next check</span>
<span style="color:#888;">${nextDate}</span>
</div>
<div style="font-size:10px;color:#555;margin-top:8px;">${dueIn}</div>`;
document.getElementById('vv-parity-body').innerHTML = html;
})();
// ── Watchdog ─────────────────────────────────────────────────────────────
(function() {
const wd = d.watchdog ?? {};
const el = document.getElementById('vv-watchdog-body');
if (!el) return;
const healthy = wd.healthy ?? true;
const ctrStrikes = wd.ctr_strikes ?? {};
const ctrNames = Object.keys(ctrStrikes);
const rwLevel = wd.rw_level ?? 0;
const rwPaused = wd.rw_paused ?? [];
const rwStopped = wd.rw_stopped ?? [];
const daemonHit = (wd.daemon_strikes ?? 0) > 0;
const oom = wd.oom_count ?? 0;
const reboots = wd.reboots_12h ?? 0;
const restartCount = wd.restart_count ?? 0;
const restarts = wd.restarts_24h ?? [];
const stab = wd.stability ?? {};
const stabStrikes = stab.strikes ?? {};
const stabNames = Object.keys(stabStrikes);
const storWd = wd.storage_wd ?? {};
const growthStr = storWd.growth_strikes ?? {};
const logStr = storWd.log_strikes ?? {};
const storIssues = Object.keys(growthStr).length + Object.keys(logStr).length;
const netWd = wd.network_wd ?? {};
const npmStrikes = netWd.npm_strikes ?? 0;
const issueCount = ctrNames.length + stabNames.length + storIssues
+ (daemonHit ? 1 : 0) + (oom > 0 ? 1 : 0)
+ (reboots > 0 ? 1 : 0) + (npmStrikes > 0 ? 1 : 0);
const bannerCls = healthy ? 'vv-banner-ok' : (reboots || oom || daemonHit ? 'vv-banner-err' : 'vv-banner-warn');
const bannerTxt = healthy ? '✓ All clear' : `⚠ ${issueCount} issue${issueCount !== 1 ? 's' : ''}`;
let html = `<div class="vv-banner ${bannerCls}">${bannerTxt}`;
if (reboots > 0) html += `<span style="font-size:10px;">${reboots} reboot${reboots !== 1 ? 's' : ''}/12h</span>`;
html += `</div>`;
// ── Alerts (critical items) ──────────────────────────────────────────
if (rwLevel > 0) {
const rwColor = rwLevel >= 3 ? '#f44336' : rwLevel >= 2 ? '#ff9800' : '#fdd835';
const rwLabel = ['', 'Soft', 'Medium', 'Hard'][rwLevel] ?? `L${rwLevel}`;
html += `<div style="font-size:11px;color:${rwColor};margin-bottom:4px;">⚡ Resource mgr: ${rwLabel}`;
if (rwPaused.length) html += ` · ${rwPaused.length} paused`;
if (rwStopped.length) html += ` · ${rwStopped.length} stopped`;
html += `</div>`;
}
if (daemonHit) html += `<div style="font-size:11px;color:#f44336;margin-bottom:4px;">✗ Docker daemon strikes</div>`;
if (oom > 0) html += `<div style="font-size:11px;color:#f44336;margin-bottom:4px;">✗ OOM events: ${oom}</div>`;
// ── System stats grid ────────────────────────────────────────────────
function wdPct(v, warn, crit) {
return v >= crit ? '#f44336' : v >= warn ? '#ff9800' : '#4caf50';
}
const ramFree = stab.ram_free_gb ?? 0;
const ramColor = ramFree < 6 ? '#f44336' : ramFree < 12 ? '#ff9800' : '#4caf50';
const load = stab.load_1min ?? 0;
const _wdCores = sys.cpu_cores || 0;
const loadColor = _wdCores > 0
? (load > _wdCores * 2 ? '#f44336' : load > _wdCores ? '#ff9800' : '#4caf50')
: (load > 6 ? '#f44336' : load > 3 ? '#ff9800' : '#4caf50');
const nicOk = (stab.nic_state ?? '') === 'up';
const sshdOk = stab.sshd_ok ?? true;
const zombies = stab.zombies ?? 0;
const uptimeSec = sys.uptime_sec ?? 0;
const uptimeDays = Math.floor(uptimeSec / 86400);
const uptimeHrs = Math.floor((uptimeSec % 86400) / 3600);
const uptimeStr = uptimeDays > 0 ? `${uptimeDays}d ${uptimeHrs}h` : `${uptimeHrs}h`;
const uptimeColor = uptimeDays === 0 ? '#ff9800' : '#4caf50';
const stabCount = stabNames.length;
const fdOpen = stab.fd_open ?? 0;
const fdPct = stab.fd_pct ?? 0;
const fdStr = fdOpen >= 1e6 ? (fdOpen/1e6).toFixed(1)+'M' : fdOpen >= 1000 ? (fdOpen/1000).toFixed(1)+'k' : String(fdOpen);
const fdColor = fdPct >= 50 ? '#f44336' : fdPct >= 20 ? '#ff9800' : '#4caf50';
const cpuRow = stab.cpu_temp != null
? `<span style="color:#444;">CPU</span><span style="color:${wdPct(stab.cpu_temp,75,90)};">${stab.cpu_temp}°C</span>` : '';
let statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:11px;margin-top:8px;margin-bottom:6px;">
<span style="color:#444;">rootfs</span><span style="color:${wdPct(stab.rootfs_pct??0,75,90)};">${stab.rootfs_pct??0}%</span>
<span style="color:#444;">/var/log</span><span style="color:${wdPct(stab.log_pct??0,75,90)};">${stab.log_pct??0}%</span>
<span style="color:#444;">/tmp</span><span style="color:${wdPct(stab.tmp_pct??0,75,90)};">${stab.tmp_pct??0}%</span>
<span style="color:#444;">RAM free</span><span style="color:${ramColor};">${ramFree}GB</span>
<span style="color:#444;">Load</span><span style="color:${loadColor};">${load}</span>
${cpuRow}
<span style="color:#444;">Zombies</span><span style="color:${zombies>0?'#ff9800':'#4caf50'};">${zombies}</span>
<span style="color:#444;">FD open</span><span style="color:${fdColor};">${fdStr}</span>
<span style="color:#444;">${stab.nic??'nic'}</span><span style="color:${nicOk?'#4caf50':'#f44336'};">● ${stab.nic_state??'?'}</span>
<span style="color:#444;">sshd</span><span style="color:${sshdOk?'#4caf50':'#f44336'};">${sshdOk?'● ok':'✗ down'}</span>
<span style="color:#444;">NPM</span><span style="color:${npmStrikes>0?'#ff9800':'#4caf50'};">${npmStrikes>0?npmStrikes+'× strikes':'● ok'}</span>
<span style="color:#444;">Uptime</span><span style="color:${uptimeColor};">${uptimeStr}</span>
<span style="color:#444;">Reboots</span><span style="color:${reboots>0?'#f44336':'#4caf50'};">${reboots}/12h</span>
<span style="color:#444;">Strikes</span><span style="color:${stabCount>0?'#ff9800':'#4caf50'};">${stabCount>0?stabCount+' active':'none'}</span>
</div>`;
html += statsHtml;
// ── Stability strikes (system watchdog) ──────────────────────────────
if (stabNames.length) {
html += `<div style="font-size:10px;color:#555;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Stability strikes</div>`;
stabNames.forEach(k => {
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:#ff9800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${k}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${stabStrikes[k]}×</span>
</div>`;
});
}
// ── Storage watchdog strikes ─────────────────────────────────────────
if (storIssues > 0) {
html += `<div style="font-size:10px;color:#555;margin-top:${stabNames.length?6:0}px;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Storage watchdog</div>`;
Object.entries(growthStr).forEach(([k, v]) => {
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:#ff9800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">↑ ${k}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${v}×</span>
</div>`;
});
Object.entries(logStr).forEach(([k, v]) => {
const shortKey = k.length > 22 ? '…' + k.slice(-22) : k;
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:#ff9800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;" title="${k}">log ${shortKey}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${v}×</span>
</div>`;
});
}
// ── Container strikes ────────────────────────────────────────────────
if (ctrNames.length) {
html += `<div style="font-size:10px;color:#555;margin-top:${stabNames.length||storIssues?6:0}px;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Container strikes</div>`;
ctrNames.forEach(name => {
html += `<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:2px;">
<span style="color:#f44336;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${name}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${ctrStrikes[name]}×</span>
</div>`;
});
}
// ── Recent restarts ──────────────────────────────────────────────────
if (restartCount > 0) {
const hasAbove = ctrNames.length || stabNames.length || storIssues;
html += `<div style="font-size:10px;color:#555;margin-top:${hasAbove?6:0}px;margin-bottom:3px;text-transform:uppercase;letter-spacing:.05em;">Restarts 24h <span style="color:#ff9800;">${restartCount}</span></div>`;
const now = Math.floor(Date.now() / 1000);
restarts.forEach(r => {
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;">${vvEscHtml(r.name)}</span>
<span style="flex-shrink:0;margin-left:6px;color:#444;">${ago}</span>
</div>`;
});
}
el.innerHTML = html;
const wdCard = document.getElementById('vv-watchdog-card');
if (wdCard) {
wdCard.classList.remove('vv-accent-ok','vv-accent-warn','vv-accent-err');
if (healthy) wdCard.classList.add('vv-accent-ok');
else if (reboots > 0 || oom > 0 || daemonHit) wdCard.classList.add('vv-accent-err');
else wdCard.classList.add('vv-accent-warn');
}
})();
// ── Pools ─────────────────────────────────────────────────────────────────
vvDiskIo = d.disk_io ?? {};
vvLastStorageDisks = d.storage ?? [];
(() => {
const disks = vvLastStorageDisks;
const titleEl = document.getElementById('vv-pools-title');
if (titleEl && disks.length) {
const okDisks = disks.filter(d => d.status === 'DISK_OK' || !d.status).length;
const totalGb = disks.reduce((s, d) => s + (d.size_gb ?? 0), 0);
const usedGb = disks.reduce((s, d) => s + (d.used_gb ?? 0), 0);
const pct = totalGb > 0 ? Math.round(usedGb / totalGb * 100) : 0;
const pctColor = pct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : pct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50';
const temps = disks.map(d => d.temp).filter(t => t != null);
const maxTemp = temps.length ? Math.max(...temps) : null;
const maxTempColor = maxTemp == null ? '#444'
: maxTemp >= (vvThresholds.ssd_crit ?? 70) ? '#f44336'
: maxTemp >= (vvThresholds.ssd_warn ?? 60) ? '#ff9800' : '#4caf50';
const [pIor, pIow] = vvIoSum(disks.map(d => d.device));
const ioParts = [];
if (pIor >= 0.05) ioParts.push(`<span style="color:#3a7a3a;">↓${vvFmtRate(pIor)}</span>`);
if (pIow >= 0.05) ioParts.push(`<span style="color:#7a4a1a;">↑${vvFmtRate(pIow)}</span>`);
const ioHtml = ioParts.length ? `· <span style="font-weight:400;">${ioParts.join(' ')}</span>` : '';
titleEl.innerHTML = `Pools
<span style="font-size:10px;color:#444;font-weight:400;text-transform:none;letter-spacing:0;margin-left:6px;">
${okDisks}/${disks.length} ok
· <span style="color:${pctColor};">${pct}%</span> used
${maxTemp != null ? `· <span style="color:${maxTempColor};">max ${maxTemp}°</span>` : ''}
${ioHtml}
</span>`;
const [ptr, ptw] = vvIoTotalSum(disks.map(d => d.device));
const totEl = document.getElementById('vv-pools-io-total');
if (totEl && (ptr > 0.001 || ptw > 0.001))
totEl.innerHTML = `<span style="color:#3a5a3a;">↓${vvFmtGb(ptr)}</span> <span style="color:#5a3a1a;">↑${vvFmtGb(ptw)}</span>`;
}
})();
document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks);
// ── Array disks — summary header + min 3 columns ─────────────────────────
const arrayDisks = d.array_disks ?? [];
if (arrayDisks.length) {
const dataDisks = arrayDisks.filter(d => d.role === 'data');
const okDisks = dataDisks.filter(d => d.status === 'DISK_OK').length;
const errDisks = dataDisks.length - okDisks;
const temps = arrayDisks.map(d => d.temp).filter(t => t != null);
const maxTemp = temps.length ? Math.max(...temps) : null;
const maxTempColor = maxTemp == null ? '#444' : maxTemp >= (vvThresholds.hdd_crit ?? 55) ? '#f44336' : maxTemp >= (vvThresholds.hdd_warn ?? 45) ? '#ff9800' : '#4caf50';
const totalUsedGb = dataDisks.reduce((s, d) => s + (d.used_gb ?? 0), 0);
const totalSizeGb = dataDisks.reduce((s, d) => s + (d.size_gb ?? 0), 0);
const arrPct = totalSizeGb > 0 ? Math.round(totalUsedGb / totalSizeGb * 100) : 0;
const arrPctColor = arrPct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : arrPct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50';
const [arrIor, arrIow] = vvIoSum(arrayDisks.map(d => d.device));
const arrIoParts = [];
if (arrIor >= 0.05) arrIoParts.push(`<span style="color:#3a7a3a;">↓${vvFmtRate(arrIor)}</span>`);
if (arrIow >= 0.05) arrIoParts.push(`<span style="color:#7a4a1a;">↑${vvFmtRate(arrIow)}</span>`);
const arrIoHtml = arrIoParts.length
? `· <span style="font-weight:400;">${arrIoParts.join(' ')}</span>` : '';
const titleEl = document.getElementById('vv-array-title');
if (titleEl) titleEl.innerHTML = `Array
<span style="font-size:10px;color:#444;font-weight:400;text-transform:none;letter-spacing:0;margin-left:6px;">
${okDisks}/${dataDisks.length} ok
${errDisks > 0 ? `<span style="color:#f44336;margin-left:4px;">· ${errDisks} err</span>` : ''}
· <span style="color:${arrPctColor};">${arrPct}%</span> used
${maxTemp != null ? `· <span style="color:${maxTempColor};">max ${maxTemp}°</span>` : ''}
${arrIoHtml}
</span>`;
const [atr, atw] = vvIoTotalSum(arrayDisks.map(d => d.device));
const arrTotEl = document.getElementById('vv-array-io-total');
if (arrTotEl && (atr > 0.001 || atw > 0.001))
arrTotEl.innerHTML = `<span style="color:#3a5a3a;">↓${vvFmtGb(atr)}</span> <span style="color:#5a3a1a;">↑${vvFmtGb(atw)}</span>`;
const numCols = window.innerWidth <= 1024 ? 2 : Math.max(3, Math.ceil(arrayDisks.length / 6));
const perCol = Math.ceil(arrayDisks.length / numCols);
const cols = [];
for (let i = 0; i < numCols; i++) cols.push(arrayDisks.slice(i * perCol, (i + 1) * perCol));
document.getElementById('vv-array-body').innerHTML =
`<div style="display:flex;gap:10px;">${cols.map(vvDiskCol).join('')}</div>`;
} else {
document.getElementById('vv-array-body').innerHTML = '<p style="color:#555;font-style:italic;font-size:12px;">No array disks</p>';
}
(function() {
const c = document.getElementById('vv-array-card');
if (!c) return;
c.classList.remove('vv-accent-ok','vv-accent-err');
if (sys.array_state === 'STARTED') c.classList.add('vv-accent-ok');
else if (sys.array_state && sys.array_state !== 'UNKNOWN') c.classList.add('vv-accent-err');
})();
// ── Rsync ────────────────────────────────────────────────────────────────
(function() {
const rs = d.rsync ?? {};
const enabled = rs.enabled ?? true;
const windows = rs.windows ?? {};
const active = rs.active ?? [];
const lastSync = rs.last_sync ?? {};
const bwSummary = rs.bw_summary ?? {};
const now = Math.floor(Date.now() / 1000);
const el = document.getElementById('vv-rsync-body');
if (!el) return;
function _dur(s) {
if (!s) return '—';
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm' + (s%60 ? String(s%60).padStart(2,'0')+'s' : '');
return Math.floor(s/3600) + 'h' + Math.floor((s%3600)/60) + 'm';
}
function _ago(diff) {
if (diff < 60) return diff + 's';
if (diff < 3600) return Math.floor(diff/60) + 'm';
if (diff < 86400) return Math.floor(diff/3600) + 'h';
return Math.floor(diff/86400) + 'd';
}
function _fmtBytes(b) {
if (!b) return '';
if (b >= 1073741824) return (b/1073741824).toFixed(1) + 'G';
if (b >= 1048576) return (b/1048576).toFixed(0) + 'M';
return (b/1024).toFixed(0) + 'K';
}
const gCol = enabled ? '#4caf50' : '#555';
// ── Header: gate dot + window badges ──────────────────────────────────
let html = `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<span style="font-size:10px;font-weight:600;color:${gCol};">● ${enabled ? 'ENABLED' : 'DISABLED'}</span>
<div style="display:flex;gap:3px;">`;
const _flagMap = {
critical: 'CRITICAL_RSYNC_ENABLED',
intermediate: 'INTERMEDIATE_RSYNC_ENABLED',
daily: 'DAILY_RSYNC_ENABLED',
weekly: 'WEEKLY_RSYNC_ENABLED',
monthly: 'MONTHLY_RSYNC_ENABLED',
};
[['C','critical'],['I','intermediate'],['D','daily'],['W','weekly'],['M','monthly']].forEach(([s,k]) => {
const on = windows[k] ?? false;
const run = active.some(a => a.profile?.includes(k));
const flag = _flagMap[k];
const bg = run ? '#1a2a0a' : on ? '#0f1a0f' : '#111';
const brd = run ? '#3a6a1a' : on ? '#1a3a1a' : '#222';
const col = run ? '#8bc34a' : on ? '#4caf50' : '#333';
const tog = flag ? `data-flag="${flag}" data-enabled="${on?'1':'0'}" onclick="vvWdRsyncToggle(this)"` : '';
const cur = flag ? 'cursor:pointer;' : '';
const tip = `Toggle ${k} rsync`;
html += `<span title="${tip}" ${tog} style="${cur}font-size:9px;padding:2px 5px;border-radius:2px;
background:${bg};border:1px solid ${brd};color:${col};font-weight:600;">${s}</span>`;
});
html += `</div></div>`;
// ── Profile activity bars (7 days) ────────────────────────────────────
const profEntries = Object.entries(bwSummary);
if (profEntries.length) {
const maxRuns = Math.max(...profEntries.map(([,v]) => v.runs), 1);
const colors = ['#4caf50','#4a9eff','#ffb74d','#ce93d8','#ef5350','#4dd0e1'];
html += `<div style="margin-bottom:6px;">
<div style="font-size:9px;color:#2a2a2a;text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px;">7-day profile activity</div>`;
profEntries.forEach(([name, v], i) => {
const pct = Math.round(v.runs / maxRuns * 100);
const col = colors[i % colors.length];
const bytes = v.bytes > 0 ? _fmtBytes(v.bytes) : '';
const short = name.length > 18 ? name.slice(0,16) + '…' : name;
html += `<div style="margin-bottom:4px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:1px;">
<span style="font-size:9px;color:#555;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;max-width:110px;" title="${name}">${short}</span>
<span style="font-size:9px;color:#2a2a2a;flex-shrink:0;margin-left:4px;">
${v.runs}×${bytes ? ' · ' + bytes : ''}</span>
</div>
<div style="height:3px;background:#111;border-radius:2px;overflow:hidden;">
<div style="height:100%;width:${pct}%;background:${col};border-radius:2px;opacity:.7;"></div>
</div>
</div>`;
});
html += `</div>`;
}
// ── Active syncs ───────────────────────────────────────────────────────
if (active.length) {
active.forEach(a => {
const sec = a.elapsed ?? 0;
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;">⟳ ${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;">
<div style="height:100%;background:#3a6a1a;border-radius:2px;
animation:vvRsPulse 1.4s ease-in-out infinite;width:60%;"></div>
</div>
</div>`;
});
}
// ── Tier last-run rows with recency bars ───────────────────────────────
const tierMeta = {
critical: {label:'Critical', cadenceSec: 30*60},
daily: {label:'Daily', cadenceSec: 24*3600},
intermediate: {label:'Interm', cadenceSec: 4*3600},
weekly: {label:'Weekly', cadenceSec: 7*86400},
};
html += `<div style="border-top:1px solid #1a1a1a;padding-top:6px;">`;
Object.entries(tierMeta).forEach(([key, meta]) => {
const s = lastSync[key];
const ts = s?.ts ?? 0;
const ok = s?.status === 'ok' || s?.status === 'success';
const warn = s?.status === 'warn';
const run = s?.status === 'running';
const err = s && !ok && !warn && !run && ts > 0;
const col = run ? '#4a9eff' : ok ? '#4caf50' : warn ? '#ff9800' : err ? '#f44336' : '#2a2a2a';
const icon = run ? '⟳' : ok ? '✓' : warn ? '!' : err ? '✗' : '—';
const diff = ts ? now - ts : null;
const pct = ts ? Math.min(100, Math.round((now - ts) / meta.cadenceSec * 100)) : 0;
const barC = pct >= 100 ? '#3a1a1a' : pct >= 75 ? '#2a1a0a' : '#0a1a0a';
const fillC= pct >= 100 ? '#f44336' : pct >= 75 ? '#ff9800' : '#2a6a2a';
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;">${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;">
${diff !== null ? _ago(diff) : '—'}</span>
<span style="font-size:10px;color:${col};width:10px;text-align:right;">${icon}</span>
</div>
<div style="height:2px;background:${barC};border-radius:1px;overflow:hidden;">
<div style="height:100%;width:${pct}%;background:${fillC};border-radius:1px;"></div>
</div>
</div>`;
});
html += `</div>`;
el.innerHTML = html;
const card = document.getElementById('vv-rsync-card');
if (card) {
card.classList.remove('vv-accent-ok','vv-accent-warn','vv-accent-err');
if (!enabled) card.classList.add('vv-accent-err');
else if (active.length) card.classList.add('vv-accent-ok');
}
})();
// ── GPU ─────────────────────────────────────────────────────────────────
// One card per installed GPU. Falls back to the legacy single-GPU payload so the
// page still renders against a cached response written before 'gpus' existed.
const gpuList = (d.gpus && d.gpus.length) ? d.gpus
: ((d.gpu && d.gpu.available) ? [d.gpu] : []);
const allProcs = d.gpu_procs ?? [];
const renderGpuCard = (gpu, bodyId, labelId, fallbackLabel) => {
const bodyEl = document.getElementById(bodyId);
const labelEl = document.getElementById(labelId);
if (!bodyEl) return;
if (!gpu || !gpu.available) {
if (labelEl) labelEl.textContent = fallbackLabel;
bodyEl.innerHTML = '<p style="color:#555;font-style:italic">No GPU detected</p>';
return;
}
// Attribute processes to this card by UUID. Older payloads have no gpu_uuid on the
// process rows — in that case only the first card claims them, rather than every
// card showing the same list.
const gpuProcs = allProcs.filter(p =>
p.gpu_uuid ? p.gpu_uuid === gpu.uuid : (gpu.index ?? 0) === 0
);
const vramPct = gpu.memory_total > 0 ? Math.round(gpu.memory_used / gpu.memory_total * 100) : 0;
const utilPct = gpu.utilization ?? 0;
const temp = gpu.temperature ?? 0;
const tempColor = temp >= 85 ? '#f44336' : temp >= 70 ? '#ff9800' : '#4caf50';
const powerStr = gpu.power_w != null ? gpu.power_w + ' W' : '—';
const procCount = gpuProcs.length;
const procColor = procCount > 0 ? '#4caf50' : '#555';
if (labelEl) labelEl.textContent = 'GPU ' + (gpu.index ?? 0);
// Process list — which apps are actually using this GPU (name + VRAM)
let gpuProcHtml = '';
if (procCount > 0) {
const rows = gpuProcs.map(p => {
const pname = (p.name || '').split('/').pop() || p.name || 'proc';
return `<div style="display:flex;justify-content:space-between;font-size:10px;margin-bottom:2px;">
<span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${pname}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${p.memory_mb} MB</span>
</div>`;
}).join('');
gpuProcHtml = `<div style="margin-top:6px;border-top:1px solid #2a2a2a;padding-top:6px;">
<div style="font-size:10px;color:#555;margin-bottom:3px;">GPU processes</div>
<div style="max-height:60px;overflow-y:auto;scrollbar-width:none;-ms-overflow-style:none;">${rows}</div>
</div>`;
}
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;">${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>` +
vvMeter('VRAM', vramPct, `${gpu.memory_used} / ${gpu.memory_total} MB`) +
vvMeter('GPU', utilPct, `${utilPct}%`) +
vvMeter('Encode', gpu.enc_pct ?? 0, `${gpu.enc_pct ?? 0}%`) +
vvMeter('Decode', gpu.dec_pct ?? 0, `${gpu.dec_pct ?? 0}%`) +
`<div style="display:flex;justify-content:space-between;font-size:11px;margin-top:6px;margin-bottom:8px;">
<span style="color:#666;">Temp</span>
<span style="color:${tempColor};font-weight:bold;">${temp}°C</span>
<span style="color:#666;margin-left:12px;">Power</span>
<span style="color:#aaa;font-weight:bold;">${powerStr}</span>
</div>` + gpuProcHtml;
};
renderGpuCard(gpuList[0], 'vv-gpu-body', 'vv-gpu-label', 'GPU');
renderGpuCard(gpuList[1], 'vv-gpu1-body', 'vv-gpu1-label', 'GPU 1');
// Row 3 is 8 columns wide: Rsync(1) + GPU0(1) + GPU1(1) + Transcode(1) + Streams(4).
// On a single-GPU host the second card is hidden and Transcode widens back to 2 so the
// row still fills exactly — otherwise it would leave a one-column hole.
const gpu1Card = document.getElementById('vv-gpu1-card');
const transcodeCard = document.getElementById('vv-transcode');
const twoGpus = gpuList.length > 1;
if (gpu1Card) gpu1Card.style.display = twoGpus ? '' : 'none';
if (transcodeCard) transcodeCard.style.gridColumn = twoGpus ? 'span 1' : 'span 2';
// ── Scripts ─────────────────────────────────────────────────────────────
vvLastScripts = d.scripts ?? {};
vvRenderScripts();
// ── Transcode ───────────────────────────────────────────────────────────
const tc = d.transcode ?? {};
if (!tc.available) {
document.getElementById('vv-transcode-body').innerHTML =
'<p style="color:#555;font-style:italic">No transcode state — ramdisk_setup.sh not yet run.</p>';
} else {
const loc = tc.is_ramdisk ? 'Ramdisk' : 'SSD';
const locColor = tc.is_ramdisk ? '#4caf50' : '#ff9800';
// RAM disk bar
const rd = tc.ramdisk ?? {};
const rdUsed = rd.used_mb ?? 0;
const rdSize = rd.size_mb ?? 0;
const rdPct = rdSize > 0 ? Math.round(rdUsed / rdSize * 100) : 0;
const rdHue = Math.round(120 * (1 - rdPct / 100));
// SSD bar
const ssd = tc.ssd ?? {};
const ssdUsed = ssd.used_mb ?? 0;
const ssdSize = ssd.size_mb ?? 0;
const ssdPct = ssdSize > 0 ? Math.round(ssdUsed / ssdSize * 100) : 0;
const ssdHue = Math.round(120 * (1 - ssdPct / 100));
// Flip info
const ago = tc.last_flip_ago;
let flipStr = 'never';
if (ago !== null && ago !== undefined) {
if (ago < 60) flipStr = ago + 's ago';
else if (ago < 3600) flipStr = Math.floor(ago / 60) + 'm ago';
else flipStr = Math.floor(ago / 3600) + 'h ' + Math.floor((ago % 3600) / 60) + 'm ago';
}
// Server icon helper
function vvSrvIcon(type, name) {
const cfg = { emby: ['#4caf50','#fff','E'], jellyfin: ['#00A4DC','#fff','JF'], plex: ['#E5A00D','#000','P'] };
const [bg, fg, lbl] = cfg[type] ?? ['#555','#fff', (type[0] || '?').toUpperCase()];
return `<span title="${name}" style="display:inline-flex;align-items:center;justify-content:center;
width:18px;height:18px;background:${bg};border-radius:3px;font-size:8px;font-weight:bold;
color:${fg};flex-shrink:0;">${lbl}</span>`;
}
// Active sessions from shared streams data
const activeSessions = vvLastSessions.filter(s => s.is_tc);
const typeLabel = t => ({ LiveTvProgram:'Live TV', Movie:'Movie', Episode:'TV', Audio:'Music' }[t] ?? t);
const activeFiles = tc.active_files ?? 0;
let activeSegHtml = '';
if (activeFiles > 0) {
activeSegHtml = `<div style="margin-top:8px;border-top:1px solid #2a2a2a;padding-top:6px;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:10px;color:#555;">Active segments</span>
<span style="font-size:10px;"><span style="color:#4caf50;font-weight:600;">${activeFiles}</span><span style="color:#444;font-size:9px;margin-left:4px;">Live TV / Direct Stream</span></span>
</div>`;
}
let sessHtml = '';
if (activeSessions.length) {
function vvTcRow(s) {
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;">${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>`;
}
const mid = Math.ceil(activeSessions.length / 2);
const colA = activeSessions.slice(0, mid).map(vvTcRow).join('');
const colB = activeSessions.slice(mid).map(vvTcRow).join('');
const colBDiv = colB ? `<div style="flex:1;min-width:0;border-left:1px solid #222;padding-left:8px;">${colB}</div>` : '';
sessHtml = `<div style="margin-top:8px;border-top:1px solid #2a2a2a;padding-top:6px;">
<div style="font-size:10px;color:#555;margin-bottom:4px;">Active transcodes <span style="color:#ff9800;font-weight:600;">${activeSessions.length}</span></div>
<div style="max-height:44px;overflow-y:auto;overflow-x:hidden;scrollbar-width:none;-ms-overflow-style:none;">
<div style="display:flex;gap:0;">
<div style="flex:1;min-width:0;">${colA}</div>${colBDiv}
</div>
</div>
</div>`;
}
const nvmeColor = ssd.available ? '#4caf50' : '#f44336';
const rdFreed = tc.last_rd_freed ?? null;
const ssdFreed = tc.last_ssd_freed ?? null;
const rdCleanTip = rdFreed ? `<span style="font-size:9px;color:#555;margin-left:4px;">↓${rdFreed}</span>` : '';
const ssdCleanTip = ssdFreed ? `<span style="font-size:9px;color:#555;margin-left:4px;">↓${ssdFreed}</span>` : '';
document.getElementById('vv-transcode-body').innerHTML =
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;background:${locColor}22;border:1px solid ${locColor};color:${locColor};
padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600;">● ${loc}${rdCleanTip}</span>
<span style="display:inline-flex;align-items:center;background:${nvmeColor}22;border:1px solid ${nvmeColor};color:${nvmeColor};
padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600;">● NVMe${ssdCleanTip}</span>
</div>
<span style="font-size:10px;color:#555;">Flips: ${tc.flip_count_hour ?? 0}/hr · ${flipStr}</span>
</div>
<div style="margin-bottom:7px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px;">
<span style="color:#666;">Ramdisk</span>
<span style="color:#555;font-size:10px;">${rdUsed} / ${rdSize} MB</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${rdPct}%;height:100%;background:hsl(${rdHue},70%,45%);border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>
<div style="margin-bottom:4px;">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px;">
<span style="color:#666;">SSD Fallback</span>
<span style="color:#555;font-size:10px;">${ssd.available ? ssdUsed + ' / ' + ssdSize + ' MB' : '—'}</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${ssdPct}%;height:100%;background:hsl(${ssdHue},70%,45%);border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>${activeSegHtml}${sessHtml}`;
}
// ── Containers and VMs ──────────────────────────────────────────────────
const dfData = d.docker_folders ?? { available: false, folders: [], ungrouped: [] };
dfData.vms = d.vms ?? { available: false, vms: [] };
vvRenderDockerFolders(dfData);
// ── AI ──────────────────────────────────────────────────────────────────
if (d.ai) vvRenderAi(d.ai);
})
.catch(vvPollFailed);
}
// ── AI residency card ────────────────────────────────────────────────────────
// Leads with offload, not with size. 100% on this card is the difference between ~62 tok/s and
// roughly a quarter of that, and nothing else in the WebGUI surfaces it — a model that has
// quietly fallen back to partial CPU offload is otherwise invisible until answers feel slow.
function vvRenderAi(ai) {
const box = document.getElementById('vv-ai-stats-body');
if (!box) return;
const rt = ai.runtime ?? {}, ix = ai.index ?? {};
const line = (label, value, cls) =>
`<div style="display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:6px;">`
+ `<span style="font-size:11px;color:#666;">${vvEscHtml(label)}</span>`
+ `<span style="font-size:11px;font-family:monospace;${cls || 'color:#999;'}">${vvEscHtml(value)}</span></div>`;
let h = '';
if (!rt.reachable) {
h += line('Ollama', 'unreachable', 'color:#e57;');
} else if (!rt.loaded) {
h += line('Model', 'not loaded', 'color:#ffb74d;');
h += `<div style="font-size:10px;color:#666;margin:-2px 0 7px;">loads on first question</div>`;
} else {
const p = rt.offload_pct;
const full = p === 100;
h += line('GPU offload', p === null ? '—' : p + '%', full ? 'color:#6fcf97;' : 'color:#ffb74d;');
h += `<div style="font-size:10px;color:#666;margin:-2px 0 7px;">`
+ (full ? 'all on GPU' : 'layers on CPU — slow') + `</div>`;
if (rt.context) h += line('Context', rt.context.toLocaleString());
}
if (rt.gpu) {
h += line('VRAM', (rt.gpu.mem_used/1024).toFixed(1) + '/' + (rt.gpu.mem_total/1024).toFixed(1) + ' GB');
h += line('GPU util', rt.gpu.util + '%');
}
// Staleness is stated, not implied. An index older than the newest tracked file answers
// confidently out of code that has since changed — the one failure a grounded answer cannot
// reveal on its own.
if (!ix.exists) {
h += line('Index', 'not built', 'color:#e57;');
} else {
h += line('Index', ix.chunks.toLocaleString() + ' chunks',
ix.stale ? 'color:#ffb74d;' : 'color:#999;');
if (ix.stale) h += `<div style="font-size:10px;color:#8a6a3a;margin:-2px 0 7px;">source newer — reindex</div>`;
}
box.innerHTML = h;
}
// ── AI token ledger card ─────────────────────────────────────────────────────
// Not part of the monitor payload. The cache writer runs once a minute and this is the one card
// on the page whose numbers only move when a turn finishes, so it is fetched on its own from
// api/ai.php?action=tokens — the same endpoint and the same shape the AI tab reads, so the two
// surfaces cannot disagree about what has been spent.
let vvTokData = null, vvTokScope = 'all';
function vvTokAgo(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now()/1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d/60) + 'm';
if (d < 86400) return Math.floor(d/3600) + 'h';
return Math.floor(d/86400) + 'd';
}
function vvLoadTokens() {
return fetch('/plugins/varaverk/api/ai.php?action=tokens')
.then(r => r.json())
.then(d => { if (d.ok) { vvTokData = d.tokens; vvRenderTokens(); } })
.catch(() => {});
}
function vvRenderTokens() {
const box = document.getElementById('vv-ai-tokens-body');
if (!box || !vvTokData) return;
const num = n => (n || 0).toLocaleString();
const hosts = vvTokData.hosts || {};
// A host with no rows reads "not collected here", never 0. Each host writes to its own data/
// and only ai_token_sync.sh moves a ledger between them, so a zero would claim the partner did
// no work when the truth is that this host cannot see its ledger at all.
let h = `<div class="vv-ai-tok-l vv-ai-tok-head">Nodes</div>`
+ `<div class="vv-ai-hostrow${vvTokScope === 'all' ? ' active' : ''}" data-scope="all">`
+ `<span class="vv-ai-hostrow-n">All hosts</span>`
+ `<span class="vv-ai-hostrow-v">${num(vvTokData.all.total)}</span></div>`;
Object.keys(hosts).forEach(id => {
const x = hosts[id];
const tag = x.self ? ' · this host' : x.synced ? ' · synced ' + vvTokAgo(x.synced) : '';
h += `<div class="vv-ai-hostrow${vvTokScope === id ? ' active' : ''}" data-scope="${vvEscAttr(id)}">`
+ `<span class="vv-ai-hostrow-n">${vvEscHtml(x.name)}</span>`
+ `<span class="vv-ai-hostrow-h">${vvEscHtml(id)}${vvEscHtml(tag)}</span>`
+ (x.seen ? `<span class="vv-ai-hostrow-v">${num(x.all.total)}</span>`
: `<span class="vv-ai-hostrow-v none">not collected here</span>`)
+ `</div>`;
});
const sel = vvTokScope === 'all' ? vvTokData : hosts[vvTokScope];
const scope = vvTokScope === 'all' ? 'all hosts'
: (hosts[vvTokScope] ? hosts[vvTokScope].name : vvTokScope);
h += `<div class="vv-ai-tok-l vv-ai-tok-head">Token usage — ${vvEscHtml(scope)}</div>`;
if (!sel || !sel.all.turns) {
h += vvTokScope === 'all'
? `<div class="vv-ai-none">no turns recorded yet</div>`
: `<div class="vv-ai-none">no rows from this host in the local ledger</div>`;
box.innerHTML = h;
return;
}
const cell = (label, b) =>
`<div><div class="vv-ai-tok-l">${label}</div>`
+ `<div class="vv-ai-tok-v">${num(b.total)}</div>`
+ `<div class="vv-ai-tok-s">${num(b.turns)} turn${b.turns === 1 ? '' : 's'}`
+ ` · ${num(b.prompt)} in / ${num(b.completion)} out</div></div>`;
h += `<div class="vv-ai-tok-grid">`
+ cell('Today', sel.today) + cell('Last 7 days', sel.week) + cell('All time', sel.all)
+ `</div>`;
// Profile and source splits describe the whole ledger, so they appear only under the all-hosts
// scope rather than sitting beneath a host heading they do not describe.
const foot = [];
if (vvTokData.first) foot.push(`since ${vvEscHtml(vvTokData.first)} · ${vvTokData.days} day${vvTokData.days === 1 ? '' : 's'}`);
if (vvTokData.best_tok_s) foot.push(`best ${vvEscHtml(vvTokData.best_tok_s)} tok/s`);
if (vvTokScope === 'all') {
const by = o => Object.keys(o || {}).map(k => `${vvEscHtml(k)} ${num(o[k])}`).join(' · ');
if (Object.keys(vvTokData.profiles || {}).length) foot.push('profile: ' + by(vvTokData.profiles));
if (Object.keys(vvTokData.sources || {}).length) foot.push('source: ' + by(vvTokData.sources));
}
if (foot.length) h += `<div class="vv-ai-tok-foot">`
+ foot.map(f => `<span>${f}</span>`).join('') + `</div>`;
box.innerHTML = h;
}
// ── AI row height ────────────────────────────────────────────────────────────
// Conversations and Tokens are capped to the Assistant and scroll inside that cap. CSS cannot
// state this on its own: align-self:stretch makes an item fill its grid track, but the track was
// sized from the item's own content first, so a card with a long list simply grows the row and
// then dutifully fills the bigger row. Measuring the Assistant breaks that circle — it is the
// only card in the row whose height is decided by nothing but itself.
//
// Applied only in the eight-column layout. Below 1025px the Assistant drops to a row of its own
// and stops being what these two sit beside, so a cap taken from it would describe nothing.
function vvAiRowSync() {
const asst = document.getElementById('vv-ai-assistant-card');
const chats = document.getElementById('vv-ai-chats-card');
const toks = document.getElementById('vv-ai-tokens-card');
const stats = document.getElementById('vv-ai-stats-card');
if (!asst || !chats) return;
if (!window.matchMedia('(min-width: 1025px)').matches) {
chats.style.maxHeight = '';
if (toks) toks.style.maxHeight = '';
return;
}
const h = asst.offsetHeight;
chats.style.maxHeight = h + 'px';
// Tokens is in the second row, under AI — its share is what the Assistant leaves once the
// first row and the 12px gap are taken. Floored so it stays a card rather than a sliver on a
// short viewport where the AI card happens to be tall.
if (toks && stats) toks.style.maxHeight = Math.max(140, h - stats.offsetHeight - 12) + 'px';
}
// Delegated on the container, which survives every render — the rows inside do not, and binding
// per row would rebind the whole list on each poll to no purpose.
if (document.getElementById('vv-ai-tokens-body')) {
document.getElementById('vv-ai-tokens-body').addEventListener('click', e => {
const row = e.target.closest('.vv-ai-hostrow');
if (!row) return;
vvTokScope = row.dataset.scope;
vvRenderTokens(); // scope is a view of data already held; no refetch
});
// 60s, not the 5s the dashboard polls at. The ledger only moves when a turn completes, and a
// turn completing here refreshes the card directly — this interval exists for turns taken on
// the AI tab and for the partner ledger that ai_token_sync.sh pulls every two hours.
vvPollRunner(vvLoadTokens, 60000);
}
// 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() {
return fetch('/plugins/varaverk/api/monitor_fast.php')
.then(r => r.json())
.then(d => {
// CPU
const cpu = d.cpu ?? {};
document.getElementById('vv-cpu-body').innerHTML = vvRenderCpu(cpu);
vvCpuHistory.push(cpu.overall ?? 0);
if (vvCpuHistory.length > VV_HIST_MAX) vvCpuHistory.shift();
vvDrawChart(document.getElementById('vv-cpu-canvas'), vvCpuHistory, '#4caf50', 'rgba(76,175,80,0.18)', true);
// Memory
document.getElementById('vv-memory-body').innerHTML = vvRenderMemory(d.mem ?? {});
// Network
const net = d.net ?? {};
if (net.available) {
const rx = net.rx_bps ?? 0;
const tx = net.tx_bps ?? 0;
const linkMbps = net.speed_mbps ?? 0;
const linkLabel = linkMbps >= 1000 ? (linkMbps / 1000) + ' Gb/s' : linkMbps ? linkMbps + ' Mb/s' : '—';
vvNetRxHistory.push(rx);
vvNetTxHistory.push(tx);
if (vvNetRxHistory.length > VV_HIST_MAX) vvNetRxHistory.shift();
if (vvNetTxHistory.length > VV_HIST_MAX) vvNetTxHistory.shift();
const maxSeen = Math.max(...vvNetRxHistory, ...vvNetTxHistory, 1);
const maxBps = maxSeen * 1.25;
const peakRx = Math.max(...vvNetRxHistory, 0);
const peakTx = Math.max(...vvNetTxHistory, 0);
const ipRows = [
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;">${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>
</div>
</div>
<div style="text-align:right;font-size:11px;color:#aaa;line-height:1.6;">${ipRows}</div>
</div>
<canvas id="vv-net-canvas" style="width:100%;height:110px;display:block;"></canvas>`;
vvDrawNetChart(document.getElementById('vv-net-canvas'), vvNetRxHistory, vvNetTxHistory, maxBps);
} else {
document.getElementById('vv-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</p>';
}
})
.catch(() => {});
}
// 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() {
const cpu = document.getElementById('vv-cpu');
const pools = document.getElementById('vv-storage-card');
if (!cpu || !pools) return;
const w = cpu.offsetWidth;
if (w === 0) return;
pools.style.flex = 'none';
pools.style.boxSizing = 'border-box';
pools.style.width = w + 'px';
}
new ResizeObserver(vvSyncCardWidths).observe(document.getElementById('vv-cpu'));
requestAnimationFrame(vvSyncCardWidths);
// ── Media streams (slower poll — media server API calls) ──────────────────────
// Map a session's client string to a human-readable device category
function vvDeviceType(client) {
const c = (client || '').toLowerCase();
// Order matters: more specific patterns first
if (/android\s*tv|androidtv|shield|nvidia\s*shield|android.*tv|tv.*android/.test(c)) return 'Android TV';
if (/amazon|fire\s*tv|firetv|fire\s*stick|firestick/.test(c)) return 'Fire TV';
if (/tvos|apple\s*tv/.test(c)) return 'Apple TV';
if (/infuse/.test(c)) return 'Apple TV';
if (/android|mobile/.test(c)) return 'Android';
if (/ios|iphone|ipad/.test(c)) return 'iOS';
if (/roku/.test(c)) return 'Roku';
if (/webos|lg\s*tv|lgtv|lg /.test(c)) return 'LG';
if (/samsung/.test(c)) return 'Samsung';
if (/google\s*tv|googletv|chromecast/.test(c)) return 'Google TV';
if (/kodi/.test(c)) return 'Kodi';
if (/web|chrome|firefox|safari|browser|edge/.test(c)) return 'Browser';
if (/desktop|theater|media\s*player|windows|linux|mac\s*os|macos/.test(c)) return 'Desktop';
return null; // unknown — don't show
}
function vvFmtSec(sec) {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
return h > 0
? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
: `${m}:${String(s).padStart(2,'0')}`;
}
function vvRenderStreams() {
if (vvStreamServerCount === 0) return;
const sessions = vvLastSessions;
const names = vvLastStreamNames;
const el = document.getElementById('vv-streams-body');
if (!el) return;
// Per-server counts from full session list
const serverCounts = {};
sessions.forEach(s => serverCounts[s.server] = (serverCounts[s.server] || 0) + 1);
const badges = names.map(n => {
const cnt = serverCounts[n] ?? 0;
return cnt > 0
? `<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
// shades[0] = base bg, shades[1] = slightly lighter, cycling per index
function vvChipShade(cls, i) {
const shades = {
'vv-chip-device': ['#0d1e2e', '#112436'],
'vv-chip-res': ['#0b1e1c', '#0e2422'],
'vv-chip-codec': ['#201500', '#261900'],
'vv-chip-mtype': ['#1a0d2e', '#1f1136'],
};
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)}>${vvEscHtml(label)}</span>`;
}
// Device type summary — e.g. "3 Android 1 iOS 2 Roku"
const devCounts = {};
sessions.forEach(s => {
const t = vvDeviceType(s.client);
if (t) devCounts[t] = (devCounts[t] || 0) + 1;
});
const deviceBar = Object.entries(devCounts)
.sort((a, b) => b[1] - a[1])
.map(([t, n], i) => vvChip('vv-chip-device', `${n} ${t}`, i))
.join('');
const deviceSection = deviceBar
? `<span class="vv-chip-group-device"><span class="vv-device-sep">·</span>${deviceBar}</span>`
: '';
// Resolution summary — e.g. "2 4K 3 1080p 1 720p"
function vvResLabel(h) {
if (h >= 2160) return '4K';
if (h >= 1080) return '1080p';
if (h >= 720) return '720p';
if (h >= 480) return '480p';
return null;
}
const resCounts = {};
sessions.forEach(s => {
const r = vvResLabel(s.height || 0);
if (r) resCounts[r] = (resCounts[r] || 0) + 1;
});
const resOrder = ['4K', '1080p', '720p', '480p'];
const resBar = resOrder.filter(r => resCounts[r])
.map((r, i) => vvChip('vv-chip-res', `${resCounts[r]} ${r}`, i))
.join('');
const resSection = resBar ? `<span class="vv-chip-group-res"><span class="vv-device-sep">·</span>${resBar}</span>` : '';
// Codec summary — e.g. "4 H.264 2 HEVC"
function vvCodecLabel(c) {
if (!c) return null;
const m = { h264: 'H.264', hevc: 'HEVC', av1: 'AV1', vp9: 'VP9', mpeg4: 'MPEG-4', mpeg2video: 'MPEG-2' };
return m[c] || c.toUpperCase();
}
const codecCounts = {};
sessions.forEach(s => {
const c = vvCodecLabel(s.codec || '');
if (c) codecCounts[c] = (codecCounts[c] || 0) + 1;
});
const codecBar = Object.entries(codecCounts)
.sort((a, b) => b[1] - a[1])
.map(([c, n], i) => vvChip('vv-chip-codec', `${n} ${c}`, i))
.join('');
const codecSection = codecBar ? `<span class="vv-chip-group-codec"><span class="vv-device-sep">·</span>${codecBar}</span>` : '';
// Media type summary — Movies, Shows, Music, Live
function vvMediaType(t) {
if (!t) return null;
const lc = t.toLowerCase();
if (lc === 'movie') return 'Movies';
if (lc === 'episode') return 'Shows';
if (lc === 'audio' || lc === 'track' || lc === 'musicvideo') return 'Music';
if (lc === 'livetvprogram' || lc === 'tvchannel' || lc === 'livetv' || lc === 'recording') return 'Live';
return null;
}
const mtypeCounts = {};
sessions.forEach(s => {
const mt = vvMediaType(s.type || '');
if (mt) mtypeCounts[mt] = (mtypeCounts[mt] || 0) + 1;
});
const mtypeOrder = ['Movies', 'Shows', 'Music', 'Live'];
const mtypeBar = mtypeOrder.filter(mt => mtypeCounts[mt])
.map((mt, i) => vvChip('vv-chip-mtype', `${mtypeCounts[mt]} ${mt}`, i))
.join('');
const mtypeSection = mtypeBar ? `<span class="vv-device-sep">·</span>${mtypeBar}` : '';
if (sessions.length === 0) {
el.innerHTML = `<div class="vv-stream-servers"><div class="vv-stream-left">${badges}</div></div>`
+ '<p class="vv-stream-empty">Nothing playing</p>';
return;
}
const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - vvLastStreamPollAt);
function vvStreamRow(s) {
const isLive = s.type === 'LiveTvProgram' || s.dur_sec === 0;
const inc = s.paused ? 0 : elapsed;
const curSec = Math.max(0, (s.pos_sec ?? 0) + inc);
const barColor = isLive ? '#1e3a5a' : (s.is_tc ? '#e65100' : '#4caf50');
const tcColor = s.paused ? '#fdd835' : '#555';
const icon = s.paused ? '⏸' : '▶';
const iconColor = s.paused ? '#fdd835' : isLive ? '#2196f3' : s.is_tc ? '#ff9800' : '#aaa';
let timeStr = '';
let pct = s.pct ?? 0;
if (isLive) {
pct = 75;
timeStr = `<span style="color:${tcColor};">${vvFmtSec(curSec)}</span>`;
} else if (s.dur_sec > 0) {
pct = Math.min(100, Math.round(curSec / s.dur_sec * 100));
timeStr = `<span style="color:${tcColor};">${vvFmtSec(curSec)} / ${vvFmtSec(s.dur_sec)}</span>`;
}
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> ${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>${vvEscHtml(s.user)}</span>
${timeStr}
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.9s linear;"></div>
</div>
</div>`;
}
// Left-fill columns of 3 — col 1 fills first, col 2 opens when col 1 hits 3
const shown = sessions.slice(0, 12);
const perCol = 3;
const sCols = [];
for (let i = 0; i < shown.length; i += perCol) sCols.push(shown.slice(i, i + perCol));
const vvStreamCol = col =>
`<div style="flex:1;min-width:0;">${col.map(s =>
`<div style="margin-bottom:8px;">${vvStreamRow(s)}</div>`
).join('')}</div>`;
const overflow = sessions.length > 12
? `<div style="font-size:10px;color:#555;margin-top:4px;">+${sessions.length - 12} more not shown</div>`
: '';
el.innerHTML = `<div class="vv-stream-servers">
<div class="vv-stream-left">${badges}${mtypeSection}</div>
<div class="vv-stream-right">${deviceSection}${resSection}${codecSection}</div>
</div>
<div style="display:flex;gap:10px;">${sCols.map(vvStreamCol).join('')}</div>${overflow}`;
}
function vvPollStreams() {
return fetch('/plugins/varaverk/api/media.php')
.then(r => r.json())
.then(d => {
vvLastSessions = d.sessions ?? [];
vvLastStreamNames = d.server_names ?? [];
vvStreamServerCount = d.server_count ?? 0;
vvLastStreamPollAt = Math.floor(Date.now() / 1000);
const el = document.getElementById('vv-streams-body');
if (vvStreamServerCount === 0) {
el.innerHTML = '<p class="vv-stream-empty">No media servers detected.<br>'
+ '<span>Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.</span></p>';
return;
}
vvRenderStreams();
})
.catch(() => {});
}
// 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 ────────────────────────────────────────────────────────────────
function vvTogglePool(name) {
vvPoolsOpen[name] = !vvPoolsOpen[name];
document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks);
}
function vvTogglePoolGroup(groupName) {
vvPoolGroupOpen[groupName] = !vvPoolGroupOpen[groupName];
document.getElementById('vv-storage-body').innerHTML = vvRenderPools(vvLastStorageDisks);
}
function vvRenderPools(disks) {
if (!disks.length) return '<p style="color:#555;font-style:italic;font-size:12px;">No pools found</p>';
// Group drives by pool name
const poolMap = {};
disks.forEach(d => {
const n = d.name;
if (!poolMap[n]) poolMap[n] = [];
poolMap[n].push(d);
});
// Build super-groups: pools whose names share a common prefix + trailing digits
const superGroupMap = {}; // groupName -> [poolName, ...]
Object.keys(poolMap).forEach(poolName => {
const match = poolName.match(/^(.+?)(\d+)$/);
const gName = match ? match[1] : poolName;
if (!superGroupMap[gName]) superGroupMap[gName] = [];
superGroupMap[gName].push(poolName);
});
// Render a single pool row (with optional drive-expand toggle)
function renderPoolRow(poolName, drives) {
const isOpen = !!vvPoolsOpen[poolName];
const multi = drives.length > 1;
const totalGb = drives.reduce((s, d) => s + (d.size_gb ?? 0), 0);
const usedGb = drives.reduce((s, d) => s + (d.used_gb ?? 0), 0);
const pct = totalGb > 0 ? Math.round(usedGb / totalGb * 100) : 0;
const pctColor = pct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : pct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50';
const temps = drives.map(d => d.temp).filter(t => t != null);
const maxTemp = temps.length ? Math.max(...temps) : null;
const tempColor = maxTemp != null ? vvTempColor(maxTemp, drives[0].transport) : '#444';
const allOk = drives.every(d => d.status === 'DISK_OK' || !d.status);
const statusColor = allOk ? '#aaa' : '#f44336';
const sName = poolName.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const [pIor, pIow] = vvIoSum(drives.map(d => d.device));
const poolIoHtml = (() => {
const parts = [];
if (pIor >= 0.05) parts.push(`<span style="color:#3a7a3a;">↓${vvFmtRate(pIor)}</span>`);
if (pIow >= 0.05) parts.push(`<span style="color:#7a4a1a;">↑${vvFmtRate(pIow)}</span>`);
return parts.length ? `<span style="font-size:9px;margin-left:5px;">${parts.join(' ')}</span>` : '';
})();
let html = `<div style="margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;
${multi ? 'cursor:pointer;' : ''}" ${multi ? `onclick="vvTogglePool('${sName}')"` : ''}>
<span style="color:${statusColor};display:flex;align-items:center;gap:5px;">
${multi ? `<span style="color:#555;font-size:9px;width:8px;">${isOpen ? '▾' : '▸'}</span>` : '<span style="width:8px;display:inline-block;"></span>'}
${poolName}
${multi ? `<span style="font-size:9px;color:#444;background:#1a1a1a;padding:0 4px;border-radius:2px;">${drives.length} drives</span>` : ''}
${poolIoHtml}
</span>
<span style="display:flex;align-items:center;gap:8px;">
${maxTemp != null ? `<span style="color:${tempColor};font-size:10px;">${maxTemp}°</span>` : ''}
<span style="color:#555;font-size:10px;">${vvFmt(usedGb)} / ${vvFmt(totalGb)}</span>
</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${pctColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
if (multi && isOpen) {
html += `<div style="padding-left:12px;border-left:1px solid #252525;margin-bottom:8px;margin-top:-4px;">`;
drives.forEach(d => { html += vvDiskRow({ ...d, name: d.device || d.name }); });
html += `</div>`;
}
return html;
}
let html = '';
Object.entries(superGroupMap).forEach(([groupName, poolNames]) => {
if (poolNames.length === 1) {
// Single pool — render directly, no super-group header
html += renderPoolRow(poolNames[0], poolMap[poolNames[0]]);
} else {
// Super-group: aggregate header + expandable list of individual pools
const isGroupOpen = !!vvPoolGroupOpen[groupName];
const sGroup = groupName.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const allDrives = poolNames.flatMap(n => poolMap[n]);
const totalGb = allDrives.reduce((s, d) => s + (d.size_gb ?? 0), 0);
const usedGb = allDrives.reduce((s, d) => s + (d.used_gb ?? 0), 0);
const pct = totalGb > 0 ? Math.round(usedGb / totalGb * 100) : 0;
const pctColor = pct >= (vvThresholds.util_crit ?? 90) ? '#f44336' : pct >= (vvThresholds.util_warn ?? 70) ? '#ff9800' : '#4caf50';
const temps = allDrives.map(d => d.temp).filter(t => t != null);
const maxTemp = temps.length ? Math.max(...temps) : null;
const tempColor = maxTemp != null ? vvTempColor(maxTemp, allDrives[0].transport) : '#444';
html += `<div style="margin-bottom:${isGroupOpen ? '2' : '8'}px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px;cursor:pointer;"
onclick="vvTogglePoolGroup('${sGroup}')">
<span style="color:#aaa;display:flex;align-items:center;gap:5px;">
<span style="color:#555;font-size:9px;width:8px;">${isGroupOpen ? '▾' : '▸'}</span>
${groupName}
<span style="font-size:9px;color:#444;background:#1a1a1a;padding:0 4px;border-radius:2px;">${poolNames.length} pools</span>
</span>
<span style="display:flex;align-items:center;gap:8px;">
${maxTemp != null ? `<span style="color:${tempColor};font-size:10px;">${maxTemp}°</span>` : ''}
<span style="color:#555;font-size:10px;">${vvFmt(usedGb)} / ${vvFmt(totalGb)}</span>
</span>
</div>
<div style="background:#1a1a1a;border-radius:3px;height:6px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${pctColor};border-radius:3px;transition:width 0.4s;"></div>
</div>
</div>`;
if (isGroupOpen) {
html += `<div style="padding-left:12px;border-left:1px solid #252525;margin-bottom:8px;margin-top:2px;">`;
poolNames.forEach(poolName => { html += renderPoolRow(poolName, poolMap[poolName]); });
html += `</div>`;
}
}
});
return html;
}
// ── System clock tick (updates time every 30s without a full poll) ────────────
function vvTickClock() {
const el = document.getElementById('vv-system-body');
if (!el) return;
const now = new Date();
const timeStr = now.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const t = el.querySelector('.vv-clock');
if (t) t.textContent = timeStr;
}
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) {
// 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(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) {
vvDfData = data;
const el = document.getElementById('vv-docker-folders-body');
if (!el || !data) return;
// Update container count badge in header
const allCtrs = [...(data.folders ?? []).flatMap(f => f.containers), ...(data.ungrouped ?? [])];
const totalCtrs = allCtrs.length;
const runCtrs = allCtrs.filter(c => c.running).length;
const countEl = document.getElementById('vv-docker-count');
if (countEl && totalCtrs > 0) countEl.textContent = `${runCtrs}/${totalCtrs}`;
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">${vvEscHtml(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(' ') : '—';
// 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) {
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">${vvEscHtml(c.name)}</span>
<span class="vv-df-status">${vvEscHtml(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;margin-left:auto;">${running}/${total}</span>`;
const sid = vvEscAttr(String(f.id ?? '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
let iconHtml = '';
if (f.isEmoji) {
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">${vvEscHtml(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 item list — folders first, then ungrouped containers as individual rows
const _folders = (data.folders ?? []).map(f => ({ ...f, _type: 'folder', isEmoji: false }));
const _solo = (data.ungrouped ?? []).map(c => ({ ...c, _type: 'container' }));
const _items = [..._folders, ..._solo];
if (!_items.length) {
html += '<div class="vv-df-empty">No containers found</div>';
el.innerHTML = html;
return;
}
function renderItem(item) {
return item._type === 'folder' ? renderFolder(item) : renderContainer(item);
}
// Column count: 3 big / 2 intermediate / 1 small
const _w = window.innerWidth;
const _cols = _w > 1400 ? 3 : _w > 640 ? 2 : 1;
if (_cols === 1) {
html += `<div class="vv-df-col">${_items.map(renderItem).join('')}</div>`;
} else {
const perCol = Math.ceil(_items.length / _cols);
const colDivs = Array.from({length: _cols}, (_, i) =>
`<div class="vv-df-col">${_items.slice(i * perCol, (i + 1) * perCol).map(renderItem).join('')}</div>`
).join('');
html += `<div class="vv-df-cols">${colDivs}</div>`;
}
el.innerHTML = html;
}
// Close action bar when clicking outside the card
document.addEventListener('click', () => {
if (vvDfActive !== null) { vvDfActive = null; vvRenderDockerFolders(vvDfData); }
});
// 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.
// ── Help panel ───────────────────────────────────────────────────────────────
// Collapse state and depth are both remembered. Which one you want is a preference, not a
// per-visit decision — and on a page you leave open, re-collapsing help you deliberately opened
// would be its own small annoyance.
function vvMonToggleHelp(header) {
const body = header.nextElementSibling;
const chevron = header.querySelector('.vv-sug-chevron');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : '';
chevron.textContent = open ? '▸' : '▾';
localStorage.setItem('vv-sug-monitor-howto', open ? '0' : '1');
}
function vvMonToggleMore(btn) {
const brief = document.getElementById('vv-mon-howto-brief');
const full = document.getElementById('vv-mon-howto-full');
if (!brief || !full) return;
const showFull = full.style.display === 'none';
full.style.display = showFull ? '' : 'none';
brief.style.display = showFull ? 'none' : '';
btn.textContent = showFull ? 'Less' : 'More info';
btn.classList.toggle('vv-more-on', showFull);
localStorage.setItem('vv-mon-howto-more', showFull ? '1' : '0');
}
(function vvMonRestoreHelp() {
if (localStorage.getItem('vv-sug-monitor-howto') === '1') {
const h = document.querySelector('[data-save-key="monitor-howto"] .vv-sug-header');
if (h) vvMonToggleHelp(h);
}
if (localStorage.getItem('vv-mon-howto-more') === '1') {
const b = document.getElementById('vv-mon-howto-more');
if (b) vvMonToggleMore(b);
}
})();
// ── AI row ───────────────────────────────────────────────────────────────────
// Constructed only when the row rendered. The card markup is behind vv_ai_ui_on(), so on any
// other host these ids do not exist and the factories are never called — the row is absent
// rather than broken.
//
// Same store as the AI tab, so a conversation started here is the one you carry on there. The
// instance keys itself on its prefix and tears down any predecessor, which matters on this tab
// specifically: Unraid swaps tab content by AJAX without unloading the previous page's script,
// and this page already has three poll loops that survive that.
if (document.getElementById('vv-mon-ai-chat')) {
let vvMonChatList = null;
const vvMonChat = VvAiChat({
prefix: 'vv-mon-ai',
profile: 'chat',
empty: 'Ask anything. Questions about this installation are handed to the Varaverk '
+ 'assistant automatically.',
onChats: id => { if (vvMonChatList) vvMonChatList.setActive(id); },
// The turn that just finished is the one spend you watched happen. Waiting up to a minute
// for the poll to reflect it reads as the card being broken.
onTurn: () => vvLoadTokens(),
});
vvMonChatList = VvAiChatList({ into: 'vv-mon-ai-chats', chat: vvMonChat });
vvMonChatList.reload();
// Re-measured rather than computed once. The Assistant changes height when the transcript is
// expanded, and the AI card above changes when its first real payload replaces "Loading...";
// both move the ceiling these two cards sit under. Observing the two sources covers each
// without watching the cards being adjusted, so there is no feedback loop. The resize listener
// is for crossing the 1025px breakpoint, which changes whether a cap applies at all.
vvAiRowSync();
if (window.ResizeObserver) {
const ro = new ResizeObserver(vvAiRowSync);
['vv-ai-assistant-card', 'vv-ai-stats-card'].forEach(id => {
const el = document.getElementById(id);
if (el) ro.observe(el);
});
}
window.addEventListener('resize', vvAiRowSync);
}
</script>