varaverk: monitor overhaul — CPU/memory/network/streams cards; scheduler polish

Monitor tab:
- CPU card: per-core bars (freq-colored), overall usage bar, rolling history chart
- Memory card: breakdown by system/VM/ZFS/Docker/free with progress bars
- Network card: LAN + ext + Tailscale IP display, live RX/TX chart with auto-scale
- Streams card: Emby/Jellyfin/Plex sessions with playback bar and server badges
- GPU card: adds power draw, encode%, decode% meters
- Transcode card: ramdisk vs SSD location pill, session count, flip history
- Canvas chart helpers: vvDrawChart, vvDrawNetChart, vvMeter, vvFmtBps, vvFmtGib

api/monitor.php: adds cpu, mem, net to response payload
This commit is contained in:
Gmer4Lfe
2026-05-24 10:52:42 -04:00
parent f7bef55e51
commit ab3e28531e
7 changed files with 938 additions and 61 deletions
@@ -19,7 +19,7 @@ $logFile = vv_job_log_path($id);
$logDir = dirname($logFile);
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
file_put_contents($logFile, date('[Y-m-d H:i:s]') . " [DRY RUN] started\n", FILE_APPEND);
file_put_contents($logFile, "\n── " . date('Y-m-d H:i:s') . " [DRY RUN] ──────────────────────\n", FILE_APPEND);
$flags = vv_job_flags($id);
exec('nohup env DRY_RUN=1 bash ' . escapeshellarg($script) . ($flags ? " $flags" : '') . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &');
@@ -5,6 +5,9 @@ require_once dirname(__DIR__) . '/include/monitor.php';
echo json_encode([
'fallback' => vv_fallback_state(),
'resources' => vv_system_resources(),
'cpu' => vv_cpu_per_core(),
'mem' => vv_memory_breakdown(),
'net' => vv_network_stats(),
'gpu' => vv_gpu_stats(),
'gpu_procs' => vv_gpu_processes(),
'containers' => vv_docker_containers(),
@@ -19,8 +19,7 @@ $logFile = vv_job_log_path($id);
$logDir = dirname($logFile);
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
// Stamp the log so the panel shows when the run started
file_put_contents($logFile, date('[Y-m-d H:i:s]') . " Manual run started\n", FILE_APPEND);
file_put_contents($logFile, "\n── " . date('Y-m-d H:i:s') . " ──────────────────────\n", FILE_APPEND);
$flags = vv_job_flags($id);
exec('nohup bash ' . escapeshellarg($script) . ($flags ? " $flags" : '') . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &');
@@ -17,7 +17,7 @@
color: #888; letter-spacing: 0.05em; }
/* Fallback state badge */
.vv-state-badge { font-size: 20px; font-weight: bold; padding: 4px 0; }
.vv-state-badge { font-size: 20px; font-weight: bold; padding: 4px 0; margin-bottom: 2px; }
.vv-state-normal { color: #4caf50; }
.vv-state-failover { color: #f44336; }
.vv-state-no_internet { color: #ff9800; }
@@ -108,6 +108,50 @@
.vv-dry-btn { border-color: #ff9800 !important; color: #ff9800 !important; }
.vv-dry-btn:hover { background: #e65100 !important; color: #fff !important;
border-color: #ff9800 !important; }
.vv-log-btn { border-color: #555 !important; color: #666 !important; }
.vv-log-btn:hover { background: #333 !important; color: #aaa !important; border-color: #777 !important; }
.vv-log-btn.vv-has-log { border-color: #4caf50 !important; color: #4caf50 !important; }
.vv-log-btn.vv-has-log:hover { background: #1b5e20 !important; color: #fff !important;
border-color: #4caf50 !important; }
.vv-edit-btn { border-color: #7b1fa2 !important; color: #ce93d8 !important; }
.vv-edit-btn:hover { background: #4a148c !important; color: #fff !important; border-color: #7b1fa2 !important; }
.vv-add-script-btn { background: #1565c0 !important; border: none !important; color: #fff !important; margin-left: 8px; }
.vv-add-script-btn:hover { background: #0d47a1 !important; }
.vv-save-script-btn-style { background: #1565c0 !important; border: none !important; color: #fff !important; }
.vv-save-script-btn-style:hover { background: #0d47a1 !important; }
.vv-delete-btn { background: #b71c1c !important; border: none !important; color: #fff !important; margin-left: 4px; }
.vv-delete-btn:hover { background: #7f0000 !important; }
.vv-conf-btn { border-color: #00838f !important; color: #4dd0e1 !important; }
.vv-conf-btn:hover { background: #006064 !important; color: #fff !important; border-color: #00838f !important; }
/* Config form */
#vv-confform { padding: 2px 4px; }
.vv-cf-group { border-bottom: 1px solid #252525; padding-bottom: 14px; margin-bottom: 14px; }
.vv-cf-group:last-child { border-bottom: none; margin-bottom: 0; }
.vv-cf-group-header { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: bold;
color: #7a9eb5; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
.vv-cf-file { font-size: 10px; color: #555; background: #1a1a1a; border: 1px solid #2e2e2e;
padding: 1px 6px; border-radius: 3px; font-weight: normal; text-transform: none;
letter-spacing: 0; }
.vv-cf-field { margin-bottom: 10px; }
.vv-cf-key { font-family: monospace; font-size: 12px; color: #ccc; margin-bottom: 3px; }
.vv-cf-desc { font-size: 11px; color: #666; margin-bottom: 4px; font-style: italic; line-height: 1.4; }
.vv-cf-scalar { display: block; width: 100%; box-sizing: border-box; background: #111; border: 1px solid #333;
color: #ddd; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; }
.vv-cf-scalar:focus { border-color: #555; outline: none; }
.vv-cf-array { display: block; width: 100%; box-sizing: border-box; background: #0d0d0d; border: 1px solid #333;
color: #ccc; padding: 8px; border-radius: 4px; font-family: monospace; font-size: 11px;
line-height: 1.6; resize: vertical; min-height: 60px; }
.vv-cf-array:focus { border-color: #555; outline: none; }
.vv-cf-empty { color: #555; font-size: 13px; font-style: italic; padding: 20px 4px; text-align: center; margin: 0; }
.vv-custom-empty { color: #666; font-size: 13px; padding: 8px 4px; margin: 0; font-style: italic; }
.vv-custom-count { font-size: 12px; color: #666; margin-left: 8px; flex-shrink: 0; }
.vv-section-sep { font-size: 11px; font-weight: bold; color: #666; text-transform: uppercase;
letter-spacing: 0.08em; padding: 10px 4px 4px; border-top: 1px solid #222; margin-top: 8px; }
#vv-editor { flex-direction: column; gap: 0; }
.vv-editor-body { font-family: monospace; font-size: 12px; background: #0d0d0d; color: #ccc;
border: 1px solid #333; border-radius: 4px; padding: 10px 12px; resize: none;
line-height: 1.5; scroll-behavior: auto; tab-size: 2; width: 100%; box-sizing: border-box; }
/* Suggestions panel — accordion */
#vv-suggestions { overflow-y: auto; }
@@ -144,7 +188,8 @@
.vv-log-pre { background: #0d0d0d; border: 1px solid #333; border-radius: 4px;
padding: 10px 12px; margin: 0; font-family: monospace; font-size: 12px;
color: #ccc; white-space: pre-wrap; word-break: break-all;
max-height: 340px; overflow-y: auto; line-height: 1.5; }
max-height: 340px; overflow-y: auto; line-height: 1.5;
scroll-behavior: auto; }
/* Toggle switch */
.vv-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
@@ -188,6 +233,28 @@
.vv-doc-body pre { background: #111; padding: 12px; border-radius: 4px; overflow-x: auto; }
.vv-doc-hint { font-size: 12px; color: #666; margin-top: 8px; }
/* Media stream sessions */
.vv-stream-servers { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.vv-server-badge { background: #333; border: 1px solid #555; color: #aaa;
font-size: 10px; padding: 1px 7px; border-radius: 10px; }
.vv-server-badge-sm { font-size: 9px; padding: 1px 6px; flex-shrink: 0; }
.vv-stream-empty { color: #555; font-style: italic; font-size: 12px; margin: 4px 0; }
.vv-stream-empty span { font-size: 11px; color: #444; }
.vv-stream-row { margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #282828; }
.vv-stream-row:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
.vv-stream-top { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.vv-stream-icon { font-size: 10px; color: #888; flex-shrink: 0; }
.vv-stream-title { flex: 1; font-size: 12px; color: #ddd; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vv-stream-meta { display: flex; gap: 10px; flex-wrap: wrap; font-size: 10px;
color: #666; margin-bottom: 5px; }
.vv-stream-user { color: #888; }
.vv-stream-client { color: #555; }
.vv-stream-method { font-weight: 500; }
.vv-stream-time { color: #555; margin-left: auto; }
.vv-stream-bar { height: 3px; background: #1a1a1a; border-radius: 2px; overflow: hidden; }
.vv-stream-bar div { height: 100%; border-radius: 2px; transition: width 0.4s; }
/* Live var substitution colours */
code.vv-live-var { color: #4caf50; background: #0d1f0d; }
code.vv-unknown-var { color: #ff9800; background: #1f130d; }
@@ -3,6 +3,7 @@
// schedule.json is per-host, never synced.
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/confform.php';
define('SCHEDULE_FILE', '/boot/config/plugins/varaverk/schedule.json');
define('CRON_FILE', '/etc/cron.d/varaverk');
@@ -59,7 +60,7 @@ function vv_cron_rebuild(array $schedule): bool {
$logDir = dirname($logFile);
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
$flags = !empty($entry['log_enabled']) ? ' --log' : '';
$lines[] = "{$entry['cron']} bash \"$script\"$flags >> \"$logFile\" 2>&1";
$lines[] = "{$entry['cron']} bash -c 'echo; echo \"── \$(date \"+%Y-%m-%d %H:%M:%S\") ──────────────────────\"; bash \"$script\"$flags' >> \"$logFile\" 2>&1";
}
$lines[] = "";
@@ -171,6 +172,25 @@ function vv_script_description(string $path): string {
return $first;
}
function vv_custom_scripts(): array {
$dir = SCRIPTS_DIR . '/Custom';
$schedule = vv_schedule_load();
$scripts = [];
foreach (glob("$dir/*.sh") ?: [] as $path) {
$rel = 'Custom/' . basename($path);
$entry = $schedule[$rel] ?? [];
$scripts[] = [
'id' => $rel,
'label' => basename($path, '.sh'),
'desc' => vv_script_description($path),
'enabled' => (bool)($entry['enabled'] ?? false),
'cron' => $entry['cron'] ?? '',
'log_enabled' => (bool)($entry['log_enabled'] ?? false),
];
}
return $scripts;
}
// Walk the scripts repo and return the job tree:
// orchestrators as top-level, individual scripts as children
function vv_job_tree(): array {
@@ -6,27 +6,41 @@
<div class="vv-card" id="vv-fallback">
<h3>Fallback State</h3>
<div class="vv-state-badge" id="vv-fallback-state">—</div>
<div id="vv-fallback-desc"></div>
</div>
<div class="vv-card" id="vv-resources">
<h3>Resources</h3>
<div id="vv-resource-body">Loading...</div>
<div class="vv-card" id="vv-cpu" style="flex:2;min-width:240px;">
<h3>CPU</h3>
<div id="vv-cpu-body">Loading...</div>
</div>
<div class="vv-card" id="vv-gpu-card">
<div class="vv-card" id="vv-memory" style="flex:2;min-width:240px;">
<h3>Memory</h3>
<div id="vv-memory-body">Loading...</div>
</div>
<div class="vv-card" id="vv-network" style="flex:2;min-width:240px;">
<h3>Network</h3>
<div id="vv-network-body">Loading...</div>
</div>
</div>
<div class="vv-row">
<div class="vv-card" id="vv-gpu-card" style="flex:2;min-width:240px;">
<h3>GPU</h3>
<div id="vv-gpu-body">Loading...</div>
</div>
</div>
<div class="vv-row">
<div class="vv-card vv-wide" id="vv-transcode">
<div class="vv-card" id="vv-transcode">
<h3>Transcode</h3>
<div id="vv-transcode-body">Loading...</div>
</div>
</div>
<div class="vv-row">
<div class="vv-card" id="vv-streams" style="flex:2;min-width:280px;">
<h3>Streams</h3>
<div id="vv-streams-body">Loading...</div>
</div>
<div class="vv-card vv-wide" id="vv-docker">
<h3>Containers</h3>
<table id="vv-docker-table">
@@ -39,36 +53,397 @@
</div>
<script>
// Poll monitor API every 5 seconds
// ── Shared helpers ────────────────────────────────────────────────────────────
function vvMeter(label, pct, text) {
const color = pct >= 85 ? '#f44336' : pct >= 65 ? '#ff9800' : '#4caf50';
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 vvNetRxHistory = [];
let vvNetTxHistory = [];
// ── 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 vvCoreColor(freqMhz, maxMhz, minMhz) {
if (!freqMhz || !maxMhz || maxMhz === minMhz) return '#4caf50';
const t = Math.max(0, Math.min(1, (freqMhz - minMhz) / (maxMhz - minMhz)));
// blue(220°) → green(120°) → red(0°) as t goes 0→1
const hue = Math.round((1 - t) * 220);
return `hsl(${hue},70%,45%)`;
}
function vvRenderCpu(cpu) {
const overall = cpu.overall ?? 0;
const cores = cpu.cores ?? [];
// Overall usage bar
let html = vvMeter('Overall', overall, overall + '%');
// Per-core vertical bars
if (cores.length) {
html += `<div style="display:flex;align-items:flex-end;gap:3px;height:54px;margin:10px 0 4px;">`;
cores.forEach(c => {
const color = vvCoreColor(c.freq_mhz, c.max_mhz, c.min_mhz);
const usePct = c.usage_pct ?? 0;
const barH = Math.max(2, Math.round(usePct * 0.46)); // max ~46px at 100%
const label = c.freq_mhz ? (c.freq_mhz >= 1000 ? (c.freq_mhz/1000).toFixed(1)+'G' : c.freq_mhz+'M') : '';
html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;min-width:10px;">
<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(220,70%,45%)">Low freq</span>
<span style="color:hsl(120,70%,45%)">Mid</span>
<span style="color:hsl(0,70%,45%)">High freq</span>
</div>`;
}
// Canvas chart
html += `<canvas id="vv-cpu-canvas" style="width:100%;height:36px;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';
let html = `<div style="font-size:14px;font-weight:600;color:${totalColor};margin-bottom:10px;">
${vvFmtGib(usedKb)} <span style="font-size:11px;color:#555;font-weight:400;">/ ${vvFmtGib(total)}</span>
</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.zfs_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);
return html;
}
// ── Poll ──────────────────────────────────────────────────────────────────────
function vvPollMonitor() {
fetch('/plugins/varaverk/api/monitor.php')
.then(r => r.json())
.then(d => {
// Fallback state
const fb = d.fallback ?? {};
// ── Fallback state ──────────────────────────────────────────────────────
const fb = d.fallback ?? {};
const state = (fb.state ?? 'UNKNOWN').toUpperCase();
const badge = document.getElementById('vv-fallback-state');
badge.textContent = fb.state ?? '—';
badge.className = 'vv-state-badge vv-state-' + (fb.state ?? 'unknown').toLowerCase();
badge.textContent = state;
badge.className = 'vv-state-badge vv-state-' + state.toLowerCase();
// Resources
const res = d.resources ?? {};
document.getElementById('vv-resource-body').innerHTML =
`<p>RAM: ${res.ram_free_mb ?? '—'} MB free / ${res.ram_total_mb ?? '—'} MB</p>
<p>CPU: ${res.cpu_percent ?? '—'}%</p>
<p>Ramdisk: ${res.ramdisk?.used_mb ?? '—'} MB / ${res.ramdisk?.size_mb ?? '—'} MB</p>`;
const descMap = {
NORMAL: ['#4caf50', '✓', 'Nominal'],
FAILOVER: ['#f44336', '⚠', 'Running peer containers'],
NO_INTERNET: ['#ff9800', '⚡', 'Internet lost'],
DARK: ['#9e9e9e', '◌', 'Isolation mode'],
UNKNOWN: ['#555', '?', 'State unavailable'],
};
const [dColor, dIcon, dText] = descMap[state] ?? ['#555', '?', ''];
let descHtml = `<div style="font-size:12px;color:${dColor};margin-top:6px;">${dIcon} ${dText}</div>`;
// GPU
const gpu = d.gpu ?? {};
if (gpu.available) {
document.getElementById('vv-gpu-body').innerHTML =
`<p>${gpu.name}</p>
<p>VRAM: ${gpu.memory_used} / ${gpu.memory_total} MB</p>
<p>Util: ${gpu.utilization}% &nbsp; Temp: ${gpu.temperature}°C</p>`;
if (state === 'FAILOVER') {
const start = parseInt(fb.failover_start ?? 0);
if (start > 0) {
const sec = Math.floor(Date.now() / 1000) - start;
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60);
descHtml += `<div style="font-size:11px;color:#888;margin-top:3px;">Duration: ${h}h ${m}m</div>`;
}
const tiers = ['tier2_started','tier3_started','tier4_started']
.filter(k => fb[k] === 'true')
.map(k => k.replace('_started','').replace('tier','T'));
if (tiers.length)
descHtml += `<div style="font-size:11px;color:#f44336;margin-top:3px;">Active: ${tiers.join(' ')}</div>`;
}
document.getElementById('vv-fallback-desc').innerHTML = descHtml;
// ── 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 ──────────────────────────────────────────────────────────────
const mem = d.mem ?? {};
document.getElementById('vv-memory-body').innerHTML = vvRenderMemory(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' : '—';
const linkBps = linkMbps * 1e6;
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; // auto-scale with 25% headroom
const ipRows = [
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN&nbsp;&nbsp;</span>${net.local_ip}</div>` : '',
net.ext_ip ? `<div><span style="color:#555;font-size:9px;">EXT&nbsp;&nbsp;</span>${net.ext_ip}</div>` : '',
net.ts_ip ? `<div><span style="color:#555;font-size:9px;">TS&nbsp;&nbsp;&nbsp;</span>${net.ts_ip}</div>` : '',
].filter(Boolean).join('');
document.getElementById('vv-network-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} &nbsp;·&nbsp; ${linkLabel}</div>
<div style="display:flex;gap:16px;font-size:13px;font-weight:600;">
<span style="color:#4caf50;">↓ ${vvFmtBps(rx)}</span>
<span style="color:#ff9800;">↑ ${vvFmtBps(tx)}</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:52px;display:block;margin-bottom:4px;"></canvas>
<div style="display:flex;gap:12px;font-size:9px;color:#555;">
<span><span style="color:#4caf50;">━</span> IN (RX)</span>
<span><span style="color:#ff9800;">━</span> OUT (TX)</span>
</div>`;
vvDrawNetChart(document.getElementById('vv-net-canvas'), vvNetRxHistory, vvNetTxHistory, maxBps);
} else {
document.getElementById('vv-gpu-body').innerHTML = '<p>No GPU detected</p>';
document.getElementById('vv-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</p>';
}
// Docker
// ── GPU ─────────────────────────────────────────────────────────────────
const gpu = d.gpu ?? {};
const gpuProcs = d.gpu_procs ?? [];
if (gpu.available) {
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';
document.getElementById('vv-gpu-body').innerHTML =
// header row: name + process count pill
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div style="font-size:11px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${gpu.name}</div>
<div style="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;">
<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>`;
} else {
document.getElementById('vv-gpu-body').innerHTML = '<p style="color:#555;font-style:italic">No GPU detected</p>';
}
// ── 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';
const rd = tc.ramdisk ?? {};
const usedMb = rd.used_mb ?? 0;
const sizeMb = rd.size_mb ?? 0;
const pct = sizeMb > 0 ? Math.round(usedMb / sizeMb * 100) : 0;
const barColor = pct > 85 ? '#f44336' : pct > 65 ? '#ff9800' : '#4caf50';
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';
}
const totalSess = (tc.ram_sessions ?? 0) + (tc.ssd_sessions ?? 0);
let sessStr = totalSess === 0 ? 'none' : totalSess + ' active';
if (tc.ram_sessions > 0 && tc.ssd_sessions > 0)
sessStr += ` (split: ${tc.ram_sessions} RAM / ${tc.ssd_sessions} SSD)`;
document.getElementById('vv-transcode-body').innerHTML = `
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="background:${locColor}22;border:1px solid ${locColor};color:${locColor};
padding:2px 10px;border-radius:12px;font-size:12px;font-weight:bold;">● ${loc}</span>
<span style="color:#888;font-size:12px;">Sessions: ${sessStr}</span>
<span style="color:#666;font-size:12px;margin-left:auto;">Flips this hour: ${tc.flip_count_hour ?? 0}</span>
</div>
<div style="margin-bottom:4px;">
<div style="display:flex;justify-content:space-between;font-size:11px;color:#666;margin-bottom:3px;">
<span>Ramdisk</span><span>${usedMb} MB / ${sizeMb} MB (${pct}%)</span>
</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.4s;"></div>
</div>
</div>
<div style="font-size:11px;color:#555;margin-top:6px;">Last flip: ${flipStr}</div>`;
}
// ── Docker ──────────────────────────────────────────────────────────────
const containers = d.containers ?? [];
const tbody = document.getElementById('vv-docker-body');
tbody.innerHTML = containers.length
@@ -77,9 +452,78 @@ function vvPollMonitor() {
).join('')
: '<tr><td colspan="3">No running containers</td></tr>';
})
.catch(() => {}); // silent on poll failure
.catch(() => {});
}
vvPollMonitor();
setInterval(vvPollMonitor, 5000);
// ── Media streams (slower poll — media server API calls) ──────────────────────
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 vvPollStreams() {
fetch('/plugins/varaverk/api/media.php')
.then(r => r.json())
.then(d => {
const sessions = d.sessions ?? [];
const names = d.server_names ?? [];
const el = document.getElementById('vv-streams-body');
if (d.server_count === 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;
}
// Server badges in header
const badges = names.map(n =>
`<span class="vv-server-badge">${n}</span>`
).join('');
if (sessions.length === 0) {
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>`
+ '<p class="vv-stream-empty">Nothing playing</p>';
return;
}
const rows = sessions.map(s => {
const icon = s.paused ? '⏸' : '▶';
const barColor = s.is_tc ? '#ff9800' : '#4caf50';
const methColor = s.is_tc ? '#ff9800' : '#4caf50';
const timeStr = s.dur_sec > 0
? `${vvFmtSec(s.pos_sec)} / ${vvFmtSec(s.dur_sec)}`
: '';
return `<div class="vv-stream-row">
<div class="vv-stream-top">
<span class="vv-stream-icon">${icon}</span>
<span class="vv-stream-title" title="${s.title}">${s.title}</span>
<span class="vv-server-badge vv-server-badge-sm">${s.server}</span>
</div>
<div class="vv-stream-meta">
<span class="vv-stream-user">${s.user}</span>
<span class="vv-stream-client">${s.client}</span>
<span class="vv-stream-method" style="color:${methColor};">${s.method}</span>
${timeStr ? `<span class="vv-stream-time">${timeStr}</span>` : ''}
</div>
<div class="vv-stream-bar">
<div style="width:${s.pct}%;background:${barColor};"></div>
</div>
</div>`;
}).join('');
el.innerHTML = `<div class="vv-stream-servers">${badges}</div>${rows}`;
})
.catch(() => {});
}
vvPollStreams();
setInterval(vvPollStreams, 12000);
</script>
@@ -1,7 +1,8 @@
<?php
require_once dirname(__DIR__) . '/include/scheduler.php';
$tree = vv_job_tree();
$blocks = vv_parse_user_script_template();
$tree = vv_job_tree();
$customs = vv_custom_scripts();
$blocks = vv_parse_user_script_template();
// Build a flat lookup: rel_path → [enabled, cron, in_tree]
$treeMap = [];
@@ -45,7 +46,11 @@ foreach ($tree as $orch) {
<div class="vv-job-actions">
<button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">&#9654; Run</button>
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">&#9654; Dry Run</button>
<button class="vv-btn-sm" onclick="vvSelectLog(this)">Log</button>
<?php if (vv_conf_has_sections($orch['id'])): ?>
<button class="vv-btn-sm vv-conf-btn" onclick="vvEditConf('<?= $oid ?>')">Config</button>
<?php endif; ?>
<?php $orchLog = vv_job_log_path($orch['id']); ?>
<button class="vv-btn-sm vv-log-btn<?= (file_exists($orchLog) && filesize($orchLog) > 0) ? ' vv-has-log' : '' ?>" onclick="vvSelectLog(this)">Log</button>
<label class="vv-log-label" title="Enable verbose --log output">
<input type="checkbox" class="vv-log-enabled"
<?= $orch['log_enabled'] ? 'checked' : '' ?>
@@ -80,7 +85,11 @@ foreach ($tree as $orch) {
<div class="vv-job-actions">
<button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">&#9654; Run</button>
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">&#9654; Dry Run</button>
<button class="vv-btn-sm" onclick="vvSelectLog(this)">Log</button>
<?php if (vv_conf_has_sections($child['id'])): ?>
<button class="vv-btn-sm vv-conf-btn" onclick="vvEditConf('<?= $cid ?>')">Config</button>
<?php endif; ?>
<?php $childLog = vv_job_log_path($child['id']); ?>
<button class="vv-btn-sm vv-log-btn<?= (file_exists($childLog) && filesize($childLog) > 0) ? ' vv-has-log' : '' ?>" onclick="vvSelectLog(this)">Log</button>
<label class="vv-log-label" title="Enable verbose --log output">
<input type="checkbox" class="vv-log-enabled"
<?= $child['log_enabled'] ? 'checked' : '' ?>
@@ -97,11 +106,61 @@ foreach ($tree as $orch) {
</div>
<?php endforeach; ?>
<!-- Custom Scripts container -->
<div class="vv-card vv-wide vv-sched-card vv-custom-card">
<div class="vv-job-row">
<span class="vv-job-label" style="font-weight:bold;">Custom Scripts</span>
<span class="vv-custom-count"><?= count($customs) ?> script<?= count($customs) !== 1 ? 's' : '' ?></span>
<button class="vv-advanced-toggle" style="margin-left:auto;width:100px" onclick="vvToggleCustom(this)">Scripts ▸</button>
</div>
<div class="vv-children" style="display:none;">
<?php if (empty($customs)): ?>
<p class="vv-custom-empty">No custom scripts yet — click <strong>+ Add Script</strong> to create one.</p>
<?php else: ?>
<?php foreach ($customs as $cs): $csid = htmlspecialchars($cs['id']); ?>
<div class="vv-script" data-id="<?= $csid ?>">
<div class="vv-job-row">
<label class="vv-toggle" title="Enable/disable">
<input type="checkbox" class="vv-enabled"
<?= $cs['enabled'] ? 'checked' : '' ?>
onchange="vvSaveJob(this)">
<span class="vv-slider"></span>
</label>
<span class="vv-job-label"><?= htmlspecialchars($cs['label']) ?></span>
<input type="text" class="vv-cron" value="<?= htmlspecialchars($cs['cron']) ?>"
placeholder="cron expression">
<span class="vv-save-check"></span>
</div>
<?php if (!empty($cs['desc'])): ?>
<div class="vv-job-desc" title="<?= htmlspecialchars($cs['desc']) ?>"><?= htmlspecialchars($cs['desc']) ?></div>
<?php endif; ?>
<div class="vv-job-actions">
<button class="vv-btn-sm vv-run-btn" onclick="vvRunJob(this)">&#9654; Run</button>
<button class="vv-btn-sm vv-dry-btn" onclick="vvDryRun(this)">&#9654; Dry Run</button>
<button class="vv-btn-sm vv-edit-btn" onclick="vvEditScript('<?= $csid ?>')">Edit</button>
<?php $csLog = vv_job_log_path($cs['id']); ?>
<button class="vv-btn-sm vv-log-btn<?= (file_exists($csLog) && filesize($csLog) > 0) ? ' vv-has-log' : '' ?>" onclick="vvSelectLog(this)">Log</button>
<label class="vv-log-label" title="Enable verbose --log output">
<input type="checkbox" class="vv-log-enabled"
<?= $cs['log_enabled'] ? 'checked' : '' ?>
onchange="vvSaveJob(this)">
<span>Verbose</span>
</label>
<span class="vv-job-dot"></span>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div><!-- /#vv-sched-cards -->
<!-- Single global save -->
<div class="vv-sched-footer">
<button class="vv-save-btn" onclick="vvSaveAll(this)">Save Schedule</button>
<button id="vv-save-schedule-btn" class="vv-save-btn" onclick="vvSaveAll(this)">Save Schedule</button>
<button class="vv-save-btn vv-add-script-btn" onclick="vvAddScript()">+ Add Script</button>
<button id="vv-delete-script-btn" class="vv-save-btn vv-delete-btn" onclick="vvDeleteScript()" style="display:none">&#128465; Delete</button>
<span id="vv-save-all-status" class="vv-save-status"></span>
</div>
@@ -112,17 +171,23 @@ foreach ($tree as $orch) {
<div class="vv-card vv-log-card">
<div class="vv-log-toolbar" style="margin-bottom:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<button id="vv-back-btn" class="vv-btn-sm" onclick="vvBackToSuggestions()" style="display:none">&#8592; Back</button>
<button id="vv-back-btn" class="vv-btn-sm" onclick="vvBackToSuggestions()" style="display:none">&#9776; Scheduler Info</button>
<button id="vv-restore-btn" class="vv-btn-sm" onclick="vvRestoreLastLog()" style="display:none">&#8592; <span id="vv-restore-label"></span></button>
<span id="vv-log-title" style="font-size:13px;font-weight:bold;color:#ccc;">Suggested Schedules</span>
<span id="vv-log-dot" style="display:none;width:8px;height:8px;border-radius:50%;background:#4caf50;flex-shrink:0;"></span>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<label id="vv-scroll-lock-label" class="vv-log-label" title="Lock auto-scroll" style="display:none">
<input type="checkbox" id="vv-scroll-lock"> <span>Scroll Lock</span>
<label id="vv-auto-scroll-label" class="vv-log-label" title="Auto-scroll to newest line" style="display:none">
<input type="checkbox" id="vv-auto-scroll" checked> <span>Auto Scroll</span>
</label>
<label id="vv-scroll-lock-label" class="vv-log-label" title="Lock viewport — cannot scroll while active" style="display:none">
<input type="checkbox" id="vv-scroll-lock" onchange="vvToggleScrollLock(this)"> <span>Scroll Lock</span>
</label>
<label id="vv-invert-log-label" class="vv-log-label" title="Show newest lines at top" style="display:none">
<input type="checkbox" id="vv-invert-log" onchange="vvFetchRight()"> <span>Invert</span>
</label>
<button id="vv-save-script-btn" class="vv-btn-sm vv-save-script-btn-style" onclick="vvSaveScript()" style="display:none">Save Script</button>
<button id="vv-save-conf-btn" class="vv-btn-sm vv-save-script-btn-style" onclick="vvSaveConf()" style="display:none">Save Config</button>
<span id="vv-log-ts" class="vv-log-ts"></span>
<button id="vv-clear-btn" class="vv-btn-sm" onclick="vvClearRightLog()" style="display:none">Clear</button>
</div>
@@ -195,6 +260,18 @@ foreach ($tree as $orch) {
<!-- Log view (shown when a script is selected) -->
<pre id="vv-log-pre" class="vv-log-pre vv-log-right-pre" style="display:none"></pre>
<!-- Config form view (script conf sections) -->
<div id="vv-confform" style="display:none; overflow-y:auto;"></div>
<!-- Editor view (custom scripts) -->
<div id="vv-editor" style="display:none">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
<span style="color:#888;font-size:12px;flex-shrink:0;">Name:</span>
<input type="text" id="vv-editor-name" class="vv-cron" style="flex:1" placeholder="script_name (no .sh)">
</div>
<textarea id="vv-editor-body" class="vv-editor-body" spellcheck="false"></textarea>
</div>
</div>
<?php
$totalJobs = 0; $scheduledJobs = 0;
@@ -230,6 +307,8 @@ let vvRunningId = null;
let vvLastTs = 0;
let vvStaleCount = 0;
let vvPollTimer = null;
let vvEditorId = null;
let vvConfId = null;
function vvPost(url, data) {
const params = new URLSearchParams({csrf_token, ...data});
@@ -258,26 +337,51 @@ function vvFitRight() {
right.style.height = Math.round(leftRect.bottom - rightRect.top) + 'px';
const contentH = Math.max(80, lf.getBoundingClientRect().top - 32 - toolbar.getBoundingClientRect().bottom);
// Apply to whichever content pane is visible
if (pre.style.display !== 'none') {
pre.style.maxHeight = 'none';
pre.style.height = contentH + 'px';
}
if (sug.style.display !== 'none') {
sug.style.height = contentH + 'px';
sug.style.height = contentH + 'px';
}
const ed = document.getElementById('vv-editor');
if (ed.style.display !== 'none') {
document.getElementById('vv-editor-body').style.height = Math.max(60, contentH - 38) + 'px';
}
const cf = document.getElementById('vv-confform');
if (cf.style.display !== 'none') {
cf.style.height = contentH + 'px';
}
}
window.addEventListener('resize', vvFitRight);
function vvShowLogMode(id) {
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = '';
document.getElementById('vv-back-btn').style.display = '';
document.getElementById('vv-clear-btn').style.display = '';
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-editor').style.display = 'none';
document.getElementById('vv-log-pre').style.display = '';
document.getElementById('vv-back-btn').style.display = '';
document.getElementById('vv-clear-btn').style.display = '';
document.getElementById('vv-save-script-btn').style.display = 'none';
document.getElementById('vv-save-conf-btn').style.display = 'none';
document.getElementById('vv-delete-script-btn').style.display = 'none';
document.getElementById('vv-confform').style.display = 'none';
document.getElementById('vv-auto-scroll-label').style.display = '';
document.getElementById('vv-scroll-lock-label').style.display = '';
document.getElementById('vv-invert-log-label').style.display = '';
document.getElementById('vv-save-schedule-btn').disabled = false;
document.getElementById('vv-save-schedule-btn').style.opacity = '';
const name = id.replace(/\.sh$/, '').split('/').pop();
document.getElementById('vv-log-title').textContent = name;
document.getElementById('vv-restore-btn').style.display = 'none';
}
function vvRestoreLastLog() {
const last = localStorage.getItem('vv-last-job');
if (last && document.querySelector('[data-id="' + CSS.escape(last) + '"]')) vvOpenRight(last);
}
function vvToggleScrollLock(cb) {
document.getElementById('vv-log-pre').style.overflowY = cb.checked ? 'hidden' : '';
}
function vvBackToSuggestions() {
@@ -289,12 +393,30 @@ function vvBackToSuggestions() {
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
vvSetRunning(null);
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-suggestions').style.display = '';
document.getElementById('vv-back-btn').style.display = 'none';
document.getElementById('vv-clear-btn').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-editor').style.display = 'none';
document.getElementById('vv-suggestions').style.display = '';
document.getElementById('vv-back-btn').style.display = 'none';
document.getElementById('vv-clear-btn').style.display = 'none';
document.getElementById('vv-save-script-btn').style.display = 'none';
document.getElementById('vv-save-conf-btn').style.display = 'none';
document.getElementById('vv-delete-script-btn').style.display = 'none';
document.getElementById('vv-confform').style.display = 'none';
vvConfId = null;
document.getElementById('vv-save-schedule-btn').disabled = false;
document.getElementById('vv-save-schedule-btn').style.opacity = '';
const lastJob = localStorage.getItem('vv-last-job');
if (lastJob) {
const lname = lastJob.replace(/\.sh$/, '').split('/').pop();
document.getElementById('vv-restore-label').textContent = lname;
document.getElementById('vv-restore-btn').style.display = '';
}
document.getElementById('vv-auto-scroll-label').style.display = 'none';
document.getElementById('vv-scroll-lock-label').style.display = 'none';
document.getElementById('vv-invert-log-label').style.display = 'none';
document.getElementById('vv-auto-scroll').checked = true;
document.getElementById('vv-scroll-lock').checked = false;
document.getElementById('vv-log-pre').style.overflowY = '';
document.getElementById('vv-log-title').textContent = 'Suggested Schedules';
document.getElementById('vv-log-ts').textContent = '';
document.getElementById('vv-log-dot').style.display = 'none';
@@ -307,6 +429,7 @@ function vvOpenRight(id) {
if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected');
}
vvActiveId = id;
localStorage.setItem('vv-last-job', id);
const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
if (job) job.querySelector('.vv-job-row').classList.add('vv-row-selected');
@@ -324,12 +447,21 @@ function vvFetchRight() {
.then(d => {
const pre = document.getElementById('vv-log-pre');
const ts = document.getElementById('vv-log-ts');
const scrollLock = document.getElementById('vv-scroll-lock')?.checked;
const invert = document.getElementById('vv-invert-log')?.checked;
const autoScroll = document.getElementById('vv-auto-scroll').checked;
const scrollLock = document.getElementById('vv-scroll-lock').checked;
const invert = document.getElementById('vv-invert-log').checked;
const savedScroll = pre.scrollTop;
if (!d.ok) { pre.textContent = '✗ ' + (d.error ?? 'Error'); return; }
const content = d.content || '(no log yet)';
pre.textContent = invert ? content.split('\n').reverse().join('\n') : content;
if (!scrollLock) pre.scrollTop = invert ? 0 : pre.scrollHeight;
const content = d.content || '';
vvSetLogBtnState(vvActiveId, content.trim().length > 0);
pre.textContent = content.trim() ? (invert ? content.split('\n').reverse().join('\n') : content) : '(no log yet)';
if (scrollLock) {
requestAnimationFrame(() => { pre.scrollTop = savedScroll; });
} else if (autoScroll) {
pre.scrollTop = invert ? 0 : pre.scrollHeight;
} else {
requestAnimationFrame(() => { pre.scrollTop = savedScroll; });
}
ts.textContent = d.ts ? 'Last run: ' + new Date(d.ts * 1000).toLocaleString() : '';
// Running indicator: clear when log stops changing
@@ -450,13 +582,211 @@ function vvToggleAdvanced(btn) {
btn.textContent = visible ? 'Advanced ▸' : 'Advanced ▾';
}
function vvToggleCustom(btn) {
const card = btn.closest('.vv-custom-card');
const children = card.querySelector('.vv-children');
const visible = children.style.display !== 'none';
children.style.display = visible ? 'none' : 'block';
btn.textContent = visible ? 'Scripts ▸' : 'Scripts ▾';
}
function vvSetLogBtnState(id, hasLog) {
const job = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
if (!job) return;
job.querySelector('.vv-log-btn').classList.toggle('vv-has-log', hasLog);
}
function vvClearRightLog() {
if (!vvActiveId) return;
vvPost('/plugins/varaverk/api/log.php', {id: vvActiveId, clear: '1'})
.then(() => vvFetchRight())
.then(() => { vvSetLogBtnState(vvActiveId, false); vvFetchRight(); })
.catch(() => {});
}
function vvAddScript() {
vvEditorId = null;
const nameEl = document.getElementById('vv-editor-name');
nameEl.value = '';
nameEl.readOnly = false;
document.getElementById('vv-editor-body').value = '#!/bin/bash\n\n';
vvShowEditorMode('New Script');
nameEl.focus();
}
function vvEditScript(id) {
if (vvActiveId) {
const old = document.querySelector('[data-id="' + CSS.escape(vvActiveId) + '"]');
if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected');
}
vvActiveId = null;
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
vvEditorId = id;
const name = id.replace(/^Custom\//, '').replace(/\.sh$/, '');
const nameEl = document.getElementById('vv-editor-name');
nameEl.value = name;
nameEl.readOnly = true;
document.getElementById('vv-editor-body').value = 'Loading…';
fetch('/plugins/varaverk/api/script.php?id=' + encodeURIComponent(id))
.then(r => r.json())
.then(d => { document.getElementById('vv-editor-body').value = d.ok ? d.content : '# Error loading script'; })
.catch(() => { document.getElementById('vv-editor-body').value = '# Error loading script'; });
vvShowEditorMode(name);
}
function vvShowEditorMode(title) {
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-editor').style.display = 'flex';
document.getElementById('vv-back-btn').style.display = '';
document.getElementById('vv-restore-btn').style.display = 'none';
document.getElementById('vv-save-script-btn').style.display = '';
document.getElementById('vv-clear-btn').style.display = 'none';
document.getElementById('vv-auto-scroll-label').style.display = 'none';
document.getElementById('vv-scroll-lock-label').style.display = 'none';
document.getElementById('vv-invert-log-label').style.display = 'none';
document.getElementById('vv-log-title').textContent = title;
document.getElementById('vv-log-ts').textContent = '';
document.getElementById('vv-confform').style.display = 'none';
document.getElementById('vv-save-conf-btn').style.display = 'none';
document.getElementById('vv-save-schedule-btn').disabled = true;
document.getElementById('vv-save-schedule-btn').style.opacity = '0.4';
document.getElementById('vv-delete-script-btn').style.display = vvEditorId ? '' : 'none';
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
requestAnimationFrame(vvFitRight);
}
function vvSaveScript() {
const name = document.getElementById('vv-editor-name').value.trim();
const content = document.getElementById('vv-editor-body').value;
if (!name || !/^[a-zA-Z0-9_\-]+$/.test(name)) {
alert('Name must be letters, numbers, _ or - only (no spaces, no .sh)');
return;
}
const btn = document.getElementById('vv-save-script-btn');
btn.disabled = true;
btn.textContent = 'Saving…';
vvPost('/plugins/varaverk/api/script.php', {name, content})
.then(d => {
if (!d.ok) { alert('Save failed: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = 'Save Script'; return; }
localStorage.setItem('vv-last-job', d.id);
window.location.reload();
})
.catch(() => { btn.disabled = false; btn.textContent = 'Save Script'; });
}
function vvDeleteScript() {
if (!vvEditorId) return;
const name = vvEditorId.replace(/^Custom\//, '').replace(/\.sh$/, '');
if (!confirm('Delete "' + name + '.sh"? This cannot be undone.')) return;
const btn = document.getElementById('vv-delete-script-btn');
btn.disabled = true;
btn.textContent = 'Deleting…';
vvPost('/plugins/varaverk/api/script.php', {action: 'delete', name})
.then(d => {
if (!d.ok) { alert('Delete failed: ' + (d.error ?? 'Unknown error')); btn.disabled = false; btn.textContent = '\u{1F5D1} Delete'; return; }
localStorage.removeItem('vv-last-job');
window.location.reload();
})
.catch(() => { btn.disabled = false; btn.textContent = '\u{1F5D1} Delete'; });
}
function vvShowConfMode(title) {
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-editor').style.display = 'none';
document.getElementById('vv-confform').style.display = '';
document.getElementById('vv-back-btn').style.display = '';
document.getElementById('vv-restore-btn').style.display = 'none';
document.getElementById('vv-save-script-btn').style.display = 'none';
document.getElementById('vv-save-conf-btn').style.display = '';
document.getElementById('vv-delete-script-btn').style.display = 'none';
document.getElementById('vv-clear-btn').style.display = 'none';
document.getElementById('vv-auto-scroll-label').style.display = 'none';
document.getElementById('vv-scroll-lock-label').style.display = 'none';
document.getElementById('vv-invert-log-label').style.display = 'none';
document.getElementById('vv-log-title').textContent = title;
document.getElementById('vv-log-ts').textContent = '';
document.getElementById('vv-save-schedule-btn').disabled = true;
document.getElementById('vv-save-schedule-btn').style.opacity = '0.4';
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
requestAnimationFrame(vvFitRight);
}
function vvEditConf(id) {
if (vvActiveId) {
const old = document.querySelector('[data-id="' + CSS.escape(vvActiveId) + '"]');
if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected');
}
vvActiveId = null;
if (vvPollTimer) { clearInterval(vvPollTimer); vvPollTimer = null; }
vvConfId = id;
const name = id.replace(/\.sh$/, '').split('/').pop();
const cf = document.getElementById('vv-confform');
cf.innerHTML = '<p class="vv-cf-empty">Loading…</p>';
vvShowConfMode(name + ' — Config');
fetch('/plugins/varaverk/api/confform.php?id=' + encodeURIComponent(id))
.then(r => r.json())
.then(d => {
cf.innerHTML = (!d.ok || !d.groups || d.groups.length === 0)
? '<p class="vv-cf-empty">No configurable settings found for this host.</p>'
: vvRenderConfForm(d.groups);
requestAnimationFrame(vvFitRight);
})
.catch(() => { cf.innerHTML = '<p class="vv-cf-empty">Failed to load configuration.</p>'; });
}
function vvRenderConfForm(groups) {
const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
let html = '';
for (const g of groups) {
html += '<div class="vv-cf-group">';
html += '<div class="vv-cf-group-header">' + esc(g.subsection)
+ ' <span class="vv-cf-file">' + esc(g.file) + '</span></div>';
for (const f of g.fields) {
html += '<div class="vv-cf-field">';
html += '<div class="vv-cf-key">' + esc(f.key) + '</div>';
if (f.desc) html += '<div class="vv-cf-desc">' + esc(f.desc) + '</div>';
if (f.type === 'scalar') {
html += '<input class="vv-cf-input vv-cf-scalar" type="text"'
+ ' data-key="' + esc(f.key) + '" data-file="' + esc(f.file) + '" data-type="scalar"'
+ ' value="' + esc(f.value) + '">';
} else {
const rows = Math.min(20, (f.value.match(/\n/g) || []).length + 3);
html += '<textarea class="vv-cf-input vv-cf-array"'
+ ' data-key="' + esc(f.key) + '" data-file="' + esc(f.file) + '" data-type="' + esc(f.type) + '"'
+ ' rows="' + rows + '">' + esc(f.value) + '</textarea>';
}
html += '</div>';
}
html += '</div>';
}
return html;
}
function vvSaveConf() {
if (!vvConfId) return;
const inputs = document.querySelectorAll('#vv-confform .vv-cf-input');
const changes = [];
inputs.forEach(el => changes.push({key: el.dataset.key, file: el.dataset.file, type: el.dataset.type, value: el.value}));
const btn = document.getElementById('vv-save-conf-btn');
btn.disabled = true; btn.textContent = 'Saving…';
vvPost('/plugins/varaverk/api/confform.php', {id: vvConfId, changes: JSON.stringify(changes)})
.then(d => {
btn.disabled = false;
if (d.ok) {
btn.textContent = '✓ Saved';
setTimeout(() => { btn.textContent = 'Save Config'; }, 2500);
} else {
btn.textContent = 'Save Config';
alert('Save failed: ' + (d.error ?? 'Unknown error'));
}
})
.catch(() => { btn.disabled = false; btn.textContent = 'Save Config'; });
}
function vvToggleSugBlock(bi) {
const body = document.getElementById('vv-sug-body-' + bi);
const chevron = body.closest('.vv-sug-block').querySelector('.vv-sug-chevron');
@@ -466,4 +796,18 @@ function vvToggleSugBlock(bi) {
}
</script>
<script>requestAnimationFrame(vvFitRight);</script>
<script>
requestAnimationFrame(function() {
const last = localStorage.getItem('vv-last-job');
if (last && document.querySelector('[data-id="' + CSS.escape(last) + '"]')) {
vvOpenRight(last);
} else {
if (last) {
const lname = last.replace(/\.sh$/, '').split('/').pop();
document.getElementById('vv-restore-label').textContent = lname;
document.getElementById('vv-restore-btn').style.display = '';
}
vvFitRight();
}
});
</script>